Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions apps/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
97 changes: 97 additions & 0 deletions apps/server/src/external/externalSubagentGateway.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
136 changes: 136 additions & 0 deletions apps/server/src/external/externalSubagentGateway.ts
Original file line number Diff line number Diff line change
@@ -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<string, Map<string, ExternalSubagent>>();

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<string, ExternalSubagent> {
const existing = this.sessions.get(sessionId);
if (existing) return existing;
const created = new Map<string, ExternalSubagent>();
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 };
}
}
73 changes: 63 additions & 10 deletions apps/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);

Expand All @@ -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.
Expand All @@ -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).
Expand All @@ -133,6 +184,8 @@ registerChatHandlers(
io,
store,
pi,
remoteGateway,
externalGateway,
memory,
onMemoryRemembered,
triggerEngine,
Expand Down
Loading
Loading