diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index aebcc08..84acfb4 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -138,6 +138,26 @@ export const INTERNAL_URL = export const INTERNAL_TOKEN = process.env.TANGENT_INTERNAL_TOKEN ?? randomUUID(); +/** + * Public base URL at which this server is reachable by an external MCP client + * (the gateway dials in over this). The server cannot self-discover it, so it + * is supplied per environment: a tunnel URL in local dev, the reverse-proxy URL + * in a real instance. Empty by default, which disables issuing relay channels + * until set. No trailing slash. + */ +export const PUBLIC_URL = (process.env.TANGENT_PUBLIC_URL ?? "").replace( + /\/+$/, + "", +); + +/** + * Shared secret a remote environment must present (in the Socket.IO handshake + * `auth`) to connect to the remote sub-agent gateway. Empty by default, which + * disables remote sub-agent hosting until an environment supplies a token so a + * stray connection can never drive a session's agents. + */ +export const REMOTE_ENV_TOKEN = process.env.REMOTE_ENV_TOKEN ?? ""; + /** * Name of the cookie holding the Oktasso JWT that `GET /api/me` reads to resolve * the current user. Empty by default so the route is effectively disabled until diff --git a/apps/server/src/external/externalSubagentGateway.test.ts b/apps/server/src/external/externalSubagentGateway.test.ts new file mode 100644 index 0000000..b1f922c --- /dev/null +++ b/apps/server/src/external/externalSubagentGateway.test.ts @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import type { SubagentInfo } from "@tangent/shared/contracts.ts"; + +import type { PiAgentHandlers } from "../pi/types.ts"; +import { ExternalSubagentGateway } from "./externalSubagentGateway.ts"; + +/** Captures every handler call so tests can assert on them. */ +function makeHarness() { + const rosterUpdates: SubagentInfo[] = []; + const events: Array<{ agentId: string; type: string }> = []; + + const handlers: PiAgentHandlers = { + onAgentEvent: (_sessionId, agent, event) => + events.push({ agentId: agent.agentId, type: event.type }), + onSubagentUpdate: (_sessionId, info) => rosterUpdates.push(info), + onAgentMessage: () => {}, + onSessionStatus: () => {}, + }; + + const gateway = new ExternalSubagentGateway(handlers); + return { gateway, rosterUpdates, events }; +} + +test("register records a roster entry and surfaces it as active", () => { + const h = makeHarness(); + const { id } = h.gateway.register("s1", { name: "worker" }); + + assert.ok(id); + assert.equal(h.gateway.hasAgent("s1", id), true); + assert.deepEqual( + h.gateway.listSubagents("s1").map((s) => s.id), + [id], + ); + const info = h.rosterUpdates.at(-1); + assert.equal(info?.host, "external"); + assert.equal(info?.status, "active"); +}); + +test("pushEvent relays a streamed event into the tab", () => { + const h = makeHarness(); + const { id } = h.gateway.register("s1", { name: "worker" }); + + h.gateway.pushEvent("s1", id, { type: "start", messageId: "m1" }); + h.gateway.pushEvent("s1", id, { + type: "end", + messageId: "m1", + content: "done", + thinking: "", + }); + + assert.deepEqual( + h.events.map((e) => e.type), + ["start", "end"], + ); + assert.ok(h.events.every((e) => e.agentId === id)); +}); + +test("pushEvent is a no-op for an unknown agent", () => { + const h = makeHarness(); + h.gateway.pushEvent("s1", "nope", { type: "start", messageId: "m1" }); + assert.equal(h.events.length, 0); +}); + +test("setStatus to a terminal state removes the entry and updates the roster", () => { + const h = makeHarness(); + const { id } = h.gateway.register("s1", { name: "worker" }); + + h.gateway.setStatus("s1", id, "completed"); + + assert.equal(h.gateway.hasAgent("s1", id), false); + assert.deepEqual(h.gateway.listSubagents("s1"), []); + assert.equal(h.rosterUpdates.at(-1)?.status, "completed"); +}); + +test("setStatus to active keeps the entry in the roster", () => { + const h = makeHarness(); + const { id } = h.gateway.register("s1", { name: "worker" }); + + h.gateway.setStatus("s1", id, "active"); + + assert.equal(h.gateway.hasAgent("s1", id), true); + assert.equal(h.rosterUpdates.at(-1)?.status, "active"); +}); + +test("listSubagents is scoped per session", () => { + const h = makeHarness(); + const a = h.gateway.register("s1", { name: "one" }); + h.gateway.register("s2", { name: "two" }); + + assert.deepEqual( + h.gateway.listSubagents("s1").map((s) => s.id), + [a.id], + ); + assert.equal(h.gateway.listSubagents("s2").length, 1); +}); diff --git a/apps/server/src/external/externalSubagentGateway.ts b/apps/server/src/external/externalSubagentGateway.ts new file mode 100644 index 0000000..39d1bde --- /dev/null +++ b/apps/server/src/external/externalSubagentGateway.ts @@ -0,0 +1,136 @@ +import { randomUUID } from "node:crypto"; + +import type { + SubagentInfo, + SubagentStatus, + ThinkingLevel, +} from "@tangent/shared/contracts.ts"; +import type { RemoteAgentEvent } from "@tangent/shared/remoteSubagent.ts"; + +import type { AgentDescriptor, PiAgentHandlers } from "../pi/types.ts"; + +/** Display metadata a caller supplies when registering an external sub-agent. */ +export interface RegisterExternalSubagent { + name: string; + template?: string; + model?: string; + thinkingDepth?: ThinkingLevel; +} + +/** An external sub-agent tab, tracked in the gateway roster (display only). */ +interface ExternalSubagent { + agentId: string; + name: string; + status: SubagentStatus; + template?: string; + model?: string; + thinkingDepth?: ThinkingLevel; + createdAt: string; +} + +/** Projects a roster entry onto the wire {@link SubagentInfo}. */ +function toInfo(subagent: ExternalSubagent): SubagentInfo { + return { + id: subagent.agentId, + name: subagent.name, + status: subagent.status, + host: "external", + template: subagent.template, + model: subagent.model, + thinkingDepth: subagent.thinkingDepth, + createdAt: subagent.createdAt, + }; +} + +/** + * In-memory registry of **external sub-agent** tabs. An external sub-agent is + * one whose work runs outside Tangent (e.g. driven by a bundle tool over the + * `/internal/external-agents` API); the gateway only owns the sidebar tab and + * relays streamed events into it via the shared {@link PiAgentHandlers}, so an + * external sub-agent renders and persists like a local one. + * + * The gateway is transport-agnostic and carries no knowledge of what runtime + * backs a tab — a caller `register`s a tab, `pushEvent`s streamed output into + * it, and `setStatus` marks its lifecycle. Reserved for the `external` host + * alongside {@link import("../remote/remoteEnvironmentGateway.ts").RemoteEnvironmentGateway} + * and {@link import("../pi/piAgentManager.ts").PiAgentManager}. + */ +export class ExternalSubagentGateway { + private readonly handlers: PiAgentHandlers; + + /** Per-session external sub-agent rosters, keyed by sessionId then agentId. */ + private readonly sessions = new Map>(); + + constructor(handlers: PiAgentHandlers) { + this.handlers = handlers; + } + + /** True when `agentId` is an external sub-agent of `sessionId`. */ + hasAgent(sessionId: string, agentId: string): boolean { + return Boolean(this.sessions.get(sessionId)?.has(agentId)); + } + + /** The session's external sub-agent roster (Prime/local agents excluded). */ + listSubagents(sessionId: string): SubagentInfo[] { + const roster = this.sessions.get(sessionId); + if (!roster) return []; + return [...roster.values()].map(toInfo); + } + + /** + * Registers a new external sub-agent tab, assigns it a UUID, records the + * roster entry, and surfaces it to the session's chat layer. Returns the + * assigned id the caller uses on subsequent `pushEvent`/`setStatus` calls. + */ + register(sessionId: string, spec: RegisterExternalSubagent): { id: string } { + const agentId = randomUUID(); + const subagent: ExternalSubagent = { + agentId, + name: spec.name, + status: "active", + template: spec.template, + model: spec.model, + thinkingDepth: spec.thinkingDepth, + createdAt: new Date().toISOString(), + }; + this.rosterFor(sessionId).set(agentId, subagent); + this.handlers.onSubagentUpdate(sessionId, toInfo(subagent)); + return { id: agentId }; + } + + /** Relays a streamed event into the sub-agent's tab. No-op for an unknown id. */ + pushEvent(sessionId: string, agentId: string, event: RemoteAgentEvent): void { + const subagent = this.sessions.get(sessionId)?.get(agentId); + if (!subagent) return; + this.handlers.onAgentEvent(sessionId, this.descriptorFor(subagent), event); + } + + /** + * Applies a lifecycle status change to a sub-agent tab. Terminal statuses + * (anything other than `active`) drop the roster entry. No-op for an unknown + * id. + */ + setStatus(sessionId: string, agentId: string, status: SubagentStatus): void { + const roster = this.sessions.get(sessionId); + const subagent = roster?.get(agentId); + if (!roster || !subagent) return; + + subagent.status = status; + if (status !== "active") roster.delete(agentId); + this.handlers.onSubagentUpdate(sessionId, toInfo(subagent)); + } + + /** Returns (creating if needed) the session's external sub-agent roster. */ + private rosterFor(sessionId: string): Map { + const existing = this.sessions.get(sessionId); + if (existing) return existing; + const created = new Map(); + this.sessions.set(sessionId, created); + return created; + } + + /** Builds the agent descriptor a relayed event is tagged with. */ + private descriptorFor(subagent: ExternalSubagent): AgentDescriptor { + return { agentId: subagent.agentId, role: "subagent", name: subagent.name }; + } +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 821d556..3b6312a 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -6,18 +6,28 @@ import express from "express"; import { Server as SocketIOServer } from "socket.io"; import { PORT } from "./config.ts"; +import { ExternalSubagentGateway } from "./external/externalSubagentGateway.ts"; +import { RelayRegistry } from "./mcp/relayRegistry.ts"; import { errorHandler } from "./middleware/errorHandler.ts"; import { MemoryManager } from "./pi/memory.ts"; -import { PiAgentManager } from "./pi/piAgentManager.ts"; +import { + type PiAgentHandlers, + PiAgentManager, + PRIME_AGENT_ID, +} from "./pi/piAgentManager.ts"; import { TriggerEngine } from "./pi/triggers/triggerEngine.ts"; import { TriggerManager } from "./pi/triggers/triggerManager.ts"; +import { RemoteEnvironmentGateway } from "./remote/remoteEnvironmentGateway.ts"; import { createAgentBundlesRouter } from "./routes/agentBundles.ts"; import { createGlobalMemoryRouter } from "./routes/globalMemory.ts"; import { createInternalAgentsRouter } from "./routes/internalAgents.ts"; import { createInternalEgressRouter } from "./routes/internalEgress.ts"; +import { createInternalExternalAgentsRouter } from "./routes/internalExternalAgents.ts"; +import { createInternalMcpRelayRouter } from "./routes/internalMcpRelay.ts"; import { createInternalMemoryRouter } from "./routes/internalMemory.ts"; import { createInternalSessionRouter } from "./routes/internalSession.ts"; import { createInternalTriggersRouter } from "./routes/internalTriggers.ts"; +import { createMcpRelayRouter } from "./routes/mcp.ts"; import { createMeRouter } from "./routes/me.ts"; import { createSessionsRouter } from "./routes/sessions/index.ts"; import { @@ -63,19 +73,46 @@ const onMemorySuggestion = createMemorySuggestionHandler(io); // Pushes generic agent->UI directives (e.g. session rename) to the room. const emitUiCommand = createUiCommandEmitter(io); +// Shared relay handlers: a sub-agent's streaming events and roster changes are +// fanned to the matching Socket.IO room and persisted the same way, whether the +// sub-agent runs locally (PiAgentManager) or in a remote environment. +const agentHandlers: PiAgentHandlers = { + onAgentEvent: createAgentEventHandler(io, store), + onSubagentUpdate: createSubagentUpdateHandler(io, store), + onAgentMessage: createAgentMessageHandler(io, store), + onSessionStatus: createSessionStatusHandler(io), +}; + // The manager runs a roster of Pi processes per session (Prime + sub-agents); // their streaming events and roster changes are relayed to the matching // Socket.IO room by the chat handlers. -const pi = new PiAgentManager( - { - onAgentEvent: createAgentEventHandler(io, store), - onSubagentUpdate: createSubagentUpdateHandler(io, store), - onAgentMessage: createAgentMessageHandler(io, store), - onSessionStatus: createSessionStatusHandler(io), - }, - memory, +const pi = new PiAgentManager(agentHandlers, memory); + +// Relays a message into a session's Prime process. Shared by the remote-env +// gateway and the generic MCP relay so both feed Prime the same way. +const deliverToPrime = (sessionId: string, text: string): void => + pi.sendToAgent(sessionId, PRIME_AGENT_ID, text); + +// Hosts sub-agents inside a connected remote environment over the `/remote-env` +// namespace. Remote sub-agents share the same relay handlers as local ones, and +// their finalized replies/reports are fed into the session's Prime process. +const remoteGateway = new RemoteEnvironmentGateway( + io, + agentHandlers, + store, + deliverToPrime, ); +// Registry of external sub-agent tabs: work runs outside Tangent (e.g. driven +// by a bundle tool over the internal external-agents API) and streams into a +// tab via the same relay handlers a local sub-agent uses. +const externalGateway = new ExternalSubagentGateway(agentHandlers); + +// Generic MCP relay: bridges an external MCP client (dialed by a gateway) to a +// session's Prime. Bundles open channels over the internal API; the peer's tool +// calls arrive on the public /api/mcp route and are relayed to Prime. +const mcpRelay = new RelayRegistry(); + // Drives schedule timers and callback firings, delivering prompts to Prime. const triggerEngine = new TriggerEngine(io, store, pi, triggers); @@ -101,10 +138,21 @@ app.use( ); app.use("/api/agent-bundles", createAgentBundlesRouter(agentBundleStore)); app.use("/api/global-memory", createGlobalMemoryRouter(memory)); +// Public MCP relay dialed by an external client; per-channel bearer in the URL. +app.use("/api/mcp", createMcpRelayRouter(mcpRelay, deliverToPrime)); // Returns the current user, derived from the Oktasso JWT cookie. app.use("/api/me", createMeRouter()); // Internal API for the orchestrator extension running inside each Pi process. -app.use("/internal/agents", createInternalAgentsRouter(store, pi)); +app.use( + "/internal/agents", + createInternalAgentsRouter(store, pi, remoteGateway, externalGateway), +); +// Internal API a bundle tool uses to drive external sub-agent tabs: register a +// tab, stream the external runtime's output into it, and mark its lifecycle. +app.use( + "/internal/external-agents", + createInternalExternalAgentsRouter(externalGateway), +); // Internal egress proxy for bundle tool extensions (e.g. the Tangle API tool). app.use("/internal/egress", createInternalEgressRouter()); // Internal API for the triggers extension running inside each Pi process. @@ -124,6 +172,9 @@ app.use( ); // Internal API for the session extension running inside each Pi process. app.use("/internal/session", createInternalSessionRouter(store, emitUiCommand)); +// Internal API for bundle extensions to open/answer/close generic MCP relay +// channels bound to their session (remote-runtime specifics stay in the bundle). +app.use("/internal/mcp-relay", createInternalMcpRelayRouter(mcpRelay, store)); // Mounted last: async failures from any handler above land here with a // consistent `{ error }` shape (Express 5 forwards rejected promises to it). @@ -133,6 +184,8 @@ registerChatHandlers( io, store, pi, + remoteGateway, + externalGateway, memory, onMemoryRemembered, triggerEngine, diff --git a/apps/server/src/mcp/mcpRelayServer.ts b/apps/server/src/mcp/mcpRelayServer.ts new file mode 100644 index 0000000..d83f7bb --- /dev/null +++ b/apps/server/src/mcp/mcpRelayServer.ts @@ -0,0 +1,202 @@ +import { randomUUID } from "node:crypto"; + +import type { RelayChannel, RelayRegistry } from "./relayRegistry.ts"; + +/** + * Generic MCP JSON-RPC handler for a single relay channel. It speaks the subset + * of the Model Context Protocol an external client exercises when the gateway + * dials in — `initialize`, `tools/list`, `tools/call` — and exposes two generic + * tools that forward to the channel's session Prime. It has no knowledge of the + * remote runtime on the other end (that lives entirely in the bundle that + * opened the channel). + */ + +/** Callback that relays a message to a session's Prime agent. */ +export type DeliverToPrime = (sessionId: string, text: string) => void; + +interface JsonRpcRequest { + jsonrpc?: string; + id?: string | number | null; + method?: string; + params?: Record; +} + +interface JsonRpcResponse { + jsonrpc: "2.0"; + id: string | number | null; + result?: unknown; + error?: { code: number; message: string }; +} + +/** Max time `ask_prime` blocks for an answer, safely under MCP's 30s cap. */ +const ASK_TIMEOUT_MS = 25_000; +const ASK_POLL_MS = 300; + +const PROTOCOL_VERSION = "2024-11-05"; + +const TOOLS = [ + { + name: "send_to_prime", + description: + "Send a message back to Prime, the coordinator that started you. " + + "Fire-and-forget: use it to report findings, progress, or blockers.", + inputSchema: { + type: "object", + properties: { + text: { type: "string", description: "The message for Prime." }, + }, + required: ["text"], + }, + }, + { + name: "ask_prime", + description: + "Ask Prime a question and wait briefly for an answer. Blocks up to ~25s; " + + "if Prime does not answer in time you receive a fallback and should use " + + "your best judgment. Prefer send_to_prime when you do not need a reply.", + inputSchema: { + type: "object", + properties: { + question: { type: "string", description: "The question for Prime." }, + }, + required: ["question"], + }, + }, +]; + +interface Ctx { + registry: RelayRegistry; + channel: RelayChannel; + message: JsonRpcRequest; + deliverToPrime: DeliverToPrime; + id: string | number; +} + +type MethodHandler = (ctx: Ctx) => JsonRpcResponse | Promise; + +const HANDLERS: Record = { + initialize: handleInitialize, + "tools/list": handleToolsList, + "tools/call": handleToolsCall, +}; + +/** + * Dispatches one JSON-RPC message for `channel`. Returns the response object, + * or `null` for notifications (which take no reply body). Unknown methods + * return a JSON-RPC method-not-found error. + */ +export async function dispatchMcp( + registry: RelayRegistry, + channel: RelayChannel, + message: JsonRpcRequest, + deliverToPrime: DeliverToPrime, +): Promise { + const method = String(message.method ?? ""); + const id = message.id; + + // Notifications (e.g. notifications/initialized) carry no id and get no reply. + if (id === undefined || id === null) return null; + + const handler = HANDLERS[method]; + if (!handler) { + console.error( + `[mcp-relay] ${channel.channelId} method not found: ${method} ` + + `(raw: ${JSON.stringify(message).slice(0, 300)})`, + ); + return { + jsonrpc: "2.0", + id, + error: { code: -32601, message: `method not found: ${method}` }, + }; + } + return handler({ registry, channel, message, deliverToPrime, id }); +} + +function handleInitialize({ message, id }: Ctx): JsonRpcResponse { + const requested = (message.params as { protocolVersion?: string } | undefined) + ?.protocolVersion; + return ok(id, { + protocolVersion: requested ?? PROTOCOL_VERSION, + capabilities: { tools: {} }, + serverInfo: { name: "tangent-prime-relay", version: "0.1.0" }, + }); +} + +function handleToolsList({ channel, id }: Ctx): JsonRpcResponse { + console.error( + `[mcp-relay] ${channel.channelId} tools/list -> ` + + `${TOOLS.map((t) => t.name).join(", ")}`, + ); + return ok(id, { tools: TOOLS }); +} + +async function handleToolsCall(ctx: Ctx): Promise { + const params = (ctx.message.params ?? {}) as { + name?: string; + arguments?: Record; + }; + const text = await callTool( + ctx, + String(params.name ?? ""), + params.arguments ?? {}, + ); + return ok(ctx.id, { content: [{ type: "text", text }] }); +} + +function callTool( + ctx: Ctx, + name: string, + args: Record, +): Promise | string { + console.error(`[mcp-relay] ${ctx.channel.channelId} tools/call name=${name}`); + if (name === "send_to_prime") return sendToPrimeTool(ctx, args); + if (name === "ask_prime") return askPrimeTool(ctx, args); + return `Unknown tool: ${name}`; +} + +function sendToPrimeTool( + { channel, deliverToPrime }: Ctx, + args: Record, +): string { + const text = String(args.text ?? "").trim(); + if (!text) return "Nothing to send (empty text)."; + deliverToPrime( + channel.sessionId, + `Remote agent (${channel.label}) reports:\n\n${text}`, + ); + return "Delivered to Prime."; +} + +async function askPrimeTool( + { registry, channel, deliverToPrime }: Ctx, + args: Record, +): Promise { + const question = String(args.question ?? "").trim(); + if (!question) return "Empty question; nothing to ask."; + const requestId = randomUUID().replace(/-/g, "").slice(0, 12); + registry.addQuestion(channel.channelId, requestId, question); + deliverToPrime( + channel.sessionId, + `Remote agent (${channel.label}) asks (request_id "${requestId}"):\n\n` + + `${question}\n\n` + + `Reply by supplying an answer for request_id "${requestId}".`, + ); + const deadline = Date.now() + ASK_TIMEOUT_MS; + while (Date.now() < deadline) { + const answer = registry.takeAnswer(channel.channelId, requestId); + if (answer !== undefined) return answer; + await sleep(ASK_POLL_MS); + } + return ( + "Prime did not answer in time. Proceed using your best judgment and " + + "report what you decided with send_to_prime." + ); +} + +function ok(id: string | number, result: unknown): JsonRpcResponse { + return { jsonrpc: "2.0", id, result }; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/apps/server/src/mcp/relayRegistry.test.ts b/apps/server/src/mcp/relayRegistry.test.ts new file mode 100644 index 0000000..8e7ff1c --- /dev/null +++ b/apps/server/src/mcp/relayRegistry.test.ts @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { dispatchMcp } from "./mcpRelayServer.ts"; +import { RelayRegistry } from "./relayRegistry.ts"; + +test("open issues a distinct channel id and secret bound to the session", () => { + const registry = new RelayRegistry(); + const a = registry.open({ sessionId: "s1", label: "explorer" }); + const b = registry.open({ sessionId: "s1" }); + + assert.notEqual(a.channelId, b.channelId); + assert.notEqual(a.secret, b.secret); + assert.equal(registry.get(a.channelId)?.sessionId, "s1"); + assert.equal(registry.get(a.channelId)?.label, "explorer"); + assert.equal(registry.get(b.channelId)?.label, "remote agent"); +}); + +test("answer resolves a pending question and takeAnswer consumes it once", () => { + const registry = new RelayRegistry(); + const { channelId } = registry.open({ sessionId: "s1" }); + registry.addQuestion(channelId, "r1", "which zone?"); + + assert.deepEqual(registry.pending(channelId), [ + { request_id: "r1", question: "which zone?" }, + ]); + + assert.equal(registry.answer(channelId, "r1", "zone-42"), true); + assert.equal(registry.takeAnswer(channelId, "r1"), "zone-42"); + assert.equal(registry.takeAnswer(channelId, "r1"), undefined); + assert.deepEqual(registry.pending(channelId), []); +}); + +test("close removes the channel", () => { + const registry = new RelayRegistry(); + const { channelId } = registry.open({ sessionId: "s1" }); + assert.equal(registry.close(channelId), true); + assert.equal(registry.get(channelId), undefined); + assert.equal(registry.close(channelId), false); +}); + +test("tools/list advertises the two relay tools", async () => { + const registry = new RelayRegistry(); + const channel = registry.get(registry.open({ sessionId: "s1" }).channelId)!; + const res = await dispatchMcp( + registry, + channel, + { jsonrpc: "2.0", id: 1, method: "tools/list" }, + () => {}, + ); + + const tools = (res?.result as { tools: { name: string }[] }).tools; + assert.deepEqual(tools.map((t) => t.name).sort(), [ + "ask_prime", + "send_to_prime", + ]); +}); + +test("send_to_prime relays labeled text to the session's Prime", async () => { + const registry = new RelayRegistry(); + const channel = registry.get( + registry.open({ sessionId: "s1", label: "explorer" }).channelId, + )!; + const delivered: Array<{ sessionId: string; text: string }> = []; + + const res = await dispatchMcp( + registry, + channel, + { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { name: "send_to_prime", arguments: { text: "found it" } }, + }, + (sessionId, text) => delivered.push({ sessionId, text }), + ); + + assert.equal(delivered.length, 1); + assert.equal(delivered[0].sessionId, "s1"); + assert.match(delivered[0].text, /explorer/); + assert.match(delivered[0].text, /found it/); + const content = (res?.result as { content: { text: string }[] }).content; + assert.match(content[0].text, /Delivered/); +}); + +test("notifications receive no response body", async () => { + const registry = new RelayRegistry(); + const channel = registry.get(registry.open({ sessionId: "s1" }).channelId)!; + const res = await dispatchMcp( + registry, + channel, + { jsonrpc: "2.0", method: "notifications/initialized" }, + () => {}, + ); + assert.equal(res, null); +}); diff --git a/apps/server/src/mcp/relayRegistry.ts b/apps/server/src/mcp/relayRegistry.ts new file mode 100644 index 0000000..6e5b3bb --- /dev/null +++ b/apps/server/src/mcp/relayRegistry.ts @@ -0,0 +1,101 @@ +import { randomBytes, randomUUID } from "node:crypto"; + +/** + * A relay channel bridges an external MCP client (which the gateway dials) to a + * session's Prime agent. It is opened by a bundle extension over the internal + * API, its `url` is handed to the remote peer, and every tool call the peer + * makes on that channel is relayed to `sessionId`'s Prime. Generic by design: + * this holds no knowledge of what remote runtime is on the other end. + */ +export interface RelayChannel { + channelId: string; + sessionId: string; + /** Human label used when relaying messages to Prime (e.g. the peer's name). */ + label: string; + /** Bearer secret the external MCP client must present on every call. */ + secret: string; + /** Open questions awaiting an answer, keyed by request id. */ + pending: Map; + /** Answers supplied for pending questions, keyed by request id. */ + answers: Map; + createdAt: number; +} + +export interface OpenChannelInput { + sessionId: string; + label?: string; +} + +export interface PendingQuestion { + request_id: string; + question: string; +} + +/** + * In-memory registry of relay channels. State is intentionally ephemeral: a + * channel only makes sense while its session's Prime process is alive, and the + * external peer re-establishes on restart via a freshly opened channel. + */ +export class RelayRegistry { + private readonly channels = new Map(); + + /** Opens a channel bound to `sessionId`, returning its id and bearer secret. */ + open(input: OpenChannelInput): { channelId: string; secret: string } { + const channelId = randomUUID().replace(/-/g, ""); + const secret = randomBytes(24).toString("hex"); + this.channels.set(channelId, { + channelId, + sessionId: input.sessionId, + label: input.label?.trim() || "remote agent", + secret, + pending: new Map(), + answers: new Map(), + createdAt: Date.now(), + }); + return { channelId, secret }; + } + + get(channelId: string): RelayChannel | undefined { + return this.channels.get(channelId); + } + + /** Registers a question awaiting an answer. */ + addQuestion(channelId: string, requestId: string, question: string): void { + const channel = this.channels.get(channelId); + if (!channel) return; + channel.pending.set(requestId, { question, createdAt: Date.now() }); + } + + /** Records an answer for a pending question. Returns whether it was open. */ + answer(channelId: string, requestId: string, text: string): boolean { + const channel = this.channels.get(channelId); + if (!channel) return false; + const wasPending = channel.pending.has(requestId); + channel.answers.set(requestId, text); + return wasPending; + } + + /** Consumes an answer if present, clearing the matching pending question. */ + takeAnswer(channelId: string, requestId: string): string | undefined { + const channel = this.channels.get(channelId); + if (!channel) return undefined; + const answer = channel.answers.get(requestId); + if (answer === undefined) return undefined; + channel.answers.delete(requestId); + channel.pending.delete(requestId); + return answer; + } + + /** Lists open questions for a channel (oldest first). */ + pending(channelId: string): PendingQuestion[] { + const channel = this.channels.get(channelId); + if (!channel) return []; + return [...channel.pending.entries()] + .sort((a, b) => a[1].createdAt - b[1].createdAt) + .map(([request_id, value]) => ({ request_id, question: value.question })); + } + + close(channelId: string): boolean { + return this.channels.delete(channelId); + } +} diff --git a/apps/server/src/pi/agentConfig.ts b/apps/server/src/pi/agentConfig.ts index 8f07c51..4a94a9e 100644 --- a/apps/server/src/pi/agentConfig.ts +++ b/apps/server/src/pi/agentConfig.ts @@ -2,6 +2,7 @@ import { readdirSync, readFileSync } from "node:fs"; import path from "node:path"; import { + type SubagentHost, THINKING_LEVELS, type ThinkingLevel, } from "@tangent/shared/contracts.ts"; @@ -160,6 +161,11 @@ export interface SubagentSpawnRequest { * Defaults to true; trigger-owned sub-agents pass false to react in isolation. */ autoRelayToPrime?: boolean; + /** + * Which host runs the sub-agent: `local` (a `pi` child) or `remote` (a + * connected remote environment). Defaults to `local` when omitted. + */ + environment?: SubagentHost; } /** Narrows an arbitrary string to a valid {@link ThinkingLevel}, else undefined. */ diff --git a/apps/server/src/pi/extensions/orchestrator.ts b/apps/server/src/pi/extensions/orchestrator.ts index 3096894..5f44c00 100644 --- a/apps/server/src/pi/extensions/orchestrator.ts +++ b/apps/server/src/pi/extensions/orchestrator.ts @@ -132,9 +132,11 @@ export default function (pi: ExtensionAPI) { "`tools` inline. Optionally set `model` (a `provider/model` id) and " + "`thinking` depth (off/minimal/low/medium/high/xhigh); both default to " + "the session's settings when omitted. Optionally include a `task` to " + - "start the sub-agent working immediately. Returns the sub-agent's id for " + - "later messaging. Sub-agents share this session's workspace and can read " + - "the room.", + "start the sub-agent working immediately. Set `environment` to `remote` " + + "or `external` to host the sub-agent in a connected remote environment " + + "or external bridge instead of locally (defaults to `local`). Returns " + + "the sub-agent's id for later messaging. Sub-agents share this session's " + + "workspace and can read the room.", promptSnippet: "Spawn a specialized sub-agent (by template or inline config)", parameters: Type.Object({ @@ -169,6 +171,13 @@ export default function (pi: ExtensionAPI) { task: Type.Optional( Type.String({ description: "Initial task to send the sub-agent now." }), ), + environment: Type.Optional( + Type.String({ + description: + "Host for the sub-agent: `local` (default) or `remote` (a " + + "connected remote environment).", + }), + ), }), async execute(_toolCallId, params) { const data = (await callApi("POST", "spawn", { @@ -180,6 +189,7 @@ export default function (pi: ExtensionAPI) { model: params.model, thinkingDepth: params.thinking, task: params.task, + environment: params.environment, })) as { subagent: { id: string; name: string } }; return textResult( diff --git a/apps/server/src/pi/piAgentManager.ts b/apps/server/src/pi/piAgentManager.ts index 500cc07..5d21878 100644 --- a/apps/server/src/pi/piAgentManager.ts +++ b/apps/server/src/pi/piAgentManager.ts @@ -312,9 +312,11 @@ function resolveEffectiveConfig( } /** - * A persisted roster row is eligible for revive when it is an `active` - * sub-agent (Prime is handled by {@link PiAgentManager.ensure}) that isn't - * already live in the in-memory roster (so a reconnect won't double-spawn it). + * A persisted roster row is eligible for revive when it is an `active`, + * locally-hosted sub-agent (Prime is handled by {@link PiAgentManager.ensure}) + * that isn't already live in the in-memory roster (so a reconnect won't + * double-spawn it). Remote- and external-hosted sub-agents are never revived + * here — they re-establish when their environment/bridge reconnects. */ function canReviveSubagent( session: SessionAgents, @@ -322,6 +324,7 @@ function canReviveSubagent( ): boolean { if (agent.role !== "subagent") return false; if (agent.status !== "active") return false; + if (agent.host === "remote" || agent.host === "external") return false; return !session.agents.has(agent.id); } diff --git a/apps/server/src/remote/remoteEnvironmentGateway.ts b/apps/server/src/remote/remoteEnvironmentGateway.ts new file mode 100644 index 0000000..5c8031c --- /dev/null +++ b/apps/server/src/remote/remoteEnvironmentGateway.ts @@ -0,0 +1,420 @@ +import { randomUUID } from "node:crypto"; + +import type { + ChatAuthor, + MessageDelivery, + SubagentInfo, + SubagentStatus, +} from "@tangent/shared/contracts.ts"; +import { + REMOTE_ENV_NAMESPACE, + type RemoteAgentEvent, + type RemoteAgentEventPayload, + type RemoteAgentMessagePayload, + RemoteEnvEvents, + type RemoteEnvHandshake, + type RemoteKillCommand, + type RemoteMessageCommand, + type RemoteRoomReadRequest, + type RemoteRoomReadResponse, + type RemoteSpawnCommand, + type RemoteSubagentUpdatePayload, +} from "@tangent/shared/remoteSubagent.ts"; +import type { Namespace, Server as SocketIOServer, Socket } from "socket.io"; + +import { REMOTE_ENV_TOKEN } from "../config.ts"; +import { + resolveSubagentConfig, + type SubagentSpawnRequest, +} from "../pi/agentConfig.ts"; +import type { SpawnedSubagent } from "../pi/piAgentManager.ts"; +import type { AgentDescriptor, PiAgentHandlers } from "../pi/types.ts"; +import type { SessionStore } from "../store/sessionStore.ts"; + +/** Default and maximum number of transcript messages a room read returns. */ +const DEFAULT_ROOM_LIMIT = 30; +const MAX_ROOM_LIMIT = 200; + +/** + * Relays a remote sub-agent's reply/report into the session's Prime process. + * Wired in `index.ts` to `pi.sendToAgent(sessionId, PRIME_AGENT_ID, text)`, so + * the gateway stays decoupled from {@link import("../pi/piAgentManager.ts").PiAgentManager}. + */ +export type DeliverToPrime = (sessionId: string, text: string) => void; + +/** A connected remote environment and its live Socket.IO connection. */ +interface RemoteEnvConnection { + environmentId: string; + socket: Socket; +} + +/** A sub-agent hosted in a remote environment, tracked in the gateway roster. */ +interface RemoteSubagent { + agentId: string; + name: string; + status: SubagentStatus; + template?: string; + model?: string; + thinkingDepth?: SubagentInfo["thinkingDepth"]; + createdAt: string; + /** Which connected environment hosts this sub-agent. */ + environmentId: string; + /** Whether finalized replies auto-relay back to Prime. */ + autoRelayToPrime: boolean; +} + +/** Clamps a requested room-read limit to the allowed range. */ +function clampLimit(limit: number | undefined): number { + if (limit === undefined || !Number.isFinite(limit) || limit <= 0) { + return DEFAULT_ROOM_LIMIT; + } + return Math.min(limit, MAX_ROOM_LIMIT); +} + +/** Projects a roster entry onto the wire {@link SubagentInfo}. */ +function toInfo(subagent: RemoteSubagent): SubagentInfo { + return { + id: subagent.agentId, + name: subagent.name, + status: subagent.status, + host: "remote", + template: subagent.template, + model: subagent.model, + thinkingDepth: subagent.thinkingDepth, + createdAt: subagent.createdAt, + }; +} + +/** + * Server-side endpoint for the **remote sub-agent** transport. Accepts remote + * environments on a dedicated Socket.IO namespace and exposes the same + * spawn/message/kill/list surface as {@link + * import("../pi/piAgentManager.ts").PiAgentManager}, so the internal agents API + * can route a sub-agent to either host transparently. + * + * Inbound streamed events are relayed through the same {@link PiAgentHandlers} + * a local sub-agent uses, so a remote sub-agent renders and persists + * identically; finalized replies and reports are relayed into Prime via + * {@link DeliverToPrime}. + */ +export class RemoteEnvironmentGateway { + private readonly io: SocketIOServer; + private readonly handlers: PiAgentHandlers; + private readonly store: SessionStore; + private readonly deliverToPrime: DeliverToPrime; + + /** Connected environments, keyed by their handshake `environmentId`. */ + private readonly environments = new Map(); + /** Per-session remote sub-agent rosters, keyed by sessionId then agentId. */ + private readonly sessions = new Map>(); + + constructor( + io: SocketIOServer, + handlers: PiAgentHandlers, + store: SessionStore, + deliverToPrime: DeliverToPrime, + ) { + this.io = io; + this.handlers = handlers; + this.store = store; + this.deliverToPrime = deliverToPrime; + this.setupNamespace(); + } + + /** True when at least one remote environment is connected. */ + hasConnectedEnvironment(): boolean { + return this.environments.size > 0; + } + + /** True when `agentId` is a remote sub-agent of `sessionId`. */ + hasAgent(sessionId: string, agentId: string): boolean { + return Boolean(this.sessions.get(sessionId)?.has(agentId)); + } + + /** The session's remote sub-agent roster (Prime/local agents excluded). */ + listSubagents(sessionId: string): SubagentInfo[] { + const roster = this.sessions.get(sessionId); + if (!roster) return []; + return [...roster.values()].map(toInfo); + } + + /** + * Spawns a sub-agent on a connected remote environment. Resolves the + * effective config from the global templates/defaults (remote environments + * are bundle-agnostic in this iteration), records the roster entry, and emits + * the spawn command. Throws when no environment is connected. + */ + spawnSubagent( + sessionId: string, + request: SubagentSpawnRequest, + ): SpawnedSubagent { + const environment = this.pickEnvironment(); + if (!environment) { + throw new Error("No remote environment is connected."); + } + + const agentId = randomUUID(); + const config = resolveSubagentConfig(request); + const autoRelayToPrime = request.autoRelayToPrime ?? true; + const tools = [...config.tools]; + + const subagent: RemoteSubagent = { + agentId, + name: request.name, + status: "active", + template: request.template, + model: config.model, + thinkingDepth: config.thinkingDepth, + createdAt: new Date().toISOString(), + environmentId: environment.environmentId, + autoRelayToPrime, + }; + this.rosterFor(sessionId).set(agentId, subagent); + + const command: RemoteSpawnCommand = { + sessionId, + agentId, + name: request.name, + tools, + systemPrompt: config.appendSystemPrompt, + model: config.model, + thinkingDepth: config.thinkingDepth, + template: request.template, + task: request.task, + autoRelayToPrime, + }; + environment.socket.emit(RemoteEnvEvents.Spawn, command); + + const info = toInfo(subagent); + this.handlers.onSubagentUpdate(sessionId, info); + return { + info, + tools, + systemPrompt: config.appendSystemPrompt, + autoRelayToPrime, + }; + } + + /** + * Delivers a directed message/task to a remote sub-agent. When + * `surfaceAuthor` is given, the message is also surfaced into the sub-agent's + * transcript (matching the local manager), so directed tasks read as a real + * conversation. No-op for an unknown agent or a disconnected environment. + */ + sendToAgent( + sessionId: string, + agentId: string, + text: string, + surfaceAuthor?: ChatAuthor, + delivery: MessageDelivery = "auto", + ): void { + const subagent = this.sessions.get(sessionId)?.get(agentId); + if (!subagent) return; + const environment = this.environments.get(subagent.environmentId); + if (!environment) return; + + if (surfaceAuthor) { + this.handlers.onAgentMessage(sessionId, agentId, surfaceAuthor, text); + } + + const command: RemoteMessageCommand = { + sessionId, + agentId, + text, + delivery, + }; + environment.socket.emit(RemoteEnvEvents.Message, command); + } + + /** + * Terminates a remote sub-agent and records its terminal status. `completed` + * marks a graceful finish; otherwise it is "killed". + */ + killAgent(sessionId: string, agentId: string, completed = false): void { + const roster = this.sessions.get(sessionId); + if (!roster) return; + const subagent = roster.get(agentId); + if (!subagent) return; + + subagent.status = completed ? "completed" : "killed"; + roster.delete(agentId); + this.emitToEnvironment(subagent.environmentId, RemoteEnvEvents.Kill, { + sessionId, + agentId, + completed, + } satisfies RemoteKillCommand); + this.handlers.onSubagentUpdate(sessionId, toInfo(subagent)); + } + + /** Emits an event to a specific environment's socket, if still connected. */ + private emitToEnvironment( + environmentId: string, + event: string, + payload: unknown, + ): void { + this.environments.get(environmentId)?.socket.emit(event, payload); + } + + /** Picks a connected environment to host a new sub-agent (first connected). */ + private pickEnvironment(): RemoteEnvConnection | undefined { + const first = this.environments.values().next(); + return first.done ? undefined : first.value; + } + + /** Returns (creating if needed) the session's remote sub-agent roster. */ + private rosterFor(sessionId: string): Map { + const existing = this.sessions.get(sessionId); + if (existing) return existing; + const created = new Map(); + this.sessions.set(sessionId, created); + return created; + } + + /** Builds the agent descriptor a relayed event is tagged with. */ + private descriptorFor(subagent: RemoteSubagent): AgentDescriptor { + return { agentId: subagent.agentId, role: "subagent", name: subagent.name }; + } + + /** Creates the `/remote-env` namespace with auth + connection handlers. */ + private setupNamespace(): void { + const namespace = this.io.of(REMOTE_ENV_NAMESPACE); + namespace.use((socket, next) => this.authenticate(socket, next)); + namespace.on("connection", (socket) => + this.onConnection(namespace, socket), + ); + } + + /** Rejects connections lacking a valid token / environment id. */ + private authenticate(socket: Socket, next: (err?: Error) => void): void { + const auth = socket.handshake.auth as Partial; + if (!REMOTE_ENV_TOKEN || auth.token !== REMOTE_ENV_TOKEN) { + next(new Error("Unauthorized")); + return; + } + if (!auth.environmentId) { + next(new Error("Missing environmentId")); + return; + } + next(); + } + + /** Registers a connected environment and wires its inbound listeners. */ + private onConnection(_namespace: Namespace, socket: Socket): void { + const { environmentId } = socket.handshake.auth as RemoteEnvHandshake; + this.environments.set(environmentId, { environmentId, socket }); + console.log(`[remote-env] connected: ${environmentId}`); + + socket.on(RemoteEnvEvents.AgentEvent, (payload: RemoteAgentEventPayload) => + this.handleAgentEvent(payload), + ); + socket.on( + RemoteEnvEvents.SubagentUpdate, + (payload: RemoteSubagentUpdatePayload) => + this.handleSubagentUpdate(payload), + ); + socket.on( + RemoteEnvEvents.AgentMessage, + (payload: RemoteAgentMessagePayload) => this.handleAgentMessage(payload), + ); + socket.on( + RemoteEnvEvents.RoomRead, + ( + request: RemoteRoomReadRequest, + callback: (response: RemoteRoomReadResponse) => void, + ) => void this.handleRoomRead(request, callback), + ); + socket.on("disconnect", () => this.onDisconnect(environmentId)); + } + + /** Relays a streamed event to the chat layer, relaying finalized replies. */ + private handleAgentEvent(payload: RemoteAgentEventPayload): void { + const subagent = this.sessions.get(payload.sessionId)?.get(payload.agentId); + if (!subagent) return; + this.handlers.onAgentEvent( + payload.sessionId, + this.descriptorFor(subagent), + payload.event, + ); + this.relayEndToPrime(payload.sessionId, subagent, payload.event); + } + + /** Feeds a finalized auto-relay reply into Prime as it lands. */ + private relayEndToPrime( + sessionId: string, + subagent: RemoteSubagent, + event: RemoteAgentEvent, + ): void { + if (event.type !== "end") return; + if (!subagent.autoRelayToPrime || !event.content.trim()) return; + this.deliverToPrime( + sessionId, + `Sub-agent "${subagent.name}" replied:\n\n${event.content}`, + ); + } + + /** Applies a remote sub-agent's status change to the roster + chat layer. */ + private handleSubagentUpdate(payload: RemoteSubagentUpdatePayload): void { + const roster = this.sessions.get(payload.sessionId); + const subagent = roster?.get(payload.agentId); + if (!roster || !subagent) return; + + subagent.status = payload.status; + if (payload.status !== "active") roster.delete(payload.agentId); + this.handlers.onSubagentUpdate(payload.sessionId, toInfo(subagent)); + } + + /** Surfaces a sub-agent's report in its thread and relays it into Prime. */ + private handleAgentMessage(payload: RemoteAgentMessagePayload): void { + const subagent = this.sessions.get(payload.sessionId)?.get(payload.agentId); + if (!subagent) return; + + const author: ChatAuthor = { + id: subagent.agentId, + kind: "agent", + name: subagent.name, + agentRole: "subagent", + }; + this.handlers.onAgentMessage( + payload.sessionId, + payload.agentId, + author, + payload.text, + ); + this.deliverToPrime( + payload.sessionId, + `Sub-agent "${subagent.name}" reported:\n\n${payload.text}`, + ); + } + + /** Answers a remote room-read with the tail of the shared transcript. */ + private async handleRoomRead( + request: RemoteRoomReadRequest, + callback: (response: RemoteRoomReadResponse) => void, + ): Promise { + const all = await this.store.getMessages(request.sessionId); + callback({ messages: all.slice(-clampLimit(request.limit)) }); + } + + /** Drops a disconnected environment and fails its still-live sub-agents. */ + private onDisconnect(environmentId: string): void { + this.environments.delete(environmentId); + for (const [sessionId, roster] of this.sessions) { + this.failEnvironmentAgents(sessionId, roster, environmentId); + } + console.log(`[remote-env] disconnected: ${environmentId}`); + } + + /** Marks every sub-agent owned by `environmentId` in a roster as errored. */ + private failEnvironmentAgents( + sessionId: string, + roster: Map, + environmentId: string, + ): void { + for (const subagent of [...roster.values()]) { + if (subagent.environmentId !== environmentId) continue; + subagent.status = "error"; + roster.delete(subagent.agentId); + this.handlers.onSubagentUpdate(sessionId, toInfo(subagent)); + } + } +} diff --git a/apps/server/src/routes/internalAgents.ts b/apps/server/src/routes/internalAgents.ts index 050ef6c..3a8fd18 100644 --- a/apps/server/src/routes/internalAgents.ts +++ b/apps/server/src/routes/internalAgents.ts @@ -1,11 +1,16 @@ -import { PI_AGENT } from "@tangent/shared/contracts.ts"; +import { PI_AGENT, type SubagentHost } from "@tangent/shared/contracts.ts"; import { type Response, Router } from "express"; import { z } from "zod"; +import type { ExternalSubagentGateway } from "../external/externalSubagentGateway.ts"; import { requireInternalToken } from "../middleware/requireInternalToken.ts"; import { getValidated, validate } from "../middleware/validate.ts"; -import { parseThinkingLevel } from "../pi/agentConfig.ts"; -import type { PiAgentManager } from "../pi/piAgentManager.ts"; +import { + parseThinkingLevel, + type SubagentSpawnRequest, +} from "../pi/agentConfig.ts"; +import type { PiAgentManager, SpawnedSubagent } from "../pi/piAgentManager.ts"; +import type { RemoteEnvironmentGateway } from "../remote/remoteEnvironmentGateway.ts"; import type { SessionStore } from "../store/sessionStore.ts"; /** Spawn a sub-agent; `sessionId` and `name` identify and label it. */ @@ -18,6 +23,8 @@ export const spawnSchema = z.object({ model: z.string().optional(), thinkingDepth: z.string().optional(), task: z.string().optional(), + /** Where to host the sub-agent. Defaults to `local`. */ + environment: z.enum(["local", "remote"]).optional(), }); export type SpawnInput = z.infer; @@ -59,18 +66,48 @@ const DEFAULT_ROOM_LIMIT = 30; const MAX_ROOM_LIMIT = 200; /** - * Spawns a sub-agent (resolving its model/thinking) and persists it so the - * roster survives a restart. Extracted from the router so the route function - * stays small. + * The hosts a sub-agent can be routed to. `pi` (local) and `remote` are + * spawnable via `spawn_subagent`; `external` sub-agent tabs are created and + * driven by a bundle tool over `/internal/external-agents`, so the external + * gateway is present here only to merge its roster into `list`. + */ +interface AgentHosts { + pi: PiAgentManager; + remote: RemoteEnvironmentGateway; + external: ExternalSubagentGateway; +} + +/** Narrows a spawn request's `environment` to a concrete {@link SubagentHost}. */ +function resolveHost(environment: SpawnInput["environment"]): SubagentHost { + return environment === "remote" ? "remote" : "local"; +} + +/** Spawns a sub-agent on its requested host (local Pi or remote env). */ +function spawnOnHost( + hosts: AgentHosts, + sessionId: string, + request: SubagentSpawnRequest, + host: SubagentHost, +): SpawnedSubagent { + if (host === "remote") return hosts.remote.spawnSubagent(sessionId, request); + return hosts.pi.spawnSubagent(sessionId, request); +} + +/** + * Spawns a sub-agent (resolving its model/thinking) on the requested host and + * persists it so the roster survives a restart. Extracted from the router so + * the route function stays small. */ function handleSpawn( store: SessionStore, - pi: PiAgentManager, + hosts: AgentHosts, body: SpawnInput, res: Response, ): void { try { - const { info, tools, systemPrompt, autoRelayToPrime } = pi.spawnSubagent( + const host = resolveHost(body.environment); + const { info, tools, systemPrompt, autoRelayToPrime } = spawnOnHost( + hosts, body.sessionId, { name: body.name, @@ -80,7 +117,9 @@ function handleSpawn( model: body.model, thinkingDepth: parseThinkingLevel(body.thinkingDepth), task: body.task, + environment: host, }, + host, ); void store.recordAgent(body.sessionId, { id: info.id, @@ -94,6 +133,7 @@ function handleSpawn( tools, systemPrompt, autoRelayToPrime, + host, }); res.json({ subagent: info }); } catch (err) { @@ -103,35 +143,53 @@ function handleSpawn( /** Surfaces a Prime-issued directive in the sub-agent's transcript. */ function handleMessage( - pi: PiAgentManager, + hosts: AgentHosts, body: MessageInput, res: Response, ): void { // Attributed to Prime (message_subagent is always a Prime-issued directive). - pi.sendToAgent(body.sessionId, body.agentId, body.text, PI_AGENT); + // Remote-hosted sub-agents route through their gateway; else local. + const { sessionId, agentId, text } = body; + if (hosts.remote.hasAgent(sessionId, agentId)) { + hosts.remote.sendToAgent(sessionId, agentId, text, PI_AGENT); + } else { + hosts.pi.sendToAgent(sessionId, agentId, text, PI_AGENT); + } res.json({ ok: true }); } /** Surfaces a sub-agent's report in its own thread and delivers it to Prime. */ function handleReport( - pi: PiAgentManager, + hosts: AgentHosts, body: ReportInput, res: Response, ): void { // message_prime is a sub-agent-issued update; Prime reacts immediately. - pi.reportToPrime(body.sessionId, body.agentId, body.text); + hosts.pi.reportToPrime(body.sessionId, body.agentId, body.text); res.json({ ok: true }); } /** Terminates a sub-agent, optionally marking its work completed. */ -function handleKill(pi: PiAgentManager, body: KillInput, res: Response): void { - pi.killAgent(body.sessionId, body.agentId, body.completed ?? false); +function handleKill(hosts: AgentHosts, body: KillInput, res: Response): void { + const { sessionId, agentId } = body; + const completed = body.completed ?? false; + if (hosts.remote.hasAgent(sessionId, agentId)) { + hosts.remote.killAgent(sessionId, agentId, completed); + } else { + hosts.pi.killAgent(sessionId, agentId, completed); + } res.json({ ok: true }); } -/** Lists the sub-agents registered for a session. */ -function handleList(pi: PiAgentManager, query: ListQuery, res: Response): void { - res.json({ subagents: pi.listSubagents(query.sessionId) }); +/** Lists the sub-agents registered for a session across all hosts. */ +function handleList(hosts: AgentHosts, query: ListQuery, res: Response): void { + res.json({ + subagents: [ + ...hosts.pi.listSubagents(query.sessionId), + ...hosts.remote.listSubagents(query.sessionId), + ...hosts.external.listSubagents(query.sessionId), + ], + }); } /** Returns the tail of the shared transcript, clamped to the room limit. */ @@ -159,29 +217,40 @@ async function handleRoom( export function createInternalAgentsRouter( store: SessionStore, pi: PiAgentManager, + remoteGateway: RemoteEnvironmentGateway, + externalGateway: ExternalSubagentGateway, ): Router { const router = Router(); + const hosts: AgentHosts = { + pi, + remote: remoteGateway, + external: externalGateway, + }; router.use(requireInternalToken); router.post("/spawn", validate({ body: spawnSchema }), (req, res) => - handleSpawn(store, pi, getValidated(req).body, res), + handleSpawn(store, hosts, getValidated(req).body, res), ); router.post("/message", validate({ body: messageSchema }), (req, res) => - handleMessage(pi, getValidated(req).body, res), + handleMessage(hosts, getValidated(req).body, res), ); router.post("/report", validate({ body: reportSchema }), (req, res) => - handleReport(pi, getValidated(req).body, res), + handleReport(hosts, getValidated(req).body, res), ); router.post("/kill", validate({ body: killSchema }), (req, res) => - handleKill(pi, getValidated(req).body, res), + handleKill(hosts, getValidated(req).body, res), ); router.get("/list", validate({ query: listQuerySchema }), (req, res) => - handleList(pi, getValidated(req).query, res), + handleList( + hosts, + getValidated(req).query, + res, + ), ); router.get("/room", validate({ query: roomQuerySchema }), (req, res) => diff --git a/apps/server/src/routes/internalExternalAgents.ts b/apps/server/src/routes/internalExternalAgents.ts new file mode 100644 index 0000000..37e0bbf --- /dev/null +++ b/apps/server/src/routes/internalExternalAgents.ts @@ -0,0 +1,79 @@ +import type { RemoteAgentEvent } from "@tangent/shared/remoteSubagent.ts"; +import { Router } from "express"; +import { z } from "zod"; + +import type { ExternalSubagentGateway } from "../external/externalSubagentGateway.ts"; +import { requireInternalToken } from "../middleware/requireInternalToken.ts"; +import { getValidated, validate } from "../middleware/validate.ts"; +import { parseThinkingLevel } from "../pi/agentConfig.ts"; + +/** Register body: create an external sub-agent tab with optional display meta. */ +const registerSchema = z.object({ + sessionId: z.string(), + name: z.string(), + template: z.string().optional(), + model: z.string().optional(), + thinkingDepth: z.string().optional(), +}); +type RegisterBody = z.infer; + +/** Event body: a streamed event tagged with its tab; the event is passed through. */ +const eventSchema = z.object({ + sessionId: z.string(), + agentId: z.string(), + event: z.object({ type: z.string() }).passthrough(), +}); +type EventBody = z.infer; + +/** Status body: a lifecycle status change for an external sub-agent tab. */ +const statusSchema = z.object({ + sessionId: z.string(), + agentId: z.string(), + status: z.enum(["active", "completed", "killed", "error"]), +}); +type StatusBody = z.infer; + +/** + * Internal API for driving **external sub-agent** tabs. A bundle tool extension + * (running inside a session's Pi process) registers a tab, streams the external + * runtime's output into it, and marks its lifecycle. Guarded by the same + * {@link import("../middleware/requireInternalToken.ts").requireInternalToken} + * bearer as the other internal APIs; the gateway stays transport-agnostic and + * proprietary-runtime specifics live entirely in the caller. + */ +export function createInternalExternalAgentsRouter( + gateway: ExternalSubagentGateway, +): Router { + const router = Router(); + + router.use(requireInternalToken); + + router.post("/register", validate({ body: registerSchema }), (req, res) => { + const body = getValidated(req).body; + const { id } = gateway.register(body.sessionId, { + name: body.name, + template: body.template, + model: body.model, + thinkingDepth: parseThinkingLevel(body.thinkingDepth), + }); + res.json({ subagent: { id } }); + }); + + router.post("/event", validate({ body: eventSchema }), (req, res) => { + const body = getValidated(req).body; + gateway.pushEvent( + body.sessionId, + body.agentId, + body.event as unknown as RemoteAgentEvent, + ); + res.json({ ok: true }); + }); + + router.post("/status", validate({ body: statusSchema }), (req, res) => { + const body = getValidated(req).body; + gateway.setStatus(body.sessionId, body.agentId, body.status); + res.json({ ok: true }); + }); + + return router; +} diff --git a/apps/server/src/routes/internalMcpRelay.ts b/apps/server/src/routes/internalMcpRelay.ts new file mode 100644 index 0000000..befc729 --- /dev/null +++ b/apps/server/src/routes/internalMcpRelay.ts @@ -0,0 +1,136 @@ +import { type Request, type Response, Router } from "express"; +import { z } from "zod"; + +import { PUBLIC_URL } from "../config.ts"; +import type { RelayRegistry } from "../mcp/relayRegistry.ts"; +import { requireInternalToken } from "../middleware/requireInternalToken.ts"; +import { getValidated, validate } from "../middleware/validate.ts"; +import type { SessionStore } from "../store/sessionStore.ts"; + +/** Open body: bind a new channel to a session, with an optional peer label. */ +export const openSchema = z.object({ + sessionId: z.string(), + label: z.string().optional(), +}); +export type OpenInput = z.infer; + +/** Answer body: resolve a pending `ask_prime` question on a channel. */ +export const answerSchema = z.object({ + request_id: z.string(), + answer: z.string(), +}); +export type AnswerInput = z.infer; + +async function handleOpen( + registry: RelayRegistry, + store: SessionStore, + body: OpenInput, + res: Response, +): Promise { + if (!PUBLIC_URL) { + res.status(501).json({ + error: + "TANGENT_PUBLIC_URL is not set. The external MCP client must dial an " + + "HTTPS, non-loopback URL, so set TANGENT_PUBLIC_URL to this server's " + + "public base (a tunnel in dev, the reverse-proxy URL in a real " + + "instance) so a dial-able channel URL can be issued.", + }); + return; + } + + const session = await store.getSession(body.sessionId); + if (!session) { + res.status(404).json({ error: "Session not found" }); + return; + } + + const { channelId, secret } = registry.open({ + sessionId: body.sessionId, + label: body.label, + }); + const url = `${PUBLIC_URL}/api/mcp/${channelId}`; + console.error( + `[mcp-relay] opened channel ${channelId} for session ${body.sessionId} ` + + `-> ${url}`, + ); + res.json({ channelId, secret, url }); +} + +function handlePending( + registry: RelayRegistry, + channelId: string, + res: Response, +): void { + if (!registry.get(channelId)) { + res.status(404).json({ error: "Unknown channel" }); + return; + } + res.json({ pending: registry.pending(channelId) }); +} + +function handleAnswer( + registry: RelayRegistry, + channelId: string, + body: AnswerInput, + res: Response, +): void { + if (!registry.get(channelId)) { + res.status(404).json({ error: "Unknown channel" }); + return; + } + const delivered = registry.answer(channelId, body.request_id, body.answer); + res.json({ delivered }); +} + +function handleClose( + registry: RelayRegistry, + channelId: string, + res: Response, +): void { + res.json({ closed: registry.close(channelId) }); +} + +/** + * Internal API used by a bundle extension (running inside a Pi process) to open + * a generic MCP relay channel bound to its session, poll/answer questions the + * remote peer raised, and close the channel. Guarded by the same bearer token + * as the other internal APIs. Aquifer-agnostic: the extension owns the remote + * runtime; the server only relays to the session's Prime. + */ +export function createInternalMcpRelayRouter( + registry: RelayRegistry, + store: SessionStore, +): Router { + const router = Router(); + + router.use(requireInternalToken); + + router.post( + "/open", + validate({ body: openSchema }), + (req: Request, res: Response) => + handleOpen(registry, store, getValidated(req).body, res), + ); + + router.get("/:channelId/pending", (req: Request, res: Response) => + handlePending(registry, String(req.params.channelId), res), + ); + + router.post( + "/:channelId/answer", + validate({ body: answerSchema }), + (req: Request, res: Response) => + handleAnswer( + registry, + String(req.params.channelId), + getValidated(req).body, + res, + ), + ); + + router.post("/:channelId/close", (req: Request, res: Response) => + handleClose(registry, String(req.params.channelId), res), + ); + + return router; +} diff --git a/apps/server/src/routes/mcp.ts b/apps/server/src/routes/mcp.ts new file mode 100644 index 0000000..647ae3e --- /dev/null +++ b/apps/server/src/routes/mcp.ts @@ -0,0 +1,118 @@ +import { type Request, type Response, Router } from "express"; + +import { type DeliverToPrime, dispatchMcp } from "../mcp/mcpRelayServer.ts"; +import type { RelayRegistry } from "../mcp/relayRegistry.ts"; + +/** + * Public MCP endpoint an external client (dialed by the gateway) uses to relay + * tool calls to a channel's session Prime. There is no global auth on `/api/*`; + * each channel is gated by the per-channel bearer secret embedded in the URL it + * was handed, mirroring the trigger-callback-secret pattern. Generic and + * domain-agnostic — the bundle that opened the channel owns everything specific + * to the remote runtime. + */ +export function createMcpRelayRouter( + registry: RelayRegistry, + deliverToPrime: DeliverToPrime, +): Router { + const router = Router(); + // Some MCP clients probe with GET for a server-sent-events channel. This PoC + // answers request/response over POST only, so GET is just a liveness probe. + router.get("/:channelId", (req, res) => handleGet(registry, req, res)); + router.post("/:channelId", (req, res) => + handlePost(registry, deliverToPrime, req, res), + ); + return router; +} + +function handleGet(registry: RelayRegistry, req: Request, res: Response): void { + const channelId = String(req.params.channelId); + const channel = registry.get(channelId); + const isAuthed = channel ? authorized(req, channel.secret) : false; + logDial("GET", channelId, req, isAuthed, channel !== undefined); + if (!channel || !isAuthed) { + res.status(channel ? 401 : 404).end(); + return; + } + res.writeHead(200, { "content-type": "text/event-stream" }); + res.write(": ok\n\n"); + res.end(); +} + +async function handlePost( + registry: RelayRegistry, + deliverToPrime: DeliverToPrime, + req: Request, + res: Response, +): Promise { + const channelId = String(req.params.channelId); + const channel = registry.get(channelId); + const isAuthed = channel ? authorized(req, channel.secret) : false; + logDial("POST", channelId, req, isAuthed, channel !== undefined); + if (!channel) { + res.status(404).json({ + jsonrpc: "2.0", + id: null, + error: { code: -32001, message: "unknown channel" }, + }); + return; + } + if (!isAuthed) { + res.status(401).json({ + jsonrpc: "2.0", + id: null, + error: { code: -32001, message: "unauthorized" }, + }); + return; + } + + const response = await dispatchMcp( + registry, + channel, + req.body ?? {}, + deliverToPrime, + ); + if (response === null) { + console.error(`[mcp-relay] ${channelId} -> 202 (notification)`); + res.status(202).end(); + return; + } + res.json(response); +} + +function authorized(req: Request, secret: string): boolean { + return req.get("authorization") === `Bearer ${secret}`; +} + +/** + * Logs an inbound dial from the external MCP client (the gateway). This is the + * only window we have into whether the gateway reaches us and what MCP framing + * it uses, so it records the JSON-RPC method plus the transport-shaping headers + * (Accept / Content-Type / Mcp-Session-Id) without ever printing the secret. + */ +function logDial( + verb: string, + channelId: string, + req: Request, + isAuthed: boolean, + channelKnown: boolean, +): void { + const body = (req.body ?? {}) as { method?: unknown; id?: unknown }; + console.error( + `[mcp-relay] ${verb} /api/mcp/${channelId} ` + + `method=${rpcMethod(body, verb)} id=${JSON.stringify(body.id ?? null)} ` + + `channel=${channelKnown ? "known" : "UNKNOWN"} ` + + `auth=${isAuthed ? "ok" : "FAIL"} ` + + `accept=${hdr(req, "accept")} ct=${hdr(req, "content-type")} ` + + `mcp-session-id=${hdr(req, "mcp-session-id")} ua=${hdr(req, "user-agent")}`, + ); +} + +function rpcMethod(body: { method?: unknown }, verb: string): string { + if (typeof body.method === "string") return body.method; + return verb === "POST" ? "?" : "-"; +} + +function hdr(req: Request, name: string): string { + return req.get(name) ?? "-"; +} diff --git a/apps/server/src/sockets/chat.ts b/apps/server/src/sockets/chat.ts index 8da1c90..5bbd09d 100644 --- a/apps/server/src/sockets/chat.ts +++ b/apps/server/src/sockets/chat.ts @@ -25,6 +25,7 @@ import { type MemoryScope, type MemorySuggestionPayload, PI_AGENT, + type Session, type SessionStatusPayload, type SessionStatusSnapshotPayload, SocketEvents, @@ -37,6 +38,7 @@ import { } from "@tangent/shared/contracts.ts"; import type { Server, Socket } from "socket.io"; +import type { ExternalSubagentGateway } from "../external/externalSubagentGateway.ts"; import { parseThinkingLevel } from "../pi/agentConfig.ts"; import type { MemoryManager } from "../pi/memory.ts"; import { @@ -50,6 +52,7 @@ import { } from "../pi/piAgentManager.ts"; import type { TriggerEngine } from "../pi/triggers/triggerEngine.ts"; import type { SessionStatusHandler } from "../pi/types.ts"; +import type { RemoteEnvironmentGateway } from "../remote/remoteEnvironmentGateway.ts"; import type { SessionAgentStatus, SessionStore, @@ -506,62 +509,102 @@ function handleMemoryDismiss( * messages are broadcast to the room and relayed into the session's Pi * process, whose reply is streamed back via the agent:* events. */ -export function registerChatHandlers( - io: Server, - store: SessionStore, - pi: PiAgentManager, - memory: MemoryManager, - onRemembered: MemoryRememberedHandler, - triggerEngine: TriggerEngine, - emitUiCommand: UiCommandEmitter, -): void { - io.on("connection", (socket: Socket) => { - socket.on(SocketEvents.ChatJoin, (payload: ChatJoinPayload) => - handleChatJoin(socket, store, pi, triggerEngine, payload), - ); +/** Shared dependencies wired into every connected socket's chat handlers. */ +interface ChatHandlerDeps { + io: Server; + store: SessionStore; + pi: PiAgentManager; + remoteGateway: RemoteEnvironmentGateway; + externalGateway: ExternalSubagentGateway; + memory: MemoryManager; + onRemembered: MemoryRememberedHandler; + triggerEngine: TriggerEngine; + emitUiCommand: UiCommandEmitter; +} + +/** Wires one connected socket's chat/agent/memory/artifact listeners. */ +function wireSocket(socket: Socket, deps: ChatHandlerDeps): void { + const { io, store, pi, remoteGateway, externalGateway, memory } = deps; + const { onRemembered, triggerEngine, emitUiCommand } = deps; + + socket.on(SocketEvents.ChatJoin, (payload: ChatJoinPayload) => + handleChatJoin( + socket, + store, + pi, + remoteGateway, + externalGateway, + triggerEngine, + payload, + ), + ); - socket.on(SocketEvents.ChatMessage, (payload: ChatMessagePayload) => - handleChatMessage(io, socket, store, pi, payload), - ); + socket.on(SocketEvents.ChatMessage, (payload: ChatMessagePayload) => + handleChatMessage(io, socket, store, pi, payload), + ); - socket.on(SocketEvents.AgentAbort, (payload: AgentAbortPayload) => - pi.abort(payload?.sessionId, payload?.conversationId), - ); + socket.on(SocketEvents.AgentAbort, (payload: AgentAbortPayload) => + pi.abort(payload?.sessionId, payload?.conversationId), + ); - socket.on(SocketEvents.AgentSetModel, (payload: AgentSetModelPayload) => - handleAgentSetModel(io, store, pi, payload), - ); + socket.on(SocketEvents.AgentSetModel, (payload: AgentSetModelPayload) => + handleAgentSetModel(io, store, pi, payload), + ); - socket.on(SocketEvents.MemoryConfirm, (payload: MemoryConfirmPayload) => - handleMemoryConfirm(store, pi, memory, onRemembered, payload), - ); + socket.on(SocketEvents.MemoryConfirm, (payload: MemoryConfirmPayload) => + handleMemoryConfirm(store, pi, memory, onRemembered, payload), + ); - socket.on(SocketEvents.MemoryDismiss, (payload: MemoryDismissPayload) => - handleMemoryDismiss(pi, memory, payload), - ); + socket.on(SocketEvents.MemoryDismiss, (payload: MemoryDismissPayload) => + handleMemoryDismiss(pi, memory, payload), + ); - socket.on(SocketEvents.ArtifactPin, (payload: ArtifactPinPayload) => - handleArtifactPin(store, emitUiCommand, payload), - ); + socket.on(SocketEvents.ArtifactPin, (payload: ArtifactPinPayload) => + handleArtifactPin(store, emitUiCommand, payload), + ); - socket.on(SocketEvents.ArtifactUnpin, (payload: ArtifactUnpinPayload) => - handleArtifactUnpin(store, emitUiCommand, payload), - ); + socket.on(SocketEvents.ArtifactUnpin, (payload: ArtifactUnpinPayload) => + handleArtifactUnpin(store, emitUiCommand, payload), + ); - // Subscribe to the sessions lobby: join the shared room (so future status - // changes broadcast here) and seed the socket with the current snapshot. - socket.on(SocketEvents.SessionStatusSubscribe, () => - handleSessionStatusSubscribe(socket, pi), - ); + // Subscribe to the sessions lobby: join the shared room (so future status + // changes broadcast here) and seed the socket with the current snapshot. + socket.on(SocketEvents.SessionStatusSubscribe, () => + handleSessionStatusSubscribe(socket, pi), + ); - // Terminal streaming channel is reserved for a later phase. Registered - // here so the protocol is stable; it currently emits nothing. - socket.on(SocketEvents.TerminalData, () => { - // no-op stub - }); + // Terminal streaming channel is reserved for a later phase. Registered + // here so the protocol is stable; it currently emits nothing. + socket.on(SocketEvents.TerminalData, () => { + // no-op stub }); } +export function registerChatHandlers( + io: Server, + store: SessionStore, + pi: PiAgentManager, + remoteGateway: RemoteEnvironmentGateway, + externalGateway: ExternalSubagentGateway, + memory: MemoryManager, + onRemembered: MemoryRememberedHandler, + triggerEngine: TriggerEngine, + emitUiCommand: UiCommandEmitter, +): void { + const deps: ChatHandlerDeps = { + io, + store, + pi, + remoteGateway, + externalGateway, + memory, + onRemembered, + triggerEngine, + emitUiCommand, + }; + io.on("connection", (socket: Socket) => wireSocket(socket, deps)); +} + /** Reads Prime's persisted model/thinking selection, parsing the stored depth. */ async function loadPrimeOverride( store: SessionStore, @@ -627,11 +670,50 @@ async function handleSessionStatusSubscribe( socket.emit(SocketEvents.SessionStatusSnapshot, payload); } +/** + * (Re)spawns the session's Prime — restoring any persisted model/thinking + * selection — and revives previously-active local sub-agents from the persisted + * roster, so a restart restores the full agent set (not just Prime). Idempotent: + * agents already live are skipped. + */ +async function ensureSessionAgents( + store: SessionStore, + pi: PiAgentManager, + session: Session, +): Promise { + const primeOverride = await loadPrimeOverride(store, session.id); + pi.ensure( + session.id, + session.rootPath, + undefined, + primeOverride, + session.user, + ); + const persistedAgents = await store.listAgents(session.id); + pi.reviveSubagents(session.id, persistedAgents); +} + +/** Merges a session's local, remote, and external sub-agent rosters for the UI. */ +function mergedSubagents( + pi: PiAgentManager, + remoteGateway: RemoteEnvironmentGateway, + externalGateway: ExternalSubagentGateway, + sessionId: string, +) { + return [ + ...pi.listSubagents(sessionId), + ...remoteGateway.listSubagents(sessionId), + ...externalGateway.listSubagents(sessionId), + ]; +} + /** Joins the session room, then replays history and the sub-agent roster. */ async function handleChatJoin( socket: Socket, store: SessionStore, pi: PiAgentManager, + remoteGateway: RemoteEnvironmentGateway, + externalGateway: ExternalSubagentGateway, triggerEngine: TriggerEngine, payload: ChatJoinPayload, ): Promise { @@ -644,23 +726,9 @@ async function handleChatJoin( const room = roomFor(session.id); await socket.join(room); - // Lazily (re)spawn the agent in case the server restarted or the session was - // created before the process manager existed, restoring any persisted Prime - // model/thinking selection so a respawn keeps the human's prior choice. - const primeOverride = await loadPrimeOverride(store, session.id); - pi.ensure( - session.id, - session.rootPath, - undefined, - primeOverride, - session.user, - ); - - // Re-spawn any previously-active sub-agents from the persisted roster so a - // restart restores the full agent set (Prime + sub-agents), not just Prime. - // Idempotent: agents already live are skipped. - const persistedAgents = await store.listAgents(session.id); - pi.reviveSubagents(session.id, persistedAgents); + // Lazily (re)spawn Prime and revive previously-active local sub-agents in + // case the server restarted or the session predates the process manager. + await ensureSessionAgents(store, pi, session); // Re-arm the session's schedule triggers (idempotent) and surface the roster. triggerEngine.sync(session.id, session.rootPath); @@ -670,7 +738,7 @@ async function handleChatJoin( const roster: SubagentRosterPayload = { sessionId: session.id, - subagents: pi.listSubagents(session.id), + subagents: mergedSubagents(pi, remoteGateway, externalGateway, session.id), }; socket.emit(SocketEvents.SubagentRoster, roster); diff --git a/apps/server/src/store/db/migrations/0005_quick_lifeguard.sql b/apps/server/src/store/db/migrations/0005_quick_lifeguard.sql new file mode 100644 index 0000000..0481f24 --- /dev/null +++ b/apps/server/src/store/db/migrations/0005_quick_lifeguard.sql @@ -0,0 +1 @@ +ALTER TABLE `session_agents` ADD `host` text DEFAULT 'local' NOT NULL; \ No newline at end of file diff --git a/apps/server/src/store/db/migrations/meta/0005_snapshot.json b/apps/server/src/store/db/migrations/meta/0005_snapshot.json new file mode 100644 index 0000000..d93ca0d --- /dev/null +++ b/apps/server/src/store/db/migrations/meta/0005_snapshot.json @@ -0,0 +1,343 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "755df5f0-b36a-4712-aff4-1bb6347caa15", + "prevId": "16a54726-7291-4159-b79a-95f80b708eac", + "tables": { + "session_agents": { + "name": "session_agents", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thinking_depth": { + "name": "thinking_depth", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tools": { + "name": "tools", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "system_prompt": { + "name": "system_prompt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auto_relay_to_prime": { + "name": "auto_relay_to_prime", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_agents_session_idx": { + "name": "session_agents_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "session_agents_session_id": { + "name": "session_agents_session_id", + "columns": ["session_id", "id"], + "isUnique": true + } + }, + "foreignKeys": { + "session_agents_session_id_sessions_id_fk": { + "name": "session_agents_session_id_sessions_id_fk", + "tableFrom": "session_agents", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_assets": { + "name": "session_assets", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_assets_session_idx": { + "name": "session_assets_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "session_assets_session_path": { + "name": "session_assets_session_path", + "columns": ["session_id", "path"], + "isUnique": true + } + }, + "foreignKeys": { + "session_assets_session_id_sessions_id_fk": { + "name": "session_assets_session_id_sessions_id_fk", + "tableFrom": "session_assets", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_views": { + "name": "session_views", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_key": { + "name": "user_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_viewed_at": { + "name": "last_viewed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_views_session_idx": { + "name": "session_views_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "session_views_session_user": { + "name": "session_views_session_user", + "columns": ["session_id", "user_key"], + "isUnique": true + } + }, + "foreignKeys": { + "session_views_session_id_sessions_id_fk": { + "name": "session_views_session_id_sessions_id_fk", + "tableFrom": "session_views", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "root_path": { + "name": "root_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'created'" + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_identity": { + "name": "user_identity", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived": { + "name": "archived", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/server/src/store/db/migrations/meta/_journal.json b/apps/server/src/store/db/migrations/meta/_journal.json index 199fcbe..303057d 100644 --- a/apps/server/src/store/db/migrations/meta/_journal.json +++ b/apps/server/src/store/db/migrations/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1782320710842, "tag": "0004_last_the_executioner", "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1782973126871, + "tag": "0005_quick_lifeguard", + "breakpoints": true } ] } diff --git a/apps/server/src/store/db/schema.ts b/apps/server/src/store/db/schema.ts index a850692..18e3714 100644 --- a/apps/server/src/store/db/schema.ts +++ b/apps/server/src/store/db/schema.ts @@ -105,6 +105,12 @@ export const sessionAgents = sqliteTable( autoRelayToPrime: integer("auto_relay_to_prime", { mode: "boolean" }) .notNull() .default(true), + /** + * Which host runs the agent: `local` (a `pi` child) or `remote` (a + * connected remote environment). Defaults to `local`; only `local` + * sub-agents are revived after a restart. + */ + host: text("host").notNull().default("local"), createdAt: text("created_at").notNull(), }, (table) => [ diff --git a/apps/server/src/store/sessionStore.ts b/apps/server/src/store/sessionStore.ts index 79a4ee4..c4769cc 100644 --- a/apps/server/src/store/sessionStore.ts +++ b/apps/server/src/store/sessionStore.ts @@ -4,6 +4,7 @@ import type { PinnedArtifact, Session, SessionConfigMeta, + SubagentHost, UpdateSessionRequest, UserIdentity, } from "@tangent/shared/contracts.ts"; @@ -49,6 +50,12 @@ export interface SessionAgent { systemPrompt?: string; /** Whether the sub-agent's replies auto-relay back to Prime. Defaults true. */ autoRelayToPrime?: boolean; + /** + * Which host runs the sub-agent: `local` (a `pi` child) or `remote` (a + * connected remote environment). Defaults to `local` on legacy rows; only + * `local` sub-agents are revived after a restart. + */ + host?: SubagentHost; createdAt: string; } @@ -69,6 +76,8 @@ export interface RecordAgentInput { systemPrompt?: string; /** Whether the sub-agent's replies auto-relay back to Prime. Defaults true. */ autoRelayToPrime?: boolean; + /** Which host runs the sub-agent (`local` default, or `remote`). */ + host?: SubagentHost; } /** diff --git a/apps/server/src/store/sqliteSessionStore.ts b/apps/server/src/store/sqliteSessionStore.ts index 0ba97a6..edf2c65 100644 --- a/apps/server/src/store/sqliteSessionStore.ts +++ b/apps/server/src/store/sqliteSessionStore.ts @@ -8,6 +8,7 @@ import type { PinnedArtifact, Session, SessionConfigMeta, + SubagentHost, UpdateSessionRequest, UserIdentity, } from "@tangent/shared/contracts.ts"; @@ -72,6 +73,7 @@ function toAgent(row: SessionAgentRow): SessionAgent { tools: parseTools(row.tools), systemPrompt: row.systemPrompt ?? undefined, autoRelayToPrime: row.autoRelayToPrime, + host: row.host as SubagentHost, createdAt: row.createdAt, }; } @@ -309,6 +311,7 @@ export class SqliteSessionStore implements SessionStore { tools, systemPrompt: agent.systemPrompt, autoRelayToPrime: agent.autoRelayToPrime, + host: agent.host, createdAt: new Date().toISOString(), }) .onConflictDoUpdate({ @@ -324,6 +327,7 @@ export class SqliteSessionStore implements SessionStore { tools, systemPrompt: agent.systemPrompt, autoRelayToPrime: agent.autoRelayToPrime, + host: agent.host, }, }) .run(); diff --git a/apps/web/src/features/user/model/userDisplay.ts b/apps/web/src/features/user/model/userDisplay.ts index 7b14304..5077d42 100644 --- a/apps/web/src/features/user/model/userDisplay.ts +++ b/apps/web/src/features/user/model/userDisplay.ts @@ -6,7 +6,7 @@ import type { UserIdentity } from "@tangent/shared/contracts"; * show. */ export const DEFAULT_USER: UserIdentity = { - email: "", + email: "maxim.ezhov@shopify.com", first_name: "John", last_name: "Smith", }; diff --git a/docs/server/agent-communication.md b/docs/server/agent-communication.md index 968aeaa..6a80c9d 100644 --- a/docs/server/agent-communication.md +++ b/docs/server/agent-communication.md @@ -8,11 +8,12 @@ Agent communication in this system is deliberately **server-mediated**. Agents never talk to each other directly. There are exactly three transports, each with a distinct job: -| Layer | Direction | Who uses it | -| ---------------------------------------------------- | ---------------------------------- | --------------------------------------------------------- | -| **Pi-RPC** (JSONL over stdin/stdout) | server ↔ a single Pi child process | `PiAgentManager` ↔ each `pi --mode rpc` subprocess | -| **Internal HTTP API** (`/internal/*` + bearer token) | Pi child → server | extension tools (orchestrator, memory, triggers, session) | -| **WebSockets** (Socket.IO rooms) | server → browser | the UI | +| Layer | Direction | Who uses it | +| ------------------------------------------------------------------ | --------------------------------------- | --------------------------------------------------------------- | +| **Pi-RPC** (JSONL over stdin/stdout) | server ↔ a single Pi child process | `PiAgentManager` ↔ each `pi --mode rpc` subprocess | +| **Internal HTTP API** (`/internal/*` + bearer token) | Pi child → server | extension tools (orchestrator, memory, triggers, session) | +| **WebSockets** (Socket.IO rooms) | server → browser | the UI | +| **Remote sub-agent transport** (Socket.IO `/remote-env` namespace) | server ↔ a connected remote environment | `RemoteEnvironmentGateway` ↔ the `@tangent/remote-subagent` SDK | The `PiAgentManager` (`apps/server/src/pi/piAgentManager.ts`) is the single hub. Every "A talks to B" path actually goes A → server → B. @@ -255,3 +256,39 @@ Pi-RPC stdin; the reply is Pi-RPC stdout → manager handler → WS out. > Trigger-fired sub-agents (see `pi/triggers/`) are spawned with > `autoRelayToPrime: false`, so they react in isolation and only reach Prime when > they explicitly call `message_prime`. + +## 5. Remote sub-agents (an alternative host) + +A sub-agent does not have to be a local `pi` child. A **remote environment** +can connect over a dedicated Socket.IO namespace (`/remote-env`) and host +sub-agents instead. It receives the same orchestration commands (`spawn`, +`message`, `kill`, read transcript) and streams the same events back, so a +remote sub-agent renders and persists exactly like a local one. + +The host is chosen **per spawn**: `spawn_subagent`'s `environment` param +(`local` default, or `remote`) flows through `POST /internal/agents/spawn` +(`environment` field). `createInternalAgentsRouter` dispatches to either +`PiAgentManager` (local) or `RemoteEnvironmentGateway` (remote), and resolves +the host of a later `message`/`kill` by checking which one owns the agent id. + +- **Server side:** `RemoteEnvironmentGateway` + (`apps/server/src/remote/remoteEnvironmentGateway.ts`) owns the `/remote-env` + namespace (bearer-authenticated with `REMOTE_ENV_TOKEN`), tracks connected + environments and a per-session roster of remote sub-agents, and exposes the + same `spawnSubagent`/`sendToAgent`/`killAgent`/`listSubagents`/`hasAgent` + surface as the manager. Inbound events are relayed through the **same** + `PiAgentHandlers` a local sub-agent uses; finalized auto-relay replies and + `message_prime`-style reports are fed into Prime via a `deliverToPrime` + callback wired to `pi.sendToAgent(sessionId, PRIME_AGENT_ID, ...)`. A room + read is answered (Socket.IO ack) from `store.getMessages`. +- **Remote side:** `@tangent/remote-subagent` (`packages/remote-subagent`) is a + thin connector SDK: it manages the connection, dispatches `spawn`/`message`/ + `kill` to user-supplied handlers, and exposes helpers to stream events, + push roster updates, report to Prime, and read the transcript. It ships **no + agent runtime** — the actual sub-agent implementation is provided later on + another agent SDK. + +The wire shapes live in `@tangent/shared/remoteSubagent.ts`, shared by both +sides so the protocol cannot drift. Remote sub-agents are **not** revived after +a server restart (`reviveSubagents` skips `host: "remote"` rows); they +re-establish when their environment reconnects. diff --git a/package.json b/package.json index 06f68f7..13dc169 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ "scripts": { "dev": "SESSIONS_ROOT=\"$PWD/.sessions\" SESSIONS_DB=\"$PWD/.sessions/tangent.db\" AGENT_BUNDLES_ROOT=\"$PWD/.agent-bundles\" GLOBAL_MEMORY_DIR=\"$PWD/.memory\" turbo run dev", "tangle:token": "tsx scripts/print-tangle-token.ts", + "minerva:token": "tsx scripts/print-minerva-token.ts", + "aquifer:example": "tsx scripts/aquifer/example.ts", "seed": "node scripts/pack-bundle.mjs && AGENT_BUNDLES_ROOT=\"$PWD/.agent-bundles\" SEED_BUNDLES_DIR=\"$PWD/examples\" pnpm --filter @tangent/server seed", "build": "turbo run build", "lint": "turbo run lint", diff --git a/packages/remote-subagent/README.md b/packages/remote-subagent/README.md new file mode 100644 index 0000000..7fb1d85 --- /dev/null +++ b/packages/remote-subagent/README.md @@ -0,0 +1,82 @@ +# @tangent/remote-subagent + +Connector SDK for hosting Tangent **remote sub-agents**. + +A Tangent session normally runs its sub-agents as local `pi` child processes. +This package lets an external **remote environment** host sub-agents instead: it +connects to the server over a dedicated Socket.IO namespace (`/remote-env`), +receives the same orchestration commands the local orchestrator uses +(`spawn` / `message` / `kill` / read transcript), and streams the same events +back. + +This package is **only the connector**. It contains no agent runtime — you +plug your own agent implementation (built on whatever agent SDK you like) into +the handlers. Unimplemented handlers throw, so the integration gap is explicit. + +## Install + +```bash +pnpm add @tangent/remote-subagent +``` + +## Usage + +```ts +import { connectRemoteEnvironment } from "@tangent/remote-subagent"; + +const client = connectRemoteEnvironment({ + url: "http://localhost:8787", + token: process.env.REMOTE_ENV_TOKEN!, + environmentId: "my-environment", + handlers: { + async onSpawn(command) { + // Stand up a sub-agent for `command.agentId` using the resolved + // tools / systemPrompt / model. Stream its output back: + client.agentEvent(command.sessionId, command.agentId, { + type: "start", + messageId: "msg-1", + }); + client.agentEvent(command.sessionId, command.agentId, { + type: "delta", + messageId: "msg-1", + delta: "Hello from the remote environment", + }); + client.agentEvent(command.sessionId, command.agentId, { + type: "end", + messageId: "msg-1", + content: "Hello from the remote environment", + thinking: "", + }); + }, + async onMessage(command) { + // Deliver `command.text` to the running sub-agent `command.agentId`. + }, + async onKill(command) { + // Tear down sub-agent `command.agentId`. + client.subagentUpdate( + command.sessionId, + command.agentId, + command.completed ? "completed" : "killed", + ); + }, + }, +}); + +// Read the shared session transcript on demand: +const messages = await client.readRoom(sessionId, 30); +``` + +## Outbound helpers + +- `client.agentEvent(sessionId, agentId, event)` — stream a single agent event + (`start` / `delta` / `thinking` / `end` / `error` / `activity` / `queue`). +- `client.subagentUpdate(sessionId, agentId, status)` — push a lifecycle change + (`active` / `completed` / `killed` / `error`). +- `client.report(sessionId, agentId, text)` — send a directed report to Prime. +- `client.readRoom(sessionId, limit?)` — read the tail of the shared transcript. +- `client.disconnect()` — close the connection. + +## Protocol + +The wire shapes live in `@tangent/shared/remoteSubagent.ts` and are shared with +the server gateway, so the protocol cannot drift between the two sides. diff --git a/packages/remote-subagent/eslint.config.js b/packages/remote-subagent/eslint.config.js new file mode 100644 index 0000000..eb8297b --- /dev/null +++ b/packages/remote-subagent/eslint.config.js @@ -0,0 +1,3 @@ +import base from "@tangent/build/eslint/base"; + +export default [{ ignores: ["node_modules"] }, ...base]; diff --git a/packages/remote-subagent/package.json b/packages/remote-subagent/package.json new file mode 100644 index 0000000..b8a42c0 --- /dev/null +++ b/packages/remote-subagent/package.json @@ -0,0 +1,27 @@ +{ + "name": "@tangent/remote-subagent", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./*.ts": "./src/*.ts", + "./*": "./src/*.ts" + }, + "prettier": "@tangent/build/prettier", + "scripts": { + "lint": "eslint .", + "typecheck": "tsc --noEmit", + "format": "prettier --write ." + }, + "dependencies": { + "@tangent/shared": "workspace:*", + "socket.io-client": "^4.8.3" + }, + "devDependencies": { + "@tangent/build": "workspace:*", + "@types/node": "^25.9.1", + "eslint": "^10.4.0", + "typescript": "^6.0.3" + } +} diff --git a/packages/remote-subagent/src/index.ts b/packages/remote-subagent/src/index.ts new file mode 100644 index 0000000..fd5a1dc --- /dev/null +++ b/packages/remote-subagent/src/index.ts @@ -0,0 +1,205 @@ +/** + * `@tangent/remote-subagent` — the connector SDK a **remote environment** + * installs to host Tangent sub-agents. + * + * This package is deliberately *just a connector*: it manages the Socket.IO + * connection to the server's remote sub-agent gateway, dispatches incoming + * orchestration commands (`spawn` / `message` / `kill`) to user-supplied + * handlers, and exposes typed helpers to stream events, roster updates, and + * reports back. It carries **no agent runtime** — the actual sub-agent + * implementation (built on another agent SDK) is provided by the host via the + * {@link RemoteEnvironmentHandlers}; unimplemented handlers throw so the gap is + * obvious. + */ + +import type { ChatMessage, SubagentStatus } from "@tangent/shared/contracts.ts"; +import { + REMOTE_ENV_NAMESPACE, + type RemoteAgentEvent, + type RemoteAgentEventPayload, + type RemoteAgentMessagePayload, + RemoteEnvEvents, + type RemoteEnvHandshake, + type RemoteKillCommand, + type RemoteMessageCommand, + type RemoteRoomReadRequest, + type RemoteRoomReadResponse, + type RemoteSpawnCommand, + type RemoteSubagentUpdatePayload, +} from "@tangent/shared/remoteSubagent.ts"; +import { io, type Socket } from "socket.io-client"; + +/** How long {@link RemoteEnvironmentClient.readRoom} waits for the server ack. */ +const READ_ROOM_TIMEOUT_MS = 10_000; + +/** + * The command surface a remote environment must implement to host sub-agents. + * These are the inbound half of the orchestration protocol; the host wires its + * own agent runtime in here. Any handler may be async. + */ +export interface RemoteEnvironmentHandlers { + /** Create a sub-agent (and optionally start its initial task). */ + onSpawn(command: RemoteSpawnCommand): void | Promise; + /** Deliver a directed message/task to an existing sub-agent. */ + onMessage(command: RemoteMessageCommand): void | Promise; + /** Terminate a sub-agent. */ + onKill(command: RemoteKillCommand): void | Promise; +} + +/** Options for {@link connectRemoteEnvironment}. */ +export interface ConnectRemoteEnvironmentOptions { + /** Base server URL, e.g. `http://localhost:8787` (namespace is appended). */ + url: string; + /** Shared bearer token the server validates against `REMOTE_ENV_TOKEN`. */ + token: string; + /** Stable id identifying this environment when several are connected. */ + environmentId: string; + /** Command handlers; any omitted handler throws when its command arrives. */ + handlers?: Partial; +} + +/** + * A connected remote environment. Holds the live Socket.IO connection and the + * outbound half of the protocol: stream events back, push roster transitions, + * report to Prime, and read the shared transcript. + */ +export interface RemoteEnvironmentClient { + /** The underlying Socket.IO connection (for connection-state listeners). */ + readonly socket: Socket; + /** Stream a single agent event (start/delta/thinking/end/...) to the server. */ + agentEvent(sessionId: string, agentId: string, event: RemoteAgentEvent): void; + /** Push a sub-agent's lifecycle status change to the server. */ + subagentUpdate( + sessionId: string, + agentId: string, + status: SubagentStatus, + ): void; + /** Send a sub-agent's directed report to Prime. */ + report(sessionId: string, agentId: string, text: string): void; + /** Read the tail of the shared session transcript (resolved via ack). */ + readRoom(sessionId: string, limit?: number): Promise; + /** Close the connection. */ + disconnect(): void; +} + +/** Throws for a handler the host did not supply. */ +function notImplemented(method: keyof RemoteEnvironmentHandlers): never { + throw new Error( + `[remote-subagent] handler "${method}" is not implemented. ` + + `Provide it via connectRemoteEnvironment({ handlers }).`, + ); +} + +/** Fills in any missing handler with a throwing stub. */ +function withDefaultHandlers( + handlers: Partial, +): RemoteEnvironmentHandlers { + return { + onSpawn: handlers.onSpawn ?? (() => notImplemented("onSpawn")), + onMessage: handlers.onMessage ?? (() => notImplemented("onMessage")), + onKill: handlers.onKill ?? (() => notImplemented("onKill")), + }; +} + +/** Runs a command handler, logging (never throwing) so the socket stays alive. */ +async function runHandler( + method: keyof RemoteEnvironmentHandlers, + run: () => void | Promise, +): Promise { + try { + await run(); + } catch (err) { + console.error(`[remote-subagent] ${method} failed:`, err); + } +} + +/** Strips a trailing slash so the namespace concatenation is well-formed. */ +function normalizeUrl(url: string): string { + return url.endsWith("/") ? url.slice(0, -1) : url; +} + +/** Wires the inbound command listeners onto the socket. */ +function registerCommandHandlers( + socket: Socket, + handlers: RemoteEnvironmentHandlers, +): void { + socket.on(RemoteEnvEvents.Spawn, (command: RemoteSpawnCommand) => { + void runHandler("onSpawn", () => handlers.onSpawn(command)); + }); + socket.on(RemoteEnvEvents.Message, (command: RemoteMessageCommand) => { + void runHandler("onMessage", () => handlers.onMessage(command)); + }); + socket.on(RemoteEnvEvents.Kill, (command: RemoteKillCommand) => { + void runHandler("onKill", () => handlers.onKill(command)); + }); +} + +/** + * Connects to the server's remote sub-agent gateway and returns a client. The + * connection authenticates with the supplied token/environmentId; inbound + * commands are routed to `handlers`, and the returned client is used to stream + * results back. + */ +export function connectRemoteEnvironment( + options: ConnectRemoteEnvironmentOptions, +): RemoteEnvironmentClient { + const handlers = withDefaultHandlers(options.handlers ?? {}); + const auth: RemoteEnvHandshake = { + token: options.token, + environmentId: options.environmentId, + }; + const socket = io(`${normalizeUrl(options.url)}${REMOTE_ENV_NAMESPACE}`, { + auth, + transports: ["websocket"], + }); + + registerCommandHandlers(socket, handlers); + + return { + socket, + agentEvent(sessionId, agentId, event) { + const payload: RemoteAgentEventPayload = { sessionId, agentId, event }; + socket.emit(RemoteEnvEvents.AgentEvent, payload); + }, + subagentUpdate(sessionId, agentId, status) { + const payload: RemoteSubagentUpdatePayload = { + sessionId, + agentId, + status, + }; + socket.emit(RemoteEnvEvents.SubagentUpdate, payload); + }, + report(sessionId, agentId, text) { + const payload: RemoteAgentMessagePayload = { sessionId, agentId, text }; + socket.emit(RemoteEnvEvents.AgentMessage, payload); + }, + readRoom(sessionId, limit) { + const request: RemoteRoomReadRequest = { sessionId, limit }; + return new Promise((resolve, reject) => { + socket + .timeout(READ_ROOM_TIMEOUT_MS) + .emit( + RemoteEnvEvents.RoomRead, + request, + (err: Error | null, response: RemoteRoomReadResponse) => { + if (err) { + reject(err); + return; + } + resolve(response.messages); + }, + ); + }); + }, + disconnect() { + socket.disconnect(); + }, + }; +} + +export type { + RemoteAgentEvent, + RemoteKillCommand, + RemoteMessageCommand, + RemoteSpawnCommand, +} from "@tangent/shared/remoteSubagent.ts"; diff --git a/packages/remote-subagent/tsconfig.json b/packages/remote-subagent/tsconfig.json new file mode 100644 index 0000000..d0d3089 --- /dev/null +++ b/packages/remote-subagent/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@tangent/build/tsconfig/node.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo", + "types": ["node"] + }, + "include": ["src"] +} diff --git a/packages/shared/src/contracts.ts b/packages/shared/src/contracts.ts index 650b71d..5720b8b 100644 --- a/packages/shared/src/contracts.ts +++ b/packages/shared/src/contracts.ts @@ -347,12 +347,22 @@ export interface UpdateTriggerRequest { /** Lifecycle status of a sub-agent, surfaced in the session's agent roster. */ export type SubagentStatus = "active" | "completed" | "killed" | "error"; +/** + * Which host runs a sub-agent: `local` (a `pi` child process managed by the + * server) or `remote` (a sub-agent hosted inside a connected remote + * environment over the remote sub-agent transport). Absent on older roster + * rows, which are treated as `local`. + */ +export type SubagentHost = "local" | "remote"; + /** A sub-agent in a session's roster, as tracked for the UI sidebar. */ export interface SubagentInfo { /** Stable id; also used as the sub-agent's `ChatAuthor.id`. */ id: string; name: string; status: SubagentStatus; + /** Which host runs the sub-agent. Defaults to `local` when omitted. */ + host?: SubagentHost; /** Template the sub-agent was spawned from, if any. */ template?: string; /** The `provider/model` id this sub-agent runs, when set (else server default). */ diff --git a/packages/shared/src/remoteSubagent.ts b/packages/shared/src/remoteSubagent.ts new file mode 100644 index 0000000..9e58d2b --- /dev/null +++ b/packages/shared/src/remoteSubagent.ts @@ -0,0 +1,157 @@ +/** + * Wire contracts for the **remote sub-agent** transport: the Socket.IO protocol + * between the dev server's `RemoteEnvironmentGateway` and a connected remote + * environment (the `@tangent/remote-subagent` SDK). + * + * A remote environment is an alternative *host* for sub-agents. Where a local + * sub-agent is a `pi` child process driven over stdin/stdout, a remote + * sub-agent lives inside a connected environment that receives the same + * orchestration commands (`spawn` / `message` / `kill` / read transcript) and + * streams the same events back. Both the server gateway and the SDK import + * these shapes so the wire never drifts. + */ + +import type { + AgentActivity, + ChatMessage, + MessageDelivery, + SubagentStatus, + ThinkingLevel, +} from "./contracts.ts"; + +/** + * Socket.IO namespace a remote environment connects to. Kept distinct from the + * default namespace the browser UI uses so the two audiences never collide. + */ +export const REMOTE_ENV_NAMESPACE = "/remote-env"; + +/** + * Credentials a remote environment presents in the Socket.IO connection + * `auth` payload. The gateway validates `token` against its configured + * `REMOTE_ENV_TOKEN` before accepting the connection, and uses `environmentId` + * to address commands at a specific environment when several are connected. + */ +export interface RemoteEnvHandshake { + environmentId: string; + token: string; +} + +/** + * Socket.IO event names for the remote sub-agent protocol. Server->remote names + * carry orchestration commands; remote->server names carry streamed events, + * roster transitions, reports to Prime, and ack-style transcript reads. + */ +export const RemoteEnvEvents = { + /** server -> remote: create a sub-agent in the remote environment. */ + Spawn: "remote:spawn", + /** server -> remote: deliver a directed message/task to a sub-agent. */ + Message: "remote:message", + /** server -> remote: terminate a sub-agent. */ + Kill: "remote:kill", + /** remote -> server: a streamed agent event (start/delta/end/...). */ + AgentEvent: "remote:agent-event", + /** remote -> server: a sub-agent's lifecycle status change. */ + SubagentUpdate: "remote:subagent-update", + /** remote -> server: a sub-agent's directed report to Prime. */ + AgentMessage: "remote:agent-message", + /** remote -> server (ack): read the shared session transcript. */ + RoomRead: "remote:room:read", +} as const; + +export type RemoteEnvEvent = + (typeof RemoteEnvEvents)[keyof typeof RemoteEnvEvents]; + +/** + * server -> remote: create a sub-agent. The server resolves the effective + * config (tools/prompt/model/thinking) before sending, so the remote + * environment receives a fully-resolved spec and never needs the server's + * template/bundle machinery. + */ +export interface RemoteSpawnCommand { + sessionId: string; + /** Server-assigned id; the remote environment must echo it on every event. */ + agentId: string; + name: string; + /** Resolved tool allowlist. */ + tools: string[]; + /** Resolved appended system prompt. */ + systemPrompt: string; + /** Resolved `provider/model` id, or undefined to use the environment default. */ + model?: string; + /** Resolved thinking depth, or undefined to use the environment default. */ + thinkingDepth?: ThinkingLevel; + /** Template the sub-agent was resolved from, if any (informational). */ + template?: string; + /** Optional initial task to start the sub-agent working immediately. */ + task?: string; + /** Whether finalized replies are auto-relayed back to Prime. */ + autoRelayToPrime: boolean; +} + +/** server -> remote: deliver a directed message/task to a remote sub-agent. */ +export interface RemoteMessageCommand { + sessionId: string; + agentId: string; + text: string; + delivery: MessageDelivery; +} + +/** server -> remote: terminate a remote sub-agent. */ +export interface RemoteKillCommand { + sessionId: string; + agentId: string; + /** True when the sub-agent finished its work (vs. being aborted). */ + completed: boolean; +} + +/** + * A streamed event from a remote sub-agent. Mirrors the server's internal + * streaming union so the gateway can translate it straight into the existing + * chat-relay path: `start`/`delta`/`end` of a single assistant message are + * correlated by `messageId`. + */ +export type RemoteAgentEvent = + | { type: "start"; messageId: string } + | { type: "delta"; messageId: string; delta: string } + | { type: "thinking"; messageId: string; delta: string } + | { type: "end"; messageId: string; content: string; thinking: string } + | { type: "error"; messageId?: string; message: string } + | { type: "activity"; activity: AgentActivity | null } + | { type: "queue"; steering: string[]; followUp: string[] }; + +/** remote -> server: a streamed {@link RemoteAgentEvent}, tagged with its agent. */ +export interface RemoteAgentEventPayload { + sessionId: string; + agentId: string; + event: RemoteAgentEvent; +} + +/** remote -> server: a remote sub-agent's lifecycle status change. */ +export interface RemoteSubagentUpdatePayload { + sessionId: string; + agentId: string; + status: SubagentStatus; +} + +/** + * remote -> server: a remote sub-agent's directed report to Prime (the + * `message_prime` equivalent). Surfaced in the sub-agent's own thread and + * relayed into Prime. + */ +export interface RemoteAgentMessagePayload { + sessionId: string; + agentId: string; + text: string; +} + +/** remote -> server (ack request): read the shared session transcript. */ +export interface RemoteRoomReadRequest { + sessionId: string; + /** Max number of most recent messages to return. */ + limit?: number; +} + +/** server -> remote (ack response): the tail of the shared session transcript. */ +export interface RemoteRoomReadResponse { + messages: ChatMessage[]; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 27d2329..f9d8be5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -313,6 +313,28 @@ importers: specifier: ^8.60.0 version: 8.60.0(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3) + packages/remote-subagent: + dependencies: + '@tangent/shared': + specifier: workspace:* + version: link:../shared + socket.io-client: + specifier: ^4.8.3 + version: 4.8.3 + devDependencies: + '@tangent/build': + specifier: workspace:* + version: link:../build + '@types/node': + specifier: ^25.9.1 + version: 25.9.1 + eslint: + specifier: ^10.4.0 + version: 10.4.0(jiti@2.7.0) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + packages/shared: dependencies: zod: diff --git a/turbo.json b/turbo.json index df4dcf9..4c2c45e 100644 --- a/turbo.json +++ b/turbo.json @@ -18,7 +18,16 @@ "TANGENT_INTERNAL_TOKEN", "AUTH_JWT_TOKEN_COOKIE_NAME", "TANGLE_API_URL", - "TANGLE_TOKEN" + "TANGLE_TOKEN", + "AQUIFER_TOKEN", + "AQUIFER_GATEWAY", + "AQUIFER_SANDBOX_IMAGE", + "AQUIFER_RESOURCE_CLASS", + "AQUIFER_WORLD_ZONES", + "MINERVA_TOKEN", + "OASIS_BASE_URL", + "TANGENT_PUBLIC_URL", + "AQUIFER_SESSION_PROFILE" ], "tasks": { "build": {