From 626a85d14ab32a73d5e3757a7701a03beb252db7 Mon Sep 17 00:00:00 2001 From: Maksym Yezhov Date: Tue, 11 Aug 2026 10:29:50 -0700 Subject: [PATCH 01/18] refactor: move to unified model --- .../external/externalSubagentGateway.test.ts | 13 + .../src/external/externalSubagentGateway.ts | 19 +- apps/server/src/pi/piAgentManager.test.ts | 18 + apps/server/src/pi/utils.ts | 6 +- .../remote/remoteEnvironmentGateway.test.ts | 66 ++++ .../src/remote/remoteEnvironmentGateway.ts | 13 +- apps/server/src/routes/internalAgents.ts | 1 + .../db/migrations/0006_fresh_nico_minoru.sql | 11 + .../db/migrations/meta/0006_snapshot.json | 364 ++++++++++++++++++ .../store/db/migrations/meta/_journal.json | 7 + apps/server/src/store/db/schema.ts | 9 + apps/server/src/store/inMemorySessionStore.ts | 23 +- apps/server/src/store/sessionStore.ts | 45 ++- .../src/store/sqliteSessionStore.test.ts | 54 +++ apps/server/src/store/sqliteSessionStore.ts | 64 ++- packages/shared/src/contracts.ts | 117 +++++- 16 files changed, 780 insertions(+), 50 deletions(-) create mode 100644 apps/server/src/remote/remoteEnvironmentGateway.test.ts create mode 100644 apps/server/src/store/db/migrations/0006_fresh_nico_minoru.sql create mode 100644 apps/server/src/store/db/migrations/meta/0006_snapshot.json diff --git a/apps/server/src/external/externalSubagentGateway.test.ts b/apps/server/src/external/externalSubagentGateway.test.ts index b1f922c..8a4e7f4 100644 --- a/apps/server/src/external/externalSubagentGateway.test.ts +++ b/apps/server/src/external/externalSubagentGateway.test.ts @@ -38,6 +38,19 @@ test("register records a roster entry and surfaces it as active", () => { assert.equal(info?.status, "active"); }); +test("the roster describes an external sub-agent as owned by its bundle tool", () => { + const h = makeHarness(); + h.gateway.register("s1", { name: "worker" }); + + // Tangent creates the far side in `world_spawn` and destroys it in + // `world_terminate`, so the participant is owned, not attached. + assert.deepEqual(h.gateway.listSubagents("s1")[0].connector, { + kind: "external-inbound", + lifecycle: "owned", + spawnAuthority: "bundle-tool", + }); +}); + test("pushEvent relays a streamed event into the tab", () => { const h = makeHarness(); const { id } = h.gateway.register("s1", { name: "worker" }); diff --git a/apps/server/src/external/externalSubagentGateway.ts b/apps/server/src/external/externalSubagentGateway.ts index 39d1bde..0ac9cc6 100644 --- a/apps/server/src/external/externalSubagentGateway.ts +++ b/apps/server/src/external/externalSubagentGateway.ts @@ -1,9 +1,10 @@ import { randomUUID } from "node:crypto"; -import type { - SubagentInfo, - SubagentStatus, - ThinkingLevel, +import { + connectorFields, + type SubagentInfo, + type SubagentStatus, + type ThinkingLevel, } from "@tangent/shared/contracts.ts"; import type { RemoteAgentEvent } from "@tangent/shared/remoteSubagent.ts"; @@ -34,7 +35,7 @@ function toInfo(subagent: ExternalSubagent): SubagentInfo { id: subagent.agentId, name: subagent.name, status: subagent.status, - host: "external", + ...connectorFields("external-inbound"), template: subagent.template, model: subagent.model, thinkingDepth: subagent.thinkingDepth, @@ -51,9 +52,11 @@ function toInfo(subagent: ExternalSubagent): SubagentInfo { * * 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}. + * it, and `setStatus` marks its lifecycle. Its connector is `external-inbound`: + * the far side is created and destroyed by the bundle tool driving it, so the + * participant is owned rather than attached. Sits alongside {@link + * import("../remote/remoteEnvironmentGateway.ts").RemoteEnvironmentGateway} and + * {@link import("../pi/piAgentManager.ts").PiAgentManager}. */ export class ExternalSubagentGateway { private readonly handlers: PiAgentHandlers; diff --git a/apps/server/src/pi/piAgentManager.test.ts b/apps/server/src/pi/piAgentManager.test.ts index 17eeacf..d1764e6 100644 --- a/apps/server/src/pi/piAgentManager.test.ts +++ b/apps/server/src/pi/piAgentManager.test.ts @@ -6,6 +6,8 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, mock, test } from "node:test"; +import { connectorFor } from "@tangent/shared/contracts.ts"; + import type { SessionAgent } from "../store/sessionStore.ts"; import type { MemoryManager } from "./memory.ts"; import { PiAgentManager, PRIME_AGENT_ID } from "./piAgentManager.ts"; @@ -93,6 +95,7 @@ function agentRow(overrides: Partial): SessionAgent { name: "Worker", status: "active", autoRelayToPrime: true, + connector: connectorFor("pi-stdio"), createdAt: new Date().toISOString(), ...overrides, }; @@ -219,6 +222,21 @@ test("supervisor auto-respawns a crashed agent with backoff, then gives up", () assert.equal(pi.hasAgent("s1", PRIME_AGENT_ID), false); }); +test("the local roster describes its connector", () => { + const { pi } = makeManager(); + pi.ensure("s1", "/tmp/s1"); + const { info } = pi.spawnSubagent("s1", { name: "Worker" }); + + const expected = { + kind: "pi-stdio", + lifecycle: "owned", + spawnAuthority: "server", + }; + assert.deepEqual(info.connector, expected); + assert.equal(info.host, "local"); + assert.deepEqual(pi.listSubagents("s1")[0].connector, expected); +}); + test("an intentional kill is not auto-respawned", () => { const { pi, spawns } = makeManager(); pi.ensure("s1", "/tmp/s1"); diff --git a/apps/server/src/pi/utils.ts b/apps/server/src/pi/utils.ts index 7fefdec..72ba2d5 100644 --- a/apps/server/src/pi/utils.ts +++ b/apps/server/src/pi/utils.ts @@ -1,7 +1,10 @@ import path from "node:path"; import { StringDecoder } from "node:string_decoder"; -import type { SubagentInfo } from "@tangent/shared/contracts.ts"; +import { + connectorFields, + type SubagentInfo, +} from "@tangent/shared/contracts.ts"; import type { AgentDescriptor, @@ -147,6 +150,7 @@ export function toSubagentInfo(agent: AgentProcess): SubagentInfo { id: agent.agentId, name: agent.name, status: agent.status, + ...connectorFields("pi-stdio"), ...(agent.template ? { template: agent.template } : {}), ...(agent.config.model ? { model: agent.config.model } : {}), ...(agent.config.thinkingDepth diff --git a/apps/server/src/remote/remoteEnvironmentGateway.test.ts b/apps/server/src/remote/remoteEnvironmentGateway.test.ts new file mode 100644 index 0000000..023448e --- /dev/null +++ b/apps/server/src/remote/remoteEnvironmentGateway.test.ts @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import type { Server as SocketIOServer, Socket } from "socket.io"; + +import type { PiAgentHandlers } from "../pi/types.ts"; +import type { SessionStore } from "../store/sessionStore.ts"; +import { RemoteEnvironmentGateway } from "./remoteEnvironmentGateway.ts"; + +/** + * A gateway wired to a fake namespace, plus a `connect` that registers an + * environment by driving the captured connection handler (bypassing the token + * middleware, which is not what these tests are about). + */ +function makeHarness() { + let onConnection: ((socket: Socket) => void) | undefined; + const namespace = { + use: () => {}, + on: (event: string, handler: (socket: Socket) => void) => { + if (event === "connection") onConnection = handler; + }, + }; + + const handlers: PiAgentHandlers = { + onAgentEvent: () => {}, + onSubagentUpdate: () => {}, + onAgentMessage: () => {}, + onSessionStatus: () => {}, + }; + const store = { getMessages: async () => [] } as unknown as SessionStore; + + const gateway = new RemoteEnvironmentGateway( + { of: () => namespace } as unknown as SocketIOServer, + handlers, + store, + () => {}, + ); + + const connect = (environmentId: string): void => { + const socket = { + handshake: { auth: { environmentId } }, + on: () => {}, + emit: () => {}, + } as unknown as Socket; + onConnection?.(socket); + }; + + return { gateway, connect }; +} + +test("the remote roster describes its connector and environment", () => { + const h = makeHarness(); + h.connect("env-1"); + + const { info } = h.gateway.spawnSubagent("s1", { name: "Worker" }); + + const expected = { + kind: "remote-env", + lifecycle: "owned", + spawnAuthority: "remote-env", + environmentId: "env-1", + }; + assert.deepEqual(info.connector, expected); + assert.equal(info.host, "remote"); + assert.deepEqual(h.gateway.listSubagents("s1")[0].connector, expected); +}); diff --git a/apps/server/src/remote/remoteEnvironmentGateway.ts b/apps/server/src/remote/remoteEnvironmentGateway.ts index 5c8031c..b983509 100644 --- a/apps/server/src/remote/remoteEnvironmentGateway.ts +++ b/apps/server/src/remote/remoteEnvironmentGateway.ts @@ -1,10 +1,11 @@ import { randomUUID } from "node:crypto"; -import type { - ChatAuthor, - MessageDelivery, - SubagentInfo, - SubagentStatus, +import { + type ChatAuthor, + connectorFields, + type MessageDelivery, + type SubagentInfo, + type SubagentStatus, } from "@tangent/shared/contracts.ts"; import { REMOTE_ENV_NAMESPACE, @@ -77,7 +78,7 @@ function toInfo(subagent: RemoteSubagent): SubagentInfo { id: subagent.agentId, name: subagent.name, status: subagent.status, - host: "remote", + ...connectorFields("remote-env", subagent.environmentId), template: subagent.template, model: subagent.model, thinkingDepth: subagent.thinkingDepth, diff --git a/apps/server/src/routes/internalAgents.ts b/apps/server/src/routes/internalAgents.ts index 3a8fd18..11a8275 100644 --- a/apps/server/src/routes/internalAgents.ts +++ b/apps/server/src/routes/internalAgents.ts @@ -134,6 +134,7 @@ function handleSpawn( systemPrompt, autoRelayToPrime, host, + connector: info.connector, }); res.json({ subagent: info }); } catch (err) { diff --git a/apps/server/src/store/db/migrations/0006_fresh_nico_minoru.sql b/apps/server/src/store/db/migrations/0006_fresh_nico_minoru.sql new file mode 100644 index 0000000..17d4a77 --- /dev/null +++ b/apps/server/src/store/db/migrations/0006_fresh_nico_minoru.sql @@ -0,0 +1,11 @@ +ALTER TABLE `session_agents` ADD `connector_kind` text;--> statement-breakpoint +ALTER TABLE `session_agents` ADD `connector_lifecycle` text;--> statement-breakpoint +ALTER TABLE `session_agents` ADD `connector_environment_id` text;--> statement-breakpoint +UPDATE `session_agents` SET + `connector_kind` = CASE `host` + WHEN 'remote' THEN 'remote-env' + WHEN 'external' THEN 'external-inbound' + ELSE 'pi-stdio' + END, + `connector_lifecycle` = 'owned' +WHERE `connector_kind` IS NULL; diff --git a/apps/server/src/store/db/migrations/meta/0006_snapshot.json b/apps/server/src/store/db/migrations/meta/0006_snapshot.json new file mode 100644 index 0000000..e40b2bc --- /dev/null +++ b/apps/server/src/store/db/migrations/meta/0006_snapshot.json @@ -0,0 +1,364 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "1564ffb2-f809-481c-ac44-320b83280fa8", + "prevId": "755df5f0-b36a-4712-aff4-1bb6347caa15", + "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'" + }, + "connector_kind": { + "name": "connector_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_lifecycle": { + "name": "connector_lifecycle", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_environment_id": { + "name": "connector_environment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "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 303057d..6c58b4c 100644 --- a/apps/server/src/store/db/migrations/meta/_journal.json +++ b/apps/server/src/store/db/migrations/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1782973126871, "tag": "0005_quick_lifeguard", "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1786467242333, + "tag": "0006_fresh_nico_minoru", + "breakpoints": true } ] } diff --git a/apps/server/src/store/db/schema.ts b/apps/server/src/store/db/schema.ts index 18e3714..024ef7c 100644 --- a/apps/server/src/store/db/schema.ts +++ b/apps/server/src/store/db/schema.ts @@ -111,6 +111,15 @@ export const sessionAgents = sqliteTable( * sub-agents are revived after a restart. */ host: text("host").notNull().default("local"), + /** + * The agent's connector facets (`ConnectorDescriptor`), backfilled from + * `host`. Null on rows written before they existed, which the store reads + * back through `host`. `spawnAuthority` is not stored: it follows from the + * kind, and persisting it would let the two disagree. + */ + connectorKind: text("connector_kind"), + connectorLifecycle: text("connector_lifecycle"), + connectorEnvironmentId: text("connector_environment_id"), createdAt: text("created_at").notNull(), }, (table) => [ diff --git a/apps/server/src/store/inMemorySessionStore.ts b/apps/server/src/store/inMemorySessionStore.ts index c7cda8a..f37e9da 100644 --- a/apps/server/src/store/inMemorySessionStore.ts +++ b/apps/server/src/store/inMemorySessionStore.ts @@ -4,6 +4,7 @@ import path from "node:path"; import type { ChatMessage, + ConnectorDescriptor, PinnedArtifact, Session, SessionConfigMeta, @@ -11,12 +12,13 @@ import type { } from "@tangent/shared/contracts.ts"; import { ARTIFACTS_DIRNAME, SESSIONS_ROOT } from "../config.ts"; -import type { - CreateSessionParams, - RecordAgentInput, - SessionAgent, - SessionAgentStatus, - SessionStore, +import { + connectorFromHost, + type CreateSessionParams, + type RecordAgentInput, + type SessionAgent, + type SessionAgentStatus, + type SessionStore, } from "./sessionStore.ts"; /** Id of the orchestrating Prime agent (mirrors `pi/types.ts`). */ @@ -35,6 +37,14 @@ function definedAgentFields( return out; } +/** The connector to store: explicit, else the prior one, else derived from `host`. */ +function mergeConnector( + agent: RecordAgentInput, + prior: SessionAgent | undefined, +): ConnectorDescriptor { + return agent.connector ?? prior?.connector ?? connectorFromHost(agent.host); +} + /** * Builds the next stored agent. Mirrors the SQLite store's upsert semantics: * an omitted (undefined) field leaves the prior value untouched, so a partial @@ -53,6 +63,7 @@ function mergeAgent( role: agent.role, name: agent.name, status: agent.status ?? prior?.status ?? "active", + connector: mergeConnector(agent, prior), createdAt: prior?.createdAt ?? new Date().toISOString(), }; } diff --git a/apps/server/src/store/sessionStore.ts b/apps/server/src/store/sessionStore.ts index c4769cc..e6e0937 100644 --- a/apps/server/src/store/sessionStore.ts +++ b/apps/server/src/store/sessionStore.ts @@ -1,12 +1,15 @@ -import type { - AgentRole, - ChatMessage, - PinnedArtifact, - Session, - SessionConfigMeta, - SubagentHost, - UpdateSessionRequest, - UserIdentity, +import { + type AgentRole, + type ChatMessage, + type ConnectorDescriptor, + connectorFor, + type ConnectorKind, + type PinnedArtifact, + type Session, + type SessionConfigMeta, + type SubagentHost, + type UpdateSessionRequest, + type UserIdentity, } from "@tangent/shared/contracts.ts"; /** @@ -16,6 +19,24 @@ import type { */ export type SessionAgentStatus = "active" | "killed" | "error"; +/** The connector kind each legacy `host` label stood for. */ +const CONNECTOR_KIND_BY_HOST: Record = { + local: "pi-stdio", + remote: "remote-env", + external: "external-inbound", +}; + +/** + * The connector a roster row's legacy `host` label describes. Used for rows + * written before the connector columns existed, and as the default for a row + * recorded without a descriptor. + */ +export function connectorFromHost( + host: SubagentHost | undefined, +): ConnectorDescriptor { + return connectorFor(host ? CONNECTOR_KIND_BY_HOST[host] : "pi-stdio"); +} + /** * Input accepted by {@link SessionStore.createSession}: the public wire request * plus the server-resolved {@link UserIdentity} (from the creator's Oktasso JWT), @@ -54,8 +75,12 @@ export interface SessionAgent { * 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. + * + * @deprecated Read {@link SessionAgent.connector} instead. */ host?: SubagentHost; + /** The connector that runs the agent; derived from `host` on legacy rows. */ + connector: ConnectorDescriptor; createdAt: string; } @@ -78,6 +103,8 @@ export interface RecordAgentInput { autoRelayToPrime?: boolean; /** Which host runs the sub-agent (`local` default, or `remote`). */ host?: SubagentHost; + /** The connector running the agent; omitted leaves the stored one in place. */ + connector?: ConnectorDescriptor; } /** diff --git a/apps/server/src/store/sqliteSessionStore.test.ts b/apps/server/src/store/sqliteSessionStore.test.ts index 8e485b2..eab03d4 100644 --- a/apps/server/src/store/sqliteSessionStore.test.ts +++ b/apps/server/src/store/sqliteSessionStore.test.ts @@ -50,6 +50,60 @@ test("markViewed upserts and isolates read state per user", async () => { assert.equal(a.get(session.id), "2026-03-01T00:00:00.000Z"); }); +test("recordAgent round-trips a connector descriptor", async () => { + const store = newStore(); + const session = await store.createSession({ name: "S" }); + + const recorded = await store.recordAgent(session.id, { + id: "sub-1", + role: "subagent", + name: "Worker", + host: "remote", + connector: { + kind: "remote-env", + lifecycle: "owned", + spawnAuthority: "remote-env", + environmentId: "env-1", + }, + }); + + const expected = { + kind: "remote-env", + lifecycle: "owned", + spawnAuthority: "remote-env", + environmentId: "env-1", + }; + assert.deepEqual(recorded.connector, expected); + const agents = await store.listAgents(session.id); + assert.deepEqual(agents.find((a) => a.id === "sub-1")?.connector, expected); +}); + +test("a row recorded without a connector reads back from its host", async () => { + const store = newStore(); + const session = await store.createSession({ name: "S" }); + + // Exactly the shape of a row written before the connector columns existed: + // `host` set, connector columns null. + const recorded = await store.recordAgent(session.id, { + id: "legacy", + role: "subagent", + name: "Old", + host: "remote", + }); + + assert.deepEqual(recorded.connector, { + kind: "remote-env", + lifecycle: "owned", + spawnAuthority: "remote-env", + }); + + // Prime is recorded by `createSession` with no host at all. + const prime = (await store.listAgents(session.id)).find( + (a) => a.id === "prime", + ); + assert.equal(prime?.connector.kind, "pi-stdio"); +}); + test("deleting a session cascades its read state", async () => { const store = newStore(); const session = await store.createSession({ name: "S" }); diff --git a/apps/server/src/store/sqliteSessionStore.ts b/apps/server/src/store/sqliteSessionStore.ts index edf2c65..7371f12 100644 --- a/apps/server/src/store/sqliteSessionStore.ts +++ b/apps/server/src/store/sqliteSessionStore.ts @@ -2,15 +2,19 @@ import { randomUUID } from "node:crypto"; import { mkdir } from "node:fs/promises"; import path from "node:path"; -import type { - AgentRole, - ChatMessage, - PinnedArtifact, - Session, - SessionConfigMeta, - SubagentHost, - UpdateSessionRequest, - UserIdentity, +import { + type AgentRole, + type ChatMessage, + type ConnectorDescriptor, + connectorFor, + type ConnectorKind, + type ConnectorLifecycle, + type PinnedArtifact, + type Session, + type SessionConfigMeta, + type SubagentHost, + type UpdateSessionRequest, + type UserIdentity, } from "@tangent/shared/contracts.ts"; import { and, asc, count, eq } from "drizzle-orm"; @@ -28,12 +32,13 @@ import { sessions, sessionViews, } from "./db/schema.ts"; -import type { - CreateSessionParams, - RecordAgentInput, - SessionAgent, - SessionAgentStatus, - SessionStore, +import { + connectorFromHost, + type CreateSessionParams, + type RecordAgentInput, + type SessionAgent, + type SessionAgentStatus, + type SessionStore, } from "./sessionStore.ts"; /** Id of the orchestrating Prime agent (mirrors `pi/types.ts`). */ @@ -58,6 +63,32 @@ function toSession(row: SessionRow): Session { }; } +/** + * Reads a row's connector facets, falling back to the legacy `host` label for + * rows written before the connector columns existed. + */ +function toConnector(row: SessionAgentRow): ConnectorDescriptor { + if (!row.connectorKind) return connectorFromHost(row.host as SubagentHost); + return { + ...connectorFor(row.connectorKind as ConnectorKind), + ...(row.connectorLifecycle + ? { lifecycle: row.connectorLifecycle as ConnectorLifecycle } + : {}), + ...(row.connectorEnvironmentId + ? { environmentId: row.connectorEnvironmentId } + : {}), + }; +} + +/** The connector columns a record-agent input writes; omitted leaves them as is. */ +function connectorColumns(connector: ConnectorDescriptor | undefined) { + return { + connectorKind: connector?.kind, + connectorLifecycle: connector?.lifecycle, + connectorEnvironmentId: connector?.environmentId, + }; +} + /** Maps a session_agents row onto the {@link SessionAgent} domain type. */ function toAgent(row: SessionAgentRow): SessionAgent { return { @@ -74,6 +105,7 @@ function toAgent(row: SessionAgentRow): SessionAgent { systemPrompt: row.systemPrompt ?? undefined, autoRelayToPrime: row.autoRelayToPrime, host: row.host as SubagentHost, + connector: toConnector(row), createdAt: row.createdAt, }; } @@ -312,6 +344,7 @@ export class SqliteSessionStore implements SessionStore { systemPrompt: agent.systemPrompt, autoRelayToPrime: agent.autoRelayToPrime, host: agent.host, + ...connectorColumns(agent.connector), createdAt: new Date().toISOString(), }) .onConflictDoUpdate({ @@ -328,6 +361,7 @@ export class SqliteSessionStore implements SessionStore { systemPrompt: agent.systemPrompt, autoRelayToPrime: agent.autoRelayToPrime, host: agent.host, + ...connectorColumns(agent.connector), }, }) .run(); diff --git a/packages/shared/src/contracts.ts b/packages/shared/src/contracts.ts index 5720b8b..69fde86 100644 --- a/packages/shared/src/contracts.ts +++ b/packages/shared/src/contracts.ts @@ -349,11 +349,113 @@ 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`. + * server), `remote` (a sub-agent hosted inside a connected remote environment + * over the Socket.IO remote sub-agent transport), or `external` (a sub-agent + * driven by a connected standalone bridge over the internal HTTP/SSE external + * transport). Absent on older roster rows, which are treated as `local`. + * + * @deprecated Superseded by {@link ConnectorDescriptor}, which keeps the + * decisions this label bundles apart. Retained until the `host` column is gone. + */ +export type SubagentHost = "local" | "remote" | "external"; + +/** + * Which transport a connector drives its participant over: `pi-stdio` (a `pi` + * child process the server owns), `remote-env` (a sub-agent inside a connected + * remote environment), `external-inbound` (a runtime outside Tangent that + * streams in over the internal HTTP/SSE API), or `a2a` (an agent reached over + * the A2A protocol). */ -export type SubagentHost = "local" | "remote"; +export type ConnectorKind = + | "pi-stdio" + | "remote-env" + | "external-inbound" + | "a2a"; + +/** + * Whether Tangent created the participant and is responsible for destroying it + * (`owned`) or joined one that already existed and outlives the attachment + * (`attached`). + */ +export type ConnectorLifecycle = "owned" | "attached"; + +/** Who may create a participant on a connector, if anyone. */ +export type SpawnAuthority = "server" | "remote-env" | "bundle-tool" | "none"; + +/** + * The independent facets of the connector behind a participant. These are + * separate fields rather than one label because they vary independently — the + * three combinations in the tree today are a coincidence of having only three + * connectors. + */ +export interface ConnectorDescriptor { + kind: ConnectorKind; + lifecycle: ConnectorLifecycle; + spawnAuthority: SpawnAuthority; + /** The remote environment this participant is bound to, when it has one. */ + environmentId?: string; +} + +/** + * The facets each connector kind runs with today. Adding a connector adds a row + * here; the facets stay independent fields on {@link ConnectorDescriptor}, so a + * combination this table does not list is still expressible. + */ +export const CONNECTOR_FACETS: Record< + ConnectorKind, + Omit +> = { + "pi-stdio": { + kind: "pi-stdio", + lifecycle: "owned", + spawnAuthority: "server", + }, + "remote-env": { + kind: "remote-env", + lifecycle: "owned", + spawnAuthority: "remote-env", + }, + "external-inbound": { + kind: "external-inbound", + lifecycle: "owned", + spawnAuthority: "bundle-tool", + }, + a2a: { kind: "a2a", lifecycle: "attached", spawnAuthority: "none" }, +}; + +/** The legacy {@link SubagentHost} label each kind collapsed to. */ +const LEGACY_HOST: Record = { + "pi-stdio": "local", + "remote-env": "remote", + "external-inbound": "external", + a2a: undefined, +}; + +/** Builds a connector descriptor, optionally bound to a remote environment. */ +export function connectorFor( + kind: ConnectorKind, + environmentId?: string, +): ConnectorDescriptor { + return { + ...CONNECTOR_FACETS[kind], + ...(environmentId ? { environmentId } : {}), + }; +} + +/** + * Builds the connector fields of a roster entry: the descriptor plus the + * deprecated `host` label derived from it. + */ +export function connectorFields( + kind: ConnectorKind, + environmentId?: string, +): Pick { + const host = LEGACY_HOST[kind]; + return { + connector: connectorFor(kind, environmentId), + ...(host ? { host } : {}), + }; +} /** A sub-agent in a session's roster, as tracked for the UI sidebar. */ export interface SubagentInfo { @@ -361,7 +463,12 @@ export interface SubagentInfo { id: string; name: string; status: SubagentStatus; - /** Which host runs the sub-agent. Defaults to `local` when omitted. */ + /** The connector that runs the sub-agent. */ + connector: ConnectorDescriptor; + /** + * @deprecated Derived from {@link SubagentInfo.connector}; kept so clients + * still reading the coarse host label keep working. + */ host?: SubagentHost; /** Template the sub-agent was spawned from, if any. */ template?: string; From 63a274ac721d81c79d5733405167c1143f6cf0bc Mon Sep 17 00:00:00 2001 From: Maksym Yezhov Date: Tue, 11 Aug 2026 11:44:32 -0700 Subject: [PATCH 02/18] - refactor: connectorRegistry with total resolution --- .../src/connectors/connectorRegistry.test.ts | 191 ++++++++++++++++++ .../src/connectors/connectorRegistry.ts | 89 ++++++++ .../src/connectors/externalConnector.ts | 53 +++++ apps/server/src/connectors/nullConnector.ts | 40 ++++ apps/server/src/connectors/piConnector.ts | 48 +++++ apps/server/src/connectors/refusal.ts | 23 +++ .../src/connectors/remoteEnvConnector.ts | 50 +++++ apps/server/src/connectors/types.ts | 53 +++++ apps/server/src/index.ts | 19 +- apps/server/src/routes/internalAgents.ts | 135 +++++-------- apps/server/src/sockets/chat.ts | 55 ++--- packages/shared/src/contracts.ts | 25 ++- 12 files changed, 656 insertions(+), 125 deletions(-) create mode 100644 apps/server/src/connectors/connectorRegistry.test.ts create mode 100644 apps/server/src/connectors/connectorRegistry.ts create mode 100644 apps/server/src/connectors/externalConnector.ts create mode 100644 apps/server/src/connectors/nullConnector.ts create mode 100644 apps/server/src/connectors/piConnector.ts create mode 100644 apps/server/src/connectors/refusal.ts create mode 100644 apps/server/src/connectors/remoteEnvConnector.ts create mode 100644 apps/server/src/connectors/types.ts diff --git a/apps/server/src/connectors/connectorRegistry.test.ts b/apps/server/src/connectors/connectorRegistry.test.ts new file mode 100644 index 0000000..ce84e71 --- /dev/null +++ b/apps/server/src/connectors/connectorRegistry.test.ts @@ -0,0 +1,191 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + connectorFields, + type SubagentInfo, +} from "@tangent/shared/contracts.ts"; + +import { ExternalSubagentGateway } from "../external/externalSubagentGateway.ts"; +import type { PiAgentManager } from "../pi/piAgentManager.ts"; +import type { PiAgentHandlers } from "../pi/types.ts"; +import type { RemoteEnvironmentGateway } from "../remote/remoteEnvironmentGateway.ts"; +import { createConnectorRegistry } from "./connectorRegistry.ts"; + +/** A message a fake gateway was asked to deliver. */ +interface Delivery { + sessionId: string; + agentId: string; + text: string; +} + +/** A message the server surfaced into a conversation. */ +interface Surfaced { + conversationId: string; + author: string; + content: string; +} + +function rosterEntry( + id: string, + kind: "pi-stdio" | "remote-env", +): SubagentInfo { + return { + id, + name: id, + status: "active", + ...connectorFields(kind), + createdAt: "2026-01-01T00:00:00.000Z", + }; +} + +/** + * A registry over the real external gateway plus fakes for the local and remote + * transports, so a delivery can be traced to exactly one of them. + */ +function makeHarness() { + const surfaced: Surfaced[] = []; + const handlers: PiAgentHandlers = { + onAgentEvent: () => {}, + onSubagentUpdate: () => {}, + onAgentMessage: (_sessionId, conversationId, author, content) => + surfaced.push({ conversationId, author: author.name, content }), + onSessionStatus: () => {}, + }; + + const piDeliveries: Delivery[] = []; + const piKills: string[] = []; + const pi = { + hasAgent: (_sessionId: string, agentId: string) => agentId === "local-1", + listSubagents: () => [rosterEntry("local-1", "pi-stdio")], + sendToAgent: (sessionId: string, agentId: string, text: string) => + piDeliveries.push({ sessionId, agentId, text }), + killAgent: (_sessionId: string, agentId: string) => piKills.push(agentId), + } as unknown as PiAgentManager; + + const remoteDeliveries: Delivery[] = []; + const remoteGateway = { + hasAgent: (_sessionId: string, agentId: string) => agentId === "remote-1", + listSubagents: () => [rosterEntry("remote-1", "remote-env")], + sendToAgent: (sessionId: string, agentId: string, text: string) => + remoteDeliveries.push({ sessionId, agentId, text }), + killAgent: () => {}, + } as unknown as RemoteEnvironmentGateway; + + const externalGateway = new ExternalSubagentGateway(handlers); + const connectors = createConnectorRegistry( + pi, + remoteGateway, + externalGateway, + handlers, + ); + + return { + connectors, + externalGateway, + surfaced, + piDeliveries, + piKills, + remoteDeliveries, + }; +} + +test("resolution is total: an unheld participant gets a refusing connector", () => { + const h = makeHarness(); + + const connector = h.connectors.resolve("s1", "ghost"); + + assert.equal(connector.descriptor.kind, "unresolved"); + assert.equal(connector.acceptsDelivery, false); +}); + +test("a message to an unknown participant is refused in its own conversation", () => { + const h = makeHarness(); + + const result = h.connectors.resolve("s1", "ghost").deliver({ + sessionId: "s1", + participantId: "ghost", + text: "are you there", + }); + + assert.equal(result.delivered, false); + // The failure belongs to the conversation it was addressed to, not Prime's. + assert.equal(h.surfaced.at(-1)?.conversationId, "ghost"); + assert.equal(h.surfaced.at(-1)?.author, "System"); + assert.deepEqual(h.piDeliveries, []); +}); + +test("a message aimed at an external participant never reaches the local agents", () => { + const h = makeHarness(); + const { id } = h.externalGateway.register("s1", { name: "worker" }); + + const result = h.connectors.resolve("s1", id).deliver({ + sessionId: "s1", + participantId: id, + text: "do the thing", + }); + + assert.equal(result.delivered, false); + assert.equal(h.surfaced.at(-1)?.conversationId, id); + assert.deepEqual(h.piDeliveries, []); +}); + +test("a message aimed at a remote participant reaches the remote gateway", () => { + const h = makeHarness(); + + const result = h.connectors.resolve("s1", "remote-1").deliver({ + sessionId: "s1", + participantId: "remote-1", + text: "do the thing", + }); + + assert.equal(result.delivered, true); + assert.deepEqual(h.remoteDeliveries, [ + { sessionId: "s1", agentId: "remote-1", text: "do the thing" }, + ]); + assert.deepEqual(h.piDeliveries, []); +}); + +test("a message aimed at a local participant reaches the Pi manager", () => { + const h = makeHarness(); + + const result = h.connectors.resolve("s1", "local-1").deliver({ + sessionId: "s1", + participantId: "local-1", + text: "do the thing", + }); + + assert.equal(result.delivered, true); + assert.deepEqual(h.piDeliveries, [ + { sessionId: "s1", agentId: "local-1", text: "do the thing" }, + ]); +}); + +test("killing an external participant reaches its gateway", () => { + const h = makeHarness(); + const { id } = h.externalGateway.register("s1", { name: "worker" }); + + h.connectors.resolve("s1", id).kill("s1", id, true); + + assert.equal(h.externalGateway.hasAgent("s1", id), false); + assert.deepEqual(h.piKills, []); +}); + +test("list walks every connector's roster", () => { + const h = makeHarness(); + const { id } = h.externalGateway.register("s1", { name: "worker" }); + + assert.deepEqual( + h.connectors.list("s1").map((s) => s.id), + ["local-1", "remote-1", id], + ); +}); + +test("only connectors the spawn API may act on are spawners", () => { + const h = makeHarness(); + + assert.ok(h.connectors.spawner("pi-stdio")); + assert.ok(h.connectors.spawner("remote-env")); + assert.equal(h.connectors.spawner("external-inbound"), undefined); + assert.equal(h.connectors.spawner("a2a"), undefined); +}); diff --git a/apps/server/src/connectors/connectorRegistry.ts b/apps/server/src/connectors/connectorRegistry.ts new file mode 100644 index 0000000..098b3a0 --- /dev/null +++ b/apps/server/src/connectors/connectorRegistry.ts @@ -0,0 +1,89 @@ +import type { + ConnectorKind, + SpawnAuthority, + SubagentInfo, +} from "@tangent/shared/contracts.ts"; + +import type { ExternalSubagentGateway } from "../external/externalSubagentGateway.ts"; +import type { PiAgentManager } from "../pi/piAgentManager.ts"; +import type { PiAgentHandlers } from "../pi/types.ts"; +import type { RemoteEnvironmentGateway } from "../remote/remoteEnvironmentGateway.ts"; +import { ExternalConnector } from "./externalConnector.ts"; +import { NullConnector } from "./nullConnector.ts"; +import { PiConnector } from "./piConnector.ts"; +import { RemoteEnvConnector } from "./remoteEnvConnector.ts"; +import type { Connector } from "./types.ts"; + +/** + * The spawn authorities the server may act on for a caller. `bundle-tool` and + * `none` participants exist only because something else created them, so the + * spawn API refuses them however the request is phrased. + */ +const API_SPAWN_AUTHORITIES: SpawnAuthority[] = ["server", "remote-env"]; + +/** A connector that can create participants, narrowed so `spawn` is callable. */ +export type SpawningConnector = Connector & { + spawn: NonNullable; +}; + +/** Whether the spawn API may create participants on this connector. */ +function canSpawn(connector: Connector): connector is SpawningConnector { + if (!connector.spawn) return false; + return API_SPAWN_AUTHORITIES.includes(connector.descriptor.spawnAuthority); +} + +/** + * The set of connectors a session's participants can live on, and the single + * lookup that maps a participant to one of them. + * + * {@link ConnectorRegistry.resolve} is **total**: every participant id resolves + * to a connector, an unknown one to a {@link NullConnector} that refuses in the + * addressed conversation. Callers therefore never re-derive routing, and there + * is no fall-through branch for a participant to be mis-delivered down. + */ +export class ConnectorRegistry { + private readonly connectors: Connector[]; + private readonly fallback: Connector; + + constructor(connectors: Connector[], fallback: Connector) { + this.connectors = connectors; + this.fallback = fallback; + } + + /** The connector holding `participantId`, or the refusing fallback. */ + resolve(sessionId: string, participantId: string): Connector { + const held = this.connectors.find((connector) => + connector.has(sessionId, participantId), + ); + return held ?? this.fallback; + } + + /** Every connector's roster for the session, in registration order. */ + list(sessionId: string): SubagentInfo[] { + return this.connectors.flatMap((connector) => connector.list(sessionId)); + } + + /** The connector that spawns `kind` on the server's behalf, if any may. */ + spawner(kind: ConnectorKind): SpawningConnector | undefined { + const connector = this.connectors.find((c) => c.descriptor.kind === kind); + if (!connector || !canSpawn(connector)) return undefined; + return connector; + } +} + +/** Builds the registry over the gateways the server runs today. */ +export function createConnectorRegistry( + pi: PiAgentManager, + remoteGateway: RemoteEnvironmentGateway, + externalGateway: ExternalSubagentGateway, + handlers: PiAgentHandlers, +): ConnectorRegistry { + return new ConnectorRegistry( + [ + new PiConnector(pi), + new RemoteEnvConnector(remoteGateway), + new ExternalConnector(externalGateway, handlers), + ], + new NullConnector(handlers), + ); +} diff --git a/apps/server/src/connectors/externalConnector.ts b/apps/server/src/connectors/externalConnector.ts new file mode 100644 index 0000000..8bafd3f --- /dev/null +++ b/apps/server/src/connectors/externalConnector.ts @@ -0,0 +1,53 @@ +import { connectorFor } from "@tangent/shared/contracts.ts"; + +import type { ExternalSubagentGateway } from "../external/externalSubagentGateway.ts"; +import type { PiAgentHandlers } from "../pi/types.ts"; +import { refuseDelivery } from "./refusal.ts"; +import type { Connector, DeliveryRequest, DeliveryResult } from "./types.ts"; + +/** Shown in the sub-agent's own thread when a message cannot reach it. */ +const NO_INBOUND_CHANNEL = + "This sub-agent runs outside Tangent, so it can't receive messages here."; + +/** + * The connector for external sub-agent tabs, whose work runs outside Tangent + * and streams in over the internal external-agents API. A thin adapter over + * {@link ExternalSubagentGateway}, which is unchanged. + * + * Traffic is inbound only: the driving bundle tool owns the far side, so there + * is no channel to deliver a message back over. That is declared rather than + * left to a missing method, so a message aimed here is refused in this tab + * instead of falling through to the local agent map. + */ +export class ExternalConnector implements Connector { + readonly descriptor = connectorFor("external-inbound"); + readonly acceptsDelivery = false; + + private readonly gateway: ExternalSubagentGateway; + private readonly handlers: PiAgentHandlers; + + constructor(gateway: ExternalSubagentGateway, handlers: PiAgentHandlers) { + this.gateway = gateway; + this.handlers = handlers; + } + + has(sessionId: string, participantId: string): boolean { + return this.gateway.hasAgent(sessionId, participantId); + } + + list(sessionId: string) { + return this.gateway.listSubagents(sessionId); + } + + deliver(request: DeliveryRequest): DeliveryResult { + return refuseDelivery(this.handlers, request, NO_INBOUND_CHANNEL); + } + + kill(sessionId: string, participantId: string, completed: boolean): void { + this.gateway.setStatus( + sessionId, + participantId, + completed ? "completed" : "killed", + ); + } +} diff --git a/apps/server/src/connectors/nullConnector.ts b/apps/server/src/connectors/nullConnector.ts new file mode 100644 index 0000000..b98867c --- /dev/null +++ b/apps/server/src/connectors/nullConnector.ts @@ -0,0 +1,40 @@ +import { connectorFor, type SubagentInfo } from "@tangent/shared/contracts.ts"; + +import type { PiAgentHandlers } from "../pi/types.ts"; +import { refuseDelivery } from "./refusal.ts"; +import type { Connector, DeliveryRequest, DeliveryResult } from "./types.ts"; + +/** Shown in the addressed conversation when no connector holds the participant. */ +const NOT_AVAILABLE = + "This agent is no longer available, so the message wasn't delivered."; + +/** + * The connector a registry answers with when no other one holds the + * participant. It exists so resolution is total: an unknown id gets a refusal + * in the conversation it was addressed to, rather than a silent fall-through + * into whichever transport happens to be checked last. + */ +export class NullConnector implements Connector { + readonly descriptor = connectorFor("unresolved"); + readonly acceptsDelivery = false; + + private readonly handlers: PiAgentHandlers; + + constructor(handlers: PiAgentHandlers) { + this.handlers = handlers; + } + + has(): boolean { + return false; + } + + list(): SubagentInfo[] { + return []; + } + + deliver(request: DeliveryRequest): DeliveryResult { + return refuseDelivery(this.handlers, request, NOT_AVAILABLE); + } + + kill(): void {} +} diff --git a/apps/server/src/connectors/piConnector.ts b/apps/server/src/connectors/piConnector.ts new file mode 100644 index 0000000..28daa89 --- /dev/null +++ b/apps/server/src/connectors/piConnector.ts @@ -0,0 +1,48 @@ +import { connectorFor } from "@tangent/shared/contracts.ts"; + +import type { SubagentSpawnRequest } from "../pi/agentConfig.ts"; +import type { PiAgentManager, SpawnedSubagent } from "../pi/piAgentManager.ts"; +import type { Connector, DeliveryRequest, DeliveryResult } from "./types.ts"; + +/** + * The connector for agents running as `pi` child processes the server owns — + * every session's Prime and its local sub-agents. A thin adapter over {@link + * PiAgentManager}, which is unchanged. + */ +export class PiConnector implements Connector { + readonly descriptor = connectorFor("pi-stdio"); + readonly acceptsDelivery = true; + + private readonly pi: PiAgentManager; + + constructor(pi: PiAgentManager) { + this.pi = pi; + } + + has(sessionId: string, participantId: string): boolean { + return this.pi.hasAgent(sessionId, participantId); + } + + list(sessionId: string) { + return this.pi.listSubagents(sessionId); + } + + deliver(request: DeliveryRequest): DeliveryResult { + this.pi.sendToAgent( + request.sessionId, + request.participantId, + request.text, + request.surfaceAuthor, + request.delivery, + ); + return { delivered: true }; + } + + spawn(sessionId: string, request: SubagentSpawnRequest): SpawnedSubagent { + return this.pi.spawnSubagent(sessionId, request); + } + + kill(sessionId: string, participantId: string, completed: boolean): void { + this.pi.killAgent(sessionId, participantId, completed); + } +} diff --git a/apps/server/src/connectors/refusal.ts b/apps/server/src/connectors/refusal.ts new file mode 100644 index 0000000..5219c13 --- /dev/null +++ b/apps/server/src/connectors/refusal.ts @@ -0,0 +1,23 @@ +import { SYSTEM_AUTHOR } from "@tangent/shared/contracts.ts"; + +import type { PiAgentHandlers } from "../pi/types.ts"; +import type { DeliveryRequest, DeliveryResult } from "./types.ts"; + +/** + * Refuses a delivery and says so in the conversation it was aimed at, rather + * than in Prime's. The sender learns from the result; the user sees the reason + * in the thread where the message was meant to land. + */ +export function refuseDelivery( + handlers: PiAgentHandlers, + request: DeliveryRequest, + reason: string, +): DeliveryResult { + handlers.onAgentMessage( + request.sessionId, + request.participantId, + SYSTEM_AUTHOR, + reason, + ); + return { delivered: false, reason }; +} diff --git a/apps/server/src/connectors/remoteEnvConnector.ts b/apps/server/src/connectors/remoteEnvConnector.ts new file mode 100644 index 0000000..c41622c --- /dev/null +++ b/apps/server/src/connectors/remoteEnvConnector.ts @@ -0,0 +1,50 @@ +import { connectorFor } from "@tangent/shared/contracts.ts"; + +import type { SubagentSpawnRequest } from "../pi/agentConfig.ts"; +import type { SpawnedSubagent } from "../pi/piAgentManager.ts"; +import type { RemoteEnvironmentGateway } from "../remote/remoteEnvironmentGateway.ts"; +import type { Connector, DeliveryRequest, DeliveryResult } from "./types.ts"; + +/** + * The connector for sub-agents hosted inside a connected remote environment. A + * thin adapter over {@link RemoteEnvironmentGateway}, which is unchanged. Its + * descriptor carries no `environmentId` — that belongs to each participant's + * roster entry, not to the connector as a whole. + */ +export class RemoteEnvConnector implements Connector { + readonly descriptor = connectorFor("remote-env"); + readonly acceptsDelivery = true; + + private readonly gateway: RemoteEnvironmentGateway; + + constructor(gateway: RemoteEnvironmentGateway) { + this.gateway = gateway; + } + + has(sessionId: string, participantId: string): boolean { + return this.gateway.hasAgent(sessionId, participantId); + } + + list(sessionId: string) { + return this.gateway.listSubagents(sessionId); + } + + deliver(request: DeliveryRequest): DeliveryResult { + this.gateway.sendToAgent( + request.sessionId, + request.participantId, + request.text, + request.surfaceAuthor, + request.delivery, + ); + return { delivered: true }; + } + + spawn(sessionId: string, request: SubagentSpawnRequest): SpawnedSubagent { + return this.gateway.spawnSubagent(sessionId, request); + } + + kill(sessionId: string, participantId: string, completed: boolean): void { + this.gateway.killAgent(sessionId, participantId, completed); + } +} diff --git a/apps/server/src/connectors/types.ts b/apps/server/src/connectors/types.ts new file mode 100644 index 0000000..afe7647 --- /dev/null +++ b/apps/server/src/connectors/types.ts @@ -0,0 +1,53 @@ +import type { + ChatAuthor, + ConnectorDescriptor, + MessageDelivery, + SubagentInfo, +} from "@tangent/shared/contracts.ts"; + +import type { SubagentSpawnRequest } from "../pi/agentConfig.ts"; +import type { SpawnedSubagent } from "../pi/piAgentManager.ts"; + +/** A message addressed to one participant, as a connector receives it. */ +export interface DeliveryRequest { + sessionId: string; + participantId: string; + text: string; + /** + * Surfaces the message in the participant's own transcript attributed to this + * author. Omitted by internal relays, which are already surfaced elsewhere. + */ + surfaceAuthor?: ChatAuthor; + delivery?: MessageDelivery; +} + +/** What became of a delivery. `reason` is set only when it was refused. */ +export interface DeliveryResult { + delivered: boolean; + reason?: string; +} + +/** + * One way of reaching participants: a transport plus the roster of participants + * it currently holds. A registry resolves a participant to exactly one of + * these, so every caller routes through the same lookup instead of re-deriving + * the transport from a host label. + * + * `deliver` is required of every connector. One that cannot accept a message + * declares {@link Connector.acceptsDelivery} false and refuses, because an + * absent method is a compile-time refusal while an untaken branch is a runtime + * mis-delivery — and the tree has had both. + */ +export interface Connector { + readonly descriptor: ConnectorDescriptor; + /** Whether this connector can carry a message to its participants at all. */ + readonly acceptsDelivery: boolean; + has(sessionId: string, participantId: string): boolean; + list(sessionId: string): SubagentInfo[]; + deliver(request: DeliveryRequest): DeliveryResult; + /** Present only where {@link ConnectorDescriptor.spawnAuthority} allows it. */ + spawn?(sessionId: string, request: SubagentSpawnRequest): SpawnedSubagent; + kill(sessionId: string, participantId: string, completed: boolean): void; + /** Restores a participant after a restart. PR 1.4 fills this in. */ + revive?(sessionId: string, participantId: string): void; +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 3b6312a..0423ae7 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -6,6 +6,7 @@ import express from "express"; import { Server as SocketIOServer } from "socket.io"; import { PORT } from "./config.ts"; +import { createConnectorRegistry } from "./connectors/connectorRegistry.ts"; import { ExternalSubagentGateway } from "./external/externalSubagentGateway.ts"; import { RelayRegistry } from "./mcp/relayRegistry.ts"; import { errorHandler } from "./middleware/errorHandler.ts"; @@ -108,6 +109,16 @@ const remoteGateway = new RemoteEnvironmentGateway( // tab via the same relay handlers a local sub-agent uses. const externalGateway = new ExternalSubagentGateway(agentHandlers); +// The single lookup from a participant to the connector that reaches it. Every +// spawn/message/kill/list route goes through it, so an id no connector holds is +// refused in its own conversation instead of falling through to the local Pi. +const connectors = createConnectorRegistry( + pi, + remoteGateway, + externalGateway, + 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. @@ -143,10 +154,7 @@ 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, remoteGateway, externalGateway), -); +app.use("/internal/agents", createInternalAgentsRouter(store, pi, connectors)); // 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( @@ -184,8 +192,7 @@ registerChatHandlers( io, store, pi, - remoteGateway, - externalGateway, + connectors, memory, onMemoryRemembered, triggerEngine, diff --git a/apps/server/src/routes/internalAgents.ts b/apps/server/src/routes/internalAgents.ts index 11a8275..83c1ff7 100644 --- a/apps/server/src/routes/internalAgents.ts +++ b/apps/server/src/routes/internalAgents.ts @@ -1,16 +1,16 @@ -import { PI_AGENT, type SubagentHost } from "@tangent/shared/contracts.ts"; +import { + connectorFields, + type ConnectorKind, + PI_AGENT, +} from "@tangent/shared/contracts.ts"; import { type Response, Router } from "express"; import { z } from "zod"; -import type { ExternalSubagentGateway } from "../external/externalSubagentGateway.ts"; +import type { ConnectorRegistry } from "../connectors/connectorRegistry.ts"; import { requireInternalToken } from "../middleware/requireInternalToken.ts"; import { getValidated, validate } from "../middleware/validate.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 { parseThinkingLevel } from "../pi/agentConfig.ts"; +import type { PiAgentManager } from "../pi/piAgentManager.ts"; import type { SessionStore } from "../store/sessionStore.ts"; /** Spawn a sub-agent; `sessionId` and `name` identify and label it. */ @@ -65,49 +65,32 @@ export type RoomQuery = z.infer; const DEFAULT_ROOM_LIMIT = 30; const MAX_ROOM_LIMIT = 200; -/** - * 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); +/** The connector a spawn request's `environment` names. */ +function spawnKind(environment: SpawnInput["environment"]): ConnectorKind { + return environment === "remote" ? "remote-env" : "pi-stdio"; } /** - * 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. + * Spawns a sub-agent (resolving its model/thinking) on the connector its + * requested environment names and persists it so the roster survives a restart. + * Extracted from the router so the route function stays small. */ function handleSpawn( store: SessionStore, - hosts: AgentHosts, + connectors: ConnectorRegistry, body: SpawnInput, res: Response, ): void { + const kind = spawnKind(body.environment); + const connector = connectors.spawner(kind); + if (!connector) { + res.status(400).json({ error: `Cannot spawn a ${kind} sub-agent.` }); + return; + } + try { - const host = resolveHost(body.environment); - const { info, tools, systemPrompt, autoRelayToPrime } = spawnOnHost( - hosts, + const { host } = connectorFields(kind); + const { info, tools, systemPrompt, autoRelayToPrime } = connector.spawn( body.sessionId, { name: body.name, @@ -119,7 +102,6 @@ function handleSpawn( task: body.task, environment: host, }, - host, ); void store.recordAgent(body.sessionId, { id: info.id, @@ -144,53 +126,52 @@ function handleSpawn( /** Surfaces a Prime-issued directive in the sub-agent's transcript. */ function handleMessage( - hosts: AgentHosts, + connectors: ConnectorRegistry, body: MessageInput, res: Response, ): void { // Attributed to Prime (message_subagent is always a Prime-issued directive). - // 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 }); + const { delivered, reason } = connectors.resolve(sessionId, agentId).deliver({ + sessionId, + participantId: agentId, + text, + surfaceAuthor: PI_AGENT, + }); + res.json({ ok: delivered, ...(reason ? { error: reason } : {}) }); } /** Surfaces a sub-agent's report in its own thread and delivers it to Prime. */ function handleReport( - hosts: AgentHosts, + pi: PiAgentManager, body: ReportInput, res: Response, ): void { // message_prime is a sub-agent-issued update; Prime reacts immediately. - hosts.pi.reportToPrime(body.sessionId, body.agentId, body.text); + pi.reportToPrime(body.sessionId, body.agentId, body.text); res.json({ ok: true }); } /** Terminates a sub-agent, optionally marking its work completed. */ -function handleKill(hosts: AgentHosts, body: KillInput, res: Response): void { +function handleKill( + connectors: ConnectorRegistry, + 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); - } + connectors + .resolve(sessionId, agentId) + .kill(sessionId, agentId, body.completed ?? false); res.json({ ok: true }); } -/** 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), - ], - }); +/** Lists the sub-agents registered for a session across every connector. */ +function handleList( + connectors: ConnectorRegistry, + query: ListQuery, + res: Response, +): void { + res.json({ subagents: connectors.list(query.sessionId) }); } /** Returns the tail of the shared transcript, clamped to the room limit. */ @@ -218,37 +199,31 @@ async function handleRoom( export function createInternalAgentsRouter( store: SessionStore, pi: PiAgentManager, - remoteGateway: RemoteEnvironmentGateway, - externalGateway: ExternalSubagentGateway, + connectors: ConnectorRegistry, ): 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, hosts, getValidated(req).body, res), + handleSpawn(store, connectors, getValidated(req).body, res), ); router.post("/message", validate({ body: messageSchema }), (req, res) => - handleMessage(hosts, getValidated(req).body, res), + handleMessage(connectors, getValidated(req).body, res), ); router.post("/report", validate({ body: reportSchema }), (req, res) => - handleReport(hosts, getValidated(req).body, res), + handleReport(pi, getValidated(req).body, res), ); router.post("/kill", validate({ body: killSchema }), (req, res) => - handleKill(hosts, getValidated(req).body, res), + handleKill(connectors, getValidated(req).body, res), ); router.get("/list", validate({ query: listQuerySchema }), (req, res) => handleList( - hosts, + connectors, getValidated(req).query, res, ), diff --git a/apps/server/src/sockets/chat.ts b/apps/server/src/sockets/chat.ts index 5bbd09d..e461e75 100644 --- a/apps/server/src/sockets/chat.ts +++ b/apps/server/src/sockets/chat.ts @@ -38,7 +38,7 @@ import { } from "@tangent/shared/contracts.ts"; import type { Server, Socket } from "socket.io"; -import type { ExternalSubagentGateway } from "../external/externalSubagentGateway.ts"; +import type { ConnectorRegistry } from "../connectors/connectorRegistry.ts"; import { parseThinkingLevel } from "../pi/agentConfig.ts"; import type { MemoryManager } from "../pi/memory.ts"; import { @@ -52,7 +52,6 @@ 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, @@ -514,8 +513,7 @@ interface ChatHandlerDeps { io: Server; store: SessionStore; pi: PiAgentManager; - remoteGateway: RemoteEnvironmentGateway; - externalGateway: ExternalSubagentGateway; + connectors: ConnectorRegistry; memory: MemoryManager; onRemembered: MemoryRememberedHandler; triggerEngine: TriggerEngine; @@ -524,23 +522,15 @@ interface ChatHandlerDeps { /** 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 { io, store, pi, connectors, memory } = deps; const { onRemembered, triggerEngine, emitUiCommand } = deps; socket.on(SocketEvents.ChatJoin, (payload: ChatJoinPayload) => - handleChatJoin( - socket, - store, - pi, - remoteGateway, - externalGateway, - triggerEngine, - payload, - ), + handleChatJoin(socket, store, pi, connectors, triggerEngine, payload), ); socket.on(SocketEvents.ChatMessage, (payload: ChatMessagePayload) => - handleChatMessage(io, socket, store, pi, payload), + handleChatMessage(io, socket, store, pi, connectors, payload), ); socket.on(SocketEvents.AgentAbort, (payload: AgentAbortPayload) => @@ -584,8 +574,7 @@ export function registerChatHandlers( io: Server, store: SessionStore, pi: PiAgentManager, - remoteGateway: RemoteEnvironmentGateway, - externalGateway: ExternalSubagentGateway, + connectors: ConnectorRegistry, memory: MemoryManager, onRemembered: MemoryRememberedHandler, triggerEngine: TriggerEngine, @@ -595,8 +584,7 @@ export function registerChatHandlers( io, store, pi, - remoteGateway, - externalGateway, + connectors, memory, onRemembered, triggerEngine, @@ -693,27 +681,12 @@ async function ensureSessionAgents( 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, + connectors: ConnectorRegistry, triggerEngine: TriggerEngine, payload: ChatJoinPayload, ): Promise { @@ -738,7 +711,7 @@ async function handleChatJoin( const roster: SubagentRosterPayload = { sessionId: session.id, - subagents: mergedSubagents(pi, remoteGateway, externalGateway, session.id), + subagents: connectors.list(session.id), }; socket.emit(SocketEvents.SubagentRoster, roster); @@ -831,6 +804,7 @@ async function handleChatMessage( socket: Socket, store: SessionStore, pi: PiAgentManager, + connectors: ConnectorRegistry, payload: ChatMessagePayload, ): Promise { const session = await store.getSession(payload?.sessionId); @@ -860,7 +834,7 @@ async function handleChatMessage( await store.appendMessage(userMessage); io.to(room).emit(SocketEvents.ChatMessage, userMessage); - // Relay the message into the session's Pi process, surfacing any attached + // Relay the message to the conversation's participant, surfacing any attached // files by their workspace-relative path so the agent knows to read them. The // reply streams back asynchronously through the agent event handler. // `delivery` controls whether a mid-run message steers (before the next LLM @@ -871,5 +845,10 @@ async function handleChatMessage( pi.prompt(session.id, session.rootPath, text, delivery); return; } - pi.sendToAgent(session.id, conversationId, text, undefined, delivery); + connectors.resolve(session.id, conversationId).deliver({ + sessionId: session.id, + participantId: conversationId, + text, + delivery, + }); } diff --git a/packages/shared/src/contracts.ts b/packages/shared/src/contracts.ts index 69fde86..f9c945d 100644 --- a/packages/shared/src/contracts.ts +++ b/packages/shared/src/contracts.ts @@ -204,6 +204,18 @@ export const TRIGGER_AUTHOR: ChatAuthor = { agentRole: "prime", }; +/** + * Author attributed to messages the server itself emits into a conversation, + * such as a message that could not be delivered to its participant. Surfaced in + * the conversation it concerns so the failure is visible where it happened. + */ +export const SYSTEM_AUTHOR: ChatAuthor = { + id: "system", + kind: "agent", + name: "System", + agentRole: "prime", +}; + /** Which external signal drives a trigger. */ export type TriggerKind = "schedule" | "callback"; @@ -365,12 +377,17 @@ export type SubagentHost = "local" | "remote" | "external"; * remote environment), `external-inbound` (a runtime outside Tangent that * streams in over the internal HTTP/SSE API), or `a2a` (an agent reached over * the A2A protocol). + * + * `unresolved` is the kind of a participant no connector claims. It exists so + * resolution is total — the server answers with a connector that refuses rather + * than with `undefined` — and is never persisted or spawned. */ export type ConnectorKind = | "pi-stdio" | "remote-env" | "external-inbound" - | "a2a"; + | "a2a" + | "unresolved"; /** * Whether Tangent created the participant and is responsible for destroying it @@ -421,6 +438,11 @@ export const CONNECTOR_FACETS: Record< spawnAuthority: "bundle-tool", }, a2a: { kind: "a2a", lifecycle: "attached", spawnAuthority: "none" }, + unresolved: { + kind: "unresolved", + lifecycle: "attached", + spawnAuthority: "none", + }, }; /** The legacy {@link SubagentHost} label each kind collapsed to. */ @@ -429,6 +451,7 @@ const LEGACY_HOST: Record = { "remote-env": "remote", "external-inbound": "external", a2a: undefined, + unresolved: undefined, }; /** Builds a connector descriptor, optionally bound to a remote environment. */ From 573d6b15a589f5be385b6facf3ad1cfa0092cb11 Mon Sep 17 00:00:00 2001 From: Maksym Yezhov Date: Tue, 11 Aug 2026 13:09:00 -0700 Subject: [PATCH 03/18] - refactor: Run as an entity --- .../src/connectors/connectorRegistry.test.ts | 53 +- .../src/connectors/connectorRegistry.ts | 13 +- .../src/connectors/externalConnector.ts | 15 +- apps/server/src/connectors/nullConnector.ts | 16 +- apps/server/src/connectors/piConnector.ts | 22 +- .../src/connectors/remoteEnvConnector.ts | 19 +- apps/server/src/connectors/types.ts | 30 +- .../external/externalSubagentGateway.test.ts | 80 ++- .../src/external/externalSubagentGateway.ts | 113 ++++- apps/server/src/index.ts | 23 +- apps/server/src/pi/piAgentManager.test.ts | 156 +++++- apps/server/src/pi/piAgentManager.ts | 142 ++++-- apps/server/src/pi/triggers/triggerEngine.ts | 17 +- apps/server/src/pi/types.ts | 20 +- .../remote/remoteEnvironmentGateway.test.ts | 3 + .../src/remote/remoteEnvironmentGateway.ts | 70 ++- apps/server/src/routes/internalAgents.ts | 4 +- .../src/routes/internalExternalAgents.ts | 58 ++- apps/server/src/runs/runRegistry.test.ts | 159 ++++++ apps/server/src/runs/runRegistry.ts | 167 +++++++ apps/server/src/sockets/chat.ts | 43 +- .../migrations/0007_complex_puppet_master.sql | 17 + .../db/migrations/meta/0007_snapshot.json | 473 ++++++++++++++++++ .../store/db/migrations/meta/_journal.json | 7 + apps/server/src/store/db/schema.ts | 40 ++ apps/server/src/store/inMemoryRunStore.ts | 65 +++ apps/server/src/store/runStore.ts | 49 ++ apps/server/src/store/sqliteRunStore.ts | 97 ++++ .../src/features/chat/hooks/useSessionChat.ts | 90 +++- packages/remote-subagent/src/index.ts | 29 +- packages/shared/src/contracts.ts | 82 ++- packages/shared/src/remoteSubagent.ts | 15 + 32 files changed, 2082 insertions(+), 105 deletions(-) create mode 100644 apps/server/src/runs/runRegistry.test.ts create mode 100644 apps/server/src/runs/runRegistry.ts create mode 100644 apps/server/src/store/db/migrations/0007_complex_puppet_master.sql create mode 100644 apps/server/src/store/db/migrations/meta/0007_snapshot.json create mode 100644 apps/server/src/store/inMemoryRunStore.ts create mode 100644 apps/server/src/store/runStore.ts create mode 100644 apps/server/src/store/sqliteRunStore.ts diff --git a/apps/server/src/connectors/connectorRegistry.test.ts b/apps/server/src/connectors/connectorRegistry.test.ts index ce84e71..3169bf1 100644 --- a/apps/server/src/connectors/connectorRegistry.test.ts +++ b/apps/server/src/connectors/connectorRegistry.test.ts @@ -10,6 +10,8 @@ import { ExternalSubagentGateway } from "../external/externalSubagentGateway.ts" import type { PiAgentManager } from "../pi/piAgentManager.ts"; import type { PiAgentHandlers } from "../pi/types.ts"; import type { RemoteEnvironmentGateway } from "../remote/remoteEnvironmentGateway.ts"; +import { RunRegistry } from "../runs/runRegistry.ts"; +import { InMemoryRunStore } from "../store/inMemoryRunStore.ts"; import { createConnectorRegistry } from "./connectorRegistry.ts"; /** A message a fake gateway was asked to deliver. */ @@ -55,12 +57,18 @@ function makeHarness() { const piDeliveries: Delivery[] = []; const piKills: string[] = []; + const piAborts: string[] = []; const pi = { hasAgent: (_sessionId: string, agentId: string) => agentId === "local-1", listSubagents: () => [rosterEntry("local-1", "pi-stdio")], sendToAgent: (sessionId: string, agentId: string, text: string) => piDeliveries.push({ sessionId, agentId, text }), killAgent: (_sessionId: string, agentId: string) => piKills.push(agentId), + // Mirrors the real manager: only a busy agent has anything to cancel. + abort: (_sessionId: string, agentId: string) => { + piAborts.push(agentId); + return agentId === "local-1"; + }, } as unknown as PiAgentManager; const remoteDeliveries: Delivery[] = []; @@ -72,7 +80,10 @@ function makeHarness() { killAgent: () => {}, } as unknown as RemoteEnvironmentGateway; - const externalGateway = new ExternalSubagentGateway(handlers); + const externalGateway = new ExternalSubagentGateway( + handlers, + new RunRegistry(new InMemoryRunStore()), + ); const connectors = createConnectorRegistry( pi, remoteGateway, @@ -86,6 +97,7 @@ function makeHarness() { surfaced, piDeliveries, piKills, + piAborts, remoteDeliveries, }; } @@ -161,6 +173,45 @@ test("a message aimed at a local participant reaches the Pi manager", () => { ]); }); +test("cancelling a local participant's run reaches the Pi manager", () => { + const h = makeHarness(); + + const result = h.connectors.cancelRun({ + sessionId: "s1", + participantId: "local-1", + }); + + assert.equal(result.cancelled, true); + assert.deepEqual(h.piAborts, ["local-1"]); +}); + +test("a transport with no cancel protocol refuses, and says why", () => { + const h = makeHarness(); + const external = h.externalGateway.register("s1", { name: "worker" }); + + const remote = h.connectors.cancelRun({ + sessionId: "s1", + participantId: "remote-1", + }); + const ext = h.connectors.cancelRun({ + sessionId: "s1", + participantId: external.id, + }); + const unknown = h.connectors.cancelRun({ + sessionId: "s1", + participantId: "ghost", + }); + + for (const result of [remote, ext, unknown]) { + assert.equal(result.cancelled, false); + assert.ok(result.reason); + } + // A refused cancellation stays out of the transcript, unlike a refused + // delivery: nothing was said, so nothing needs answering. + assert.deepEqual(h.surfaced, []); + assert.deepEqual(h.piAborts, []); +}); + test("killing an external participant reaches its gateway", () => { const h = makeHarness(); const { id } = h.externalGateway.register("s1", { name: "worker" }); diff --git a/apps/server/src/connectors/connectorRegistry.ts b/apps/server/src/connectors/connectorRegistry.ts index 098b3a0..03f3942 100644 --- a/apps/server/src/connectors/connectorRegistry.ts +++ b/apps/server/src/connectors/connectorRegistry.ts @@ -12,7 +12,7 @@ import { ExternalConnector } from "./externalConnector.ts"; import { NullConnector } from "./nullConnector.ts"; import { PiConnector } from "./piConnector.ts"; import { RemoteEnvConnector } from "./remoteEnvConnector.ts"; -import type { Connector } from "./types.ts"; +import type { CancelResult, Connector, RunCancellation } from "./types.ts"; /** * The spawn authorities the server may act on for a caller. `bundle-tool` and @@ -63,6 +63,17 @@ export class ConnectorRegistry { return this.connectors.flatMap((connector) => connector.list(sessionId)); } + /** + * Stops a participant's in-progress Run through whichever connector holds it. + * Cancellation is a connector capability, not something a caller decides by + * inspecting the transport. + */ + cancelRun(request: RunCancellation): CancelResult { + return this.resolve(request.sessionId, request.participantId).cancelRun( + request, + ); + } + /** The connector that spawns `kind` on the server's behalf, if any may. */ spawner(kind: ConnectorKind): SpawningConnector | undefined { const connector = this.connectors.find((c) => c.descriptor.kind === kind); diff --git a/apps/server/src/connectors/externalConnector.ts b/apps/server/src/connectors/externalConnector.ts index 8bafd3f..26247c3 100644 --- a/apps/server/src/connectors/externalConnector.ts +++ b/apps/server/src/connectors/externalConnector.ts @@ -3,12 +3,21 @@ import { connectorFor } from "@tangent/shared/contracts.ts"; import type { ExternalSubagentGateway } from "../external/externalSubagentGateway.ts"; import type { PiAgentHandlers } from "../pi/types.ts"; import { refuseDelivery } from "./refusal.ts"; -import type { Connector, DeliveryRequest, DeliveryResult } from "./types.ts"; +import type { + CancelResult, + Connector, + DeliveryRequest, + DeliveryResult, +} from "./types.ts"; /** Shown in the sub-agent's own thread when a message cannot reach it. */ const NO_INBOUND_CHANNEL = "This sub-agent runs outside Tangent, so it can't receive messages here."; +/** Why a cancellation is refused: the same missing channel, stated for runs. */ +const NO_CANCEL_CHANNEL = + "This sub-agent runs outside Tangent, so its work can't be stopped from here."; + /** * The connector for external sub-agent tabs, whose work runs outside Tangent * and streams in over the internal external-agents API. A thin adapter over @@ -43,6 +52,10 @@ export class ExternalConnector implements Connector { return refuseDelivery(this.handlers, request, NO_INBOUND_CHANNEL); } + cancelRun(): CancelResult { + return { cancelled: false, reason: NO_CANCEL_CHANNEL }; + } + kill(sessionId: string, participantId: string, completed: boolean): void { this.gateway.setStatus( sessionId, diff --git a/apps/server/src/connectors/nullConnector.ts b/apps/server/src/connectors/nullConnector.ts index b98867c..2d5a1e0 100644 --- a/apps/server/src/connectors/nullConnector.ts +++ b/apps/server/src/connectors/nullConnector.ts @@ -2,12 +2,20 @@ import { connectorFor, type SubagentInfo } from "@tangent/shared/contracts.ts"; import type { PiAgentHandlers } from "../pi/types.ts"; import { refuseDelivery } from "./refusal.ts"; -import type { Connector, DeliveryRequest, DeliveryResult } from "./types.ts"; +import type { + CancelResult, + Connector, + DeliveryRequest, + DeliveryResult, +} from "./types.ts"; /** Shown in the addressed conversation when no connector holds the participant. */ const NOT_AVAILABLE = "This agent is no longer available, so the message wasn't delivered."; +/** Why a cancellation is refused: there is no participant to cancel anything on. */ +const NOT_AVAILABLE_TO_CANCEL = "This agent is no longer available."; + /** * The connector a registry answers with when no other one holds the * participant. It exists so resolution is total: an unknown id gets a refusal @@ -36,5 +44,11 @@ export class NullConnector implements Connector { return refuseDelivery(this.handlers, request, NOT_AVAILABLE); } + cancelRun(): CancelResult { + // Silent, unlike a refused delivery: nothing was said, so nothing needs + // answering in the transcript. + return { cancelled: false, reason: NOT_AVAILABLE_TO_CANCEL }; + } + kill(): void {} } diff --git a/apps/server/src/connectors/piConnector.ts b/apps/server/src/connectors/piConnector.ts index 28daa89..2aa749c 100644 --- a/apps/server/src/connectors/piConnector.ts +++ b/apps/server/src/connectors/piConnector.ts @@ -2,12 +2,21 @@ import { connectorFor } from "@tangent/shared/contracts.ts"; import type { SubagentSpawnRequest } from "../pi/agentConfig.ts"; import type { PiAgentManager, SpawnedSubagent } from "../pi/piAgentManager.ts"; -import type { Connector, DeliveryRequest, DeliveryResult } from "./types.ts"; +import type { + CancelResult, + Connector, + DeliveryRequest, + DeliveryResult, + RunCancellation, +} from "./types.ts"; + +/** Why a cancellation was refused when the participant was already idle. */ +const NOTHING_RUNNING = "That agent isn't running anything right now."; /** * The connector for agents running as `pi` child processes the server owns — * every session's Prime and its local sub-agents. A thin adapter over {@link - * PiAgentManager}, which is unchanged. + * PiAgentManager}, which owns its own run boundaries. */ export class PiConnector implements Connector { readonly descriptor = connectorFor("pi-stdio"); @@ -34,10 +43,19 @@ export class PiConnector implements Connector { request.text, request.surfaceAuthor, request.delivery, + request.ingress, ); return { delivered: true }; } + cancelRun(request: RunCancellation): CancelResult { + // Pi's abort RPC targets the process, not a run id: the participant has at + // most one Run open, so cancelling it is cancelling that Run. + const cancelled = this.pi.abort(request.sessionId, request.participantId); + if (cancelled) return { cancelled: true }; + return { cancelled: false, reason: NOTHING_RUNNING }; + } + spawn(sessionId: string, request: SubagentSpawnRequest): SpawnedSubagent { return this.pi.spawnSubagent(sessionId, request); } diff --git a/apps/server/src/connectors/remoteEnvConnector.ts b/apps/server/src/connectors/remoteEnvConnector.ts index c41622c..3f34bbb 100644 --- a/apps/server/src/connectors/remoteEnvConnector.ts +++ b/apps/server/src/connectors/remoteEnvConnector.ts @@ -3,7 +3,19 @@ import { connectorFor } from "@tangent/shared/contracts.ts"; import type { SubagentSpawnRequest } from "../pi/agentConfig.ts"; import type { SpawnedSubagent } from "../pi/piAgentManager.ts"; import type { RemoteEnvironmentGateway } from "../remote/remoteEnvironmentGateway.ts"; -import type { Connector, DeliveryRequest, DeliveryResult } from "./types.ts"; +import type { + CancelResult, + Connector, + DeliveryRequest, + DeliveryResult, +} from "./types.ts"; + +/** + * Why a cancellation is refused: the remote protocol carries commands to spawn, + * message and kill an agent, but nothing to interrupt a turn in progress. + */ +const NO_CANCEL_PROTOCOL = + "Remote sub-agents can't be interrupted mid-turn; kill it instead."; /** * The connector for sub-agents hosted inside a connected remote environment. A @@ -36,10 +48,15 @@ export class RemoteEnvConnector implements Connector { request.text, request.surfaceAuthor, request.delivery, + request.ingress, ); return { delivered: true }; } + cancelRun(): CancelResult { + return { cancelled: false, reason: NO_CANCEL_PROTOCOL }; + } + spawn(sessionId: string, request: SubagentSpawnRequest): SpawnedSubagent { return this.gateway.spawnSubagent(sessionId, request); } diff --git a/apps/server/src/connectors/types.ts b/apps/server/src/connectors/types.ts index afe7647..c832c74 100644 --- a/apps/server/src/connectors/types.ts +++ b/apps/server/src/connectors/types.ts @@ -2,6 +2,8 @@ import type { ChatAuthor, ConnectorDescriptor, MessageDelivery, + RunId, + RunIngress, SubagentInfo, } from "@tangent/shared/contracts.ts"; @@ -19,6 +21,11 @@ export interface DeliveryRequest { */ surfaceAuthor?: ChatAuthor; delivery?: MessageDelivery; + /** + * What this delivery counts as when it starts a Run. Defaults to `reaction`, + * which is what a message from a human or another participant is. + */ + ingress?: RunIngress; } /** What became of a delivery. `reason` is set only when it was refused. */ @@ -27,6 +34,24 @@ export interface DeliveryResult { reason?: string; } +/** A request to stop one participant's in-progress work. */ +export interface RunCancellation { + sessionId: string; + participantId: string; + /** The Run to cancel; the connector resolves the open one when omitted. */ + runId?: RunId; +} + +/** + * What became of a cancellation. `reason` is set only when it was refused — + * because the transport has no cancel protocol, or because there was nothing + * running to cancel. + */ +export interface CancelResult { + cancelled: boolean; + reason?: string; +} + /** * One way of reaching participants: a transport plus the roster of participants * it currently holds. A registry resolves a participant to exactly one of @@ -36,7 +61,8 @@ export interface DeliveryResult { * `deliver` is required of every connector. One that cannot accept a message * declares {@link Connector.acceptsDelivery} false and refuses, because an * absent method is a compile-time refusal while an untaken branch is a runtime - * mis-delivery — and the tree has had both. + * mis-delivery — and the tree has had both. `cancelRun` follows the same rule: + * a transport with no cancel protocol refuses by declaration. */ export interface Connector { readonly descriptor: ConnectorDescriptor; @@ -45,6 +71,8 @@ export interface Connector { has(sessionId: string, participantId: string): boolean; list(sessionId: string): SubagentInfo[]; deliver(request: DeliveryRequest): DeliveryResult; + /** Stops a participant's in-progress Run, or says why it cannot. */ + cancelRun(request: RunCancellation): CancelResult; /** Present only where {@link ConnectorDescriptor.spawnAuthority} allows it. */ spawn?(sessionId: string, request: SubagentSpawnRequest): SpawnedSubagent; kill(sessionId: string, participantId: string, completed: boolean): void; diff --git a/apps/server/src/external/externalSubagentGateway.test.ts b/apps/server/src/external/externalSubagentGateway.test.ts index 8a4e7f4..9d5fa59 100644 --- a/apps/server/src/external/externalSubagentGateway.test.ts +++ b/apps/server/src/external/externalSubagentGateway.test.ts @@ -4,23 +4,31 @@ import { test } from "node:test"; import type { SubagentInfo } from "@tangent/shared/contracts.ts"; import type { PiAgentHandlers } from "../pi/types.ts"; +import { RunRegistry } from "../runs/runRegistry.ts"; +import { InMemoryRunStore } from "../store/inMemoryRunStore.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 events: Array<{ agentId: string; type: string; runId?: string }> = []; const handlers: PiAgentHandlers = { onAgentEvent: (_sessionId, agent, event) => - events.push({ agentId: agent.agentId, type: event.type }), + events.push({ + agentId: agent.agentId, + type: event.type, + runId: event.runId, + }), onSubagentUpdate: (_sessionId, info) => rosterUpdates.push(info), onAgentMessage: () => {}, onSessionStatus: () => {}, }; - const gateway = new ExternalSubagentGateway(handlers); - return { gateway, rosterUpdates, events }; + const runStore = new InMemoryRunStore(); + const runs = new RunRegistry(runStore); + const gateway = new ExternalSubagentGateway(handlers, runs); + return { gateway, rosterUpdates, events, runs, runStore }; } test("register records a roster entry and surfaces it as active", () => { @@ -97,6 +105,70 @@ test("setStatus to active keeps the entry in the roster", () => { assert.equal(h.rosterUpdates.at(-1)?.status, "active"); }); +test("a turn's run carries the far side's session id and drain cursor", async () => { + const h = makeHarness(); + const { id } = h.gateway.register("s1", { name: "worker" }); + + const runId = h.gateway.openRun("s1", id, { + externalId: "aquifer-1", + cursor: "7", + }); + assert.ok(runId); + h.gateway.pushEvent("s1", id, { type: "start", messageId: "m1" }); + h.gateway.endRun("s1", id, "completed", { runId, cursor: "31" }); + + assert.equal(h.events.at(-1)?.runId, runId); + const stored = await h.runStore.getRun(runId); + assert.equal(stored?.externalId, "aquifer-1"); + assert.equal(stored?.cursor, "31"); + assert.equal(stored?.status, "completed"); + assert.equal(stored?.ingress, "tool"); +}); + +test("an event with no run id is attributed to the tab's open run", () => { + const h = makeHarness(); + const { id } = h.gateway.register("s1", { name: "worker" }); + const runId = h.gateway.openRun("s1", id); + + h.gateway.pushEvent("s1", id, { type: "activity", activity: null }); + + assert.equal(h.events.at(-1)?.runId, runId); +}); + +test("a run id belonging to another tab is not honored", () => { + const h = makeHarness(); + const mine = h.gateway.register("s1", { name: "mine" }); + const theirs = h.gateway.register("s1", { name: "theirs" }); + const theirRun = h.gateway.openRun("s1", theirs.id); + const myRun = h.gateway.openRun("s1", mine.id); + + h.gateway.pushEvent( + "s1", + mine.id, + { type: "start", messageId: "m1" }, + theirRun, + ); + + assert.equal(h.events.at(-1)?.runId, myRun); +}); + +test("a tab going terminal settles the run it was working under", async () => { + const h = makeHarness(); + const { id } = h.gateway.register("s1", { name: "worker" }); + const runId = h.gateway.openRun("s1", id); + assert.ok(runId); + + h.gateway.setStatus("s1", id, "error"); + + assert.equal(h.runs.current("s1", id), undefined); + assert.equal((await h.runStore.getRun(runId))?.status, "failed"); +}); + +test("openRun refuses an unknown agent", () => { + const h = makeHarness(); + assert.equal(h.gateway.openRun("s1", "nope"), undefined); +}); + test("listSubagents is scoped per session", () => { const h = makeHarness(); const a = h.gateway.register("s1", { name: "one" }); diff --git a/apps/server/src/external/externalSubagentGateway.ts b/apps/server/src/external/externalSubagentGateway.ts index 0ac9cc6..cf3d47c 100644 --- a/apps/server/src/external/externalSubagentGateway.ts +++ b/apps/server/src/external/externalSubagentGateway.ts @@ -2,6 +2,8 @@ import { randomUUID } from "node:crypto"; import { connectorFields, + type Run, + type RunId, type SubagentInfo, type SubagentStatus, type ThinkingLevel, @@ -9,6 +11,7 @@ import { import type { RemoteAgentEvent } from "@tangent/shared/remoteSubagent.ts"; import type { AgentDescriptor, PiAgentHandlers } from "../pi/types.ts"; +import type { RunRegistry, SettledStatus } from "../runs/runRegistry.ts"; /** Display metadata a caller supplies when registering an external sub-agent. */ export interface RegisterExternalSubagent { @@ -18,6 +21,14 @@ export interface RegisterExternalSubagent { thinkingDepth?: ThinkingLevel; } +/** What a caller supplies to open a Run for an external sub-agent's turn. */ +export interface OpenExternalRun { + /** The far side's own id for this work (an Aquifer World session id). */ + externalId?: string; + /** Where the far side's stream is being read from, to resume by. */ + cursor?: string; +} + /** An external sub-agent tab, tracked in the gateway roster (display only). */ interface ExternalSubagent { agentId: string; @@ -29,6 +40,16 @@ interface ExternalSubagent { createdAt: string; } +/** Whether a Run is this participant's, or another's (or none at all). */ +function belongsTo( + run: Run | undefined, + sessionId: string, + agentId: string, +): boolean { + if (!run) return false; + return run.sessionId === sessionId && run.participantId === agentId; +} + /** Projects a roster entry onto the wire {@link SubagentInfo}. */ function toInfo(subagent: ExternalSubagent): SubagentInfo { return { @@ -60,12 +81,14 @@ function toInfo(subagent: ExternalSubagent): SubagentInfo { */ export class ExternalSubagentGateway { private readonly handlers: PiAgentHandlers; + private readonly runs: RunRegistry; /** Per-session external sub-agent rosters, keyed by sessionId then agentId. */ private readonly sessions = new Map>(); - constructor(handlers: PiAgentHandlers) { + constructor(handlers: PiAgentHandlers, runs: RunRegistry) { this.handlers = handlers; + this.runs = runs; } /** True when `agentId` is an external sub-agent of `sessionId`. */ @@ -101,17 +124,86 @@ export class ExternalSubagentGateway { 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 { + /** + * Opens a Run for a turn of external work, carrying the far side's own id for + * it and where its stream is being read from. Returns the Run's id, which the + * caller passes back on `pushEvent` and `endRun`. Undefined for an unknown id. + * + * The driving tool knows the turn's boundaries — the server cannot see them — + * so it declares them rather than having them guessed from the event stream. + */ + openRun( + sessionId: string, + agentId: string, + input: OpenExternalRun = {}, + ): RunId | undefined { + const subagent = this.sessions.get(sessionId)?.get(agentId); + if (!subagent) return undefined; + return this.runs.open({ + sessionId, + participantId: agentId, + ingress: "tool", + externalId: input.externalId, + cursor: input.cursor, + }).id; + } + + /** + * Settles a turn's Run, recording how far its stream was read. Names the Run + * explicitly or settles whatever the tab has open; a cursor with no Run to + * record it against is dropped. + */ + endRun( + sessionId: string, + agentId: string, + status: SettledStatus, + input: { runId?: RunId; cursor?: string } = {}, + ): void { + const runId = this.attributeTo(sessionId, agentId, input.runId); + if (!runId) return; + if (input.cursor) this.runs.setCursor(runId, input.cursor); + this.runs.settle(runId, status); + } + + /** + * Relays a streamed event into the sub-agent's tab, attributed to the Run the + * caller opened for the turn (or to whatever that tab has open). No-op for an + * unknown id. + */ + pushEvent( + sessionId: string, + agentId: string, + event: RemoteAgentEvent, + runId?: RunId, + ): void { const subagent = this.sessions.get(sessionId)?.get(agentId); if (!subagent) return; - this.handlers.onAgentEvent(sessionId, this.descriptorFor(subagent), event); + this.handlers.onAgentEvent(sessionId, this.descriptorFor(subagent), { + ...event, + runId: this.attributeTo(sessionId, agentId, runId), + }); + } + + /** + * Resolves which Run an inbound event is attributed to. A supplied id must + * name a Run of the addressed participant — the caller is an external tool, so + * one tab's stream must not be able to land under another's Run. + */ + private attributeTo( + sessionId: string, + agentId: string, + runId: RunId | undefined, + ): RunId | undefined { + if (runId && belongsTo(this.runs.get(runId), sessionId, agentId)) { + return runId; + } + return this.runs.current(sessionId, agentId)?.id; } /** * 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. + * (anything other than `active`) drop the roster entry and settle whatever + * Run the tab still had open. No-op for an unknown id. */ setStatus(sessionId: string, agentId: string, status: SubagentStatus): void { const roster = this.sessions.get(sessionId); @@ -119,7 +211,14 @@ export class ExternalSubagentGateway { if (!roster || !subagent) return; subagent.status = status; - if (status !== "active") roster.delete(agentId); + if (status !== "active") { + roster.delete(agentId); + this.runs.settleOpenFor( + sessionId, + agentId, + status === "completed" ? "completed" : "failed", + ); + } this.handlers.onSubagentUpdate(sessionId, toInfo(subagent)); } diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 0423ae7..150b382 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -31,6 +31,7 @@ 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 { RunRegistry } from "./runs/runRegistry.ts"; import { createAgentEventHandler, createAgentMessageHandler, @@ -43,11 +44,14 @@ import { } from "./sockets/chat.ts"; import { openDb } from "./store/db/client.ts"; import { FileAgentBundleStore } from "./store/fileAgentBundleStore.ts"; +import { SqliteRunStore } from "./store/sqliteRunStore.ts"; import { SqliteSessionStore } from "./store/sqliteSessionStore.ts"; -// Single shared store instance backs both REST routes and socket handlers. -// Opening the DB applies pending drizzle-kit migrations on startup. -const store = new SqliteSessionStore(openDb()); +// Opening the DB applies pending drizzle-kit migrations on startup. The single +// shared connection backs both stores: session metadata for the REST routes and +// socket handlers, runs for the run registry. +const db = openDb(); +const store = new SqliteSessionStore(db); // Filesystem-backed marketplace of saved agent bundles. const agentBundleStore = new FileAgentBundleStore(); @@ -84,10 +88,18 @@ const agentHandlers: PiAgentHandlers = { onSessionStatus: createSessionStatusHandler(io), }; +// Tracks which participant is working under which run id, so every stream is +// attributable and cancellation has a run to act on. Rows left `running` by a +// previous process are settled once here: nothing can run before we start. +const runs = new RunRegistry(new SqliteRunStore(db)); +void runs.failStaleRuns().then((failed) => { + if (failed > 0) console.log(`[runs] settled ${failed} stale run(s)`); +}); + // 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(agentHandlers, memory); +const pi = new PiAgentManager(agentHandlers, memory, runs); // 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. @@ -102,12 +114,13 @@ const remoteGateway = new RemoteEnvironmentGateway( agentHandlers, store, deliverToPrime, + runs, ); // 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); +const externalGateway = new ExternalSubagentGateway(agentHandlers, runs); // The single lookup from a participant to the connector that reaches it. Every // spawn/message/kill/list route goes through it, so an id no connector holds is diff --git a/apps/server/src/pi/piAgentManager.test.ts b/apps/server/src/pi/piAgentManager.test.ts index d1764e6..001fd00 100644 --- a/apps/server/src/pi/piAgentManager.test.ts +++ b/apps/server/src/pi/piAgentManager.test.ts @@ -8,6 +8,8 @@ import { afterEach, mock, test } from "node:test"; import { connectorFor } from "@tangent/shared/contracts.ts"; +import { RunRegistry } from "../runs/runRegistry.ts"; +import { InMemoryRunStore } from "../store/inMemoryRunStore.ts"; import type { SessionAgent } from "../store/sessionStore.ts"; import type { MemoryManager } from "./memory.ts"; import { PiAgentManager, PRIME_AGENT_ID } from "./piAgentManager.ts"; @@ -46,14 +48,25 @@ interface SpawnRecord { child: FakeChild; } +/** One relayed agent event, reduced to what the run tests assert on. */ +interface RelayedEvent { + agentId: string; + type: string; + runId?: string; +} + /** Builds a manager wired to a fake launcher; returns it plus the spawn log. */ function makeManager(): { pi: PiAgentManager; spawns: SpawnRecord[]; rosterUpdates: { id: string; status: string }[]; + events: RelayedEvent[]; + runs: RunRegistry; + runStore: InMemoryRunStore; } { const spawns: SpawnRecord[] = []; const rosterUpdates: { id: string; status: string }[] = []; + const events: RelayedEvent[] = []; const fakeSpawn = (( _command: string, @@ -70,7 +83,12 @@ function makeManager(): { }) as unknown as typeof spawn; const handlers: PiAgentHandlers = { - onAgentEvent: () => {}, + onAgentEvent: (_sessionId, agent, event) => + events.push({ + agentId: agent.agentId, + type: event.type, + runId: event.runId, + }), onSubagentUpdate: (_sessionId, subagent) => rosterUpdates.push({ id: subagent.id, status: subagent.status }), onAgentMessage: () => {}, @@ -82,8 +100,10 @@ function makeManager(): { buildPreamble: () => "## Memory\n\n(empty)", } as unknown as MemoryManager; - const pi = new PiAgentManager(handlers, memory, fakeSpawn); - return { pi, spawns, rosterUpdates }; + const runStore = new InMemoryRunStore(); + const runs = new RunRegistry(runStore); + const pi = new PiAgentManager(handlers, memory, runs, fakeSpawn); + return { pi, spawns, rosterUpdates, events, runs, runStore }; } /** A persisted roster row with sane defaults, overridable per field. */ @@ -237,6 +257,136 @@ test("the local roster describes its connector", () => { assert.deepEqual(pi.listSubagents("s1")[0].connector, expected); }); +/** Feeds one Pi stdout event into an agent's reader. */ +function emitPi(child: FakeChild, event: Record): void { + child.stdout.emit("data", Buffer.from(`${JSON.stringify(event)}\n`)); +} + +/** Drives a full Pi turn: one assistant message from `agent_start` to `agent_end`. */ +function runTurn(child: FakeChild, text: string): void { + emitPi(child, { type: "agent_start" }); + emitPi(child, { type: "message_start", message: { role: "assistant" } }); + emitPi(child, { + type: "message_update", + assistantMessageEvent: { type: "text_delta", delta: text }, + }); + emitPi(child, { + type: "message_end", + message: { role: "assistant", content: text }, + }); + emitPi(child, { type: "agent_end" }); +} + +/** Yields to the microtask queue so write-through persistence has landed. */ +function flush(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} + +test("a prompt opens one run and every event in the turn carries its id", async () => { + const h = makeManager(); + h.pi.ensure("s1", "/tmp/s1"); + + h.pi.prompt("s1", "/tmp/s1", "hello"); + const runId = h.runs.current("s1", PRIME_AGENT_ID)?.id; + assert.ok(runId, "prompting an idle agent opens a run"); + + runTurn(h.spawns[0].child, "hi there"); + await flush(); + + const relayed = h.events.filter((e) => e.agentId === PRIME_AGENT_ID); + assert.deepEqual( + relayed.map((e) => e.type), + ["activity", "start", "activity", "delta", "end", "activity", "activity"], + ); + assert.ok( + relayed.every((e) => e.runId === runId), + "every event of the turn is attributed to the one run", + ); + assert.equal((await h.runStore.getRun(runId))?.status, "completed"); +}); + +test("a message delivered mid-run joins the run in flight", () => { + const h = makeManager(); + h.pi.ensure("s1", "/tmp/s1"); + + h.pi.prompt("s1", "/tmp/s1", "first"); + const runId = h.runs.current("s1", PRIME_AGENT_ID)?.id; + h.pi.prompt("s1", "/tmp/s1", "and also this"); + + assert.equal( + h.runs.current("s1", PRIME_AGENT_ID)?.id, + runId, + "a steer/follow-up is part of the turn Pi is already taking", + ); +}); + +test("aborting a run settles it as cancelled, not completed", async () => { + const h = makeManager(); + h.pi.ensure("s1", "/tmp/s1"); + h.pi.prompt("s1", "/tmp/s1", "long job"); + const runId = h.runs.current("s1", PRIME_AGENT_ID)?.id; + assert.ok(runId); + + assert.equal(h.pi.abort("s1", PRIME_AGENT_ID), true); + emitPi(h.spawns[0].child, { type: "agent_end" }); + await flush(); + + assert.equal(h.runs.current("s1", PRIME_AGENT_ID), undefined); + assert.equal((await h.runStore.getRun(runId))?.status, "cancelled"); +}); + +test("aborting an idle agent refuses instead of cancelling nothing", () => { + const h = makeManager(); + h.pi.ensure("s1", "/tmp/s1"); + + assert.equal(h.pi.abort("s1", PRIME_AGENT_ID), false); + assert.equal(h.pi.abort("s1", "ghost"), false); +}); + +test("a turn Pi starts on its own still gets a run", () => { + const h = makeManager(); + h.pi.ensure("s1", "/tmp/s1"); + + emitPi(h.spawns[0].child, { type: "agent_start" }); + + assert.ok(h.runs.current("s1", PRIME_AGENT_ID)); +}); + +test("a crash fails the run that was in flight", async () => { + const h = makeManager(); + h.pi.ensure("s1", "/tmp/s1"); + h.pi.prompt("s1", "/tmp/s1", "work"); + const runId = h.runs.current("s1", PRIME_AGENT_ID)?.id; + assert.ok(runId); + + h.spawns[0].child.crash(); + await flush(); + + assert.equal((await h.runStore.getRun(runId))?.status, "failed"); +}); + +test("a sub-agent's initial task is a tool-driven run", () => { + const h = makeManager(); + h.pi.ensure("s1", "/tmp/s1"); + + const { info } = h.pi.spawnSubagent("s1", { name: "Worker", task: "go" }); + + assert.equal(h.runs.current("s1", info.id)?.ingress, "tool"); +}); + +test("killing a sub-agent mid-run cancels its run", async () => { + const h = makeManager(); + h.pi.ensure("s1", "/tmp/s1"); + const { info } = h.pi.spawnSubagent("s1", { name: "Worker", task: "go" }); + const runId = h.runs.current("s1", info.id)?.id; + assert.ok(runId); + + h.pi.killAgent("s1", info.id); + await flush(); + + assert.equal((await h.runStore.getRun(runId))?.status, "cancelled"); +}); + test("an intentional kill is not auto-respawned", () => { const { pi, spawns } = makeManager(); pi.ensure("s1", "/tmp/s1"); diff --git a/apps/server/src/pi/piAgentManager.ts b/apps/server/src/pi/piAgentManager.ts index 5d21878..0c8a7f8 100644 --- a/apps/server/src/pi/piAgentManager.ts +++ b/apps/server/src/pi/piAgentManager.ts @@ -6,6 +6,7 @@ import { type ChatAuthor, type MessageDelivery, PI_AGENT, + type RunIngress, type SessionRunStatus, type SessionStatusPayload, type SubagentInfo, @@ -24,6 +25,7 @@ import { PI_PROXY_URL, PI_THINKING, } from "../config.ts"; +import type { RunRegistry, SettledStatus } from "../runs/runRegistry.ts"; import type { SessionAgent } from "../store/sessionStore.ts"; import { type AgentConfig, @@ -37,6 +39,7 @@ import { loadInstalledConfig } from "./config/bundleLoader.ts"; import type { MemoryManager } from "./memory.ts"; import { type AgentDescriptor, + type AgentEvent, type AgentProcess, type AssistantDelta, type PiAgentHandlers, @@ -398,6 +401,14 @@ function toStringArray(value: unknown): string[] { return value.filter((item): item is string => typeof item === "string"); } +/** + * How a Run settles when its participant is killed: a graceful, Prime-initiated + * finish completed the work; anything else stopped it short. + */ +function settledForKill(completed: boolean): SettledStatus { + return completed ? "completed" : "cancelled"; +} + /** * Maps a delivery mode to Pi's `streamingBehavior` for a mid-run message: only * an explicit steer nudges before the next LLM call; everything else (including @@ -424,6 +435,12 @@ export class PiAgentManager { private readonly sessions = new Map(); private readonly handlers: PiAgentHandlers; private readonly memory: MemoryManager; + /** + * The Runs this manager's agents work under. Pi is the authority on its own + * run boundaries (`agent_start` opens, `agent_end` settles), so it opens and + * settles them rather than having them inferred from the event stream. + */ + private readonly runs: RunRegistry; /** * Process launcher, injectable so tests can supply a fake child without * spawning a real `pi` binary. Defaults to Node's {@link spawn}. @@ -438,11 +455,26 @@ export class PiAgentManager { constructor( handlers: PiAgentHandlers, memory: MemoryManager, + runs: RunRegistry, spawnProcess: typeof spawn = spawn, ) { this.spawnProcess = spawnProcess; this.handlers = handlers; this.memory = memory; + this.runs = runs; + } + + /** + * Relays an agent event, attributed to the Run its participant is working + * under. The single stamping point, so no emit site can forget attribution. + */ + private emit( + sessionId: string, + descriptor: AgentDescriptor, + event: AgentEvent, + ): void { + const runId = this.runs.current(sessionId, descriptor.agentId)?.id; + this.handlers.onAgentEvent(sessionId, descriptor, { ...event, runId }); } /** @@ -662,6 +694,9 @@ export class PiAgentManager { existing.aborted = true; existing.intentionalKill = true; existing.child.kill(); + // The killed process will never emit `agent_end`, so settle here: the turn + // we cut was stopped, not finished. + this.runs.settleOpenFor(sessionId, agentId, "cancelled"); const agent = this.spawnAgent(sessionId, session, descriptor, nextConfig); const info = toSubagentInfo(agent); @@ -681,9 +716,17 @@ export class PiAgentManager { rootPath: string, text: string, delivery: MessageDelivery = "auto", + ingress: RunIngress = "reaction", ): void { this.ensure(sessionId, rootPath); - this.sendToAgent(sessionId, PRIME_AGENT_ID, text, undefined, delivery); + this.sendToAgent( + sessionId, + PRIME_AGENT_ID, + text, + undefined, + delivery, + ingress, + ); } /** @@ -742,7 +785,7 @@ export class PiAgentManager { task: string | undefined, ): void { if (task && task.trim()) { - this.sendToAgent(sessionId, agentId, task, PI_AGENT); + this.sendToAgent(sessionId, agentId, task, PI_AGENT, "auto", "tool"); } } @@ -757,6 +800,10 @@ export class PiAgentManager { * is also surfaced into that sub-agent's transcript (attributed to * `surfaceAuthor`), so directed tasks read as a real conversation. Internal * relays (e.g. feeding a sub-agent's reply back to Prime) omit it. + * + * A message that finds the agent idle opens a Run with `ingress`; one that + * finds it mid-run joins the Run in flight, because Pi folds a steer or + * follow-up into the turn it is already taking. */ sendToAgent( sessionId: string, @@ -764,9 +811,11 @@ export class PiAgentManager { text: string, surfaceAuthor?: ChatAuthor, delivery: MessageDelivery = "auto", + ingress: RunIngress = "reaction", ): void { const agent = this.sessions.get(sessionId)?.agents.get(agentId); if (!agent) { + // No participant, so no Run: this error belongs to no unit of work. this.handlers.onAgentEvent( sessionId, { agentId, role: "prime", name: "Prime" }, @@ -775,24 +824,41 @@ export class PiAgentManager { return; } + if (!agent.busy) { + this.runs.open({ sessionId, participantId: agentId, ingress }); + } + this.surfaceDirectedMessage(sessionId, agent, text, surfaceAuthor); + this.writePrompt(sessionId, agent, text, delivery); + this.notifyStatus(sessionId); + } + /** + * Writes a prompt to an agent's stdin, marking it busy. A prompt that arrives + * mid-stream carries a `streamingBehavior` (steer / follow-up); that field is + * only valid while streaming, so an idle agent gets a plain prompt. + */ + private writePrompt( + sessionId: string, + agent: AgentProcess, + text: string, + delivery: MessageDelivery, + ): void { + const wasBusy = agent.busy; const command: Record = { id: randomUUID(), type: "prompt", message: text, + ...(wasBusy + ? { streamingBehavior: busyStreamingBehavior(delivery) } + : {}), }; - // `streamingBehavior` is only valid while the agent is streaming; when idle, - // send a plain prompt. - if (agent.busy) { - command.streamingBehavior = busyStreamingBehavior(delivery); - } console.log( - `[pi:${sessionId}:${agentId}] prompt`, + `[pi:${sessionId}:${agent.agentId}] prompt`, JSON.stringify({ role: agent.role, - wasBusy: agent.busy, + wasBusy, delivery, textLength: text.length, }), @@ -800,7 +866,6 @@ export class PiAgentManager { agent.busy = true; agent.child.stdin.write(`${JSON.stringify(command)}\n`); - this.notifyStatus(sessionId); } /** @@ -840,19 +905,23 @@ export class PiAgentManager { sessionId, PRIME_AGENT_ID, `Sub-agent "${agent.name}" reported:\n\n${text}`, + undefined, + "auto", + "tool", ); } /** - * Aborts an agent's in-progress run (Prime or a sub-agent) by sending Pi's - * `abort` RPC command on stdin. The process stays alive and emits `agent_end`, - * which resets its state through the normal event flow. No-op when the agent - * is unknown or idle. `aborted` is flagged so a half-finished sub-agent reply - * is not relayed back to Prime. + * Cancels a participant's in-progress Run by sending Pi's `abort` RPC command + * on stdin. The process stays alive and emits `agent_end`, which settles the + * Run as `cancelled` and resets state through the normal event flow. Returns + * false when there is nothing to cancel (unknown or idle agent), so the caller + * can tell a refusal from a cancellation. `aborted` is flagged so a + * half-finished sub-agent reply is not relayed back to Prime. */ - abort(sessionId: string, agentId: string): void { + abort(sessionId: string, agentId: string): boolean { const agent = this.sessions.get(sessionId)?.agents.get(agentId); - if (!agent || !agent.busy) return; + if (!agent || !agent.busy) return false; agent.aborted = true; console.log( @@ -862,6 +931,7 @@ export class PiAgentManager { agent.child.stdin.write( `${JSON.stringify({ id: randomUUID(), type: "abort" })}\n`, ); + return true; } /** @@ -879,6 +949,7 @@ export class PiAgentManager { agent.intentionalKill = true; session.agents.delete(agentId); agent.child.kill(); + this.runs.settleOpenFor(sessionId, agentId, settledForKill(completed)); this.handlers.onSubagentUpdate(sessionId, toSubagentInfo(agent)); this.notifyStatus(sessionId); } @@ -1161,6 +1232,15 @@ export class PiAgentManager { /** Begins a run: marks the agent busy and shows the "thinking" indicator. */ private onAgentStart(sessionId: string, agent: AgentProcess): void { + // A turn we did not initiate (Pi starting work off its own queue) still gets + // a Run, so no stream is unattributable. + if (!this.runs.current(sessionId, agent.agentId)) { + this.runs.open({ + sessionId, + participantId: agent.agentId, + ingress: "reaction", + }); + } agent.busy = true; agent.aborted = false; agent.currentMessageId = null; @@ -1204,7 +1284,7 @@ export class PiAgentManager { if (!agent.startEmitted) { agent.startEmitted = true; - this.handlers.onAgentEvent(sessionId, descriptor, { + this.emit(sessionId, descriptor, { type: "start", messageId: agent.currentMessageId, }); @@ -1217,7 +1297,7 @@ export class PiAgentManager { agent.thinkingAccum += delta.text; } - this.handlers.onAgentEvent(sessionId, descriptor, { + this.emit(sessionId, descriptor, { type: delta.kind, messageId: agent.currentMessageId, delta: delta.text, @@ -1267,7 +1347,7 @@ export class PiAgentManager { assistantTextFromMessage(event.message) || agent.accum || ""; if (content.trim()) agent.lastFinalContent = content; - this.handlers.onAgentEvent(sessionId, descriptor, { + this.emit(sessionId, descriptor, { type: "end", messageId: agent.currentMessageId as string, content, @@ -1312,7 +1392,7 @@ export class PiAgentManager { descriptor: AgentDescriptor, event: PiStdoutEvent, ): void { - this.handlers.onAgentEvent(sessionId, descriptor, { + this.emit(sessionId, descriptor, { type: "queue", steering: toStringArray(event.steering), followUp: toStringArray(event.followUp), @@ -1334,6 +1414,7 @@ export class PiAgentManager { }), ); + const cancelled = agent.aborted; agent.currentMessageId = null; agent.startEmitted = false; agent.accum = ""; @@ -1343,7 +1424,14 @@ export class PiAgentManager { agent.aborted = false; this.notifyStatus(sessionId); + // Emitted before the Run settles, so the run's last event still carries its + // id. An aborted run was cut short: that is a cancellation, not a finish. this.emitActivity(sessionId, agent, null); + this.runs.settleOpenFor( + sessionId, + agent.agentId, + cancelled ? "cancelled" : "completed", + ); } /** @@ -1357,10 +1445,7 @@ export class PiAgentManager { activity: AgentActivity | null, ): void { agent.lastActivity = activity; - this.handlers.onAgentEvent(sessionId, toDescriptor(agent), { - type: "activity", - activity, - }); + this.emit(sessionId, toDescriptor(agent), { type: "activity", activity }); } /** @@ -1427,13 +1512,10 @@ export class PiAgentManager { role: agent.role, name: agent.name, }; - this.handlers.onAgentEvent(sessionId, descriptor, { - type: "error", - messageId, - message, - }); + this.emit(sessionId, descriptor, { type: "error", messageId, message }); this.notifyStatus(sessionId); this.emitActivity(sessionId, agent, null); + this.runs.settleOpenFor(sessionId, agent.agentId, "failed"); } /** diff --git a/apps/server/src/pi/triggers/triggerEngine.ts b/apps/server/src/pi/triggers/triggerEngine.ts index 808f657..09f4a0d 100644 --- a/apps/server/src/pi/triggers/triggerEngine.ts +++ b/apps/server/src/pi/triggers/triggerEngine.ts @@ -4,6 +4,7 @@ import type { BundleTrigger } from "@tangent/shared/configBundle.ts"; import type { ChatAuthor, ChatMessage, + RunIngress, Trigger, TriggerRosterPayload, TriggerTarget, @@ -36,6 +37,11 @@ function triggerAuthor(stored: StoredTrigger): ChatAuthor { return { ...TRIGGER_AUTHOR, name: stored.title ?? stored.name }; } +/** What a firing counts as when it opens a Run: the trigger's own signal. */ +function ingressFor(stored: StoredTrigger): RunIngress { + return stored.kind === "schedule" ? "schedule" : "webhook"; +} + /** Builds the spawn request that revives a `subagent`-target trigger's sub-agent. */ function buildSubagentSpawnRequest( target: Extract, @@ -246,7 +252,7 @@ export class TriggerEngine { }; await this.store.appendMessage(message); this.io.to(roomFor(sessionId)).emit(SocketEvents.ChatMessage, message); - this.pi.prompt(sessionId, rootPath, prompt); + this.pi.prompt(sessionId, rootPath, prompt, "auto", ingressFor(stored)); } /** @@ -262,7 +268,14 @@ export class TriggerEngine { prompt: string, ): void { const { agentId } = this.ensureSubagent(sessionId, rootPath, stored); - this.pi.sendToAgent(sessionId, agentId, prompt, triggerAuthor(stored)); + this.pi.sendToAgent( + sessionId, + agentId, + prompt, + triggerAuthor(stored), + "auto", + ingressFor(stored), + ); } /** diff --git a/apps/server/src/pi/types.ts b/apps/server/src/pi/types.ts index 0d29b32..68b135a 100644 --- a/apps/server/src/pi/types.ts +++ b/apps/server/src/pi/types.ts @@ -4,6 +4,7 @@ import type { AgentActivity, AgentRole, ChatAuthor, + RunId, SessionRunStatus, SubagentInfo, SubagentStatus, @@ -16,11 +17,11 @@ import type { AgentConfig, ResolvedSessionConfig } from "./agentConfig.ts"; export const PRIME_AGENT_ID = "prime"; /** - * Event surfaced to the chat layer as an agent streams a reply. `messageId` - * correlates the `start`/`delta`/`end` of a single assistant message so the - * client can build it up incrementally. + * The streamed body of an {@link AgentEvent}, before run attribution. + * `messageId` correlates the `start`/`delta`/`end` of a single assistant + * message so the client can build it up incrementally. */ -export type AgentEvent = +type AgentEventBody = | { type: "start"; messageId: string } | { type: "delta"; messageId: string; delta: string } | { type: "thinking"; messageId: string; delta: string } @@ -29,6 +30,17 @@ export type AgentEvent = | { type: "activity"; activity: AgentActivity | null } | { type: "queue"; steering: string[]; followUp: string[] }; +/** + * Event surfaced to the chat layer as an agent streams a reply, attributed to + * the {@link Run} that produced it. Deltas and thinking stay Run events rather + * than Messages — only finalized content is persisted as a Message. + * + * `runId` is optional because a connector can relay a stream the server never + * opened a Run for (an event about a participant that no longer exists, or a + * far end that predates run attribution). + */ +export type AgentEvent = AgentEventBody & { runId?: RunId }; + /** Identifies which agent in a session produced an {@link AgentEvent}. */ export interface AgentDescriptor { agentId: string; diff --git a/apps/server/src/remote/remoteEnvironmentGateway.test.ts b/apps/server/src/remote/remoteEnvironmentGateway.test.ts index 023448e..ede370b 100644 --- a/apps/server/src/remote/remoteEnvironmentGateway.test.ts +++ b/apps/server/src/remote/remoteEnvironmentGateway.test.ts @@ -4,6 +4,8 @@ import { test } from "node:test"; import type { Server as SocketIOServer, Socket } from "socket.io"; import type { PiAgentHandlers } from "../pi/types.ts"; +import { RunRegistry } from "../runs/runRegistry.ts"; +import { InMemoryRunStore } from "../store/inMemoryRunStore.ts"; import type { SessionStore } from "../store/sessionStore.ts"; import { RemoteEnvironmentGateway } from "./remoteEnvironmentGateway.ts"; @@ -34,6 +36,7 @@ function makeHarness() { handlers, store, () => {}, + new RunRegistry(new InMemoryRunStore()), ); const connect = (environmentId: string): void => { diff --git a/apps/server/src/remote/remoteEnvironmentGateway.ts b/apps/server/src/remote/remoteEnvironmentGateway.ts index b983509..069a4c8 100644 --- a/apps/server/src/remote/remoteEnvironmentGateway.ts +++ b/apps/server/src/remote/remoteEnvironmentGateway.ts @@ -4,6 +4,8 @@ import { type ChatAuthor, connectorFields, type MessageDelivery, + type RunId, + type RunIngress, type SubagentInfo, type SubagentStatus, } from "@tangent/shared/contracts.ts"; @@ -30,6 +32,7 @@ import { } from "../pi/agentConfig.ts"; import type { SpawnedSubagent } from "../pi/piAgentManager.ts"; import type { AgentDescriptor, PiAgentHandlers } from "../pi/types.ts"; +import type { RunRegistry } from "../runs/runRegistry.ts"; import type { SessionStore } from "../store/sessionStore.ts"; /** Default and maximum number of transcript messages a room read returns. */ @@ -97,12 +100,18 @@ function toInfo(subagent: RemoteSubagent): SubagentInfo { * 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}. + * + * The protocol has no run-end marker: an environment reports its events and its + * agent's lifecycle, not run boundaries. So the gateway opens a Run when it + * sends work and settles it from what the protocol does say — the next piece of + * work for that participant, a status change, or the environment dropping. */ export class RemoteEnvironmentGateway { private readonly io: SocketIOServer; private readonly handlers: PiAgentHandlers; private readonly store: SessionStore; private readonly deliverToPrime: DeliverToPrime; + private readonly runs: RunRegistry; /** Connected environments, keyed by their handshake `environmentId`. */ private readonly environments = new Map(); @@ -114,11 +123,13 @@ export class RemoteEnvironmentGateway { handlers: PiAgentHandlers, store: SessionStore, deliverToPrime: DeliverToPrime, + runs: RunRegistry, ) { this.io = io; this.handlers = handlers; this.store = store; this.deliverToPrime = deliverToPrime; + this.runs = runs; this.setupNamespace(); } @@ -172,6 +183,13 @@ export class RemoteEnvironmentGateway { }; this.rosterFor(sessionId).set(agentId, subagent); + // An initial task is work, so it gets a Run; a sub-agent spawned idle does + // not until something asks it for something. + const runId = request.task?.trim() + ? this.runs.open({ sessionId, participantId: agentId, ingress: "tool" }) + .id + : undefined; + const command: RemoteSpawnCommand = { sessionId, agentId, @@ -183,6 +201,7 @@ export class RemoteEnvironmentGateway { template: request.template, task: request.task, autoRelayToPrime, + runId, }; environment.socket.emit(RemoteEnvEvents.Spawn, command); @@ -201,6 +220,9 @@ export class RemoteEnvironmentGateway { * `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. + * + * Opens a Run for the message and puts its id on the command, so the + * environment can echo it back on the events it streams. */ sendToAgent( sessionId: string, @@ -208,25 +230,36 @@ export class RemoteEnvironmentGateway { text: string, surfaceAuthor?: ChatAuthor, delivery: MessageDelivery = "auto", + ingress: RunIngress = "reaction", ): void { - const subagent = this.sessions.get(sessionId)?.get(agentId); - if (!subagent) return; - const environment = this.environments.get(subagent.environmentId); + const environment = this.environmentFor(sessionId, agentId); if (!environment) return; if (surfaceAuthor) { this.handlers.onAgentMessage(sessionId, agentId, surfaceAuthor, text); } + const run = this.runs.open({ sessionId, participantId: agentId, ingress }); const command: RemoteMessageCommand = { sessionId, agentId, text, delivery, + runId: run.id, }; environment.socket.emit(RemoteEnvEvents.Message, command); } + /** The connected environment hosting a sub-agent, if both are still live. */ + private environmentFor( + sessionId: string, + agentId: string, + ): RemoteEnvConnection | undefined { + const subagent = this.sessions.get(sessionId)?.get(agentId); + if (!subagent) return undefined; + return this.environments.get(subagent.environmentId); + } + /** * Terminates a remote sub-agent and records its terminal status. `completed` * marks a graceful finish; otherwise it is "killed". @@ -239,6 +272,11 @@ export class RemoteEnvironmentGateway { subagent.status = completed ? "completed" : "killed"; roster.delete(agentId); + this.runs.settleOpenFor( + sessionId, + agentId, + completed ? "completed" : "cancelled", + ); this.emitToEnvironment(subagent.environmentId, RemoteEnvEvents.Kill, { sessionId, agentId, @@ -334,11 +372,22 @@ export class RemoteEnvironmentGateway { this.handlers.onAgentEvent( payload.sessionId, this.descriptorFor(subagent), - payload.event, + { ...payload.event, runId: this.runIdFor(payload) }, ); this.relayEndToPrime(payload.sessionId, subagent, payload.event); } + /** + * The Run an inbound event belongs to: the id the environment echoed, or the + * one that participant currently has open. An environment that echoes nothing + * still gets attribution, and an echoed id the server no longer holds open is + * still trusted — it names the work, and the store has the rest. + */ + private runIdFor(payload: RemoteAgentEventPayload): RunId | undefined { + if (payload.runId) return payload.runId; + return this.runs.current(payload.sessionId, payload.agentId)?.id; + } + /** Feeds a finalized auto-relay reply into Prime as it lands. */ private relayEndToPrime( sessionId: string, @@ -360,7 +409,16 @@ export class RemoteEnvironmentGateway { if (!roster || !subagent) return; subagent.status = payload.status; - if (payload.status !== "active") roster.delete(payload.agentId); + if (payload.status !== "active") { + roster.delete(payload.agentId); + // The agent leaving `active` is the closest thing the protocol has to a + // run-end marker: whatever it was working on is over either way. + this.runs.settleOpenFor( + payload.sessionId, + payload.agentId, + payload.status === "completed" ? "completed" : "failed", + ); + } this.handlers.onSubagentUpdate(payload.sessionId, toInfo(subagent)); } @@ -415,6 +473,8 @@ export class RemoteEnvironmentGateway { if (subagent.environmentId !== environmentId) continue; subagent.status = "error"; roster.delete(subagent.agentId); + // The far side is gone mid-work: the Run stopped without finishing. + this.runs.settleOpenFor(sessionId, subagent.agentId, "failed"); this.handlers.onSubagentUpdate(sessionId, toInfo(subagent)); } } diff --git a/apps/server/src/routes/internalAgents.ts b/apps/server/src/routes/internalAgents.ts index 83c1ff7..26317f1 100644 --- a/apps/server/src/routes/internalAgents.ts +++ b/apps/server/src/routes/internalAgents.ts @@ -130,13 +130,15 @@ function handleMessage( body: MessageInput, res: Response, ): void { - // Attributed to Prime (message_subagent is always a Prime-issued directive). + // Attributed to Prime (message_subagent is always a Prime-issued directive), + // and a tool call is what creates the work. const { sessionId, agentId, text } = body; const { delivered, reason } = connectors.resolve(sessionId, agentId).deliver({ sessionId, participantId: agentId, text, surfaceAuthor: PI_AGENT, + ingress: "tool", }); res.json({ ok: delivered, ...(reason ? { error: reason } : {}) }); } diff --git a/apps/server/src/routes/internalExternalAgents.ts b/apps/server/src/routes/internalExternalAgents.ts index 37e0bbf..5c34500 100644 --- a/apps/server/src/routes/internalExternalAgents.ts +++ b/apps/server/src/routes/internalExternalAgents.ts @@ -1,5 +1,5 @@ import type { RemoteAgentEvent } from "@tangent/shared/remoteSubagent.ts"; -import { Router } from "express"; +import { type Response, Router } from "express"; import { z } from "zod"; import type { ExternalSubagentGateway } from "../external/externalSubagentGateway.ts"; @@ -22,9 +22,32 @@ const eventSchema = z.object({ sessionId: z.string(), agentId: z.string(), event: z.object({ type: z.string() }).passthrough(), + /** The run the event belongs to; resolved from the tab when omitted. */ + runId: z.string().optional(), }); type EventBody = z.infer; +/** Run body: open a run for one turn of external work. */ +const runSchema = z.object({ + sessionId: z.string(), + agentId: z.string(), + /** The far side's own id for the work (e.g. an Aquifer World session id). */ + externalId: z.string().optional(), + /** Where the far side's stream is being read from, to resume by. */ + cursor: z.string().optional(), +}); +type RunBody = z.infer; + +/** Run-end body: settle a run, recording how far its stream was read. */ +const runEndSchema = z.object({ + sessionId: z.string(), + agentId: z.string(), + status: z.enum(["completed", "cancelled", "failed"]), + runId: z.string().optional(), + cursor: z.string().optional(), +}); +type RunEndBody = z.infer; + /** Status body: a lifecycle status change for an external sub-agent tab. */ const statusSchema = z.object({ sessionId: z.string(), @@ -33,6 +56,23 @@ const statusSchema = z.object({ }); type StatusBody = z.infer; +/** Opens a run for a turn of external work, answering with its id. */ +function handleOpenRun( + gateway: ExternalSubagentGateway, + body: RunBody, + res: Response, +): void { + const runId = gateway.openRun(body.sessionId, body.agentId, { + externalId: body.externalId, + cursor: body.cursor, + }); + if (!runId) { + res.status(404).json({ error: "Unknown external sub-agent." }); + return; + } + res.json({ runId }); +} + /** * 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 @@ -59,12 +99,28 @@ export function createInternalExternalAgentsRouter( res.json({ subagent: { id } }); }); + // A turn of external work is a Run: the driving tool declares its start and + // end, because only it can see the far side's boundaries. + router.post("/run", validate({ body: runSchema }), (req, res) => + handleOpenRun(gateway, getValidated(req).body, res), + ); + + router.post("/run/end", validate({ body: runEndSchema }), (req, res) => { + const body = getValidated(req).body; + gateway.endRun(body.sessionId, body.agentId, body.status, { + runId: body.runId, + cursor: body.cursor, + }); + res.json({ ok: true }); + }); + 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, + body.runId, ); res.json({ ok: true }); }); diff --git a/apps/server/src/runs/runRegistry.test.ts b/apps/server/src/runs/runRegistry.test.ts new file mode 100644 index 0000000..7b2fa4a --- /dev/null +++ b/apps/server/src/runs/runRegistry.test.ts @@ -0,0 +1,159 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { after, test } from "node:test"; + +// Point the session root at a throwaway dir before importing modules that read +// config at load time, so creating a session never touches the repo. +const ROOT = mkdtempSync(path.join(tmpdir(), "run-registry-")); +process.env.SESSIONS_ROOT = ROOT; + +const { openDb } = await import("../store/db/client.ts"); +const { SqliteRunStore } = await import("../store/sqliteRunStore.ts"); +const { SqliteSessionStore } = await import("../store/sqliteSessionStore.ts"); +const { RunRegistry } = await import("./runRegistry.ts"); + +after(() => rmSync(ROOT, { recursive: true, force: true })); + +/** + * A registry over a fresh in-memory DB, plus the session its runs hang off: + * `runs.session_id` is a real foreign key, so a run needs a real session. + */ +async function newRegistry() { + const db = openDb(":memory:"); + const store = new SqliteRunStore(db); + const session = await new SqliteSessionStore(db).createSession({ name: "S" }); + return { runs: new RunRegistry(store), store, sessionId: session.id }; +} + +/** Yields to the microtask queue so write-through persistence has landed. */ +function flush(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} + +test("opening a run persists it as running and makes it current", async () => { + const h = await newRegistry(); + + const run = h.runs.open({ + sessionId: h.sessionId, + participantId: "prime", + ingress: "reaction", + }); + await flush(); + + assert.equal(run.status, "running"); + assert.equal(run.homeConversationId, "prime"); + assert.equal(h.runs.current(h.sessionId, "prime")?.id, run.id); + assert.equal(h.runs.get(run.id)?.id, run.id); + const stored = await h.store.getRun(run.id); + assert.equal(stored?.status, "running"); + assert.equal(stored?.ingress, "reaction"); + assert.equal(stored?.endedAt, undefined); +}); + +test("a participant has one run at a time: opening settles the previous", async () => { + const h = await newRegistry(); + + const first = h.runs.open({ + sessionId: h.sessionId, + participantId: "prime", + ingress: "reaction", + }); + const second = h.runs.open({ + sessionId: h.sessionId, + participantId: "prime", + ingress: "schedule", + }); + await flush(); + + assert.equal(h.runs.current(h.sessionId, "prime")?.id, second.id); + assert.equal(h.runs.get(first.id), undefined); + assert.equal((await h.store.getRun(first.id))?.status, "completed"); + assert.equal((await h.store.getRun(second.id))?.status, "running"); +}); + +test("runs are per participant, not per session", async () => { + const h = await newRegistry(); + + const prime = h.runs.open({ + sessionId: h.sessionId, + participantId: "prime", + ingress: "reaction", + }); + const worker = h.runs.open({ + sessionId: h.sessionId, + participantId: "worker-1", + ingress: "tool", + }); + + assert.equal(h.runs.current(h.sessionId, "prime")?.id, prime.id); + assert.equal(h.runs.current(h.sessionId, "worker-1")?.id, worker.id); +}); + +test("settling records which terminal state a run reached", async () => { + const h = await newRegistry(); + + const run = h.runs.open({ + sessionId: h.sessionId, + participantId: "prime", + ingress: "reaction", + }); + h.runs.settle(run.id, "cancelled"); + await flush(); + + assert.equal(h.runs.current(h.sessionId, "prime"), undefined); + const stored = await h.store.getRun(run.id); + assert.equal(stored?.status, "cancelled"); + assert.ok(stored?.endedAt); +}); + +test("a connector's cursor and external id round-trip through the store", async () => { + const h = await newRegistry(); + + const run = h.runs.open({ + sessionId: h.sessionId, + participantId: "worker-1", + ingress: "tool", + externalId: "aquifer-session-1", + cursor: "0", + }); + h.runs.setCursor(run.id, "42"); + h.runs.setExternalId(run.id, "aquifer-session-2"); + await flush(); + + assert.equal(h.runs.current(h.sessionId, "worker-1")?.cursor, "42"); + const stored = await h.store.getRun(run.id); + assert.equal(stored?.cursor, "42"); + assert.equal(stored?.externalId, "aquifer-session-2"); +}); + +test("failStaleRuns settles runs a previous process left running", async () => { + const h = await newRegistry(); + + const run = h.runs.open({ + sessionId: h.sessionId, + participantId: "prime", + ingress: "reaction", + }); + await flush(); + + // A fresh registry over the same store is the next process: it holds nothing + // in memory, so the row is residue. + const next = new RunRegistry(h.store); + assert.equal(await next.failStaleRuns(), 1); + + const stored = await h.store.getRun(run.id); + assert.equal(stored?.status, "failed"); + assert.ok(stored?.endedAt); + assert.equal(await next.failStaleRuns(), 0); +}); + +test("settling an unknown run is a no-op", async () => { + const h = await newRegistry(); + + h.runs.settle("no-such-run", "completed"); + h.runs.settleOpenFor(h.sessionId, "prime", "completed"); + + assert.equal(h.runs.get("no-such-run"), undefined); +}); diff --git a/apps/server/src/runs/runRegistry.ts b/apps/server/src/runs/runRegistry.ts new file mode 100644 index 0000000..c724fe5 --- /dev/null +++ b/apps/server/src/runs/runRegistry.ts @@ -0,0 +1,167 @@ +import { randomUUID } from "node:crypto"; + +import type { + Run, + RunId, + RunIngress, + RunStatus, +} from "@tangent/shared/contracts.ts"; + +import type { RunStore } from "../store/runStore.ts"; + +/** What a connector supplies to open a {@link Run}. */ +export interface OpenRunInput { + sessionId: string; + participantId: string; + /** Defaults to `participantId`: a conversation is agent-keyed for now. */ + homeConversationId?: string; + ingress: RunIngress; + /** The far side's own id for this work, when the connector has one. */ + externalId?: string; + /** Initial resume cursor, when the connector streams by cursor. */ + cursor?: string; +} + +/** The terminal states {@link RunRegistry.settle} accepts. */ +export type SettledStatus = Exclude; + +/** Composite key of the at-most-one Run a participant has open. */ +function keyFor(sessionId: string, participantId: string): string { + return `${sessionId}\u0000${participantId}`; +} + +/** + * The server's index of {@link Run}s: which participant is working, under which + * id, so a stream can be attributed and cancellation has something to act on. + * + * Open Runs are held in memory and written through to the {@link RunStore}. In + * memory is authoritative for "what is running now" — a Run only exists while + * the process that opened it lives — and the table is the durable record of what + * ran, which is what makes a Run inspectable after the fact. + * + * Runs are serial per participant: {@link open} settles whichever Run that + * participant still had open. That is what bounds a connector whose protocol has + * no run-end marker. + */ +export class RunRegistry { + private readonly store: RunStore; + /** Open runs by `(sessionId, participantId)`. */ + private readonly byParticipant = new Map(); + /** The same runs by id, so a client-supplied run id resolves directly. */ + private readonly byId = new Map(); + + constructor(store: RunStore) { + this.store = store; + } + + /** + * Opens a Run for a participant, settling any Run it still had open as + * `completed`. Returns synchronously — the caller is on the streaming path — + * with persistence following behind. + */ + open(input: OpenRunInput): Run { + this.settleOpenFor(input.sessionId, input.participantId, "completed"); + + const now = new Date().toISOString(); + const run: Run = { + id: randomUUID(), + sessionId: input.sessionId, + participantId: input.participantId, + homeConversationId: input.homeConversationId ?? input.participantId, + status: "running", + ingress: input.ingress, + externalId: input.externalId, + cursor: input.cursor, + createdAt: now, + updatedAt: now, + }; + + this.byParticipant.set(keyFor(run.sessionId, run.participantId), run); + this.byId.set(run.id, run); + void this.store + .createRun({ + id: run.id, + sessionId: run.sessionId, + participantId: run.participantId, + homeConversationId: run.homeConversationId, + ingress: run.ingress, + externalId: run.externalId, + cursor: run.cursor, + }) + .catch((err) => console.error("[runs] createRun failed:", err)); + return run; + } + + /** The Run a participant currently has open, if any. */ + current(sessionId: string, participantId: string): Run | undefined { + return this.byParticipant.get(keyFor(sessionId, participantId)); + } + + /** An open Run by id. Settled Runs live only in the store. */ + get(runId: RunId): Run | undefined { + return this.byId.get(runId); + } + + /** Settles a Run, recording which terminal state it reached. */ + settle(runId: RunId, status: SettledStatus): void { + const run = this.byId.get(runId); + if (!run) return; + this.forget(run); + this.persist(run.id, { status, endedAt: new Date().toISOString() }); + } + + /** Settles the Run a participant has open, if it has one. */ + settleOpenFor( + sessionId: string, + participantId: string, + status: SettledStatus, + ): void { + const run = this.current(sessionId, participantId); + if (!run) return; + this.settle(run.id, status); + } + + /** Records a connector's resume cursor against an open Run. */ + setCursor(runId: RunId, cursor: string): void { + const run = this.byId.get(runId); + if (!run) return; + run.cursor = cursor; + this.persist(runId, { cursor }); + } + + /** Records the far side's own id against an open Run. */ + setExternalId(runId: RunId, externalId: string): void { + const run = this.byId.get(runId); + if (!run) return; + run.externalId = externalId; + this.persist(runId, { externalId }); + } + + /** + * Settles every row the store still has marked `running`. Called once at + * startup: nothing can be running before the process starts, so such a row is + * a previous process's Run that never got to settle. + */ + async failStaleRuns(): Promise { + return this.store.failStaleRuns(); + } + + /** Drops a Run from both indexes. */ + private forget(run: Run): void { + this.byId.delete(run.id); + const key = keyFor(run.sessionId, run.participantId); + if (this.byParticipant.get(key)?.id === run.id) { + this.byParticipant.delete(key); + } + } + + /** Write-through persistence; a failed write must not break a stream. */ + private persist( + runId: RunId, + input: Parameters[1], + ): void { + void this.store + .updateRun(runId, input) + .catch((err) => console.error("[runs] updateRun failed:", err)); + } +} diff --git a/apps/server/src/sockets/chat.ts b/apps/server/src/sockets/chat.ts index e461e75..6082801 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 RunId, type Session, type SessionStatusPayload, type SessionStatusSnapshotPayload, @@ -134,6 +135,8 @@ interface EmitContext { sessionId: string; conversationId: string; author: ChatAuthor; + /** The Run that produced the event, when the connector attributed one. */ + runId?: RunId; } function emitStart(io: Server, ctx: EmitContext, messageId: string): void { @@ -144,7 +147,7 @@ function emitStart(io: Server, ctx: EmitContext, messageId: string): void { ctx.author, "", ); - const payload: AgentStartPayload = { message }; + const payload: AgentStartPayload = { message, runId: ctx.runId }; io.to(ctx.room).emit(SocketEvents.AgentStart, payload); } @@ -157,6 +160,7 @@ function emitDelta( sessionId: ctx.sessionId, messageId: event.messageId, delta: event.delta, + runId: ctx.runId, }; io.to(ctx.room).emit(SocketEvents.AgentDelta, payload); } @@ -170,6 +174,7 @@ function emitThinking( sessionId: ctx.sessionId, messageId: event.messageId, delta: event.delta, + runId: ctx.runId, }; io.to(ctx.room).emit(SocketEvents.AgentThinking, payload); } @@ -188,9 +193,10 @@ function emitEnd( event.content, event.thinking, ); - // Persist before broadcasting so reconnecting clients see it in history. + // Persist before broadcasting so reconnecting clients see it in history. The + // run id rides the payload, not the message: what is persisted is unchanged. void store.appendMessage(message).then(() => { - const payload: AgentEndPayload = { message }; + const payload: AgentEndPayload = { message, runId: ctx.runId }; io.to(ctx.room).emit(SocketEvents.AgentEnd, payload); }); } @@ -204,6 +210,7 @@ function emitError( sessionId: ctx.sessionId, messageId: event.messageId, message: event.message, + runId: ctx.runId, }; io.to(ctx.room).emit(SocketEvents.AgentError, payload); } @@ -217,6 +224,7 @@ function emitActivity( sessionId: ctx.sessionId, conversationId: ctx.conversationId, activity, + runId: ctx.runId, }; io.to(ctx.room).emit(SocketEvents.AgentActivity, payload); } @@ -231,6 +239,7 @@ function emitQueue( conversationId: ctx.conversationId, steering: event.steering, followUp: event.followUp, + runId: ctx.runId, }; io.to(ctx.room).emit(SocketEvents.AgentQueue, payload); } @@ -253,6 +262,7 @@ export function createAgentEventHandler( sessionId, conversationId: agent.agentId, author: authorFor(agent), + runId: event.runId, }; relayStreamingEvent(io, ctx, event); relayTerminalEvent(io, store, ctx, event); @@ -500,6 +510,31 @@ function handleMemoryDismiss( ); } +/** + * Cancels a participant's Run through whichever connector holds it. The client + * names the Run when it is tracking one; otherwise the connector cancels + * whatever that participant has open. A refusal (a transport with no cancel + * protocol, or nothing running) is logged rather than surfaced: the user asked + * to stop something that isn't stoppable, and a system message in the thread + * would be noise. + */ +function handleAgentAbort( + connectors: ConnectorRegistry, + payload: AgentAbortPayload, +): void { + const sessionId = payload?.sessionId; + const participantId = payload?.conversationId; + if (!sessionId || !participantId) return; + + const { cancelled, reason } = connectors.cancelRun({ + sessionId, + participantId, + runId: payload.runId, + }); + if (cancelled) return; + console.log(`[runs] cancel refused for ${participantId}: ${reason}`); +} + /** * Registers chat (and a reserved terminal) handlers on the Socket.IO server. * @@ -534,7 +569,7 @@ function wireSocket(socket: Socket, deps: ChatHandlerDeps): void { ); socket.on(SocketEvents.AgentAbort, (payload: AgentAbortPayload) => - pi.abort(payload?.sessionId, payload?.conversationId), + handleAgentAbort(connectors, payload), ); socket.on(SocketEvents.AgentSetModel, (payload: AgentSetModelPayload) => diff --git a/apps/server/src/store/db/migrations/0007_complex_puppet_master.sql b/apps/server/src/store/db/migrations/0007_complex_puppet_master.sql new file mode 100644 index 0000000..a16f00e --- /dev/null +++ b/apps/server/src/store/db/migrations/0007_complex_puppet_master.sql @@ -0,0 +1,17 @@ +CREATE TABLE `runs` ( + `id` text PRIMARY KEY NOT NULL, + `session_id` text NOT NULL, + `participant_id` text NOT NULL, + `home_conversation_id` text NOT NULL, + `status` text DEFAULT 'running' NOT NULL, + `ingress` text NOT NULL, + `external_id` text, + `cursor` text, + `created_at` text NOT NULL, + `updated_at` text NOT NULL, + `ended_at` text, + FOREIGN KEY (`session_id`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `runs_session_idx` ON `runs` (`session_id`);--> statement-breakpoint +CREATE INDEX `runs_session_participant_idx` ON `runs` (`session_id`,`participant_id`); \ No newline at end of file diff --git a/apps/server/src/store/db/migrations/meta/0007_snapshot.json b/apps/server/src/store/db/migrations/meta/0007_snapshot.json new file mode 100644 index 0000000..0789af2 --- /dev/null +++ b/apps/server/src/store/db/migrations/meta/0007_snapshot.json @@ -0,0 +1,473 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "c846f8a6-30ed-4409-a4ac-0c34ac1c3ac4", + "prevId": "1564ffb2-f809-481c-ac44-320b83280fa8", + "tables": { + "runs": { + "name": "runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "participant_id": { + "name": "participant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "home_conversation_id": { + "name": "home_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "ingress": { + "name": "ingress", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": 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 + }, + "ended_at": { + "name": "ended_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "runs_session_idx": { + "name": "runs_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "runs_session_participant_idx": { + "name": "runs_session_participant_idx", + "columns": ["session_id", "participant_id"], + "isUnique": false + } + }, + "foreignKeys": { + "runs_session_id_sessions_id_fk": { + "name": "runs_session_id_sessions_id_fk", + "tableFrom": "runs", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "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'" + }, + "connector_kind": { + "name": "connector_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_lifecycle": { + "name": "connector_lifecycle", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_environment_id": { + "name": "connector_environment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "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 6c58b4c..c682d5e 100644 --- a/apps/server/src/store/db/migrations/meta/_journal.json +++ b/apps/server/src/store/db/migrations/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1786467242333, "tag": "0006_fresh_nico_minoru", "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1786472001817, + "tag": "0007_complex_puppet_master", + "breakpoints": true } ] } diff --git a/apps/server/src/store/db/schema.ts b/apps/server/src/store/db/schema.ts index 024ef7c..e9bd0a9 100644 --- a/apps/server/src/store/db/schema.ts +++ b/apps/server/src/store/db/schema.ts @@ -128,6 +128,45 @@ export const sessionAgents = sqliteTable( ], ); +/** + * One unit of work by one participant: what a stream of agent events is + * attributable to, and what cancellation acts on. Runs are serial per + * participant, so `(session_id, participant_id)` with `status = 'running'` + * identifies at most one row. + */ +export const runs = sqliteTable( + "runs", + { + id: text("id").primaryKey(), + sessionId: text("session_id") + .notNull() + .references(() => sessions.id, { onDelete: "cascade" }), + /** The participant doing the work (an agent id today). */ + participantId: text("participant_id").notNull(), + /** The conversation this run's messages land in by default. */ + homeConversationId: text("home_conversation_id").notNull(), + /** `running` | `completed` | `cancelled` | `failed`. */ + status: text("status").notNull().default("running"), + /** `reaction` | `schedule` | `webhook` | `tool`. */ + ingress: text("ingress").notNull(), + /** The far side's own id for this work (an Aquifer World session id). */ + externalId: text("external_id"), + /** Connector-private resume cursor (the Aquifer drain's `lastSeq`). */ + cursor: text("cursor"), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull(), + /** Set when the run settled; null while it is `running`. */ + endedAt: text("ended_at"), + }, + (table) => [ + index("runs_session_idx").on(table.sessionId), + index("runs_session_participant_idx").on( + table.sessionId, + table.participantId, + ), + ], +); + /** When each user last opened a session. `user_key` is the email, or `local`. */ export const sessionViews = sqliteTable( "session_views", @@ -147,3 +186,4 @@ export const sessionViews = sqliteTable( export type SessionRow = typeof sessions.$inferSelect; export type SessionAssetRow = typeof sessionAssets.$inferSelect; export type SessionAgentRow = typeof sessionAgents.$inferSelect; +export type RunRow = typeof runs.$inferSelect; diff --git a/apps/server/src/store/inMemoryRunStore.ts b/apps/server/src/store/inMemoryRunStore.ts new file mode 100644 index 0000000..caa43a4 --- /dev/null +++ b/apps/server/src/store/inMemoryRunStore.ts @@ -0,0 +1,65 @@ +import type { Run, RunId } from "@tangent/shared/contracts.ts"; + +import type { CreateRunInput, RunStore, UpdateRunInput } from "./runStore.ts"; + +/** + * Process-local {@link RunStore}, mirroring + * {@link import("./inMemorySessionStore.ts").InMemorySessionStore}. Runs vanish + * with the process, so this is for tests and for wiring a run registry that has + * no DB to write to. + */ +export class InMemoryRunStore implements RunStore { + private readonly runs = new Map(); + + async createRun(input: CreateRunInput): Promise { + const now = new Date().toISOString(); + const run: Run = { + ...input, + status: "running", + createdAt: now, + updatedAt: now, + }; + this.runs.set(run.id, run); + return run; + } + + async updateRun(id: RunId, input: UpdateRunInput): Promise { + const run = this.runs.get(id); + if (!run) return; + // Absent fields leave the stored value alone, matching the SQLite store. + this.runs.set(id, { + ...run, + status: input.status ?? run.status, + externalId: input.externalId ?? run.externalId, + cursor: input.cursor ?? run.cursor, + endedAt: input.endedAt ?? run.endedAt, + updatedAt: new Date().toISOString(), + }); + } + + async getRun(id: RunId): Promise { + return this.runs.get(id); + } + + async listRuns(sessionId: string): Promise { + return [...this.runs.values()] + .filter((run) => run.sessionId === sessionId) + .sort((a, b) => a.createdAt.localeCompare(b.createdAt)); + } + + async failStaleRuns(): Promise { + const now = new Date().toISOString(); + let failed = 0; + for (const [id, run] of this.runs) { + if (run.status !== "running") continue; + this.runs.set(id, { + ...run, + status: "failed", + endedAt: now, + updatedAt: now, + }); + failed += 1; + } + return failed; + } +} diff --git a/apps/server/src/store/runStore.ts b/apps/server/src/store/runStore.ts new file mode 100644 index 0000000..faebbdc --- /dev/null +++ b/apps/server/src/store/runStore.ts @@ -0,0 +1,49 @@ +import type { + Run, + RunId, + RunIngress, + RunStatus, +} from "@tangent/shared/contracts.ts"; + +/** The fields a caller supplies to persist a new {@link Run}. */ +export interface CreateRunInput { + id: RunId; + sessionId: string; + participantId: string; + homeConversationId: string; + ingress: RunIngress; + externalId?: string; + cursor?: string; +} + +/** + * The mutable fields of a {@link Run}. Omitted fields are left alone, matching + * how {@link import("./sessionStore.ts").RecordAgentInput} treats a partial + * write. + */ +export interface UpdateRunInput { + status?: RunStatus; + externalId?: string; + cursor?: string; + endedAt?: string; +} + +/** + * Durable home of the session's {@link Run}s. Kept apart from + * {@link import("./sessionStore.ts").SessionStore} because a Run is not session + * metadata: it is short-lived, written on the streaming hot path, and read by + * the run registry rather than by the REST routes. + */ +export interface RunStore { + createRun(input: CreateRunInput): Promise; + updateRun(id: RunId, input: UpdateRunInput): Promise; + getRun(id: RunId): Promise; + /** The session's runs, oldest first. */ + listRuns(sessionId: string): Promise; + /** + * Settles every row still marked `running` as `failed`. Nothing can be + * running before the process starts, so a `running` row at boot is the + * residue of a previous process that never got to settle it. + */ + failStaleRuns(): Promise; +} diff --git a/apps/server/src/store/sqliteRunStore.ts b/apps/server/src/store/sqliteRunStore.ts new file mode 100644 index 0000000..78bb6a2 --- /dev/null +++ b/apps/server/src/store/sqliteRunStore.ts @@ -0,0 +1,97 @@ +import type { + Run, + RunId, + RunIngress, + RunStatus, +} from "@tangent/shared/contracts.ts"; +import { asc, eq } from "drizzle-orm"; + +import type { Db } from "./db/client.ts"; +import { type RunRow, runs } from "./db/schema.ts"; +import type { CreateRunInput, RunStore, UpdateRunInput } from "./runStore.ts"; + +/** Maps a runs row onto the wire {@link Run}. */ +function toRun(row: RunRow): Run { + return { + id: row.id, + sessionId: row.sessionId, + participantId: row.participantId, + homeConversationId: row.homeConversationId, + status: row.status as RunStatus, + ingress: row.ingress as RunIngress, + externalId: row.externalId ?? undefined, + cursor: row.cursor ?? undefined, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + endedAt: row.endedAt ?? undefined, + }; +} + +/** SQLite-backed {@link RunStore} over the shared session metadata DB. */ +export class SqliteRunStore implements RunStore { + private readonly db: Db; + + constructor(db: Db) { + this.db = db; + } + + async createRun(input: CreateRunInput): Promise { + const now = new Date().toISOString(); + const row: RunRow = { + id: input.id, + sessionId: input.sessionId, + participantId: input.participantId, + homeConversationId: input.homeConversationId, + status: "running", + ingress: input.ingress, + externalId: input.externalId ?? null, + cursor: input.cursor ?? null, + createdAt: now, + updatedAt: now, + endedAt: null, + }; + this.db.insert(runs).values(row).run(); + return toRun(row); + } + + async updateRun(id: RunId, input: UpdateRunInput): Promise { + // Drizzle omits `undefined` fields, so an absent field leaves the stored + // value untouched. + this.db + .update(runs) + .set({ + status: input.status, + externalId: input.externalId, + cursor: input.cursor, + endedAt: input.endedAt, + updatedAt: new Date().toISOString(), + }) + .where(eq(runs.id, id)) + .run(); + } + + async getRun(id: RunId): Promise { + const row = this.db.select().from(runs).where(eq(runs.id, id)).get(); + return row ? toRun(row) : undefined; + } + + async listRuns(sessionId: string): Promise { + const rows = this.db + .select() + .from(runs) + .where(eq(runs.sessionId, sessionId)) + .orderBy(asc(runs.createdAt)) + .all(); + return rows.map(toRun); + } + + async failStaleRuns(): Promise { + const now = new Date().toISOString(); + const result = this.db + .update(runs) + .set({ status: "failed", endedAt: now, updatedAt: now }) + .where(eq(runs.status, "running")) + .run(); + return result.changes; + } +} diff --git a/apps/web/src/features/chat/hooks/useSessionChat.ts b/apps/web/src/features/chat/hooks/useSessionChat.ts index 8829e7a..75205f9 100644 --- a/apps/web/src/features/chat/hooks/useSessionChat.ts +++ b/apps/web/src/features/chat/hooks/useSessionChat.ts @@ -21,6 +21,7 @@ import { type MessageDelivery, PI_AGENT, type PinnedArtifact, + type RunId, type Session, SocketEvents, type SubagentInfo, @@ -121,6 +122,14 @@ export function useSessionChat(sessionId: string) { // Maps an in-flight message id to its conversation so `agent:error` (which // only carries a messageId) can clear the right thread's streaming state. const conversationByMessageId = useRef>(new Map()); + // The run each conversation is currently working under, so `abort` can name + // the run it means. Refs, not state: nothing renders from either, and the + // conversation-keyed state above already drives every visual. + const runIdByConversation = useRef>(new Map()); + // Runs with a message mid-stream. `agent:activity` going null also happens + // between messages within a run, so this is what distinguishes "the run's last + // event" from "the spinner cleared because text started arriving". + const streamingRuns = useRef>(new Set()); // The current human's chat identity, derived from `GET /api/me`. Using the // email as the author id keeps "is this my message?" detection stable across @@ -174,6 +183,23 @@ export function useSessionChat(sessionId: string) { for (const id of statuses.keys()) publish(id); }; + // Follows a conversation's run through the activity indicator: a non-null + // activity records which run is working, and a null one with nothing + // streaming is the run's last event, so the conversation has no run again. + const trackRunActivity = ( + conversationId: string, + activity: AgentActivity | null, + runId: RunId | undefined, + ) => { + if (activity) { + if (runId) runIdByConversation.current.set(conversationId, runId); + return; + } + const current = runId ?? runIdByConversation.current.get(conversationId); + if (!current || streamingRuns.current.has(current)) return; + runIdByConversation.current.delete(conversationId); + }; + socket.on("connect", () => { // Reset on (re)connect rather than synchronously in the effect body so we // don't trigger cascading renders; history and roster repopulate via the @@ -190,6 +216,8 @@ export function useSessionChat(sessionId: string) { setActivityByConversation(new Map()); setMemorySuggestions([]); conversationByMessageId.current.clear(); + runIdByConversation.current.clear(); + streamingRuns.current.clear(); // Reset the published statuses; the join snapshot (roster + replayed // activity) republishes them. Prime is present immediately. streaming.clear(); @@ -206,6 +234,8 @@ export function useSessionChat(sessionId: string) { setActivityByConversation(new Map()); setMemorySuggestions([]); conversationByMessageId.current.clear(); + runIdByConversation.current.clear(); + streamingRuns.current.clear(); // Nothing is running while disconnected; clear the busy inputs and // republish every known agent as idle (keeping their lifecycle status). streaming.clear(); @@ -223,22 +253,29 @@ export function useSessionChat(sessionId: string) { // An agent begins a (new) message: append an empty placeholder we fill via // deltas and mark that conversation's message stream in flight. - socket.on(SocketEvents.AgentStart, ({ message }: AgentStartPayload) => { - conversationByMessageId.current.set(message.id, message.conversationId); - setStreamingConversations((prev) => { - const next = new Set(prev); - next.add(message.conversationId); - return next; - }); - setStreamingMessageIds((prev) => { - const next = new Set(prev); - next.add(message.id); - return next; - }); - setMessages((prev) => [...prev, message]); - streaming.add(message.conversationId); - publish(message.conversationId); - }); + socket.on( + SocketEvents.AgentStart, + ({ message, runId }: AgentStartPayload) => { + conversationByMessageId.current.set(message.id, message.conversationId); + if (runId) { + runIdByConversation.current.set(message.conversationId, runId); + streamingRuns.current.add(runId); + } + setStreamingConversations((prev) => { + const next = new Set(prev); + next.add(message.conversationId); + return next; + }); + setStreamingMessageIds((prev) => { + const next = new Set(prev); + next.add(message.id); + return next; + }); + setMessages((prev) => [...prev, message]); + streaming.add(message.conversationId); + publish(message.conversationId); + }, + ); // Streamed token: append it to the matching in-flight message. socket.on( SocketEvents.AgentDelta, @@ -266,8 +303,9 @@ export function useSessionChat(sessionId: string) { // A single message finished: replace its placeholder with the final // message and end that message's stream. The run may still be busy (the // activity indicator drives that); message streaming is cleared here. - socket.on(SocketEvents.AgentEnd, ({ message }: AgentEndPayload) => { + socket.on(SocketEvents.AgentEnd, ({ message, runId }: AgentEndPayload) => { conversationByMessageId.current.delete(message.id); + if (runId) streamingRuns.current.delete(runId); setStreamingConversations((prev) => { if (!prev.has(message.conversationId)) return prev; const next = new Set(prev); @@ -290,7 +328,8 @@ export function useSessionChat(sessionId: string) { // ephemeral spinner bubble; null clears it (message streaming / run idle). socket.on( SocketEvents.AgentActivity, - ({ conversationId, activity }: AgentActivityPayload) => { + ({ conversationId, activity, runId }: AgentActivityPayload) => { + trackRunActivity(conversationId, activity, runId); setActivityByConversation((prev) => { const next = new Map(prev); if (activity) { @@ -310,11 +349,13 @@ export function useSessionChat(sessionId: string) { ); socket.on( SocketEvents.AgentError, - ({ messageId, message }: AgentErrorPayload) => { + ({ messageId, message, runId }: AgentErrorPayload) => { const conversationId = messageId ? conversationByMessageId.current.get(messageId) : undefined; if (messageId) conversationByMessageId.current.delete(messageId); + if (runId) streamingRuns.current.delete(runId); + if (conversationId) runIdByConversation.current.delete(conversationId); if (messageId) { setStreamingMessageIds((prev) => { if (!prev.has(messageId)) return prev; @@ -465,12 +506,17 @@ export function useSessionChat(sessionId: string) { socket.emit(SocketEvents.ChatMessage, payload); } - // Aborts an agent's in-progress run by id (`"prime"` or a sub-agent id). The - // server resets the run's state and the UI clears via the usual agent events. + // Cancels the run an agent (`"prime"` or a sub-agent id) is working under, + // naming the run when we know it so a run that has since been replaced isn't + // the one cancelled. The UI clears via the usual agent events. function abort(conversationId: string) { const socket = socketRef.current; if (!socket) return; - const payload: AgentAbortPayload = { sessionId, conversationId }; + const payload: AgentAbortPayload = { + sessionId, + conversationId, + runId: runIdByConversation.current.get(conversationId), + }; socket.emit(SocketEvents.AgentAbort, payload); } diff --git a/packages/remote-subagent/src/index.ts b/packages/remote-subagent/src/index.ts index fd5a1dc..473c87c 100644 --- a/packages/remote-subagent/src/index.ts +++ b/packages/remote-subagent/src/index.ts @@ -12,7 +12,11 @@ * obvious. */ -import type { ChatMessage, SubagentStatus } from "@tangent/shared/contracts.ts"; +import type { + ChatMessage, + RunId, + SubagentStatus, +} from "@tangent/shared/contracts.ts"; import { REMOTE_ENV_NAMESPACE, type RemoteAgentEvent, @@ -66,8 +70,18 @@ export interface ConnectRemoteEnvironmentOptions { 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; + /** + * Stream a single agent event (start/delta/thinking/end/...) to the server. + * Pass the `runId` from the command that asked for this work to attribute the + * event to it; without one the server attributes it to whatever that + * sub-agent has open. + */ + agentEvent( + sessionId: string, + agentId: string, + event: RemoteAgentEvent, + runId?: RunId, + ): void; /** Push a sub-agent's lifecycle status change to the server. */ subagentUpdate( sessionId: string, @@ -157,8 +171,13 @@ export function connectRemoteEnvironment( return { socket, - agentEvent(sessionId, agentId, event) { - const payload: RemoteAgentEventPayload = { sessionId, agentId, event }; + agentEvent(sessionId, agentId, event, runId) { + const payload: RemoteAgentEventPayload = { + sessionId, + agentId, + event, + runId, + }; socket.emit(RemoteEnvEvents.AgentEvent, payload); }, subagentUpdate(sessionId, agentId, status) { diff --git a/packages/shared/src/contracts.ts b/packages/shared/src/contracts.ts index f9c945d..4773028 100644 --- a/packages/shared/src/contracts.ts +++ b/packages/shared/src/contracts.ts @@ -480,6 +480,61 @@ export function connectorFields( }; } +/** Identifies a single {@link Run}. */ +export type RunId = string; + +/** + * Lifecycle of a {@link Run}: `running` while the participant works, then one + * of three terminal states — it finished (`completed`), it was cancelled by a + * human or a supervisor (`cancelled`), or it stopped without finishing + * (`failed`). "Finished its task" and "was stopped" are different facts, so a + * settled Run keeps which one happened. + */ +export type RunStatus = "running" | "completed" | "cancelled" | "failed"; + +/** + * What started a {@link Run}: the participant reacting to a message + * (`reaction`), a schedule firing (`schedule`), an inbound callback + * (`webhook`), or a tool call creating work (`tool`). A reaction vocabulary + * alone cannot describe a schedule or a tool, which is why ingress is its own + * field. + */ +export type RunIngress = "reaction" | "schedule" | "webhook" | "tool"; + +/** + * One unit of work by one participant: what a stream of agent events is + * attributable to, and what cancellation acts on. Runs are serial per + * participant — opening one settles whichever Run that participant still had + * open. + */ +export interface Run { + id: RunId; + sessionId: string; + /** The participant doing the work. */ + participantId: string; + /** The conversation this Run's messages land in by default. */ + homeConversationId: string; + status: RunStatus; + ingress: RunIngress; + /** + * The far side's own id for this work, when a connector has one: an Aquifer + * World session id today, an A2A `Task.id` later. + */ + externalId?: string; + /** + * Connector-private resume cursor for this Run (the Aquifer drain's + * `lastSeq`), so a far end that streams by cursor has somewhere durable to + * keep its position. + */ + cursor?: string; + /** ISO-8601 timestamp. */ + createdAt: string; + /** ISO-8601 timestamp. */ + updatedAt: string; + /** ISO-8601 timestamp the Run settled; absent while it is `running`. */ + endedAt?: string; +} + /** 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`. */ @@ -650,10 +705,14 @@ export interface TerminalDataPayload { /** * Emitted when the Pi agent begins a reply. Carries an empty-content - * `ChatMessage` that the client appends and then fills in via deltas. + * `ChatMessage` that the client appends and then fills in via deltas. `runId` + * sits beside the message rather than on it: a Message gains its own envelope + * fields in a later change, while the stream is a Run event. */ export interface AgentStartPayload { message: ChatMessage; + /** The {@link Run} producing this stream, when one is attributable. */ + runId?: RunId; } /** A streamed chunk of the agent's reply, keyed by the message it extends. */ @@ -661,6 +720,8 @@ export interface AgentDeltaPayload { sessionId: string; messageId: string; delta: string; + /** The {@link Run} producing this stream, when one is attributable. */ + runId?: RunId; } /** A streamed chunk of the agent's reasoning, keyed by the message it extends. */ @@ -668,11 +729,15 @@ export interface AgentThinkingPayload { sessionId: string; messageId: string; delta: string; + /** The {@link Run} producing this stream, when one is attributable. */ + runId?: RunId; } /** Emitted when the agent finishes; carries the final, complete message. */ export interface AgentEndPayload { message: ChatMessage; + /** The {@link Run} producing this stream, when one is attributable. */ + runId?: RunId; } /** Emitted when the agent fails to produce (or finish) a reply. */ @@ -680,6 +745,8 @@ export interface AgentErrorPayload { sessionId: string; messageId?: string; message: string; + /** The {@link Run} that failed, when one is attributable. */ + runId?: RunId; } /** The kind of work an agent is currently doing, for the ephemeral indicator. */ @@ -707,6 +774,8 @@ export interface AgentActivityPayload { sessionId: string; conversationId: string; activity: AgentActivity | null; + /** The {@link Run} this activity belongs to, when one is attributable. */ + runId?: RunId; } /** @@ -722,6 +791,8 @@ export interface AgentQueuePayload { steering: string[]; /** Follow-up messages waiting until the run fully stops. */ followUp: string[]; + /** The {@link Run} these messages are queued behind, when attributable. */ + runId?: RunId; } /** Full sub-agent roster for a session, emitted on join and on reset. */ @@ -797,13 +868,16 @@ export interface ArtifactUnpinPayload { } /** - * Sent (client -> server) to abort an agent's in-progress run. `conversationId` - * is the target agent's id (`"prime"` or a sub-agent id), matching how messages - * are tagged, so any agent's current work can be cancelled. + * Sent (client -> server) to cancel a {@link Run}. `conversationId` is the + * target agent's id (`"prime"` or a sub-agent id), matching how messages are + * tagged; `runId` names the Run when the client is tracking it, and the server + * resolves the participant's open Run when it is absent. */ export interface AgentAbortPayload { sessionId: string; conversationId: string; + /** The {@link Run} to cancel; the server resolves it when omitted. */ + runId?: RunId; } /** diff --git a/packages/shared/src/remoteSubagent.ts b/packages/shared/src/remoteSubagent.ts index 9e58d2b..e13d3f6 100644 --- a/packages/shared/src/remoteSubagent.ts +++ b/packages/shared/src/remoteSubagent.ts @@ -15,6 +15,7 @@ import type { AgentActivity, ChatMessage, MessageDelivery, + RunId, SubagentStatus, ThinkingLevel, } from "./contracts.ts"; @@ -86,6 +87,11 @@ export interface RemoteSpawnCommand { task?: string; /** Whether finalized replies are auto-relayed back to Prime. */ autoRelayToPrime: boolean; + /** + * The Run the initial `task` is work for, to echo back on its events. Absent + * when no task is sent, or when the server predates run attribution. + */ + runId?: RunId; } /** server -> remote: deliver a directed message/task to a remote sub-agent. */ @@ -94,6 +100,8 @@ export interface RemoteMessageCommand { agentId: string; text: string; delivery: MessageDelivery; + /** The Run this message is work for, to echo back on its events. */ + runId?: RunId; } /** server -> remote: terminate a remote sub-agent. */ @@ -124,6 +132,13 @@ export interface RemoteAgentEventPayload { sessionId: string; agentId: string; event: RemoteAgentEvent; + /** + * The Run this event belongs to, echoed from the command that started the + * work. Optional: an environment that doesn't echo it (or predates run + * attribution) still streams, and the server attributes the event to whatever + * Run that participant has open. + */ + runId?: RunId; } /** remote -> server: a remote sub-agent's lifecycle status change. */ From 06ae21d7eb24a799f26e1d83f53eafde7a7962a5 Mon Sep 17 00:00:00 2001 From: Maksym Yezhov Date: Tue, 11 Aug 2026 15:06:58 -0700 Subject: [PATCH 04/18] - refactor: Lifecycle-driven revive, reattach, and detached --- .../src/connectors/connectorRegistry.test.ts | 144 ++++++++++++++---- .../src/connectors/connectorRegistry.ts | 39 ++++- .../src/connectors/externalConnector.ts | 7 + apps/server/src/connectors/nullConnector.ts | 5 + apps/server/src/connectors/piConnector.ts | 7 + .../src/connectors/remoteEnvConnector.ts | 31 +++- apps/server/src/connectors/types.ts | 14 +- .../external/externalSubagentGateway.test.ts | 90 ++++++++++- .../src/external/externalSubagentGateway.ts | 84 +++++++++- apps/server/src/index.ts | 9 +- apps/server/src/pi/piAgentManager.test.ts | 54 ++++--- apps/server/src/pi/piAgentManager.ts | 67 ++++---- .../remote/remoteEnvironmentGateway.test.ts | 142 +++++++++++++++-- .../src/remote/remoteEnvironmentGateway.ts | 102 +++++++++++-- apps/server/src/sockets/chat.ts | 30 ++-- apps/server/src/store/db/schema.ts | 5 +- apps/server/src/store/inMemorySessionStore.ts | 23 +++ apps/server/src/store/sessionStore.ts | 29 +++- .../src/store/sqliteSessionStore.test.ts | 88 +++++++++++ apps/server/src/store/sqliteSessionStore.ts | 32 ++++ .../sidebar/agents/AgentStatusIndicator.tsx | 4 +- .../src/features/chat/hooks/useAgentStatus.ts | 1 + packages/shared/src/contracts.ts | 29 +++- 23 files changed, 877 insertions(+), 159 deletions(-) diff --git a/apps/server/src/connectors/connectorRegistry.test.ts b/apps/server/src/connectors/connectorRegistry.test.ts index 3169bf1..f85abb0 100644 --- a/apps/server/src/connectors/connectorRegistry.test.ts +++ b/apps/server/src/connectors/connectorRegistry.test.ts @@ -2,7 +2,9 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { + type ConnectorDescriptor, connectorFields, + connectorFor, type SubagentInfo, } from "@tangent/shared/contracts.ts"; @@ -12,6 +14,8 @@ import type { PiAgentHandlers } from "../pi/types.ts"; import type { RemoteEnvironmentGateway } from "../remote/remoteEnvironmentGateway.ts"; import { RunRegistry } from "../runs/runRegistry.ts"; import { InMemoryRunStore } from "../store/inMemoryRunStore.ts"; +import { InMemorySessionStore } from "../store/inMemorySessionStore.ts"; +import type { SessionAgent } from "../store/sessionStore.ts"; import { createConnectorRegistry } from "./connectorRegistry.ts"; /** A message a fake gateway was asked to deliver. */ @@ -41,52 +45,89 @@ function rosterEntry( }; } -/** - * A registry over the real external gateway plus fakes for the local and remote - * transports, so a delivery can be traced to exactly one of them. - */ -function makeHarness() { - const surfaced: Surfaced[] = []; - const handlers: PiAgentHandlers = { - onAgentEvent: () => {}, - onSubagentUpdate: () => {}, - onAgentMessage: (_sessionId, conversationId, author, content) => - surfaced.push({ conversationId, author: author.name, content }), - onSessionStatus: () => {}, +/** A persisted roster row, as a revive reads one. */ +function agentRow( + id: string, + connector: ConnectorDescriptor, + overrides: Partial = {}, +): SessionAgent { + return { + id, + sessionId: "s1", + role: "subagent", + name: id, + status: "detached", + connector, + createdAt: "2026-01-01T00:00:00.000Z", + ...overrides, }; +} - const piDeliveries: Delivery[] = []; - const piKills: string[] = []; - const piAborts: string[] = []; +/** A fake Pi manager recording what it was asked to do with `local-1`. */ +function fakePi() { + const deliveries: Delivery[] = []; + const kills: string[] = []; + const aborts: string[] = []; + const revives: string[] = []; const pi = { hasAgent: (_sessionId: string, agentId: string) => agentId === "local-1", listSubagents: () => [rosterEntry("local-1", "pi-stdio")], sendToAgent: (sessionId: string, agentId: string, text: string) => - piDeliveries.push({ sessionId, agentId, text }), - killAgent: (_sessionId: string, agentId: string) => piKills.push(agentId), + deliveries.push({ sessionId, agentId, text }), + killAgent: (_sessionId: string, agentId: string) => kills.push(agentId), // Mirrors the real manager: only a busy agent has anything to cancel. abort: (_sessionId: string, agentId: string) => { - piAborts.push(agentId); + aborts.push(agentId); return agentId === "local-1"; }, + reviveSubagent: (_sessionId: string, agent: SessionAgent) => + revives.push(agent.id), } as unknown as PiAgentManager; + return { pi, deliveries, kills, aborts, revives }; +} - const remoteDeliveries: Delivery[] = []; - const remoteGateway = { +/** A fake remote gateway recording what it was asked to do with `remote-1`. */ +function fakeRemote() { + const deliveries: Delivery[] = []; + const reattaches: string[] = []; + const gateway = { hasAgent: (_sessionId: string, agentId: string) => agentId === "remote-1", listSubagents: () => [rosterEntry("remote-1", "remote-env")], - sendToAgent: (sessionId: string, agentId: string, text: string) => - remoteDeliveries.push({ sessionId, agentId, text }), + sendToAgent: (sessionId: string, agentId: string, text: string) => { + deliveries.push({ sessionId, agentId, text }); + return true; + }, killAgent: () => {}, + reattach: (_sessionId: string, agent: SessionAgent) => + reattaches.push(agent.id), } as unknown as RemoteEnvironmentGateway; + return { gateway, deliveries, reattaches }; +} +/** + * A registry over the real external gateway plus fakes for the local and remote + * transports, so a delivery can be traced to exactly one of them. + */ +function makeHarness() { + const surfaced: Surfaced[] = []; + const handlers: PiAgentHandlers = { + onAgentEvent: () => {}, + onSubagentUpdate: () => {}, + onAgentMessage: (_sessionId, conversationId, author, content) => + surfaced.push({ conversationId, author: author.name, content }), + onSessionStatus: () => {}, + }; + + const local = fakePi(); + const remote = fakeRemote(); const externalGateway = new ExternalSubagentGateway( handlers, new RunRegistry(new InMemoryRunStore()), + new InMemorySessionStore(), ); const connectors = createConnectorRegistry( - pi, - remoteGateway, + local.pi, + remote.gateway, externalGateway, handlers, ); @@ -95,10 +136,12 @@ function makeHarness() { connectors, externalGateway, surfaced, - piDeliveries, - piKills, - piAborts, - remoteDeliveries, + piDeliveries: local.deliveries, + piKills: local.kills, + piAborts: local.aborts, + piRevives: local.revives, + remoteDeliveries: remote.deliveries, + remoteReattaches: remote.reattaches, }; } @@ -232,6 +275,51 @@ test("list walks every connector's roster", () => { ); }); +test("revive routes each persisted row to the connector that recorded it", () => { + const h = makeHarness(); + + h.connectors.revive("s1", [ + agentRow("local-1", connectorFor("pi-stdio")), + agentRow("remote-1", connectorFor("remote-env", "env-1")), + agentRow("ext-1", connectorFor("external-inbound")), + ]); + + assert.deepEqual(h.piRevives, ["local-1"]); + assert.deepEqual(h.remoteReattaches, ["remote-1"]); + // The external gateway is real, so its reattach is visible in the roster — + // restored as `detached`, since nothing here creates the far side. + assert.deepEqual(h.externalGateway.listSubagents("s1"), [ + { + id: "ext-1", + name: "ext-1", + status: "detached", + ...connectorFields("external-inbound"), + template: undefined, + model: undefined, + thinkingDepth: undefined, + createdAt: "2026-01-01T00:00:00.000Z", + }, + ]); +}); + +test("revive skips Prime, terminal rows and attached participants", () => { + const h = makeHarness(); + + h.connectors.revive("s1", [ + agentRow("prime", connectorFor("pi-stdio"), { role: "prime" }), + agentRow("killed-1", connectorFor("pi-stdio"), { status: "killed" }), + // An attached connector's far end exists independently of Tangent, so it + // waits to be reattached rather than being brought back from a row. + agentRow("attached-1", { + kind: "pi-stdio", + lifecycle: "attached", + spawnAuthority: "server", + }), + ]); + + assert.deepEqual(h.piRevives, []); +}); + test("only connectors the spawn API may act on are spawners", () => { const h = makeHarness(); diff --git a/apps/server/src/connectors/connectorRegistry.ts b/apps/server/src/connectors/connectorRegistry.ts index 03f3942..d712390 100644 --- a/apps/server/src/connectors/connectorRegistry.ts +++ b/apps/server/src/connectors/connectorRegistry.ts @@ -1,13 +1,15 @@ -import type { - ConnectorKind, - SpawnAuthority, - SubagentInfo, +import { + type ConnectorKind, + isTerminalStatus, + type SpawnAuthority, + type SubagentInfo, } from "@tangent/shared/contracts.ts"; import type { ExternalSubagentGateway } from "../external/externalSubagentGateway.ts"; import type { PiAgentManager } from "../pi/piAgentManager.ts"; import type { PiAgentHandlers } from "../pi/types.ts"; import type { RemoteEnvironmentGateway } from "../remote/remoteEnvironmentGateway.ts"; +import type { SessionAgent } from "../store/sessionStore.ts"; import { ExternalConnector } from "./externalConnector.ts"; import { NullConnector } from "./nullConnector.ts"; import { PiConnector } from "./piConnector.ts"; @@ -76,10 +78,35 @@ export class ConnectorRegistry { /** The connector that spawns `kind` on the server's behalf, if any may. */ spawner(kind: ConnectorKind): SpawningConnector | undefined { - const connector = this.connectors.find((c) => c.descriptor.kind === kind); + const connector = this.forKind(kind); if (!connector || !canSpawn(connector)) return undefined; return connector; } + + /** + * Restores a session's persisted sub-agents through whichever connector each + * one belongs to. Resolution is by recorded kind rather than by {@link + * ConnectorRegistry.resolve}, because nothing holds a participant yet — that + * is the whole point of a revive. + * + * Terminal rows are skipped, so nothing resurrects a participant that finished + * or was killed. So are `attached` ones: that connector's far end exists + * independently of Tangent and waits to be reattached rather than being brought + * back from a row. + */ + revive(sessionId: string, persisted: SessionAgent[]): void { + for (const agent of persisted) { + if (agent.role !== "subagent") continue; + if (isTerminalStatus(agent.status)) continue; + if (agent.connector.lifecycle !== "owned") continue; + this.forKind(agent.connector.kind)?.revive(sessionId, agent); + } + } + + /** The connector registered for a kind, if the server runs one. */ + private forKind(kind: ConnectorKind): Connector | undefined { + return this.connectors.find((c) => c.descriptor.kind === kind); + } } /** Builds the registry over the gateways the server runs today. */ @@ -92,7 +119,7 @@ export function createConnectorRegistry( return new ConnectorRegistry( [ new PiConnector(pi), - new RemoteEnvConnector(remoteGateway), + new RemoteEnvConnector(remoteGateway, handlers), new ExternalConnector(externalGateway, handlers), ], new NullConnector(handlers), diff --git a/apps/server/src/connectors/externalConnector.ts b/apps/server/src/connectors/externalConnector.ts index 26247c3..4ee5878 100644 --- a/apps/server/src/connectors/externalConnector.ts +++ b/apps/server/src/connectors/externalConnector.ts @@ -2,6 +2,7 @@ import { connectorFor } from "@tangent/shared/contracts.ts"; import type { ExternalSubagentGateway } from "../external/externalSubagentGateway.ts"; import type { PiAgentHandlers } from "../pi/types.ts"; +import type { SessionAgent } from "../store/sessionStore.ts"; import { refuseDelivery } from "./refusal.ts"; import type { CancelResult, @@ -63,4 +64,10 @@ export class ExternalConnector implements Connector { completed ? "completed" : "killed", ); } + + revive(sessionId: string, agent: SessionAgent): void { + // Nothing here creates the far side — the bundle tool driving it does — so + // the tab comes back `detached` and the next turn pushed into it reattaches. + this.gateway.reattach(sessionId, agent); + } } diff --git a/apps/server/src/connectors/nullConnector.ts b/apps/server/src/connectors/nullConnector.ts index 2d5a1e0..cc0cd7d 100644 --- a/apps/server/src/connectors/nullConnector.ts +++ b/apps/server/src/connectors/nullConnector.ts @@ -51,4 +51,9 @@ export class NullConnector implements Connector { } kill(): void {} + + revive(): void { + // Reachable only for a row whose connector kind the server no longer runs. + // There is nothing to restore it onto, so it keeps whatever status it has. + } } diff --git a/apps/server/src/connectors/piConnector.ts b/apps/server/src/connectors/piConnector.ts index 2aa749c..c46a663 100644 --- a/apps/server/src/connectors/piConnector.ts +++ b/apps/server/src/connectors/piConnector.ts @@ -2,6 +2,7 @@ import { connectorFor } from "@tangent/shared/contracts.ts"; import type { SubagentSpawnRequest } from "../pi/agentConfig.ts"; import type { PiAgentManager, SpawnedSubagent } from "../pi/piAgentManager.ts"; +import type { SessionAgent } from "../store/sessionStore.ts"; import type { CancelResult, Connector, @@ -63,4 +64,10 @@ export class PiConnector implements Connector { kill(sessionId: string, participantId: string, completed: boolean): void { this.pi.killAgent(sessionId, participantId, completed); } + + revive(sessionId: string, agent: SessionAgent): void { + // The server owns the process, so restoring the participant is re-spawning + // it from the config the row kept. + this.pi.reviveSubagent(sessionId, agent); + } } diff --git a/apps/server/src/connectors/remoteEnvConnector.ts b/apps/server/src/connectors/remoteEnvConnector.ts index 3f34bbb..26fac4a 100644 --- a/apps/server/src/connectors/remoteEnvConnector.ts +++ b/apps/server/src/connectors/remoteEnvConnector.ts @@ -2,7 +2,10 @@ import { connectorFor } from "@tangent/shared/contracts.ts"; import type { SubagentSpawnRequest } from "../pi/agentConfig.ts"; import type { SpawnedSubagent } from "../pi/piAgentManager.ts"; +import type { PiAgentHandlers } from "../pi/types.ts"; import type { RemoteEnvironmentGateway } from "../remote/remoteEnvironmentGateway.ts"; +import type { SessionAgent } from "../store/sessionStore.ts"; +import { refuseDelivery } from "./refusal.ts"; import type { CancelResult, Connector, @@ -17,20 +20,26 @@ import type { const NO_CANCEL_PROTOCOL = "Remote sub-agents can't be interrupted mid-turn; kill it instead."; +/** Shown in the sub-agent's own thread when its environment isn't connected. */ +const ENVIRONMENT_DETACHED = + "This sub-agent's environment is disconnected, so the message wasn't delivered."; + /** * The connector for sub-agents hosted inside a connected remote environment. A - * thin adapter over {@link RemoteEnvironmentGateway}, which is unchanged. Its - * descriptor carries no `environmentId` — that belongs to each participant's - * roster entry, not to the connector as a whole. + * thin adapter over {@link RemoteEnvironmentGateway}. Its descriptor carries no + * `environmentId` — that belongs to each participant's roster entry, not to the + * connector as a whole. */ export class RemoteEnvConnector implements Connector { readonly descriptor = connectorFor("remote-env"); readonly acceptsDelivery = true; private readonly gateway: RemoteEnvironmentGateway; + private readonly handlers: PiAgentHandlers; - constructor(gateway: RemoteEnvironmentGateway) { + constructor(gateway: RemoteEnvironmentGateway, handlers: PiAgentHandlers) { this.gateway = gateway; + this.handlers = handlers; } has(sessionId: string, participantId: string): boolean { @@ -42,7 +51,7 @@ export class RemoteEnvConnector implements Connector { } deliver(request: DeliveryRequest): DeliveryResult { - this.gateway.sendToAgent( + const delivered = this.gateway.sendToAgent( request.sessionId, request.participantId, request.text, @@ -50,7 +59,10 @@ export class RemoteEnvConnector implements Connector { request.delivery, request.ingress, ); - return { delivered: true }; + if (delivered) return { delivered: true }; + // A detached participant stays in the roster, so this connector still holds + // it and has to say why the message went nowhere. + return refuseDelivery(this.handlers, request, ENVIRONMENT_DETACHED); } cancelRun(): CancelResult { @@ -64,4 +76,11 @@ export class RemoteEnvConnector implements Connector { kill(sessionId: string, participantId: string, completed: boolean): void { this.gateway.killAgent(sessionId, participantId, completed); } + + revive(sessionId: string, agent: SessionAgent): void { + // The environment owns the process, and the protocol has no way to ask it + // what it still runs. So the tab comes back `detached` and the far end + // reattaches by declaring the participant active again. + this.gateway.reattach(sessionId, agent); + } } diff --git a/apps/server/src/connectors/types.ts b/apps/server/src/connectors/types.ts index c832c74..5a1c8dc 100644 --- a/apps/server/src/connectors/types.ts +++ b/apps/server/src/connectors/types.ts @@ -9,6 +9,7 @@ import type { import type { SubagentSpawnRequest } from "../pi/agentConfig.ts"; import type { SpawnedSubagent } from "../pi/piAgentManager.ts"; +import type { SessionAgent } from "../store/sessionStore.ts"; /** A message addressed to one participant, as a connector receives it. */ export interface DeliveryRequest { @@ -61,8 +62,9 @@ export interface CancelResult { * `deliver` is required of every connector. One that cannot accept a message * declares {@link Connector.acceptsDelivery} false and refuses, because an * absent method is a compile-time refusal while an untaken branch is a runtime - * mis-delivery — and the tree has had both. `cancelRun` follows the same rule: - * a transport with no cancel protocol refuses by declaration. + * mis-delivery — and the tree has had both. `cancelRun` and `revive` follow the + * same rule: a transport with no cancel protocol, or no way to bring a + * participant back, says so by declaration. */ export interface Connector { readonly descriptor: ConnectorDescriptor; @@ -76,6 +78,10 @@ export interface Connector { /** Present only where {@link ConnectorDescriptor.spawnAuthority} allows it. */ spawn?(sessionId: string, request: SubagentSpawnRequest): SpawnedSubagent; kill(sessionId: string, participantId: string, completed: boolean): void; - /** Restores a participant after a restart. PR 1.4 fills this in. */ - revive?(sessionId: string, participantId: string): void; + /** + * Restores one persisted participant after a restart. What that means is the + * connector's to decide: re-spawning the process it owns, or restoring the + * roster entry as `detached` and waiting for the far end to come back. + */ + revive(sessionId: string, agent: SessionAgent): void; } diff --git a/apps/server/src/external/externalSubagentGateway.test.ts b/apps/server/src/external/externalSubagentGateway.test.ts index 9d5fa59..3b3a29a 100644 --- a/apps/server/src/external/externalSubagentGateway.test.ts +++ b/apps/server/src/external/externalSubagentGateway.test.ts @@ -6,6 +6,8 @@ import type { SubagentInfo } from "@tangent/shared/contracts.ts"; import type { PiAgentHandlers } from "../pi/types.ts"; import { RunRegistry } from "../runs/runRegistry.ts"; import { InMemoryRunStore } from "../store/inMemoryRunStore.ts"; +import { InMemorySessionStore } from "../store/inMemorySessionStore.ts"; +import type { SessionAgent } from "../store/sessionStore.ts"; import { ExternalSubagentGateway } from "./externalSubagentGateway.ts"; /** Captures every handler call so tests can assert on them. */ @@ -27,8 +29,27 @@ function makeHarness() { const runStore = new InMemoryRunStore(); const runs = new RunRegistry(runStore); - const gateway = new ExternalSubagentGateway(handlers, runs); - return { gateway, rosterUpdates, events, runs, runStore }; + const store = new InMemorySessionStore(); + const gateway = new ExternalSubagentGateway(handlers, runs, store); + return { gateway, rosterUpdates, events, runs, runStore, store }; +} + +/** A persisted roster row, as a reattach reads one. */ +function agentRow(id: string, overrides: Partial = {}) { + return { + id, + sessionId: "s1", + role: "subagent", + name: "worker", + status: "detached", + connector: { + kind: "external-inbound", + lifecycle: "owned", + spawnAuthority: "bundle-tool", + }, + createdAt: "2026-01-01T00:00:00.000Z", + ...overrides, + } satisfies SessionAgent; } test("register records a roster entry and surfaces it as active", () => { @@ -46,6 +67,71 @@ test("register records a roster entry and surfaces it as active", () => { assert.equal(info?.status, "active"); }); +test("register persists a roster row so a restart has something to reattach to", async () => { + const h = makeHarness(); + const { id } = h.gateway.register("s1", { + name: "worker", + model: "claude", + template: "researcher", + }); + + const persisted = (await h.store.listAgents("s1")).find((a) => a.id === id); + assert.ok(persisted); + assert.equal(persisted.role, "subagent"); + assert.equal(persisted.name, "worker"); + assert.equal(persisted.status, "active"); + assert.equal(persisted.model, "claude"); + assert.equal(persisted.template, "researcher"); + assert.equal(persisted.connector.kind, "external-inbound"); +}); + +test("reattach restores a persisted tab as detached", () => { + const h = makeHarness(); + + h.gateway.reattach("s1", agentRow("ext-1", { model: "claude" })); + + assert.equal(h.gateway.hasAgent("s1", "ext-1"), true); + const info = h.gateway.listSubagents("s1")[0]; + assert.equal(info.status, "detached"); + assert.equal(info.model, "claude"); + assert.equal(info.createdAt, "2026-01-01T00:00:00.000Z"); + assert.equal(h.rosterUpdates.at(-1)?.status, "detached"); +}); + +test("a detached tab reattaches when the far side pushes its next turn", () => { + const h = makeHarness(); + h.gateway.reattach("s1", agentRow("ext-1")); + + h.gateway.pushEvent("s1", "ext-1", { type: "start", messageId: "m1" }); + + assert.equal(h.gateway.listSubagents("s1")[0].status, "active"); + assert.deepEqual( + h.events.map((e) => e.type), + ["start"], + ); +}); + +test("reattach never downgrades a live tab", () => { + const h = makeHarness(); + const { id } = h.gateway.register("s1", { name: "worker" }); + const updatesBefore = h.rosterUpdates.length; + + h.gateway.reattach("s1", agentRow(id, { status: "active" })); + + assert.equal(h.gateway.listSubagents("s1")[0].status, "active"); + assert.equal(h.rosterUpdates.length, updatesBefore, "no roster churn"); +}); + +test("setStatus to detached keeps the entry, unlike a terminal status", () => { + const h = makeHarness(); + const { id } = h.gateway.register("s1", { name: "worker" }); + + h.gateway.setStatus("s1", id, "detached"); + + assert.equal(h.gateway.hasAgent("s1", id), true); + assert.equal(h.rosterUpdates.at(-1)?.status, "detached"); +}); + test("the roster describes an external sub-agent as owned by its bundle tool", () => { const h = makeHarness(); h.gateway.register("s1", { name: "worker" }); diff --git a/apps/server/src/external/externalSubagentGateway.ts b/apps/server/src/external/externalSubagentGateway.ts index cf3d47c..22235a6 100644 --- a/apps/server/src/external/externalSubagentGateway.ts +++ b/apps/server/src/external/externalSubagentGateway.ts @@ -2,6 +2,8 @@ import { randomUUID } from "node:crypto"; import { connectorFields, + connectorFor, + isTerminalStatus, type Run, type RunId, type SubagentInfo, @@ -10,8 +12,10 @@ import { } from "@tangent/shared/contracts.ts"; import type { RemoteAgentEvent } from "@tangent/shared/remoteSubagent.ts"; +import { parseThinkingLevel } from "../pi/agentConfig.ts"; import type { AgentDescriptor, PiAgentHandlers } from "../pi/types.ts"; import type { RunRegistry, SettledStatus } from "../runs/runRegistry.ts"; +import type { SessionAgent, SessionStore } from "../store/sessionStore.ts"; /** Display metadata a caller supplies when registering an external sub-agent. */ export interface RegisterExternalSubagent { @@ -65,8 +69,9 @@ function toInfo(subagent: ExternalSubagent): SubagentInfo { } /** - * 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 + * Registry of **external sub-agent** tabs, held in memory and persisted as + * roster rows so a restart has something to reattach to. 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. @@ -82,13 +87,19 @@ function toInfo(subagent: ExternalSubagent): SubagentInfo { export class ExternalSubagentGateway { private readonly handlers: PiAgentHandlers; private readonly runs: RunRegistry; + private readonly store: SessionStore; /** Per-session external sub-agent rosters, keyed by sessionId then agentId. */ private readonly sessions = new Map>(); - constructor(handlers: PiAgentHandlers, runs: RunRegistry) { + constructor( + handlers: PiAgentHandlers, + runs: RunRegistry, + store: SessionStore, + ) { this.handlers = handlers; this.runs = runs; + this.store = store; } /** True when `agentId` is an external sub-agent of `sessionId`. */ @@ -107,6 +118,10 @@ export class ExternalSubagentGateway { * 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. + * + * The tab is persisted as a roster row, so a restart has something to reattach + * to. Best-effort: `sessionId` comes from an external caller, and a bogus one + * must fail the row rather than the process. */ register(sessionId: string, spec: RegisterExternalSubagent): { id: string } { const agentId = randomUUID(); @@ -120,10 +135,52 @@ export class ExternalSubagentGateway { createdAt: new Date().toISOString(), }; this.rosterFor(sessionId).set(agentId, subagent); + void this.store + .recordAgent(sessionId, { + id: agentId, + role: "subagent", + name: subagent.name, + status: subagent.status, + model: subagent.model, + thinkingDepth: subagent.thinkingDepth, + template: subagent.template, + host: "external", + connector: connectorFor("external-inbound"), + }) + .catch((err: unknown) => { + console.error( + `[external] failed to persist sub-agent "${subagent.name}" in session ${sessionId}:`, + err, + ); + }); this.handlers.onSubagentUpdate(sessionId, toInfo(subagent)); return { id: agentId }; } + /** + * Restores a persisted tab as `detached`. Nothing here creates the far side — + * the bundle tool driving it does — so the tab comes back as a place for its + * next turn to land rather than as something claiming to be live. + * + * Idempotent, and never downgrades a live tab. + */ + reattach(sessionId: string, agent: SessionAgent): void { + const roster = this.rosterFor(sessionId); + if (roster.get(agent.id)?.status === "active") return; + + const subagent: ExternalSubagent = { + agentId: agent.id, + name: agent.name, + status: "detached", + template: agent.template, + model: agent.model, + thinkingDepth: parseThinkingLevel(agent.thinkingDepth), + createdAt: agent.createdAt, + }; + roster.set(agent.id, subagent); + this.handlers.onSubagentUpdate(sessionId, toInfo(subagent)); + } + /** * Opens a Run for a turn of external work, carrying the far side's own id for * it and where its stream is being read from. Returns the Run's id, which the @@ -139,6 +196,7 @@ export class ExternalSubagentGateway { ): RunId | undefined { const subagent = this.sessions.get(sessionId)?.get(agentId); if (!subagent) return undefined; + this.markAttached(sessionId, subagent); return this.runs.open({ sessionId, participantId: agentId, @@ -178,12 +236,23 @@ export class ExternalSubagentGateway { ): void { const subagent = this.sessions.get(sessionId)?.get(agentId); if (!subagent) return; + this.markAttached(sessionId, subagent); this.handlers.onAgentEvent(sessionId, this.descriptorFor(subagent), { ...event, runId: this.attributeTo(sessionId, agentId, runId), }); } + /** + * Completes the reattach: a detached tab receiving a turn or streaming output + * is the far side coming back. No-op — and no roster churn — for a live tab. + */ + private markAttached(sessionId: string, subagent: ExternalSubagent): void { + if (subagent.status !== "detached") return; + subagent.status = "active"; + this.handlers.onSubagentUpdate(sessionId, toInfo(subagent)); + } + /** * Resolves which Run an inbound event is attributed to. A supplied id must * name a Run of the addressed participant — the caller is an external tool, so @@ -201,9 +270,10 @@ export class ExternalSubagentGateway { } /** - * Applies a lifecycle status change to a sub-agent tab. Terminal statuses - * (anything other than `active`) drop the roster entry and settle whatever - * Run the tab still had open. No-op for an unknown id. + * Applies a lifecycle status change to a sub-agent tab. A terminal status drops + * the roster entry and settles whatever Run the tab still had open; `detached` + * keeps it, because the point of that state is having something to come back + * to. No-op for an unknown id. */ setStatus(sessionId: string, agentId: string, status: SubagentStatus): void { const roster = this.sessions.get(sessionId); @@ -211,7 +281,7 @@ export class ExternalSubagentGateway { if (!roster || !subagent) return; subagent.status = status; - if (status !== "active") { + if (isTerminalStatus(status)) { roster.delete(agentId); this.runs.settleOpenFor( sessionId, diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 150b382..c16666c 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -96,6 +96,13 @@ void runs.failStaleRuns().then((failed) => { if (failed > 0) console.log(`[runs] settled ${failed} stale run(s)`); }); +// No participant outlives the server, so every sub-agent row still claiming to +// be live is stale. Marking them `detached` here is what keeps the sessions list +// from counting agents that no longer exist; a revive moves them back. +void store.detachActiveSubagents().then((detached) => { + if (detached > 0) console.log(`[agents] detached ${detached} stale row(s)`); +}); + // 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. @@ -120,7 +127,7 @@ const remoteGateway = new RemoteEnvironmentGateway( // 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, runs); +const externalGateway = new ExternalSubagentGateway(agentHandlers, runs, store); // The single lookup from a participant to the connector that reaches it. Every // spawn/message/kill/list route goes through it, so an id no connector holds is diff --git a/apps/server/src/pi/piAgentManager.test.ts b/apps/server/src/pi/piAgentManager.test.ts index 001fd00..91d6724 100644 --- a/apps/server/src/pi/piAgentManager.test.ts +++ b/apps/server/src/pi/piAgentManager.test.ts @@ -136,13 +136,13 @@ afterEach(() => { mock.restoreAll(); }); -test("reviveSubagents re-spawns active sub-agents from persisted config", () => { +test("reviveSubagent re-spawns a sub-agent from its persisted config", () => { const { pi, spawns } = makeManager(); pi.ensure("s1", "/tmp/s1"); assert.equal(spawns.length, 1, "ensure spawns Prime"); - const persisted: SessionAgent[] = [ - agentRow({ id: PRIME_AGENT_ID, role: "prime", name: "Prime" }), + pi.reviveSubagent( + "s1", agentRow({ id: "sub-active", name: "Scout", @@ -150,12 +150,9 @@ test("reviveSubagents re-spawns active sub-agents from persisted config", () => tools: ["read", "grep"], systemPrompt: "You are Scout.", }), - agentRow({ id: "sub-killed", name: "Old", status: "killed" }), - ]; - pi.reviveSubagents("s1", persisted); + ); - // Only the active sub-agent is revived (Prime is already live, killed skipped). - assert.equal(spawns.length, 2, "one active sub-agent revived"); + assert.equal(spawns.length, 2, "the sub-agent is revived"); const revived = spawns[1]; assert.equal(revived.agentId, "sub-active"); assert.ok( @@ -175,20 +172,39 @@ test("reviveSubagents re-spawns active sub-agents from persisted config", () => ); }); -test("reviveSubagents is idempotent and skips already-live agents", () => { +test("reviveSubagent restores a detached row, and refuses Prime and terminal ones", () => { const { pi, spawns } = makeManager(); pi.ensure("s1", "/tmp/s1"); - const persisted: SessionAgent[] = [ - agentRow({ - id: "sub-active", - status: "active", - tools: ["read"], - systemPrompt: "prompt", - }), - ]; - pi.reviveSubagents("s1", persisted); - pi.reviveSubagents("s1", persisted); + // `detached` is the ordinary pre-revive state: the boot reconciliation puts + // every stale row there, so revive has to accept it. + pi.reviveSubagent("s1", agentRow({ id: "sub-detached", status: "detached" })); + pi.reviveSubagent("s1", agentRow({ id: "sub-killed", status: "killed" })); + pi.reviveSubagent("s1", agentRow({ id: "sub-done", status: "completed" })); + pi.reviveSubagent( + "s1", + agentRow({ id: PRIME_AGENT_ID, role: "prime", name: "Prime" }), + ); + + assert.equal(spawns.length, 2, "only the detached row is revived"); + assert.deepEqual( + pi.listSubagents("s1").map((s) => s.id), + ["sub-detached"], + ); +}); + +test("reviveSubagent is idempotent and skips an already-live agent", () => { + const { pi, spawns } = makeManager(); + pi.ensure("s1", "/tmp/s1"); + + const persisted = agentRow({ + id: "sub-active", + status: "active", + tools: ["read"], + systemPrompt: "prompt", + }); + pi.reviveSubagent("s1", persisted); + pi.reviveSubagent("s1", persisted); assert.equal(spawns.length, 2, "second revive does not double-spawn"); }); diff --git a/apps/server/src/pi/piAgentManager.ts b/apps/server/src/pi/piAgentManager.ts index 0c8a7f8..db98f75 100644 --- a/apps/server/src/pi/piAgentManager.ts +++ b/apps/server/src/pi/piAgentManager.ts @@ -6,6 +6,7 @@ import { type ChatAuthor, type MessageDelivery, PI_AGENT, + RESTORABLE_STATUSES, type RunIngress, type SessionRunStatus, type SessionStatusPayload, @@ -315,19 +316,22 @@ function resolveEffectiveConfig( } /** - * 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. + * A persisted roster row is eligible for revive when it is a sub-agent (Prime is + * handled by {@link PiAgentManager.ensure}) that is not terminal and isn't + * already live in the in-memory roster, so a reconnect won't double-spawn it. + * `detached` is the ordinary pre-revive state — the boot reconciliation puts + * every stale row there — so excluding it would stop revive working at all. + * + * Which transport a row belongs to is not asked here: {@link + * import("../connectors/connectorRegistry.ts").ConnectorRegistry.revive} routes + * each row to its own connector, so only rows this manager owns arrive. */ function canReviveSubagent( session: SessionAgents, agent: SessionAgent, ): boolean { if (agent.role !== "subagent") return false; - if (agent.status !== "active") return false; - if (agent.host === "remote" || agent.host === "external") return false; + if (!RESTORABLE_STATUSES.includes(agent.status)) return false; return !session.agents.has(agent.id); } @@ -564,37 +568,34 @@ export class PiAgentManager { } /** - * Re-spawns the session's previously-active sub-agents from their persisted - * roster after a full restart (when the in-memory roster holds only Prime). - * Each process comes back with the exact config it was spawned with (tools, - * appended system prompt, model/thinking, template, auto-relay), but its - * original task is deliberately NOT re-delivered: Pi is ephemeral - * (`--no-session`), so the revived process starts idle and Prime decides — from - * the transcript and session memory — whether to re-task it. + * Re-spawns one persisted sub-agent after a restart (when the in-memory roster + * holds only Prime). The process comes back with the exact config it was + * spawned with (tools, appended system prompt, model/thinking, template, + * auto-relay), but its original task is deliberately NOT re-delivered: Pi is + * ephemeral (`--no-session`), so the revived process starts idle and Prime + * decides — from the transcript and session memory — whether to re-task it. * - * No-op for a session whose Prime isn't ensured yet, and skips any agent that - * is already live (so a reconnect doesn't double-spawn) or not `active`. + * No-op for a session whose Prime isn't ensured yet, for an agent that is + * already live (so a reconnect doesn't double-spawn), and for a terminal row. */ - reviveSubagents(sessionId: string, persisted: SessionAgent[]): void { + reviveSubagent(sessionId: string, agent: SessionAgent): void { const session = this.sessions.get(sessionId); if (!session) return; + if (!canReviveSubagent(session, agent)) return; - for (const agent of persisted) { - if (!canReviveSubagent(session, agent)) continue; - const revived = this.spawnAgent( - sessionId, - session, - { - agentId: agent.id, - role: "subagent", - name: agent.name, - template: agent.template, - autoRelayToPrime: agent.autoRelayToPrime ?? true, - }, - this.reconstructSubagentConfig(session, agent), - ); - this.handlers.onSubagentUpdate(sessionId, toSubagentInfo(revived)); - } + const revived = this.spawnAgent( + sessionId, + session, + { + agentId: agent.id, + role: "subagent", + name: agent.name, + template: agent.template, + autoRelayToPrime: agent.autoRelayToPrime ?? true, + }, + this.reconstructSubagentConfig(session, agent), + ); + this.handlers.onSubagentUpdate(sessionId, toSubagentInfo(revived)); } /** diff --git a/apps/server/src/remote/remoteEnvironmentGateway.test.ts b/apps/server/src/remote/remoteEnvironmentGateway.test.ts index ede370b..64a7c67 100644 --- a/apps/server/src/remote/remoteEnvironmentGateway.test.ts +++ b/apps/server/src/remote/remoteEnvironmentGateway.test.ts @@ -1,18 +1,29 @@ import assert from "node:assert/strict"; import { test } from "node:test"; +import { connectorFor, type SubagentInfo } from "@tangent/shared/contracts.ts"; +import { + type RemoteAgentEventPayload, + RemoteEnvEvents, +} from "@tangent/shared/remoteSubagent.ts"; import type { Server as SocketIOServer, Socket } from "socket.io"; import type { PiAgentHandlers } from "../pi/types.ts"; import { RunRegistry } from "../runs/runRegistry.ts"; import { InMemoryRunStore } from "../store/inMemoryRunStore.ts"; -import type { SessionStore } from "../store/sessionStore.ts"; +import { InMemorySessionStore } from "../store/inMemorySessionStore.ts"; import { RemoteEnvironmentGateway } from "./remoteEnvironmentGateway.ts"; +/** Lets a fire-and-forget roster replay settle before asserting on it. */ +function flush(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} + /** * A gateway wired to a fake namespace, plus a `connect` that registers an * environment by driving the captured connection handler (bypassing the token - * middleware, which is not what these tests are about). + * middleware, which is not what these tests are about). The returned handle + * drives the environment's inbound events and its disconnect. */ function makeHarness() { let onConnection: ((socket: Socket) => void) | undefined; @@ -23,32 +34,62 @@ function makeHarness() { }, }; + const rosterUpdates: SubagentInfo[] = []; const handlers: PiAgentHandlers = { onAgentEvent: () => {}, - onSubagentUpdate: () => {}, + onSubagentUpdate: (_sessionId, info) => rosterUpdates.push(info), onAgentMessage: () => {}, onSessionStatus: () => {}, }; - const store = { getMessages: async () => [] } as unknown as SessionStore; + const store = new InMemorySessionStore(); + const runStore = new InMemoryRunStore(); + const runs = new RunRegistry(runStore); const gateway = new RemoteEnvironmentGateway( { of: () => namespace } as unknown as SocketIOServer, handlers, store, () => {}, - new RunRegistry(new InMemoryRunStore()), + runs, ); - const connect = (environmentId: string): void => { + const connect = (environmentId: string) => { + const listeners = new Map void>(); + const sent: Array<{ event: string; payload: unknown }> = []; const socket = { handshake: { auth: { environmentId } }, - on: () => {}, - emit: () => {}, + on: (event: string, handler: (payload: unknown) => void) => + listeners.set(event, handler), + emit: (event: string, payload: unknown) => sent.push({ event, payload }), } as unknown as Socket; onConnection?.(socket); + return { + sent, + send: (event: string, payload: unknown) => + listeners.get(event)?.(payload), + disconnect: () => listeners.get("disconnect")?.(undefined), + }; }; - return { gateway, connect }; + return { gateway, connect, store, runs, runStore, rosterUpdates }; +} + +/** Seeds a persisted remote roster row hosted by `environmentId`. */ +async function seedAgent( + store: InMemorySessionStore, + sessionId: string, + id: string, + environmentId: string, + status: SubagentInfo["status"] = "active", +): Promise { + await store.recordAgent(sessionId, { + id, + role: "subagent", + name: id, + status, + host: "remote", + connector: connectorFor("remote-env", environmentId), + }); } test("the remote roster describes its connector and environment", () => { @@ -67,3 +108,86 @@ test("the remote roster describes its connector and environment", () => { assert.equal(info.host, "remote"); assert.deepEqual(h.gateway.listSubagents("s1")[0].connector, expected); }); + +test("a disconnecting environment detaches its sub-agents and keeps their tabs", async () => { + const h = makeHarness(); + const env = h.connect("env-1"); + const { info } = h.gateway.spawnSubagent("s1", { + name: "Worker", + task: "go", + }); + const runId = h.runs.current("s1", info.id)?.id; + assert.ok(runId); + + env.disconnect(); + + // The tab survives: "the far end is gone" is not a kill, and the entry has to + // exist for the environment to reattach to. + assert.equal(h.gateway.hasAgent("s1", info.id), true); + assert.equal(h.gateway.listSubagents("s1")[0].status, "detached"); + assert.equal(h.rosterUpdates.at(-1)?.status, "detached"); + assert.equal((await h.runStore.getRun(runId))?.status, "failed"); +}); + +test("a detached participant refuses delivery instead of dropping it silently", () => { + const h = makeHarness(); + const env = h.connect("env-1"); + const { info } = h.gateway.spawnSubagent("s1", { name: "Worker" }); + + assert.equal(h.gateway.sendToAgent("s1", info.id, "hello"), true); + env.disconnect(); + + assert.equal(h.gateway.sendToAgent("s1", info.id, "hello again"), false); +}); + +test("a reconnecting environment gets its persisted roster replayed as detached", async () => { + const h = makeHarness(); + await seedAgent(h.store, "s1", "remote-1", "env-1"); + await seedAgent(h.store, "s1", "remote-done", "env-1", "completed"); + await seedAgent(h.store, "s2", "other-env", "env-2"); + + h.connect("env-1"); + await flush(); + + // Only this environment's non-terminal rows come back, and none of them claims + // to be live: the protocol cannot ask what the far side still runs. + assert.deepEqual( + h.gateway.listSubagents("s1").map((s) => [s.id, s.status]), + [["remote-1", "detached"]], + ); + assert.deepEqual(h.gateway.listSubagents("s2"), []); +}); + +test("a detached participant reattaches when its environment streams again", async () => { + const h = makeHarness(); + await seedAgent(h.store, "s1", "remote-1", "env-1"); + + const env = h.connect("env-1"); + await flush(); + assert.equal(h.gateway.listSubagents("s1")[0].status, "detached"); + + env.send(RemoteEnvEvents.AgentEvent, { + sessionId: "s1", + agentId: "remote-1", + event: { type: "start", messageId: "m1" }, + } satisfies RemoteAgentEventPayload); + + assert.equal(h.gateway.listSubagents("s1")[0].status, "active"); +}); + +test("reattach ignores a row that never recorded its environment", async () => { + const h = makeHarness(); + await h.store.recordAgent("s1", { + id: "legacy-1", + role: "subagent", + name: "legacy", + status: "active", + host: "remote", + }); + + h.gateway.reattach("s1", (await h.store.listAgents("s1"))[0]); + + // Pre-connector-column rows never recorded which environment hosted them, so + // there is nothing to reattach them to. + assert.deepEqual(h.gateway.listSubagents("s1"), []); +}); diff --git a/apps/server/src/remote/remoteEnvironmentGateway.ts b/apps/server/src/remote/remoteEnvironmentGateway.ts index 069a4c8..b22abb5 100644 --- a/apps/server/src/remote/remoteEnvironmentGateway.ts +++ b/apps/server/src/remote/remoteEnvironmentGateway.ts @@ -3,7 +3,9 @@ import { randomUUID } from "node:crypto"; import { type ChatAuthor, connectorFields, + isTerminalStatus, type MessageDelivery, + RESTORABLE_STATUSES, type RunId, type RunIngress, type SubagentInfo, @@ -27,13 +29,14 @@ import type { Namespace, Server as SocketIOServer, Socket } from "socket.io"; import { REMOTE_ENV_TOKEN } from "../config.ts"; import { + parseThinkingLevel, resolveSubagentConfig, type SubagentSpawnRequest, } from "../pi/agentConfig.ts"; import type { SpawnedSubagent } from "../pi/piAgentManager.ts"; import type { AgentDescriptor, PiAgentHandlers } from "../pi/types.ts"; import type { RunRegistry } from "../runs/runRegistry.ts"; -import type { SessionStore } from "../store/sessionStore.ts"; +import type { SessionAgent, SessionStore } from "../store/sessionStore.ts"; /** Default and maximum number of transcript messages a room read returns. */ const DEFAULT_ROOM_LIMIT = 30; @@ -219,10 +222,14 @@ export class RemoteEnvironmentGateway { * 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. + * conversation. * * Opens a Run for the message and puts its id on the command, so the * environment can echo it back on the events it streams. + * + * Returns whether the message reached an environment: a detached participant + * stays in the roster, so its connector needs to hear that nothing was sent + * rather than assume a silent success. */ sendToAgent( sessionId: string, @@ -231,9 +238,9 @@ export class RemoteEnvironmentGateway { surfaceAuthor?: ChatAuthor, delivery: MessageDelivery = "auto", ingress: RunIngress = "reaction", - ): void { + ): boolean { const environment = this.environmentFor(sessionId, agentId); - if (!environment) return; + if (!environment) return false; if (surfaceAuthor) { this.handlers.onAgentMessage(sessionId, agentId, surfaceAuthor, text); @@ -248,6 +255,40 @@ export class RemoteEnvironmentGateway { runId: run.id, }; environment.socket.emit(RemoteEnvEvents.Message, command); + return true; + } + + /** + * Restores a persisted sub-agent's roster entry as `detached`. The protocol + * has no way to ask an environment what it still runs, so the entry exists to + * be reattached to rather than claiming to be live: the far end reattaches by + * sending a `subagent-update` marking the participant `active` again. + * + * Idempotent, and never downgrades a live entry — a reconnect that races a + * join must not detach something the environment has already re-declared. + */ + reattach(sessionId: string, agent: SessionAgent): void { + const environmentId = agent.connector.environmentId; + // Rows written before the connector columns existed never recorded which + // environment hosted them, so there is nothing to reattach them to. + if (!environmentId) return; + + const roster = this.rosterFor(sessionId); + if (roster.get(agent.id)?.status === "active") return; + + const subagent: RemoteSubagent = { + agentId: agent.id, + name: agent.name, + status: "detached", + template: agent.template, + model: agent.model, + thinkingDepth: parseThinkingLevel(agent.thinkingDepth), + createdAt: agent.createdAt, + environmentId, + autoRelayToPrime: agent.autoRelayToPrime ?? true, + }; + roster.set(agent.id, subagent); + this.handlers.onSubagentUpdate(sessionId, toInfo(subagent)); } /** The connected environment hosting a sub-agent, if both are still live. */ @@ -363,12 +404,29 @@ export class RemoteEnvironmentGateway { ) => void this.handleRoomRead(request, callback), ); socket.on("disconnect", () => this.onDisconnect(environmentId)); + + void this.replayRoster(environmentId); + } + + /** + * Rebuilds the roster this environment's persisted sub-agents belong to, so a + * reconnect after a server restart has tabs to reattach to instead of an empty + * roster. Each comes back `detached`; the environment moves whichever it still + * runs back to `active`. + */ + private async replayRoster(environmentId: string): Promise { + const agents = await this.store.listAgentsForEnvironment(environmentId); + for (const agent of agents) { + if (!RESTORABLE_STATUSES.includes(agent.status)) continue; + this.reattach(agent.sessionId, agent); + } } /** 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.markAttached(payload.sessionId, subagent); this.handlers.onAgentEvent( payload.sessionId, this.descriptorFor(subagent), @@ -377,6 +435,17 @@ export class RemoteEnvironmentGateway { this.relayEndToPrime(payload.sessionId, subagent, payload.event); } + /** + * Completes the reattach: a detached participant producing output is the far + * end declaring itself live again, which is the only evidence this protocol + * offers. No-op — and no roster churn — for one that was already live. + */ + private markAttached(sessionId: string, subagent: RemoteSubagent): void { + if (subagent.status !== "detached") return; + subagent.status = "active"; + this.handlers.onSubagentUpdate(sessionId, toInfo(subagent)); + } + /** * The Run an inbound event belongs to: the id the environment echoed, or the * one that participant currently has open. An environment that echoes nothing @@ -409,10 +478,10 @@ export class RemoteEnvironmentGateway { if (!roster || !subagent) return; subagent.status = payload.status; - if (payload.status !== "active") { + if (isTerminalStatus(payload.status)) { roster.delete(payload.agentId); - // The agent leaving `active` is the closest thing the protocol has to a - // run-end marker: whatever it was working on is over either way. + // The agent reaching a terminal status is the closest thing the protocol + // has to a run-end marker: whatever it was working on is over either way. this.runs.settleOpenFor( payload.sessionId, payload.agentId, @@ -426,6 +495,7 @@ export class RemoteEnvironmentGateway { private handleAgentMessage(payload: RemoteAgentMessagePayload): void { const subagent = this.sessions.get(payload.sessionId)?.get(payload.agentId); if (!subagent) return; + this.markAttached(payload.sessionId, subagent); const author: ChatAuthor = { id: subagent.agentId, @@ -454,25 +524,29 @@ export class RemoteEnvironmentGateway { callback({ messages: all.slice(-clampLimit(request.limit)) }); } - /** Drops a disconnected environment and fails its still-live sub-agents. */ + /** Drops a disconnected environment and detaches its sub-agents. */ private onDisconnect(environmentId: string): void { this.environments.delete(environmentId); for (const [sessionId, roster] of this.sessions) { - this.failEnvironmentAgents(sessionId, roster, environmentId); + this.detachEnvironmentAgents(sessionId, roster, environmentId); } console.log(`[remote-env] disconnected: ${environmentId}`); } - /** Marks every sub-agent owned by `environmentId` in a roster as errored. */ - private failEnvironmentAgents( + /** + * Marks every sub-agent hosted by `environmentId` as `detached`, keeping its + * roster entry. "The far end is gone" is not an error and not a kill, and the + * entry has to survive for the environment to reattach to when it comes back. + */ + private detachEnvironmentAgents( sessionId: string, roster: Map, environmentId: string, ): void { - for (const subagent of [...roster.values()]) { + for (const subagent of roster.values()) { if (subagent.environmentId !== environmentId) continue; - subagent.status = "error"; - roster.delete(subagent.agentId); + if (isTerminalStatus(subagent.status)) continue; + subagent.status = "detached"; // The far side is gone mid-work: the Run stopped without finishing. this.runs.settleOpenFor(sessionId, subagent.agentId, "failed"); this.handlers.onSubagentUpdate(sessionId, toInfo(subagent)); diff --git a/apps/server/src/sockets/chat.ts b/apps/server/src/sockets/chat.ts index 6082801..8d41a2c 100644 --- a/apps/server/src/sockets/chat.ts +++ b/apps/server/src/sockets/chat.ts @@ -53,10 +53,7 @@ import { } from "../pi/piAgentManager.ts"; import type { TriggerEngine } from "../pi/triggers/triggerEngine.ts"; import type { SessionStatusHandler } from "../pi/types.ts"; -import type { - SessionAgentStatus, - SessionStore, -} from "../store/sessionStore.ts"; +import type { SessionStore } from "../store/sessionStore.ts"; function roomFor(sessionId: string): string { return `session:${sessionId}`; @@ -313,13 +310,9 @@ export function createSubagentUpdateHandler( const payload: SubagentUpdatePayload = { sessionId, subagent }; io.to(roomFor(sessionId)).emit(SocketEvents.SubagentUpdate, payload); - // Persist so a restart's revive sees the current status (only `active` is - // re-spawned); `error` stays distinct, completions and kills collapse. - const status: SessionAgentStatus = - subagent.status === "active" || subagent.status === "error" - ? subagent.status - : "killed"; - void store.setAgentStatus(sessionId, subagent.id, status); + // Persisted as-is: a participant's lifecycle is transcript-visible history, + // so nothing is collapsed on the way to the row. + void store.setAgentStatus(sessionId, subagent.id, subagent.status); }; } @@ -695,13 +688,14 @@ async function handleSessionStatusSubscribe( /** * (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. + * selection — and revives its persisted sub-agents through their own connectors, + * so a restart restores the full agent set (not just Prime). Idempotent: agents + * already live are skipped. */ async function ensureSessionAgents( store: SessionStore, pi: PiAgentManager, + connectors: ConnectorRegistry, session: Session, ): Promise { const primeOverride = await loadPrimeOverride(store, session.id); @@ -713,7 +707,7 @@ async function ensureSessionAgents( session.user, ); const persistedAgents = await store.listAgents(session.id); - pi.reviveSubagents(session.id, persistedAgents); + connectors.revive(session.id, persistedAgents); } /** Joins the session room, then replays history and the sub-agent roster. */ @@ -734,9 +728,9 @@ async function handleChatJoin( const room = roomFor(session.id); await socket.join(room); - // 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); + // Lazily (re)spawn Prime and revive the session's sub-agents in case the + // server restarted or the session predates the process manager. + await ensureSessionAgents(store, pi, connectors, session); // Re-arm the session's schedule triggers (idempotent) and surface the roster. triggerEngine.sync(session.id, session.rootPath); diff --git a/apps/server/src/store/db/schema.ts b/apps/server/src/store/db/schema.ts index e9bd0a9..520f4e9 100644 --- a/apps/server/src/store/db/schema.ts +++ b/apps/server/src/store/db/schema.ts @@ -80,7 +80,7 @@ export const sessionAgents = sqliteTable( name: text("name").notNull(), /** The agent's task/description, when known. */ purpose: text("purpose"), - /** `active` | `killed`. */ + /** `active` | `detached` | `completed` | `killed` | `error`. */ status: text("status").notNull().default("active"), model: text("model"), thinkingDepth: text("thinking_depth"), @@ -107,8 +107,7 @@ export const sessionAgents = sqliteTable( .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. + * connected remote environment). Defaults to `local`. */ host: text("host").notNull().default("local"), /** diff --git a/apps/server/src/store/inMemorySessionStore.ts b/apps/server/src/store/inMemorySessionStore.ts index f37e9da..b1244a4 100644 --- a/apps/server/src/store/inMemorySessionStore.ts +++ b/apps/server/src/store/inMemorySessionStore.ts @@ -235,6 +235,29 @@ export class InMemorySessionStore implements SessionStore { return this.agents.get(sessionId) ?? []; } + async detachActiveSubagents(): Promise { + let detached = 0; + for (const [sessionId, agents] of this.agents) { + const next = agents.map((agent) => { + if (agent.role !== "subagent" || agent.status !== "active") + return agent; + detached += 1; + return { ...agent, status: "detached" as const }; + }); + this.agents.set(sessionId, next); + } + return detached; + } + + async listAgentsForEnvironment( + environmentId: string, + ): Promise { + return [...this.agents.values()] + .flat() + .filter((agent) => agent.role === "subagent") + .filter((agent) => agent.connector.environmentId === environmentId); + } + async markViewed( sessionId: string, userKey: string, diff --git a/apps/server/src/store/sessionStore.ts b/apps/server/src/store/sessionStore.ts index e6e0937..dd60e9b 100644 --- a/apps/server/src/store/sessionStore.ts +++ b/apps/server/src/store/sessionStore.ts @@ -8,16 +8,19 @@ import { type Session, type SessionConfigMeta, type SubagentHost, + type SubagentStatus, type UpdateSessionRequest, type UserIdentity, } from "@tangent/shared/contracts.ts"; /** - * Persisted lifecycle status of a session agent. `error` is distinct so the - * sessions list can flag "needs attention"; completions and kills both collapse - * to `killed`, and only `active` agents are revived on restart. + * Persisted lifecycle status of a session agent — the same set as the wire + * {@link SubagentStatus}, deliberately. Nothing is collapsed on the way to the + * database any more: "finished its task" and "was terminated" are different + * facts, and discarding one of them at every restart is what made a + * participant's lifecycle unreadable as history. */ -export type SessionAgentStatus = "active" | "killed" | "error"; +export type SessionAgentStatus = SubagentStatus; /** The connector kind each legacy `host` label stood for. */ const CONNECTOR_KIND_BY_HOST: Record = { @@ -73,8 +76,7 @@ export interface SessionAgent { 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. + * connected remote environment). Defaults to `local` on legacy rows. * * @deprecated Read {@link SessionAgent.connector} instead. */ @@ -162,6 +164,21 @@ export interface SessionStore { ): Promise; /** Lists a session's agents (Prime first), oldest first. */ listAgents(sessionId: string): Promise; + /** + * Marks every `active` sub-agent row `detached`, returning how many changed. + * Run once at boot: no process outlives the server, so such a row is a claim + * about a participant that no longer exists. Terminal rows are history and + * Prime rows belong to {@link + * import("../pi/piAgentManager.ts").PiAgentManager.ensure}, so both are left + * alone. + */ + detachActiveSubagents(): Promise; + /** + * Every sub-agent row hosted by one remote environment, across sessions, so a + * reconnecting environment can have its roster replayed. Rows written before + * the connector columns existed carry no environment id and never match. + */ + listAgentsForEnvironment(environmentId: string): Promise; /** Records that `userKey` viewed `sessionId` at `at` (ISO-8601), upserting. */ markViewed(sessionId: string, userKey: string, at: string): Promise; diff --git a/apps/server/src/store/sqliteSessionStore.test.ts b/apps/server/src/store/sqliteSessionStore.test.ts index eab03d4..983b446 100644 --- a/apps/server/src/store/sqliteSessionStore.test.ts +++ b/apps/server/src/store/sqliteSessionStore.test.ts @@ -104,6 +104,94 @@ test("a row recorded without a connector reads back from its host", async () => assert.equal(prime?.connector.kind, "pi-stdio"); }); +test("a completed sub-agent stays completed, rather than collapsing to killed", async () => { + const store = newStore(); + const session = await store.createSession({ name: "S" }); + await store.recordAgent(session.id, { + id: "sub-1", + role: "subagent", + name: "Worker", + }); + + await store.setAgentStatus(session.id, "sub-1", "completed"); + + const agents = await store.listAgents(session.id); + assert.equal(agents.find((a) => a.id === "sub-1")?.status, "completed"); +}); + +test("detachActiveSubagents marks live sub-agents detached and leaves the rest", async () => { + const store = newStore(); + const session = await store.createSession({ name: "S" }); + for (const [id, status] of [ + ["live", "active"], + ["already", "detached"], + ["done", "completed"], + ["gone", "killed"], + ["broken", "error"], + ] as const) { + await store.recordAgent(session.id, { + id, + role: "subagent", + name: id, + status, + }); + } + + const detached = await store.detachActiveSubagents(); + + // Only the row claiming to be live changes; the terminal ones are history and + // the already-detached one needs nothing done to it. + assert.equal(detached, 1); + const byId = new Map( + (await store.listAgents(session.id)).map((a) => [a.id, a.status]), + ); + assert.equal(byId.get("live"), "detached"); + assert.equal(byId.get("already"), "detached"); + assert.equal(byId.get("done"), "completed"); + assert.equal(byId.get("gone"), "killed"); + assert.equal(byId.get("broken"), "error"); + // Prime is the process manager's to ensure, not this reconciliation's. + assert.equal(byId.get("prime"), "active"); +}); + +test("listAgentsForEnvironment finds one environment's sub-agents across sessions", async () => { + const store = newStore(); + const a = await store.createSession({ name: "A" }); + const b = await store.createSession({ name: "B" }); + const remote = (id: string, environmentId: string) => ({ + id, + role: "subagent" as const, + name: id, + host: "remote" as const, + connector: { + kind: "remote-env" as const, + lifecycle: "owned" as const, + spawnAuthority: "remote-env" as const, + environmentId, + }, + }); + await store.recordAgent(a.id, remote("mine-a", "env-1")); + await store.recordAgent(b.id, remote("mine-b", "env-1")); + await store.recordAgent(a.id, remote("theirs", "env-2")); + // A row written before the connector columns existed records no environment. + await store.recordAgent(a.id, { + id: "legacy", + role: "subagent", + name: "legacy", + host: "remote", + }); + + const found = await store.listAgentsForEnvironment("env-1"); + + assert.deepEqual( + found.map((agent) => [agent.id, agent.sessionId]).sort(), + [ + ["mine-a", a.id], + ["mine-b", b.id], + ].sort(), + ); +}); + test("deleting a session cascades its read state", async () => { const store = newStore(); const session = await store.createSession({ name: "S" }); diff --git a/apps/server/src/store/sqliteSessionStore.ts b/apps/server/src/store/sqliteSessionStore.ts index 7371f12..0324b58 100644 --- a/apps/server/src/store/sqliteSessionStore.ts +++ b/apps/server/src/store/sqliteSessionStore.ts @@ -407,6 +407,38 @@ export class SqliteSessionStore implements SessionStore { return rows.map(toAgent); } + async detachActiveSubagents(): Promise { + const rows = this.db + .update(sessionAgents) + .set({ status: "detached" }) + .where( + and( + eq(sessionAgents.role, "subagent"), + eq(sessionAgents.status, "active"), + ), + ) + .returning({ id: sessionAgents.id }) + .all(); + return rows.length; + } + + async listAgentsForEnvironment( + environmentId: string, + ): Promise { + const rows = this.db + .select() + .from(sessionAgents) + .where( + and( + eq(sessionAgents.role, "subagent"), + eq(sessionAgents.connectorEnvironmentId, environmentId), + ), + ) + .orderBy(asc(sessionAgents.createdAt)) + .all(); + return rows.map(toAgent); + } + async markViewed( sessionId: string, userKey: string, diff --git a/apps/web/src/features/chat/components/sidebar/agents/AgentStatusIndicator.tsx b/apps/web/src/features/chat/components/sidebar/agents/AgentStatusIndicator.tsx index e1f4586..5844d95 100644 --- a/apps/web/src/features/chat/components/sidebar/agents/AgentStatusIndicator.tsx +++ b/apps/web/src/features/chat/components/sidebar/agents/AgentStatusIndicator.tsx @@ -14,7 +14,7 @@ interface AgentStatusProps { /** * Status-tinted indicator mirroring an agent's lifecycle. A busy run pulses; an - * idle agent shows its terminal/active state. Reads the agent's live status from + * idle agent shows its detached/terminal/active state. Reads its live status from * the shared cache, so the sidebar agent card and the opened agent tab trigger * always agree and the state survives a page reload. */ @@ -28,6 +28,8 @@ export function AgentStatusIndicator({ sessionId, agentId }: AgentStatusProps) { switch (status) { case "active": return ; + case "detached": + return ; case "completed": return ; case "killed": diff --git a/apps/web/src/features/chat/hooks/useAgentStatus.ts b/apps/web/src/features/chat/hooks/useAgentStatus.ts index 328c665..f1ae4ee 100644 --- a/apps/web/src/features/chat/hooks/useAgentStatus.ts +++ b/apps/web/src/features/chat/hooks/useAgentStatus.ts @@ -9,6 +9,7 @@ import { const LIFECYCLE_LABELS: Record = { active: "Ready", + detached: "Disconnected", completed: "Completed", killed: "Killed", error: "Error", diff --git a/packages/shared/src/contracts.ts b/packages/shared/src/contracts.ts index 4773028..a4afd0f 100644 --- a/packages/shared/src/contracts.ts +++ b/packages/shared/src/contracts.ts @@ -356,8 +356,33 @@ export interface UpdateTriggerRequest { schedule?: TriggerSchedule; } -/** Lifecycle status of a sub-agent, surfaced in the session's agent roster. */ -export type SubagentStatus = "active" | "completed" | "killed" | "error"; +/** + * Lifecycle status of a sub-agent, surfaced in the session's agent roster. + * + * `detached` is the state of a participant that has a place in the roster with + * nothing behind it — the far end dropped, or the server restarted and has not + * restored it yet. Distinct from `error` (something went wrong) and from the + * terminal `completed` / `killed`, and the only non-terminal status a revive or + * a reattach can move a participant out of. + */ +export type SubagentStatus = + | "active" + | "detached" + | "completed" + | "killed" + | "error"; + +/** + * The statuses a participant can still be restored from. Everything else is + * terminal: a `completed`, `killed` or `error`ed participant stays that way, and + * only these two describe one that ought to have something behind it. + */ +export const RESTORABLE_STATUSES: SubagentStatus[] = ["active", "detached"]; + +/** Whether a status is one nothing moves a participant out of. */ +export function isTerminalStatus(status: SubagentStatus): boolean { + return !RESTORABLE_STATUSES.includes(status); +} /** * Which host runs a sub-agent: `local` (a `pi` child process managed by the From f5a516758e8c970d8529167cccbc4dd4bdbb4525 Mon Sep 17 00:00:00 2001 From: Maksym Yezhov Date: Tue, 11 Aug 2026 16:33:22 -0700 Subject: [PATCH 05/18] - refactor: message envelope and server-resolved authorship --- apps/server/src/pi/triggers/triggerEngine.ts | 12 +- apps/server/src/routes/sessions/handlers.ts | 5 +- apps/server/src/sockets/chat.ts | 242 +++++--- apps/server/src/sockets/chatAuthor.test.ts | 50 ++ apps/server/src/sockets/mentions.test.ts | 46 ++ apps/server/src/sockets/mentions.ts | 59 ++ apps/server/src/store/chatLog.test.ts | 233 +++++++- apps/server/src/store/chatLog.ts | 67 ++- .../db/migrations/0008_special_quasimodo.sql | 10 + .../db/migrations/meta/0008_snapshot.json | 533 ++++++++++++++++++ .../store/db/migrations/meta/_journal.json | 7 + apps/server/src/store/db/schema.ts | 27 + apps/server/src/store/inMemorySessionStore.ts | 21 + apps/server/src/store/sessionStore.ts | 8 + .../src/store/sqliteSessionStore.test.ts | 56 +- apps/server/src/store/sqliteSessionStore.ts | 68 +++ .../src/features/chat/hooks/useSessionChat.ts | 16 +- apps/web/src/features/user/api/userApi.ts | 4 +- .../src/features/user/hooks/useCurrentUser.ts | 3 +- .../src/features/user/model/userDisplay.ts | 23 - packages/shared/src/contracts.ts | 107 +++- 21 files changed, 1457 insertions(+), 140 deletions(-) create mode 100644 apps/server/src/sockets/chatAuthor.test.ts create mode 100644 apps/server/src/sockets/mentions.test.ts create mode 100644 apps/server/src/sockets/mentions.ts create mode 100644 apps/server/src/store/db/migrations/0008_special_quasimodo.sql create mode 100644 apps/server/src/store/db/migrations/meta/0008_snapshot.json diff --git a/apps/server/src/pi/triggers/triggerEngine.ts b/apps/server/src/pi/triggers/triggerEngine.ts index 09f4a0d..bcd2c9a 100644 --- a/apps/server/src/pi/triggers/triggerEngine.ts +++ b/apps/server/src/pi/triggers/triggerEngine.ts @@ -10,7 +10,11 @@ import type { TriggerTarget, TriggerUpdatePayload, } from "@tangent/shared/contracts.ts"; -import { SocketEvents, TRIGGER_AUTHOR } from "@tangent/shared/contracts.ts"; +import { + SocketEvents, + sourceFromAuthor, + TRIGGER_AUTHOR, +} from "@tangent/shared/contracts.ts"; import { Cron } from "croner"; import type { Server } from "socket.io"; @@ -242,11 +246,15 @@ export class TriggerEngine { stored: StoredTrigger, prompt: string, ): Promise { + const author = triggerAuthor(stored); const message: ChatMessage = { id: randomUUID(), sessionId, conversationId: PRIME_AGENT_ID, - author: triggerAuthor(stored), + seq: await this.store.nextSeq(sessionId, PRIME_AGENT_ID), + author, + mentions: [], + source: sourceFromAuthor(author), content: prompt, createdAt: new Date().toISOString(), }; diff --git a/apps/server/src/routes/sessions/handlers.ts b/apps/server/src/routes/sessions/handlers.ts index 6f957bd..a6148d0 100644 --- a/apps/server/src/routes/sessions/handlers.ts +++ b/apps/server/src/routes/sessions/handlers.ts @@ -10,7 +10,7 @@ import type { UploadFilesResponse, UserIdentity, } from "@tangent/shared/contracts.ts"; -import { PI_AGENT } from "@tangent/shared/contracts.ts"; +import { PI_AGENT, sourceFromAuthor } from "@tangent/shared/contracts.ts"; import type { Request, Response } from "express"; import multer from "multer"; @@ -168,7 +168,10 @@ async function createSessionFromBundle( id: randomUUID(), sessionId, conversationId: PRIME_AGENT_ID, + seq: await store.nextSeq(sessionId, PRIME_AGENT_ID), author: PI_AGENT, + mentions: [], + source: sourceFromAuthor(PI_AGENT), content: config.welcomeMessage, createdAt: new Date().toISOString(), }); diff --git a/apps/server/src/sockets/chat.ts b/apps/server/src/sockets/chat.ts index 8d41a2c..2b5001f 100644 --- a/apps/server/src/sockets/chat.ts +++ b/apps/server/src/sockets/chat.ts @@ -19,6 +19,8 @@ import { type ChatJoinPayload, type ChatMessage, type ChatMessagePayload, + DEFAULT_USER, + humanAuthor, MEMORY_AUTHOR, type MemoryConfirmPayload, type MemoryDismissPayload, @@ -30,6 +32,7 @@ import { type SessionStatusPayload, type SessionStatusSnapshotPayload, SocketEvents, + sourceFromAuthor, type SubagentRosterPayload, type SubagentUpdatePayload, type ThinkingLevel, @@ -39,6 +42,7 @@ import { } from "@tangent/shared/contracts.ts"; import type { Server, Socket } from "socket.io"; +import { resolveUserIdentity } from "../auth/identity.ts"; import type { ConnectorRegistry } from "../connectors/connectorRegistry.ts"; import { parseThinkingLevel } from "../pi/agentConfig.ts"; import type { MemoryManager } from "../pi/memory.ts"; @@ -54,6 +58,7 @@ import { import type { TriggerEngine } from "../pi/triggers/triggerEngine.ts"; import type { SessionStatusHandler } from "../pi/types.ts"; import type { SessionStore } from "../store/sessionStore.ts"; +import { type MentionCandidate, resolveMentions } from "./mentions.ts"; function roomFor(sessionId: string): string { return `session:${sessionId}`; @@ -77,23 +82,36 @@ export function createSessionStatusHandler(io: Server): SessionStatusHandler { }; } -function buildMessage( - id: string, - sessionId: string, - conversationId: string, - author: ChatAuthor, - content: string, - thinking?: string, - attachments?: Attachment[], -): ChatMessage { +/** + * Everything a Message needs beyond its envelope defaults. `seq` comes from the + * store's allocator rather than being derivable here, which is what stops a + * writer inventing one. + */ +interface MessageInput { + id: string; + sessionId: string; + conversationId: string; + seq: number; + author: ChatAuthor; + content: string; + mentions?: string[]; + thinking?: string; + attachments?: Attachment[]; + runId?: RunId; + endsRun?: boolean; +} + +function buildMessage(input: MessageInput): ChatMessage { + // `runId` and `endsRun` ride the rest spread: an absent one is `undefined`, + // which JSON drops on both the wire and the way to the log. `thinking` and + // `attachments` are guarded because an empty string or array is not absent. + const { thinking, attachments, mentions, ...rest } = input; return { - id, - sessionId, - conversationId, - author, - content, + ...rest, + mentions: mentions ?? [], + source: sourceFromAuthor(input.author), ...(thinking ? { thinking } : {}), - ...(attachments && attachments.length ? { attachments } : {}), + ...(attachments?.length ? { attachments } : {}), createdAt: new Date().toISOString(), }; } @@ -136,16 +154,46 @@ interface EmitContext { runId?: RunId; } -function emitStart(io: Server, ctx: EmitContext, messageId: string): void { - const message = buildMessage( - messageId, - ctx.sessionId, - ctx.conversationId, - ctx.author, - "", - ); - const payload: AgentStartPayload = { message, runId: ctx.runId }; - io.to(ctx.room).emit(SocketEvents.AgentStart, payload); +/** + * The `seq` reserved for each streaming message, held from `start` until the + * turn finalizes so the placeholder and the Message that replaces it share one + * ordinal. + * + * It doubles as the ordering gate. Reserving is asynchronous, and the client + * drops a delta for a message id it has not seen, so every subsequent event for + * that message chains off this promise: callbacks on one promise run in + * registration order, which makes `start` before `delta` structural rather than + * a matter of timing. + */ +const reservedSeqs = new Map>(); + +/** The reservation to emit behind, or an immediate one when there was no start. */ +function seqGate(messageId: string | undefined): Promise { + const reserved = messageId ? reservedSeqs.get(messageId) : undefined; + return reserved ?? Promise.resolve(0); +} + +function emitStart( + io: Server, + store: SessionStore, + ctx: EmitContext, + messageId: string, +): void { + const reservation = store.nextSeq(ctx.sessionId, ctx.conversationId); + reservedSeqs.set(messageId, reservation); + void reservation.then((seq) => { + const message = buildMessage({ + id: messageId, + sessionId: ctx.sessionId, + conversationId: ctx.conversationId, + seq, + author: ctx.author, + content: "", + runId: ctx.runId, + }); + const payload: AgentStartPayload = { message, runId: ctx.runId }; + io.to(ctx.room).emit(SocketEvents.AgentStart, payload); + }); } function emitDelta( @@ -159,7 +207,9 @@ function emitDelta( delta: event.delta, runId: ctx.runId, }; - io.to(ctx.room).emit(SocketEvents.AgentDelta, payload); + void seqGate(event.messageId).then(() => { + io.to(ctx.room).emit(SocketEvents.AgentDelta, payload); + }); } function emitThinking( @@ -173,7 +223,9 @@ function emitThinking( delta: event.delta, runId: ctx.runId, }; - io.to(ctx.room).emit(SocketEvents.AgentThinking, payload); + void seqGate(event.messageId).then(() => { + io.to(ctx.room).emit(SocketEvents.AgentThinking, payload); + }); } function emitEnd( @@ -182,17 +234,27 @@ function emitEnd( ctx: EmitContext, event: { messageId: string; content: string; thinking: string }, ): void { - const message = buildMessage( - event.messageId, - ctx.sessionId, - ctx.conversationId, - ctx.author, - event.content, - event.thinking, - ); - // Persist before broadcasting so reconnecting clients see it in history. The - // run id rides the payload, not the message: what is persisted is unchanged. - void store.appendMessage(message).then(() => { + // Take the seq this turn reserved at `start`; a finalized message that never + // streamed (no reservation) allocates one now. + const reserved = reservedSeqs.get(event.messageId); + reservedSeqs.delete(event.messageId); + const allocation = + reserved ?? store.nextSeq(ctx.sessionId, ctx.conversationId); + + void allocation.then(async (seq) => { + const message = buildMessage({ + id: event.messageId, + sessionId: ctx.sessionId, + conversationId: ctx.conversationId, + seq, + author: ctx.author, + content: event.content, + thinking: event.thinking, + runId: ctx.runId, + ...(ctx.runId ? { endsRun: true } : {}), + }); + // Persist before broadcasting so reconnecting clients see it in history. + await store.appendMessage(message); const payload: AgentEndPayload = { message, runId: ctx.runId }; io.to(ctx.room).emit(SocketEvents.AgentEnd, payload); }); @@ -209,7 +271,13 @@ function emitError( message: event.message, runId: ctx.runId, }; - io.to(ctx.room).emit(SocketEvents.AgentError, payload); + // A failed turn spends its reserved seq without persisting anything, leaving a + // gap. Emitted behind the reservation so the error still lands after `start`. + const gate = seqGate(event.messageId); + if (event.messageId) reservedSeqs.delete(event.messageId); + void gate.then(() => { + io.to(ctx.room).emit(SocketEvents.AgentError, payload); + }); } function emitActivity( @@ -261,7 +329,7 @@ export function createAgentEventHandler( author: authorFor(agent), runId: event.runId, }; - relayStreamingEvent(io, ctx, event); + relayStreamingEvent(io, store, ctx, event); relayTerminalEvent(io, store, ctx, event); }; } @@ -269,12 +337,13 @@ export function createAgentEventHandler( /** Relays the streaming variants (placeholder + incremental tokens). */ function relayStreamingEvent( io: Server, + store: SessionStore, ctx: EmitContext, event: AgentEvent, ): void { switch (event.type) { case "start": - return emitStart(io, ctx, event.messageId); + return emitStart(io, store, ctx, event.messageId); case "delta": return emitDelta(io, ctx, event); case "thinking": @@ -343,16 +412,18 @@ export function createAgentMessageHandler( store: SessionStore, ): AgentMessageHandler { return (sessionId, conversationId, author, content) => { - const message = buildMessage( - randomUUID(), - sessionId, - conversationId, - author, - content, - ); - void store.appendMessage(message).then(() => { + void (async () => { + const message = buildMessage({ + id: randomUUID(), + sessionId, + conversationId, + seq: await store.nextSeq(sessionId, conversationId), + author, + content, + }); + await store.appendMessage(message); io.to(roomFor(sessionId)).emit(SocketEvents.ChatMessage, message); - }); + })(); }; } @@ -374,13 +445,14 @@ export function createMemoryRememberedHandler( ): MemoryRememberedHandler { return async (sessionId, scope, text) => { const message: ChatMessage = { - ...buildMessage( - randomUUID(), + ...buildMessage({ + id: randomUUID(), sessionId, - PRIME_AGENT_ID, - MEMORY_AUTHOR, - text, - ), + conversationId: PRIME_AGENT_ID, + seq: await store.nextSeq(sessionId, PRIME_AGENT_ID), + author: MEMORY_AUTHOR, + content: text, + }), memory: { scope }, }; await store.appendMessage(message); @@ -553,12 +625,16 @@ function wireSocket(socket: Socket, deps: ChatHandlerDeps): void { const { io, store, pi, connectors, memory } = deps; const { onRemembered, triggerEngine, emitUiCommand } = deps; + // Resolved once per connection: the identity is the connection's, and the + // client never gets a say in who its messages are attributed to. + const author = resolveSocketAuthor(socket.handshake.headers.cookie); + socket.on(SocketEvents.ChatJoin, (payload: ChatJoinPayload) => handleChatJoin(socket, store, pi, connectors, triggerEngine, payload), ); socket.on(SocketEvents.ChatMessage, (payload: ChatMessagePayload) => - handleChatMessage(io, socket, store, pi, connectors, payload), + handleChatMessage(io, socket, store, pi, connectors, author, payload), ); socket.on(SocketEvents.AgentAbort, (payload: AgentAbortPayload) => @@ -827,6 +903,34 @@ async function handleArtifactUnpin( emitUiCommand(session.id, { kind: "artifacts.update", artifacts }); } +/** + * Who a message in this session can address: Prime plus every sub-agent any + * connector holds. Names come from the live roster, so a mention resolves + * against what the sender currently sees in the sidebar. + */ +function mentionCandidates( + connectors: ConnectorRegistry, + sessionId: string, +): MentionCandidate[] { + return [ + { id: PI_AGENT.id, name: PI_AGENT.name }, + ...connectors + .list(sessionId) + .map((subagent) => ({ id: subagent.id, name: subagent.name })), + ]; +} + +/** + * The chat identity of whoever is on the other end of a socket, read from the + * connection's own cookie rather than from anything the client sends. Falls back + * to {@link DEFAULT_USER} when no JWT is configured or the cookie is absent — + * the same fallback the UI uses, so both sides agree on the id and a message + * still renders as your own. + */ +export function resolveSocketAuthor(cookieHeader: string | undefined) { + return humanAuthor(resolveUserIdentity(cookieHeader) ?? DEFAULT_USER); +} + /** Persists + broadcasts a human message and relays it into the Pi process. */ async function handleChatMessage( io: Server, @@ -834,6 +938,7 @@ async function handleChatMessage( store: SessionStore, pi: PiAgentManager, connectors: ConnectorRegistry, + author: ChatAuthor, payload: ChatMessagePayload, ): Promise { const session = await store.getSession(payload?.sessionId); @@ -850,16 +955,21 @@ async function handleChatMessage( // Broadcast the user's own message to the room (including the sender, so it // renders without optimistic updates and other participants see it). Tagged - // with the target conversation so it lands in the right thread. - const userMessage = buildMessage( - randomUUID(), - session.id, + // with the target conversation so it lands in the right thread. The author is + // the socket's resolved identity, never the payload's claim. + const userMessage = buildMessage({ + id: randomUUID(), + sessionId: session.id, conversationId, - payload.author, - payload.content, - undefined, - payload.attachments, - ); + seq: await store.nextSeq(session.id, conversationId), + author, + content: payload.content, + mentions: resolveMentions( + payload.content, + mentionCandidates(connectors, session.id), + ), + attachments: payload.attachments, + }); await store.appendMessage(userMessage); io.to(room).emit(SocketEvents.ChatMessage, userMessage); diff --git a/apps/server/src/sockets/chatAuthor.test.ts b/apps/server/src/sockets/chatAuthor.test.ts new file mode 100644 index 0000000..b6ccf8a --- /dev/null +++ b/apps/server/src/sockets/chatAuthor.test.ts @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { DEFAULT_USER } from "@tangent/shared/contracts.ts"; + +/** + * `AUTH_JWT_TOKEN_COOKIE_NAME` is read once when `config.ts` is first imported, + * so the cookie name is set before the module graph loads and both branches of + * `resolveSocketAuthor` are reachable from one file. + */ +process.env.AUTH_JWT_TOKEN_COOKIE_NAME = "OKTASSO_TOKEN"; +const { resolveSocketAuthor } = await import("./chat.ts"); + +/** An unsigned JWT carrying just the claims the identity resolver reads. */ +function tokenFor(claims: Record): string { + const payload = Buffer.from(JSON.stringify(claims)).toString("base64url"); + return `header.${payload}.signature`; +} + +test("the author comes from the connection's own JWT", () => { + const cookie = `OKTASSO_TOKEN=${tokenFor({ + email: "ada@example.com", + given_name: "Ada", + family_name: "Lovelace", + })}`; + + assert.deepEqual(resolveSocketAuthor(cookie), { + id: "ada@example.com", + kind: "human", + name: "Ada L.", + }); +}); + +test("no cookie falls back to the identity the UI also assumes", () => { + const author = resolveSocketAuthor(undefined); + + assert.equal(author.kind, "human"); + assert.equal( + author.id, + DEFAULT_USER.email, + "server and UI must agree on the id, or your own messages look like someone else's", + ); +}); + +test("a malformed token falls back rather than throwing", () => { + assert.equal( + resolveSocketAuthor("OKTASSO_TOKEN=not-a-jwt").id, + DEFAULT_USER.email, + ); +}); diff --git a/apps/server/src/sockets/mentions.test.ts b/apps/server/src/sockets/mentions.test.ts new file mode 100644 index 0000000..e0cb172 --- /dev/null +++ b/apps/server/src/sockets/mentions.test.ts @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { type MentionCandidate, resolveMentions } from "./mentions.ts"; + +const ROSTER: MentionCandidate[] = [ + { id: "prime", name: "Prime" }, + { id: "sub-1", name: "Worker One" }, + { id: "sub-2", name: "researcher" }, +]; + +test("resolves a name to its participant id", () => { + assert.deepEqual(resolveMentions("@Prime take a look", ROSTER), ["prime"]); +}); + +test("matches case-insensitively and ignores spacing in the name", () => { + assert.deepEqual(resolveMentions("@workerone ping", ROSTER), ["sub-1"]); + assert.deepEqual(resolveMentions("@RESEARCHER ping", ROSTER), ["sub-2"]); +}); + +test("an id can be mentioned directly", () => { + assert.deepEqual(resolveMentions("@sub-1 status?", ROSTER), ["sub-1"]); +}); + +test("trailing punctuation belongs to the sentence, not the name", () => { + assert.deepEqual(resolveMentions("thanks @Prime, and @sub-2!", ROSTER), [ + "prime", + "sub-2", + ]); +}); + +test("an unknown mention stays prose rather than becoming an id", () => { + assert.deepEqual(resolveMentions("@nobody hello @Prime", ROSTER), ["prime"]); +}); + +test("a repeated mention is listed once, in first-mention order", () => { + assert.deepEqual( + resolveMentions("@sub-2 and @Prime and @sub-2 again", ROSTER), + ["sub-2", "prime"], + ); +}); + +test("a message with no mentions resolves to nothing", () => { + assert.deepEqual(resolveMentions("just a message", ROSTER), []); + assert.deepEqual(resolveMentions("an email a@b.com", ROSTER), []); +}); diff --git a/apps/server/src/sockets/mentions.ts b/apps/server/src/sockets/mentions.ts new file mode 100644 index 0000000..f1530ee --- /dev/null +++ b/apps/server/src/sockets/mentions.ts @@ -0,0 +1,59 @@ +/** + * Resolving `@name` in a message body to participant ids, once, at write time. + * + * The point is that nothing downstream re-reads the body to decide who should + * act: `ChatMessage.mentions` carries ids, and a participant renamed later does + * not change who an old message addressed. + */ + +/** A participant a mention can resolve to. */ +export interface MentionCandidate { + id: string; + name: string; +} + +/** `@` followed by a run of non-whitespace, which is the whole mention grammar. */ +const MENTION_PATTERN = /@([^\s@]+)/g; + +/** + * Strips the characters a display name carries but a typed mention won't, so + * `@WorkerOne` matches a participant named `Worker One`. + */ +function normalize(value: string): string { + return value.toLowerCase().replace(/[\s_-]/g, ""); +} + +/** + * Trailing punctuation belongs to the sentence, not the name: `@prime,` and + * `@prime.` both address Prime. + */ +function trimTrailingPunctuation(token: string): string { + return token.replace(/[.,;:!?)\]}'"]+$/, ""); +} + +/** + * Resolves every `@name` in `text` to a candidate's id, in first-mention order + * and without duplicates. A mention matching nothing in the roster is left as + * prose — an unresolvable id would be worse than no mention at all. + * + * Names are matched case-insensitively and ignoring spaces, underscores and + * hyphens, so a multiword name is reachable only when typed without its spaces. + * Ids match too, which is how a client that already knows an id can be exact. + */ +export function resolveMentions( + text: string, + candidates: readonly MentionCandidate[], +): string[] { + const byToken = new Map(); + for (const candidate of candidates) { + byToken.set(normalize(candidate.name), candidate.id); + byToken.set(normalize(candidate.id), candidate.id); + } + + const resolved: string[] = []; + for (const [, token] of text.matchAll(MENTION_PATTERN)) { + const id = byToken.get(normalize(trimTrailingPunctuation(token))); + if (id && !resolved.includes(id)) resolved.push(id); + } + return resolved; +} diff --git a/apps/server/src/store/chatLog.test.ts b/apps/server/src/store/chatLog.test.ts index 8c9e493..7744c5c 100644 --- a/apps/server/src/store/chatLog.test.ts +++ b/apps/server/src/store/chatLog.test.ts @@ -1,12 +1,24 @@ import assert from "node:assert/strict"; -import { mkdtempSync, rmSync } from "node:fs"; +import { + appendFileSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, +} from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { test } from "node:test"; import type { ChatMessage } from "@tangent/shared/contracts.ts"; -import { appendMessage, readActivity } from "./chatLog.ts"; +import { + appendMessage, + highestSeq, + readActivity, + readAllMessages, + readMessages, +} from "./chatLog.ts"; function tempRoot(): { root: string; cleanup: () => void } { const root = mkdtempSync(path.join(tmpdir(), "chatlog-")); @@ -16,39 +28,61 @@ function tempRoot(): { root: string; cleanup: () => void } { }; } -function agentMessage(id: string, createdAt: string): ChatMessage { +/** Writes raw JSONL lines the way a pre-envelope build would have. */ +function writeLegacyLog( + root: string, + conversationId: string, + lines: Record[], +): string { + const dir = path.join(root, ".tangent", "chats"); + mkdirSync(dir, { recursive: true }); + const file = path.join(dir, `${conversationId}.jsonl`); + for (const line of lines) appendFileSync(file, `${JSON.stringify(line)}\n`); + return file; +} + +function agentMessage(id: string, createdAt: string, seq = 1): ChatMessage { return { id, sessionId: "s", conversationId: "prime", + seq, author: { id: "prime", kind: "agent", name: "Prime" }, + mentions: [], + source: { kind: "agent" }, content: "hi", createdAt, }; } -function humanMessage(id: string, createdAt: string): ChatMessage { +function humanMessage(id: string, createdAt: string, seq = 1): ChatMessage { return { id, sessionId: "s", conversationId: "prime", + seq, author: { id: "u", kind: "human", name: "You" }, + mentions: [], + source: { kind: "human" }, content: "hi", createdAt, }; } -function subagentMessage(id: string, createdAt: string): ChatMessage { +function subagentMessage(id: string, createdAt: string, seq = 1): ChatMessage { return { id, sessionId: "s", conversationId: "sub-1", + seq, author: { id: "sub-1", kind: "agent", name: "Worker", agentRole: "subagent", }, + mentions: [], + source: { kind: "agent" }, content: "hi", createdAt, }; @@ -69,9 +103,9 @@ test("readActivity returns zero for a session with no chat log", async () => { test("readActivity counts only agent messages and tracks last activity", async () => { const { root, cleanup } = tempRoot(); try { - await appendMessage(root, humanMessage("1", "2026-01-01T00:00:00.000Z")); - await appendMessage(root, agentMessage("2", "2026-01-01T00:00:01.000Z")); - await appendMessage(root, agentMessage("3", "2026-01-01T00:00:02.000Z")); + await appendMessage(root, humanMessage("1", "2026-01-01T00:00:00.000Z", 1)); + await appendMessage(root, agentMessage("2", "2026-01-01T00:00:01.000Z", 2)); + await appendMessage(root, agentMessage("3", "2026-01-01T00:00:02.000Z", 3)); const all = await readActivity(root); assert.equal(all.unreadCount, 2); @@ -84,9 +118,15 @@ test("readActivity counts only agent messages and tracks last activity", async ( test("readActivity counts only the Prime conversation, not subagents", async () => { const { root, cleanup } = tempRoot(); try { - await appendMessage(root, agentMessage("1", "2026-01-01T00:00:00.000Z")); - await appendMessage(root, subagentMessage("2", "2026-01-01T00:00:01.000Z")); - await appendMessage(root, subagentMessage("3", "2026-01-01T00:00:02.000Z")); + await appendMessage(root, agentMessage("1", "2026-01-01T00:00:00.000Z", 1)); + await appendMessage( + root, + subagentMessage("2", "2026-01-01T00:00:01.000Z", 1), + ); + await appendMessage( + root, + subagentMessage("3", "2026-01-01T00:00:02.000Z", 2), + ); const all = await readActivity(root); assert.equal(all.unreadCount, 1, "subagent messages are excluded"); @@ -98,9 +138,9 @@ test("readActivity counts only the Prime conversation, not subagents", async () test("readActivity only counts agent messages strictly after `since`", async () => { const { root, cleanup } = tempRoot(); try { - await appendMessage(root, agentMessage("1", "2026-01-01T00:00:00.000Z")); - await appendMessage(root, agentMessage("2", "2026-01-01T00:00:01.000Z")); - await appendMessage(root, humanMessage("3", "2026-01-01T00:00:02.000Z")); + await appendMessage(root, agentMessage("1", "2026-01-01T00:00:00.000Z", 1)); + await appendMessage(root, agentMessage("2", "2026-01-01T00:00:01.000Z", 2)); + await appendMessage(root, humanMessage("3", "2026-01-01T00:00:02.000Z", 3)); const since = await readActivity(root, "2026-01-01T00:00:00.000Z"); assert.equal(since.unreadCount, 1, "the at-or-before message is excluded"); @@ -109,3 +149,168 @@ test("readActivity only counts agent messages strictly after `since`", async () cleanup(); } }); + +test("a line written before the envelope existed reads back with one", async () => { + const { root, cleanup } = tempRoot(); + try { + writeLegacyLog(root, "prime", [ + { + id: "a", + sessionId: "s", + conversationId: "prime", + author: { id: "u", kind: "human", name: "You" }, + content: "first", + createdAt: "2026-01-01T00:00:00.000Z", + }, + { + id: "b", + sessionId: "s", + conversationId: "prime", + author: { id: "prime", kind: "agent", name: "Prime" }, + content: "second", + createdAt: "2026-01-01T00:00:01.000Z", + }, + { + id: "c", + sessionId: "s", + conversationId: "prime", + author: { id: "system", kind: "agent", name: "System" }, + content: "third", + createdAt: "2026-01-01T00:00:02.000Z", + }, + ]); + + const messages = await readMessages(root, "prime"); + assert.deepEqual( + messages.map((message) => message.seq), + [1, 2, 3], + "seq comes from the line's position in the log", + ); + assert.deepEqual( + messages.map((message) => message.source.kind), + ["human", "agent", "system"], + "provenance is derived from the author, System included", + ); + assert.deepEqual( + messages.map((message) => message.mentions), + [[], [], []], + ); + } finally { + cleanup(); + } +}); + +test("reading a legacy log never rewrites it", async () => { + const { root, cleanup } = tempRoot(); + try { + const file = writeLegacyLog(root, "prime", [ + { + id: "a", + sessionId: "s", + conversationId: "prime", + author: { id: "u", kind: "human", name: "You" }, + content: "first", + createdAt: "2026-01-01T00:00:00.000Z", + }, + ]); + const before = readFileSync(file, "utf8"); + + await readMessages(root, "prime"); + await readAllMessages(root); + + assert.equal(readFileSync(file, "utf8"), before); + } finally { + cleanup(); + } +}); + +test("a log that gained the envelope mid-file keeps one sequence", async () => { + const { root, cleanup } = tempRoot(); + try { + writeLegacyLog(root, "prime", [ + { + id: "old-1", + sessionId: "s", + conversationId: "prime", + author: { id: "u", kind: "human", name: "You" }, + content: "legacy", + createdAt: "2026-01-01T00:00:00.000Z", + }, + { + id: "old-2", + sessionId: "s", + conversationId: "prime", + author: { id: "prime", kind: "agent", name: "Prime" }, + content: "legacy", + createdAt: "2026-01-01T00:00:01.000Z", + }, + ]); + assert.equal(await highestSeq(root, "prime"), 2); + + // What the store's counter would hand out next, seeded above the log. + await appendMessage( + root, + humanMessage("new-1", "2026-01-01T00:00:02.000Z", 3), + ); + + const messages = await readMessages(root, "prime"); + assert.deepEqual( + messages.map((message) => [message.id, message.seq]), + [ + ["old-1", 1], + ["old-2", 2], + ["new-1", 3], + ], + "old positions and new allocations form one sequence", + ); + } finally { + cleanup(); + } +}); + +test("readMessages orders by seq, not by append order", async () => { + const { root, cleanup } = tempRoot(); + try { + // A steer persisted while an earlier turn was still streaming lands in the + // file after the turn it interrupted but holds the lower seq. + await appendMessage( + root, + agentMessage("late", "2026-01-01T00:00:09.000Z", 5), + ); + await appendMessage( + root, + humanMessage("early", "2026-01-01T00:00:03.000Z", 4), + ); + + const messages = await readMessages(root, "prime"); + assert.deepEqual( + messages.map((message) => message.id), + ["early", "late"], + ); + } finally { + cleanup(); + } +}); + +test("readAllMessages still merges conversations by createdAt", async () => { + const { root, cleanup } = tempRoot(); + try { + // Each conversation numbers from 1, so seq says nothing across them. + await appendMessage( + root, + subagentMessage("sub", "2026-01-01T00:00:00.000Z", 1), + ); + await appendMessage( + root, + agentMessage("prime", "2026-01-01T00:00:01.000Z", 1), + ); + + const messages = await readAllMessages(root); + assert.deepEqual( + messages.map((message) => message.id), + ["sub", "prime"], + ); + } finally { + cleanup(); + } +}); diff --git a/apps/server/src/store/chatLog.ts b/apps/server/src/store/chatLog.ts index 0f9f687..841014a 100644 --- a/apps/server/src/store/chatLog.ts +++ b/apps/server/src/store/chatLog.ts @@ -1,7 +1,11 @@ import { appendFile, mkdir, readdir, readFile } from "node:fs/promises"; import path from "node:path"; -import { type ChatMessage, PI_AGENT } from "@tangent/shared/contracts.ts"; +import { + type ChatMessage, + PI_AGENT, + sourceFromAuthor, +} from "@tangent/shared/contracts.ts"; /** Per-session subdirectory (under `.tangent/`) holding chat JSONL files. */ const CHATS_DIR = path.join(".tangent", "chats"); @@ -33,9 +37,26 @@ function logFile(rootPath: string, conversationId: string): string { return path.join(chatsDir(rootPath), `${conversationId}${LOG_EXT}`); } +/** + * Fills in the envelope fields a line written before they existed has no way to + * carry. `position` is the line's 1-based place in the log, which is what a + * legacy message's `seq` is: the conversation counter is seeded above this same + * high-water mark, so old and new numbering form one sequence. The file itself + * is never rewritten — this adapter runs on every read instead. + */ +function normalizeMessage(parsed: ChatMessage, position: number): ChatMessage { + return { + ...parsed, + seq: parsed.seq ?? position, + mentions: parsed.mentions ?? [], + source: parsed.source ?? sourceFromAuthor(parsed.author), + }; +} + /** * Parses a JSONL file's contents into messages, tolerating a torn or blank - * trailing line (e.g. a crash mid-append). Unparseable lines are skipped. + * trailing line (e.g. a crash mid-append). Unparseable lines are skipped, so a + * corrupt line shifts the positions the messages after it are numbered from. */ function parseLines(raw: string): ChatMessage[] { const messages: ChatMessage[] = []; @@ -43,7 +64,8 @@ function parseLines(raw: string): ChatMessage[] { const trimmed = line.trim(); if (!trimmed) continue; try { - messages.push(JSON.parse(trimmed) as ChatMessage); + const parsed = JSON.parse(trimmed) as ChatMessage; + messages.push(normalizeMessage(parsed, messages.length + 1)); } catch { // Skip a partial/corrupt line rather than failing the whole read. } @@ -52,10 +74,19 @@ function parseLines(raw: string): ChatMessage[] { } /** - * Stable global ordering for a session's merged transcript. Within one - * conversation file append order already matches id order; across files we sort - * by `createdAt` then `id` so consumers that rely on global order (history - * replay, `slice(-limit)`) see a deterministic sequence. + * Ordering within one Conversation: its `seq`, which is exactly what the + * per-conversation counter exists to provide. Ties fall back to `id` so a + * hand-edited or duplicated seq still sorts deterministically. + */ +function bySeq(a: ChatMessage, b: ChatMessage): number { + return a.seq - b.seq || a.id.localeCompare(b.id); +} + +/** + * Stable global ordering for a session's merged transcript. `seq` is + * per-conversation and says nothing across them, so the merge sorts by + * `createdAt` then `id` — consumers that rely on global order (history replay, + * `slice(-limit)`) still see a deterministic sequence. */ function byCreatedThenId(a: ChatMessage, b: ChatMessage): number { return a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id); @@ -80,8 +111,8 @@ export async function appendMessage( } /** - * Reads a single conversation's messages in append (id) order, or `[]` when the - * log doesn't exist yet. + * Reads a single conversation's messages in `seq` order, or `[]` when the log + * doesn't exist yet. */ export async function readMessages( rootPath: string, @@ -89,14 +120,26 @@ export async function readMessages( ): Promise { if (isUnsafeConversationId(conversationId)) return []; try { - return parseLines( - await readFile(logFile(rootPath, conversationId), "utf8"), - ); + const raw = await readFile(logFile(rootPath, conversationId), "utf8"); + return parseLines(raw).sort(bySeq); } catch { return []; } } +/** + * The highest `seq` a conversation's log already occupies, or `0` when it has + * none. Seeds the counter so allocation starts above every message persisted + * before `seq` existed. + */ +export async function highestSeq( + rootPath: string, + conversationId: string, +): Promise { + const messages = await readMessages(rootPath, conversationId); + return messages.at(-1)?.seq ?? 0; +} + export interface ChatActivity { unreadCount: number; lastActivityAt?: string; diff --git a/apps/server/src/store/db/migrations/0008_special_quasimodo.sql b/apps/server/src/store/db/migrations/0008_special_quasimodo.sql new file mode 100644 index 0000000..cc582c8 --- /dev/null +++ b/apps/server/src/store/db/migrations/0008_special_quasimodo.sql @@ -0,0 +1,10 @@ +CREATE TABLE `conversations` ( + `id` text NOT NULL, + `session_id` text NOT NULL, + `next_seq` integer DEFAULT 1 NOT NULL, + `created_at` text NOT NULL, + FOREIGN KEY (`session_id`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `conversations_session_idx` ON `conversations` (`session_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `conversations_session_id` ON `conversations` (`session_id`,`id`); \ No newline at end of file diff --git a/apps/server/src/store/db/migrations/meta/0008_snapshot.json b/apps/server/src/store/db/migrations/meta/0008_snapshot.json new file mode 100644 index 0000000..ecdf85f --- /dev/null +++ b/apps/server/src/store/db/migrations/meta/0008_snapshot.json @@ -0,0 +1,533 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "3089ff01-711d-4a01-89f9-02a03b0d23fd", + "prevId": "c846f8a6-30ed-4409-a4ac-0c34ac1c3ac4", + "tables": { + "conversations": { + "name": "conversations", + "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 + }, + "next_seq": { + "name": "next_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "conversations_session_idx": { + "name": "conversations_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "conversations_session_id": { + "name": "conversations_session_id", + "columns": ["session_id", "id"], + "isUnique": true + } + }, + "foreignKeys": { + "conversations_session_id_sessions_id_fk": { + "name": "conversations_session_id_sessions_id_fk", + "tableFrom": "conversations", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "runs": { + "name": "runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "participant_id": { + "name": "participant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "home_conversation_id": { + "name": "home_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "ingress": { + "name": "ingress", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": 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 + }, + "ended_at": { + "name": "ended_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "runs_session_idx": { + "name": "runs_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "runs_session_participant_idx": { + "name": "runs_session_participant_idx", + "columns": ["session_id", "participant_id"], + "isUnique": false + } + }, + "foreignKeys": { + "runs_session_id_sessions_id_fk": { + "name": "runs_session_id_sessions_id_fk", + "tableFrom": "runs", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "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'" + }, + "connector_kind": { + "name": "connector_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_lifecycle": { + "name": "connector_lifecycle", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_environment_id": { + "name": "connector_environment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "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 c682d5e..b363c96 100644 --- a/apps/server/src/store/db/migrations/meta/_journal.json +++ b/apps/server/src/store/db/migrations/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1786472001817, "tag": "0007_complex_puppet_master", "breakpoints": true + }, + { + "idx": 8, + "version": "6", + "when": 1786481948836, + "tag": "0008_special_quasimodo", + "breakpoints": true } ] } diff --git a/apps/server/src/store/db/schema.ts b/apps/server/src/store/db/schema.ts index 520f4e9..f9dc98e 100644 --- a/apps/server/src/store/db/schema.ts +++ b/apps/server/src/store/db/schema.ts @@ -166,6 +166,32 @@ export const runs = sqliteTable( ], ); +/** + * Per-conversation `seq` counter: the write authority that gives a Conversation + * an order rather than a race. Rows are created on first allocation, seeded + * above whatever the conversation's existing JSONL log already occupies, so + * numbering never collides with messages persisted before `seq` existed. + * + * The embryo of a full Conversation entity — it holds only the counter today. + */ +export const conversations = sqliteTable( + "conversations", + { + /** Conversation id: an agent id today (`prime` or a sub-agent uuid). */ + id: text("id").notNull(), + sessionId: text("session_id") + .notNull() + .references(() => sessions.id, { onDelete: "cascade" }), + /** The next `seq` to hand out; incremented as each is allocated. */ + nextSeq: integer("next_seq").notNull().default(1), + createdAt: text("created_at").notNull(), + }, + (table) => [ + unique("conversations_session_id").on(table.sessionId, table.id), + index("conversations_session_idx").on(table.sessionId), + ], +); + /** When each user last opened a session. `user_key` is the email, or `local`. */ export const sessionViews = sqliteTable( "session_views", @@ -186,3 +212,4 @@ export type SessionRow = typeof sessions.$inferSelect; export type SessionAssetRow = typeof sessionAssets.$inferSelect; export type SessionAgentRow = typeof sessionAgents.$inferSelect; export type RunRow = typeof runs.$inferSelect; +export type ConversationRow = typeof conversations.$inferSelect; diff --git a/apps/server/src/store/inMemorySessionStore.ts b/apps/server/src/store/inMemorySessionStore.ts index b1244a4..db41c6c 100644 --- a/apps/server/src/store/inMemorySessionStore.ts +++ b/apps/server/src/store/inMemorySessionStore.ts @@ -74,6 +74,8 @@ export class InMemorySessionStore implements SessionStore { private readonly artifacts = new Map(); private readonly agents = new Map(); private readonly views = new Map>(); + /** Per-conversation `seq` counters, keyed `sessionId/conversationId`. */ + private readonly seqs = new Map(); async listSessions(): Promise { return [...this.sessions.values()].sort((a, b) => @@ -152,6 +154,9 @@ export class InMemorySessionStore implements SessionStore { async deleteSession(id: string): Promise { this.messages.delete(id); + for (const key of this.seqs.keys()) { + if (key.startsWith(`${id}/`)) this.seqs.delete(key); + } this.artifacts.delete(id); this.agents.delete(id); return this.sessions.delete(id); @@ -170,6 +175,22 @@ export class InMemorySessionStore implements SessionStore { } } + async nextSeq(sessionId: string, conversationId: string): Promise { + const key = `${sessionId}/${conversationId}`; + const allocated = this.seqs.get(key) ?? this.seedSeq(sessionId, key); + this.seqs.set(key, allocated + 1); + return allocated; + } + + /** Seeds a counter above whatever the in-memory transcript already holds. */ + private seedSeq(sessionId: string, key: string): number { + const conversationId = key.slice(sessionId.length + 1); + const held = (this.messages.get(sessionId) ?? []).filter( + (message) => message.conversationId === conversationId, + ); + return Math.max(0, ...held.map((message) => message.seq)) + 1; + } + async getArtifacts(sessionId: string): Promise { return this.artifacts.get(sessionId) ?? []; } diff --git a/apps/server/src/store/sessionStore.ts b/apps/server/src/store/sessionStore.ts index dd60e9b..b63ab5a 100644 --- a/apps/server/src/store/sessionStore.ts +++ b/apps/server/src/store/sessionStore.ts @@ -134,6 +134,14 @@ export interface SessionStore { getMessages(sessionId: string): Promise; appendMessage(message: ChatMessage): Promise; + /** + * Allocates the next `seq` in a Conversation. The single allocator: {@link + * appendMessage} persists whatever it is handed, and `ChatMessage.seq` is + * required, so no writer can skip this. Monotonic but not gap-free — a + * streamed turn reserves its `seq` before its content exists, and a stream + * that fails leaves the number spent. + */ + nextSeq(sessionId: string, conversationId: string): Promise; /** Returns the session's pinned artifacts, oldest first. */ getArtifacts(sessionId: string): Promise; diff --git a/apps/server/src/store/sqliteSessionStore.test.ts b/apps/server/src/store/sqliteSessionStore.test.ts index 983b446..ff44881 100644 --- a/apps/server/src/store/sqliteSessionStore.test.ts +++ b/apps/server/src/store/sqliteSessionStore.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { after, test } from "node:test"; @@ -192,6 +192,60 @@ test("listAgentsForEnvironment finds one environment's sub-agents across session ); }); +test("nextSeq is monotonic within a conversation", async () => { + const store = newStore(); + const session = await store.createSession({ name: "S" }); + + const allocated = [ + await store.nextSeq(session.id, "prime"), + await store.nextSeq(session.id, "prime"), + await store.nextSeq(session.id, "prime"), + ]; + + assert.deepEqual(allocated, [1, 2, 3]); +}); + +test("each conversation gets its own sequence", async () => { + const store = newStore(); + const session = await store.createSession({ name: "S" }); + + await store.nextSeq(session.id, "prime"); + await store.nextSeq(session.id, "prime"); + + assert.equal( + await store.nextSeq(session.id, "sub-1"), + 1, + "a sub-agent's thread starts at 1 regardless of Prime's", + ); + assert.equal(await store.nextSeq(session.id, "prime"), 3); +}); + +test("nextSeq seeds above a transcript written before seq existed", async () => { + const store = newStore(); + const session = await store.createSession({ name: "S" }); + + // Two lines carrying no envelope, exactly as an older build would have left + // them. They read back as seq 1 and 2, so allocation must start at 3. + const dir = path.join(session.rootPath, ".tangent", "chats"); + mkdirSync(dir, { recursive: true }); + const legacy = (id: string) => + `${JSON.stringify({ + id, + sessionId: session.id, + conversationId: "prime", + author: { id: "u", kind: "human", name: "You" }, + content: id, + createdAt: "2026-01-01T00:00:00.000Z", + })}\n`; + writeFileSync( + path.join(dir, "prime.jsonl"), + `${legacy("old-1")}${legacy("old-2")}`, + ); + + assert.equal(await store.nextSeq(session.id, "prime"), 3); + assert.equal(await store.nextSeq(session.id, "prime"), 4); +}); + test("deleting a session cascades its read state", async () => { const store = newStore(); const session = await store.createSession({ name: "S" }); diff --git a/apps/server/src/store/sqliteSessionStore.ts b/apps/server/src/store/sqliteSessionStore.ts index 0324b58..082cf28 100644 --- a/apps/server/src/store/sqliteSessionStore.ts +++ b/apps/server/src/store/sqliteSessionStore.ts @@ -21,10 +21,12 @@ import { and, asc, count, eq } from "drizzle-orm"; import { ARTIFACTS_DIRNAME, SESSIONS_ROOT } from "../config.ts"; import { appendMessage as appendChatMessage, + highestSeq, readAllMessages, } from "./chatLog.ts"; import type { Db } from "./db/client.ts"; import { + conversations, type SessionAgentRow, sessionAgents, sessionAssets, @@ -278,6 +280,72 @@ export class SqliteSessionStore implements SessionStore { await appendChatMessage(rootPath, message); } + async nextSeq(sessionId: string, conversationId: string): Promise { + await this.seedConversation(sessionId, conversationId); + // better-sqlite3 is synchronous, so read-then-increment inside one + // transaction is genuinely atomic: two concurrent writers get an order + // rather than the same number. + return this.db.transaction((tx) => { + const row = tx + .select({ nextSeq: conversations.nextSeq }) + .from(conversations) + .where( + and( + eq(conversations.sessionId, sessionId), + eq(conversations.id, conversationId), + ), + ) + .get(); + const allocated = row?.nextSeq ?? 1; + tx.update(conversations) + .set({ nextSeq: allocated + 1 }) + .where( + and( + eq(conversations.sessionId, sessionId), + eq(conversations.id, conversationId), + ), + ) + .run(); + return allocated; + }); + } + + /** + * Creates a conversation's counter row on first use, starting above whatever + * its existing JSONL log occupies. Messages written before `seq` existed are + * numbered from their position on read, so seeding at 1 would hand out + * numbers a legacy transcript already uses. + */ + private async seedConversation( + sessionId: string, + conversationId: string, + ): Promise { + const existing = this.db + .select({ id: conversations.id }) + .from(conversations) + .where( + and( + eq(conversations.sessionId, sessionId), + eq(conversations.id, conversationId), + ), + ) + .get(); + if (existing) return; + + const rootPath = await this.rootPathFor(sessionId); + const occupied = rootPath ? await highestSeq(rootPath, conversationId) : 0; + this.db + .insert(conversations) + .values({ + id: conversationId, + sessionId, + nextSeq: occupied + 1, + createdAt: new Date().toISOString(), + }) + .onConflictDoNothing() + .run(); + } + async getArtifacts(sessionId: string): Promise { return this.readArtifacts(sessionId); } diff --git a/apps/web/src/features/chat/hooks/useSessionChat.ts b/apps/web/src/features/chat/hooks/useSessionChat.ts index 75205f9..51967f5 100644 --- a/apps/web/src/features/chat/hooks/useSessionChat.ts +++ b/apps/web/src/features/chat/hooks/useSessionChat.ts @@ -12,9 +12,9 @@ import { type ArtifactPinPayload, type ArtifactUnpinPayload, type Attachment, - type ChatAuthor, type ChatMessage, type ChatMessagePayload, + humanAuthor, type MemoryConfirmPayload, type MemoryDismissPayload, type MemorySuggestionPayload, @@ -44,7 +44,6 @@ import { } from "@/features/chat/model/agentStatusQueryKeys"; import { SessionQueryKeys } from "@/features/sessions/model/sessionQueryKeys"; import { useCurrentUser } from "@/features/user/hooks/useCurrentUser"; -import { userShortName } from "@/features/user/model/userDisplay"; import { queryClient } from "@/shared/api/queryClient"; import { BASE_PREFIX } from "@/shared/lib/basePath"; @@ -131,15 +130,11 @@ export function useSessionChat(sessionId: string) { // event" from "the spinner cleared because text started arriving". const streamingRuns = useRef>(new Set()); - // The current human's chat identity, derived from `GET /api/me`. Using the - // email as the author id keeps "is this my message?" detection stable across - // reloads, and the short name (`John S.`) is what other participants see. + // The current human's chat identity. Only used to recognise our own messages + // in the transcript — the server authors what it persists, from the socket's + // own cookie, so this shares `humanAuthor` with it rather than guessing. const user = useCurrentUser(); - const author: ChatAuthor = { - id: user.email || "local-user", - kind: "human", - name: userShortName(user), - }; + const author = humanAuthor(user); useEffect(() => { if (!sessionId) return; @@ -497,7 +492,6 @@ export function useSessionChat(sessionId: string) { const payload: ChatMessagePayload = { sessionId, - author, content: trimmed, conversationId: options?.conversationId ?? PI_AGENT.id, delivery: options?.delivery ?? "auto", diff --git a/apps/web/src/features/user/api/userApi.ts b/apps/web/src/features/user/api/userApi.ts index 0b51765..7499bb0 100644 --- a/apps/web/src/features/user/api/userApi.ts +++ b/apps/web/src/features/user/api/userApi.ts @@ -1,9 +1,7 @@ -import type { UserIdentity } from "@tangent/shared/contracts"; +import { DEFAULT_USER, type UserIdentity } from "@tangent/shared/contracts"; import { apiUrl } from "@/shared/lib/basePath"; -import { DEFAULT_USER } from "../model/userDisplay"; - /** * Fetches the current user from `GET /api/me`. The endpoint returns `401`/`501` * when no Oktasso JWT is present (e.g. local development), so any non-ok diff --git a/apps/web/src/features/user/hooks/useCurrentUser.ts b/apps/web/src/features/user/hooks/useCurrentUser.ts index 48ba709..6aacf65 100644 --- a/apps/web/src/features/user/hooks/useCurrentUser.ts +++ b/apps/web/src/features/user/hooks/useCurrentUser.ts @@ -1,8 +1,7 @@ -import type { UserIdentity } from "@tangent/shared/contracts"; +import { DEFAULT_USER, type UserIdentity } from "@tangent/shared/contracts"; import { useQuery } from "@tanstack/react-query"; import { getMe } from "@/features/user/api/userApi"; -import { DEFAULT_USER } from "@/features/user/model/userDisplay"; import { UserQueryKeys } from "@/features/user/model/userQueryKeys"; /** diff --git a/apps/web/src/features/user/model/userDisplay.ts b/apps/web/src/features/user/model/userDisplay.ts index 5077d42..480c90c 100644 --- a/apps/web/src/features/user/model/userDisplay.ts +++ b/apps/web/src/features/user/model/userDisplay.ts @@ -1,16 +1,5 @@ import type { UserIdentity } from "@tangent/shared/contracts"; -/** - * Safety-net identity used when the Oktasso JWT is unavailable (e.g. local - * development without the cookie configured), so the UI always has a name to - * show. - */ -export const DEFAULT_USER: UserIdentity = { - email: "maxim.ezhov@shopify.com", - first_name: "John", - last_name: "Smith", -}; - /** * The user's initials for an avatar badge: first letter of the first name plus * first letter of the last name, uppercased (e.g. `John Smith` -> `JS`). @@ -22,15 +11,3 @@ export function userInitials(user: UserIdentity): string { const initials = `${first}${last}`.toUpperCase(); return initials || "?"; } - -/** - * The user's short display name: first name plus last-name initial (e.g. - * `John Smith` -> `John S.`). Falls back to the first name alone, then the - * email, when name parts are missing. - */ -export function userShortName(user: UserIdentity): string { - const first = user.first_name.trim(); - const lastInitial = user.last_name.trim().charAt(0).toUpperCase(); - if (first && lastInitial) return `${first} ${lastInitial}.`; - return first || user.email; -} diff --git a/packages/shared/src/contracts.ts b/packages/shared/src/contracts.ts index a4afd0f..a2e91c1 100644 --- a/packages/shared/src/contracts.ts +++ b/packages/shared/src/contracts.ts @@ -22,6 +22,31 @@ export interface UserIdentity { last_name: string; } +/** + * Safety-net identity used when the Oktasso JWT is unavailable (e.g. local + * development without `AUTH_JWT_TOKEN_COOKIE_NAME` configured). Shared so the + * server's socket-resolved authorship and the UI's own view of "who am I" land + * on the same id — a disagreement would render your own messages as somebody + * else's. + */ +export const DEFAULT_USER: UserIdentity = { + email: "maxim.ezhov@shopify.com", + first_name: "John", + last_name: "Smith", +}; + +/** + * The user's short display name: first name plus last-name initial (e.g. + * `John Smith` -> `John S.`). Falls back to the first name alone, then the + * email, when name parts are missing. + */ +export function userShortName(user: UserIdentity): string { + const first = user.first_name.trim(); + const lastInitial = user.last_name.trim().charAt(0).toUpperCase(); + if (first && lastInitial) return `${first} ${lastInitial}.`; + return first || user.email; +} + /** * Live run status of a session, derived from its Pi process roster on the * server and pushed to clients over the socket: @@ -162,6 +187,21 @@ export interface ChatAuthor { agentRole?: AgentRole; } +/** + * The chat identity of a human, derived from their resolved {@link + * UserIdentity}. The email is the author id so "is this my message?" stays + * stable across reloads. Shared between the server (which resolves authorship + * from the request's own cookie) and the UI, so there is one definition rather + * than two that can drift. + */ +export function humanAuthor(user: UserIdentity): ChatAuthor { + return { + id: user.email || "local-user", + kind: "human", + name: userShortName(user), + }; +} + /** * The session's Prime coding agent. It is the only agent a human talks to and * the only one allowed to direct sub-agents. Shared across every session. @@ -599,6 +639,36 @@ export interface Attachment { size: number; } +/** + * Where a Message came from, as opposed to who wrote it: + * - `human` — typed by a person. + * - `agent` — produced by an agent's run. + * - `system` — emitted by the server itself (an undeliverable message, a + * structured cause). + * - `relay` — forwarded from another Conversation on a participant's behalf. No + * producer yet; the auto-relay paths become this rather than mangling content. + */ +export type MessageSourceKind = "human" | "agent" | "system" | "relay"; + +/** Provenance of a Message, independent of its {@link ChatAuthor}. */ +export interface MessageSource { + kind: MessageSourceKind; + /** The participant it originated from, when distinct from the author. */ + from?: string; + /** The Conversation it was posted from, when it arrived from another. */ + fromConversation?: string; +} + +/** + * Derives a Message's provenance from its author. The write path and the + * legacy-line adapter in `chatLog.ts` share this, so a message persisted before + * the envelope existed reads back with the same `source` a new one would get. + */ +export function sourceFromAuthor(author: ChatAuthor): MessageSource { + if (author.id === SYSTEM_AUTHOR.id) return { kind: "system" }; + return { kind: author.kind }; +} + /** A single chat message. `content` is markdown. */ export interface ChatMessage { id: string; @@ -609,7 +679,22 @@ export interface ChatMessage { * which transcript the client buckets the message into. */ conversationId: string; + /** + * Position in its Conversation, monotonic from 1 and assigned server-side. + * Not gap-free: a stream that reserves a `seq` and then fails leaves a hole. + * Messages persisted before this field existed are numbered from their + * position in the log on read. + */ + seq: number; author: ChatAuthor; + /** + * Participant ids this Message expects to act, resolved from `@name` at write + * time so nothing downstream has to regex the body. Empty means "posted to + * the Conversation, addressed to no one in particular". + */ + mentions: string[]; + /** How the Message came to exist, as opposed to who authored it. */ + source: MessageSource; content: string; /** * The agent's reasoning (markdown), streamed before/alongside `content`. @@ -623,6 +708,14 @@ export interface ChatMessage { * memory bubble (icon + tonal background) and records which store changed. */ memory?: { scope: MemoryScope }; + /** The {@link Run} that produced this Message, when one is attributable. */ + runId?: RunId; + /** Whether this Message is the last of its Run. */ + endsRun?: boolean; + /** Groups a request with its answers. Semantics arrive with the Reactor. */ + correlationId?: string; + /** The Message this one answers, when it answers one. */ + inReplyTo?: string; /** ISO-8601 timestamp. */ createdAt: string; } @@ -706,10 +799,13 @@ export interface ChatJoinPayload { */ export type MessageDelivery = "auto" | "steer" | "followUp"; -/** Payload sent by the client to post a new chat message. */ +/** + * Payload sent by the client to post a new chat message. Carries no author: the + * server resolves the sender from the socket's own identity, so a client cannot + * claim to be someone else. + */ export interface ChatMessagePayload { sessionId: string; - author: ChatAuthor; content: string; /** * Target agent's id (`"prime"` or a sub-agent id). Defaults to `"prime"` when @@ -730,9 +826,10 @@ export interface TerminalDataPayload { /** * Emitted when the Pi agent begins a reply. Carries an empty-content - * `ChatMessage` that the client appends and then fills in via deltas. `runId` - * sits beside the message rather than on it: a Message gains its own envelope - * fields in a later change, while the stream is a Run event. + * `ChatMessage` that the client appends and then fills in via deltas. Its `seq` + * is already reserved, so the placeholder and the finalized Message that + * replaces it share one ordinal. `runId` stays beside the message as well, + * because the delta and error payloads have no message to carry it. */ export interface AgentStartPayload { message: ChatMessage; From e5a3a9d657159f39f18b8d08ec7fb8d67c064812 Mon Sep 17 00:00:00 2001 From: Maksym Yezhov Date: Wed, 12 Aug 2026 11:46:29 -0700 Subject: [PATCH 06/18] - refactor: reaction predicates replace hardcoded routing --- .../src/connectors/connectorRegistry.test.ts | 10 +- .../src/connectors/connectorRegistry.ts | 13 +- .../src/connectors/externalConnector.ts | 9 +- apps/server/src/connectors/nullConnector.ts | 6 +- apps/server/src/connectors/piConnector.ts | 15 +- apps/server/src/connectors/refusal.ts | 16 +- .../src/connectors/remoteEnvConnector.ts | 24 +- apps/server/src/connectors/types.ts | 12 +- apps/server/src/conversation/causes.ts | 30 + .../src/conversation/conversationRouter.ts | 208 +++++ apps/server/src/conversation/fanOut.test.ts | 313 +++++++ apps/server/src/conversation/fanOut.ts | 274 ++++++ .../conversation/membershipRegistry.test.ts | 163 ++++ .../src/conversation/membershipRegistry.ts | 168 ++++ apps/server/src/conversation/reaction.test.ts | 107 +++ apps/server/src/conversation/reaction.ts | 74 ++ .../external/externalSubagentGateway.test.ts | 4 +- .../src/external/externalSubagentGateway.ts | 8 +- apps/server/src/index.ts | 92 +- apps/server/src/pi/agentConfig.ts | 8 +- apps/server/src/pi/piAgentManager.test.ts | 46 +- apps/server/src/pi/piAgentManager.ts | 174 +--- apps/server/src/pi/triggers/triggerEngine.ts | 76 +- apps/server/src/pi/types.ts | 67 +- .../remote/remoteEnvironmentGateway.test.ts | 21 +- .../src/remote/remoteEnvironmentGateway.ts | 141 ++- apps/server/src/routes/internalAgents.ts | 144 ++- apps/server/src/routes/internalMemory.ts | 2 +- apps/server/src/routes/internalSession.ts | 2 +- apps/server/src/sockets/agentEvents.ts | 326 +++++++ apps/server/src/sockets/chat.ts | 848 ++---------------- apps/server/src/sockets/chatArtifacts.ts | 78 ++ apps/server/src/sockets/chatMemory.ts | 109 +++ apps/server/src/sockets/rooms.ts | 11 + apps/server/src/sockets/sessionRoster.ts | 175 ++++ .../db/migrations/0009_exotic_micromax.sql | 27 + .../db/migrations/meta/0009_snapshot.json | 616 +++++++++++++ .../store/db/migrations/meta/_journal.json | 7 + apps/server/src/store/db/schema.ts | 43 + .../src/store/inMemoryMembershipStore.ts | 26 + apps/server/src/store/membershipStore.ts | 33 + .../src/store/sqliteMembershipStore.test.ts | 91 ++ .../server/src/store/sqliteMembershipStore.ts | 64 ++ packages/shared/src/contracts.ts | 29 + packages/shared/src/remoteSubagent.ts | 14 +- 45 files changed, 3504 insertions(+), 1220 deletions(-) create mode 100644 apps/server/src/conversation/causes.ts create mode 100644 apps/server/src/conversation/conversationRouter.ts create mode 100644 apps/server/src/conversation/fanOut.test.ts create mode 100644 apps/server/src/conversation/fanOut.ts create mode 100644 apps/server/src/conversation/membershipRegistry.test.ts create mode 100644 apps/server/src/conversation/membershipRegistry.ts create mode 100644 apps/server/src/conversation/reaction.test.ts create mode 100644 apps/server/src/conversation/reaction.ts create mode 100644 apps/server/src/sockets/agentEvents.ts create mode 100644 apps/server/src/sockets/chatArtifacts.ts create mode 100644 apps/server/src/sockets/chatMemory.ts create mode 100644 apps/server/src/sockets/rooms.ts create mode 100644 apps/server/src/sockets/sessionRoster.ts create mode 100644 apps/server/src/store/db/migrations/0009_exotic_micromax.sql create mode 100644 apps/server/src/store/db/migrations/meta/0009_snapshot.json create mode 100644 apps/server/src/store/inMemoryMembershipStore.ts create mode 100644 apps/server/src/store/membershipStore.ts create mode 100644 apps/server/src/store/sqliteMembershipStore.test.ts create mode 100644 apps/server/src/store/sqliteMembershipStore.ts diff --git a/apps/server/src/connectors/connectorRegistry.test.ts b/apps/server/src/connectors/connectorRegistry.test.ts index f85abb0..57a01e4 100644 --- a/apps/server/src/connectors/connectorRegistry.test.ts +++ b/apps/server/src/connectors/connectorRegistry.test.ts @@ -10,7 +10,7 @@ import { import { ExternalSubagentGateway } from "../external/externalSubagentGateway.ts"; import type { PiAgentManager } from "../pi/piAgentManager.ts"; -import type { PiAgentHandlers } from "../pi/types.ts"; +import type { ConversationEventSink } from "../pi/types.ts"; import type { RemoteEnvironmentGateway } from "../remote/remoteEnvironmentGateway.ts"; import { RunRegistry } from "../runs/runRegistry.ts"; import { InMemoryRunStore } from "../store/inMemoryRunStore.ts"; @@ -72,7 +72,7 @@ function fakePi() { const pi = { hasAgent: (_sessionId: string, agentId: string) => agentId === "local-1", listSubagents: () => [rosterEntry("local-1", "pi-stdio")], - sendToAgent: (sessionId: string, agentId: string, text: string) => + sendToAgent: ({ sessionId, agentId, text }: Delivery) => deliveries.push({ sessionId, agentId, text }), killAgent: (_sessionId: string, agentId: string) => kills.push(agentId), // Mirrors the real manager: only a busy agent has anything to cancel. @@ -93,7 +93,7 @@ function fakeRemote() { const gateway = { hasAgent: (_sessionId: string, agentId: string) => agentId === "remote-1", listSubagents: () => [rosterEntry("remote-1", "remote-env")], - sendToAgent: (sessionId: string, agentId: string, text: string) => { + sendToAgent: ({ sessionId, agentId, text }: Delivery) => { deliveries.push({ sessionId, agentId, text }); return true; }, @@ -110,10 +110,10 @@ function fakeRemote() { */ function makeHarness() { const surfaced: Surfaced[] = []; - const handlers: PiAgentHandlers = { + const handlers: ConversationEventSink = { onAgentEvent: () => {}, onSubagentUpdate: () => {}, - onAgentMessage: (_sessionId, conversationId, author, content) => + onAgentMessage: ({ conversationId, author, content }) => surfaced.push({ conversationId, author: author.name, content }), onSessionStatus: () => {}, }; diff --git a/apps/server/src/connectors/connectorRegistry.ts b/apps/server/src/connectors/connectorRegistry.ts index d712390..7440d17 100644 --- a/apps/server/src/connectors/connectorRegistry.ts +++ b/apps/server/src/connectors/connectorRegistry.ts @@ -7,7 +7,7 @@ import { import type { ExternalSubagentGateway } from "../external/externalSubagentGateway.ts"; import type { PiAgentManager } from "../pi/piAgentManager.ts"; -import type { PiAgentHandlers } from "../pi/types.ts"; +import type { ConversationEventSink } from "../pi/types.ts"; import type { RemoteEnvironmentGateway } from "../remote/remoteEnvironmentGateway.ts"; import type { SessionAgent } from "../store/sessionStore.ts"; import { ExternalConnector } from "./externalConnector.ts"; @@ -76,6 +76,15 @@ export class ConnectorRegistry { ); } + /** + * Whether the connector for `kind` can carry a message at all. Read when + * deriving a Membership: a transport nothing can be delivered to declares a + * `never` reaction instead of silently swallowing wakes. + */ + acceptsDelivery(kind: ConnectorKind): boolean { + return this.forKind(kind)?.acceptsDelivery ?? false; + } + /** The connector that spawns `kind` on the server's behalf, if any may. */ spawner(kind: ConnectorKind): SpawningConnector | undefined { const connector = this.forKind(kind); @@ -114,7 +123,7 @@ export function createConnectorRegistry( pi: PiAgentManager, remoteGateway: RemoteEnvironmentGateway, externalGateway: ExternalSubagentGateway, - handlers: PiAgentHandlers, + handlers: ConversationEventSink, ): ConnectorRegistry { return new ConnectorRegistry( [ diff --git a/apps/server/src/connectors/externalConnector.ts b/apps/server/src/connectors/externalConnector.ts index 4ee5878..edc8527 100644 --- a/apps/server/src/connectors/externalConnector.ts +++ b/apps/server/src/connectors/externalConnector.ts @@ -1,7 +1,7 @@ import { connectorFor } from "@tangent/shared/contracts.ts"; import type { ExternalSubagentGateway } from "../external/externalSubagentGateway.ts"; -import type { PiAgentHandlers } from "../pi/types.ts"; +import type { ConversationEventSink } from "../pi/types.ts"; import type { SessionAgent } from "../store/sessionStore.ts"; import { refuseDelivery } from "./refusal.ts"; import type { @@ -34,9 +34,12 @@ export class ExternalConnector implements Connector { readonly acceptsDelivery = false; private readonly gateway: ExternalSubagentGateway; - private readonly handlers: PiAgentHandlers; + private readonly handlers: ConversationEventSink; - constructor(gateway: ExternalSubagentGateway, handlers: PiAgentHandlers) { + constructor( + gateway: ExternalSubagentGateway, + handlers: ConversationEventSink, + ) { this.gateway = gateway; this.handlers = handlers; } diff --git a/apps/server/src/connectors/nullConnector.ts b/apps/server/src/connectors/nullConnector.ts index cc0cd7d..c5fd8c7 100644 --- a/apps/server/src/connectors/nullConnector.ts +++ b/apps/server/src/connectors/nullConnector.ts @@ -1,6 +1,6 @@ import { connectorFor, type SubagentInfo } from "@tangent/shared/contracts.ts"; -import type { PiAgentHandlers } from "../pi/types.ts"; +import type { ConversationEventSink } from "../pi/types.ts"; import { refuseDelivery } from "./refusal.ts"; import type { CancelResult, @@ -26,9 +26,9 @@ export class NullConnector implements Connector { readonly descriptor = connectorFor("unresolved"); readonly acceptsDelivery = false; - private readonly handlers: PiAgentHandlers; + private readonly handlers: ConversationEventSink; - constructor(handlers: PiAgentHandlers) { + constructor(handlers: ConversationEventSink) { this.handlers = handlers; } diff --git a/apps/server/src/connectors/piConnector.ts b/apps/server/src/connectors/piConnector.ts index c46a663..0de0cb5 100644 --- a/apps/server/src/connectors/piConnector.ts +++ b/apps/server/src/connectors/piConnector.ts @@ -38,14 +38,13 @@ export class PiConnector implements Connector { } deliver(request: DeliveryRequest): DeliveryResult { - this.pi.sendToAgent( - request.sessionId, - request.participantId, - request.text, - request.surfaceAuthor, - request.delivery, - request.ingress, - ); + this.pi.sendToAgent({ + sessionId: request.sessionId, + agentId: request.participantId, + text: request.text, + delivery: request.delivery, + ingress: request.ingress, + }); return { delivered: true }; } diff --git a/apps/server/src/connectors/refusal.ts b/apps/server/src/connectors/refusal.ts index 5219c13..df796a0 100644 --- a/apps/server/src/connectors/refusal.ts +++ b/apps/server/src/connectors/refusal.ts @@ -1,6 +1,6 @@ import { SYSTEM_AUTHOR } from "@tangent/shared/contracts.ts"; -import type { PiAgentHandlers } from "../pi/types.ts"; +import type { ConversationEventSink } from "../pi/types.ts"; import type { DeliveryRequest, DeliveryResult } from "./types.ts"; /** @@ -9,15 +9,15 @@ import type { DeliveryRequest, DeliveryResult } from "./types.ts"; * in the thread where the message was meant to land. */ export function refuseDelivery( - handlers: PiAgentHandlers, + handlers: ConversationEventSink, request: DeliveryRequest, reason: string, ): DeliveryResult { - handlers.onAgentMessage( - request.sessionId, - request.participantId, - SYSTEM_AUTHOR, - reason, - ); + handlers.onAgentMessage({ + sessionId: request.sessionId, + conversationId: request.participantId, + author: SYSTEM_AUTHOR, + content: reason, + }); return { delivered: false, reason }; } diff --git a/apps/server/src/connectors/remoteEnvConnector.ts b/apps/server/src/connectors/remoteEnvConnector.ts index 26fac4a..52ae113 100644 --- a/apps/server/src/connectors/remoteEnvConnector.ts +++ b/apps/server/src/connectors/remoteEnvConnector.ts @@ -2,7 +2,7 @@ import { connectorFor } from "@tangent/shared/contracts.ts"; import type { SubagentSpawnRequest } from "../pi/agentConfig.ts"; import type { SpawnedSubagent } from "../pi/piAgentManager.ts"; -import type { PiAgentHandlers } from "../pi/types.ts"; +import type { ConversationEventSink } from "../pi/types.ts"; import type { RemoteEnvironmentGateway } from "../remote/remoteEnvironmentGateway.ts"; import type { SessionAgent } from "../store/sessionStore.ts"; import { refuseDelivery } from "./refusal.ts"; @@ -35,9 +35,12 @@ export class RemoteEnvConnector implements Connector { readonly acceptsDelivery = true; private readonly gateway: RemoteEnvironmentGateway; - private readonly handlers: PiAgentHandlers; + private readonly handlers: ConversationEventSink; - constructor(gateway: RemoteEnvironmentGateway, handlers: PiAgentHandlers) { + constructor( + gateway: RemoteEnvironmentGateway, + handlers: ConversationEventSink, + ) { this.gateway = gateway; this.handlers = handlers; } @@ -51,14 +54,13 @@ export class RemoteEnvConnector implements Connector { } deliver(request: DeliveryRequest): DeliveryResult { - const delivered = this.gateway.sendToAgent( - request.sessionId, - request.participantId, - request.text, - request.surfaceAuthor, - request.delivery, - request.ingress, - ); + const delivered = this.gateway.sendToAgent({ + sessionId: request.sessionId, + agentId: request.participantId, + text: request.text, + delivery: request.delivery, + ingress: request.ingress, + }); if (delivered) return { delivered: true }; // A detached participant stays in the roster, so this connector still holds // it and has to say why the message went nowhere. diff --git a/apps/server/src/connectors/types.ts b/apps/server/src/connectors/types.ts index 5a1c8dc..209770a 100644 --- a/apps/server/src/connectors/types.ts +++ b/apps/server/src/connectors/types.ts @@ -1,5 +1,4 @@ import type { - ChatAuthor, ConnectorDescriptor, MessageDelivery, RunId, @@ -11,16 +10,15 @@ import type { SubagentSpawnRequest } from "../pi/agentConfig.ts"; import type { SpawnedSubagent } from "../pi/piAgentManager.ts"; import type { SessionAgent } from "../store/sessionStore.ts"; -/** A message addressed to one participant, as a connector receives it. */ +/** + * A message addressed to one participant, as a connector receives it. It carries + * no author: the Message it came from is already persisted and broadcast, so a + * connector's job is to deliver text, not to decide what appears in a transcript. + */ export interface DeliveryRequest { sessionId: string; participantId: string; text: string; - /** - * Surfaces the message in the participant's own transcript attributed to this - * author. Omitted by internal relays, which are already surfaced elsewhere. - */ - surfaceAuthor?: ChatAuthor; delivery?: MessageDelivery; /** * What this delivery counts as when it starts a Run. Defaults to `reaction`, diff --git a/apps/server/src/conversation/causes.ts b/apps/server/src/conversation/causes.ts new file mode 100644 index 0000000..83ae0ae --- /dev/null +++ b/apps/server/src/conversation/causes.ts @@ -0,0 +1,30 @@ +/** + * Why the fan-out engine stopped instead of waking someone. A bound that cuts a + * cascade without saying so converts a runaway loop into a stall, which is + * harder to diagnose and no less broken — so every cause is surfaced as a + * system Message in the Conversation it happened in. + * + * A delivery a connector refuses is not here: the connector already says so in + * the addressed conversation through `refuseDelivery`. + */ +export type TerminationCause = + | { kind: "wave-depth-exhausted"; participantId: string; limit: number } + | { kind: "reaction-budget-exhausted"; limit: number } + | { kind: "wake-refused"; participantId: string }; + +/** The text a cause is surfaced as. */ +export function describeCause(cause: TerminationCause): string { + if (cause.kind === "wake-refused") { + return "This agent doesn't react to messages here, so nothing was delivered."; + } + if (cause.kind === "wave-depth-exhausted") { + return ( + `Stopped here: this chain of reactions reached its limit of ${cause.limit} ` + + `hops, so nothing further was woken.` + ); + } + return ( + `Stopped here: this conversation reached its limit of ${cause.limit} ` + + `reactions for one chain, so nothing further was woken.` + ); +} diff --git a/apps/server/src/conversation/conversationRouter.ts b/apps/server/src/conversation/conversationRouter.ts new file mode 100644 index 0000000..9b0ae77 --- /dev/null +++ b/apps/server/src/conversation/conversationRouter.ts @@ -0,0 +1,208 @@ +import { randomUUID } from "node:crypto"; + +import { + type Attachment, + type ChatAuthor, + type ChatMessage, + type MemoryScope, + type MessageDelivery, + type RunId, + type RunIngress, + SocketEvents, + sourceFromAuthor, + SYSTEM_AUTHOR, +} from "@tangent/shared/contracts.ts"; +import type { Server } from "socket.io"; + +import type { ConnectorRegistry } from "../connectors/connectorRegistry.ts"; +import { roomFor } from "../sockets/rooms.ts"; +import type { Membership } from "../store/membershipStore.ts"; +import type { SessionStore } from "../store/sessionStore.ts"; +import { FanOutEngine, type FanOutResult } from "./fanOut.ts"; +import type { MembershipRegistry } from "./membershipRegistry.ts"; + +/** + * Everything a Message needs beyond its envelope defaults. `seq` comes from the + * store's allocator unless a streaming turn already reserved one, which is what + * stops a writer inventing an ordinal. + */ +export interface PostInput { + sessionId: string; + conversationId: string; + author: ChatAuthor; + content: string; + id?: string; + seq?: number; + mentions?: string[]; + thinking?: string; + attachments?: Attachment[]; + runId?: RunId; + endsRun?: boolean; + memory?: { scope: MemoryScope }; + /** What created this Message, when a reaction did not. */ + ingress?: RunIngress; + /** Whether a mid-run delivery steers or queues behind the current turn. */ + delivery?: MessageDelivery; + /** + * Set false for output that must not wake anyone — a cancelled turn, whose + * content is history rather than a request. + */ + provokes?: boolean; + /** + * Broadcasts the Message. Defaults to `chat:message`; a streamed turn passes + * its own `agent:end` payload so the client keeps replacing its placeholder. + */ + broadcast?: (message: ChatMessage) => void; +} + +/** The posted Message and what fanning it out did. */ +export interface PostResult extends FanOutResult { + message: ChatMessage; +} + +/** Fields an empty value must omit rather than persist as empty. */ +function whatIsThere(input: PostInput): Partial { + const fields: Partial = {}; + if (input.thinking) fields.thinking = input.thinking; + if (input.attachments?.length) fields.attachments = input.attachments; + if (input.memory) fields.memory = input.memory; + return fields; +} + +function buildMessage(input: PostInput & { seq: number }): ChatMessage { + // `runId` and `endsRun` are written as given: an absent one is `undefined`, + // which JSON drops on both the wire and the way to the log. + return { + id: input.id ?? randomUUID(), + sessionId: input.sessionId, + conversationId: input.conversationId, + seq: input.seq, + author: input.author, + mentions: input.mentions ?? [], + source: sourceFromAuthor(input.author), + content: input.content, + runId: input.runId, + endsRun: input.endsRun, + ...whatIsThere(input), + createdAt: new Date().toISOString(), + }; +} + +/** + * Appends the attached files by their workspace-relative path, so a recipient + * knows they exist and can read them with its own file tools. + */ +function withAttachments(content: string, attachments?: Attachment[]): string { + if (!attachments || attachments.length === 0) return content; + const list = attachments.map((file) => `- ${file.path}`).join("\n"); + const intro = + "The user attached the following files (paths are relative to your workspace):"; + return content ? `${content}\n\n${intro}\n${list}` : `${intro}\n${list}`; +} + +/** How a recipient woken from someone else's Conversation is told whose it was. */ +function frameFor(message: ChatMessage, recipient: Membership): string { + const directed = message.mentions.includes(recipient.participantId); + if (message.author.agentRole === "subagent") { + const verb = directed ? "reported" : "replied"; + return `Sub-agent "${message.author.name}" ${verb}:`; + } + return `${message.author.name} posted in another conversation:`; +} + +/** + * The text one recipient's transport receives for a Message. A participant + * reading its own Conversation gets the content as written; one woken from + * another Conversation gets the provenance framing that used to be baked into a + * wrapped relay string. Framing is a projection, so what is persisted stays the + * author's own words. + */ +export function deliveryText( + message: ChatMessage, + recipient: Membership, +): string { + const body = withAttachments(message.content, message.attachments); + if (message.conversationId === recipient.participantId) return body; + return `${frameFor(message, recipient)}\n\n${body}`; +} + +/** + * The one way a Message enters a Conversation: allocate its ordinal, persist it, + * broadcast it, then let the fan-out engine decide who reacts. Nothing else + * chooses a recipient — a caller says what happened and where, never who should + * run because of it. + */ +export class ConversationRouter { + private readonly io: Server; + private readonly store: SessionStore; + private readonly engine: FanOutEngine; + private connectors?: ConnectorRegistry; + + constructor( + io: Server, + store: SessionStore, + memberships: MembershipRegistry, + ) { + this.io = io; + this.store = store; + this.engine = new FanOutEngine( + memberships, + () => this.requireConnectors(), + (sessionId, conversationId, text) => { + void this.post({ + sessionId, + conversationId, + author: SYSTEM_AUTHOR, + content: text, + }); + }, + ); + } + + /** + * Hands the router the connector registry. Separate from the constructor + * because the registry needs the event sink that needs the router: the cycle + * is in the wiring, not in the dependency. + */ + useConnectors(connectors: ConnectorRegistry): void { + this.connectors = connectors; + } + + async post(input: PostInput): Promise { + const seq = + input.seq ?? + (await this.store.nextSeq(input.sessionId, input.conversationId)); + const message = buildMessage({ ...input, seq }); + // Persist before broadcasting so a reconnecting client sees it in history. + await this.store.appendMessage(message); + this.broadcast(message, input.broadcast); + if (input.provokes === false) { + return { message, woke: [], refused: [] }; + } + + const outcome = await this.engine.fanOut({ + message, + ingress: input.ingress, + delivery: input.delivery, + project: deliveryText, + }); + return { message, ...outcome }; + } + + private broadcast( + message: ChatMessage, + override?: (message: ChatMessage) => void, + ): void { + if (override) return override(message); + this.io + .to(roomFor(message.sessionId)) + .emit(SocketEvents.ChatMessage, message); + } + + private requireConnectors(): ConnectorRegistry { + if (!this.connectors) { + throw new Error("ConversationRouter used before useConnectors()"); + } + return this.connectors; + } +} diff --git a/apps/server/src/conversation/fanOut.test.ts b/apps/server/src/conversation/fanOut.test.ts new file mode 100644 index 0000000..dda1d92 --- /dev/null +++ b/apps/server/src/conversation/fanOut.test.ts @@ -0,0 +1,313 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import type { + ChatAuthor, + ChatMessage, + MessageSourceKind, +} from "@tangent/shared/contracts.ts"; + +import type { ConnectorRegistry } from "../connectors/connectorRegistry.ts"; +import type { DeliveryRequest, DeliveryResult } from "../connectors/types.ts"; +import type { Membership } from "../store/membershipStore.ts"; +import { FanOutEngine } from "./fanOut.ts"; +import type { MembershipRegistry } from "./membershipRegistry.ts"; + +const HUMAN: ChatAuthor = { id: "ada@x", kind: "human", name: "Ada" }; +const PRIME: ChatAuthor = { + id: "prime", + kind: "agent", + name: "Prime", + agentRole: "prime", +}; +const WORKER: ChatAuthor = { + id: "sub-1", + kind: "agent", + name: "Worker", + agentRole: "subagent", +}; + +let counter = 0; + +/** A persisted Message, with the envelope fields the engine reads. */ +function message(overrides: Partial = {}): ChatMessage { + counter += 1; + const author = overrides.author ?? HUMAN; + const kind: MessageSourceKind = + author.id === "system" ? "system" : author.kind; + return { + id: `m${counter}`, + sessionId: "s1", + conversationId: "prime", + seq: counter, + author, + source: { kind }, + mentions: [], + content: "hello", + createdAt: new Date().toISOString(), + ...overrides, + }; +} + +function membership( + participantId: string, + conversationId: string, + reaction: string, +): Membership { + return { + sessionId: "s1", + participantId, + conversationId, + reaction, + ingress: "reaction", + transcriptVisibility: "shared", + }; +} + +/** A refused delivery, phrased the way a connector phrases one. */ +const REFUSED: DeliveryResult = { + delivered: false, + reason: "That agent isn't reachable.", +}; + +/** + * An engine over a fixed membership table and a recording connector registry. + * `deliver` is what "who was woken" is measured by, and `notices` is what the + * conversation was told about anything the engine stopped. + */ +function makeEngine( + rows: Membership[], + refuse: (participantId: string) => boolean = () => false, +) { + const delivered: DeliveryRequest[] = []; + const notices: { conversationId: string; text: string }[] = []; + + const memberships = { + membersOf: async (_sessionId: string, conversationId: string) => + rows.filter((row) => row.conversationId === conversationId), + } as unknown as MembershipRegistry; + + const connectors = { + resolve: (_sessionId: string, participantId: string) => ({ + deliver: (request: DeliveryRequest): DeliveryResult => { + if (refuse(participantId)) return REFUSED; + delivered.push(request); + return { delivered: true }; + }, + }), + } as unknown as ConnectorRegistry; + + const engine = new FanOutEngine( + memberships, + () => connectors, + (_sessionId, conversationId, text) => + notices.push({ conversationId, text }), + ); + + return { engine, delivered, notices }; +} + +/** The plain projection: what a recipient reads is not what this test is about. */ +const project = (msg: ChatMessage) => msg.content; + +test("a human message wakes the participant whose conversation it is", async () => { + const h = makeEngine([membership("prime", "prime", "fromHumans+mentionsMe")]); + + const result = await h.engine.fanOut({ message: message(), project }); + + assert.deepEqual(result.woke, ["prime"]); + assert.deepEqual( + h.delivered.map((d) => d.participantId), + ["prime"], + ); +}); + +test("a participant never reacts to its own message", async () => { + const h = makeEngine([membership("prime", "prime", "always")]); + + const result = await h.engine.fanOut({ + message: message({ author: PRIME }), + project, + }); + + assert.deepEqual(result.woke, []); + assert.deepEqual(h.notices, []); +}); + +test("a finalized sub-agent turn wakes the orchestrator watching its conversation", async () => { + const h = makeEngine([ + membership("sub-1", "sub-1", "fromHumans+mentionsMe"), + membership("prime", "sub-1", "atRunEnd+mentionsMe"), + ]); + + const result = await h.engine.fanOut({ + message: message({ + conversationId: "sub-1", + author: WORKER, + endsRun: true, + source: { kind: "agent", from: "sub-1" }, + }), + project, + }); + + assert.deepEqual(result.woke, ["prime"]); +}); + +test("a system notice provokes nothing", async () => { + const h = makeEngine([membership("prime", "prime", "always")]); + + const result = await h.engine.fanOut({ + message: message({ + author: { id: "system", kind: "agent", name: "System" }, + source: { kind: "system" }, + }), + project, + }); + + assert.deepEqual(result.woke, []); + assert.deepEqual(h.notices, [], "a notice about a stop cannot cause one"); +}); + +test("a mentioned participant that does not react is said to have refused", async () => { + const h = makeEngine([ + membership("sub-1", "sub-1", "never"), + membership("prime", "sub-1", "mentionsMe"), + ]); + + const result = await h.engine.fanOut({ + message: message({ + conversationId: "sub-1", + author: PRIME, + mentions: ["sub-1"], + }), + project, + }); + + assert.deepEqual(result.woke, []); + assert.deepEqual(result.refused, [ + { + participantId: "sub-1", + reason: + "This agent doesn't react to messages here, so nothing was delivered.", + }, + ]); + assert.equal(h.notices.length, 1, "the refusal is visible in the thread"); + assert.equal(h.notices[0].conversationId, "sub-1"); +}); + +test("a participant that ignores a message it was not addressed by stays silent", async () => { + const h = makeEngine([ + membership("sub-1", "sub-1", "never"), + membership("prime", "sub-1", "mentionsMe"), + ]); + + const result = await h.engine.fanOut({ + message: message({ conversationId: "sub-1", author: PRIME }), + project, + }); + + assert.deepEqual(result.refused, []); + assert.deepEqual( + h.notices, + [], + "not reacting is only news if you were asked", + ); +}); + +test("a connector's refusal reaches the sender instead of reading as success", async () => { + const h = makeEngine( + [membership("prime", "prime", "always")], + (participantId) => participantId === "prime", + ); + + const result = await h.engine.fanOut({ message: message(), project }); + + assert.deepEqual(result.woke, []); + assert.deepEqual(result.refused, [ + { participantId: "prime", reason: REFUSED.reason }, + ]); +}); + +test("a cycle of reactions stops at the depth limit, and says why once", async () => { + // Two participants that each react to everything the other says: without the + // engine's wave budget this never terminates. + const h = makeEngine([ + membership("b", "a", "always"), + membership("a", "b", "always"), + ]); + + let author = { ...WORKER, id: "a", name: "A" }; + let conversationId = "a"; + for (let hop = 0; hop < 20; hop += 1) { + const result = await h.engine.fanOut({ + message: message({ conversationId, author, endsRun: true }), + project, + }); + if (result.woke.length === 0) break; + const next = result.woke[0]; + author = { ...WORKER, id: next, name: next }; + conversationId = next; + } + + assert.equal(h.delivered.length, 8, "the chain runs to the hop limit"); + assert.equal(h.notices.length, 1, "and announces the stop exactly once"); + assert.match(h.notices[0].text, /reached its limit of 8 hops/); +}); + +test("deliberate work starts a fresh chain instead of inheriting one", async () => { + const h = makeEngine([ + membership("b", "a", "always"), + membership("a", "b", "always"), + ]); + + // Run one chain to exhaustion, then post as a tool call: a long orchestration + // driven by explicit tool calls must not be cut short by an earlier cascade. + let author = { ...WORKER, id: "a", name: "A" }; + let conversationId = "a"; + for (let hop = 0; hop < 20; hop += 1) { + const result = await h.engine.fanOut({ + message: message({ conversationId, author, endsRun: true }), + project, + }); + if (result.woke.length === 0) break; + conversationId = result.woke[0]; + author = { ...WORKER, id: conversationId, name: conversationId }; + } + const spent = h.delivered.length; + + const result = await h.engine.fanOut({ + message: message({ conversationId: "a", author: PRIME }), + ingress: "tool", + project, + }); + + assert.deepEqual(result.woke, ["b"]); + assert.equal(h.delivered.length, spent + 1); +}); + +test("fan-out of one conversation is dispatched in the order posted", async () => { + const h = makeEngine([membership("prime", "prime", "always")]); + + await Promise.all([ + h.engine.fanOut({ message: message({ content: "first" }), project }), + h.engine.fanOut({ message: message({ content: "second" }), project }), + h.engine.fanOut({ message: message({ content: "third" }), project }), + ]); + + assert.deepEqual( + h.delivered.map((d) => d.text), + ["first", "second", "third"], + ); +}); + +test("a post carries its ingress to the run it opens, over the membership's", async () => { + const h = makeEngine([membership("prime", "prime", "always")]); + + await h.engine.fanOut({ message: message(), project }); + await h.engine.fanOut({ message: message(), ingress: "schedule", project }); + + assert.deepEqual( + h.delivered.map((d) => d.ingress), + ["reaction", "schedule"], + ); +}); diff --git a/apps/server/src/conversation/fanOut.ts b/apps/server/src/conversation/fanOut.ts new file mode 100644 index 0000000..f33328e --- /dev/null +++ b/apps/server/src/conversation/fanOut.ts @@ -0,0 +1,274 @@ +import { randomUUID } from "node:crypto"; + +import type { + ChatMessage, + MessageDelivery, + RunIngress, +} from "@tangent/shared/contracts.ts"; + +import type { ConnectorRegistry } from "../connectors/connectorRegistry.ts"; +import type { Membership } from "../store/membershipStore.ts"; +import { describeCause, type TerminationCause } from "./causes.ts"; +import type { MembershipRegistry } from "./membershipRegistry.ts"; +import { type MessageFacts, messageFacts, parseReaction } from "./reaction.ts"; + +/** + * How many automatic reaction hops one chain may take. Deliberate work — a tool + * call, a schedule, an inbound callback — starts a new chain, so this bounds + * cascades rather than the length of an orchestration. + */ +const MAX_WAVE_DEPTH = 8; + +/** How many reactions one chain may dispatch into a single Conversation. */ +const MAX_CONVERSATION_REACTIONS = 24; + +/** A chain of automatic reactions, and how far along it a Message sits. */ +interface Wave { + id: string; + depth: number; +} + +/** One Message to fan out, and how its recipients should read it. */ +export interface FanOutRequest { + message: ChatMessage; + /** Set when something other than a reaction created this Message. */ + ingress?: RunIngress; + /** Whether a mid-run delivery steers or queues behind the current turn. */ + delivery?: MessageDelivery; + /** The text one recipient receives, projected from the envelope. */ + project: (message: ChatMessage, recipient: Membership) => string; +} + +/** Posts a system notice into a Conversation. */ +export type Notify = ( + sessionId: string, + conversationId: string, + text: string, +) => void; + +/** + * What a fan-out did. `refused` is what a sender needs: a participant that was + * addressed and did not wake is the one outcome indistinguishable from success. + */ +export interface FanOutResult { + woke: string[]; + refused: { participantId: string; reason: string }[]; +} + +function keyFor(sessionId: string, id: string): string { + return `${sessionId}\u0000${id}`; +} + +/** Marks every member a bound stopped as refused, with the reason it stopped. */ +function refusedBy( + members: Membership[], + reason: string, +): FanOutResult["refused"] { + return members.map((member) => ({ + participantId: member.participantId, + reason, + })); +} + +/** + * Evaluates a Conversation's memberships against a posted Message and delivers + * to whoever reacts. This is the only thing that decides who runs: predicates + * are configuration, so no call site chooses a recipient by inspecting the + * transport or the sender's role. + * + * Because predicates are arbitrary code, termination cannot live in them. The + * engine guarantees it instead: a participant never reacts to its own Message, a + * system notice provokes nothing, and every automatic hop is counted against a + * depth and a per-Conversation budget. Exhausting either is announced in the + * Conversation — a bound that stops a cascade silently turns a runaway loop into + * a stall. + */ +export class FanOutEngine { + private readonly memberships: MembershipRegistry; + private readonly connectors: () => ConnectorRegistry; + private readonly notify: Notify; + /** The wave each participant was last woken in, by session. */ + private readonly waves = new Map(); + /** Reactions already dispatched into one Conversation within one wave. */ + private readonly spent = new Map(); + /** Per-Conversation serialization, so one fan-out finishes before the next. */ + private readonly queues = new Map>(); + + constructor( + memberships: MembershipRegistry, + connectors: () => ConnectorRegistry, + notify: Notify, + ) { + this.memberships = memberships; + this.connectors = connectors; + this.notify = notify; + } + + /** + * Queues a Message's fan-out behind whatever that Conversation is already + * fanning out, so reactions are dispatched in the order Messages were + * persisted. Deliberately arrival-ordered rather than strictly `seq`-ordered: + * `seq` is monotonic but not gap-free, so waiting for a number a failed stream + * spent would stall the Conversation forever. + */ + fanOut(request: FanOutRequest): Promise { + const { sessionId, conversationId } = request.message; + const key = keyFor(sessionId, conversationId); + const next = (this.queues.get(key) ?? Promise.resolve()) + .then(() => this.dispatch(request)) + .catch((err) => { + console.error( + `[conversation] fan-out failed in ${conversationId}:`, + err, + ); + return { woke: [], refused: [] }; + }); + this.queues.set( + key, + next.then(() => undefined), + ); + return next; + } + + private async dispatch(request: FanOutRequest): Promise { + const { message } = request; + const result: FanOutResult = { woke: [], refused: [] }; + // A notice about why something stopped is not itself a stimulus. + if (message.source.kind === "system") return result; + + const members = await this.memberships.membersOf( + message.sessionId, + message.conversationId, + ); + const facts = messageFacts(message); + const reacting = members.filter((member) => this.reacts(member, facts)); + this.announceRefusals(message, members, facts, reacting, result); + if (reacting.length === 0) return result; + + const wave = this.waveFor(message, request.ingress); + if (wave.depth + 1 > MAX_WAVE_DEPTH) { + const cause: TerminationCause = { + kind: "wave-depth-exhausted", + participantId: reacting[0].participantId, + limit: MAX_WAVE_DEPTH, + }; + this.announce(message, cause); + result.refused.push(...refusedBy(reacting, describeCause(cause))); + return result; + } + + for (const member of reacting) { + if (!this.spend(wave.id, message.conversationId)) { + const cause: TerminationCause = { + kind: "reaction-budget-exhausted", + limit: MAX_CONVERSATION_REACTIONS, + }; + this.announce(message, cause); + result.refused.push(...refusedBy(reacting, describeCause(cause))); + return result; + } + this.deliver(request, member, wave, result); + } + return result; + } + + /** Whether a member acts on this Message. A participant never reacts to itself. */ + private reacts(member: Membership, facts: MessageFacts): boolean { + if (member.participantId === facts.authorId) return false; + if (member.participantId === facts.sourceFrom) return false; + return parseReaction(member.reaction)(facts, member.participantId); + } + + /** Wakes one member, recording the hop so its own output stays in this wave. */ + private deliver( + request: FanOutRequest, + member: Membership, + wave: Wave, + result: FanOutResult, + ): void { + const { message } = request; + this.waves.set(keyFor(message.sessionId, member.participantId), { + id: wave.id, + depth: wave.depth + 1, + }); + + const { delivered, reason } = this.connectors() + .resolve(message.sessionId, member.participantId) + .deliver({ + sessionId: message.sessionId, + participantId: member.participantId, + text: request.project(message, member), + ingress: request.ingress ?? member.ingress, + delivery: request.delivery, + }); + if (delivered) { + result.woke.push(member.participantId); + return; + } + // The connector already said why in the addressed conversation; the sender + // hears it in the result. + result.refused.push({ + participantId: member.participantId, + reason: reason ?? "The message wasn't delivered.", + }); + } + + /** + * The wave a Message belongs to. Work a participant deliberately created, or + * an outside signal, starts a fresh one: only automatic reactions accumulate + * depth, so a long orchestration driven by tool calls is never cut short. + */ + private waveFor(message: ChatMessage, ingress?: RunIngress): Wave { + if (ingress && ingress !== "reaction") + return { id: randomUUID(), depth: 0 }; + const inherited = this.waves.get( + keyFor(message.sessionId, message.author.id), + ); + return inherited ?? { id: randomUUID(), depth: 0 }; + } + + /** Charges one reaction to a wave's budget in a Conversation. */ + private spend(waveId: string, conversationId: string): boolean { + const key = keyFor(waveId, conversationId); + const used = this.spent.get(key) ?? 0; + if (used >= MAX_CONVERSATION_REACTIONS) return false; + this.spent.set(key, used + 1); + return true; + } + + /** + * Says so when a Message addressed a member that declined to act. Being + * mentioned by name and silently ignored is the one refusal a sender cannot + * otherwise tell from success. + */ + private announceRefusals( + message: ChatMessage, + members: Membership[], + facts: MessageFacts, + reacting: Membership[], + result: FanOutResult, + ): void { + for (const member of members) { + if (reacting.includes(member)) continue; + if (member.participantId === facts.authorId) continue; + if (!facts.mentions.includes(member.participantId)) continue; + const cause: TerminationCause = { + kind: "wake-refused", + participantId: member.participantId, + }; + this.announce(message, cause); + result.refused.push({ + participantId: member.participantId, + reason: describeCause(cause), + }); + } + } + + private announce(message: ChatMessage, cause: TerminationCause): void { + this.notify( + message.sessionId, + message.conversationId, + describeCause(cause), + ); + } +} diff --git a/apps/server/src/conversation/membershipRegistry.test.ts b/apps/server/src/conversation/membershipRegistry.test.ts new file mode 100644 index 0000000..06c9a9c --- /dev/null +++ b/apps/server/src/conversation/membershipRegistry.test.ts @@ -0,0 +1,163 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { connectorFor } from "@tangent/shared/contracts.ts"; + +import { InMemoryMembershipStore } from "../store/inMemoryMembershipStore.ts"; +import { InMemorySessionStore } from "../store/inMemorySessionStore.ts"; +import type { Membership } from "../store/membershipStore.ts"; +import { MembershipRegistry } from "./membershipRegistry.ts"; + +/** A registry over in-memory stores; external tabs accept no delivery. */ +function makeRegistry() { + const sessions = new InMemorySessionStore(); + const store = new InMemoryMembershipStore(); + const registry = new MembershipRegistry( + sessions, + store, + (kind) => kind !== "external-inbound", + ); + return { sessions, store, registry }; +} + +/** `(participantId, reaction)` pairs, which is what a derivation is about. */ +function shape(members: Membership[]): [string, string][] { + return members.map((member) => [member.participantId, member.reaction]); +} + +test("Prime's own conversation holds Prime, reacting to people and mentions", async () => { + const h = makeRegistry(); + + const members = await h.registry.membersOf("s1", "prime"); + + assert.deepEqual(shape(members), [["prime", "fromHumans+mentionsMe"]]); +}); + +test("an auto-relaying sub-agent's conversation puts Prime on its run ends", async () => { + const h = makeRegistry(); + await h.sessions.recordAgent("s1", { + id: "sub-1", + role: "subagent", + name: "Worker", + status: "active", + autoRelayToPrime: true, + connector: connectorFor("pi-stdio"), + }); + + const members = await h.registry.membersOf("s1", "sub-1"); + + assert.deepEqual(shape(members), [ + ["sub-1", "fromHumans+mentionsMe"], + ["prime", "atRunEnd+mentionsMe"], + ]); +}); + +test("a sub-agent that does not auto-relay is reachable only by being addressed", async () => { + const h = makeRegistry(); + await h.sessions.recordAgent("s1", { + id: "sub-1", + role: "subagent", + name: "Worker", + status: "active", + autoRelayToPrime: false, + connector: connectorFor("pi-stdio"), + }); + + const members = await h.registry.membersOf("s1", "sub-1"); + + assert.deepEqual(shape(members), [ + ["sub-1", "fromHumans+mentionsMe"], + ["prime", "mentionsMe"], + ]); +}); + +test("a participant nothing can deliver to declares that it never reacts", async () => { + const h = makeRegistry(); + await h.sessions.recordAgent("s1", { + id: "tab-1", + role: "subagent", + name: "External", + status: "active", + connector: connectorFor("external-inbound"), + }); + + const members = await h.registry.membersOf("s1", "tab-1"); + + assert.deepEqual(shape(members), [ + ["tab-1", "never"], + ["prime", "atRunEnd+mentionsMe"], + ]); + assert.equal(members[0].transcriptVisibility, "opaque"); +}); + +test("a derived conversation is persisted, so it is derived once", async () => { + const h = makeRegistry(); + await h.sessions.recordAgent("s1", { + id: "sub-1", + role: "subagent", + name: "Worker", + status: "active", + autoRelayToPrime: false, + connector: connectorFor("pi-stdio"), + }); + + await h.registry.membersOf("s1", "sub-1"); + + // A second registry over the same store reads rows rather than re-deriving — + // which is what makes a later edit to a membership stick. + const reopened = new MembershipRegistry(h.sessions, h.store, () => true); + assert.deepEqual(shape(await reopened.membersOf("s1", "sub-1")), [ + ["sub-1", "fromHumans+mentionsMe"], + ["prime", "mentionsMe"], + ]); +}); + +test("a stored membership wins over what the roster would derive", async () => { + const h = makeRegistry(); + await h.sessions.recordAgent("s1", { + id: "sub-1", + role: "subagent", + name: "Worker", + status: "active", + autoRelayToPrime: true, + connector: connectorFor("pi-stdio"), + }); + await h.store.put({ + sessionId: "s1", + participantId: "prime", + conversationId: "sub-1", + reaction: "never", + ingress: "reaction", + transcriptVisibility: "shared", + }); + + assert.deepEqual(shape(await h.registry.membersOf("s1", "sub-1")), [ + ["prime", "never"], + ]); +}); + +test("a spawn whose row has not landed yet still resolves, without being kept", async () => { + const h = makeRegistry(); + + // The window between a spawn and its persisted roster row: the first task has + // to reach the sub-agent, but the row's own facts must win once it exists. + assert.deepEqual(shape(await h.registry.membersOf("s1", "sub-1")), [ + ["sub-1", "fromHumans+mentionsMe"], + ["prime", "atRunEnd+mentionsMe"], + ]); + assert.deepEqual(await h.store.listForSession("s1"), []); + + await h.sessions.recordAgent("s1", { + id: "sub-1", + role: "subagent", + name: "Worker", + status: "active", + autoRelayToPrime: false, + connector: connectorFor("pi-stdio"), + }); + + assert.deepEqual(shape(await h.registry.membersOf("s1", "sub-1")), [ + ["sub-1", "fromHumans+mentionsMe"], + ["prime", "mentionsMe"], + ]); +}); diff --git a/apps/server/src/conversation/membershipRegistry.ts b/apps/server/src/conversation/membershipRegistry.ts new file mode 100644 index 0000000..9e13143 --- /dev/null +++ b/apps/server/src/conversation/membershipRegistry.ts @@ -0,0 +1,168 @@ +import type { + ConnectorKind, + ReactionSpec, + TranscriptVisibility, +} from "@tangent/shared/contracts.ts"; + +import { PRIME_AGENT_ID } from "../pi/types.ts"; +import type { Membership, MembershipStore } from "../store/membershipStore.ts"; +import type { SessionAgent, SessionStore } from "../store/sessionStore.ts"; +import { reactionSpec } from "./reaction.ts"; + +/** Whether a participant's transport can be delivered to at all. */ +export type AcceptsDelivery = (kind: ConnectorKind) => boolean; + +/** + * What a participant reacts to in its own Conversation: a person talking to it, + * or another participant addressing it. + */ +const ADDRESSABLE = reactionSpec("fromHumans", "mentionsMe"); + +/** + * The orchestrator's reaction in a sub-agent's Conversation, and the successor + * to `autoRelayToPrime: true`: the sub-agent's finalized output plus anything + * that addresses Prime explicitly. Not `always` — a human message or a trigger + * prompt landing in that thread never woke Prime, and should not start doing so. + */ +const ORCHESTRATOR = reactionSpec("atRunEnd", "mentionsMe"); + +/** + * The successor to `autoRelayToPrime: false`: reachable only when the sub-agent + * addresses Prime itself, which is what `message_prime` now does. + */ +const ON_REQUEST = reactionSpec("mentionsMe"); + +/** A member that has declared it does not act — a display-only external tab. */ +const INERT = reactionSpec("never"); + +function membership( + sessionId: string, + participantId: string, + conversationId: string, + reaction: ReactionSpec, + transcriptVisibility: TranscriptVisibility = "shared", +): Membership { + return { + sessionId, + participantId, + conversationId, + reaction, + ingress: "reaction", + transcriptVisibility, + }; +} + +/** + * Who is in each Conversation and what each of them reacts to. Reads through to + * a {@link MembershipStore}, deriving rows from the agent roster for + * Conversations that have none — so a session whose migration backfill never ran + * still resolves correctly, the same read-time fallback the connector columns + * use. + */ +export class MembershipRegistry { + private readonly sessions: SessionStore; + private readonly store: MembershipStore; + private readonly acceptsDelivery: AcceptsDelivery; + /** sessionId -> conversationId -> memberships. */ + private readonly cache = new Map>(); + + constructor( + sessions: SessionStore, + store: MembershipStore, + acceptsDelivery: AcceptsDelivery, + ) { + this.sessions = sessions; + this.store = store; + this.acceptsDelivery = acceptsDelivery; + } + + /** + * The memberships of one Conversation. A sub-agent whose roster row has not + * landed yet — the window between a spawn and its persisted row — is derived + * provisionally: returned so its first task still reaches it, but neither + * cached nor persisted, so the row's own facts win as soon as it exists. + */ + async membersOf( + sessionId: string, + conversationId: string, + ): Promise { + const byConversation = await this.load(sessionId); + const known = byConversation.get(conversationId); + if (known) return known; + + const agents = await this.sessions.listAgents(sessionId); + const agent = agents.find((candidate) => candidate.id === conversationId); + const derived = this.derive(sessionId, conversationId, agent); + if (!agent && conversationId !== PRIME_AGENT_ID) return derived; + + byConversation.set(conversationId, derived); + for (const row of derived) await this.store.put(row); + return derived; + } + + /** The session's memberships, indexed by conversation on first use. */ + private async load(sessionId: string): Promise> { + const cached = this.cache.get(sessionId); + if (cached) return cached; + + const byConversation = new Map(); + for (const row of await this.store.listForSession(sessionId)) { + const existing = byConversation.get(row.conversationId); + if (existing) existing.push(row); + else byConversation.set(row.conversationId, [row]); + } + this.cache.set(sessionId, byConversation); + return byConversation; + } + + /** + * Builds a Conversation's memberships from the roster row and its connector. + * Prime's own Conversation holds only Prime; a sub-agent's holds the sub-agent + * and the orchestrator. + */ + private derive( + sessionId: string, + conversationId: string, + agent: SessionAgent | undefined, + ): Membership[] { + if (conversationId === PRIME_AGENT_ID) { + return [ + membership(sessionId, PRIME_AGENT_ID, PRIME_AGENT_ID, ADDRESSABLE), + ]; + } + + const relays = agent?.autoRelayToPrime ?? true; + return [ + this.subject(sessionId, conversationId, agent), + membership( + sessionId, + PRIME_AGENT_ID, + conversationId, + relays ? ORCHESTRATOR : ON_REQUEST, + ), + ]; + } + + /** + * The membership of the participant whose Conversation this is. One on a + * transport nothing can deliver to declares that it never acts, rather than + * accepting wakes that would be swallowed. + */ + private subject( + sessionId: string, + conversationId: string, + agent: SessionAgent | undefined, + ): Membership { + const reachable = !agent || this.acceptsDelivery(agent.connector.kind); + if (reachable) { + return membership(sessionId, conversationId, conversationId, ADDRESSABLE); + } + return membership( + sessionId, + conversationId, + conversationId, + INERT, + "opaque", + ); + } +} diff --git a/apps/server/src/conversation/reaction.test.ts b/apps/server/src/conversation/reaction.test.ts new file mode 100644 index 0000000..5b173d3 --- /dev/null +++ b/apps/server/src/conversation/reaction.test.ts @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import type { ChatMessage } from "@tangent/shared/contracts.ts"; + +import type { MessageFacts } from "./reaction.ts"; +import { messageFacts, parseReaction, reactionSpec } from "./reaction.ts"; + +/** Envelope facts with sane defaults, overridable per field. */ +function facts(overrides: Partial = {}): MessageFacts { + return { + conversationId: "prime", + seq: 1, + authorId: "user@example.com", + sourceKind: "human", + mentions: [], + endsRun: false, + ...overrides, + }; +} + +test("fromHumans reacts to a person and to nothing else", () => { + const reacts = parseReaction("fromHumans"); + + assert.equal(reacts(facts(), "prime"), true); + assert.equal(reacts(facts({ sourceKind: "agent" }), "prime"), false); + assert.equal(reacts(facts({ sourceKind: "system" }), "prime"), false); +}); + +test("mentionsMe is addressing, not name matching", () => { + const reacts = parseReaction("mentionsMe"); + + assert.equal(reacts(facts({ mentions: ["prime"] }), "prime"), true); + assert.equal(reacts(facts({ mentions: ["sub-1"] }), "prime"), false); + // The body is never consulted: only the envelope's mention list counts. + assert.equal(reacts(facts({ mentions: [] }), "prime"), false); +}); + +test("atRunEnd reacts to a finalized turn only", () => { + const reacts = parseReaction("atRunEnd"); + + assert.equal( + reacts(facts({ sourceKind: "agent", endsRun: true }), "p"), + true, + ); + assert.equal(reacts(facts({ sourceKind: "agent" }), "p"), false); +}); + +test("a spec is the disjunction of the presets it names", () => { + const reacts = parseReaction(reactionSpec("atRunEnd", "mentionsMe")); + + assert.equal( + reacts(facts({ sourceKind: "agent", endsRun: true }), "p"), + true, + ); + assert.equal(reacts(facts({ mentions: ["p"] }), "p"), true); + assert.equal(reacts(facts({ sourceKind: "agent" }), "p"), false); +}); + +test("an unreadable spec never reacts", () => { + // A spec written by a newer server, or corrupted: inventing a reaction for a + // value we cannot read is how an unintended cascade starts. + assert.equal(parseReaction("whenTheMoonIsFull")(facts(), "p"), false); + assert.equal(parseReaction("")(facts(), "p"), false); + // A recognizable token still counts alongside one we cannot read. + assert.equal(parseReaction("nonsense+fromHumans")(facts(), "p"), true); +}); + +test("never refuses what always accepts", () => { + assert.equal( + parseReaction("always")(facts({ sourceKind: "system" }), "p"), + true, + ); + assert.equal(parseReaction("never")(facts(), "p"), false); +}); + +test("messageFacts exposes the envelope and not the body", () => { + const message: ChatMessage = { + id: "m1", + sessionId: "s1", + conversationId: "sub-1", + seq: 7, + author: { + id: "sub-1", + kind: "agent", + name: "Worker", + agentRole: "subagent", + }, + source: { kind: "agent", from: "sub-1" }, + mentions: ["prime"], + content: "@prime please review", + endsRun: true, + runId: "run-1", + createdAt: "2026-01-01T00:00:00.000Z", + }; + + assert.deepEqual(messageFacts(message), { + conversationId: "sub-1", + seq: 7, + authorId: "sub-1", + sourceKind: "agent", + sourceFrom: "sub-1", + mentions: ["prime"], + endsRun: true, + runId: "run-1", + }); +}); diff --git a/apps/server/src/conversation/reaction.ts b/apps/server/src/conversation/reaction.ts new file mode 100644 index 0000000..6745b86 --- /dev/null +++ b/apps/server/src/conversation/reaction.ts @@ -0,0 +1,74 @@ +import type { + ChatMessage, + MessageSourceKind, + ReactionName, + ReactionSpec, + RunId, +} from "@tangent/shared/contracts.ts"; + +/** + * The envelope facts a {@link Reaction} may read. Predicates see structured + * addressing and provenance, never the body: free-text parsing is hostile to + * rename and spoofing, so nothing downstream regexes content to decide whether + * a participant runs. + */ +export interface MessageFacts { + conversationId: string; + seq: number; + authorId: string; + sourceKind: MessageSourceKind; + /** The participant it originated from, when distinct from the author. */ + sourceFrom?: string; + mentions: string[]; + endsRun: boolean; + runId?: RunId; +} + +/** Whether `self` should act on a Message posted to a Conversation it is in. */ +export type Reaction = (message: MessageFacts, self: string) => boolean; + +const PRESETS: Record = { + always: () => true, + fromHumans: (message) => message.sourceKind === "human", + mentionsMe: (message, self) => message.mentions.includes(self), + atRunEnd: (message) => message.endsRun, + never: () => false, +}; + +/** Whether a stored token names a preset this server knows. */ +export function isReactionName(value: string): value is ReactionName { + return Object.hasOwn(PRESETS, value); +} + +/** The stored form of a composed reaction. */ +export function reactionSpec(...names: ReactionName[]): ReactionSpec { + return names.join("+"); +} + +/** + * Reads a stored spec as the disjunction of the presets it names. Unreadable + * tokens are dropped, and a spec that names nothing recognizable never reacts: + * inventing a reaction for a value we cannot read is how a cascade starts. + */ +export function parseReaction(spec: ReactionSpec): Reaction { + const named = spec + .split("+") + .map((token) => token.trim()) + .filter(isReactionName); + if (named.length === 0) return PRESETS.never; + return (message, self) => named.some((name) => PRESETS[name](message, self)); +} + +/** Projects a persisted Message onto the facts a predicate is allowed to read. */ +export function messageFacts(message: ChatMessage): MessageFacts { + return { + conversationId: message.conversationId, + seq: message.seq, + authorId: message.author.id, + sourceKind: message.source.kind, + sourceFrom: message.source.from, + mentions: message.mentions, + endsRun: message.endsRun ?? false, + runId: message.runId, + }; +} diff --git a/apps/server/src/external/externalSubagentGateway.test.ts b/apps/server/src/external/externalSubagentGateway.test.ts index 3b3a29a..d8c8096 100644 --- a/apps/server/src/external/externalSubagentGateway.test.ts +++ b/apps/server/src/external/externalSubagentGateway.test.ts @@ -3,7 +3,7 @@ import { test } from "node:test"; import type { SubagentInfo } from "@tangent/shared/contracts.ts"; -import type { PiAgentHandlers } from "../pi/types.ts"; +import type { ConversationEventSink } from "../pi/types.ts"; import { RunRegistry } from "../runs/runRegistry.ts"; import { InMemoryRunStore } from "../store/inMemoryRunStore.ts"; import { InMemorySessionStore } from "../store/inMemorySessionStore.ts"; @@ -15,7 +15,7 @@ function makeHarness() { const rosterUpdates: SubagentInfo[] = []; const events: Array<{ agentId: string; type: string; runId?: string }> = []; - const handlers: PiAgentHandlers = { + const handlers: ConversationEventSink = { onAgentEvent: (_sessionId, agent, event) => events.push({ agentId: agent.agentId, diff --git a/apps/server/src/external/externalSubagentGateway.ts b/apps/server/src/external/externalSubagentGateway.ts index 22235a6..5789138 100644 --- a/apps/server/src/external/externalSubagentGateway.ts +++ b/apps/server/src/external/externalSubagentGateway.ts @@ -13,7 +13,7 @@ import { import type { RemoteAgentEvent } from "@tangent/shared/remoteSubagent.ts"; import { parseThinkingLevel } from "../pi/agentConfig.ts"; -import type { AgentDescriptor, PiAgentHandlers } from "../pi/types.ts"; +import type { AgentDescriptor, ConversationEventSink } from "../pi/types.ts"; import type { RunRegistry, SettledStatus } from "../runs/runRegistry.ts"; import type { SessionAgent, SessionStore } from "../store/sessionStore.ts"; @@ -73,7 +73,7 @@ function toInfo(subagent: ExternalSubagent): SubagentInfo { * roster rows so a restart has something to reattach to. 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 + * relays streamed events into it via the shared {@link ConversationEventSink}, so an * external sub-agent renders and persists like a local one. * * The gateway is transport-agnostic and carries no knowledge of what runtime @@ -85,7 +85,7 @@ function toInfo(subagent: ExternalSubagent): SubagentInfo { * {@link import("../pi/piAgentManager.ts").PiAgentManager}. */ export class ExternalSubagentGateway { - private readonly handlers: PiAgentHandlers; + private readonly handlers: ConversationEventSink; private readonly runs: RunRegistry; private readonly store: SessionStore; @@ -93,7 +93,7 @@ export class ExternalSubagentGateway { private readonly sessions = new Map>(); constructor( - handlers: PiAgentHandlers, + handlers: ConversationEventSink, runs: RunRegistry, store: SessionStore, ) { diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index c16666c..fe842df 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -7,12 +7,14 @@ import { Server as SocketIOServer } from "socket.io"; import { PORT } from "./config.ts"; import { createConnectorRegistry } from "./connectors/connectorRegistry.ts"; +import { ConversationRouter } from "./conversation/conversationRouter.ts"; +import { MembershipRegistry } from "./conversation/membershipRegistry.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 { - type PiAgentHandlers, + type ConversationEventSink, PiAgentManager, PRIME_AGENT_ID, } from "./pi/piAgentManager.ts"; @@ -35,15 +37,18 @@ import { RunRegistry } from "./runs/runRegistry.ts"; import { createAgentEventHandler, createAgentMessageHandler, - createMemoryRememberedHandler, - createMemorySuggestionHandler, createSessionStatusHandler, createSubagentUpdateHandler, - createUiCommandEmitter, - registerChatHandlers, -} from "./sockets/chat.ts"; +} from "./sockets/agentEvents.ts"; +import { registerChatHandlers } from "./sockets/chat.ts"; +import { + createMemoryRememberedHandler, + createMemorySuggestionHandler, +} from "./sockets/chatMemory.ts"; +import { createUiCommandEmitter } from "./sockets/sessionRoster.ts"; import { openDb } from "./store/db/client.ts"; import { FileAgentBundleStore } from "./store/fileAgentBundleStore.ts"; +import { SqliteMembershipStore } from "./store/sqliteMembershipStore.ts"; import { SqliteRunStore } from "./store/sqliteRunStore.ts"; import { SqliteSessionStore } from "./store/sqliteSessionStore.ts"; @@ -71,20 +76,37 @@ const memory = new MemoryManager(); // Owns each session's triggers (schedule + callback) and their persistence. const triggers = new TriggerManager(); -// Surfaces applied memory writes / pending suggestions to the session room. -const onMemoryRemembered = createMemoryRememberedHandler(io, store); +// Surfaces pending memory suggestions to the session room. 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), +// Who is in each Conversation and what each of them reacts to. Rows are derived +// from the agent roster on a cache miss, so a session the backfill never touched +// still resolves; `acceptsDelivery` is read lazily because the registry it comes +// from is built below. +const memberships = new MembershipRegistry( + store, + new SqliteMembershipStore(db), + (kind) => connectors.acceptsDelivery(kind), +); + +// The one way a Message enters a Conversation: persist, broadcast, then deliver +// to whoever reacts. Every entry point — a human turn, a trigger firing, a tool +// call, a finalized agent turn — goes through it. +const conversations = new ConversationRouter(io, store, memberships); + +// Surfaces applied memory writes as a highlighted message in Prime's thread. +const onMemoryRemembered = createMemoryRememberedHandler(conversations); + +// Shared event sink: a participant's streaming events, roster changes and posted +// messages land the same way whether it runs locally (PiAgentManager), in a +// remote environment, or entirely outside Tangent. +const agentHandlers: ConversationEventSink = { + onAgentEvent: createAgentEventHandler(io, store, conversations), onSubagentUpdate: createSubagentUpdateHandler(io, store), - onAgentMessage: createAgentMessageHandler(io, store), + onAgentMessage: createAgentMessageHandler(conversations), onSessionStatus: createSessionStatusHandler(io), }; @@ -108,19 +130,13 @@ void store.detachActiveSubagents().then((detached) => { // Socket.IO room by the chat handlers. const pi = new PiAgentManager(agentHandlers, memory, runs); -// 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. +// namespace. Remote sub-agents share the same event sink as local ones, so what +// they produce is persisted in their own Conversation and fanned out from there. const remoteGateway = new RemoteEnvironmentGateway( io, agentHandlers, store, - deliverToPrime, runs, ); @@ -139,13 +155,29 @@ const connectors = createConnectorRegistry( agentHandlers, ); +// Closes the loop: the router needs connectors to deliver a reaction, and the +// connectors needed the sink that needs the router. The cycle is in the wiring, +// not in the dependency, so it is broken here rather than by an indirection. +conversations.useConnectors(connectors); + +// Relays a message into a session's Prime. The generic MCP relay's peer is not a +// participant in any Conversation, so its text is delivered rather than posted. +const deliverToPrime = (sessionId: string, text: string): void => { + connectors.resolve(sessionId, PRIME_AGENT_ID).deliver({ + sessionId, + participantId: PRIME_AGENT_ID, + text, + }); +}; + // 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); +// Drives schedule timers and callback firings, posting prompts into the target's +// Conversation. +const triggerEngine = new TriggerEngine(io, store, pi, triggers, conversations); app.get("/api/health", (req, res) => { const cookies = Object.fromEntries( @@ -174,7 +206,10 @@ 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, connectors)); +app.use( + "/internal/agents", + createInternalAgentsRouter(store, connectors, conversations), +); // 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( @@ -208,16 +243,17 @@ app.use("/internal/mcp-relay", createInternalMcpRelayRouter(mcpRelay, store)); // consistent `{ error }` shape (Express 5 forwards rejected promises to it). app.use(errorHandler); -registerChatHandlers( +registerChatHandlers({ io, store, pi, connectors, + conversations, memory, - onMemoryRemembered, + onRemembered: onMemoryRemembered, triggerEngine, emitUiCommand, -); +}); httpServer.listen(PORT, () => { console.log(`[server] listening on http://localhost:${PORT}`); diff --git a/apps/server/src/pi/agentConfig.ts b/apps/server/src/pi/agentConfig.ts index 4a94a9e..e4be5e9 100644 --- a/apps/server/src/pi/agentConfig.ts +++ b/apps/server/src/pi/agentConfig.ts @@ -154,11 +154,11 @@ export interface SubagentSpawnRequest { model?: string; /** Inline thinking depth; overrides the template/default thinking depth. */ thinkingDepth?: ThinkingLevel; - /** Optional initial task to deliver to the sub-agent right after spawn. */ - task?: string; /** - * Whether the sub-agent's finalized replies are auto-relayed back to Prime. - * Defaults to true; trigger-owned sub-agents pass false to react in isolation. + * Whether Prime reacts to the sub-agent's finalized replies. Defaults to true; + * trigger-owned sub-agents pass false to work in isolation, reaching Prime only + * by addressing it. Persisted, and the value the sub-agent's Memberships are + * derived from. */ autoRelayToPrime?: boolean; /** diff --git a/apps/server/src/pi/piAgentManager.test.ts b/apps/server/src/pi/piAgentManager.test.ts index 91d6724..2fc22b0 100644 --- a/apps/server/src/pi/piAgentManager.test.ts +++ b/apps/server/src/pi/piAgentManager.test.ts @@ -13,7 +13,7 @@ import { InMemoryRunStore } from "../store/inMemoryRunStore.ts"; import type { SessionAgent } from "../store/sessionStore.ts"; import type { MemoryManager } from "./memory.ts"; import { PiAgentManager, PRIME_AGENT_ID } from "./piAgentManager.ts"; -import type { PiAgentHandlers } from "./types.ts"; +import type { ConversationEventSink } from "./types.ts"; /** * Minimal stand-in for a Pi child process. It records stdin writes and lets a @@ -82,7 +82,7 @@ function makeManager(): { return child as unknown as ChildProcessWithoutNullStreams; }) as unknown as typeof spawn; - const handlers: PiAgentHandlers = { + const handlers: ConversationEventSink = { onAgentEvent: (_sessionId, agent, event) => events.push({ agentId: agent.agentId, @@ -302,7 +302,7 @@ test("a prompt opens one run and every event in the turn carries its id", async const h = makeManager(); h.pi.ensure("s1", "/tmp/s1"); - h.pi.prompt("s1", "/tmp/s1", "hello"); + h.pi.sendToAgent({ sessionId: "s1", agentId: PRIME_AGENT_ID, text: "hello" }); const runId = h.runs.current("s1", PRIME_AGENT_ID)?.id; assert.ok(runId, "prompting an idle agent opens a run"); @@ -325,9 +325,13 @@ test("a message delivered mid-run joins the run in flight", () => { const h = makeManager(); h.pi.ensure("s1", "/tmp/s1"); - h.pi.prompt("s1", "/tmp/s1", "first"); + h.pi.sendToAgent({ sessionId: "s1", agentId: PRIME_AGENT_ID, text: "first" }); const runId = h.runs.current("s1", PRIME_AGENT_ID)?.id; - h.pi.prompt("s1", "/tmp/s1", "and also this"); + h.pi.sendToAgent({ + sessionId: "s1", + agentId: PRIME_AGENT_ID, + text: "and also this", + }); assert.equal( h.runs.current("s1", PRIME_AGENT_ID)?.id, @@ -339,7 +343,11 @@ test("a message delivered mid-run joins the run in flight", () => { test("aborting a run settles it as cancelled, not completed", async () => { const h = makeManager(); h.pi.ensure("s1", "/tmp/s1"); - h.pi.prompt("s1", "/tmp/s1", "long job"); + h.pi.sendToAgent({ + sessionId: "s1", + agentId: PRIME_AGENT_ID, + text: "long job", + }); const runId = h.runs.current("s1", PRIME_AGENT_ID)?.id; assert.ok(runId); @@ -371,7 +379,7 @@ test("a turn Pi starts on its own still gets a run", () => { test("a crash fails the run that was in flight", async () => { const h = makeManager(); h.pi.ensure("s1", "/tmp/s1"); - h.pi.prompt("s1", "/tmp/s1", "work"); + h.pi.sendToAgent({ sessionId: "s1", agentId: PRIME_AGENT_ID, text: "work" }); const runId = h.runs.current("s1", PRIME_AGENT_ID)?.id; assert.ok(runId); @@ -381,11 +389,23 @@ test("a crash fails the run that was in flight", async () => { assert.equal((await h.runStore.getRun(runId))?.status, "failed"); }); -test("a sub-agent's initial task is a tool-driven run", () => { +test("a spawn alone opens no run; the task that follows does", () => { const h = makeManager(); h.pi.ensure("s1", "/tmp/s1"); - const { info } = h.pi.spawnSubagent("s1", { name: "Worker", task: "go" }); + const { info } = h.pi.spawnSubagent("s1", { name: "Worker" }); + assert.equal( + h.runs.current("s1", info.id), + undefined, + "a sub-agent with nothing to do yet is not working", + ); + + h.pi.sendToAgent({ + sessionId: "s1", + agentId: info.id, + text: "go", + ingress: "tool", + }); assert.equal(h.runs.current("s1", info.id)?.ingress, "tool"); }); @@ -393,7 +413,13 @@ test("a sub-agent's initial task is a tool-driven run", () => { test("killing a sub-agent mid-run cancels its run", async () => { const h = makeManager(); h.pi.ensure("s1", "/tmp/s1"); - const { info } = h.pi.spawnSubagent("s1", { name: "Worker", task: "go" }); + const { info } = h.pi.spawnSubagent("s1", { name: "Worker" }); + h.pi.sendToAgent({ + sessionId: "s1", + agentId: info.id, + text: "go", + ingress: "tool", + }); const runId = h.runs.current("s1", info.id)?.id; assert.ok(runId); diff --git a/apps/server/src/pi/piAgentManager.ts b/apps/server/src/pi/piAgentManager.ts index db98f75..a3a34f8 100644 --- a/apps/server/src/pi/piAgentManager.ts +++ b/apps/server/src/pi/piAgentManager.ts @@ -3,9 +3,7 @@ import { randomUUID } from "node:crypto"; import { type AgentActivity, - type ChatAuthor, type MessageDelivery, - PI_AGENT, RESTORABLE_STATUSES, type RunIngress, type SessionRunStatus, @@ -43,7 +41,7 @@ import { type AgentEvent, type AgentProcess, type AssistantDelta, - type PiAgentHandlers, + type ConversationEventSink, type PiStdoutEvent, PRIME_AGENT_ID, type SessionAgents, @@ -72,7 +70,7 @@ export type { AgentEvent, AgentEventHandler, AgentMessageHandler, - PiAgentHandlers, + ConversationEventSink, SubagentUpdateHandler, } from "./types.ts"; @@ -159,6 +157,17 @@ export interface SpawnedSubagent { autoRelayToPrime: boolean; } +/** One message to deliver to one agent's stdin. */ +export interface SendToAgentOptions { + sessionId: string; + agentId: string; + text: string; + /** Whether a mid-run message steers or queues. Defaults to `auto`. */ + delivery?: MessageDelivery; + /** What the message counts as if it opens a Run. Defaults to `reaction`. */ + ingress?: RunIngress; +} + /** A requested model/thinking change; either field may be omitted to keep it. */ export interface AgentModelSelection { model?: string; @@ -437,7 +446,7 @@ function busyStreamingBehavior( */ export class PiAgentManager { private readonly sessions = new Map(); - private readonly handlers: PiAgentHandlers; + private readonly handlers: ConversationEventSink; private readonly memory: MemoryManager; /** * The Runs this manager's agents work under. Pi is the authority on its own @@ -457,7 +466,7 @@ export class PiAgentManager { private readonly lastStatus = new Map(); constructor( - handlers: PiAgentHandlers, + handlers: ConversationEventSink, memory: MemoryManager, runs: RunRegistry, spawnProcess: typeof spawn = spawn, @@ -708,31 +717,10 @@ export class PiAgentManager { } /** - * Relays a human message to the session's Prime process, spawning it first if - * needed. Only Prime receives human input; sub-agents are directed by Prime. - * `delivery` controls how the message is queued when Prime is mid-run. - */ - prompt( - sessionId: string, - rootPath: string, - text: string, - delivery: MessageDelivery = "auto", - ingress: RunIngress = "reaction", - ): void { - this.ensure(sessionId, rootPath); - this.sendToAgent( - sessionId, - PRIME_AGENT_ID, - text, - undefined, - delivery, - ingress, - ); - } - - /** - * Spawns a sub-agent for the session and returns its roster entry. Optionally - * delivers an initial task. The session's Prime must already exist. + * Spawns a sub-agent for the session and returns its roster entry. The + * session's Prime must already exist. An initial task is not delivered here: + * it is a Message posted into the new sub-agent's Conversation, which is what + * both surfaces it and wakes the sub-agent. */ spawnSubagent( sessionId: string, @@ -766,8 +754,6 @@ export class PiAgentManager { const info = toSubagentInfo(agent); this.handlers.onSubagentUpdate(sessionId, info); - this.deliverInitialTask(sessionId, agentId, request.task); - return { info, tools: [...config.tools], @@ -776,44 +762,19 @@ export class PiAgentManager { }; } - /** - * Delivers an optional initial task to a freshly spawned sub-agent, skipping - * empty or whitespace-only tasks. - */ - private deliverInitialTask( - sessionId: string, - agentId: string, - task: string | undefined, - ): void { - if (task && task.trim()) { - this.sendToAgent(sessionId, agentId, task, PI_AGENT, "auto", "tool"); - } - } - /** * Delivers a message to a specific agent's stdin. If that agent is already * streaming, the message is queued: `delivery: "steer"` applies it after the * current tool call (before the next LLM call), while `"followUp"` (and the * `"auto"` default) waits until the run fully stops. The default preserves - * the original behavior so internal relays never drop a message. - * - * When `surfaceAuthor` is provided and the target is a sub-agent, the message - * is also surfaced into that sub-agent's transcript (attributed to - * `surfaceAuthor`), so directed tasks read as a real conversation. Internal - * relays (e.g. feeding a sub-agent's reply back to Prime) omit it. + * the original behavior so nothing is dropped. * * A message that finds the agent idle opens a Run with `ingress`; one that * finds it mid-run joins the Run in flight, because Pi folds a steer or * follow-up into the turn it is already taking. */ - sendToAgent( - sessionId: string, - agentId: string, - text: string, - surfaceAuthor?: ChatAuthor, - delivery: MessageDelivery = "auto", - ingress: RunIngress = "reaction", - ): void { + sendToAgent(options: SendToAgentOptions): void { + const { sessionId, agentId, text } = options; const agent = this.sessions.get(sessionId)?.agents.get(agentId); if (!agent) { // No participant, so no Run: this error belongs to no unit of work. @@ -826,11 +787,11 @@ export class PiAgentManager { } if (!agent.busy) { + const ingress = options.ingress ?? "reaction"; this.runs.open({ sessionId, participantId: agentId, ingress }); } - this.surfaceDirectedMessage(sessionId, agent, text, surfaceAuthor); - this.writePrompt(sessionId, agent, text, delivery); + this.writePrompt(sessionId, agent, text, options.delivery ?? "auto"); this.notifyStatus(sessionId); } @@ -869,49 +830,6 @@ export class PiAgentManager { agent.child.stdin.write(`${JSON.stringify(command)}\n`); } - /** - * Surfaces a directed message into a sub-agent's transcript (attributed to - * `surfaceAuthor`) so directed tasks and human nudges read as a real - * conversation. No-op for Prime or when no author is given (internal relays). - */ - private surfaceDirectedMessage( - sessionId: string, - agent: AgentProcess, - text: string, - surfaceAuthor?: ChatAuthor, - ): void { - if (!surfaceAuthor || agent.role !== "subagent") return; - this.handlers.onAgentMessage(sessionId, agent.agentId, surfaceAuthor, text); - } - - /** - * Delivers a sub-agent's directed update to Prime (the `message_prime` tool). - * The report is surfaced in the sub-agent's own transcript (attributed to the - * sub-agent) so the user sees it in that thread, and delivered to Prime's - * stdin so it can react immediately — Prime is event-driven and otherwise - * only wakes on the end-of-run relay. Ignored for unknown or non-sub-agents. - */ - reportToPrime(sessionId: string, fromAgentId: string, text: string): void { - const agent = this.sessions.get(sessionId)?.agents.get(fromAgentId); - if (!agent || agent.role !== "subagent") return; - - const author: ChatAuthor = { - id: agent.agentId, - kind: "agent", - name: agent.name, - agentRole: "subagent", - }; - this.handlers.onAgentMessage(sessionId, fromAgentId, author, text); - this.sendToAgent( - sessionId, - PRIME_AGENT_ID, - `Sub-agent "${agent.name}" reported:\n\n${text}`, - undefined, - "auto", - "tool", - ); - } - /** * Cancels a participant's in-progress Run by sending Pi's `abort` RPC command * on stdin. The process stays alive and emits `agent_end`, which settles the @@ -1337,7 +1255,12 @@ export class PiAgentManager { }); } - /** Emits the `end` event for the in-flight message and records its text. */ + /** + * Emits the `end` event for the in-flight message and records its text. The + * event is what gets persisted as a Message and fanned out, so who wakes on it + * is decided there — this only reports that the turn produced something, and + * whether it was cut short. + */ private finalizeMessage( sessionId: string, agent: AgentProcess, @@ -1353,15 +1276,8 @@ export class PiAgentManager { messageId: agent.currentMessageId as string, content, thinking: agent.thinkingAccum, + ...(agent.aborted ? { aborted: true } : {}), }); - - // Safety net: relay every finalized sub-agent message to Prime as it lands, - // not just the last one at run end, so intermediate reports (e.g. submitted - // run ids) reach Prime even when the sub-agent doesn't call message_prime. - // A user-aborted run is intentionally cut short, so its partial output is - // not relayed back to Prime as if the sub-agent finished its task. - if (agent.aborted) return; - this.relaySubagentReply(sessionId, agent, content); } /** @@ -1400,11 +1316,7 @@ export class PiAgentManager { }); } - /** - * Ends a run: clears busy + the activity indicator. Sub-agent replies are no - * longer relayed here — each finalized message is relayed to Prime as it - * lands in {@link finalizeMessage}, so intermediate reports aren't dropped. - */ + /** Ends a run: clears busy + the activity indicator. */ private onAgentEnd(sessionId: string, agent: AgentProcess): void { console.log( `[pi:${sessionId}:${agent.agentId}] agent_end`, @@ -1471,28 +1383,6 @@ export class PiAgentManager { return entries; } - /** - * Keeps Prime in the loop: each finalized sub-agent message is fed back so - * Prime can react as it lands (sub-agents are directed only by Prime; this - * closes the loop). Called per message rather than once at run end so - * intermediate updates aren't dropped. No-op for Prime or empty content. - */ - private relaySubagentReply( - sessionId: string, - agent: AgentProcess, - content: string, - ): void { - if (agent.role !== "subagent" || !content.trim()) return; - // Trigger-owned sub-agents react in isolation; they reach Prime only when - // they explicitly call `message_prime`, never via this automatic relay. - if (!agent.autoRelayToPrime) return; - this.sendToAgent( - sessionId, - PRIME_AGENT_ID, - `Sub-agent "${agent.name}" replied:\n\n${content}`, - ); - } - /** Emits an error (and resets state) for an in-flight assistant message. */ private failInFlight( sessionId: string, diff --git a/apps/server/src/pi/triggers/triggerEngine.ts b/apps/server/src/pi/triggers/triggerEngine.ts index bcd2c9a..fa2e61f 100644 --- a/apps/server/src/pi/triggers/triggerEngine.ts +++ b/apps/server/src/pi/triggers/triggerEngine.ts @@ -1,23 +1,18 @@ -import { randomUUID } from "node:crypto"; - import type { BundleTrigger } from "@tangent/shared/configBundle.ts"; import type { ChatAuthor, - ChatMessage, RunIngress, Trigger, TriggerRosterPayload, TriggerTarget, TriggerUpdatePayload, } from "@tangent/shared/contracts.ts"; -import { - SocketEvents, - sourceFromAuthor, - TRIGGER_AUTHOR, -} from "@tangent/shared/contracts.ts"; +import { SocketEvents, TRIGGER_AUTHOR } from "@tangent/shared/contracts.ts"; import { Cron } from "croner"; import type { Server } from "socket.io"; +import type { ConversationRouter } from "../../conversation/conversationRouter.ts"; +import { roomFor } from "../../sockets/rooms.ts"; import type { SessionStore } from "../../store/sessionStore.ts"; import type { SubagentSpawnRequest } from "../agentConfig.ts"; import { type PiAgentManager, PRIME_AGENT_ID } from "../piAgentManager.ts"; @@ -32,10 +27,6 @@ interface ScheduleHandle { stop: () => void; } -function roomFor(sessionId: string): string { - return `session:${sessionId}`; -} - /** Author attributed to a trigger's delivered prompt (labelled by the trigger). */ function triggerAuthor(stored: StoredTrigger): ChatAuthor { return { ...TRIGGER_AUTHOR, name: stored.title ?? stored.name }; @@ -82,10 +73,9 @@ function parseEvery(every: string | undefined): number | undefined { /** * Drives triggers at runtime: arms schedule timers, runs the signal-to-prompt - * transform, and delivers the result to Prime exactly like a user chat turn - * (persist + broadcast + `pi.prompt`). One instance is shared across sessions; - * per-session schedule handles are tracked here while definitions live in the - * {@link TriggerManager}. + * transform, and posts the result into its target's Conversation exactly like a + * user chat turn. One instance is shared across sessions; per-session schedule + * handles are tracked here while definitions live in the {@link TriggerManager}. */ export class TriggerEngine { private readonly timers = new Map>(); @@ -93,17 +83,20 @@ export class TriggerEngine { private readonly store: SessionStore; private readonly pi: PiAgentManager; private readonly triggers: TriggerManager; + private readonly conversations: ConversationRouter; constructor( io: Server, store: SessionStore, pi: PiAgentManager, triggers: TriggerManager, + conversations: ConversationRouter, ) { this.io = io; this.store = store; this.pi = pi; this.triggers = triggers; + this.conversations = conversations; } /** Seeds a bundle's triggers into a new session and arms its schedules. */ @@ -222,7 +215,7 @@ export class TriggerEngine { const prompt = await resolveTriggerPrompt(rootPath, stored, signal); if (stored.target.type === "subagent") { - this.deliverToSubagent(sessionId, rootPath, stored, prompt); + await this.deliverToSubagent(sessionId, rootPath, stored, prompt); } else { await this.deliverToPrime(sessionId, rootPath, stored, prompt); } @@ -236,9 +229,9 @@ export class TriggerEngine { } /** - * Delivers a firing to Prime (the legacy, discouraged path): surfaces it in - * Prime's thread and relays it to Prime — spawning Prime if needed, exactly as - * a user message would. + * Delivers a firing to Prime (the legacy, discouraged path): posts it into + * Prime's Conversation addressed to Prime — spawning Prime first if needed, + * since a cold session has nothing to wake. */ private async deliverToPrime( sessionId: string, @@ -246,44 +239,38 @@ export class TriggerEngine { stored: StoredTrigger, prompt: string, ): Promise { - const author = triggerAuthor(stored); - const message: ChatMessage = { - id: randomUUID(), + this.pi.ensure(sessionId, rootPath); + await this.conversations.post({ sessionId, conversationId: PRIME_AGENT_ID, - seq: await this.store.nextSeq(sessionId, PRIME_AGENT_ID), - author, - mentions: [], - source: sourceFromAuthor(author), + author: triggerAuthor(stored), content: prompt, - createdAt: new Date().toISOString(), - }; - await this.store.appendMessage(message); - this.io.to(roomFor(sessionId)).emit(SocketEvents.ChatMessage, message); - this.pi.prompt(sessionId, rootPath, prompt, "auto", ingressFor(stored)); + mentions: [PRIME_AGENT_ID], + ingress: ingressFor(stored), + }); } /** * Delivers a firing to the trigger's dedicated sub-agent: revives it from the - * stored spec if it died (or after a restart), then surfaces the prompt in its - * thread and prompts it. The sub-agent reacts in isolation — its replies are - * not auto-relayed to Prime, though it may reach Prime via `message_prime`. + * stored spec if it died (or after a restart), then posts the prompt into its + * Conversation addressed to it. The sub-agent works in isolation — Prime does + * not react to its replies, though it may reach Prime by addressing it. */ - private deliverToSubagent( + private async deliverToSubagent( sessionId: string, rootPath: string, stored: StoredTrigger, prompt: string, - ): void { + ): Promise { const { agentId } = this.ensureSubagent(sessionId, rootPath, stored); - this.pi.sendToAgent( + await this.conversations.post({ sessionId, - agentId, - prompt, - triggerAuthor(stored), - "auto", - ingressFor(stored), - ); + conversationId: agentId, + author: triggerAuthor(stored), + content: prompt, + mentions: [agentId], + ingress: ingressFor(stored), + }); } /** @@ -328,7 +315,6 @@ export class TriggerEngine { id: info.id, role: "subagent", name: info.name, - purpose: request.task, status: "active", model: info.model, thinkingDepth: info.thinkingDepth, diff --git a/apps/server/src/pi/types.ts b/apps/server/src/pi/types.ts index 68b135a..11f3666 100644 --- a/apps/server/src/pi/types.ts +++ b/apps/server/src/pi/types.ts @@ -5,6 +5,7 @@ import type { AgentRole, ChatAuthor, RunId, + RunIngress, SessionRunStatus, SubagentInfo, SubagentStatus, @@ -25,7 +26,17 @@ type AgentEventBody = | { 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: "end"; + messageId: string; + content: string; + thinking: string; + /** + * Set when the turn was cut short by a cancellation. Its content is + * history, not a request, so it is persisted but provokes no reaction. + */ + aborted?: boolean; + } | { type: "error"; messageId?: string; message: string } | { type: "activity"; activity: AgentActivity | null } | { type: "queue"; steering: string[]; followUp: string[] }; @@ -59,17 +70,26 @@ export type SubagentUpdateHandler = ( subagent: SubagentInfo, ) => void; +/** A Message a transport asks the conversation layer to post. */ +export interface PostedMessage { + sessionId: string; + /** The Conversation it lands in. */ + conversationId: string; + /** Who it is attributed to. */ + author: ChatAuthor; + content: string; + /** Participants it addresses by id, which is how a wake is requested. */ + mentions?: string[]; + /** What created it, when a reaction did not. */ + ingress?: RunIngress; +} + /** - * Surfaces a directed message (e.g. a task Prime sends a sub-agent) into a - * specific conversation's transcript. `conversationId` is the owning agent's - * id; `author` is the sender to attribute it to. + * Posts a Message into a Conversation. A transport reaches for this to say what + * happened — a report it received, a refusal it has to explain — never to route + * work: who wakes on a Message is the fan-out engine's decision. */ -export type AgentMessageHandler = ( - sessionId: string, - conversationId: string, - author: ChatAuthor, - content: string, -) => void; +export type AgentMessageHandler = (message: PostedMessage) => void; /** Relays a session's live run status change to the shared sessions lobby. */ export type SessionStatusHandler = ( @@ -77,12 +97,18 @@ export type SessionStatusHandler = ( status: SessionRunStatus, ) => void; -export interface PiAgentHandlers { +/** + * Where a participant's events go: whatever holds participants — the local Pi + * manager, the remote-env gateway, an external tab — reports through this one + * interface, so a conversation renders and persists the same regardless of which + * transport produced it. + */ +export interface ConversationEventSink { /** Relays an agent's streaming events to the session's room. */ onAgentEvent: AgentEventHandler; /** Relays a sub-agent's spawn or status change to the session's room. */ onSubagentUpdate: SubagentUpdateHandler; - /** Surfaces a directed message into a sub-agent's transcript. */ + /** Posts a Message into a Conversation. */ onAgentMessage: AgentMessageHandler; /** Broadcasts a session's live run status change to the sessions lobby. */ onSessionStatus: SessionStatusHandler; @@ -105,8 +131,8 @@ export interface AgentProcess { busy: boolean; /** * Set when the current run was aborted by the user (via the `abort` RPC - * command). Read on `agent_end` to skip relaying a half-finished sub-agent - * reply back to Prime, then reset for the next run. + * command). Carried onto the finalized `end` event so a half-finished reply is + * persisted without waking anyone, then reset for the next run. */ aborted: boolean; /** The id of the assistant message currently streaming, if any. */ @@ -121,17 +147,12 @@ export interface AgentProcess { accum: string; /** Accumulated reasoning for the in-flight assistant message. */ thinkingAccum: string; - /** - * Text of the most recently finalized assistant message in this run. Used to - * relay a sub-agent's reply back to Prime after the whole run ends, since a - * single run can finalize multiple distinct messages. - */ + /** Text of the most recently finalized assistant message in this run. */ lastFinalContent: string; /** - * Whether this agent's finalized replies are auto-relayed back to Prime as - * they land. True for ordinary sub-agents (Prime directs them); false for a - * trigger-owned sub-agent, which reacts in isolation but may still reach Prime - * on its own via `message_prime`. + * Whether Prime reacts to this agent's finalized replies. Retained so a spawn + * can persist it and a revive can restore it; what it now describes is Prime's + * Membership in this agent's Conversation, which is what actually decides. */ autoRelayToPrime: boolean; /** diff --git a/apps/server/src/remote/remoteEnvironmentGateway.test.ts b/apps/server/src/remote/remoteEnvironmentGateway.test.ts index 64a7c67..9435bbd 100644 --- a/apps/server/src/remote/remoteEnvironmentGateway.test.ts +++ b/apps/server/src/remote/remoteEnvironmentGateway.test.ts @@ -8,7 +8,7 @@ import { } from "@tangent/shared/remoteSubagent.ts"; import type { Server as SocketIOServer, Socket } from "socket.io"; -import type { PiAgentHandlers } from "../pi/types.ts"; +import type { ConversationEventSink } from "../pi/types.ts"; import { RunRegistry } from "../runs/runRegistry.ts"; import { InMemoryRunStore } from "../store/inMemoryRunStore.ts"; import { InMemorySessionStore } from "../store/inMemorySessionStore.ts"; @@ -35,7 +35,7 @@ function makeHarness() { }; const rosterUpdates: SubagentInfo[] = []; - const handlers: PiAgentHandlers = { + const handlers: ConversationEventSink = { onAgentEvent: () => {}, onSubagentUpdate: (_sessionId, info) => rosterUpdates.push(info), onAgentMessage: () => {}, @@ -49,7 +49,6 @@ function makeHarness() { { of: () => namespace } as unknown as SocketIOServer, handlers, store, - () => {}, runs, ); @@ -112,9 +111,12 @@ test("the remote roster describes its connector and environment", () => { test("a disconnecting environment detaches its sub-agents and keeps their tabs", async () => { const h = makeHarness(); const env = h.connect("env-1"); - const { info } = h.gateway.spawnSubagent("s1", { - name: "Worker", - task: "go", + const { info } = h.gateway.spawnSubagent("s1", { name: "Worker" }); + h.gateway.sendToAgent({ + sessionId: "s1", + agentId: info.id, + text: "go", + ingress: "tool", }); const runId = h.runs.current("s1", info.id)?.id; assert.ok(runId); @@ -134,10 +136,13 @@ test("a detached participant refuses delivery instead of dropping it silently", const env = h.connect("env-1"); const { info } = h.gateway.spawnSubagent("s1", { name: "Worker" }); - assert.equal(h.gateway.sendToAgent("s1", info.id, "hello"), true); + const send = (text: string) => + h.gateway.sendToAgent({ sessionId: "s1", agentId: info.id, text }); + + assert.equal(send("hello"), true); env.disconnect(); - assert.equal(h.gateway.sendToAgent("s1", info.id, "hello again"), false); + assert.equal(send("hello again"), false); }); test("a reconnecting environment gets its persisted roster replayed as detached", async () => { diff --git a/apps/server/src/remote/remoteEnvironmentGateway.ts b/apps/server/src/remote/remoteEnvironmentGateway.ts index b22abb5..1f86f5a 100644 --- a/apps/server/src/remote/remoteEnvironmentGateway.ts +++ b/apps/server/src/remote/remoteEnvironmentGateway.ts @@ -1,7 +1,6 @@ import { randomUUID } from "node:crypto"; import { - type ChatAuthor, connectorFields, isTerminalStatus, type MessageDelivery, @@ -13,7 +12,6 @@ import { } from "@tangent/shared/contracts.ts"; import { REMOTE_ENV_NAMESPACE, - type RemoteAgentEvent, type RemoteAgentEventPayload, type RemoteAgentMessagePayload, RemoteEnvEvents, @@ -34,7 +32,11 @@ import { type SubagentSpawnRequest, } from "../pi/agentConfig.ts"; import type { SpawnedSubagent } from "../pi/piAgentManager.ts"; -import type { AgentDescriptor, PiAgentHandlers } from "../pi/types.ts"; +import { + type AgentDescriptor, + type ConversationEventSink, + PRIME_AGENT_ID, +} from "../pi/types.ts"; import type { RunRegistry } from "../runs/runRegistry.ts"; import type { SessionAgent, SessionStore } from "../store/sessionStore.ts"; @@ -42,12 +44,16 @@ import type { SessionAgent, SessionStore } from "../store/sessionStore.ts"; 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; +/** One message to deliver to one remote sub-agent. */ +export interface RemoteSendOptions { + sessionId: string; + agentId: string; + text: string; + /** Whether a mid-run message steers or queues. Defaults to `auto`. */ + delivery?: MessageDelivery; + /** What the message counts as for the Run it opens. Defaults to `reaction`. */ + ingress?: RunIngress; +} /** A connected remote environment and its live Socket.IO connection. */ interface RemoteEnvConnection { @@ -99,10 +105,11 @@ function toInfo(subagent: RemoteSubagent): SubagentInfo { * 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}. + * Inbound streamed events are relayed through the same {@link + * ConversationEventSink} a local sub-agent uses, so a remote sub-agent renders + * and persists identically. Whether a finalized reply or a report wakes Prime is + * not this gateway's business: it posts what happened into the sub-agent's + * Conversation, and the Memberships there decide. * * The protocol has no run-end marker: an environment reports its events and its * agent's lifecycle, not run boundaries. So the gateway opens a Run when it @@ -111,9 +118,8 @@ function toInfo(subagent: RemoteSubagent): SubagentInfo { */ export class RemoteEnvironmentGateway { private readonly io: SocketIOServer; - private readonly handlers: PiAgentHandlers; + private readonly handlers: ConversationEventSink; private readonly store: SessionStore; - private readonly deliverToPrime: DeliverToPrime; private readonly runs: RunRegistry; /** Connected environments, keyed by their handshake `environmentId`. */ @@ -123,15 +129,13 @@ export class RemoteEnvironmentGateway { constructor( io: SocketIOServer, - handlers: PiAgentHandlers, + handlers: ConversationEventSink, store: SessionStore, - deliverToPrime: DeliverToPrime, runs: RunRegistry, ) { this.io = io; this.handlers = handlers; this.store = store; - this.deliverToPrime = deliverToPrime; this.runs = runs; this.setupNamespace(); } @@ -186,13 +190,9 @@ export class RemoteEnvironmentGateway { }; this.rosterFor(sessionId).set(agentId, subagent); - // An initial task is work, so it gets a Run; a sub-agent spawned idle does - // not until something asks it for something. - const runId = request.task?.trim() - ? this.runs.open({ sessionId, participantId: agentId, ingress: "tool" }) - .id - : undefined; - + // No `task`: an initial task is a Message posted into the new sub-agent's + // Conversation, which arrives here as an ordinary delivery right after this + // command. So the environment always spawns idle. const command: RemoteSpawnCommand = { sessionId, agentId, @@ -202,9 +202,7 @@ export class RemoteEnvironmentGateway { model: config.model, thinkingDepth: config.thinkingDepth, template: request.template, - task: request.task, autoRelayToPrime, - runId, }; environment.socket.emit(RemoteEnvEvents.Spawn, command); @@ -219,39 +217,29 @@ export class RemoteEnvironmentGateway { } /** - * 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. - * - * Opens a Run for the message and puts its id on the command, so the - * environment can echo it back on the events it streams. + * Delivers a directed message/task to a remote sub-agent. Opens a Run for the + * message and puts its id on the command, so the environment can echo it back + * on the events it streams. * * Returns whether the message reached an environment: a detached participant * stays in the roster, so its connector needs to hear that nothing was sent * rather than assume a silent success. */ - sendToAgent( - sessionId: string, - agentId: string, - text: string, - surfaceAuthor?: ChatAuthor, - delivery: MessageDelivery = "auto", - ingress: RunIngress = "reaction", - ): boolean { + sendToAgent(options: RemoteSendOptions): boolean { + const { sessionId, agentId, text } = options; const environment = this.environmentFor(sessionId, agentId); if (!environment) return false; - if (surfaceAuthor) { - this.handlers.onAgentMessage(sessionId, agentId, surfaceAuthor, text); - } - - const run = this.runs.open({ sessionId, participantId: agentId, ingress }); + const run = this.runs.open({ + sessionId, + participantId: agentId, + ingress: options.ingress ?? "reaction", + }); const command: RemoteMessageCommand = { sessionId, agentId, text, - delivery, + delivery: options.delivery ?? "auto", runId: run.id, }; environment.socket.emit(RemoteEnvEvents.Message, command); @@ -422,7 +410,11 @@ export class RemoteEnvironmentGateway { } } - /** Relays a streamed event to the chat layer, relaying finalized replies. */ + /** + * Relays a streamed event to the chat layer. A finalized one is persisted as a + * Message there and fanned out from the sub-agent's own Conversation, so this + * gateway no longer feeds Prime a second copy. + */ private handleAgentEvent(payload: RemoteAgentEventPayload): void { const subagent = this.sessions.get(payload.sessionId)?.get(payload.agentId); if (!subagent) return; @@ -432,7 +424,6 @@ export class RemoteEnvironmentGateway { this.descriptorFor(subagent), { ...payload.event, runId: this.runIdFor(payload) }, ); - this.relayEndToPrime(payload.sessionId, subagent, payload.event); } /** @@ -457,20 +448,6 @@ export class RemoteEnvironmentGateway { return this.runs.current(payload.sessionId, payload.agentId)?.id; } - /** 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); @@ -491,28 +468,30 @@ export class RemoteEnvironmentGateway { this.handlers.onSubagentUpdate(payload.sessionId, toInfo(subagent)); } - /** Surfaces a sub-agent's report in its thread and relays it into Prime. */ + /** + * Posts a sub-agent's report in its own thread, addressed to Prime. Being + * addressed is what reaches Prime — the same mechanism a local sub-agent's + * `message_prime` uses, so neither transport carries its own copy of "and now + * tell Prime". + */ private handleAgentMessage(payload: RemoteAgentMessagePayload): void { const subagent = this.sessions.get(payload.sessionId)?.get(payload.agentId); if (!subagent) return; this.markAttached(payload.sessionId, subagent); - 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}`, - ); + this.handlers.onAgentMessage({ + sessionId: payload.sessionId, + conversationId: payload.agentId, + author: { + id: subagent.agentId, + kind: "agent", + name: subagent.name, + agentRole: "subagent", + }, + content: payload.text, + mentions: [PRIME_AGENT_ID], + ingress: "tool", + }); } /** Answers a remote room-read with the tail of the shared transcript. */ diff --git a/apps/server/src/routes/internalAgents.ts b/apps/server/src/routes/internalAgents.ts index 26317f1..ce15917 100644 --- a/apps/server/src/routes/internalAgents.ts +++ b/apps/server/src/routes/internalAgents.ts @@ -1,4 +1,5 @@ import { + type ChatAuthor, connectorFields, type ConnectorKind, PI_AGENT, @@ -7,10 +8,11 @@ import { type Response, Router } from "express"; import { z } from "zod"; import type { ConnectorRegistry } from "../connectors/connectorRegistry.ts"; +import type { ConversationRouter } from "../conversation/conversationRouter.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 { PRIME_AGENT_ID } from "../pi/types.ts"; import type { SessionStore } from "../store/sessionStore.ts"; /** Spawn a sub-agent; `sessionId` and `name` identify and label it. */ @@ -72,15 +74,18 @@ function spawnKind(environment: SpawnInput["environment"]): ConnectorKind { /** * Spawns a sub-agent (resolving its model/thinking) on the connector its - * requested environment names and persists it so the roster survives a restart. - * Extracted from the router so the route function stays small. + * requested environment names, persists it so the roster survives a restart, and + * posts its initial task. The row is awaited before the task is posted, so the + * sub-agent's Memberships are derived from its persisted facts rather than from + * a default. */ -function handleSpawn( +async function handleSpawn( store: SessionStore, connectors: ConnectorRegistry, + router: ConversationRouter, body: SpawnInput, res: Response, -): void { +): Promise { const kind = spawnKind(body.environment); const connector = connectors.spawner(kind); if (!connector) { @@ -99,11 +104,10 @@ function handleSpawn( tools: body.tools, model: body.model, thinkingDepth: parseThinkingLevel(body.thinkingDepth), - task: body.task, environment: host, }, ); - void store.recordAgent(body.sessionId, { + await store.recordAgent(body.sessionId, { id: info.id, role: "subagent", name: info.name, @@ -119,41 +123,108 @@ function handleSpawn( connector: info.connector, }); res.json({ subagent: info }); + // Answered first: the sub-agent exists either way, and a failure to post its + // first task must not read as a failed spawn Prime might retry. + await postDirective(router, body.sessionId, info.id, body.task).catch( + (err: unknown) => { + console.error(`[agents] initial task for ${info.id} failed:`, err); + }, + ); } catch (err) { res.status(400).json({ error: (err as Error).message }); } } -/** Surfaces a Prime-issued directive in the sub-agent's transcript. */ -function handleMessage( - connectors: ConnectorRegistry, - body: MessageInput, - res: Response, -): void { - // Attributed to Prime (message_subagent is always a Prime-issued directive), - // and a tool call is what creates the work. - const { sessionId, agentId, text } = body; - const { delivered, reason } = connectors.resolve(sessionId, agentId).deliver({ +/** + * Posts a Prime-issued directive into a sub-agent's Conversation, addressed to + * it. Surfacing and delivery are the same act: the sub-agent reacts because it + * was addressed, and the bubble the user reads is the Message that woke it. + * Skips an empty or whitespace-only task. + */ +async function postDirective( + router: ConversationRouter, + sessionId: string, + agentId: string, + text: string | undefined, +): Promise { + if (!text?.trim()) return undefined; + const { refused } = await router.post({ sessionId, - participantId: agentId, - text, - surfaceAuthor: PI_AGENT, + conversationId: agentId, + author: PI_AGENT, + content: text, + mentions: [agentId], ingress: "tool", }); - res.json({ ok: delivered, ...(reason ? { error: reason } : {}) }); + return refused.find((entry) => entry.participantId === agentId)?.reason; } -/** Surfaces a sub-agent's report in its own thread and delivers it to Prime. */ -function handleReport( - pi: PiAgentManager, +/** + * Posts a Prime-issued directive into the sub-agent's Conversation. Prime hears + * about a sub-agent that did not wake, because a directive that reaches nobody + * looks exactly like one that worked. + */ +async function handleMessage( + router: ConversationRouter, + body: MessageInput, + res: Response, +): Promise { + const refused = await postDirective( + router, + body.sessionId, + body.agentId, + body.text, + ); + res.json({ ok: !refused, ...(refused ? { error: refused } : {}) }); +} + +/** + * Posts a sub-agent's report into its own thread, addressed to Prime. A thin + * alias over the same post `/message` makes: `message_prime` reaches Prime by + * addressing it, not by a dedicated relay. + */ +async function handleReport( + connectors: ConnectorRegistry, + router: ConversationRouter, body: ReportInput, res: Response, -): void { - // message_prime is a sub-agent-issued update; Prime reacts immediately. - pi.reportToPrime(body.sessionId, body.agentId, body.text); +): Promise { + const { sessionId, agentId, text } = body; + const author = subagentAuthor(connectors, sessionId, agentId); + if (!author) { + res.status(404).json({ error: "That sub-agent is no longer available." }); + return; + } + + await router.post({ + sessionId, + conversationId: agentId, + author, + content: text, + mentions: [PRIME_AGENT_ID], + ingress: "tool", + }); res.json({ ok: true }); } +/** The chat author of a live sub-agent, read from the roster it appears in. */ +function subagentAuthor( + connectors: ConnectorRegistry, + sessionId: string, + agentId: string, +): ChatAuthor | undefined { + const subagent = connectors + .list(sessionId) + .find((candidate) => candidate.id === agentId); + if (!subagent) return undefined; + return { + id: subagent.id, + kind: "agent", + name: subagent.name, + agentRole: "subagent", + }; +} + /** Terminates a sub-agent, optionally marking its work completed. */ function handleKill( connectors: ConnectorRegistry, @@ -200,23 +271,34 @@ async function handleRoom( */ export function createInternalAgentsRouter( store: SessionStore, - pi: PiAgentManager, connectors: ConnectorRegistry, + conversations: ConversationRouter, ): Router { const router = Router(); router.use(requireInternalToken); router.post("/spawn", validate({ body: spawnSchema }), (req, res) => - handleSpawn(store, connectors, getValidated(req).body, res), + handleSpawn( + store, + connectors, + conversations, + getValidated(req).body, + res, + ), ); router.post("/message", validate({ body: messageSchema }), (req, res) => - handleMessage(connectors, getValidated(req).body, res), + handleMessage(conversations, getValidated(req).body, res), ); router.post("/report", validate({ body: reportSchema }), (req, res) => - handleReport(pi, getValidated(req).body, res), + handleReport( + connectors, + conversations, + getValidated(req).body, + res, + ), ); router.post("/kill", validate({ body: killSchema }), (req, res) => diff --git a/apps/server/src/routes/internalMemory.ts b/apps/server/src/routes/internalMemory.ts index cd7dfd3..a434a2e 100644 --- a/apps/server/src/routes/internalMemory.ts +++ b/apps/server/src/routes/internalMemory.ts @@ -7,7 +7,7 @@ import type { MemoryManager } from "../pi/memory.ts"; import type { MemoryRememberedHandler, MemorySuggestionHandler, -} from "../sockets/chat.ts"; +} from "../sockets/chatMemory.ts"; import type { SessionStore } from "../store/sessionStore.ts"; import { loadSession } from "./sessions/utils.ts"; diff --git a/apps/server/src/routes/internalSession.ts b/apps/server/src/routes/internalSession.ts index 65ed63c..773b103 100644 --- a/apps/server/src/routes/internalSession.ts +++ b/apps/server/src/routes/internalSession.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { requireInternalToken } from "../middleware/requireInternalToken.ts"; import { getValidated, validate } from "../middleware/validate.ts"; -import type { UiCommandEmitter } from "../sockets/chat.ts"; +import type { UiCommandEmitter } from "../sockets/sessionRoster.ts"; import type { SessionStore } from "../store/sessionStore.ts"; import { loadSession } from "./sessions/utils.ts"; diff --git a/apps/server/src/sockets/agentEvents.ts b/apps/server/src/sockets/agentEvents.ts new file mode 100644 index 0000000..cb5413a --- /dev/null +++ b/apps/server/src/sockets/agentEvents.ts @@ -0,0 +1,326 @@ +import { + type AgentActivity, + type AgentActivityPayload, + type AgentDeltaPayload, + type AgentEndPayload, + type AgentErrorPayload, + type AgentQueuePayload, + type AgentStartPayload, + type AgentThinkingPayload, + type ChatAuthor, + PI_AGENT, + type RunId, + type SessionStatusPayload, + SocketEvents, + sourceFromAuthor, + type SubagentUpdatePayload, +} from "@tangent/shared/contracts.ts"; +import type { Server } from "socket.io"; + +import type { ConversationRouter } from "../conversation/conversationRouter.ts"; +import type { + AgentDescriptor, + AgentEvent, + AgentEventHandler, + AgentMessageHandler, + SessionStatusHandler, + SubagentUpdateHandler, +} from "../pi/types.ts"; +import type { SessionStore } from "../store/sessionStore.ts"; +import { roomFor, SESSIONS_LOBBY } from "./rooms.ts"; + +/** Resolves the chat author for an agent: Prime is fixed, sub-agents per id. */ +function authorFor(agent: AgentDescriptor): ChatAuthor { + if (agent.role === "prime") return PI_AGENT; + return { + id: agent.agentId, + kind: "agent", + name: agent.name, + agentRole: "subagent", + }; +} + +/** Shared context for emitting one agent event into its session room. */ +interface EmitContext { + room: string; + sessionId: string; + conversationId: string; + author: ChatAuthor; + /** The Run that produced the event, when the connector attributed one. */ + runId?: RunId; +} + +/** + * The `seq` reserved for each streaming message, held from `start` until the + * turn finalizes so the placeholder and the Message that replaces it share one + * ordinal. + * + * It doubles as the ordering gate. Reserving is asynchronous, and the client + * drops a delta for a message id it has not seen, so every subsequent event for + * that message chains off this promise: callbacks on one promise run in + * registration order, which makes `start` before `delta` structural rather than + * a matter of timing. + */ +const reservedSeqs = new Map>(); + +/** The reservation to emit behind, or an immediate one when there was no start. */ +function seqGate(messageId: string | undefined): Promise { + const reserved = messageId ? reservedSeqs.get(messageId) : undefined; + return reserved ?? Promise.resolve(0); +} + +function emitStart( + io: Server, + store: SessionStore, + ctx: EmitContext, + messageId: string, +): void { + const reservation = store.nextSeq(ctx.sessionId, ctx.conversationId); + reservedSeqs.set(messageId, reservation); + void reservation.then((seq) => { + const payload: AgentStartPayload = { + message: { + id: messageId, + sessionId: ctx.sessionId, + conversationId: ctx.conversationId, + seq, + author: ctx.author, + mentions: [], + source: sourceFromAuthor(ctx.author), + content: "", + runId: ctx.runId, + createdAt: new Date().toISOString(), + }, + runId: ctx.runId, + }; + io.to(ctx.room).emit(SocketEvents.AgentStart, payload); + }); +} + +function emitDelta( + io: Server, + ctx: EmitContext, + event: { messageId: string; delta: string }, +): void { + const payload: AgentDeltaPayload = { + sessionId: ctx.sessionId, + messageId: event.messageId, + delta: event.delta, + runId: ctx.runId, + }; + void seqGate(event.messageId).then(() => { + io.to(ctx.room).emit(SocketEvents.AgentDelta, payload); + }); +} + +function emitThinking( + io: Server, + ctx: EmitContext, + event: { messageId: string; delta: string }, +): void { + const payload: AgentThinkingPayload = { + sessionId: ctx.sessionId, + messageId: event.messageId, + delta: event.delta, + runId: ctx.runId, + }; + void seqGate(event.messageId).then(() => { + io.to(ctx.room).emit(SocketEvents.AgentThinking, payload); + }); +} + +/** + * Posts a finalized turn as a Message in the producing agent's Conversation, and + * broadcasts it as `agent:end` so the client replaces its streaming placeholder + * instead of appending a second bubble. Posting is what wakes whoever reacts to + * it, which is why nothing here decides who hears about it. + * + * Empty output and a cancelled turn are persisted but provoke nothing: neither is + * a request, and a half-finished reply should not read as a finished one. + */ +function emitEnd( + io: Server, + conversations: ConversationRouter, + ctx: EmitContext, + event: { + messageId: string; + content: string; + thinking: string; + aborted?: boolean; + }, +): void { + // Take the seq this turn reserved at `start`; a finalized message that never + // streamed (no reservation) allocates one now. + const reserved = reservedSeqs.get(event.messageId); + reservedSeqs.delete(event.messageId); + + void (async () => { + const seq = reserved ? await reserved : undefined; + await conversations.post({ + id: event.messageId, + sessionId: ctx.sessionId, + conversationId: ctx.conversationId, + seq, + author: ctx.author, + content: event.content, + thinking: event.thinking, + runId: ctx.runId, + ...(ctx.runId ? { endsRun: true } : {}), + provokes: Boolean(event.content.trim()) && !event.aborted, + broadcast: (message) => { + const payload: AgentEndPayload = { message, runId: ctx.runId }; + io.to(ctx.room).emit(SocketEvents.AgentEnd, payload); + }, + }); + })(); +} + +function emitError( + io: Server, + ctx: EmitContext, + event: { messageId?: string; message: string }, +): void { + const payload: AgentErrorPayload = { + sessionId: ctx.sessionId, + messageId: event.messageId, + message: event.message, + runId: ctx.runId, + }; + // A failed turn spends its reserved seq without persisting anything, leaving a + // gap. Emitted behind the reservation so the error still lands after `start`. + const gate = seqGate(event.messageId); + if (event.messageId) reservedSeqs.delete(event.messageId); + void gate.then(() => { + io.to(ctx.room).emit(SocketEvents.AgentError, payload); + }); +} + +function emitActivity( + io: Server, + ctx: EmitContext, + activity: AgentActivity | null, +): void { + const payload: AgentActivityPayload = { + sessionId: ctx.sessionId, + conversationId: ctx.conversationId, + activity, + runId: ctx.runId, + }; + io.to(ctx.room).emit(SocketEvents.AgentActivity, payload); +} + +function emitQueue( + io: Server, + ctx: EmitContext, + event: { steering: string[]; followUp: string[] }, +): void { + const payload: AgentQueuePayload = { + sessionId: ctx.sessionId, + conversationId: ctx.conversationId, + steering: event.steering, + followUp: event.followUp, + runId: ctx.runId, + }; + io.to(ctx.room).emit(SocketEvents.AgentQueue, payload); +} + +/** + * Builds the handler that relays agents' streaming events to the matching + * session room, dispatching each event variant to its emit helper. + * + * Each message is tagged with the producing agent's id as its `conversationId` + * so the client can bucket it into the right transcript (Prime's main thread or + * a sub-agent's drill-in thread). Reasoning streams for every agent. + */ +export function createAgentEventHandler( + io: Server, + store: SessionStore, + conversations: ConversationRouter, +): AgentEventHandler { + return (sessionId, agent, event) => { + const ctx: EmitContext = { + room: roomFor(sessionId), + sessionId, + conversationId: agent.agentId, + author: authorFor(agent), + runId: event.runId, + }; + relayStreamingEvent(io, store, ctx, event); + relayTerminalEvent(io, conversations, ctx, event); + }; +} + +/** Relays the streaming variants (placeholder + incremental tokens). */ +function relayStreamingEvent( + io: Server, + store: SessionStore, + ctx: EmitContext, + event: AgentEvent, +): void { + switch (event.type) { + case "start": + return emitStart(io, store, ctx, event.messageId); + case "delta": + return emitDelta(io, ctx, event); + case "thinking": + return emitThinking(io, ctx, event); + } +} + +/** Relays the terminal/run-level variants (finalize, error, activity). */ +function relayTerminalEvent( + io: Server, + conversations: ConversationRouter, + ctx: EmitContext, + event: AgentEvent, +): void { + switch (event.type) { + case "end": + return emitEnd(io, conversations, ctx, event); + case "error": + return emitError(io, ctx, event); + case "activity": + return emitActivity(io, ctx, event.activity); + case "queue": + return emitQueue(io, ctx, event); + } +} + +/** Builds the handler that broadcasts sub-agent roster changes to the room. */ +export function createSubagentUpdateHandler( + io: Server, + store: SessionStore, +): SubagentUpdateHandler { + return (sessionId, subagent) => { + const payload: SubagentUpdatePayload = { sessionId, subagent }; + io.to(roomFor(sessionId)).emit(SocketEvents.SubagentUpdate, payload); + + // Persisted as-is: a participant's lifecycle is transcript-visible history, + // so nothing is collapsed on the way to the row. + void store.setAgentStatus(sessionId, subagent.id, subagent.status); + }; +} + +/** + * Builds the handler a transport uses to post a Message into a Conversation — + * a report it received, or a refusal it has to explain. It is the router's `post` + * with the transport's own dependencies left out. + */ +export function createAgentMessageHandler( + conversations: ConversationRouter, +): AgentMessageHandler { + return (posted) => { + void conversations.post(posted); + }; +} + +/** + * Builds the {@link SessionStatusHandler} that fans status changes out to the + * lobby room, so every list view reflects a session's run status live. + */ +export function createSessionStatusHandler(io: Server): SessionStatusHandler { + return (sessionId, status) => { + const payload: SessionStatusPayload = { sessionId, status }; + io.to(SESSIONS_LOBBY).emit(SocketEvents.SessionStatus, payload); + }; +} diff --git a/apps/server/src/sockets/chat.ts b/apps/server/src/sockets/chat.ts index 2b5001f..b9526ac 100644 --- a/apps/server/src/sockets/chat.ts +++ b/apps/server/src/sockets/chat.ts @@ -1,578 +1,61 @@ -import { randomUUID } from "node:crypto"; - import { type AgentAbortPayload, - type AgentActivity, - type AgentActivityPayload, - type AgentDeltaPayload, - type AgentEndPayload, - type AgentErrorPayload, - type AgentModelPayload, - type AgentQueuePayload, type AgentSetModelPayload, - type AgentStartPayload, - type AgentThinkingPayload, type ArtifactPinPayload, type ArtifactUnpinPayload, - type Attachment, type ChatAuthor, type ChatJoinPayload, - type ChatMessage, type ChatMessagePayload, DEFAULT_USER, humanAuthor, - MEMORY_AUTHOR, type MemoryConfirmPayload, type MemoryDismissPayload, - type MemoryScope, - type MemorySuggestionPayload, PI_AGENT, - type RunId, - type Session, - type SessionStatusPayload, - type SessionStatusSnapshotPayload, SocketEvents, - sourceFromAuthor, type SubagentRosterPayload, - type SubagentUpdatePayload, - type ThinkingLevel, type TriggerRosterPayload, - type UiCommand, - type UiCommandPayload, } from "@tangent/shared/contracts.ts"; import type { Server, Socket } from "socket.io"; import { resolveUserIdentity } from "../auth/identity.ts"; import type { ConnectorRegistry } from "../connectors/connectorRegistry.ts"; -import { parseThinkingLevel } from "../pi/agentConfig.ts"; +import type { ConversationRouter } from "../conversation/conversationRouter.ts"; import type { MemoryManager } from "../pi/memory.ts"; -import { - type AgentDescriptor, - type AgentEvent, - type AgentEventHandler, - type AgentMessageHandler, - type PiAgentManager, - PRIME_AGENT_ID, - type SubagentUpdateHandler, -} from "../pi/piAgentManager.ts"; +import { type PiAgentManager, PRIME_AGENT_ID } from "../pi/piAgentManager.ts"; import type { TriggerEngine } from "../pi/triggers/triggerEngine.ts"; -import type { SessionStatusHandler } from "../pi/types.ts"; import type { SessionStore } from "../store/sessionStore.ts"; +import { + handleArtifactPin, + handleArtifactUnpin, + replayArtifacts, +} from "./chatArtifacts.ts"; +import { + handleMemoryConfirm, + handleMemoryDismiss, + type MemoryRememberedHandler, +} from "./chatMemory.ts"; import { type MentionCandidate, resolveMentions } from "./mentions.ts"; +import { roomFor } from "./rooms.ts"; +import { + emitPrimeSelection, + ensureSessionAgents, + handleAgentSetModel, + handleSessionStatusSubscribe, + replayAgentActivities, + type UiCommandEmitter, +} from "./sessionRoster.ts"; -function roomFor(sessionId: string): string { - return `session:${sessionId}`; -} - -/** - * Shared room every client viewing a session list (the switcher, the sessions - * table) joins to receive live run-status updates for all sessions at once, - * without subscribing to each session's individual room. - */ -const SESSIONS_LOBBY = "sessions:lobby"; - -/** - * Builds the {@link SessionStatusHandler} that fans status changes out to the - * lobby room, so every list view reflects a session's run status live. - */ -export function createSessionStatusHandler(io: Server): SessionStatusHandler { - return (sessionId, status) => { - const payload: SessionStatusPayload = { sessionId, status }; - io.to(SESSIONS_LOBBY).emit(SocketEvents.SessionStatus, payload); - }; -} - -/** - * Everything a Message needs beyond its envelope defaults. `seq` comes from the - * store's allocator rather than being derivable here, which is what stops a - * writer inventing one. - */ -interface MessageInput { - id: string; - sessionId: string; - conversationId: string; - seq: number; - author: ChatAuthor; - content: string; - mentions?: string[]; - thinking?: string; - attachments?: Attachment[]; - runId?: RunId; - endsRun?: boolean; -} - -function buildMessage(input: MessageInput): ChatMessage { - // `runId` and `endsRun` ride the rest spread: an absent one is `undefined`, - // which JSON drops on both the wire and the way to the log. `thinking` and - // `attachments` are guarded because an empty string or array is not absent. - const { thinking, attachments, mentions, ...rest } = input; - return { - ...rest, - mentions: mentions ?? [], - source: sourceFromAuthor(input.author), - ...(thinking ? { thinking } : {}), - ...(attachments?.length ? { attachments } : {}), - createdAt: new Date().toISOString(), - }; -} - -/** - * Appends a list of attached files (by their workspace-relative path) to the - * human's message before it is handed to the agent, so the agent knows the - * files exist and can read them with its own file tools. Returns the content - * unchanged when nothing was attached. - */ -function promptWithAttachments( - content: string, - attachments?: Attachment[], -): string { - if (!attachments || attachments.length === 0) return content; - const list = attachments.map((a) => `- ${a.path}`).join("\n"); - const intro = - "The user attached the following files (paths are relative to your workspace):"; - return content ? `${content}\n\n${intro}\n${list}` : `${intro}\n${list}`; -} - -/** Resolves the chat author for an agent: Prime is fixed, sub-agents per id. */ -function authorFor(agent: AgentDescriptor): ChatAuthor { - if (agent.role === "prime") return PI_AGENT; - return { - id: agent.agentId, - kind: "agent", - name: agent.name, - agentRole: "subagent", - }; -} - -/** Shared context for emitting one agent event into its session room. */ -interface EmitContext { - room: string; - sessionId: string; - conversationId: string; - author: ChatAuthor; - /** The Run that produced the event, when the connector attributed one. */ - runId?: RunId; -} - -/** - * The `seq` reserved for each streaming message, held from `start` until the - * turn finalizes so the placeholder and the Message that replaces it share one - * ordinal. - * - * It doubles as the ordering gate. Reserving is asynchronous, and the client - * drops a delta for a message id it has not seen, so every subsequent event for - * that message chains off this promise: callbacks on one promise run in - * registration order, which makes `start` before `delta` structural rather than - * a matter of timing. - */ -const reservedSeqs = new Map>(); - -/** The reservation to emit behind, or an immediate one when there was no start. */ -function seqGate(messageId: string | undefined): Promise { - const reserved = messageId ? reservedSeqs.get(messageId) : undefined; - return reserved ?? Promise.resolve(0); -} - -function emitStart( - io: Server, - store: SessionStore, - ctx: EmitContext, - messageId: string, -): void { - const reservation = store.nextSeq(ctx.sessionId, ctx.conversationId); - reservedSeqs.set(messageId, reservation); - void reservation.then((seq) => { - const message = buildMessage({ - id: messageId, - sessionId: ctx.sessionId, - conversationId: ctx.conversationId, - seq, - author: ctx.author, - content: "", - runId: ctx.runId, - }); - const payload: AgentStartPayload = { message, runId: ctx.runId }; - io.to(ctx.room).emit(SocketEvents.AgentStart, payload); - }); -} - -function emitDelta( - io: Server, - ctx: EmitContext, - event: { messageId: string; delta: string }, -): void { - const payload: AgentDeltaPayload = { - sessionId: ctx.sessionId, - messageId: event.messageId, - delta: event.delta, - runId: ctx.runId, - }; - void seqGate(event.messageId).then(() => { - io.to(ctx.room).emit(SocketEvents.AgentDelta, payload); - }); -} - -function emitThinking( - io: Server, - ctx: EmitContext, - event: { messageId: string; delta: string }, -): void { - const payload: AgentThinkingPayload = { - sessionId: ctx.sessionId, - messageId: event.messageId, - delta: event.delta, - runId: ctx.runId, - }; - void seqGate(event.messageId).then(() => { - io.to(ctx.room).emit(SocketEvents.AgentThinking, payload); - }); -} - -function emitEnd( - io: Server, - store: SessionStore, - ctx: EmitContext, - event: { messageId: string; content: string; thinking: string }, -): void { - // Take the seq this turn reserved at `start`; a finalized message that never - // streamed (no reservation) allocates one now. - const reserved = reservedSeqs.get(event.messageId); - reservedSeqs.delete(event.messageId); - const allocation = - reserved ?? store.nextSeq(ctx.sessionId, ctx.conversationId); - - void allocation.then(async (seq) => { - const message = buildMessage({ - id: event.messageId, - sessionId: ctx.sessionId, - conversationId: ctx.conversationId, - seq, - author: ctx.author, - content: event.content, - thinking: event.thinking, - runId: ctx.runId, - ...(ctx.runId ? { endsRun: true } : {}), - }); - // Persist before broadcasting so reconnecting clients see it in history. - await store.appendMessage(message); - const payload: AgentEndPayload = { message, runId: ctx.runId }; - io.to(ctx.room).emit(SocketEvents.AgentEnd, payload); - }); -} - -function emitError( - io: Server, - ctx: EmitContext, - event: { messageId?: string; message: string }, -): void { - const payload: AgentErrorPayload = { - sessionId: ctx.sessionId, - messageId: event.messageId, - message: event.message, - runId: ctx.runId, - }; - // A failed turn spends its reserved seq without persisting anything, leaving a - // gap. Emitted behind the reservation so the error still lands after `start`. - const gate = seqGate(event.messageId); - if (event.messageId) reservedSeqs.delete(event.messageId); - void gate.then(() => { - io.to(ctx.room).emit(SocketEvents.AgentError, payload); - }); -} - -function emitActivity( - io: Server, - ctx: EmitContext, - activity: AgentActivity | null, -): void { - const payload: AgentActivityPayload = { - sessionId: ctx.sessionId, - conversationId: ctx.conversationId, - activity, - runId: ctx.runId, - }; - io.to(ctx.room).emit(SocketEvents.AgentActivity, payload); -} - -function emitQueue( - io: Server, - ctx: EmitContext, - event: { steering: string[]; followUp: string[] }, -): void { - const payload: AgentQueuePayload = { - sessionId: ctx.sessionId, - conversationId: ctx.conversationId, - steering: event.steering, - followUp: event.followUp, - runId: ctx.runId, - }; - io.to(ctx.room).emit(SocketEvents.AgentQueue, payload); -} - -/** - * Builds the handler that relays agents' streaming events to the matching - * session room, dispatching each event variant to its emit helper. - * - * Each message is tagged with the producing agent's id as its `conversationId` - * so the client can bucket it into the right transcript (Prime's main thread or - * a sub-agent's drill-in thread). Reasoning streams for every agent. - */ -export function createAgentEventHandler( - io: Server, - store: SessionStore, -): AgentEventHandler { - return (sessionId, agent, event) => { - const ctx: EmitContext = { - room: roomFor(sessionId), - sessionId, - conversationId: agent.agentId, - author: authorFor(agent), - runId: event.runId, - }; - relayStreamingEvent(io, store, ctx, event); - relayTerminalEvent(io, store, ctx, event); - }; -} - -/** Relays the streaming variants (placeholder + incremental tokens). */ -function relayStreamingEvent( - io: Server, - store: SessionStore, - ctx: EmitContext, - event: AgentEvent, -): void { - switch (event.type) { - case "start": - return emitStart(io, store, ctx, event.messageId); - case "delta": - return emitDelta(io, ctx, event); - case "thinking": - return emitThinking(io, ctx, event); - } -} - -/** Relays the terminal/run-level variants (finalize, error, activity). */ -function relayTerminalEvent( - io: Server, - store: SessionStore, - ctx: EmitContext, - event: AgentEvent, -): void { - switch (event.type) { - case "end": - return emitEnd(io, store, ctx, event); - case "error": - return emitError(io, ctx, event); - case "activity": - return emitActivity(io, ctx, event.activity); - case "queue": - return emitQueue(io, ctx, event); - } -} - -/** Builds the handler that broadcasts sub-agent roster changes to the room. */ -export function createSubagentUpdateHandler( - io: Server, - store: SessionStore, -): SubagentUpdateHandler { - return (sessionId, subagent) => { - const payload: SubagentUpdatePayload = { sessionId, subagent }; - io.to(roomFor(sessionId)).emit(SocketEvents.SubagentUpdate, payload); - - // Persisted as-is: a participant's lifecycle is transcript-visible history, - // so nothing is collapsed on the way to the row. - void store.setAgentStatus(sessionId, subagent.id, subagent.status); - }; -} - -/** - * Pushes a generic agent->UI directive into a session room. This is the single - * transport every UI-affecting feature shares: callers build a {@link UiCommand} - * variant (e.g. `session.update`) and this broadcasts it; clients dispatch by - * `command.kind` and ignore kinds they don't recognize. - */ -export type UiCommandEmitter = (sessionId: string, command: UiCommand) => void; - -/** Builds the {@link UiCommandEmitter} bound to the Socket.IO server. */ -export function createUiCommandEmitter(io: Server): UiCommandEmitter { - return (sessionId, command) => { - const payload: UiCommandPayload = { sessionId, command }; - io.to(roomFor(sessionId)).emit(SocketEvents.UiCommand, payload); - }; -} - -/** - * Builds the handler that surfaces a directed message into a sub-agent's - * thread (e.g. a task Prime sends a sub-agent). The message is persisted and - * broadcast as a normal `chat:message`, so it lands in the right transcript via - * its `conversationId` and survives reconnects. - */ -export function createAgentMessageHandler( - io: Server, - store: SessionStore, -): AgentMessageHandler { - return (sessionId, conversationId, author, content) => { - void (async () => { - const message = buildMessage({ - id: randomUUID(), - sessionId, - conversationId, - seq: await store.nextSeq(sessionId, conversationId), - author, - content, - }); - await store.appendMessage(message); - io.to(roomFor(sessionId)).emit(SocketEvents.ChatMessage, message); - })(); - }; -} - -/** - * Handler that surfaces an applied memory write as a highlighted, persisted - * chat message. Built from the actual stored text (not the agent's claim) so - * the user always sees ground truth. - */ -export type MemoryRememberedHandler = ( - sessionId: string, - scope: MemoryScope, - text: string, -) => Promise; - -/** Builds the {@link MemoryRememberedHandler} bound to the room + store. */ -export function createMemoryRememberedHandler( - io: Server, - store: SessionStore, -): MemoryRememberedHandler { - return async (sessionId, scope, text) => { - const message: ChatMessage = { - ...buildMessage({ - id: randomUUID(), - sessionId, - conversationId: PRIME_AGENT_ID, - seq: await store.nextSeq(sessionId, PRIME_AGENT_ID), - author: MEMORY_AUTHOR, - content: text, - }), - memory: { scope }, - }; - await store.appendMessage(message); - io.to(roomFor(sessionId)).emit(SocketEvents.ChatMessage, message); - }; -} - -/** Emits a memory suggestion card into the session room. */ -export type MemorySuggestionHandler = ( - payload: MemorySuggestionPayload, -) => void; - -/** Builds the {@link MemorySuggestionHandler} bound to the room. */ -export function createMemorySuggestionHandler( - io: Server, -): MemorySuggestionHandler { - return (payload) => { - io.to(roomFor(payload.sessionId)).emit( - SocketEvents.MemorySuggestion, - payload, - ); - }; -} - -/** - * Applies a confirmed suggestion: writes it to the resolved store, surfaces the - * highlight, and tells Prime the user approved so it can continue honestly. - */ -async function handleMemoryConfirm( - store: SessionStore, - pi: PiAgentManager, - memory: MemoryManager, - onRemembered: MemoryRememberedHandler, - payload: MemoryConfirmPayload, -): Promise { - const suggestion = memory.takeSuggestion(payload?.suggestionId); - if (!suggestion || suggestion.sessionId !== payload.sessionId) return; - - const session = await store.getSession(suggestion.sessionId); - if (!session) return; - - const result = memory.write( - session.rootPath, - suggestion.scope, - suggestion.text, - ); - await onRemembered(suggestion.sessionId, result.scope, result.added); - pi.sendToAgent( - suggestion.sessionId, - PRIME_AGENT_ID, - `The user confirmed your suggestion. It has been stored to ${result.scope} ` + - `memory: "${result.added}".`, - ); -} - -/** - * Applies a human-requested model/thinking change: respawns the target agent's - * Pi process with the new settings, persists the selection so it survives a - * restart, and surfaces it. Sub-agent changes ride the roster-update handler - * (fired inside {@link PiAgentManager.setAgentModel}); Prime's change is - * broadcast here via the dedicated `agent:model` event. - */ -function handleAgentSetModel( - io: Server, - store: SessionStore, - pi: PiAgentManager, - payload: AgentSetModelPayload, -): void { - if (!payload) return; - const { sessionId, agentId, model, thinkingDepth } = payload; - if (!sessionId || !agentId) return; - - const result = pi.setAgentModel(sessionId, agentId, { model, thinkingDepth }); - if (!result) return; - - persistAndBroadcastSelection(io, store, sessionId, result); -} - -/** Persists an agent's new selection and broadcasts it (Prime only). */ -function persistAndBroadcastSelection( - io: Server, - store: SessionStore, - sessionId: string, - result: NonNullable>, -): void { - void store.recordAgent(sessionId, { - id: result.info.id, - role: result.role, - name: result.info.name, - status: "active", - model: result.info.model, - thinkingDepth: result.info.thinkingDepth, - template: result.info.template, - }); - - // Sub-agent changes already broadcast via the roster-update handler fired - // inside setAgentModel; Prime has no roster entry, so emit its own event. - if (result.role !== "prime") return; - const out: AgentModelPayload = { - sessionId, - agentId: result.info.id, - model: result.info.model, - thinkingDepth: result.info.thinkingDepth, - }; - io.to(roomFor(sessionId)).emit(SocketEvents.AgentModel, out); -} - -/** Tells Prime a suggestion was declined; nothing is written. */ -function handleMemoryDismiss( - pi: PiAgentManager, - memory: MemoryManager, - payload: MemoryDismissPayload, -): void { - const suggestion = memory.takeSuggestion(payload?.suggestionId); - if (!suggestion || suggestion.sessionId !== payload.sessionId) return; - pi.sendToAgent( - suggestion.sessionId, - PRIME_AGENT_ID, - `The user declined to remember: "${suggestion.text}". Do not store it.`, - ); +/** Shared dependencies wired into every connected socket's chat handlers. */ +export interface ChatHandlerDeps { + io: Server; + store: SessionStore; + pi: PiAgentManager; + connectors: ConnectorRegistry; + conversations: ConversationRouter; + memory: MemoryManager; + onRemembered: MemoryRememberedHandler; + triggerEngine: TriggerEngine; + emitUiCommand: UiCommandEmitter; } /** @@ -600,26 +83,6 @@ function handleAgentAbort( console.log(`[runs] cancel refused for ${participantId}: ${reason}`); } -/** - * Registers chat (and a reserved terminal) handlers on the Socket.IO server. - * - * Phase 2 behaviour: clients join one room per session and receive history on - * join. Each session is backed by a long-lived Pi agent process; posted - * messages are broadcast to the room and relayed into the session's Pi - * process, whose reply is streamed back via the agent:* events. - */ -/** Shared dependencies wired into every connected socket's chat handlers. */ -interface ChatHandlerDeps { - io: Server; - store: SessionStore; - pi: PiAgentManager; - connectors: ConnectorRegistry; - 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, connectors, memory } = deps; @@ -634,7 +97,7 @@ function wireSocket(socket: Socket, deps: ChatHandlerDeps): void { ); socket.on(SocketEvents.ChatMessage, (payload: ChatMessagePayload) => - handleChatMessage(io, socket, store, pi, connectors, author, payload), + handleChatMessage(socket, deps, author, payload), ); socket.on(SocketEvents.AgentAbort, (payload: AgentAbortPayload) => @@ -646,11 +109,11 @@ function wireSocket(socket: Socket, deps: ChatHandlerDeps): void { ); socket.on(SocketEvents.MemoryConfirm, (payload: MemoryConfirmPayload) => - handleMemoryConfirm(store, pi, memory, onRemembered, payload), + handleMemoryConfirm(store, connectors, memory, onRemembered, payload), ); socket.on(SocketEvents.MemoryDismiss, (payload: MemoryDismissPayload) => - handleMemoryDismiss(pi, memory, payload), + handleMemoryDismiss(connectors, memory, payload), ); socket.on(SocketEvents.ArtifactPin, (payload: ArtifactPinPayload) => @@ -674,116 +137,15 @@ function wireSocket(socket: Socket, deps: ChatHandlerDeps): void { }); } -export function registerChatHandlers( - io: Server, - store: SessionStore, - pi: PiAgentManager, - connectors: ConnectorRegistry, - memory: MemoryManager, - onRemembered: MemoryRememberedHandler, - triggerEngine: TriggerEngine, - emitUiCommand: UiCommandEmitter, -): void { - const deps: ChatHandlerDeps = { - io, - store, - pi, - connectors, - 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, - sessionId: string, -): Promise<{ model?: string; thinkingDepth?: ThinkingLevel } | undefined> { - const agents = await store.listAgents(sessionId); - const prime = agents.find((a) => a.id === PRIME_AGENT_ID); - if (!prime) return undefined; - return { - model: prime.model, - thinkingDepth: parseThinkingLevel(prime.thinkingDepth), - }; -} - -/** - * Replays each live agent's current run-level activity to the joining socket, so - * a client reconnecting mid-run sees the in-progress tool call / "thinking" - * indicator and bubble instead of them going blank until the next event. - */ -function replayAgentActivities( - socket: Socket, - pi: PiAgentManager, - sessionId: string, -): void { - for (const { conversationId, activity } of pi.listActivities(sessionId)) { - const payload: AgentActivityPayload = { - sessionId, - conversationId, - activity, - }; - socket.emit(SocketEvents.AgentActivity, payload); - } -} - -/** Emits Prime's current resolved model/thinking to the joining socket. */ -function emitPrimeSelection( - socket: Socket, - pi: PiAgentManager, - sessionId: string, -): void { - const selection = pi.getAgentSelection(sessionId, PRIME_AGENT_ID); - const payload: AgentModelPayload = { - sessionId, - agentId: PRIME_AGENT_ID, - model: selection?.model, - thinkingDepth: selection?.thinkingDepth, - }; - socket.emit(SocketEvents.AgentModel, payload); -} - /** - * Joins the shared sessions lobby and replies with the current status snapshot, - * so a list view reflects every session's run status immediately and stays live - * via later `session:status` broadcasts. Sessions absent from the snapshot are - * `idle`. - */ -async function handleSessionStatusSubscribe( - socket: Socket, - pi: PiAgentManager, -): Promise { - await socket.join(SESSIONS_LOBBY); - const payload: SessionStatusSnapshotPayload = { statuses: pi.getStatuses() }; - socket.emit(SocketEvents.SessionStatusSnapshot, payload); -} - -/** - * (Re)spawns the session's Prime — restoring any persisted model/thinking - * selection — and revives its persisted sub-agents through their own connectors, - * so a restart restores the full agent set (not just Prime). Idempotent: agents - * already live are skipped. + * Registers chat (and a reserved terminal) handlers on the Socket.IO server. + * + * Clients join one room per session and receive history on join. Each session is + * backed by a long-lived Pi agent process; a posted message is persisted, + * broadcast to the room, and delivered to whoever reacts to it. */ -async function ensureSessionAgents( - store: SessionStore, - pi: PiAgentManager, - connectors: ConnectorRegistry, - 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); - connectors.revive(session.id, persistedAgents); +export function registerChatHandlers(deps: ChatHandlerDeps): void { + deps.io.on("connection", (socket: Socket) => wireSocket(socket, deps)); } /** Joins the session room, then replays history and the sub-agent roster. */ @@ -801,8 +163,7 @@ async function handleChatJoin( return; } - const room = roomFor(session.id); - await socket.join(room); + await socket.join(roomFor(session.id)); // Lazily (re)spawn Prime and revive the session's sub-agents in case the // server restarted or the session predates the process manager. @@ -835,74 +196,6 @@ async function handleChatJoin( await replayArtifacts(socket, store, session.id); } -/** - * Surfaces the session's current pinned-artifact list to just the joining - * socket, using the same `artifacts.update` directive that broadcasts later - * mutations. - */ -async function replayArtifacts( - socket: Socket, - store: SessionStore, - sessionId: string, -): Promise { - const artifacts = await store.getArtifacts(sessionId); - const payload: UiCommandPayload = { - sessionId, - command: { kind: "artifacts.update", artifacts }, - }; - socket.emit(SocketEvents.UiCommand, payload); -} - -/** A validated artifact reference extracted from a pin/unpin payload. */ -interface ArtifactRef { - sessionId: string; - path: string; -} - -/** Validates a pin/unpin payload, returning a trimmed ref or null if invalid. */ -function readArtifactRef(payload?: { - sessionId?: string; - path?: string; -}): ArtifactRef | null { - if (!payload) return null; - const path = payload.path?.trim(); - if (!payload.sessionId || !path) return null; - return { sessionId: payload.sessionId, path }; -} - -/** Pins an artifact, then broadcasts the updated list to the session room. */ -async function handleArtifactPin( - store: SessionStore, - emitUiCommand: UiCommandEmitter, - payload: ArtifactPinPayload, -): Promise { - const ref = readArtifactRef(payload); - if (!ref) return; - const session = await store.getSession(ref.sessionId); - if (!session) return; - - const artifacts = await store.pinArtifact(session.id, { - path: ref.path, - title: payload.title?.trim() || ref.path, - }); - emitUiCommand(session.id, { kind: "artifacts.update", artifacts }); -} - -/** Unpins an artifact, then broadcasts the updated list to the session room. */ -async function handleArtifactUnpin( - store: SessionStore, - emitUiCommand: UiCommandEmitter, - payload: ArtifactUnpinPayload, -): Promise { - const ref = readArtifactRef(payload); - if (!ref) return; - const session = await store.getSession(ref.sessionId); - if (!session) return; - - const artifacts = await store.unpinArtifact(session.id, ref.path); - emitUiCommand(session.id, { kind: "artifacts.update", artifacts }); -} - /** * Who a message in this session can address: Prime plus every sub-agent any * connector holds. Names come from the live roster, so a mention resolves @@ -931,37 +224,37 @@ export function resolveSocketAuthor(cookieHeader: string | undefined) { return humanAuthor(resolveUserIdentity(cookieHeader) ?? DEFAULT_USER); } -/** Persists + broadcasts a human message and relays it into the Pi process. */ +/** + * Posts a human message into the conversation it was typed in. Whoever reacts to + * it runs: Prime because a human talking in its thread is what it reacts to, a + * sub-agent because the message landed in its own thread. No branch on which + * conversation it was — that was the hardcoded routing this replaces. + * + * `pi.ensure` stays because it is lifecycle, not delivery: a cold session has no + * Prime process for a reaction to reach. + */ async function handleChatMessage( - io: Server, socket: Socket, - store: SessionStore, - pi: PiAgentManager, - connectors: ConnectorRegistry, + deps: ChatHandlerDeps, author: ChatAuthor, payload: ChatMessagePayload, ): Promise { + const { store, pi, connectors, conversations } = deps; const session = await store.getSession(payload?.sessionId); if (!session) { socket.emit("error", { message: "Session not found" }); return; } - const room = roomFor(session.id); - // Target agent thread: Prime by default, or a specific sub-agent so users can - // steer it from its own tab. + // Target thread: Prime by default, or a specific sub-agent so users can steer + // it from its own tab. const conversationId = payload.conversationId ?? PRIME_AGENT_ID; - const delivery = payload.delivery ?? "auto"; - - // Broadcast the user's own message to the room (including the sender, so it - // renders without optimistic updates and other participants see it). Tagged - // with the target conversation so it lands in the right thread. The author is - // the socket's resolved identity, never the payload's claim. - const userMessage = buildMessage({ - id: randomUUID(), + if (conversationId === PRIME_AGENT_ID) + pi.ensure(session.id, session.rootPath); + + await conversations.post({ sessionId: session.id, conversationId, - seq: await store.nextSeq(session.id, conversationId), author, content: payload.content, mentions: resolveMentions( @@ -969,25 +262,6 @@ async function handleChatMessage( mentionCandidates(connectors, session.id), ), attachments: payload.attachments, - }); - await store.appendMessage(userMessage); - io.to(room).emit(SocketEvents.ChatMessage, userMessage); - - // Relay the message to the conversation's participant, surfacing any attached - // files by their workspace-relative path so the agent knows to read them. The - // reply streams back asynchronously through the agent event handler. - // `delivery` controls whether a mid-run message steers (before the next LLM - // call) or queues as a follow-up. The message is already persisted/broadcast - // above, so sub-agent sends omit `surfaceAuthor` to avoid a duplicate bubble. - const text = promptWithAttachments(payload.content, payload.attachments); - if (conversationId === PRIME_AGENT_ID) { - pi.prompt(session.id, session.rootPath, text, delivery); - return; - } - connectors.resolve(session.id, conversationId).deliver({ - sessionId: session.id, - participantId: conversationId, - text, - delivery, + delivery: payload.delivery ?? "auto", }); } diff --git a/apps/server/src/sockets/chatArtifacts.ts b/apps/server/src/sockets/chatArtifacts.ts new file mode 100644 index 0000000..42c66e3 --- /dev/null +++ b/apps/server/src/sockets/chatArtifacts.ts @@ -0,0 +1,78 @@ +import { + type ArtifactPinPayload, + type ArtifactUnpinPayload, + SocketEvents, + type UiCommandPayload, +} from "@tangent/shared/contracts.ts"; +import type { Socket } from "socket.io"; + +import type { SessionStore } from "../store/sessionStore.ts"; +import type { UiCommandEmitter } from "./sessionRoster.ts"; + +/** A validated artifact reference extracted from a pin/unpin payload. */ +interface ArtifactRef { + sessionId: string; + path: string; +} + +/** Validates a pin/unpin payload, returning a trimmed ref or null if invalid. */ +function readArtifactRef(payload?: { + sessionId?: string; + path?: string; +}): ArtifactRef | null { + if (!payload) return null; + const path = payload.path?.trim(); + if (!payload.sessionId || !path) return null; + return { sessionId: payload.sessionId, path }; +} + +/** + * Surfaces the session's current pinned-artifact list to just the joining + * socket, using the same `artifacts.update` directive that broadcasts later + * mutations. + */ +export async function replayArtifacts( + socket: Socket, + store: SessionStore, + sessionId: string, +): Promise { + const artifacts = await store.getArtifacts(sessionId); + const payload: UiCommandPayload = { + sessionId, + command: { kind: "artifacts.update", artifacts }, + }; + socket.emit(SocketEvents.UiCommand, payload); +} + +/** Pins an artifact, then broadcasts the updated list to the session room. */ +export async function handleArtifactPin( + store: SessionStore, + emitUiCommand: UiCommandEmitter, + payload: ArtifactPinPayload, +): Promise { + const ref = readArtifactRef(payload); + if (!ref) return; + const session = await store.getSession(ref.sessionId); + if (!session) return; + + const artifacts = await store.pinArtifact(session.id, { + path: ref.path, + title: payload.title?.trim() || ref.path, + }); + emitUiCommand(session.id, { kind: "artifacts.update", artifacts }); +} + +/** Unpins an artifact, then broadcasts the updated list to the session room. */ +export async function handleArtifactUnpin( + store: SessionStore, + emitUiCommand: UiCommandEmitter, + payload: ArtifactUnpinPayload, +): Promise { + const ref = readArtifactRef(payload); + if (!ref) return; + const session = await store.getSession(ref.sessionId); + if (!session) return; + + const artifacts = await store.unpinArtifact(session.id, ref.path); + emitUiCommand(session.id, { kind: "artifacts.update", artifacts }); +} diff --git a/apps/server/src/sockets/chatMemory.ts b/apps/server/src/sockets/chatMemory.ts new file mode 100644 index 0000000..b9f2c60 --- /dev/null +++ b/apps/server/src/sockets/chatMemory.ts @@ -0,0 +1,109 @@ +import { + MEMORY_AUTHOR, + type MemoryConfirmPayload, + type MemoryDismissPayload, + type MemoryScope, + type MemorySuggestionPayload, + SocketEvents, +} from "@tangent/shared/contracts.ts"; +import type { Server } from "socket.io"; + +import type { ConnectorRegistry } from "../connectors/connectorRegistry.ts"; +import type { ConversationRouter } from "../conversation/conversationRouter.ts"; +import type { MemoryManager } from "../pi/memory.ts"; +import { PRIME_AGENT_ID } from "../pi/types.ts"; +import type { SessionStore } from "../store/sessionStore.ts"; +import { roomFor } from "./rooms.ts"; + +/** + * Handler that surfaces an applied memory write as a highlighted, persisted + * chat message. Built from the actual stored text (not the agent's claim) so + * the user always sees ground truth. + */ +export type MemoryRememberedHandler = ( + sessionId: string, + scope: MemoryScope, + text: string, +) => Promise; + +/** Builds the {@link MemoryRememberedHandler} bound to the conversation router. */ +export function createMemoryRememberedHandler( + conversations: ConversationRouter, +): MemoryRememberedHandler { + return async (sessionId, scope, text) => { + await conversations.post({ + sessionId, + conversationId: PRIME_AGENT_ID, + author: MEMORY_AUTHOR, + content: text, + memory: { scope }, + }); + }; +} + +/** Emits a memory suggestion card into the session room. */ +export type MemorySuggestionHandler = ( + payload: MemorySuggestionPayload, +) => void; + +/** Builds the {@link MemorySuggestionHandler} bound to the room. */ +export function createMemorySuggestionHandler( + io: Server, +): MemorySuggestionHandler { + return (payload) => { + io.to(roomFor(payload.sessionId)).emit( + SocketEvents.MemorySuggestion, + payload, + ); + }; +} + +/** + * Applies a confirmed suggestion: writes it to the resolved store, surfaces the + * highlight, and tells Prime the user approved so it can continue honestly. + * + * The confirmation itself is delivered rather than posted: the user already sees + * the highlight, and a second bubble saying they clicked "yes" is noise. + */ +export async function handleMemoryConfirm( + store: SessionStore, + connectors: ConnectorRegistry, + memory: MemoryManager, + onRemembered: MemoryRememberedHandler, + payload: MemoryConfirmPayload, +): Promise { + const suggestion = memory.takeSuggestion(payload?.suggestionId); + if (!suggestion || suggestion.sessionId !== payload.sessionId) return; + + const session = await store.getSession(suggestion.sessionId); + if (!session) return; + + const result = memory.write( + session.rootPath, + suggestion.scope, + suggestion.text, + ); + await onRemembered(suggestion.sessionId, result.scope, result.added); + connectors.resolve(suggestion.sessionId, PRIME_AGENT_ID).deliver({ + sessionId: suggestion.sessionId, + participantId: PRIME_AGENT_ID, + text: + `The user confirmed your suggestion. It has been stored to ${result.scope} ` + + `memory: "${result.added}".`, + }); +} + +/** Tells Prime a suggestion was declined; nothing is written. */ +export function handleMemoryDismiss( + connectors: ConnectorRegistry, + memory: MemoryManager, + payload: MemoryDismissPayload, +): void { + const suggestion = memory.takeSuggestion(payload?.suggestionId); + if (!suggestion || suggestion.sessionId !== payload.sessionId) return; + connectors.resolve(suggestion.sessionId, PRIME_AGENT_ID).deliver({ + sessionId: suggestion.sessionId, + participantId: PRIME_AGENT_ID, + text: `The user declined to remember: "${suggestion.text}". Do not store it.`, + }); +} diff --git a/apps/server/src/sockets/rooms.ts b/apps/server/src/sockets/rooms.ts new file mode 100644 index 0000000..124c290 --- /dev/null +++ b/apps/server/src/sockets/rooms.ts @@ -0,0 +1,11 @@ +/** The Socket.IO room every client viewing one session joins. */ +export function roomFor(sessionId: string): string { + return `session:${sessionId}`; +} + +/** + * Shared room every client viewing a session list (the switcher, the sessions + * table) joins to receive live run-status updates for all sessions at once, + * without subscribing to each session's individual room. + */ +export const SESSIONS_LOBBY = "sessions:lobby"; diff --git a/apps/server/src/sockets/sessionRoster.ts b/apps/server/src/sockets/sessionRoster.ts new file mode 100644 index 0000000..e3b198b --- /dev/null +++ b/apps/server/src/sockets/sessionRoster.ts @@ -0,0 +1,175 @@ +import { + type AgentActivityPayload, + type AgentModelPayload, + type AgentSetModelPayload, + type Session, + type SessionStatusSnapshotPayload, + SocketEvents, + type ThinkingLevel, + type UiCommand, + type UiCommandPayload, +} from "@tangent/shared/contracts.ts"; +import type { Server, Socket } from "socket.io"; + +import type { ConnectorRegistry } from "../connectors/connectorRegistry.ts"; +import { parseThinkingLevel } from "../pi/agentConfig.ts"; +import { type PiAgentManager, PRIME_AGENT_ID } from "../pi/piAgentManager.ts"; +import type { SessionStore } from "../store/sessionStore.ts"; +import { roomFor, SESSIONS_LOBBY } from "./rooms.ts"; + +/** + * Pushes a generic agent->UI directive into a session room. This is the single + * transport every UI-affecting feature shares: callers build a {@link UiCommand} + * variant (e.g. `session.update`) and this broadcasts it; clients dispatch by + * `command.kind` and ignore kinds they don't recognize. + */ +export type UiCommandEmitter = (sessionId: string, command: UiCommand) => void; + +/** Builds the {@link UiCommandEmitter} bound to the Socket.IO server. */ +export function createUiCommandEmitter(io: Server): UiCommandEmitter { + return (sessionId, command) => { + const payload: UiCommandPayload = { sessionId, command }; + io.to(roomFor(sessionId)).emit(SocketEvents.UiCommand, payload); + }; +} + +/** + * Applies a human-requested model/thinking change: respawns the target agent's + * Pi process with the new settings, persists the selection so it survives a + * restart, and surfaces it. Sub-agent changes ride the roster-update handler + * (fired inside {@link PiAgentManager.setAgentModel}); Prime's change is + * broadcast here via the dedicated `agent:model` event. + */ +export function handleAgentSetModel( + io: Server, + store: SessionStore, + pi: PiAgentManager, + payload: AgentSetModelPayload, +): void { + if (!payload) return; + const { sessionId, agentId, model, thinkingDepth } = payload; + if (!sessionId || !agentId) return; + + const result = pi.setAgentModel(sessionId, agentId, { model, thinkingDepth }); + if (!result) return; + + persistAndBroadcastSelection(io, store, sessionId, result); +} + +/** Persists an agent's new selection and broadcasts it (Prime only). */ +function persistAndBroadcastSelection( + io: Server, + store: SessionStore, + sessionId: string, + result: NonNullable>, +): void { + void store.recordAgent(sessionId, { + id: result.info.id, + role: result.role, + name: result.info.name, + status: "active", + model: result.info.model, + thinkingDepth: result.info.thinkingDepth, + template: result.info.template, + }); + + // Sub-agent changes already broadcast via the roster-update handler fired + // inside setAgentModel; Prime has no roster entry, so emit its own event. + if (result.role !== "prime") return; + const out: AgentModelPayload = { + sessionId, + agentId: result.info.id, + model: result.info.model, + thinkingDepth: result.info.thinkingDepth, + }; + io.to(roomFor(sessionId)).emit(SocketEvents.AgentModel, out); +} + +/** Reads Prime's persisted model/thinking selection, parsing the stored depth. */ +async function loadPrimeOverride( + store: SessionStore, + sessionId: string, +): Promise<{ model?: string; thinkingDepth?: ThinkingLevel } | undefined> { + const agents = await store.listAgents(sessionId); + const prime = agents.find((agent) => agent.id === PRIME_AGENT_ID); + if (!prime) return undefined; + return { + model: prime.model, + thinkingDepth: parseThinkingLevel(prime.thinkingDepth), + }; +} + +/** + * (Re)spawns the session's Prime — restoring any persisted model/thinking + * selection — and revives its persisted sub-agents through their own connectors, + * so a restart restores the full agent set (not just Prime). Idempotent: agents + * already live are skipped. + */ +export async function ensureSessionAgents( + store: SessionStore, + pi: PiAgentManager, + connectors: ConnectorRegistry, + 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); + connectors.revive(session.id, persistedAgents); +} + +/** + * Replays each live agent's current run-level activity to the joining socket, so + * a client reconnecting mid-run sees the in-progress tool call / "thinking" + * indicator and bubble instead of them going blank until the next event. + */ +export function replayAgentActivities( + socket: Socket, + pi: PiAgentManager, + sessionId: string, +): void { + for (const { conversationId, activity } of pi.listActivities(sessionId)) { + const payload: AgentActivityPayload = { + sessionId, + conversationId, + activity, + }; + socket.emit(SocketEvents.AgentActivity, payload); + } +} + +/** Emits Prime's current resolved model/thinking to the joining socket. */ +export function emitPrimeSelection( + socket: Socket, + pi: PiAgentManager, + sessionId: string, +): void { + const selection = pi.getAgentSelection(sessionId, PRIME_AGENT_ID); + const payload: AgentModelPayload = { + sessionId, + agentId: PRIME_AGENT_ID, + model: selection?.model, + thinkingDepth: selection?.thinkingDepth, + }; + socket.emit(SocketEvents.AgentModel, payload); +} + +/** + * Joins the shared sessions lobby and replies with the current status snapshot, + * so a list view reflects every session's run status immediately and stays live + * via later `session:status` broadcasts. Sessions absent from the snapshot are + * `idle`. + */ +export async function handleSessionStatusSubscribe( + socket: Socket, + pi: PiAgentManager, +): Promise { + await socket.join(SESSIONS_LOBBY); + const payload: SessionStatusSnapshotPayload = { statuses: pi.getStatuses() }; + socket.emit(SocketEvents.SessionStatusSnapshot, payload); +} diff --git a/apps/server/src/store/db/migrations/0009_exotic_micromax.sql b/apps/server/src/store/db/migrations/0009_exotic_micromax.sql new file mode 100644 index 0000000..b2d695c --- /dev/null +++ b/apps/server/src/store/db/migrations/0009_exotic_micromax.sql @@ -0,0 +1,27 @@ +CREATE TABLE `memberships` ( + `participant_id` text NOT NULL, + `conversation_id` text NOT NULL, + `session_id` text NOT NULL, + `reaction` text DEFAULT 'never' NOT NULL, + `ingress` text DEFAULT 'reaction' NOT NULL, + `transcript_visibility` text DEFAULT 'shared' NOT NULL, + `created_at` text NOT NULL, + FOREIGN KEY (`session_id`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `memberships_session_conversation_idx` ON `memberships` (`session_id`,`conversation_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `memberships_session_conversation_participant` ON `memberships` (`session_id`,`conversation_id`,`participant_id`);--> statement-breakpoint +INSERT OR IGNORE INTO `memberships` (`participant_id`, `conversation_id`, `session_id`, `reaction`, `ingress`, `transcript_visibility`, `created_at`) +SELECT `id`, `id`, `session_id`, + CASE WHEN `connector_kind` = 'external-inbound' OR (`connector_kind` IS NULL AND `host` = 'external') THEN 'never' ELSE 'fromHumans+mentionsMe' END, + 'reaction', + CASE WHEN `connector_kind` = 'external-inbound' OR (`connector_kind` IS NULL AND `host` = 'external') THEN 'opaque' ELSE 'shared' END, + `created_at` +FROM `session_agents` WHERE `role` = 'subagent';--> statement-breakpoint +INSERT OR IGNORE INTO `memberships` (`participant_id`, `conversation_id`, `session_id`, `reaction`, `ingress`, `transcript_visibility`, `created_at`) +SELECT 'prime', `id`, `session_id`, + CASE WHEN `auto_relay_to_prime` = 1 THEN 'atRunEnd+mentionsMe' ELSE 'mentionsMe' END, + 'reaction', 'shared', `created_at` +FROM `session_agents` WHERE `role` = 'subagent';--> statement-breakpoint +INSERT OR IGNORE INTO `memberships` (`participant_id`, `conversation_id`, `session_id`, `reaction`, `ingress`, `transcript_visibility`, `created_at`) +SELECT 'prime', 'prime', `id`, 'fromHumans+mentionsMe', 'reaction', 'shared', `created_at` FROM `sessions`; \ No newline at end of file diff --git a/apps/server/src/store/db/migrations/meta/0009_snapshot.json b/apps/server/src/store/db/migrations/meta/0009_snapshot.json new file mode 100644 index 0000000..3038d6c --- /dev/null +++ b/apps/server/src/store/db/migrations/meta/0009_snapshot.json @@ -0,0 +1,616 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "5774c697-0371-4863-a7b4-5b958bbdb438", + "prevId": "3089ff01-711d-4a01-89f9-02a03b0d23fd", + "tables": { + "conversations": { + "name": "conversations", + "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 + }, + "next_seq": { + "name": "next_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "conversations_session_idx": { + "name": "conversations_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "conversations_session_id": { + "name": "conversations_session_id", + "columns": ["session_id", "id"], + "isUnique": true + } + }, + "foreignKeys": { + "conversations_session_id_sessions_id_fk": { + "name": "conversations_session_id_sessions_id_fk", + "tableFrom": "conversations", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "memberships": { + "name": "memberships", + "columns": { + "participant_id": { + "name": "participant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reaction": { + "name": "reaction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'never'" + }, + "ingress": { + "name": "ingress", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'reaction'" + }, + "transcript_visibility": { + "name": "transcript_visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'shared'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "memberships_session_conversation_idx": { + "name": "memberships_session_conversation_idx", + "columns": ["session_id", "conversation_id"], + "isUnique": false + }, + "memberships_session_conversation_participant": { + "name": "memberships_session_conversation_participant", + "columns": ["session_id", "conversation_id", "participant_id"], + "isUnique": true + } + }, + "foreignKeys": { + "memberships_session_id_sessions_id_fk": { + "name": "memberships_session_id_sessions_id_fk", + "tableFrom": "memberships", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "runs": { + "name": "runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "participant_id": { + "name": "participant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "home_conversation_id": { + "name": "home_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "ingress": { + "name": "ingress", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": 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 + }, + "ended_at": { + "name": "ended_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "runs_session_idx": { + "name": "runs_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "runs_session_participant_idx": { + "name": "runs_session_participant_idx", + "columns": ["session_id", "participant_id"], + "isUnique": false + } + }, + "foreignKeys": { + "runs_session_id_sessions_id_fk": { + "name": "runs_session_id_sessions_id_fk", + "tableFrom": "runs", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "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'" + }, + "connector_kind": { + "name": "connector_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_lifecycle": { + "name": "connector_lifecycle", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_environment_id": { + "name": "connector_environment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "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 b363c96..9dffa68 100644 --- a/apps/server/src/store/db/migrations/meta/_journal.json +++ b/apps/server/src/store/db/migrations/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1786481948836, "tag": "0008_special_quasimodo", "breakpoints": true + }, + { + "idx": 9, + "version": "6", + "when": 1786488898589, + "tag": "0009_exotic_micromax", + "breakpoints": true } ] } diff --git a/apps/server/src/store/db/schema.ts b/apps/server/src/store/db/schema.ts index f9dc98e..5b7979f 100644 --- a/apps/server/src/store/db/schema.ts +++ b/apps/server/src/store/db/schema.ts @@ -192,6 +192,48 @@ export const conversations = sqliteTable( ], ); +/** + * A participant's standing in one Conversation: whether it reacts to what is + * posted there, what work arriving through it counts as, and how much of the + * transcript it sees. The successor to `session_agents.auto_relay_to_prime`, + * which was a one-bit approximation of the reaction predicate. + * + * `session_id` is here because a participant or conversation id is only unique + * within a session. + */ +export const memberships = sqliteTable( + "memberships", + { + /** The participant that holds the membership (an agent id today). */ + participantId: text("participant_id").notNull(), + /** The conversation it is a member of (an agent id today). */ + conversationId: text("conversation_id").notNull(), + sessionId: text("session_id") + .notNull() + .references(() => sessions.id, { onDelete: "cascade" }), + /** A `ReactionSpec`: `+`-joined preset names, read as a disjunction. */ + reaction: text("reaction").notNull().default("never"), + /** `reaction` | `schedule` | `webhook` | `tool`. */ + ingress: text("ingress").notNull().default("reaction"), + /** `shared` | `summarized` | `opaque`. */ + transcriptVisibility: text("transcript_visibility") + .notNull() + .default("shared"), + createdAt: text("created_at").notNull(), + }, + (table) => [ + unique("memberships_session_conversation_participant").on( + table.sessionId, + table.conversationId, + table.participantId, + ), + index("memberships_session_conversation_idx").on( + table.sessionId, + table.conversationId, + ), + ], +); + /** When each user last opened a session. `user_key` is the email, or `local`. */ export const sessionViews = sqliteTable( "session_views", @@ -213,3 +255,4 @@ export type SessionAssetRow = typeof sessionAssets.$inferSelect; export type SessionAgentRow = typeof sessionAgents.$inferSelect; export type RunRow = typeof runs.$inferSelect; export type ConversationRow = typeof conversations.$inferSelect; +export type MembershipRow = typeof memberships.$inferSelect; diff --git a/apps/server/src/store/inMemoryMembershipStore.ts b/apps/server/src/store/inMemoryMembershipStore.ts new file mode 100644 index 0000000..4ea9c3c --- /dev/null +++ b/apps/server/src/store/inMemoryMembershipStore.ts @@ -0,0 +1,26 @@ +import type { Membership, MembershipStore } from "./membershipStore.ts"; + +/** Key of one membership, matching the table's uniqueness. */ +function keyFor(membership: Membership): string { + const { sessionId, conversationId, participantId } = membership; + return `${sessionId}\u0000${conversationId}\u0000${participantId}`; +} + +/** + * Process-local {@link MembershipStore}, mirroring + * {@link import("./inMemoryRunStore.ts").InMemoryRunStore}. For tests and for + * wiring a membership registry that has no DB to write to. + */ +export class InMemoryMembershipStore implements MembershipStore { + private readonly memberships = new Map(); + + async listForSession(sessionId: string): Promise { + return [...this.memberships.values()].filter( + (membership) => membership.sessionId === sessionId, + ); + } + + async put(membership: Membership): Promise { + this.memberships.set(keyFor(membership), membership); + } +} diff --git a/apps/server/src/store/membershipStore.ts b/apps/server/src/store/membershipStore.ts new file mode 100644 index 0000000..e40d420 --- /dev/null +++ b/apps/server/src/store/membershipStore.ts @@ -0,0 +1,33 @@ +import type { + ReactionSpec, + RunIngress, + TranscriptVisibility, +} from "@tangent/shared/contracts.ts"; + +/** + * A participant's standing in one Conversation. `reaction` decides whether it is + * woken by what lands there, `ingress` classifies the work that arrives through + * this membership when nothing more specific created it, and + * `transcriptVisibility` says how much of the Conversation it may see. + */ +export interface Membership { + sessionId: string; + participantId: string; + conversationId: string; + reaction: ReactionSpec; + ingress: RunIngress; + transcriptVisibility: TranscriptVisibility; +} + +/** + * Durable home of the session's {@link Membership}s. Kept apart from + * {@link import("./sessionStore.ts").SessionStore} for the same reason + * {@link import("./runStore.ts").RunStore} is: it is read by one registry on the + * delivery path, not by the REST routes. + */ +export interface MembershipStore { + /** Every membership in a session, so a registry can seed one lookup. */ + listForSession(sessionId: string): Promise; + /** Upserts by `(sessionId, conversationId, participantId)`. */ + put(membership: Membership): Promise; +} diff --git a/apps/server/src/store/sqliteMembershipStore.test.ts b/apps/server/src/store/sqliteMembershipStore.test.ts new file mode 100644 index 0000000..d51ab52 --- /dev/null +++ b/apps/server/src/store/sqliteMembershipStore.test.ts @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { after, test } from "node:test"; + +import type { Membership } from "./membershipStore.ts"; + +// Point the session root at a throwaway dir before importing modules that read +// config at load time. +const ROOT = mkdtempSync(path.join(tmpdir(), "membership-store-")); +process.env.SESSIONS_ROOT = ROOT; + +const { openDb } = await import("./db/client.ts"); +const { SqliteSessionStore } = await import("./sqliteSessionStore.ts"); +const { SqliteMembershipStore } = await import("./sqliteMembershipStore.ts"); + +after(() => rmSync(ROOT, { recursive: true, force: true })); + +/** A membership store plus the session store its rows reference. */ +async function newStore() { + const db = openDb(":memory:"); + const sessions = new SqliteSessionStore(db); + const session = await sessions.createSession({ name: "S" }); + return { store: new SqliteMembershipStore(db), sessionId: session.id }; +} + +function membership(overrides: Partial & { sessionId: string }) { + return { + participantId: "prime", + conversationId: "sub-1", + reaction: "atRunEnd+mentionsMe", + ingress: "reaction", + transcriptVisibility: "shared", + ...overrides, + } satisfies Membership; +} + +test("a membership round-trips through the table", async () => { + const { store, sessionId } = await newStore(); + const row = membership({ sessionId }); + + await store.put(row); + + assert.deepEqual(await store.listForSession(sessionId), [row]); +}); + +test("put upserts on (session, conversation, participant)", async () => { + const { store, sessionId } = await newStore(); + await store.put(membership({ sessionId })); + + await store.put(membership({ sessionId, reaction: "never" })); + + const rows = await store.listForSession(sessionId); + assert.equal(rows.length, 1, "re-declaring a membership edits it"); + assert.equal(rows[0].reaction, "never"); +}); + +test("one participant holds a membership per conversation", async () => { + const { store, sessionId } = await newStore(); + + await store.put(membership({ sessionId, conversationId: "prime" })); + await store.put(membership({ sessionId, conversationId: "sub-1" })); + + const rows = await store.listForSession(sessionId); + assert.deepEqual(rows.map((r) => r.conversationId).sort(), [ + "prime", + "sub-1", + ]); +}); + +test("memberships are scoped to their session", async () => { + const { store, sessionId } = await newStore(); + await store.put(membership({ sessionId })); + + // A participant id is only unique within a session, which is why the row + // carries one at all. + assert.deepEqual(await store.listForSession("other-session"), []); +}); + +test("deleting a session takes its memberships with it", async () => { + const db = openDb(":memory:"); + const sessions = new SqliteSessionStore(db); + const store = new SqliteMembershipStore(db); + const session = await sessions.createSession({ name: "S" }); + await store.put(membership({ sessionId: session.id })); + + await sessions.deleteSession(session.id); + + assert.deepEqual(await store.listForSession(session.id), []); +}); diff --git a/apps/server/src/store/sqliteMembershipStore.ts b/apps/server/src/store/sqliteMembershipStore.ts new file mode 100644 index 0000000..d4a9f74 --- /dev/null +++ b/apps/server/src/store/sqliteMembershipStore.ts @@ -0,0 +1,64 @@ +import type { + RunIngress, + TranscriptVisibility, +} from "@tangent/shared/contracts.ts"; +import { asc, eq } from "drizzle-orm"; + +import type { Db } from "./db/client.ts"; +import { type MembershipRow, memberships } from "./db/schema.ts"; +import type { Membership, MembershipStore } from "./membershipStore.ts"; + +/** Maps a memberships row onto the domain {@link Membership}. */ +function toMembership(row: MembershipRow): Membership { + return { + sessionId: row.sessionId, + participantId: row.participantId, + conversationId: row.conversationId, + reaction: row.reaction, + ingress: row.ingress as RunIngress, + transcriptVisibility: row.transcriptVisibility as TranscriptVisibility, + }; +} + +/** SQLite-backed {@link MembershipStore} over the shared session metadata DB. */ +export class SqliteMembershipStore implements MembershipStore { + private readonly db: Db; + + constructor(db: Db) { + this.db = db; + } + + async listForSession(sessionId: string): Promise { + const rows = this.db + .select() + .from(memberships) + .where(eq(memberships.sessionId, sessionId)) + .orderBy(asc(memberships.createdAt)) + .all(); + return rows.map(toMembership); + } + + async put(membership: Membership): Promise { + const { reaction, ingress, transcriptVisibility } = membership; + this.db + .insert(memberships) + .values({ + participantId: membership.participantId, + conversationId: membership.conversationId, + sessionId: membership.sessionId, + reaction, + ingress, + transcriptVisibility, + createdAt: new Date().toISOString(), + }) + .onConflictDoUpdate({ + target: [ + memberships.sessionId, + memberships.conversationId, + memberships.participantId, + ], + set: { reaction, ingress, transcriptVisibility }, + }) + .run(); + } +} diff --git a/packages/shared/src/contracts.ts b/packages/shared/src/contracts.ts index a2e91c1..c082954 100644 --- a/packages/shared/src/contracts.ts +++ b/packages/shared/src/contracts.ts @@ -600,6 +600,35 @@ export interface Run { endedAt?: string; } +/** + * A named reaction predicate a Membership can declare: + * - `always` — act on every Message in the Conversation (self excluded). + * - `fromHumans` — act only on what a person typed. + * - `mentionsMe` — act only when addressed. A directed message is one whose + * `mentions` include you; `directed` is not a separate value. + * - `atRunEnd` — act only on a Message that ends its Run. + * - `never` — a member that declares it does not act. + */ +export type ReactionName = + | "always" + | "fromHumans" + | "mentionsMe" + | "atRunEnd" + | "never"; + +/** + * A Membership's stored reaction: one or more {@link ReactionName}s joined by + * `+`, read as a disjunction (`fromHumans+mentionsMe`). Each preset stays + * atomic; composition lives in the stored value. + */ +export type ReactionSpec = string; + +/** + * How much of a Conversation a Membership may see. `summarized` is a declared + * label until the context-budget work makes it a mechanism. + */ +export type TranscriptVisibility = "shared" | "summarized" | "opaque"; + /** 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`. */ diff --git a/packages/shared/src/remoteSubagent.ts b/packages/shared/src/remoteSubagent.ts index e13d3f6..c82c4b2 100644 --- a/packages/shared/src/remoteSubagent.ts +++ b/packages/shared/src/remoteSubagent.ts @@ -83,14 +83,16 @@ export interface RemoteSpawnCommand { 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; /** - * The Run the initial `task` is work for, to echo back on its events. Absent - * when no task is sent, or when the server predates run attribution. + * @deprecated No longer sent. An initial task is a Message posted into the new + * sub-agent's Conversation, so it arrives as an ordinary + * {@link RemoteMessageCommand} immediately after this one. Kept so an + * environment built against the older command still type-checks. */ + task?: string; + /** Whether Prime reacts to this sub-agent's finalized replies. */ + autoRelayToPrime: boolean; + /** @deprecated No longer sent; the initial task's command carries its own Run. */ runId?: RunId; } From 8cd7ecb064a609a1f08a99bc21f54b08414bc2d3 Mon Sep 17 00:00:00 2001 From: Maksym Yezhov Date: Wed, 12 Aug 2026 12:57:20 -0700 Subject: [PATCH 07/18] - refactor: cross-conversation posts --- .../conversation/conversationRouter.test.ts | 160 ++++++++++++++++++ .../src/conversation/conversationRouter.ts | 76 ++++++++- apps/server/src/conversation/fanOut.test.ts | 42 ++++- apps/server/src/conversation/fanOut.ts | 22 ++- .../conversation/membershipRegistry.test.ts | 18 ++ .../src/conversation/membershipRegistry.ts | 15 ++ apps/server/src/routes/internalAgents.ts | 9 +- .../chat/components/message/ChatMessage.tsx | 2 + .../chat/components/message/MessageHeader.tsx | 12 ++ .../chat/components/message/messageOrigin.ts | 16 ++ 10 files changed, 360 insertions(+), 12 deletions(-) create mode 100644 apps/server/src/conversation/conversationRouter.test.ts create mode 100644 apps/web/src/features/chat/components/message/messageOrigin.ts diff --git a/apps/server/src/conversation/conversationRouter.test.ts b/apps/server/src/conversation/conversationRouter.test.ts new file mode 100644 index 0000000..1092ce0 --- /dev/null +++ b/apps/server/src/conversation/conversationRouter.test.ts @@ -0,0 +1,160 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + type ChatAuthor, + connectorFor, + PI_AGENT, +} from "@tangent/shared/contracts.ts"; +import type { Server } from "socket.io"; + +import type { ConnectorRegistry } from "../connectors/connectorRegistry.ts"; +import type { DeliveryRequest } from "../connectors/types.ts"; +import { InMemoryMembershipStore } from "../store/inMemoryMembershipStore.ts"; +import { InMemorySessionStore } from "../store/inMemorySessionStore.ts"; +import { ConversationRouter } from "./conversationRouter.ts"; +import { MembershipRegistry } from "./membershipRegistry.ts"; + +const WORKER: ChatAuthor = { + id: "sub-1", + kind: "agent", + name: "Worker", + agentRole: "subagent", +}; + +/** + * A router over in-memory stores and a real membership registry, so + * authorization is answered by the same derivation delivery uses. `delivered` + * is what "who was woken" is measured by and `emitted` what the room saw. + */ +function makeRouter() { + const sessions = new InMemorySessionStore(); + const memberships = new MembershipRegistry( + sessions, + new InMemoryMembershipStore(), + () => true, + ); + const emitted: unknown[] = []; + const delivered: DeliveryRequest[] = []; + + const io = { + to: () => ({ + emit: (_event: string, payload: unknown) => emitted.push(payload), + }), + } as unknown as Server; + + const connectors = { + resolve: () => ({ + deliver: (request: DeliveryRequest) => { + delivered.push(request); + return { delivered: true }; + }, + }), + } as unknown as ConnectorRegistry; + + const router = new ConversationRouter(io, sessions, memberships); + router.useConnectors(connectors); + return { router, sessions, emitted, delivered }; +} + +/** A persisted sub-agent, so its Conversation's memberships derive from a row. */ +async function withWorker(sessions: InMemorySessionStore, id = "sub-1") { + await sessions.recordAgent("s1", { + id, + role: "subagent", + name: "Worker", + status: "active", + autoRelayToPrime: true, + connector: connectorFor("pi-stdio"), + }); +} + +test("an ordinary post records provenance derived from its author", async () => { + const h = makeRouter(); + await withWorker(h.sessions); + + const { message } = await h.router.post({ + sessionId: "s1", + conversationId: "sub-1", + author: WORKER, + content: "done", + }); + + assert.deepEqual(message.source, { kind: "agent" }); +}); + +test("a cross-conversation post is recorded as having arrived from elsewhere", async () => { + const h = makeRouter(); + await withWorker(h.sessions); + + const { message, woke } = await h.router.postToConversation({ + sessionId: "s1", + conversationId: "sub-1", + fromConversation: "prime", + author: PI_AGENT, + content: "look into the failing build", + mentions: ["sub-1"], + ingress: "tool", + }); + + assert.deepEqual(message?.source, { + kind: "relay", + from: "prime", + fromConversation: "prime", + }); + assert.deepEqual(woke, ["sub-1"], "the addressed participant still runs"); + assert.equal(h.delivered.length, 1); + assert.equal(h.emitted.length, 1, "and the room sees it as one message"); + + const persisted = await h.sessions.getMessages("s1"); + assert.equal(persisted.length, 1); + assert.equal(persisted[0].source.fromConversation, "prime"); +}); + +test("a participant with no membership there posts nothing at all", async () => { + const h = makeRouter(); + await withWorker(h.sessions); + await withWorker(h.sessions, "sub-2"); + + // sub-2's conversation holds sub-2 and the orchestrator; a peer worker is not + // a member of it, so the post is refused rather than delivered. + const { message, woke, refused } = await h.router.postToConversation({ + sessionId: "s1", + conversationId: "sub-2", + fromConversation: "sub-1", + author: WORKER, + content: "take this over", + mentions: ["sub-2"], + ingress: "tool", + }); + + assert.equal(message, undefined); + assert.deepEqual(woke, []); + assert.deepEqual( + refused.map((entry) => entry.participantId), + ["sub-1"], + "the refusal is about the author, not the recipient", + ); + assert.match(refused[0].reason, /isn't a member of that conversation/); + assert.deepEqual(await h.sessions.getMessages("s1"), []); + assert.deepEqual(h.emitted, [], "nothing reached the room either"); +}); + +test("posting into the conversation it was written from is an ordinary post", async () => { + const h = makeRouter(); + await withWorker(h.sessions); + + const { message } = await h.router.postToConversation({ + sessionId: "s1", + conversationId: "sub-1", + fromConversation: "sub-1", + author: WORKER, + content: "progress", + }); + + assert.deepEqual( + message?.source, + { kind: "agent" }, + "its own thread is not somewhere else", + ); +}); diff --git a/apps/server/src/conversation/conversationRouter.ts b/apps/server/src/conversation/conversationRouter.ts index 9b0ae77..5b916da 100644 --- a/apps/server/src/conversation/conversationRouter.ts +++ b/apps/server/src/conversation/conversationRouter.ts @@ -6,6 +6,7 @@ import { type ChatMessage, type MemoryScope, type MessageDelivery, + type MessageSource, type RunId, type RunIngress, SocketEvents, @@ -39,6 +40,12 @@ export interface PostInput { runId?: RunId; endsRun?: boolean; memory?: { scope: MemoryScope }; + /** + * The Conversation the author wrote this from, when it is not this one. Set + * only by {@link ConversationRouter.postToConversation}, and what makes the + * Message's provenance a `relay` rather than an ordinary turn. + */ + fromConversation?: string; /** What created this Message, when a reaction did not. */ ingress?: RunIngress; /** Whether a mid-run delivery steers or queues behind the current turn. */ @@ -60,6 +67,20 @@ export interface PostResult extends FanOutResult { message: ChatMessage; } +/** Everything {@link ConversationRouter.postToConversation} needs. */ +export interface CrossPostInput extends PostInput { + fromConversation: string; +} + +/** + * A cross-Conversation post. `message` is absent when Membership did not + * authorize it, in which case `refused` names the author and says why — a post + * that never happened must not read like one that woke nobody. + */ +export interface CrossPostResult extends FanOutResult { + message?: ChatMessage; +} + /** Fields an empty value must omit rather than persist as empty. */ function whatIsThere(input: PostInput): Partial { const fields: Partial = {}; @@ -69,6 +90,24 @@ function whatIsThere(input: PostInput): Partial { return fields; } +/** + * Where a Message came from. A post written from another Conversation records + * that it was, so the log can answer "did this arrive across a boundary" + * instead of leaving the provenance to be reconstructed at delivery time. + */ +function sourceFor(input: PostInput): MessageSource { + const { fromConversation } = input; + // A Conversation is not somewhere else from itself. + if (!fromConversation || fromConversation === input.conversationId) { + return sourceFromAuthor(input.author); + } + return { + kind: "relay", + from: input.author.id, + fromConversation, + }; +} + function buildMessage(input: PostInput & { seq: number }): ChatMessage { // `runId` and `endsRun` are written as given: an absent one is `undefined`, // which JSON drops on both the wire and the way to the log. @@ -79,7 +118,7 @@ function buildMessage(input: PostInput & { seq: number }): ChatMessage { seq: input.seq, author: input.author, mentions: input.mentions ?? [], - source: sourceFromAuthor(input.author), + source: sourceFor(input), content: input.content, runId: input.runId, endsRun: input.endsRun, @@ -135,6 +174,7 @@ export function deliveryText( export class ConversationRouter { private readonly io: Server; private readonly store: SessionStore; + private readonly memberships: MembershipRegistry; private readonly engine: FanOutEngine; private connectors?: ConnectorRegistry; @@ -145,6 +185,7 @@ export class ConversationRouter { ) { this.io = io; this.store = store; + this.memberships = memberships; this.engine = new FanOutEngine( memberships, () => this.requireConnectors(), @@ -189,6 +230,39 @@ export class ConversationRouter { return { message, ...outcome }; } + /** + * Posts into a Conversation the author is not writing from — an orchestrator + * reporting into the human's thread, or issuing a directive in a worker's. + * A Run's output lands in its home Conversation by default, so writing + * elsewhere is deliberate and has to be authorized: only a participant that + * holds a Membership there may post there. + * + * The post stays in its author's wave whatever its ingress, which is what + * stops two Conversations whose participants wake each other from laundering + * an unbounded cycle by changing rooms. + */ + async postToConversation(input: CrossPostInput): Promise { + if (input.fromConversation === input.conversationId) { + return this.post(input); + } + + const membership = await this.memberships.memberIn( + input.sessionId, + input.conversationId, + input.author.id, + ); + if (membership) return this.post(input); + + const reason = `${input.author.name} isn't a member of that conversation, so nothing was posted.`; + console.log( + `[conversation] refused a post by ${input.author.id} into ${input.conversationId}`, + ); + return { + woke: [], + refused: [{ participantId: input.author.id, reason }], + }; + } + private broadcast( message: ChatMessage, override?: (message: ChatMessage) => void, diff --git a/apps/server/src/conversation/fanOut.test.ts b/apps/server/src/conversation/fanOut.test.ts index dda1d92..5f87844 100644 --- a/apps/server/src/conversation/fanOut.test.ts +++ b/apps/server/src/conversation/fanOut.test.ts @@ -238,7 +238,7 @@ test("a cycle of reactions stops at the depth limit, and says why once", async ( let author = { ...WORKER, id: "a", name: "A" }; let conversationId = "a"; - for (let hop = 0; hop < 20; hop += 1) { + for (let hop = 0; hop < 40; hop += 1) { const result = await h.engine.fanOut({ message: message({ conversationId, author, endsRun: true }), project, @@ -249,9 +249,43 @@ test("a cycle of reactions stops at the depth limit, and says why once", async ( conversationId = next; } - assert.equal(h.delivered.length, 8, "the chain runs to the hop limit"); + assert.equal(h.delivered.length, 24, "the chain runs to the hop limit"); assert.equal(h.notices.length, 1, "and announces the stop exactly once"); - assert.match(h.notices[0].text, /reached its limit of 8 hops/); + assert.match(h.notices[0].text, /reached its limit of 24 hops/); +}); + +test("a cycle that changes rooms on every hop is bounded just the same", async () => { + // Each hop is a deliberate tool call, which on its own starts a fresh chain. + // Because each one is also written from the author's own conversation, the + // chain travels with it — otherwise this pair launders an unbounded cycle by + // taking turns in each other's rooms while neither room fills its budget. + const h = makeEngine([ + membership("a", "a", "always"), + membership("b", "b", "always"), + ]); + + let author = { ...WORKER, id: "a", name: "A" }; + for (let hop = 0; hop < 40; hop += 1) { + const target = author.id === "a" ? "b" : "a"; + const result = await h.engine.fanOut({ + message: message({ + conversationId: target, + author, + source: { kind: "relay", from: author.id, fromConversation: author.id }, + }), + ingress: "tool", + project, + }); + if (result.woke.length === 0) break; + author = { ...WORKER, id: result.woke[0], name: result.woke[0] }; + } + + assert.equal( + h.delivered.length, + 24, + "hopping rooms does not reset the chain", + ); + assert.equal(h.notices.length, 1); }); test("deliberate work starts a fresh chain instead of inheriting one", async () => { @@ -264,7 +298,7 @@ test("deliberate work starts a fresh chain instead of inheriting one", async () // driven by explicit tool calls must not be cut short by an earlier cascade. let author = { ...WORKER, id: "a", name: "A" }; let conversationId = "a"; - for (let hop = 0; hop < 20; hop += 1) { + for (let hop = 0; hop < 40; hop += 1) { const result = await h.engine.fanOut({ message: message({ conversationId, author, endsRun: true }), project, diff --git a/apps/server/src/conversation/fanOut.ts b/apps/server/src/conversation/fanOut.ts index f33328e..46e40da 100644 --- a/apps/server/src/conversation/fanOut.ts +++ b/apps/server/src/conversation/fanOut.ts @@ -13,11 +13,13 @@ import type { MembershipRegistry } from "./membershipRegistry.ts"; import { type MessageFacts, messageFacts, parseReaction } from "./reaction.ts"; /** - * How many automatic reaction hops one chain may take. Deliberate work — a tool - * call, a schedule, an inbound callback — starts a new chain, so this bounds - * cascades rather than the length of an orchestration. + * How many hops one chain may take. Deliberate work — a tool call, a schedule, + * an inbound callback — starts a new chain, so this bounds cascades rather than + * the length of an orchestration. A cross-Conversation post is the exception: + * it stays in its author's chain however it was made, and each round trip + * between two participants spends two hops. */ -const MAX_WAVE_DEPTH = 8; +const MAX_WAVE_DEPTH = 24; /** How many reactions one chain may dispatch into a single Conversation. */ const MAX_CONVERSATION_REACTIONS = 24; @@ -217,13 +219,21 @@ export class FanOutEngine { * The wave a Message belongs to. Work a participant deliberately created, or * an outside signal, starts a fresh one: only automatic reactions accumulate * depth, so a long orchestration driven by tool calls is never cut short. + * + * A post written from another Conversation is exempt. Its depth travels with + * it whatever created it, because otherwise two Conversations whose + * participants each wake the other launder an unbounded cycle by hopping + * rooms while each individual room stays under budget. */ private waveFor(message: ChatMessage, ingress?: RunIngress): Wave { - if (ingress && ingress !== "reaction") - return { id: randomUUID(), depth: 0 }; const inherited = this.waves.get( keyFor(message.sessionId, message.author.id), ); + if (message.source.fromConversation) { + return inherited ?? { id: randomUUID(), depth: 0 }; + } + if (ingress && ingress !== "reaction") + return { id: randomUUID(), depth: 0 }; return inherited ?? { id: randomUUID(), depth: 0 }; } diff --git a/apps/server/src/conversation/membershipRegistry.test.ts b/apps/server/src/conversation/membershipRegistry.test.ts index 06c9a9c..834a630 100644 --- a/apps/server/src/conversation/membershipRegistry.test.ts +++ b/apps/server/src/conversation/membershipRegistry.test.ts @@ -136,6 +136,24 @@ test("a stored membership wins over what the roster would derive", async () => { ]); }); +test("membership answers whether one participant stands in a conversation", async () => { + const h = makeRegistry(); + await h.sessions.recordAgent("s1", { + id: "sub-1", + role: "subagent", + name: "Worker", + status: "active", + autoRelayToPrime: true, + connector: connectorFor("pi-stdio"), + }); + + // What lets the orchestrator be woken by a worker's thread is what authorizes + // it to write into one; a peer worker holds no standing there at all. + const prime = await h.registry.memberIn("s1", "sub-1", "prime"); + assert.equal(prime?.reaction, "atRunEnd+mentionsMe"); + assert.equal(await h.registry.memberIn("s1", "sub-1", "sub-2"), undefined); +}); + test("a spawn whose row has not landed yet still resolves, without being kept", async () => { const h = makeRegistry(); diff --git a/apps/server/src/conversation/membershipRegistry.ts b/apps/server/src/conversation/membershipRegistry.ts index 9e13143..458886a 100644 --- a/apps/server/src/conversation/membershipRegistry.ts +++ b/apps/server/src/conversation/membershipRegistry.ts @@ -100,6 +100,21 @@ export class MembershipRegistry { return derived; } + /** + * The standing one participant holds in a Conversation, or nothing when it + * holds none. This is the check delivery already makes, read in the other + * direction: what lets a participant be woken by a Conversation is what + * authorizes it to write into one. + */ + async memberIn( + sessionId: string, + conversationId: string, + participantId: string, + ): Promise { + const members = await this.membersOf(sessionId, conversationId); + return members.find((member) => member.participantId === participantId); + } + /** The session's memberships, indexed by conversation on first use. */ private async load(sessionId: string): Promise> { const cached = this.cache.get(sessionId); diff --git a/apps/server/src/routes/internalAgents.ts b/apps/server/src/routes/internalAgents.ts index ce15917..783aeea 100644 --- a/apps/server/src/routes/internalAgents.ts +++ b/apps/server/src/routes/internalAgents.ts @@ -140,6 +140,10 @@ async function handleSpawn( * it. Surfacing and delivery are the same act: the sub-agent reacts because it * was addressed, and the bubble the user reads is the Message that woke it. * Skips an empty or whitespace-only task. + * + * Prime writes this from its own Conversation, so it is a cross-Conversation + * post: authorized by Prime's Membership in the sub-agent's thread, recorded as + * having arrived from elsewhere, and kept inside the wave Prime is already in. */ async function postDirective( router: ConversationRouter, @@ -148,14 +152,17 @@ async function postDirective( text: string | undefined, ): Promise { if (!text?.trim()) return undefined; - const { refused } = await router.post({ + const { message, refused } = await router.postToConversation({ sessionId, conversationId: agentId, + fromConversation: PRIME_AGENT_ID, author: PI_AGENT, content: text, mentions: [agentId], ingress: "tool", }); + // Nothing was posted at all: the refusal is about Prime, not the recipient. + if (!message) return refused[0]?.reason; return refused.find((entry) => entry.participantId === agentId)?.reason; } diff --git a/apps/web/src/features/chat/components/message/ChatMessage.tsx b/apps/web/src/features/chat/components/message/ChatMessage.tsx index e0d0185..e74af96 100644 --- a/apps/web/src/features/chat/components/message/ChatMessage.tsx +++ b/apps/web/src/features/chat/components/message/ChatMessage.tsx @@ -18,6 +18,7 @@ import { MessageAvatar } from "./MessageAvatar"; import type { MessageBubbleVariant } from "./MessageBubble"; import { MessageHeader } from "./MessageHeader"; import { MessageLayout } from "./MessageLayout"; +import { originLabelFor } from "./messageOrigin"; import { roleLabelFor } from "./messageRole"; import { ThinkingOnlyMessage } from "./ThinkingOnlyMessage"; @@ -99,6 +100,7 @@ function ChatMessageContent({ roleLabel={roleLabel} createdAt={message.createdAt} content={message.content} + origin={originLabelFor(message)} onCollapse={onCollapse} /> } diff --git a/apps/web/src/features/chat/components/message/MessageHeader.tsx b/apps/web/src/features/chat/components/message/MessageHeader.tsx index e921121..252e35c 100644 --- a/apps/web/src/features/chat/components/message/MessageHeader.tsx +++ b/apps/web/src/features/chat/components/message/MessageHeader.tsx @@ -1,3 +1,4 @@ +import { Icon } from "@tangent/ui-primitives/icon"; import { InlineStack } from "@tangent/ui-primitives/layout"; import { Text } from "@tangent/ui-primitives/typography"; @@ -16,6 +17,8 @@ interface MessageHeaderProps { roleLabel: string; createdAt: string; content: string; + /** Where the author wrote this from, when it was not this conversation. */ + origin?: string; onCollapse?: () => void; } @@ -24,6 +27,7 @@ export function MessageHeader({ roleLabel, createdAt, content, + origin, onCollapse, }: MessageHeaderProps) { return ( @@ -34,6 +38,14 @@ export function MessageHeader({ {" · "} {formatMessageTime(createdAt)} + {origin ? ( + + + + {origin} + + + ) : null} ); diff --git a/apps/web/src/features/chat/components/message/messageOrigin.ts b/apps/web/src/features/chat/components/message/messageOrigin.ts new file mode 100644 index 0000000..811dc5d --- /dev/null +++ b/apps/web/src/features/chat/components/message/messageOrigin.ts @@ -0,0 +1,16 @@ +import { PI_AGENT } from "@tangent/shared/contracts"; + +import type { ChatMessage } from "@/features/chat/model/types"; + +/** + * How a message written from another conversation is labelled, so a report + * arriving from elsewhere reads differently from a peer turn in this thread. + * Only the orchestrator's thread can be named without the roster; any other + * origin is stated without being resolved rather than shown as a raw id. + */ +export function originLabelFor(message: ChatMessage): string | undefined { + const origin = message.source.fromConversation; + if (!origin) return undefined; + if (origin === PI_AGENT.id) return `from ${PI_AGENT.name}'s thread`; + return "from another thread"; +} From fd23c99712a61920e99b77575cb8e29fc0e31bf5 Mon Sep 17 00:00:00 2001 From: Maksym Yezhov Date: Wed, 12 Aug 2026 15:13:57 -0700 Subject: [PATCH 08/18] - refactor: credentials as a connector facet --- .../src/connectors/connectorRegistry.test.ts | 27 +++ .../server/src/connectors/credentials.test.ts | 82 +++++++++ apps/server/src/connectors/credentials.ts | 163 ++++++++++++++++++ .../src/connectors/externalConnector.ts | 2 + apps/server/src/connectors/nullConnector.ts | 2 + apps/server/src/connectors/piConnector.ts | 2 + .../src/connectors/remoteEnvConnector.ts | 2 + apps/server/src/connectors/types.ts | 11 ++ .../external/externalSubagentGateway.test.ts | 2 + apps/server/src/mcp/relayRegistry.test.ts | 16 ++ apps/server/src/mcp/relayRegistry.ts | 17 +- .../src/middleware/requireCredential.ts | 21 +++ .../src/middleware/requireInternalToken.ts | 23 +-- apps/server/src/pi/piAgentManager.test.ts | 1 + apps/server/src/pi/piAgentManager.ts | 4 +- .../remote/remoteEnvironmentGateway.test.ts | 65 +++++-- .../src/remote/remoteEnvironmentGateway.ts | 10 +- apps/server/src/routes/internalAgents.ts | 10 +- .../src/routes/internalExternalAgents.ts | 11 +- apps/server/src/routes/mcp.ts | 12 +- apps/server/src/store/db/schema.ts | 5 +- .../src/store/sqliteSessionStore.test.ts | 4 + packages/shared/src/contracts.ts | 29 +++- 23 files changed, 463 insertions(+), 58 deletions(-) create mode 100644 apps/server/src/connectors/credentials.test.ts create mode 100644 apps/server/src/connectors/credentials.ts create mode 100644 apps/server/src/middleware/requireCredential.ts diff --git a/apps/server/src/connectors/connectorRegistry.test.ts b/apps/server/src/connectors/connectorRegistry.test.ts index 57a01e4..7052caa 100644 --- a/apps/server/src/connectors/connectorRegistry.test.ts +++ b/apps/server/src/connectors/connectorRegistry.test.ts @@ -154,6 +154,32 @@ test("resolution is total: an unheld participant gets a refusing connector", () assert.equal(connector.acceptsDelivery, false); }); +test("every connector's credential agrees with the scheme it publishes", () => { + const h = makeHarness(); + const { id } = h.externalGateway.register("s1", { name: "worker" }); + + // The descriptor names the scheme (it goes to clients); the credential holds + // the secret (it does not). A connector whose two disagreed would be lying + // about how its far end is authenticated. + for (const participantId of ["local-1", "remote-1", id, "ghost"]) { + const connector = h.connectors.resolve("s1", participantId); + assert.equal( + connector.credential.scheme, + connector.descriptor.credentialScheme, + `${participantId} publishes a scheme its credential does not implement`, + ); + } + + assert.equal( + h.connectors.resolve("s1", "local-1").descriptor.credentialScheme, + "inherited-token", + ); + assert.equal( + h.connectors.resolve("s1", "ghost").credential.configured, + false, + ); +}); + test("a message to an unknown participant is refused in its own conversation", () => { const h = makeHarness(); @@ -314,6 +340,7 @@ test("revive skips Prime, terminal rows and attached participants", () => { kind: "pi-stdio", lifecycle: "attached", spawnAuthority: "server", + credentialScheme: "inherited-token", }), ]); diff --git a/apps/server/src/connectors/credentials.test.ts b/apps/server/src/connectors/credentials.test.ts new file mode 100644 index 0000000..c0126db --- /dev/null +++ b/apps/server/src/connectors/credentials.test.ts @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + BearerCredential, + deniedCredential, + HandshakeTokenCredential, + InheritedTokenCredential, + mintSecretCredential, +} from "./credentials.ts"; + +test("a bearer credential accepts only its own token, exactly", () => { + const credential = new BearerCredential("internal-bearer", "s3cret"); + + assert.equal(credential.verify({ authorization: "Bearer s3cret" }), true); + assert.equal(credential.verify({ authorization: "Bearer s3cre" }), false); + assert.equal(credential.verify({ authorization: "bearer s3cret" }), false); + assert.equal(credential.verify({ authorization: "s3cret" }), false); + assert.equal(credential.verify({}), false); +}); + +test("an unset secret authorizes nobody, rather than everybody", () => { + // The direction this has to fail in: a server with no token configured must + // refuse every caller, including one presenting the empty string. + const bearer = new BearerCredential("internal-bearer", ""); + const handshake = new HandshakeTokenCredential(""); + + assert.equal(bearer.configured, false); + assert.equal(bearer.verify({ authorization: "Bearer " }), false); + assert.equal(handshake.configured, false); + assert.equal(handshake.verify({ token: "" }), false); + assert.equal(handshake.verify({ token: undefined }), false); +}); + +test("the handshake credential reads the handshake, not a header", () => { + const credential = new HandshakeTokenCredential("env-token"); + + assert.equal(credential.verify({ token: "env-token" }), true); + assert.equal(credential.verify({ token: "other" }), false); + assert.equal(credential.verify({ authorization: "Bearer env-token" }), false); +}); + +test("only an inherited credential hands its secret to a spawned child", () => { + const inherited = new InheritedTokenCredential("tok"); + + assert.deepEqual(inherited.spawnEnv(), { TANGENT_INTERNAL_TOKEN: "tok" }); + assert.deepEqual( + new BearerCredential("internal-bearer", "tok").spawnEnv(), + {}, + ); + assert.deepEqual(new HandshakeTokenCredential("tok").spawnEnv(), {}); + assert.deepEqual(deniedCredential.spawnEnv(), {}); +}); + +test("the same secret verifies the same way however it was issued", () => { + // `inherited-token` and `internal-bearer` differ only in issuance, so a Pi + // child and a bundle tool presenting the token it inherited both pass. + const inherited = new InheritedTokenCredential("shared"); + const bearer = new BearerCredential("internal-bearer", "shared"); + const presented = { authorization: "Bearer shared" }; + + assert.equal(inherited.verify(presented), true); + assert.equal(bearer.verify(presented), true); + assert.notEqual(inherited.scheme, bearer.scheme); +}); + +test("a minted secret opens its own subject and nothing else", () => { + const a = mintSecretCredential(); + const b = mintSecretCredential(); + + assert.notEqual(a.secret, b.secret); + assert.equal(a.verify({ authorization: `Bearer ${a.secret}` }), true); + assert.equal(a.verify({ authorization: `Bearer ${b.secret}` }), false); + assert.equal(a.scheme, "minted-secret"); +}); + +test("the denied credential authorizes nothing at all", () => { + assert.equal(deniedCredential.scheme, "none"); + assert.equal(deniedCredential.configured, false); + assert.equal(deniedCredential.verify({ authorization: "Bearer x" }), false); + assert.equal(deniedCredential.verify({ token: "x" }), false); +}); diff --git a/apps/server/src/connectors/credentials.ts b/apps/server/src/connectors/credentials.ts new file mode 100644 index 0000000..0b22fa7 --- /dev/null +++ b/apps/server/src/connectors/credentials.ts @@ -0,0 +1,163 @@ +import { randomBytes } from "node:crypto"; + +import type { CredentialScheme } from "@tangent/shared/contracts.ts"; + +import { INTERNAL_TOKEN, REMOTE_ENV_TOKEN } from "../config.ts"; + +/** Env var a spawned Pi child reads its inherited credential from. */ +const INHERITED_TOKEN_VAR = "TANGENT_INTERNAL_TOKEN"; + +/** Bytes of entropy in a minted per-subject secret. */ +const MINTED_SECRET_BYTES = 24; + +/** + * What a caller presented, whatever transport it arrived on. Both fields are + * optional because a credential reads only the one its scheme uses — a socket + * handshake carries no header, and an HTTP request carries no handshake. + */ +export interface CredentialPresentation { + /** The HTTP `Authorization` header. */ + authorization?: string; + /** The Socket.IO handshake's `auth.token`. */ + token?: string; +} + +/** + * How one connector proves who is talking to it. Credential is the thing that + * varies per connector and nowhere else, so it sits beside the connector's + * other facets rather than in a shared auth path each connector edits: adding + * a connector means adding an implementation here, not widening a guard. + * + * An unconfigured credential authorizes nothing. That is the direction this + * has to fail in — a server with no secret set must refuse every caller rather + * than accept every caller. + */ +export interface ConnectorCredential { + readonly scheme: CredentialScheme; + /** Whether a secret is set at all. When false, {@link verify} is always false. */ + readonly configured: boolean; + /** Whether what a caller presented authorizes it. */ + verify(presented: CredentialPresentation): boolean; + /** + * Environment a process the server spawns inherits the credential through. + * Empty for every scheme that hands its secret over some other way. + */ + spawnEnv(): Record; +} + +/** A shared secret presented as an HTTP `Authorization: Bearer` header. */ +export class BearerCredential implements ConnectorCredential { + readonly scheme: CredentialScheme; + protected readonly token: string; + + constructor(scheme: CredentialScheme, token: string) { + this.scheme = scheme; + this.token = token; + } + + get configured(): boolean { + return this.token.length > 0; + } + + verify(presented: CredentialPresentation): boolean { + if (!this.configured) return false; + return presented.authorization === `Bearer ${this.token}`; + } + + spawnEnv(): Record { + return {}; + } +} + +/** + * The server's internal token as a Pi child receives it: handed down through + * the child's environment at spawn, presented back as a bearer on the internal + * API. Issuance is the only thing that separates it from + * {@link externalCredential}, which checks the same secret from a caller that + * already holds it. + */ +export class InheritedTokenCredential extends BearerCredential { + constructor(token: string) { + super("inherited-token", token); + } + + override spawnEnv(): Record { + return { [INHERITED_TOKEN_VAR]: this.token }; + } +} + +/** + * A secret configured on both sides out of band and presented in a Socket.IO + * handshake rather than a header — the remote environment's scheme. + */ +export class HandshakeTokenCredential implements ConnectorCredential { + readonly scheme: CredentialScheme = "shared-token"; + private readonly token: string; + + constructor(token: string) { + this.token = token; + } + + get configured(): boolean { + return this.token.length > 0; + } + + verify(presented: CredentialPresentation): boolean { + if (!this.configured) return false; + return presented.token === this.token; + } + + spawnEnv(): Record { + return {}; + } +} + +/** + * A secret minted for one subject rather than for the server: the peer is told + * it when its channel is opened, and it opens nothing else. This is the scheme + * that shapes the interface — the other three are per-server singletons, so + * without this one a credential could have been a constant. + */ +export class MintedSecretCredential extends BearerCredential { + constructor(secret: string) { + super("minted-secret", secret); + } + + /** The secret to hand the subject. Readable because it has to be told. */ + get secret(): string { + return this.token; + } +} + +/** Mints a credential for one subject, with a fresh secret. */ +export function mintSecretCredential(): MintedSecretCredential { + return new MintedSecretCredential( + randomBytes(MINTED_SECRET_BYTES).toString("hex"), + ); +} + +/** + * The credential of a connector that authenticates nobody, for the null + * connector. Declared rather than absent, like its `acceptsDelivery: false`: + * an unclaimed participant has no far end to prove anything. + */ +export const deniedCredential: ConnectorCredential = { + scheme: "none", + configured: false, + verify: () => false, + spawnEnv: () => ({}), +}; + +/** Pi children: the internal token, inherited through the spawn environment. */ +export const piCredential = new InheritedTokenCredential(INTERNAL_TOKEN); + +/** Remote environments: the `REMOTE_ENV_TOKEN` handshake. */ +export const remoteEnvCredential = new HandshakeTokenCredential( + REMOTE_ENV_TOKEN, +); + +/** External registrants: the same internal token, presented as a bearer. */ +export const externalCredential = new BearerCredential( + "internal-bearer", + INTERNAL_TOKEN, +); diff --git a/apps/server/src/connectors/externalConnector.ts b/apps/server/src/connectors/externalConnector.ts index edc8527..e618c2e 100644 --- a/apps/server/src/connectors/externalConnector.ts +++ b/apps/server/src/connectors/externalConnector.ts @@ -3,6 +3,7 @@ import { connectorFor } from "@tangent/shared/contracts.ts"; import type { ExternalSubagentGateway } from "../external/externalSubagentGateway.ts"; import type { ConversationEventSink } from "../pi/types.ts"; import type { SessionAgent } from "../store/sessionStore.ts"; +import { externalCredential } from "./credentials.ts"; import { refuseDelivery } from "./refusal.ts"; import type { CancelResult, @@ -32,6 +33,7 @@ const NO_CANCEL_CHANNEL = export class ExternalConnector implements Connector { readonly descriptor = connectorFor("external-inbound"); readonly acceptsDelivery = false; + readonly credential = externalCredential; private readonly gateway: ExternalSubagentGateway; private readonly handlers: ConversationEventSink; diff --git a/apps/server/src/connectors/nullConnector.ts b/apps/server/src/connectors/nullConnector.ts index c5fd8c7..faac312 100644 --- a/apps/server/src/connectors/nullConnector.ts +++ b/apps/server/src/connectors/nullConnector.ts @@ -1,6 +1,7 @@ import { connectorFor, type SubagentInfo } from "@tangent/shared/contracts.ts"; import type { ConversationEventSink } from "../pi/types.ts"; +import { deniedCredential } from "./credentials.ts"; import { refuseDelivery } from "./refusal.ts"; import type { CancelResult, @@ -25,6 +26,7 @@ const NOT_AVAILABLE_TO_CANCEL = "This agent is no longer available."; export class NullConnector implements Connector { readonly descriptor = connectorFor("unresolved"); readonly acceptsDelivery = false; + readonly credential = deniedCredential; private readonly handlers: ConversationEventSink; diff --git a/apps/server/src/connectors/piConnector.ts b/apps/server/src/connectors/piConnector.ts index 0de0cb5..37322a9 100644 --- a/apps/server/src/connectors/piConnector.ts +++ b/apps/server/src/connectors/piConnector.ts @@ -3,6 +3,7 @@ import { connectorFor } from "@tangent/shared/contracts.ts"; import type { SubagentSpawnRequest } from "../pi/agentConfig.ts"; import type { PiAgentManager, SpawnedSubagent } from "../pi/piAgentManager.ts"; import type { SessionAgent } from "../store/sessionStore.ts"; +import { piCredential } from "./credentials.ts"; import type { CancelResult, Connector, @@ -22,6 +23,7 @@ const NOTHING_RUNNING = "That agent isn't running anything right now."; export class PiConnector implements Connector { readonly descriptor = connectorFor("pi-stdio"); readonly acceptsDelivery = true; + readonly credential = piCredential; private readonly pi: PiAgentManager; diff --git a/apps/server/src/connectors/remoteEnvConnector.ts b/apps/server/src/connectors/remoteEnvConnector.ts index 52ae113..006c0c4 100644 --- a/apps/server/src/connectors/remoteEnvConnector.ts +++ b/apps/server/src/connectors/remoteEnvConnector.ts @@ -5,6 +5,7 @@ import type { SpawnedSubagent } from "../pi/piAgentManager.ts"; import type { ConversationEventSink } from "../pi/types.ts"; import type { RemoteEnvironmentGateway } from "../remote/remoteEnvironmentGateway.ts"; import type { SessionAgent } from "../store/sessionStore.ts"; +import { remoteEnvCredential } from "./credentials.ts"; import { refuseDelivery } from "./refusal.ts"; import type { CancelResult, @@ -33,6 +34,7 @@ const ENVIRONMENT_DETACHED = export class RemoteEnvConnector implements Connector { readonly descriptor = connectorFor("remote-env"); readonly acceptsDelivery = true; + readonly credential = remoteEnvCredential; private readonly gateway: RemoteEnvironmentGateway; private readonly handlers: ConversationEventSink; diff --git a/apps/server/src/connectors/types.ts b/apps/server/src/connectors/types.ts index 209770a..3f4f6e3 100644 --- a/apps/server/src/connectors/types.ts +++ b/apps/server/src/connectors/types.ts @@ -9,6 +9,7 @@ import type { import type { SubagentSpawnRequest } from "../pi/agentConfig.ts"; import type { SpawnedSubagent } from "../pi/piAgentManager.ts"; import type { SessionAgent } from "../store/sessionStore.ts"; +import type { ConnectorCredential } from "./credentials.ts"; /** * A message addressed to one participant, as a connector receives it. It carries @@ -63,11 +64,21 @@ export interface CancelResult { * mis-delivery — and the tree has had both. `cancelRun` and `revive` follow the * same rule: a transport with no cancel protocol, or no way to bring a * participant back, says so by declaration. + * + * `credential` follows it too, and is why adding a connector cannot mean + * editing a shared auth path: a new one does not compile until it says how its + * far end proves who it is. */ export interface Connector { readonly descriptor: ConnectorDescriptor; /** Whether this connector can carry a message to its participants at all. */ readonly acceptsDelivery: boolean; + /** + * How this connector's far end authenticates. Its scheme is the descriptor's + * {@link ConnectorDescriptor.credentialScheme}; the secret is not on the + * descriptor, because the descriptor goes to clients. + */ + readonly credential: ConnectorCredential; has(sessionId: string, participantId: string): boolean; list(sessionId: string): SubagentInfo[]; deliver(request: DeliveryRequest): DeliveryResult; diff --git a/apps/server/src/external/externalSubagentGateway.test.ts b/apps/server/src/external/externalSubagentGateway.test.ts index d8c8096..14ce4ff 100644 --- a/apps/server/src/external/externalSubagentGateway.test.ts +++ b/apps/server/src/external/externalSubagentGateway.test.ts @@ -46,6 +46,7 @@ function agentRow(id: string, overrides: Partial = {}) { kind: "external-inbound", lifecycle: "owned", spawnAuthority: "bundle-tool", + credentialScheme: "internal-bearer", }, createdAt: "2026-01-01T00:00:00.000Z", ...overrides, @@ -142,6 +143,7 @@ test("the roster describes an external sub-agent as owned by its bundle tool", ( kind: "external-inbound", lifecycle: "owned", spawnAuthority: "bundle-tool", + credentialScheme: "internal-bearer", }); }); diff --git a/apps/server/src/mcp/relayRegistry.test.ts b/apps/server/src/mcp/relayRegistry.test.ts index 8e7ff1c..2028084 100644 --- a/apps/server/src/mcp/relayRegistry.test.ts +++ b/apps/server/src/mcp/relayRegistry.test.ts @@ -16,6 +16,22 @@ test("open issues a distinct channel id and secret bound to the session", () => assert.equal(registry.get(b.channelId)?.label, "remote agent"); }); +test("a channel's credential opens that channel and no other", () => { + const registry = new RelayRegistry(); + const a = registry.open({ sessionId: "s1" }); + const b = registry.open({ sessionId: "s1" }); + + const credential = registry.get(a.channelId)!.credential; + assert.equal( + credential.verify({ authorization: `Bearer ${a.secret}` }), + true, + ); + assert.equal( + credential.verify({ authorization: `Bearer ${b.secret}` }), + false, + ); +}); + test("answer resolves a pending question and takeAnswer consumes it once", () => { const registry = new RelayRegistry(); const { channelId } = registry.open({ sessionId: "s1" }); diff --git a/apps/server/src/mcp/relayRegistry.ts b/apps/server/src/mcp/relayRegistry.ts index 6e5b3bb..4d36319 100644 --- a/apps/server/src/mcp/relayRegistry.ts +++ b/apps/server/src/mcp/relayRegistry.ts @@ -1,4 +1,9 @@ -import { randomBytes, randomUUID } from "node:crypto"; +import { randomUUID } from "node:crypto"; + +import { + type ConnectorCredential, + mintSecretCredential, +} from "../connectors/credentials.ts"; /** * A relay channel bridges an external MCP client (which the gateway dials) to a @@ -12,8 +17,8 @@ export interface RelayChannel { 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; + /** The credential this channel — and only this channel — is opened by. */ + credential: ConnectorCredential; /** Open questions awaiting an answer, keyed by request id. */ pending: Map; /** Answers supplied for pending questions, keyed by request id. */ @@ -42,17 +47,17 @@ export class RelayRegistry { /** 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"); + const credential = mintSecretCredential(); this.channels.set(channelId, { channelId, sessionId: input.sessionId, label: input.label?.trim() || "remote agent", - secret, + credential, pending: new Map(), answers: new Map(), createdAt: Date.now(), }); - return { channelId, secret }; + return { channelId, secret: credential.secret }; } get(channelId: string): RelayChannel | undefined { diff --git a/apps/server/src/middleware/requireCredential.ts b/apps/server/src/middleware/requireCredential.ts new file mode 100644 index 0000000..9305cb4 --- /dev/null +++ b/apps/server/src/middleware/requireCredential.ts @@ -0,0 +1,21 @@ +import type { NextFunction, Request, RequestHandler, Response } from "express"; + +import type { ConnectorCredential } from "../connectors/credentials.ts"; + +/** + * Guards a router with one connector's credential. The comparison lives in the + * credential, so a route says which far end it is for rather than knowing what + * that far end presents — which is what keeps a new connector from having to + * edit a shared auth path. + */ +export function requireCredential( + credential: ConnectorCredential, +): RequestHandler { + return function guard(req: Request, res: Response, next: NextFunction): void { + if (!credential.verify({ authorization: req.get("authorization") })) { + res.status(401).json({ error: "Unauthorized" }); + return; + } + next(); + }; +} diff --git a/apps/server/src/middleware/requireInternalToken.ts b/apps/server/src/middleware/requireInternalToken.ts index 06720e0..551820c 100644 --- a/apps/server/src/middleware/requireInternalToken.ts +++ b/apps/server/src/middleware/requireInternalToken.ts @@ -1,20 +1,9 @@ -import type { NextFunction, Request, Response } from "express"; - -import { INTERNAL_TOKEN } from "../config.ts"; +import { piCredential } from "../connectors/credentials.ts"; +import { requireCredential } from "./requireCredential.ts"; /** - * Shared guard for the `/internal/*` APIs: rejects any request not bearing the - * server's internal bearer token. The token is shared with the Pi processes via - * env, so arbitrary local callers can't drive a session's agents/triggers/memory. + * Guard for the `/internal/*` APIs a Pi process calls: the extensions running + * inside each child present the token they inherited at spawn, so the Pi + * connector's credential is the one that answers for them. */ -export function requireInternalToken( - req: Request, - res: Response, - next: NextFunction, -): void { - if (req.get("authorization") !== `Bearer ${INTERNAL_TOKEN}`) { - res.status(401).json({ error: "Unauthorized" }); - return; - } - next(); -} +export const requireInternalToken = requireCredential(piCredential); diff --git a/apps/server/src/pi/piAgentManager.test.ts b/apps/server/src/pi/piAgentManager.test.ts index 2fc22b0..1bdee84 100644 --- a/apps/server/src/pi/piAgentManager.test.ts +++ b/apps/server/src/pi/piAgentManager.test.ts @@ -267,6 +267,7 @@ test("the local roster describes its connector", () => { kind: "pi-stdio", lifecycle: "owned", spawnAuthority: "server", + credentialScheme: "inherited-token", }; assert.deepEqual(info.connector, expected); assert.equal(info.host, "local"); diff --git a/apps/server/src/pi/piAgentManager.ts b/apps/server/src/pi/piAgentManager.ts index a3a34f8..e1d985d 100644 --- a/apps/server/src/pi/piAgentManager.ts +++ b/apps/server/src/pi/piAgentManager.ts @@ -15,7 +15,6 @@ import { } from "@tangent/shared/contracts.ts"; import { - INTERNAL_TOKEN, INTERNAL_URL, PI_BIN, PI_DEBUG, @@ -24,6 +23,7 @@ import { PI_PROXY_URL, PI_THINKING, } from "../config.ts"; +import { piCredential } from "../connectors/credentials.ts"; import type { RunRegistry, SettledStatus } from "../runs/runRegistry.ts"; import type { SessionAgent } from "../store/sessionStore.ts"; import { @@ -949,7 +949,7 @@ export class PiAgentManager { TANGENT_AGENT_ID: descriptor.agentId, TANGENT_AGENT_ROLE: descriptor.role, TANGENT_INTERNAL_URL: INTERNAL_URL, - TANGENT_INTERNAL_TOKEN: INTERNAL_TOKEN, + ...piCredential.spawnEnv(), }, stdio: ["pipe", "pipe", "pipe"], }, diff --git a/apps/server/src/remote/remoteEnvironmentGateway.test.ts b/apps/server/src/remote/remoteEnvironmentGateway.test.ts index 9435bbd..afca432 100644 --- a/apps/server/src/remote/remoteEnvironmentGateway.test.ts +++ b/apps/server/src/remote/remoteEnvironmentGateway.test.ts @@ -8,6 +8,7 @@ import { } from "@tangent/shared/remoteSubagent.ts"; import type { Server as SocketIOServer, Socket } from "socket.io"; +import { HandshakeTokenCredential } from "../connectors/credentials.ts"; import type { ConversationEventSink } from "../pi/types.ts"; import { RunRegistry } from "../runs/runRegistry.ts"; import { InMemoryRunStore } from "../store/inMemoryRunStore.ts"; @@ -19,16 +20,38 @@ function flush(): Promise { return new Promise((resolve) => setImmediate(resolve)); } +/** The environment token this harness's gateway is configured with. */ +const ENV_TOKEN = "test-env-token"; + +/** An environment's socket: what it was sent, and what it listens for. */ +function fakeSocket(auth: Record) { + const listeners = new Map void>(); + const sent: Array<{ event: string; payload: unknown }> = []; + const socket = { + handshake: { auth }, + on: (event: string, handler: (payload: unknown) => void) => + listeners.set(event, handler), + emit: (event: string, payload: unknown) => sent.push({ event, payload }), + } as unknown as Socket; + return { socket, listeners, sent }; +} + /** * A gateway wired to a fake namespace, plus a `connect` that registers an - * environment by driving the captured connection handler (bypassing the token - * middleware, which is not what these tests are about). The returned handle - * drives the environment's inbound events and its disconnect. + * environment by driving the captured middleware and then the captured + * connection handler — so every test goes through the real credential check, + * presenting the right token unless it asks not to. The returned handle drives + * the environment's inbound events and its disconnect. */ function makeHarness() { let onConnection: ((socket: Socket) => void) | undefined; + let authenticate: + | ((socket: Socket, next: (err?: Error) => void) => void) + | undefined; const namespace = { - use: () => {}, + use: (fn: (socket: Socket, next: (err?: Error) => void) => void) => { + authenticate = fn; + }, on: (event: string, handler: (socket: Socket) => void) => { if (event === "connection") onConnection = handler; }, @@ -50,19 +73,21 @@ function makeHarness() { handlers, store, runs, + new HandshakeTokenCredential(ENV_TOKEN), ); - const connect = (environmentId: string) => { - const listeners = new Map void>(); - const sent: Array<{ event: string; payload: unknown }> = []; - const socket = { - handshake: { auth: { environmentId } }, - on: (event: string, handler: (payload: unknown) => void) => - listeners.set(event, handler), - emit: (event: string, payload: unknown) => sent.push({ event, payload }), - } as unknown as Socket; - onConnection?.(socket); + const connect = ( + environmentId: string, + auth: { token?: string } = { token: ENV_TOKEN }, + ) => { + const { socket, listeners, sent } = fakeSocket({ environmentId, ...auth }); + let refused: Error | undefined; + authenticate?.(socket, (err) => { + refused = err; + }); + if (!refused) onConnection?.(socket); return { + refused, sent, send: (event: string, payload: unknown) => listeners.get(event)?.(payload), @@ -91,6 +116,17 @@ async function seedAgent( }); } +test("only an environment presenting the connector's credential connects", () => { + const h = makeHarness(); + + assert.ok(h.connect("intruder", { token: "wrong-token" }).refused); + assert.ok(h.connect("silent", {}).refused); + assert.equal(h.gateway.hasConnectedEnvironment(), false); + + assert.equal(h.connect("env-1").refused, undefined); + assert.equal(h.gateway.hasConnectedEnvironment(), true); +}); + test("the remote roster describes its connector and environment", () => { const h = makeHarness(); h.connect("env-1"); @@ -101,6 +137,7 @@ test("the remote roster describes its connector and environment", () => { kind: "remote-env", lifecycle: "owned", spawnAuthority: "remote-env", + credentialScheme: "shared-token", environmentId: "env-1", }; assert.deepEqual(info.connector, expected); diff --git a/apps/server/src/remote/remoteEnvironmentGateway.ts b/apps/server/src/remote/remoteEnvironmentGateway.ts index 1f86f5a..c648d73 100644 --- a/apps/server/src/remote/remoteEnvironmentGateway.ts +++ b/apps/server/src/remote/remoteEnvironmentGateway.ts @@ -25,7 +25,10 @@ import { } from "@tangent/shared/remoteSubagent.ts"; import type { Namespace, Server as SocketIOServer, Socket } from "socket.io"; -import { REMOTE_ENV_TOKEN } from "../config.ts"; +import { + type ConnectorCredential, + remoteEnvCredential, +} from "../connectors/credentials.ts"; import { parseThinkingLevel, resolveSubagentConfig, @@ -121,6 +124,7 @@ export class RemoteEnvironmentGateway { private readonly handlers: ConversationEventSink; private readonly store: SessionStore; private readonly runs: RunRegistry; + private readonly credential: ConnectorCredential; /** Connected environments, keyed by their handshake `environmentId`. */ private readonly environments = new Map(); @@ -132,11 +136,13 @@ export class RemoteEnvironmentGateway { handlers: ConversationEventSink, store: SessionStore, runs: RunRegistry, + credential: ConnectorCredential = remoteEnvCredential, ) { this.io = io; this.handlers = handlers; this.store = store; this.runs = runs; + this.credential = credential; this.setupNamespace(); } @@ -355,7 +361,7 @@ export class RemoteEnvironmentGateway { /** 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) { + if (!this.credential.verify({ token: auth.token })) { next(new Error("Unauthorized")); return; } diff --git a/apps/server/src/routes/internalAgents.ts b/apps/server/src/routes/internalAgents.ts index 783aeea..c1b9348 100644 --- a/apps/server/src/routes/internalAgents.ts +++ b/apps/server/src/routes/internalAgents.ts @@ -8,8 +8,9 @@ import { type Response, Router } from "express"; import { z } from "zod"; import type { ConnectorRegistry } from "../connectors/connectorRegistry.ts"; +import { piCredential } from "../connectors/credentials.ts"; import type { ConversationRouter } from "../conversation/conversationRouter.ts"; -import { requireInternalToken } from "../middleware/requireInternalToken.ts"; +import { requireCredential } from "../middleware/requireCredential.ts"; import { getValidated, validate } from "../middleware/validate.ts"; import { parseThinkingLevel } from "../pi/agentConfig.ts"; import { PRIME_AGENT_ID } from "../pi/types.ts"; @@ -273,8 +274,9 @@ async function handleRoom( /** * Internal API used only by the orchestrator extension running inside each Pi * process. It lets Prime spawn/message/kill/list sub-agents and lets any agent - * read the shared transcript. Guarded by a bearer token shared with the - * spawned processes via env, so arbitrary local callers can't drive agents. + * read the shared transcript. Guarded by the Pi connector's credential — the + * token those processes inherited at spawn — so arbitrary local callers can't + * drive agents. */ export function createInternalAgentsRouter( store: SessionStore, @@ -283,7 +285,7 @@ export function createInternalAgentsRouter( ): Router { const router = Router(); - router.use(requireInternalToken); + router.use(requireCredential(piCredential)); router.post("/spawn", validate({ body: spawnSchema }), (req, res) => handleSpawn( diff --git a/apps/server/src/routes/internalExternalAgents.ts b/apps/server/src/routes/internalExternalAgents.ts index 5c34500..ecd494d 100644 --- a/apps/server/src/routes/internalExternalAgents.ts +++ b/apps/server/src/routes/internalExternalAgents.ts @@ -2,8 +2,9 @@ import type { RemoteAgentEvent } from "@tangent/shared/remoteSubagent.ts"; import { type Response, Router } from "express"; import { z } from "zod"; +import { externalCredential } from "../connectors/credentials.ts"; import type { ExternalSubagentGateway } from "../external/externalSubagentGateway.ts"; -import { requireInternalToken } from "../middleware/requireInternalToken.ts"; +import { requireCredential } from "../middleware/requireCredential.ts"; import { getValidated, validate } from "../middleware/validate.ts"; import { parseThinkingLevel } from "../pi/agentConfig.ts"; @@ -76,9 +77,9 @@ function handleOpenRun( /** * 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 + * runtime's output into it, and marks its lifecycle. Guarded by the external + * connector's own credential, which checks the same internal token the other + * internal APIs do; the gateway stays transport-agnostic and * proprietary-runtime specifics live entirely in the caller. */ export function createInternalExternalAgentsRouter( @@ -86,7 +87,7 @@ export function createInternalExternalAgentsRouter( ): Router { const router = Router(); - router.use(requireInternalToken); + router.use(requireCredential(externalCredential)); router.post("/register", validate({ body: registerSchema }), (req, res) => { const body = getValidated(req).body; diff --git a/apps/server/src/routes/mcp.ts b/apps/server/src/routes/mcp.ts index 647ae3e..65ae727 100644 --- a/apps/server/src/routes/mcp.ts +++ b/apps/server/src/routes/mcp.ts @@ -1,7 +1,7 @@ import { type Request, type Response, Router } from "express"; import { type DeliverToPrime, dispatchMcp } from "../mcp/mcpRelayServer.ts"; -import type { RelayRegistry } from "../mcp/relayRegistry.ts"; +import type { RelayChannel, RelayRegistry } from "../mcp/relayRegistry.ts"; /** * Public MCP endpoint an external client (dialed by the gateway) uses to relay @@ -28,7 +28,7 @@ export function createMcpRelayRouter( 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; + const isAuthed = channel ? authorized(req, channel) : false; logDial("GET", channelId, req, isAuthed, channel !== undefined); if (!channel || !isAuthed) { res.status(channel ? 401 : 404).end(); @@ -47,7 +47,7 @@ async function handlePost( ): Promise { const channelId = String(req.params.channelId); const channel = registry.get(channelId); - const isAuthed = channel ? authorized(req, channel.secret) : false; + const isAuthed = channel ? authorized(req, channel) : false; logDial("POST", channelId, req, isAuthed, channel !== undefined); if (!channel) { res.status(404).json({ @@ -80,8 +80,10 @@ async function handlePost( res.json(response); } -function authorized(req: Request, secret: string): boolean { - return req.get("authorization") === `Bearer ${secret}`; +function authorized(req: Request, channel: RelayChannel): boolean { + return channel.credential.verify({ + authorization: req.get("authorization"), + }); } /** diff --git a/apps/server/src/store/db/schema.ts b/apps/server/src/store/db/schema.ts index 5b7979f..f0731ca 100644 --- a/apps/server/src/store/db/schema.ts +++ b/apps/server/src/store/db/schema.ts @@ -113,8 +113,9 @@ export const sessionAgents = sqliteTable( /** * The agent's connector facets (`ConnectorDescriptor`), backfilled from * `host`. Null on rows written before they existed, which the store reads - * back through `host`. `spawnAuthority` is not stored: it follows from the - * kind, and persisting it would let the two disagree. + * back through `host`. `spawnAuthority` and `credentialScheme` are not + * stored: both follow from the kind, and persisting them would let the two + * disagree. */ connectorKind: text("connector_kind"), connectorLifecycle: text("connector_lifecycle"), diff --git a/apps/server/src/store/sqliteSessionStore.test.ts b/apps/server/src/store/sqliteSessionStore.test.ts index ff44881..cb54464 100644 --- a/apps/server/src/store/sqliteSessionStore.test.ts +++ b/apps/server/src/store/sqliteSessionStore.test.ts @@ -63,6 +63,7 @@ test("recordAgent round-trips a connector descriptor", async () => { kind: "remote-env", lifecycle: "owned", spawnAuthority: "remote-env", + credentialScheme: "shared-token", environmentId: "env-1", }, }); @@ -71,6 +72,7 @@ test("recordAgent round-trips a connector descriptor", async () => { kind: "remote-env", lifecycle: "owned", spawnAuthority: "remote-env", + credentialScheme: "shared-token", environmentId: "env-1", }; assert.deepEqual(recorded.connector, expected); @@ -95,6 +97,7 @@ test("a row recorded without a connector reads back from its host", async () => kind: "remote-env", lifecycle: "owned", spawnAuthority: "remote-env", + credentialScheme: "shared-token", }); // Prime is recorded by `createSession` with no host at all. @@ -167,6 +170,7 @@ test("listAgentsForEnvironment finds one environment's sub-agents across session kind: "remote-env" as const, lifecycle: "owned" as const, spawnAuthority: "remote-env" as const, + credentialScheme: "shared-token" as const, environmentId, }, }); diff --git a/packages/shared/src/contracts.ts b/packages/shared/src/contracts.ts index c082954..9322a45 100644 --- a/packages/shared/src/contracts.ts +++ b/packages/shared/src/contracts.ts @@ -464,6 +464,23 @@ export type ConnectorLifecycle = "owned" | "attached"; /** Who may create a participant on a connector, if anyone. */ export type SpawnAuthority = "server" | "remote-env" | "bundle-tool" | "none"; +/** + * How a connector's far end proves who it is. Only the scheme is named here: + * the secret and the check live behind + * {@link import("../../../apps/server/src/connectors/credentials.ts").ConnectorCredential}, + * server-side, because a credential is not something a client may be told. + * + * `inherited-token` and `internal-bearer` are the same server secret differing + * in issuance — one is handed to a process the server spawns, the other is + * presented back by a caller that already holds it. + */ +export type CredentialScheme = + | "inherited-token" + | "shared-token" + | "internal-bearer" + | "minted-secret" + | "none"; + /** * The independent facets of the connector behind a participant. These are * separate fields rather than one label because they vary independently — the @@ -474,6 +491,7 @@ export interface ConnectorDescriptor { kind: ConnectorKind; lifecycle: ConnectorLifecycle; spawnAuthority: SpawnAuthority; + credentialScheme: CredentialScheme; /** The remote environment this participant is bound to, when it has one. */ environmentId?: string; } @@ -491,22 +509,31 @@ export const CONNECTOR_FACETS: Record< kind: "pi-stdio", lifecycle: "owned", spawnAuthority: "server", + credentialScheme: "inherited-token", }, "remote-env": { kind: "remote-env", lifecycle: "owned", spawnAuthority: "remote-env", + credentialScheme: "shared-token", }, "external-inbound": { kind: "external-inbound", lifecycle: "owned", spawnAuthority: "bundle-tool", + credentialScheme: "internal-bearer", + }, + a2a: { + kind: "a2a", + lifecycle: "attached", + spawnAuthority: "none", + credentialScheme: "none", }, - a2a: { kind: "a2a", lifecycle: "attached", spawnAuthority: "none" }, unresolved: { kind: "unresolved", lifecycle: "attached", spawnAuthority: "none", + credentialScheme: "none", }, }; From 9788c7122b0bf1ddff4d0adafc2b989275dd6570 Mon Sep 17 00:00:00 2001 From: Maksym Yezhov Date: Wed, 12 Aug 2026 16:06:59 -0700 Subject: [PATCH 09/18] - feat: a2a agents connector --- apps/server/package.json | 1 + apps/server/src/a2a/a2aArtifacts.ts | 48 ++ apps/server/src/a2a/a2aClient.ts | 296 +++++++++ apps/server/src/a2a/a2aPeerGateway.test.ts | 487 ++++++++++++++ apps/server/src/a2a/a2aPeerGateway.ts | 511 ++++++++++++++ apps/server/src/config.ts | 7 + .../src/connectors/a2aConnector.test.ts | 154 +++++ apps/server/src/connectors/a2aConnector.ts | 92 +++ .../src/connectors/connectorRegistry.test.ts | 93 ++- .../src/connectors/connectorRegistry.ts | 12 +- .../server/src/connectors/credentials.test.ts | 32 + apps/server/src/connectors/credentials.ts | 45 +- .../conversation/membershipRegistry.test.ts | 38 ++ .../src/conversation/membershipRegistry.ts | 29 +- apps/server/src/index.ts | 14 +- apps/server/src/pi/extensions/orchestrator.ts | 45 +- apps/server/src/routes/internalAgents.ts | 39 ++ .../store/db/migrations/0010_thick_chat.sql | 1 + .../db/migrations/meta/0010_snapshot.json | 623 ++++++++++++++++++ .../store/db/migrations/meta/_journal.json | 7 + apps/server/src/store/db/schema.ts | 6 + .../src/store/sqliteSessionStore.test.ts | 41 ++ apps/server/src/store/sqliteSessionStore.ts | 4 + packages/shared/src/contracts.ts | 29 +- pnpm-lock.yaml | 36 + 25 files changed, 2656 insertions(+), 34 deletions(-) create mode 100644 apps/server/src/a2a/a2aArtifacts.ts create mode 100644 apps/server/src/a2a/a2aClient.ts create mode 100644 apps/server/src/a2a/a2aPeerGateway.test.ts create mode 100644 apps/server/src/a2a/a2aPeerGateway.ts create mode 100644 apps/server/src/connectors/a2aConnector.test.ts create mode 100644 apps/server/src/connectors/a2aConnector.ts create mode 100644 apps/server/src/store/db/migrations/0010_thick_chat.sql create mode 100644 apps/server/src/store/db/migrations/meta/0010_snapshot.json diff --git a/apps/server/package.json b/apps/server/package.json index 104ea97..9d86111 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -16,6 +16,7 @@ "db:migrate": "drizzle-kit migrate" }, "dependencies": { + "@a2a-js/sdk": "^1.0.1", "@tangent/shared": "workspace:*", "@tangent/ui-extensions-sdk": "workspace:*", "better-sqlite3": "^12.10.0", diff --git a/apps/server/src/a2a/a2aArtifacts.ts b/apps/server/src/a2a/a2aArtifacts.ts new file mode 100644 index 0000000..75c50c7 --- /dev/null +++ b/apps/server/src/a2a/a2aArtifacts.ts @@ -0,0 +1,48 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import type { UiCommandEmitter } from "../sockets/sessionRoster.ts"; +import type { SessionStore } from "../store/sessionStore.ts"; +import type { A2aArtifact } from "./a2aClient.ts"; + +/** Workspace directory an attached peer's outputs land under. */ +const A2A_DIR = "a2a"; + +/** Strips path separators and dots so a peer cannot name its way out of a2a/. */ +function safeSegment(name: string): string { + const flattened = name.replace(/[/\\]+/g, "-").replace(/^\.+/, ""); + return flattened.trim() || "artifact"; +} + +/** + * Writes an A2A artifact into the session workspace and pins it, so a peer's + * output arrives on the same path a local agent's pinned artifact does. + * + * Files land under `a2a//`, which keeps a peer's turns apart and gives + * the pinned path something stable to point at. The artifact's name becomes the + * pin title; re-pinning a path refreshes it rather than duplicating. + */ +export async function saveA2aArtifact( + store: SessionStore, + emitUiCommand: UiCommandEmitter, + input: { sessionId: string; taskId: string; artifact: A2aArtifact }, +): Promise { + const session = await store.getSession(input.sessionId); + if (!session || input.artifact.parts.length === 0) return []; + + const relativeDir = path.join(A2A_DIR, safeSegment(input.taskId)); + await mkdir(path.join(session.rootPath, relativeDir), { recursive: true }); + + const written: string[] = []; + for (const part of input.artifact.parts) { + const relativePath = path.join(relativeDir, safeSegment(part.filename)); + await writeFile(path.join(session.rootPath, relativePath), part.body); + const artifacts = await store.pinArtifact(input.sessionId, { + path: relativePath, + title: input.artifact.name, + }); + emitUiCommand(input.sessionId, { kind: "artifacts.update", artifacts }); + written.push(relativePath); + } + return written; +} diff --git a/apps/server/src/a2a/a2aClient.ts b/apps/server/src/a2a/a2aClient.ts new file mode 100644 index 0000000..7ea2fd6 --- /dev/null +++ b/apps/server/src/a2a/a2aClient.ts @@ -0,0 +1,296 @@ +import { randomUUID } from "node:crypto"; + +import { + type Artifact, + type Message, + type Part, + Role, + type SendMessageRequest, + type StreamResponse, + type Task, + type TaskArtifactUpdateEvent, + TaskState, + type TaskStatusUpdateEvent, +} from "@a2a-js/sdk"; +import { + type Client, + ClientFactory, + ClientFactoryOptions, + DefaultAgentCardResolver, + JsonRpcTransportFactory, + RestTransportFactory, +} from "@a2a-js/sdk/client"; + +/** + * What an Agent Card tells Tangent about a peer. Only the display facts: the + * transport it speaks and whether it streams are the SDK's business. + */ +export interface A2aCard { + name: string; + description?: string; +} + +/** + * Where a peer's Task has got to, reduced to what a Run needs. + * `input-required` is A2A's interrupted state: the peer stopped talking and + * waits for more input, so the turn is over while the Task lives on. + */ +export type A2aTaskPhase = + | "working" + | "input-required" + | "completed" + | "failed" + | "canceled"; + +/** One file's worth of an {@link A2aArtifact}. */ +export interface A2aArtifactPart { + filename: string; + body: string | Uint8Array; +} + +/** A peer's output, as something writable. */ +export interface A2aArtifact { + name: string; + parts: A2aArtifactPart[]; +} + +/** + * A peer's stream, normalized. The SDK's wire shapes (a `$case` union over + * protobuf-derived types) stop here, so nothing above this file knows which + * transport or protocol version answered. + */ +export type A2aEvent = + | { kind: "task"; taskId: string; phase: A2aTaskPhase } + | { kind: "status"; taskId: string; phase: A2aTaskPhase; text: string } + | { kind: "artifact"; taskId: string; artifact: A2aArtifact } + | { kind: "message"; taskId: string; text: string }; + +/** What to send a peer: text, optionally continuing an open Task. */ +export interface A2aSend { + text: string; + taskId?: string; + signal?: AbortSignal; +} + +/** An attached A2A agent, as the rest of the server talks to one. */ +export interface A2aPeer { + readonly card: A2aCard; + /** Sends text and yields the peer's reply as it arrives. */ + send(input: A2aSend): AsyncIterable; + /** Asks the peer to cancel a Task. Success is not guaranteed by A2A. */ + cancel(taskId: string): Promise; +} + +/** Discovers a peer and opens a client to it. The seam tests replace. */ +export type A2aConnect = ( + endpointUrl: string, + headers: Record, +) => Promise; + +/** Whether a phase means the peer has stopped working on this turn. */ +export function endsTurn(phase: A2aTaskPhase): boolean { + return phase !== "working"; +} + +/** + * Whether the Task outlived the turn, waiting on us. The one case where the + * next thing we send belongs to the Task we already have rather than a new one. + */ +export function awaitsInput(phase: A2aTaskPhase): boolean { + return phase === "input-required"; +} + +/** + * A2A's task states, as phases. States absent here — submitted, working, and + * whatever a future version adds — are "still working", which is the reading + * that keeps an unknown state from settling a Run early. + */ +const PHASES: Partial> = { + [TaskState.TASK_STATE_COMPLETED]: "completed", + [TaskState.TASK_STATE_CANCELED]: "canceled", + [TaskState.TASK_STATE_FAILED]: "failed", + [TaskState.TASK_STATE_REJECTED]: "failed", + [TaskState.TASK_STATE_INPUT_REQUIRED]: "input-required", + [TaskState.TASK_STATE_AUTH_REQUIRED]: "input-required", +}; + +/** Reads A2A's task state as a phase, defaulting to still working. */ +function phaseOf(state: TaskState | undefined): A2aTaskPhase { + if (state === undefined) return "working"; + return PHASES[state] ?? "working"; +} + +/** The readable content of one part, or nothing for bytes and links. */ +function textOfPart(part: Part): string { + const content = part.content; + if (content?.$case === "text") return content.value; + if (content?.$case === "data") return JSON.stringify(content.value); + return ""; +} + +/** The readable content of a message, parts joined in order. */ +function textOf(message: Message | undefined): string { + if (!message) return ""; + return message.parts.map(textOfPart).join(""); +} + +/** The extension a part's content asks for, since only two kinds are written. */ +function extensionFor(part: Part): string { + return part.content?.$case === "data" ? ".json" : ".txt"; +} + +/** A filename for a part the peer did not name, kept unique within its set. */ +function filenameFor(part: Part, artifact: Artifact, index: number): string { + if (part.filename) return part.filename; + const base = artifact.name || artifact.artifactId || "artifact"; + const suffix = artifact.parts.length > 1 ? `-${index + 1}` : ""; + return `${base}${suffix}${extensionFor(part)}`; +} + +/** The writable body of a part: its bytes, or its text. */ +function bodyOfPart(part: Part): string | Uint8Array { + if (part.content?.$case === "raw") return part.content.value; + return textOfPart(part); +} + +/** Projects an A2A artifact onto files, dropping parts that are only links. */ +function toArtifact(artifact: Artifact): A2aArtifact { + const parts = artifact.parts + .map((part, index) => ({ + filename: filenameFor(part, artifact, index), + body: bodyOfPart(part), + linked: part.content?.$case === "url", + })) + .filter((part) => !part.linked && part.body.length > 0); + return { + name: artifact.name || artifact.artifactId || "artifact", + parts: parts.map(({ filename, body }) => ({ filename, body })), + }; +} + +/** A whole Task, as the phase it is in. */ +function taskEvent(task: Task): A2aEvent { + return { kind: "task", taskId: task.id, phase: phaseOf(task.status?.state) }; +} + +/** A status change, carrying whatever the peer said along with it. */ +function statusEvent(update: TaskStatusUpdateEvent): A2aEvent { + return { + kind: "status", + taskId: update.taskId, + phase: phaseOf(update.status?.state), + text: textOf(update.status?.message), + }; +} + +/** An artifact, unless the update carries none. */ +function artifactEvent(update: TaskArtifactUpdateEvent): A2aEvent | undefined { + if (!update.artifact) return undefined; + return { + kind: "artifact", + taskId: update.taskId, + artifact: toArtifact(update.artifact), + }; +} + +/** A message the peer sent outside any status change. */ +function messageEvent(message: Message): A2aEvent { + return { kind: "message", taskId: message.taskId, text: textOf(message) }; +} + +/** Reads one stream response as an {@link A2aEvent}, or nothing. */ +function toEvent(response: StreamResponse): A2aEvent | undefined { + const payload = response.payload; + if (!payload) return undefined; + if (payload.$case === "task") return taskEvent(payload.value); + if (payload.$case === "statusUpdate") return statusEvent(payload.value); + if (payload.$case === "artifactUpdate") return artifactEvent(payload.value); + return messageEvent(payload.value); +} + +/** Builds the send request for one turn of text. */ +function sendRequest(input: A2aSend): SendMessageRequest { + return { + tenant: "", + message: { + messageId: randomUUID(), + contextId: "", + taskId: input.taskId ?? "", + role: Role.ROLE_USER, + parts: [ + { + content: { $case: "text", value: input.text }, + metadata: undefined, + filename: "", + mediaType: "text/plain", + }, + ], + metadata: undefined, + extensions: [], + referenceTaskIds: [], + }, + configuration: undefined, + metadata: undefined, + }; +} + +/** `fetch` with the peer's credential attached, or plain `fetch` without one. */ +function authorizedFetch(headers: Record): typeof fetch { + if (Object.keys(headers).length === 0) return fetch; + return (input, init) => + fetch(input, { + ...init, + headers: { + ...Object.fromEntries(new Headers(init?.headers)), + ...headers, + }, + }); +} + +/** Wraps an SDK client as an {@link A2aPeer}. */ +function toPeer(client: Client, card: A2aCard): A2aPeer { + return { + card, + async *send(input: A2aSend) { + const stream = client.sendMessageStream(sendRequest(input), { + signal: input.signal, + }); + for await (const response of stream) { + const event = toEvent(response); + if (event) yield event; + } + }, + async cancel(taskId: string) { + await client.cancelTask({ tenant: "", id: taskId, metadata: undefined }); + }, + }; +} + +/** + * Fetches a peer's Agent Card and opens a client to it. Discovery is what + * replaces spawn for A2A: the agent already exists as a service, so attaching + * is reading its card and keeping the address. + * + * The card path is the SDK's default (`/.well-known/agent-card.json`), and the + * transport is whichever of JSON-RPC or HTTP+JSON the card offers. Streaming is + * the SDK's decision too — `sendMessageStream` falls back to a single blocking + * send when the card does not declare it. + */ +export const discoverPeer: A2aConnect = async (endpointUrl, headers) => { + const fetchImpl = authorizedFetch(headers); + const factory = new ClientFactory( + ClientFactoryOptions.createFrom(ClientFactoryOptions.default, { + cardResolver: new DefaultAgentCardResolver({ fetchImpl }), + transports: [ + new JsonRpcTransportFactory({ fetchImpl }), + new RestTransportFactory({ fetchImpl }), + ], + }), + ); + const client = await factory.createFromUrl(endpointUrl); + const card = await client.getAgentCard(); + return toPeer(client, { + name: card.name, + description: card.description || undefined, + }); +}; diff --git a/apps/server/src/a2a/a2aPeerGateway.test.ts b/apps/server/src/a2a/a2aPeerGateway.test.ts new file mode 100644 index 0000000..d1b4d12 --- /dev/null +++ b/apps/server/src/a2a/a2aPeerGateway.test.ts @@ -0,0 +1,487 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { after, test } from "node:test"; + +import type { SubagentInfo, UiCommand } from "@tangent/shared/contracts.ts"; + +// Point the session root at a throwaway dir before importing modules that read +// config at load time, so a written artifact never touches the repo. +const ROOT = mkdtempSync(path.join(tmpdir(), "a2a-gateway-")); +process.env.SESSIONS_ROOT = ROOT; + +const { PeerBearerCredential } = await import("../connectors/credentials.ts"); +const { RunRegistry } = await import("../runs/runRegistry.ts"); +const { InMemoryRunStore } = await import("../store/inMemoryRunStore.ts"); +const { InMemorySessionStore } = + await import("../store/inMemorySessionStore.ts"); +const { A2aPeerGateway } = await import("./a2aPeerGateway.ts"); + +type A2aEvent = import("./a2aClient.ts").A2aEvent; +type A2aPeer = import("./a2aClient.ts").A2aPeer; +type A2aSend = import("./a2aClient.ts").A2aSend; +type AgentEvent = import("../pi/types.ts").AgentEvent; +type ConversationEventSink = import("../pi/types.ts").ConversationEventSink; +type SessionAgent = import("../store/sessionStore.ts").SessionAgent; + +after(() => rmSync(ROOT, { recursive: true, force: true })); + +const ENDPOINT = "https://agent.example.com"; + +/** What a fake peer was asked to do, so a test can assert on the far end. */ +interface PeerLog { + sends: A2aSend[]; + cancels: string[]; + discoveries: Array<{ endpointUrl: string; headers: Record }>; +} + +/** How the fake peer behaves: the events it replays, and whether it ends. */ +interface PeerBehaviour { + events?: A2aEvent[]; + /** Discovery throws, as it does for a peer that has moved or gone away. */ + undiscoverable?: boolean; + /** The stream stays open after its events, until the turn is cancelled. */ + hangs?: boolean; +} + +/** Blocks until `signal` aborts, then throws the way `fetch` does. */ +function untilAborted(signal: AbortSignal | undefined): Promise { + return new Promise((_resolve, reject) => { + signal?.addEventListener("abort", () => + reject(new Error("The operation was aborted.")), + ); + }); +} + +/** + * A peer that replays a canned event stream. Injected in place of the SDK, so a + * test drives the gateway with realistic events and never opens a socket. + */ +function fakePeer(behaviour: PeerBehaviour, log: PeerLog): A2aPeer { + return { + card: { name: "Weather", description: "Knows the weather" }, + async *send(input: A2aSend) { + log.sends.push(input); + for (const event of behaviour.events ?? []) yield event; + if (behaviour.hangs) await untilAborted(input.signal); + }, + async cancel(taskId: string) { + log.cancels.push(taskId); + }, + }; +} + +/** A relayed agent event, reduced to what these tests assert on. */ +interface RelayedEvent { + type: string; + text?: string; + runId?: string; +} + +/** The text an agent event carries, whichever half of a stream it is. */ +function textOfEvent(event: AgentEvent): string | undefined { + if ("delta" in event) return event.delta; + if ("content" in event) return event.content; + return undefined; +} + +/** A recording sink, so a test reads exactly what the gateway surfaced. */ +function captureSink(): { + handlers: ConversationEventSink; + rosterUpdates: SubagentInfo[]; + agentEvents: RelayedEvent[]; + notices: Array<{ conversationId: string; content: string }>; +} { + const rosterUpdates: SubagentInfo[] = []; + const agentEvents: RelayedEvent[] = []; + const notices: Array<{ conversationId: string; content: string }> = []; + + return { + rosterUpdates, + agentEvents, + notices, + handlers: { + onAgentEvent: (_sessionId, _agent, event) => + agentEvents.push({ + type: event.type, + text: textOfEvent(event), + runId: event.runId, + }), + onSubagentUpdate: (_sessionId, info) => rosterUpdates.push(info), + onAgentMessage: ({ conversationId, content }) => + notices.push({ conversationId, content }), + onSessionStatus: () => {}, + }, + }; +} + +/** A gateway over a fake peer, with every handler call captured. */ +function makeHarness(behaviour: PeerBehaviour = {}) { + const sink = captureSink(); + const uiCommands: UiCommand[] = []; + const log: PeerLog = { sends: [], cancels: [], discoveries: [] }; + const runStore = new InMemoryRunStore(); + const runs = new RunRegistry(runStore); + const store = new InMemorySessionStore(); + const gateway = new A2aPeerGateway( + sink.handlers, + runs, + store, + (_sessionId, command) => uiCommands.push(command), + new PeerBearerCredential("peer-secret"), + async (endpointUrl, headers) => { + log.discoveries.push({ endpointUrl, headers }); + if (behaviour.undiscoverable) throw new Error("card not found"); + return fakePeer(behaviour, log); + }, + ); + + return { ...sink, gateway, store, runs, runStore, log, uiCommands }; +} + +/** A persisted roster row for an attached peer, as a revive reads one. */ +function agentRow(overrides: Partial = {}): SessionAgent { + return { + id: "peer-1", + sessionId: "s1", + role: "subagent", + name: "Weather", + status: "detached", + connector: { + kind: "a2a", + lifecycle: "attached", + spawnAuthority: "none", + credentialScheme: "peer-bearer", + endpointUrl: ENDPOINT, + }, + createdAt: "2026-01-01T00:00:00.000Z", + ...overrides, + }; +} + +/** + * Waits for the work the gateway started behind `send`, which includes writing + * a file. Polled rather than counted in ticks so a slower disk cannot flake it. + */ +async function settled(done: () => boolean): Promise { + for (let attempt = 0; attempt < 500; attempt += 1) { + if (done()) return; + await new Promise((resolve) => setTimeout(resolve, 2)); + } + assert.fail("the turn never finished"); +} + +test("attach discovers the card, names the tab from it, and persists the endpoint", async () => { + const h = makeHarness(); + + const info = await h.gateway.attach("s1", { endpointUrl: ENDPOINT }); + + assert.equal(info.name, "Weather"); + assert.equal(info.connector.kind, "a2a"); + assert.equal(info.connector.endpointUrl, ENDPOINT); + assert.equal(h.gateway.hasAgent("s1", info.id), true); + assert.deepEqual( + h.rosterUpdates.map((r) => r.status), + ["active"], + ); + + // The credential travels outbound: discovery is Tangent dialling a far end. + assert.deepEqual(h.log.discoveries, [ + { endpointUrl: ENDPOINT, headers: { Authorization: "Bearer peer-secret" } }, + ]); + + // Awaited, not fired off: a Membership is derived from this row, so a delivery + // arriving before it landed would find nobody to address. + const persisted = (await h.store.listAgents("s1")).find( + (a) => a.id === info.id, + ); + assert.equal(persisted?.connector.endpointUrl, ENDPOINT); + assert.equal(persisted?.purpose, "Knows the weather"); +}); + +test("a caller's name wins over the one on the card", async () => { + const h = makeHarness(); + const info = await h.gateway.attach("s1", { + endpointUrl: ENDPOINT, + name: "Forecaster", + }); + assert.equal(info.name, "Forecaster"); +}); + +test("attach fails when the card cannot be read, leaving no tab behind", async () => { + const h = makeHarness({ undiscoverable: true }); + + await assert.rejects(h.gateway.attach("s1", { endpointUrl: ENDPOINT })); + + assert.deepEqual(h.gateway.listSubagents("s1"), []); + assert.deepEqual(await h.store.listAgents("s1"), []); +}); + +test("a peer's stream becomes agent events, one persisted turn and a settled Run", async () => { + const h = makeHarness({ + events: [ + { kind: "task", taskId: "task-1", phase: "working" }, + { + kind: "status", + taskId: "task-1", + phase: "working", + text: "Looking...", + }, + { + kind: "status", + taskId: "task-1", + phase: "completed", + text: "It rains.", + }, + ], + }); + const info = await h.gateway.attach("s1", { endpointUrl: ENDPOINT }); + + assert.equal( + h.gateway.send({ sessionId: "s1", participantId: info.id, text: "hi" }), + true, + ); + await settled(() => h.agentEvents.at(-1)?.type === "end"); + + assert.deepEqual( + h.agentEvents.map((e) => e.type), + ["start", "delta", "delta", "end"], + ); + assert.equal(h.agentEvents.at(-1)?.text, "Looking...It rains."); + + // The Task is the far side's name for this work, so the Run carries it. + const runs = await h.runStore.listRuns("s1"); + assert.equal(runs.length, 1); + assert.equal(runs[0].externalId, "task-1"); + assert.equal(runs[0].status, "completed"); + assert.equal(runs[0].ingress, "reaction"); + assert.equal(h.runs.current("s1", info.id), undefined); +}); + +test("a Task left waiting for input keeps its id for the next turn", async () => { + const h = makeHarness({ + events: [ + { kind: "task", taskId: "task-1", phase: "working" }, + { + kind: "status", + taskId: "task-1", + phase: "input-required", + text: "Which city?", + }, + ], + }); + const info = await h.gateway.attach("s1", { endpointUrl: ENDPOINT }); + + h.gateway.send({ sessionId: "s1", participantId: info.id, text: "weather?" }); + await settled(() => h.agentEvents.at(-1)?.type === "end"); + h.gateway.send({ sessionId: "s1", participantId: info.id, text: "Berlin" }); + await settled(() => h.log.sends.length === 2); + + // The first turn opened the Task; the second continues it rather than + // starting a fresh one, which is what a multi-turn A2A exchange is. + assert.deepEqual( + h.log.sends.map((send) => send.taskId), + [undefined, "task-1"], + ); +}); + +test("a completed Task is not continued: the next turn opens a new one", async () => { + const h = makeHarness({ + events: [ + { kind: "status", taskId: "task-1", phase: "completed", text: "done" }, + ], + }); + const info = await h.gateway.attach("s1", { endpointUrl: ENDPOINT }); + + h.gateway.send({ sessionId: "s1", participantId: info.id, text: "one" }); + await settled(() => h.log.sends.length === 1); + h.gateway.send({ sessionId: "s1", participantId: info.id, text: "two" }); + await settled(() => h.log.sends.length === 2); + + assert.deepEqual( + h.log.sends.map((send) => send.taskId), + [undefined, undefined], + ); +}); + +test("an artifact is written under the session root and pinned", async () => { + const h = makeHarness({ + events: [ + { + kind: "artifact", + taskId: "task-1", + artifact: { + name: "forecast", + parts: [{ filename: "forecast.txt", body: "rain tomorrow" }], + }, + }, + { kind: "status", taskId: "task-1", phase: "completed", text: "done" }, + ], + }); + const session = await h.store.createSession({ name: "S" }); + const info = await h.gateway.attach(session.id, { endpointUrl: ENDPOINT }); + + h.gateway.send({ + sessionId: session.id, + participantId: info.id, + text: "forecast?", + }); + await settled(() => h.uiCommands.length === 1); + + const artifacts = await h.store.getArtifacts(session.id); + assert.deepEqual( + artifacts.map((a) => [a.path, a.title]), + [[path.join("a2a", "task-1", "forecast.txt"), "forecast"]], + ); + assert.equal( + readFileSync(path.join(session.rootPath, artifacts[0].path), "utf8"), + "rain tomorrow", + ); + assert.deepEqual( + h.uiCommands.map((c) => c.kind), + ["artifacts.update"], + ); +}); + +test("cancel aborts the local stream and reaches the peer's Task", async () => { + // A peer that keeps working after its first output, so there is something to + // cancel: without a hanging stream the turn would already be over. + const h = makeHarness({ + events: [ + { kind: "task", taskId: "task-1", phase: "working" }, + { kind: "status", taskId: "task-1", phase: "working", text: "thinking" }, + ], + hangs: true, + }); + const info = await h.gateway.attach("s1", { endpointUrl: ENDPOINT }); + + h.gateway.send({ sessionId: "s1", participantId: info.id, text: "hi" }); + await settled(() => h.agentEvents.at(-1)?.type === "delta"); + const cancelled = h.gateway.cancel("s1", info.id); + await settled(() => h.agentEvents.at(-1)?.type === "end"); + + assert.equal(cancelled, true); + assert.deepEqual(h.log.cancels, ["task-1"]); + // The partial reply is persisted as history that provokes nobody, and the Run + // records that it was cancelled rather than that it failed. + const runs = await h.runStore.listRuns("s1"); + assert.equal(runs[0].status, "cancelled"); + assert.equal(h.agentEvents.at(-1)?.text, "thinking"); +}); + +test("cancelling a peer with nothing running is refused", async () => { + const h = makeHarness(); + const info = await h.gateway.attach("s1", { endpointUrl: ENDPOINT }); + + assert.equal(h.gateway.cancel("s1", info.id), false); + assert.equal(h.gateway.cancel("s1", "ghost"), false); + assert.deepEqual(h.log.cancels, []); +}); + +test("reattach restores a persisted tab as detached without dialling anyone", () => { + const h = makeHarness(); + + h.gateway.reattach("s1", agentRow()); + + assert.equal(h.gateway.hasAgent("s1", "peer-1"), true); + assert.equal(h.rosterUpdates.at(-1)?.status, "detached"); + assert.equal(h.rosterUpdates.at(-1)?.connector.endpointUrl, ENDPOINT); + // A peer is a service that may well be gone; a restart is no reason to wake it. + assert.deepEqual(h.log.discoveries, []); +}); + +test("a row with no endpoint is not restorable", () => { + const h = makeHarness(); + + h.gateway.reattach( + "s1", + agentRow({ + connector: { + kind: "a2a", + lifecycle: "attached", + spawnAuthority: "none", + credentialScheme: "peer-bearer", + }, + }), + ); + + assert.equal(h.gateway.hasAgent("s1", "peer-1"), false); +}); + +test("a detached peer re-discovers on the next delivery and goes active", async () => { + const h = makeHarness({ + events: [ + { kind: "status", taskId: "task-1", phase: "completed", text: "back" }, + ], + }); + h.gateway.reattach("s1", agentRow()); + + h.gateway.send({ + sessionId: "s1", + participantId: "peer-1", + text: "still up?", + }); + await settled(() => h.agentEvents.at(-1)?.type === "end"); + + assert.deepEqual( + h.log.discoveries.map((d) => d.endpointUrl), + [ENDPOINT], + ); + assert.equal(h.rosterUpdates.at(-1)?.status, "active"); + assert.equal(h.agentEvents.at(-1)?.text, "back"); +}); + +test("a peer that cannot be re-discovered refuses in its own thread", async () => { + const h = makeHarness({ undiscoverable: true }); + h.gateway.reattach("s1", agentRow()); + + // Accepted by the connector and refused later: an A2A turn is a request the + // sender does not wait on, so an unreachable far end explains itself here. + assert.equal( + h.gateway.send({ + sessionId: "s1", + participantId: "peer-1", + text: "still up?", + }), + true, + ); + await settled(() => h.notices.length === 1); + + assert.equal(h.notices.at(-1)?.conversationId, "peer-1"); + assert.match(h.notices.at(-1)?.content ?? "", /Couldn't reach Weather/); + assert.equal(h.rosterUpdates.at(-1)?.status, "detached"); + assert.deepEqual(await h.runStore.listRuns("s1"), []); +}); + +test("send is refused for a participant this gateway does not hold", () => { + const h = makeHarness(); + + assert.equal( + h.gateway.send({ sessionId: "s1", participantId: "ghost", text: "hi" }), + false, + ); + assert.deepEqual(h.notices, []); +}); + +test("detach ends the attachment and the Task, not the agent", async () => { + const h = makeHarness({ + events: [ + { kind: "task", taskId: "task-1", phase: "working" }, + { kind: "status", taskId: "task-1", phase: "working", text: "thinking" }, + ], + hangs: true, + }); + const info = await h.gateway.attach("s1", { endpointUrl: ENDPOINT }); + h.gateway.send({ sessionId: "s1", participantId: info.id, text: "hi" }); + await settled(() => h.agentEvents.at(-1)?.type === "delta"); + + h.gateway.detach("s1", info.id, true); + await settled(() => h.log.cancels.length === 1); + + assert.equal(h.gateway.hasAgent("s1", info.id), false); + assert.equal(h.rosterUpdates.at(-1)?.status, "completed"); + assert.deepEqual(h.log.cancels, ["task-1"]); + const runs = await h.runStore.listRuns("s1"); + assert.equal(runs[0].status, "completed"); +}); diff --git a/apps/server/src/a2a/a2aPeerGateway.ts b/apps/server/src/a2a/a2aPeerGateway.ts new file mode 100644 index 0000000..dcb3f7e --- /dev/null +++ b/apps/server/src/a2a/a2aPeerGateway.ts @@ -0,0 +1,511 @@ +import { randomUUID } from "node:crypto"; + +import { + connectorFor, + type Run, + type RunIngress, + type SubagentInfo, + type SubagentStatus, + SYSTEM_AUTHOR, +} from "@tangent/shared/contracts.ts"; + +import { + a2aCredential, + type PeerBearerCredential, +} from "../connectors/credentials.ts"; +import type { + AgentDescriptor, + AgentEvent, + ConversationEventSink, +} from "../pi/types.ts"; +import type { RunRegistry, SettledStatus } from "../runs/runRegistry.ts"; +import type { UiCommandEmitter } from "../sockets/sessionRoster.ts"; +import type { SessionAgent, SessionStore } from "../store/sessionStore.ts"; +import { saveA2aArtifact } from "./a2aArtifacts.ts"; +import { + type A2aConnect, + type A2aEvent, + type A2aPeer, + type A2aTaskPhase, + awaitsInput, + discoverPeer, + endsTurn, +} from "./a2aClient.ts"; + +/** What a caller supplies to attach an A2A agent to a session. */ +export interface AttachA2aAgent { + /** Base URL the agent's card is served from. */ + endpointUrl: string; + /** Overrides the name on the card, for a session with two of the same agent. */ + name?: string; +} + +/** What to deliver to an attached peer. */ +export interface SendToPeer { + sessionId: string; + participantId: string; + text: string; + ingress?: RunIngress; +} + +/** An attached peer's tab, plus what its in-flight turn needs. */ +interface A2aTab { + agentId: string; + name: string; + status: SubagentStatus; + endpointUrl: string; + createdAt: string; + /** The discovered client. Absent while the tab is detached. */ + peer?: A2aPeer; + /** The peer's Task this exchange continues, while it stays open. */ + taskId?: string; + /** Aborts the in-flight turn's stream. */ + abort?: AbortController; +} + +/** One turn's accumulating state: what has streamed, and how it ended. */ +interface Turn { + sessionId: string; + tab: A2aTab; + run: Run; + messageId: string; + started: boolean; + content: string; + phase: A2aTaskPhase; +} + +/** Projects a tab onto the wire {@link SubagentInfo}. */ +function toInfo(tab: A2aTab): SubagentInfo { + return { + id: tab.agentId, + name: tab.name, + status: tab.status, + connector: { ...connectorFor("a2a"), endpointUrl: tab.endpointUrl }, + createdAt: tab.createdAt, + }; +} + +/** The terminal state a Run reaches when its peer's Task ends in `phase`. */ +function settledFor(phase: A2aTaskPhase): SettledStatus { + if (phase === "failed") return "failed"; + if (phase === "canceled") return "cancelled"; + return "completed"; +} + +/** + * Registry of **attached A2A agents**: heterogeneous agents that already run as + * a service somewhere and speak the A2A protocol. Tangent is their client, so + * there is no spawn and no kill — a tab is created by discovering an Agent Card + * and ended by letting go of the address. + * + * A turn is one {@link Run}: the gateway opens it, records the peer's Task id on + * it as `externalId`, relays the peer's stream into the tab through the shared + * {@link ConversationEventSink}, and settles it when the peer's Task reaches a + * terminal state. That is what makes a peer's work inspectable and cancellable + * on the same terms as a local agent's, without Tangent owning the process. + * + * Sits alongside {@link + * import("../external/externalSubagentGateway.ts").ExternalSubagentGateway} and + * {@link import("../remote/remoteEnvironmentGateway.ts").RemoteEnvironmentGateway}. + */ +export class A2aPeerGateway { + private readonly handlers: ConversationEventSink; + private readonly runs: RunRegistry; + private readonly store: SessionStore; + private readonly emitUiCommand: UiCommandEmitter; + private readonly credential: PeerBearerCredential; + private readonly connect: A2aConnect; + + /** Per-session peer rosters, keyed by sessionId then agentId. */ + private readonly sessions = new Map>(); + + constructor( + handlers: ConversationEventSink, + runs: RunRegistry, + store: SessionStore, + emitUiCommand: UiCommandEmitter, + credential: PeerBearerCredential = a2aCredential, + connect: A2aConnect = discoverPeer, + ) { + this.handlers = handlers; + this.runs = runs; + this.store = store; + this.emitUiCommand = emitUiCommand; + this.credential = credential; + this.connect = connect; + } + + /** True when `agentId` is an attached A2A peer of `sessionId`. */ + hasAgent(sessionId: string, agentId: string): boolean { + return this.tabFor(sessionId, agentId) !== undefined; + } + + /** The session's attached peers. */ + listSubagents(sessionId: string): SubagentInfo[] { + const roster = this.sessions.get(sessionId); + if (!roster) return []; + return [...roster.values()].map(toInfo); + } + + /** + * Attaches a peer: reads its Agent Card, takes the name from it, and records + * the tab. Discovery is what replaces spawn here, so a card that cannot be + * read is a failed attach rather than a tab that never works. + * + * The roster row is awaited, not fired off: a Membership is derived from it, + * so a delivery arriving before it lands would find nobody to address. + */ + async attach(sessionId: string, spec: AttachA2aAgent): Promise { + const headers = this.credential.headers(); + const peer = await this.connect(spec.endpointUrl, headers); + const tab: A2aTab = { + agentId: randomUUID(), + name: spec.name?.trim() || peer.card.name, + status: "active", + endpointUrl: spec.endpointUrl, + createdAt: new Date().toISOString(), + peer, + }; + this.rosterFor(sessionId).set(tab.agentId, tab); + await this.store.recordAgent(sessionId, { + id: tab.agentId, + role: "subagent", + name: tab.name, + purpose: peer.card.description, + status: tab.status, + connector: { ...connectorFor("a2a"), endpointUrl: tab.endpointUrl }, + }); + const info = toInfo(tab); + this.handlers.onSubagentUpdate(sessionId, info); + return info; + } + + /** + * Restores a persisted tab as `detached`, from the endpoint the row kept. + * Nothing is dialled here: a peer is a service that may well be gone, and a + * restart is no reason to wake it. The next delivery re-discovers it. + * + * Idempotent, and never downgrades a live tab. + */ + reattach(sessionId: string, agent: SessionAgent): void { + const roster = this.rosterFor(sessionId); + if (roster.get(agent.id)?.status === "active") return; + const endpointUrl = agent.connector.endpointUrl; + if (!endpointUrl) return; + + const tab: A2aTab = { + agentId: agent.id, + name: agent.name, + status: "detached", + endpointUrl, + createdAt: agent.createdAt, + }; + roster.set(agent.id, tab); + this.handlers.onSubagentUpdate(sessionId, toInfo(tab)); + } + + /** + * Starts a turn against an attached peer. Returns whether this gateway holds + * the participant at all — the exchange itself runs behind, because an A2A + * turn is a request/response the sender does not wait on. + */ + send(input: SendToPeer): boolean { + const tab = this.tabFor(input.sessionId, input.participantId); + if (!tab) return false; + void this.turn(input, tab).catch((err: unknown) => { + console.error( + `[a2a] turn for "${tab.name}" in session ${input.sessionId} failed:`, + err, + ); + }); + return true; + } + + /** + * Cancels a peer's turn: drops the local stream and asks the peer to cancel + * its Task. Both matter — the first stops the tab from filling with output + * nobody asked for, the second is the only thing that stops the work. + */ + cancel(sessionId: string, agentId: string): boolean { + const tab = this.tabFor(sessionId, agentId); + if (!tab?.abort) return false; + this.stop(tab); + // Settled here rather than left to the aborted stream: a peer that ignores + // the abort must not leave a Run running forever. The turn's own settle + // finds nothing to do. + this.runs.settleOpenFor(sessionId, agentId, "cancelled"); + return true; + } + + /** + * Ends the attachment. What that is not: ending the agent. A peer outlives + * every session that talks to it, so this drops the tab, stops listening, and + * asks the peer to cancel whatever it was doing for us. + */ + detach(sessionId: string, agentId: string, completed: boolean): void { + const tab = this.tabFor(sessionId, agentId); + if (!tab) return; + + this.stop(tab); + tab.status = completed ? "completed" : "killed"; + this.rosterFor(sessionId).delete(agentId); + this.runs.settleOpenFor( + sessionId, + agentId, + completed ? "completed" : "failed", + ); + this.handlers.onSubagentUpdate(sessionId, toInfo(tab)); + } + + /** Runs one exchange with a peer, relaying its stream into the tab. */ + private async turn(input: SendToPeer, tab: A2aTab): Promise { + const peer = await this.peerFor(input.sessionId, tab); + if (!peer) { + this.refuse(input, `Couldn't reach ${tab.name} at ${tab.endpointUrl}.`); + return; + } + + const abort = new AbortController(); + tab.abort = abort; + const turn = this.openTurn(input, tab); + + try { + await this.stream( + turn, + peer.send({ + text: input.text, + taskId: tab.taskId, + signal: abort.signal, + }), + ); + } catch (err) { + // A cancelled turn is not a broken one: the stream ends because we asked + // it to, and what the peer had said by then is still history. + if (abort.signal.aborted) this.abortTurn(turn); + else this.failTurn(turn, err); + return; + } finally { + if (tab.abort === abort) tab.abort = undefined; + } + this.finishTurn(turn); + } + + /** Opens the Run one turn is attributable to, and its accumulating state. */ + private openTurn(input: SendToPeer, tab: A2aTab): Turn { + return { + sessionId: input.sessionId, + tab, + run: this.runs.open({ + sessionId: input.sessionId, + participantId: tab.agentId, + ingress: input.ingress ?? "reaction", + externalId: tab.taskId, + }), + messageId: randomUUID(), + started: false, + content: "", + phase: "working", + }; + } + + /** Relays a peer's stream until it ends, or until its Task does. */ + private async stream( + turn: Turn, + events: AsyncIterable, + ): Promise { + for await (const event of events) { + await this.relay(turn, event); + // A peer that keeps the stream open past a terminal state must not keep + // the turn open with it. + if (endsTurn(turn.phase)) break; + } + } + + /** Applies one normalized peer event to the turn. */ + private async relay(turn: Turn, event: A2aEvent): Promise { + this.noteTask(turn, event.taskId); + if (event.kind === "artifact") { + await saveA2aArtifact(this.store, this.emitUiCommand, { + sessionId: turn.sessionId, + taskId: event.taskId, + artifact: event.artifact, + }); + return; + } + if (event.kind === "task") { + turn.phase = event.phase; + return; + } + if (event.kind === "status") turn.phase = event.phase; + this.append(turn, event.text); + } + + /** + * Records the peer's own id for this work against the Run, which is what makes + * the two halves of a distributed turn reconcilable after the fact. + */ + private noteTask(turn: Turn, taskId: string): void { + if (!taskId || turn.tab.taskId === taskId) return; + turn.tab.taskId = taskId; + this.runs.setExternalId(turn.run.id, taskId); + } + + /** Streams the peer's text into the tab, opening the bubble on first sight. */ + private append(turn: Turn, text: string): void { + if (!text) return; + if (!turn.started) { + turn.started = true; + this.emit(turn, { type: "start", messageId: turn.messageId }); + } + turn.content += text; + this.emit(turn, { + type: "delta", + messageId: turn.messageId, + delta: text, + }); + } + + /** + * Finalizes a turn: persists what the peer said and settles the Run in the + * state its Task reached. A Task left waiting for input keeps its id, so the + * next delivery continues the same exchange rather than starting a new one. + */ + private finishTurn(turn: Turn): void { + if (turn.started) { + this.emit(turn, { + type: "end", + messageId: turn.messageId, + content: turn.content, + thinking: "", + aborted: turn.phase === "canceled", + }); + } + this.runs.settle(turn.run.id, settledFor(turn.phase)); + if (!awaitsInput(turn.phase)) turn.tab.taskId = undefined; + } + + /** + * Closes a turn the user cut short: the partial reply is persisted as history + * that provokes nobody, and the peer is kept — it did nothing wrong. + */ + private abortTurn(turn: Turn): void { + if (turn.started) { + this.emit(turn, { + type: "end", + messageId: turn.messageId, + content: turn.content, + thinking: "", + aborted: true, + }); + } + this.runs.settle(turn.run.id, "cancelled"); + } + + /** + * Reports a broken exchange in the peer's own thread and lets go of the + * client, so the next delivery re-discovers rather than retrying a peer that + * may have been restarted or moved. + */ + private failTurn(turn: Turn, err: unknown): void { + const message = err instanceof Error ? err.message : String(err); + this.emit(turn, { + type: "error", + messageId: turn.started ? turn.messageId : undefined, + message: `${turn.tab.name} stopped responding: ${message}`, + }); + this.runs.settle(turn.run.id, "failed"); + turn.tab.peer = undefined; + turn.tab.taskId = undefined; + this.markDetached(turn.sessionId, turn.tab); + } + + /** + * The peer's client, discovered on first use and after a restart. A tab whose + * peer cannot be reached goes `detached` rather than being dropped: the + * address is still good tomorrow. + */ + private async peerFor( + sessionId: string, + tab: A2aTab, + ): Promise { + if (tab.peer) return tab.peer; + try { + tab.peer = await this.connect(tab.endpointUrl, this.credential.headers()); + } catch (err) { + console.error(`[a2a] discovery of ${tab.endpointUrl} failed:`, err); + this.markDetached(sessionId, tab); + return undefined; + } + this.markAttached(sessionId, tab); + return tab.peer; + } + + /** Drops the in-flight turn: stops listening, and stops the peer working. */ + private stop(tab: A2aTab): void { + tab.abort?.abort(); + tab.abort = undefined; + this.cancelTask(tab); + } + + /** Asks the peer to cancel the Task we have open with it, if any. */ + private cancelTask(tab: A2aTab): void { + const taskId = tab.taskId; + const peer = tab.peer; + tab.taskId = undefined; + if (!taskId || !peer) return; + void peer.cancel(taskId).catch((err: unknown) => { + console.error(`[a2a] cancelling task ${taskId} failed:`, err); + }); + } + + /** Says in the peer's own thread why a message went nowhere. */ + private refuse(input: SendToPeer, reason: string): void { + this.handlers.onAgentMessage({ + sessionId: input.sessionId, + conversationId: input.participantId, + author: SYSTEM_AUTHOR, + content: reason, + }); + } + + /** Marks a tab live, surfacing the change only when it is one. */ + private markAttached(sessionId: string, tab: A2aTab): void { + if (tab.status === "active") return; + tab.status = "active"; + this.handlers.onSubagentUpdate(sessionId, toInfo(tab)); + } + + /** Marks a tab detached, surfacing the change only when it is one. */ + private markDetached(sessionId: string, tab: A2aTab): void { + if (tab.status !== "active") return; + tab.status = "detached"; + this.handlers.onSubagentUpdate(sessionId, toInfo(tab)); + } + + /** Relays one agent event, attributed to the turn's Run. */ + private emit(turn: Turn, event: AgentEvent): void { + this.handlers.onAgentEvent(turn.sessionId, descriptorFor(turn.tab), { + ...event, + runId: turn.run.id, + }); + } + + /** The tab for a participant, if this gateway holds one. */ + private tabFor(sessionId: string, agentId: string): A2aTab | undefined { + return this.sessions.get(sessionId)?.get(agentId); + } + + /** Returns (creating if needed) the session's peer 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. */ +function descriptorFor(tab: A2aTab): AgentDescriptor { + return { agentId: tab.agentId, role: "subagent", name: tab.name }; +} diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 84acfb4..293add0 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -158,6 +158,13 @@ export const PUBLIC_URL = (process.env.TANGENT_PUBLIC_URL ?? "").replace( */ export const REMOTE_ENV_TOKEN = process.env.REMOTE_ENV_TOKEN ?? ""; +/** + * Secret Tangent presents (as a bearer token) to an attached A2A agent. Unlike + * the other connector secrets this one travels outbound, so an empty value is + * not a lockout: a peer that asks for no credential is still reachable. + */ +export const A2A_TOKEN = process.env.A2A_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/connectors/a2aConnector.test.ts b/apps/server/src/connectors/a2aConnector.test.ts new file mode 100644 index 0000000..81e7474 --- /dev/null +++ b/apps/server/src/connectors/a2aConnector.test.ts @@ -0,0 +1,154 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import type { A2aPeerGateway } from "../a2a/a2aPeerGateway.ts"; +import type { ConversationEventSink } from "../pi/types.ts"; +import type { SessionAgent } from "../store/sessionStore.ts"; +import { A2aConnector } from "./a2aConnector.ts"; +import type { Connector } from "./types.ts"; + +/** A gateway recording what the connector asked of it. */ +function makeHarness(holds = true) { + const sends: string[] = []; + const cancels: string[] = []; + const detaches: Array<{ agentId: string; completed: boolean }> = []; + const reattaches: string[] = []; + const surfaced: Array<{ conversationId: string; content: string }> = []; + + const gateway = { + hasAgent: () => holds, + listSubagents: () => [], + send: ({ text }: { text: string }) => { + sends.push(text); + return holds; + }, + cancel: (_sessionId: string, agentId: string) => { + cancels.push(agentId); + return holds; + }, + detach: (_sessionId: string, agentId: string, completed: boolean) => + detaches.push({ agentId, completed }), + reattach: (_sessionId: string, agent: SessionAgent) => + reattaches.push(agent.id), + } as unknown as A2aPeerGateway; + + const handlers: ConversationEventSink = { + onAgentEvent: () => {}, + onSubagentUpdate: () => {}, + onAgentMessage: ({ conversationId, content }) => + surfaced.push({ conversationId, content }), + onSessionStatus: () => {}, + }; + + // Held as the interface, so a test sees the connector the registry sees — + // including the `spawn` this one deliberately does not implement. + const connector: Connector = new A2aConnector(gateway, handlers); + + return { + connector, + sends, + cancels, + detaches, + reattaches, + surfaced, + }; +} + +/** A persisted roster row, as a revive reads one. */ +function agentRow(): SessionAgent { + return { + id: "peer-1", + sessionId: "s1", + role: "subagent", + name: "Weather", + status: "detached", + connector: { + kind: "a2a", + lifecycle: "attached", + spawnAuthority: "none", + credentialScheme: "peer-bearer", + endpointUrl: "https://agent.example.com", + }, + createdAt: "2026-01-01T00:00:00.000Z", + }; +} + +test("the connector cannot spawn: an A2A agent is discovered, not created", () => { + const h = makeHarness(); + + // Absent rather than refusing at runtime, which is what `spawnAuthority: + // "none"` means for a connector whose far end exists without us. + assert.equal(h.connector.spawn, undefined); + assert.equal(h.connector.descriptor.spawnAuthority, "none"); + assert.equal(h.connector.descriptor.lifecycle, "attached"); +}); + +test("the published scheme is the one its credential implements", () => { + const h = makeHarness(); + + assert.equal(h.connector.descriptor.credentialScheme, "peer-bearer"); + assert.equal(h.connector.credential.scheme, "peer-bearer"); +}); + +test("delivery is accepted synchronously and handed to the gateway", () => { + const h = makeHarness(); + + const result = h.connector.deliver({ + sessionId: "s1", + participantId: "peer-1", + text: "do the thing", + }); + + assert.deepEqual(result, { delivered: true }); + assert.deepEqual(h.sends, ["do the thing"]); + // Nothing is said in the thread: the reply is what the peer will say there. + assert.deepEqual(h.surfaced, []); +}); + +test("a delivery to a peer no longer attached is refused in its own thread", () => { + const h = makeHarness(false); + + const result = h.connector.deliver({ + sessionId: "s1", + participantId: "peer-1", + text: "are you there", + }); + + assert.equal(result.delivered, false); + assert.equal(h.surfaced.at(-1)?.conversationId, "peer-1"); + assert.match(h.surfaced.at(-1)?.content ?? "", /no longer attached/); +}); + +test("cancelling reaches the gateway, and says why when there is nothing to stop", () => { + const running = makeHarness(); + const idle = makeHarness(false); + + assert.deepEqual( + running.connector.cancelRun({ sessionId: "s1", participantId: "peer-1" }), + { cancelled: true }, + ); + assert.deepEqual(running.cancels, ["peer-1"]); + + const refused = idle.connector.cancelRun({ + sessionId: "s1", + participantId: "peer-1", + }); + assert.equal(refused.cancelled, false); + assert.ok(refused.reason); +}); + +test("kill ends the attachment rather than the agent", () => { + const h = makeHarness(); + + h.connector.kill("s1", "peer-1", true); + + assert.deepEqual(h.detaches, [{ agentId: "peer-1", completed: true }]); +}); + +test("revive restores the tab from the row, since nobody will reattach it", () => { + const h = makeHarness(); + + h.connector.revive("s1", agentRow()); + + assert.deepEqual(h.reattaches, ["peer-1"]); +}); diff --git a/apps/server/src/connectors/a2aConnector.ts b/apps/server/src/connectors/a2aConnector.ts new file mode 100644 index 0000000..35b1c5f --- /dev/null +++ b/apps/server/src/connectors/a2aConnector.ts @@ -0,0 +1,92 @@ +import { connectorFor } from "@tangent/shared/contracts.ts"; + +import type { A2aPeerGateway } from "../a2a/a2aPeerGateway.ts"; +import type { ConversationEventSink } from "../pi/types.ts"; +import type { SessionAgent } from "../store/sessionStore.ts"; +import { a2aCredential } from "./credentials.ts"; +import { refuseDelivery } from "./refusal.ts"; +import type { + CancelResult, + Connector, + DeliveryRequest, + DeliveryResult, + RunCancellation, +} from "./types.ts"; + +/** Shown in the peer's own thread when this gateway no longer holds it. */ +const NOT_ATTACHED = + "This agent is no longer attached to the session, so the message wasn't delivered."; + +/** Why a cancellation was refused: the peer had nothing open for us. */ +const NOTHING_RUNNING = "That agent isn't running anything right now."; + +/** + * The connector for attached A2A agents: heterogeneous agents reached over the + * A2A protocol, which Tangent dials rather than runs. A thin adapter over {@link + * A2aPeerGateway}. + * + * It has no `spawn`. An A2A agent exists before Tangent hears of it and outlives + * every session that talks to it, so there is nothing to create — discovering an + * Agent Card is what attaching means, and `spawnAuthority: "none"` says so. + * `kill` ends the attachment for the same reason: the far end is not ours to + * end. + * + * Delivery is synchronous by declaration and asynchronous in fact: the gateway + * opens the Run and returns, and a peer that turns out to be unreachable + * explains itself in its own thread rather than making the sender wait to find + * out. + */ +export class A2aConnector implements Connector { + readonly descriptor = connectorFor("a2a"); + readonly acceptsDelivery = true; + readonly credential = a2aCredential; + + private readonly gateway: A2aPeerGateway; + private readonly handlers: ConversationEventSink; + + constructor(gateway: A2aPeerGateway, handlers: ConversationEventSink) { + this.gateway = gateway; + this.handlers = handlers; + } + + has(sessionId: string, participantId: string): boolean { + return this.gateway.hasAgent(sessionId, participantId); + } + + list(sessionId: string) { + return this.gateway.listSubagents(sessionId); + } + + deliver(request: DeliveryRequest): DeliveryResult { + const delivered = this.gateway.send({ + sessionId: request.sessionId, + participantId: request.participantId, + text: request.text, + ingress: request.ingress, + }); + if (delivered) return { delivered: true }; + return refuseDelivery(this.handlers, request, NOT_ATTACHED); + } + + cancelRun(request: RunCancellation): CancelResult { + // A2A cancellation targets the Task, and a participant has at most one Run + // open, so cancelling that Run is cancelling the Task behind it. + const cancelled = this.gateway.cancel( + request.sessionId, + request.participantId, + ); + if (cancelled) return { cancelled: true }; + return { cancelled: false, reason: NOTHING_RUNNING }; + } + + kill(sessionId: string, participantId: string, completed: boolean): void { + this.gateway.detach(sessionId, participantId, completed); + } + + revive(sessionId: string, agent: SessionAgent): void { + // Tangent is the client here, so there is nothing to wait to be reattached + // by: the tab comes back `detached` from the address the row kept, and the + // next delivery re-discovers the peer. + this.gateway.reattach(sessionId, agent); + } +} diff --git a/apps/server/src/connectors/connectorRegistry.test.ts b/apps/server/src/connectors/connectorRegistry.test.ts index 7052caa..c67dddd 100644 --- a/apps/server/src/connectors/connectorRegistry.test.ts +++ b/apps/server/src/connectors/connectorRegistry.test.ts @@ -8,6 +8,7 @@ import { type SubagentInfo, } from "@tangent/shared/contracts.ts"; +import { A2aPeerGateway } from "../a2a/a2aPeerGateway.ts"; import { ExternalSubagentGateway } from "../external/externalSubagentGateway.ts"; import type { PiAgentManager } from "../pi/piAgentManager.ts"; import type { ConversationEventSink } from "../pi/types.ts"; @@ -17,6 +18,7 @@ import { InMemoryRunStore } from "../store/inMemoryRunStore.ts"; import { InMemorySessionStore } from "../store/inMemorySessionStore.ts"; import type { SessionAgent } from "../store/sessionStore.ts"; import { createConnectorRegistry } from "./connectorRegistry.ts"; +import { PeerBearerCredential } from "./credentials.ts"; /** A message a fake gateway was asked to deliver. */ interface Delivery { @@ -120,21 +122,38 @@ function makeHarness() { const local = fakePi(); const remote = fakeRemote(); + const runs = new RunRegistry(new InMemoryRunStore()); const externalGateway = new ExternalSubagentGateway( handlers, - new RunRegistry(new InMemoryRunStore()), + runs, new InMemorySessionStore(), ); + // Real, like the external gateway, with only its discovery replaced: a peer + // that answers nothing still holds a tab, which is all resolution needs. + const a2aGateway = new A2aPeerGateway( + handlers, + runs, + new InMemorySessionStore(), + () => {}, + new PeerBearerCredential(""), + async () => ({ + card: { name: "Weather" }, + async *send() {}, + async cancel() {}, + }), + ); const connectors = createConnectorRegistry( local.pi, remote.gateway, externalGateway, + a2aGateway, handlers, ); return { connectors, externalGateway, + a2aGateway, surfaced, piDeliveries: local.deliveries, piKills: local.kills, @@ -154,14 +173,17 @@ test("resolution is total: an unheld participant gets a refusing connector", () assert.equal(connector.acceptsDelivery, false); }); -test("every connector's credential agrees with the scheme it publishes", () => { +test("every connector's credential agrees with the scheme it publishes", async () => { const h = makeHarness(); const { id } = h.externalGateway.register("s1", { name: "worker" }); + const peer = await h.a2aGateway.attach("s1", { + endpointUrl: "https://agent.example.com", + }); // The descriptor names the scheme (it goes to clients); the credential holds // the secret (it does not). A connector whose two disagreed would be lying // about how its far end is authenticated. - for (const participantId of ["local-1", "remote-1", id, "ghost"]) { + for (const participantId of ["local-1", "remote-1", id, peer.id, "ghost"]) { const connector = h.connectors.resolve("s1", participantId); assert.equal( connector.credential.scheme, @@ -174,12 +196,33 @@ test("every connector's credential agrees with the scheme it publishes", () => { h.connectors.resolve("s1", "local-1").descriptor.credentialScheme, "inherited-token", ); + assert.equal( + h.connectors.resolve("s1", peer.id).descriptor.credentialScheme, + "peer-bearer", + ); assert.equal( h.connectors.resolve("s1", "ghost").credential.configured, false, ); }); +test("a message aimed at an A2A peer reaches its gateway, not the local agents", async () => { + const h = makeHarness(); + const peer = await h.a2aGateway.attach("s1", { + endpointUrl: "https://agent.example.com", + }); + + const result = h.connectors.resolve("s1", peer.id).deliver({ + sessionId: "s1", + participantId: peer.id, + text: "do the thing", + }); + + assert.equal(result.delivered, true); + assert.deepEqual(h.piDeliveries, []); + assert.deepEqual(h.remoteDeliveries, []); +}); + test("a message to an unknown participant is refused in its own conversation", () => { const h = makeHarness(); @@ -291,13 +334,16 @@ test("killing an external participant reaches its gateway", () => { assert.deepEqual(h.piKills, []); }); -test("list walks every connector's roster", () => { +test("list walks every connector's roster", async () => { const h = makeHarness(); const { id } = h.externalGateway.register("s1", { name: "worker" }); + const peer = await h.a2aGateway.attach("s1", { + endpointUrl: "https://agent.example.com", + }); assert.deepEqual( h.connectors.list("s1").map((s) => s.id), - ["local-1", "remote-1", id], + ["local-1", "remote-1", id, peer.id], ); }); @@ -328,25 +374,44 @@ test("revive routes each persisted row to the connector that recorded it", () => ]); }); -test("revive skips Prime, terminal rows and attached participants", () => { +test("revive skips Prime and terminal rows", () => { const h = makeHarness(); h.connectors.revive("s1", [ agentRow("prime", connectorFor("pi-stdio"), { role: "prime" }), agentRow("killed-1", connectorFor("pi-stdio"), { status: "killed" }), - // An attached connector's far end exists independently of Tangent, so it - // waits to be reattached rather than being brought back from a row. - agentRow("attached-1", { - kind: "pi-stdio", - lifecycle: "attached", - spawnAuthority: "server", - credentialScheme: "inherited-token", - }), ]); assert.deepEqual(h.piRevives, []); }); +test("an attached row is restored too: what a revive means is the connector's call", () => { + const h = makeHarness(); + + // Tangent is the client of an A2A service, so "wait to be reattached" would + // mean "never". The tab comes back detached from the endpoint the row kept, + // and the next delivery re-discovers the peer. + h.connectors.revive("s1", [ + agentRow("peer-1", { + ...connectorFor("a2a"), + endpointUrl: "https://agent.example.com", + }), + ]); + + assert.deepEqual(h.a2aGateway.listSubagents("s1"), [ + { + id: "peer-1", + name: "peer-1", + status: "detached", + connector: { + ...connectorFor("a2a"), + endpointUrl: "https://agent.example.com", + }, + createdAt: "2026-01-01T00:00:00.000Z", + }, + ]); +}); + test("only connectors the spawn API may act on are spawners", () => { const h = makeHarness(); diff --git a/apps/server/src/connectors/connectorRegistry.ts b/apps/server/src/connectors/connectorRegistry.ts index 7440d17..e3097b7 100644 --- a/apps/server/src/connectors/connectorRegistry.ts +++ b/apps/server/src/connectors/connectorRegistry.ts @@ -5,11 +5,13 @@ import { type SubagentInfo, } from "@tangent/shared/contracts.ts"; +import type { A2aPeerGateway } from "../a2a/a2aPeerGateway.ts"; import type { ExternalSubagentGateway } from "../external/externalSubagentGateway.ts"; import type { PiAgentManager } from "../pi/piAgentManager.ts"; import type { ConversationEventSink } from "../pi/types.ts"; import type { RemoteEnvironmentGateway } from "../remote/remoteEnvironmentGateway.ts"; import type { SessionAgent } from "../store/sessionStore.ts"; +import { A2aConnector } from "./a2aConnector.ts"; import { ExternalConnector } from "./externalConnector.ts"; import { NullConnector } from "./nullConnector.ts"; import { PiConnector } from "./piConnector.ts"; @@ -99,15 +101,15 @@ export class ConnectorRegistry { * is the whole point of a revive. * * Terminal rows are skipped, so nothing resurrects a participant that finished - * or was killed. So are `attached` ones: that connector's far end exists - * independently of Tangent and waits to be reattached rather than being brought - * back from a row. + * or was killed. Lifecycle is not consulted: what restoring means is the + * connector's to decide, and both answers are real — re-spawning a process the + * server owns, or restoring a `detached` tab for a far end that is still out + * there. A connector with nothing to restore says so in its own `revive`. */ revive(sessionId: string, persisted: SessionAgent[]): void { for (const agent of persisted) { if (agent.role !== "subagent") continue; if (isTerminalStatus(agent.status)) continue; - if (agent.connector.lifecycle !== "owned") continue; this.forKind(agent.connector.kind)?.revive(sessionId, agent); } } @@ -123,6 +125,7 @@ export function createConnectorRegistry( pi: PiAgentManager, remoteGateway: RemoteEnvironmentGateway, externalGateway: ExternalSubagentGateway, + a2aGateway: A2aPeerGateway, handlers: ConversationEventSink, ): ConnectorRegistry { return new ConnectorRegistry( @@ -130,6 +133,7 @@ export function createConnectorRegistry( new PiConnector(pi), new RemoteEnvConnector(remoteGateway, handlers), new ExternalConnector(externalGateway, handlers), + new A2aConnector(a2aGateway, handlers), ], new NullConnector(handlers), ); diff --git a/apps/server/src/connectors/credentials.test.ts b/apps/server/src/connectors/credentials.test.ts index c0126db..a5bb26a 100644 --- a/apps/server/src/connectors/credentials.test.ts +++ b/apps/server/src/connectors/credentials.test.ts @@ -3,10 +3,12 @@ import { test } from "node:test"; import { BearerCredential, + type ConnectorCredential, deniedCredential, HandshakeTokenCredential, InheritedTokenCredential, mintSecretCredential, + PeerBearerCredential, } from "./credentials.ts"; test("a bearer credential accepts only its own token, exactly", () => { @@ -49,9 +51,39 @@ test("only an inherited credential hands its secret to a spawned child", () => { {}, ); assert.deepEqual(new HandshakeTokenCredential("tok").spawnEnv(), {}); + assert.deepEqual(new PeerBearerCredential("tok").spawnEnv(), {}); assert.deepEqual(deniedCredential.spawnEnv(), {}); }); +test("a peer credential authorizes nobody: it is only ever presented outbound", () => { + // Held as the interface, which is how a guard sees it: `verify` takes no + // argument on the class precisely because it reads nothing. + const credential: ConnectorCredential = new PeerBearerCredential( + "peer-secret", + ); + + // Nothing inbound is an A2A peer, so there is no request this should let in — + // including one presenting the very token we send out. + assert.equal(credential.scheme, "peer-bearer"); + assert.equal(credential.configured, true); + assert.equal( + credential.verify({ authorization: "Bearer peer-secret" }), + false, + ); + assert.equal(credential.verify({ token: "peer-secret" }), false); + assert.equal(credential.verify({}), false); +}); + +test("a peer credential sends a header only when a secret is configured", () => { + // An unset secret is not a lockout here, unlike the inbound schemes: a peer + // that asks for no credential is still reachable. + assert.deepEqual(new PeerBearerCredential("tok").headers(), { + Authorization: "Bearer tok", + }); + assert.deepEqual(new PeerBearerCredential("").headers(), {}); + assert.equal(new PeerBearerCredential("").configured, false); +}); + test("the same secret verifies the same way however it was issued", () => { // `inherited-token` and `internal-bearer` differ only in issuance, so a Pi // child and a bundle tool presenting the token it inherited both pass. diff --git a/apps/server/src/connectors/credentials.ts b/apps/server/src/connectors/credentials.ts index 0b22fa7..93e7b8c 100644 --- a/apps/server/src/connectors/credentials.ts +++ b/apps/server/src/connectors/credentials.ts @@ -2,7 +2,7 @@ import { randomBytes } from "node:crypto"; import type { CredentialScheme } from "@tangent/shared/contracts.ts"; -import { INTERNAL_TOKEN, REMOTE_ENV_TOKEN } from "../config.ts"; +import { A2A_TOKEN, INTERNAL_TOKEN, REMOTE_ENV_TOKEN } from "../config.ts"; /** Env var a spawned Pi child reads its inherited credential from. */ const INHERITED_TOKEN_VAR = "TANGENT_INTERNAL_TOKEN"; @@ -136,6 +136,46 @@ export function mintSecretCredential(): MintedSecretCredential { ); } +/** + * A secret Tangent presents to a far end that sits outside the trust domain, + * rather than one a caller presents to Tangent. The direction is the whole + * difference: {@link verify} refuses everything, because nothing inbound is an + * A2A peer, and the secret leaves through {@link headers}. + * + * An unset secret means the peer asked for none, so it is not a lockout the way + * it is for the inbound schemes — there is nobody to lock out. + */ +export class PeerBearerCredential implements ConnectorCredential { + readonly scheme: CredentialScheme = "peer-bearer"; + private readonly token: string; + + constructor(token: string) { + this.token = token; + } + + get configured(): boolean { + return this.token.length > 0; + } + + verify(): boolean { + return false; + } + + spawnEnv(): Record { + return {}; + } + + /** + * Headers to send the peer. Concrete-only, like {@link + * MintedSecretCredential.secret}: code holding the interface can check a + * credential, never read one out. + */ + headers(): Record { + if (!this.configured) return {}; + return { Authorization: `Bearer ${this.token}` }; + } +} + /** * The credential of a connector that authenticates nobody, for the null * connector. Declared rather than absent, like its `acceptsDelivery: false`: @@ -161,3 +201,6 @@ export const externalCredential = new BearerCredential( "internal-bearer", INTERNAL_TOKEN, ); + +/** A2A peers: the outbound token Tangent presents when it dials one. */ +export const a2aCredential = new PeerBearerCredential(A2A_TOKEN); diff --git a/apps/server/src/conversation/membershipRegistry.test.ts b/apps/server/src/conversation/membershipRegistry.test.ts index 834a630..a7735b1 100644 --- a/apps/server/src/conversation/membershipRegistry.test.ts +++ b/apps/server/src/conversation/membershipRegistry.test.ts @@ -90,6 +90,44 @@ test("a participant nothing can deliver to declares that it never reacts", async assert.equal(members[0].transcriptVisibility, "opaque"); }); +test("an A2A peer is addressable but sees none of the transcript", async () => { + const h = makeRegistry(); + await h.sessions.recordAgent("s1", { + id: "peer-1", + role: "subagent", + name: "Weather", + status: "active", + connector: connectorFor("a2a"), + }); + + const members = await h.registry.membersOf("s1", "peer-1"); + + // Reachable, so it reacts — but it sits outside Tangent's trust domain, so it + // is sent what addresses it rather than the log. Prime, being local, reads the + // thread in full. + assert.deepEqual(shape(members), [ + ["peer-1", "fromHumans+mentionsMe"], + ["prime", "atRunEnd+mentionsMe"], + ]); + assert.equal(members[0].transcriptVisibility, "opaque"); + assert.equal(members[1].transcriptVisibility, "shared"); +}); + +test("a local sub-agent still sees the shared transcript", async () => { + const h = makeRegistry(); + await h.sessions.recordAgent("s1", { + id: "sub-1", + role: "subagent", + name: "Worker", + status: "active", + connector: connectorFor("pi-stdio"), + }); + + const members = await h.registry.membersOf("s1", "sub-1"); + + assert.equal(members[0].transcriptVisibility, "shared"); +}); + test("a derived conversation is persisted, so it is derived once", async () => { const h = makeRegistry(); await h.sessions.recordAgent("s1", { diff --git a/apps/server/src/conversation/membershipRegistry.ts b/apps/server/src/conversation/membershipRegistry.ts index 458886a..c4dbf16 100644 --- a/apps/server/src/conversation/membershipRegistry.ts +++ b/apps/server/src/conversation/membershipRegistry.ts @@ -1,7 +1,8 @@ -import type { - ConnectorKind, - ReactionSpec, - TranscriptVisibility, +import { + type ConnectorKind, + DEFAULT_TRANSCRIPT_VISIBILITY, + type ReactionSpec, + type TranscriptVisibility, } from "@tangent/shared/contracts.ts"; import { PRIME_AGENT_ID } from "../pi/types.ts"; @@ -162,22 +163,32 @@ export class MembershipRegistry { * The membership of the participant whose Conversation this is. One on a * transport nothing can deliver to declares that it never acts, rather than * accepting wakes that would be swallowed. + * + * How much of the thread it sees comes from its connector rather than from + * being reachable: a participant outside Tangent's trust domain is sent what + * addresses it, not the log, whether or not it can be delivered to. */ private subject( sessionId: string, conversationId: string, agent: SessionAgent | undefined, ): Membership { - const reachable = !agent || this.acceptsDelivery(agent.connector.kind); - if (reachable) { - return membership(sessionId, conversationId, conversationId, ADDRESSABLE); + const kind = agent?.connector.kind; + if (kind && !this.acceptsDelivery(kind)) { + return membership( + sessionId, + conversationId, + conversationId, + INERT, + "opaque", + ); } return membership( sessionId, conversationId, conversationId, - INERT, - "opaque", + ADDRESSABLE, + kind ? DEFAULT_TRANSCRIPT_VISIBILITY[kind] : "shared", ); } } diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index fe842df..021ca00 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -5,6 +5,7 @@ import { createServer } from "node:http"; import express from "express"; import { Server as SocketIOServer } from "socket.io"; +import { A2aPeerGateway } from "./a2a/a2aPeerGateway.ts"; import { PORT } from "./config.ts"; import { createConnectorRegistry } from "./connectors/connectorRegistry.ts"; import { ConversationRouter } from "./conversation/conversationRouter.ts"; @@ -145,6 +146,16 @@ const remoteGateway = new RemoteEnvironmentGateway( // tab via the same relay handlers a local sub-agent uses. const externalGateway = new ExternalSubagentGateway(agentHandlers, runs, store); +// Registry of attached A2A agents: heterogeneous agents that already run as a +// service elsewhere, which Tangent dials over the A2A protocol. Their Tasks +// become Runs and their artifacts land in the session workspace. +const a2aGateway = new A2aPeerGateway( + agentHandlers, + runs, + store, + emitUiCommand, +); + // The single lookup from a participant to the connector that reaches it. Every // spawn/message/kill/list route goes through it, so an id no connector holds is // refused in its own conversation instead of falling through to the local Pi. @@ -152,6 +163,7 @@ const connectors = createConnectorRegistry( pi, remoteGateway, externalGateway, + a2aGateway, agentHandlers, ); @@ -208,7 +220,7 @@ app.use("/api/me", createMeRouter()); // Internal API for the orchestrator extension running inside each Pi process. app.use( "/internal/agents", - createInternalAgentsRouter(store, connectors, conversations), + createInternalAgentsRouter(store, connectors, conversations, a2aGateway), ); // 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. diff --git a/apps/server/src/pi/extensions/orchestrator.ts b/apps/server/src/pi/extensions/orchestrator.ts index 5f44c00..bb78390 100644 --- a/apps/server/src/pi/extensions/orchestrator.ts +++ b/apps/server/src/pi/extensions/orchestrator.ts @@ -133,10 +133,10 @@ export default function (pi: ExtensionAPI) { "`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. 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.", + "to host the sub-agent in a connected remote environment 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({ @@ -199,6 +199,43 @@ export default function (pi: ExtensionAPI) { }, }); + pi.registerTool({ + name: "attach_a2a_agent", + label: "Attach A2A Agent", + description: + "Attach an agent that already runs elsewhere and speaks the A2A " + + "protocol, by the base URL its Agent Card is served from. Nothing is " + + "created: the agent exists independently of this session, so attaching " + + "only gives it a tab you can direct with message_subagent. Its name " + + "comes from its card unless you override it. Returns the id to message " + + "it by.", + promptSnippet: "Attach an external A2A agent by its endpoint URL", + parameters: Type.Object({ + endpoint_url: Type.String({ + description: + "Base URL the agent's card is served from (e.g. " + + "https://agent.example.com).", + }), + name: Type.Optional( + Type.String({ + description: "Display name; defaults to the name on the card.", + }), + ), + }), + async execute(_toolCallId, params) { + const data = (await callApi("POST", "attach", { + sessionId: SESSION_ID, + endpointUrl: params.endpoint_url, + name: params.name, + })) as { subagent: { id: string; name: string } }; + + return textResult( + `Attached A2A agent "${data.subagent.name}" (id: ${data.subagent.id}). ` + + `Use message_subagent to direct it; its replies appear in its own thread.`, + ); + }, + }); + pi.registerTool({ name: "message_subagent", label: "Message Sub-agent", diff --git a/apps/server/src/routes/internalAgents.ts b/apps/server/src/routes/internalAgents.ts index c1b9348..593a9c0 100644 --- a/apps/server/src/routes/internalAgents.ts +++ b/apps/server/src/routes/internalAgents.ts @@ -7,6 +7,7 @@ import { import { type Response, Router } from "express"; import { z } from "zod"; +import type { A2aPeerGateway } from "../a2a/a2aPeerGateway.ts"; import type { ConnectorRegistry } from "../connectors/connectorRegistry.ts"; import { piCredential } from "../connectors/credentials.ts"; import type { ConversationRouter } from "../conversation/conversationRouter.ts"; @@ -31,6 +32,14 @@ export const spawnSchema = z.object({ }); export type SpawnInput = z.infer; +/** Attach an A2A agent that already runs as a service, by its card's base URL. */ +export const attachSchema = z.object({ + sessionId: z.string(), + endpointUrl: z.url(), + name: z.string().optional(), +}); +export type AttachInput = z.infer; + /** Deliver a Prime-issued directive to a sub-agent. */ export const messageSchema = z.object({ sessionId: z.string(), @@ -136,6 +145,31 @@ async function handleSpawn( } } +/** + * Attaches an A2A agent to the session. Nothing is created: the agent already + * runs somewhere, so this reads its Agent Card and records the tab. A card that + * cannot be read is the request failing, not a tab that never works — which is + * why, unlike a spawn, there is nothing to undo on the way out. + */ +async function handleAttach( + a2a: A2aPeerGateway, + body: AttachInput, + res: Response, +): Promise { + try { + const subagent = await a2a.attach(body.sessionId, { + endpointUrl: body.endpointUrl, + name: body.name, + }); + res.json({ subagent }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + res + .status(400) + .json({ error: `Couldn't attach ${body.endpointUrl}: ${message}` }); + } +} + /** * Posts a Prime-issued directive into a sub-agent's Conversation, addressed to * it. Surfacing and delivery are the same act: the sub-agent reacts because it @@ -282,6 +316,7 @@ export function createInternalAgentsRouter( store: SessionStore, connectors: ConnectorRegistry, conversations: ConversationRouter, + a2a: A2aPeerGateway, ): Router { const router = Router(); @@ -297,6 +332,10 @@ export function createInternalAgentsRouter( ), ); + router.post("/attach", validate({ body: attachSchema }), (req, res) => + handleAttach(a2a, getValidated(req).body, res), + ); + router.post("/message", validate({ body: messageSchema }), (req, res) => handleMessage(conversations, getValidated(req).body, res), ); diff --git a/apps/server/src/store/db/migrations/0010_thick_chat.sql b/apps/server/src/store/db/migrations/0010_thick_chat.sql new file mode 100644 index 0000000..7817da2 --- /dev/null +++ b/apps/server/src/store/db/migrations/0010_thick_chat.sql @@ -0,0 +1 @@ +ALTER TABLE `session_agents` ADD `connector_endpoint_url` text; \ No newline at end of file diff --git a/apps/server/src/store/db/migrations/meta/0010_snapshot.json b/apps/server/src/store/db/migrations/meta/0010_snapshot.json new file mode 100644 index 0000000..3e0e111 --- /dev/null +++ b/apps/server/src/store/db/migrations/meta/0010_snapshot.json @@ -0,0 +1,623 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "9b69169b-20a1-4c13-bceb-a24dd723c10f", + "prevId": "5774c697-0371-4863-a7b4-5b958bbdb438", + "tables": { + "conversations": { + "name": "conversations", + "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 + }, + "next_seq": { + "name": "next_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "conversations_session_idx": { + "name": "conversations_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "conversations_session_id": { + "name": "conversations_session_id", + "columns": ["session_id", "id"], + "isUnique": true + } + }, + "foreignKeys": { + "conversations_session_id_sessions_id_fk": { + "name": "conversations_session_id_sessions_id_fk", + "tableFrom": "conversations", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "memberships": { + "name": "memberships", + "columns": { + "participant_id": { + "name": "participant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reaction": { + "name": "reaction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'never'" + }, + "ingress": { + "name": "ingress", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'reaction'" + }, + "transcript_visibility": { + "name": "transcript_visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'shared'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "memberships_session_conversation_idx": { + "name": "memberships_session_conversation_idx", + "columns": ["session_id", "conversation_id"], + "isUnique": false + }, + "memberships_session_conversation_participant": { + "name": "memberships_session_conversation_participant", + "columns": ["session_id", "conversation_id", "participant_id"], + "isUnique": true + } + }, + "foreignKeys": { + "memberships_session_id_sessions_id_fk": { + "name": "memberships_session_id_sessions_id_fk", + "tableFrom": "memberships", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "runs": { + "name": "runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "participant_id": { + "name": "participant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "home_conversation_id": { + "name": "home_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "ingress": { + "name": "ingress", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": 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 + }, + "ended_at": { + "name": "ended_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "runs_session_idx": { + "name": "runs_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "runs_session_participant_idx": { + "name": "runs_session_participant_idx", + "columns": ["session_id", "participant_id"], + "isUnique": false + } + }, + "foreignKeys": { + "runs_session_id_sessions_id_fk": { + "name": "runs_session_id_sessions_id_fk", + "tableFrom": "runs", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "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'" + }, + "connector_kind": { + "name": "connector_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_lifecycle": { + "name": "connector_lifecycle", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_environment_id": { + "name": "connector_environment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_endpoint_url": { + "name": "connector_endpoint_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "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 9dffa68..2cd97e5 100644 --- a/apps/server/src/store/db/migrations/meta/_journal.json +++ b/apps/server/src/store/db/migrations/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1786488898589, "tag": "0009_exotic_micromax", "breakpoints": true + }, + { + "idx": 10, + "version": "6", + "when": 1786568496433, + "tag": "0010_thick_chat", + "breakpoints": true } ] } diff --git a/apps/server/src/store/db/schema.ts b/apps/server/src/store/db/schema.ts index f0731ca..dd44f1c 100644 --- a/apps/server/src/store/db/schema.ts +++ b/apps/server/src/store/db/schema.ts @@ -120,6 +120,12 @@ export const sessionAgents = sqliteTable( connectorKind: text("connector_kind"), connectorLifecycle: text("connector_lifecycle"), connectorEnvironmentId: text("connector_environment_id"), + /** + * Where a dialling connector reaches this participant — an A2A peer's Agent + * Card base URL. Null for every participant that connects to Tangent rather + * than the other way round. + */ + connectorEndpointUrl: text("connector_endpoint_url"), createdAt: text("created_at").notNull(), }, (table) => [ diff --git a/apps/server/src/store/sqliteSessionStore.test.ts b/apps/server/src/store/sqliteSessionStore.test.ts index cb54464..8e192ee 100644 --- a/apps/server/src/store/sqliteSessionStore.test.ts +++ b/apps/server/src/store/sqliteSessionStore.test.ts @@ -80,6 +80,47 @@ test("recordAgent round-trips a connector descriptor", async () => { assert.deepEqual(agents.find((a) => a.id === "sub-1")?.connector, expected); }); +test("an attached peer's endpoint round-trips, and nothing else carries one", async () => { + const store = newStore(); + const session = await store.createSession({ name: "S" }); + + await store.recordAgent(session.id, { + id: "peer-1", + role: "subagent", + name: "Weather", + connector: { + kind: "a2a", + lifecycle: "attached", + spawnAuthority: "none", + credentialScheme: "peer-bearer", + endpointUrl: "https://agent.example.com", + }, + }); + await store.recordAgent(session.id, { + id: "sub-1", + role: "subagent", + name: "Worker", + connector: { + kind: "pi-stdio", + lifecycle: "owned", + spawnAuthority: "server", + credentialScheme: "inherited-token", + }, + }); + + const agents = await store.listAgents(session.id); + assert.equal( + agents.find((a) => a.id === "peer-1")?.connector.endpointUrl, + "https://agent.example.com", + ); + // Absent rather than empty: a participant that connects to Tangent has no far + // end to dial, and the column says so by staying null. + assert.equal( + agents.find((a) => a.id === "sub-1")?.connector.endpointUrl, + undefined, + ); +}); + test("a row recorded without a connector reads back from its host", async () => { const store = newStore(); const session = await store.createSession({ name: "S" }); diff --git a/apps/server/src/store/sqliteSessionStore.ts b/apps/server/src/store/sqliteSessionStore.ts index 082cf28..f0c363e 100644 --- a/apps/server/src/store/sqliteSessionStore.ts +++ b/apps/server/src/store/sqliteSessionStore.ts @@ -79,6 +79,9 @@ function toConnector(row: SessionAgentRow): ConnectorDescriptor { ...(row.connectorEnvironmentId ? { environmentId: row.connectorEnvironmentId } : {}), + ...(row.connectorEndpointUrl + ? { endpointUrl: row.connectorEndpointUrl } + : {}), }; } @@ -88,6 +91,7 @@ function connectorColumns(connector: ConnectorDescriptor | undefined) { connectorKind: connector?.kind, connectorLifecycle: connector?.lifecycle, connectorEnvironmentId: connector?.environmentId, + connectorEndpointUrl: connector?.endpointUrl, }; } diff --git a/packages/shared/src/contracts.ts b/packages/shared/src/contracts.ts index 9322a45..ac8e8a4 100644 --- a/packages/shared/src/contracts.ts +++ b/packages/shared/src/contracts.ts @@ -473,12 +473,17 @@ export type SpawnAuthority = "server" | "remote-env" | "bundle-tool" | "none"; * `inherited-token` and `internal-bearer` are the same server secret differing * in issuance — one is handed to a process the server spawns, the other is * presented back by a caller that already holds it. + * + * `peer-bearer` runs the other way: the far end sits outside the trust domain + * and Tangent is the caller, so the secret is presented outbound and no inbound + * caller ever authenticates under this scheme. */ export type CredentialScheme = | "inherited-token" | "shared-token" | "internal-bearer" | "minted-secret" + | "peer-bearer" | "none"; /** @@ -494,6 +499,12 @@ export interface ConnectorDescriptor { credentialScheme: CredentialScheme; /** The remote environment this participant is bound to, when it has one. */ environmentId?: string; + /** + * Where the far end is reached, for a connector that dials out rather than + * being dialled. Its sibling: `environmentId` names an environment that + * connects to Tangent, `endpointUrl` an address Tangent connects to. + */ + endpointUrl?: string; } /** @@ -527,7 +538,7 @@ export const CONNECTOR_FACETS: Record< kind: "a2a", lifecycle: "attached", spawnAuthority: "none", - credentialScheme: "none", + credentialScheme: "peer-bearer", }, unresolved: { kind: "unresolved", @@ -546,6 +557,22 @@ const LEGACY_HOST: Record = { unresolved: undefined, }; +/** + * How much of a Conversation a Membership on each connector sees by default. + * A far end outside Tangent gets `opaque`: it is sent what addresses it, not + * the log. Declared as data so no membership becomes `shared` by omission. + */ +export const DEFAULT_TRANSCRIPT_VISIBILITY: Record< + ConnectorKind, + TranscriptVisibility +> = { + "pi-stdio": "shared", + "remote-env": "shared", + "external-inbound": "opaque", + a2a: "opaque", + unresolved: "opaque", +}; + /** Builds a connector descriptor, optionally bound to a remote environment. */ export function connectorFor( kind: ConnectorKind, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9d8be5..02e3970 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,9 @@ importers: apps/server: dependencies: + '@a2a-js/sdk': + specifier: ^1.0.1 + version: 1.0.1(express@5.2.1) '@tangent/shared': specifier: workspace:* version: link:../../packages/shared @@ -503,6 +506,21 @@ importers: packages: + '@a2a-js/sdk@1.0.1': + resolution: {integrity: sha512-CJQdh3Wzwo8qIx5UUkSJ7+7BEI16PB+MXMHHNSmx8JQsQed2HlQgvx1ENOiKUfYA3PlcEvxIwv14dBblhDuPmw==, tarball: https://registry.npmjs.org/@a2a-js/sdk/-/sdk-1.0.1.tgz} + engines: {node: '>=20'} + peerDependencies: + '@bufbuild/protobuf': ^2.10.2 + '@grpc/grpc-js': ^1.11.0 + express: ^4.21.2 || ^5.1.0 + peerDependenciesMeta: + '@bufbuild/protobuf': + optional: true + '@grpc/grpc-js': + optional: true + express: + optional: true + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==, tarball: https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz} engines: {node: '>=6.9.0'} @@ -3139,6 +3157,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==, tarball: https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz} hasBin: true + jose@6.2.8: + resolution: {integrity: sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==, tarball: https://registry.npmjs.org/jose/-/jose-6.2.8.tgz} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==, tarball: https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz} @@ -3968,6 +3989,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==, tarball: https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz} + uuid@11.1.1: + resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==, tarball: https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz} + hasBin: true + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==, tarball: https://registry.npmjs.org/vary/-/vary-1.1.2.tgz} engines: {node: '>= 0.8'} @@ -4099,6 +4124,13 @@ packages: snapshots: + '@a2a-js/sdk@1.0.1(express@5.2.1)': + dependencies: + jose: 6.2.8 + uuid: 11.1.1 + optionalDependencies: + express: 5.2.1 + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -6554,6 +6586,8 @@ snapshots: jiti@2.7.0: {} + jose@6.2.8: {} + js-tokens@4.0.0: {} jsesc@3.1.0: {} @@ -7693,6 +7727,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@11.1.1: {} + vary@1.1.2: {} vfile-message@4.0.3: From c64143e4921f274591ef7f70e806613464e37496 Mon Sep 17 00:00:00 2001 From: Maksym Yezhov Date: Wed, 12 Aug 2026 16:07:43 -0700 Subject: [PATCH 10/18] - refactor: harden remote workers thru shopworld case --- .../src/connectors/connectorRegistry.test.ts | 41 +++++- .../src/connectors/externalConnector.ts | 32 +++-- .../src/connectors/participantAuthor.ts | 25 ++++ .../conversation/membershipRegistry.test.ts | 37 ++++-- .../server/src/external/deliveryQueue.test.ts | 69 ++++++++++ apps/server/src/external/deliveryQueue.ts | 83 ++++++++++++ .../external/externalSubagentGateway.test.ts | 87 +++++++++++- .../src/external/externalSubagentGateway.ts | 110 ++++++++++++--- apps/server/src/index.ts | 37 +++--- apps/server/src/mcp/channelUrl.ts | 20 +++ apps/server/src/mcp/mcpRelayServer.ts | 37 +++--- apps/server/src/mcp/relayRegistry.test.ts | 70 ++++++++-- apps/server/src/mcp/relayRegistry.ts | 10 ++ apps/server/src/mcp/relayReport.test.ts | 125 ++++++++++++++++++ apps/server/src/mcp/relayReport.ts | 53 ++++++++ apps/server/src/routes/internalAgents.ts | 20 +-- .../src/routes/internalExternalAgents.ts | 81 ++++++++++-- apps/server/src/routes/internalMcpRelay.ts | 6 +- apps/server/src/routes/mcp.ts | 18 +-- 19 files changed, 827 insertions(+), 134 deletions(-) create mode 100644 apps/server/src/connectors/participantAuthor.ts create mode 100644 apps/server/src/external/deliveryQueue.test.ts create mode 100644 apps/server/src/external/deliveryQueue.ts create mode 100644 apps/server/src/mcp/channelUrl.ts create mode 100644 apps/server/src/mcp/relayReport.test.ts create mode 100644 apps/server/src/mcp/relayReport.ts diff --git a/apps/server/src/connectors/connectorRegistry.test.ts b/apps/server/src/connectors/connectorRegistry.test.ts index c67dddd..cc35485 100644 --- a/apps/server/src/connectors/connectorRegistry.test.ts +++ b/apps/server/src/connectors/connectorRegistry.test.ts @@ -10,6 +10,7 @@ import { import { A2aPeerGateway } from "../a2a/a2aPeerGateway.ts"; import { ExternalSubagentGateway } from "../external/externalSubagentGateway.ts"; +import { RelayRegistry } from "../mcp/relayRegistry.ts"; import type { PiAgentManager } from "../pi/piAgentManager.ts"; import type { ConversationEventSink } from "../pi/types.ts"; import type { RemoteEnvironmentGateway } from "../remote/remoteEnvironmentGateway.ts"; @@ -127,6 +128,7 @@ function makeHarness() { handlers, runs, new InMemorySessionStore(), + new RelayRegistry(), ); // Real, like the external gateway, with only its discovery replaced: a peer // that answers nothing still holds a tab, which is all resolution needs. @@ -239,7 +241,7 @@ test("a message to an unknown participant is refused in its own conversation", ( assert.deepEqual(h.piDeliveries, []); }); -test("a message aimed at an external participant never reaches the local agents", () => { +test("a message aimed at an external participant is queued for its driver", async () => { const h = makeHarness(); const { id } = h.externalGateway.register("s1", { name: "worker" }); @@ -249,9 +251,29 @@ test("a message aimed at an external participant never reaches the local agents" text: "do the thing", }); + assert.equal(result.delivered, true); + assert.deepEqual(await h.externalGateway.takeDeliveries("s1", 5), [ + { agentId: id, text: "do the thing" }, + ]); + assert.deepEqual(h.piDeliveries, [], "and never the local agent map"); +}); + +test("a message aimed at a detached external participant is refused in its tab", () => { + const h = makeHarness(); + const { id } = h.externalGateway.register("s1", { name: "worker" }); + h.externalGateway.setStatus("s1", id, "detached"); + + const result = h.connectors.resolve("s1", id).deliver({ + sessionId: "s1", + participantId: id, + text: "do the thing", + }); + + // Nothing is driving that far side, so queuing the wake would promise a + // delivery the transport cannot make. assert.equal(result.delivered, false); assert.equal(h.surfaced.at(-1)?.conversationId, id); - assert.deepEqual(h.piDeliveries, []); + assert.equal(h.surfaced.at(-1)?.author, "System"); }); test("a message aimed at a remote participant reaches the remote gateway", () => { @@ -417,6 +439,21 @@ test("only connectors the spawn API may act on are spawners", () => { assert.ok(h.connectors.spawner("pi-stdio")); assert.ok(h.connectors.spawner("remote-env")); + // Creating an external participant stays the bundle tool's act (`world_spawn`), + // which is what `spawnAuthority: "bundle-tool"` means: reachable by message, + // never spawnable through the spawn API. assert.equal(h.connectors.spawner("external-inbound"), undefined); assert.equal(h.connectors.spawner("a2a"), undefined); }); + +test("every transport but the unresolved one can be delivered to", () => { + const h = makeHarness(); + + // Read when deriving a Membership: an external participant now declares that + // messages reach it, so its reaction is derived like any other sub-agent's. + assert.equal(h.connectors.acceptsDelivery("pi-stdio"), true); + assert.equal(h.connectors.acceptsDelivery("remote-env"), true); + assert.equal(h.connectors.acceptsDelivery("external-inbound"), true); + assert.equal(h.connectors.acceptsDelivery("a2a"), true); + assert.equal(h.connectors.acceptsDelivery("unresolved"), false); +}); diff --git a/apps/server/src/connectors/externalConnector.ts b/apps/server/src/connectors/externalConnector.ts index e618c2e..bba2ae6 100644 --- a/apps/server/src/connectors/externalConnector.ts +++ b/apps/server/src/connectors/externalConnector.ts @@ -12,27 +12,29 @@ import type { DeliveryResult, } from "./types.ts"; -/** Shown in the sub-agent's own thread when a message cannot reach it. */ -const NO_INBOUND_CHANNEL = - "This sub-agent runs outside Tangent, so it can't receive messages here."; +/** Shown in the sub-agent's own thread when nothing is driving its far side. */ +const NOT_BEING_DRIVEN = + "This sub-agent's far side isn't being driven right now, so the message " + + "wasn't carried to it."; -/** Why a cancellation is refused: the same missing channel, stated for runs. */ +/** Why a cancellation is refused: the driver owns the turn, not the server. */ const NO_CANCEL_CHANNEL = "This sub-agent runs outside Tangent, so its work can't be stopped from here."; /** * The connector for external sub-agent tabs, whose work runs outside Tangent * and streams in over the internal external-agents API. A thin adapter over - * {@link ExternalSubagentGateway}, which is unchanged. + * {@link ExternalSubagentGateway}, which holds both directions of the transport. * - * Traffic is inbound only: the driving bundle tool owns the far side, so there - * is no channel to deliver a message back over. That is declared rather than - * left to a missing method, so a message aimed here is refused in this tab - * instead of falling through to the local agent map. + * Delivery is accepted: a message is queued for the driver that owns the far + * side and carried on its next poll, so an external participant is reached by + * being addressed like any other. Whether that driver streams or polls is + * invisible from here. Cancellation is still refused — the driver, not the + * server, holds the turn. */ export class ExternalConnector implements Connector { readonly descriptor = connectorFor("external-inbound"); - readonly acceptsDelivery = false; + readonly acceptsDelivery = true; readonly credential = externalCredential; private readonly gateway: ExternalSubagentGateway; @@ -55,7 +57,15 @@ export class ExternalConnector implements Connector { } deliver(request: DeliveryRequest): DeliveryResult { - return refuseDelivery(this.handlers, request, NO_INBOUND_CHANNEL); + const queued = this.gateway.deliver( + request.sessionId, + request.participantId, + request.text, + ); + if (!queued) { + return refuseDelivery(this.handlers, request, NOT_BEING_DRIVEN); + } + return { delivered: true }; } cancelRun(): CancelResult { diff --git a/apps/server/src/connectors/participantAuthor.ts b/apps/server/src/connectors/participantAuthor.ts new file mode 100644 index 0000000..fe7b091 --- /dev/null +++ b/apps/server/src/connectors/participantAuthor.ts @@ -0,0 +1,25 @@ +import type { ChatAuthor } from "@tangent/shared/contracts.ts"; + +import type { ConnectorRegistry } from "./connectorRegistry.ts"; + +/** + * The chat author of a live sub-agent, read from the roster it appears in. + * Nothing may assert a participant's identity on its behalf, so the name a + * Message is attributed to comes from the connector that holds it. + */ +export function subagentAuthor( + connectors: ConnectorRegistry, + sessionId: string, + participantId: string, +): ChatAuthor | undefined { + const subagent = connectors + .list(sessionId) + .find((candidate) => candidate.id === participantId); + if (!subagent) return undefined; + return { + id: subagent.id, + kind: "agent", + name: subagent.name, + agentRole: "subagent", + }; +} diff --git a/apps/server/src/conversation/membershipRegistry.test.ts b/apps/server/src/conversation/membershipRegistry.test.ts index a7735b1..0866896 100644 --- a/apps/server/src/conversation/membershipRegistry.test.ts +++ b/apps/server/src/conversation/membershipRegistry.test.ts @@ -8,15 +8,15 @@ import { InMemorySessionStore } from "../store/inMemorySessionStore.ts"; import type { Membership } from "../store/membershipStore.ts"; import { MembershipRegistry } from "./membershipRegistry.ts"; -/** A registry over in-memory stores; external tabs accept no delivery. */ -function makeRegistry() { +/** + * A registry over in-memory stores. Every connector the server runs today can be + * delivered to, so the default matches reality; the tests that care about the + * refusing case say so themselves. + */ +function makeRegistry(acceptsDelivery: () => boolean = () => true) { const sessions = new InMemorySessionStore(); const store = new InMemoryMembershipStore(); - const registry = new MembershipRegistry( - sessions, - store, - (kind) => kind !== "external-inbound", - ); + const registry = new MembershipRegistry(sessions, store, acceptsDelivery); return { sessions, store, registry }; } @@ -72,7 +72,7 @@ test("a sub-agent that does not auto-relay is reachable only by being addressed" }); test("a participant nothing can deliver to declares that it never reacts", async () => { - const h = makeRegistry(); + const h = makeRegistry(() => false); await h.sessions.recordAgent("s1", { id: "tab-1", role: "subagent", @@ -90,6 +90,27 @@ test("a participant nothing can deliver to declares that it never reacts", async assert.equal(members[0].transcriptVisibility, "opaque"); }); +test("an external worker is addressable but sees none of the transcript", async () => { + const h = makeRegistry(); + await h.sessions.recordAgent("s1", { + id: "tab-1", + role: "subagent", + name: "External", + status: "active", + connector: connectorFor("external-inbound"), + }); + + const members = await h.registry.membersOf("s1", "tab-1"); + + // Its driver collects what Tangent queues for it, so it reacts like any other + // sub-agent — while still being sent what addresses it rather than the log. + assert.deepEqual(shape(members), [ + ["tab-1", "fromHumans+mentionsMe"], + ["prime", "atRunEnd+mentionsMe"], + ]); + assert.equal(members[0].transcriptVisibility, "opaque"); +}); + test("an A2A peer is addressable but sees none of the transcript", async () => { const h = makeRegistry(); await h.sessions.recordAgent("s1", { diff --git a/apps/server/src/external/deliveryQueue.test.ts b/apps/server/src/external/deliveryQueue.test.ts new file mode 100644 index 0000000..e404722 --- /dev/null +++ b/apps/server/src/external/deliveryQueue.test.ts @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { DeliveryQueue } from "./deliveryQueue.ts"; + +test("queued work is answered without waiting", async () => { + const queue = new DeliveryQueue(); + queue.push("s1", { agentId: "ext-1", text: "go" }); + queue.push("s1", { agentId: "ext-2", text: "also go" }); + + assert.deepEqual(await queue.take("s1", 5_000), [ + { agentId: "ext-1", text: "go" }, + { agentId: "ext-2", text: "also go" }, + ]); +}); + +test("a taken batch is not handed out twice", async () => { + const queue = new DeliveryQueue(); + queue.push("s1", { agentId: "ext-1", text: "go" }); + + await queue.take("s1", 5_000); + + assert.deepEqual(await queue.take("s1", 5), []); +}); + +test("a parked poll is resolved by the next delivery", async () => { + const queue = new DeliveryQueue(); + const polled = queue.take("s1", 5_000); + + queue.push("s1", { agentId: "ext-1", text: "go" }); + + assert.deepEqual(await polled, [{ agentId: "ext-1", text: "go" }]); +}); + +test("a poll with nothing to carry gives up empty", async () => { + const queue = new DeliveryQueue(); + assert.deepEqual(await queue.take("s1", 5), []); +}); + +test("a delivery handed to a waiter is not left queued behind it", async () => { + const queue = new DeliveryQueue(); + const polled = queue.take("s1", 5_000); + queue.push("s1", { agentId: "ext-1", text: "go" }); + await polled; + + assert.deepEqual(await queue.take("s1", 5), []); +}); + +test("a dropped participant's work is discarded and its session mate's is not", async () => { + const queue = new DeliveryQueue(); + queue.push("s1", { agentId: "gone", text: "lost" }); + queue.push("s1", { agentId: "ext-2", text: "kept" }); + + queue.drop("s1", "gone"); + + assert.deepEqual(await queue.take("s1", 5_000), [ + { agentId: "ext-2", text: "kept" }, + ]); +}); + +test("one session's poll never sees another session's work", async () => { + const queue = new DeliveryQueue(); + queue.push("s2", { agentId: "ext-1", text: "theirs" }); + + assert.deepEqual(await queue.take("s1", 5), []); + assert.deepEqual(await queue.take("s2", 5_000), [ + { agentId: "ext-1", text: "theirs" }, + ]); +}); diff --git a/apps/server/src/external/deliveryQueue.ts b/apps/server/src/external/deliveryQueue.ts new file mode 100644 index 0000000..4e7d9ba --- /dev/null +++ b/apps/server/src/external/deliveryQueue.ts @@ -0,0 +1,83 @@ +/** A message waiting to be carried to one external participant. */ +export interface PendingDelivery { + agentId: string; + text: string; +} + +/** A long-poll caller waiting for the session's next batch. */ +interface Waiter { + resolve: (deliveries: PendingDelivery[]) => void; + timer: NodeJS.Timeout; +} + +/** + * The outbound half of the external connector: messages queued per session for + * a driver that comes and asks for them, rather than a far end the server can + * dial. A bundle tool holds the only route to its runtime, so the server hands + * the work over on a long poll and the driver performs the last hop. + * + * Queued work is answered immediately; an empty queue parks the caller until + * something arrives or its budget runs out. State is in memory on purpose — a + * wake the driver never collected is a wake for a runtime that is no longer + * being driven. + */ +export class DeliveryQueue { + private readonly queued = new Map(); + private readonly waiting = new Map(); + + /** Queues one delivery, handing it straight to a waiting driver if there is one. */ + push(sessionId: string, delivery: PendingDelivery): void { + const waiter = this.waiting.get(sessionId)?.shift(); + if (waiter) { + clearTimeout(waiter.timer); + waiter.resolve([delivery]); + return; + } + const queue = this.queued.get(sessionId); + if (queue) queue.push(delivery); + else this.queued.set(sessionId, [delivery]); + } + + /** + * Takes everything queued for the session, or waits up to `timeoutMs` for the + * next delivery. Resolves empty on timeout, so a driver polls in a loop + * without either spinning or holding a request open indefinitely. + */ + take(sessionId: string, timeoutMs: number): Promise { + const queue = this.queued.get(sessionId); + if (queue?.length) { + this.queued.delete(sessionId); + return Promise.resolve(queue); + } + + return new Promise((resolve) => { + const timer = setTimeout(() => { + this.forget(sessionId, waiter); + resolve([]); + }, timeoutMs); + // Unreferenced so a parked poll never holds the process open. + timer.unref?.(); + const waiter: Waiter = { resolve, timer }; + const waiters = this.waiting.get(sessionId); + if (waiters) waiters.push(waiter); + else this.waiting.set(sessionId, [waiter]); + }); + } + + /** Discards one participant's queued work — its far end is gone. */ + drop(sessionId: string, agentId: string): void { + const queue = this.queued.get(sessionId); + if (!queue) return; + const kept = queue.filter((delivery) => delivery.agentId !== agentId); + if (kept.length) this.queued.set(sessionId, kept); + else this.queued.delete(sessionId); + } + + private forget(sessionId: string, waiter: Waiter): void { + const waiters = this.waiting.get(sessionId); + if (!waiters) return; + const kept = waiters.filter((candidate) => candidate !== waiter); + if (kept.length) this.waiting.set(sessionId, kept); + else this.waiting.delete(sessionId); + } +} diff --git a/apps/server/src/external/externalSubagentGateway.test.ts b/apps/server/src/external/externalSubagentGateway.test.ts index 14ce4ff..43d8502 100644 --- a/apps/server/src/external/externalSubagentGateway.test.ts +++ b/apps/server/src/external/externalSubagentGateway.test.ts @@ -3,6 +3,7 @@ import { test } from "node:test"; import type { SubagentInfo } from "@tangent/shared/contracts.ts"; +import { RelayRegistry } from "../mcp/relayRegistry.ts"; import type { ConversationEventSink } from "../pi/types.ts"; import { RunRegistry } from "../runs/runRegistry.ts"; import { InMemoryRunStore } from "../store/inMemoryRunStore.ts"; @@ -30,8 +31,9 @@ function makeHarness() { const runStore = new InMemoryRunStore(); const runs = new RunRegistry(runStore); const store = new InMemorySessionStore(); - const gateway = new ExternalSubagentGateway(handlers, runs, store); - return { gateway, rosterUpdates, events, runs, runStore, store }; + const relay = new RelayRegistry(); + const gateway = new ExternalSubagentGateway(handlers, runs, store, relay); + return { gateway, rosterUpdates, events, runs, runStore, store, relay }; } /** A persisted roster row, as a reattach reads one. */ @@ -257,6 +259,87 @@ test("openRun refuses an unknown agent", () => { assert.equal(h.gateway.openRun("s1", "nope"), undefined); }); +test("register opens a callback channel bound to the new participant", () => { + const h = makeHarness(); + const { id, callback } = h.gateway.register("s1", { name: "worker" }); + + const channel = h.relay.get(callback.channelId); + assert.equal(channel?.sessionId, "s1"); + assert.equal(channel?.participantId, id, "the channel speaks for the tab"); + assert.equal(channel?.label, "worker"); + assert.equal( + channel?.credential.verify({ authorization: `Bearer ${callback.secret}` }), + true, + ); +}); + +test("a terminal status closes the tab's callback channel", () => { + const h = makeHarness(); + const { id, callback } = h.gateway.register("s1", { name: "worker" }); + + h.gateway.setStatus("s1", id, "completed"); + + assert.equal(h.relay.get(callback.channelId), undefined); +}); + +test("a detached tab keeps its channel, because it has something to come back to", () => { + const h = makeHarness(); + const { id, callback } = h.gateway.register("s1", { name: "worker" }); + + h.gateway.setStatus("s1", id, "detached"); + + assert.ok(h.relay.get(callback.channelId)); +}); + +test("a delivery is queued for the driver that next asks for it", async () => { + const h = makeHarness(); + const { id } = h.gateway.register("s1", { name: "worker" }); + + assert.equal(h.gateway.deliver("s1", id, "do the thing"), true); + + assert.deepEqual(await h.gateway.takeDeliveries("s1", 5), [ + { agentId: id, text: "do the thing" }, + ]); +}); + +test("a parked poll is answered by a delivery that arrives after it", async () => { + const h = makeHarness(); + const { id } = h.gateway.register("s1", { name: "worker" }); + + const polled = h.gateway.takeDeliveries("s1", 5_000); + h.gateway.deliver("s1", id, "do the thing"); + + assert.deepEqual(await polled, [{ agentId: id, text: "do the thing" }]); +}); + +test("a poll with nothing queued is answered empty", async () => { + const h = makeHarness(); + assert.deepEqual(await h.gateway.takeDeliveries("s1", 5), []); +}); + +test("nothing is queued for an unknown or detached tab", () => { + const h = makeHarness(); + const { id } = h.gateway.register("s1", { name: "worker" }); + h.gateway.setStatus("s1", id, "detached"); + + assert.equal(h.gateway.deliver("s1", "nope", "hello"), false); + assert.equal(h.gateway.deliver("s1", id, "hello"), false); +}); + +test("a tab going terminal discards what was queued for it", async () => { + const h = makeHarness(); + const gone = h.gateway.register("s1", { name: "gone" }); + const kept = h.gateway.register("s1", { name: "kept" }); + h.gateway.deliver("s1", gone.id, "lost"); + h.gateway.deliver("s1", kept.id, "still wanted"); + + h.gateway.setStatus("s1", gone.id, "completed"); + + assert.deepEqual(await h.gateway.takeDeliveries("s1", 5), [ + { agentId: kept.id, text: "still wanted" }, + ]); +}); + test("listSubagents is scoped per session", () => { const h = makeHarness(); const a = h.gateway.register("s1", { name: "one" }); diff --git a/apps/server/src/external/externalSubagentGateway.ts b/apps/server/src/external/externalSubagentGateway.ts index 5789138..f47ed08 100644 --- a/apps/server/src/external/externalSubagentGateway.ts +++ b/apps/server/src/external/externalSubagentGateway.ts @@ -12,10 +12,12 @@ import { } from "@tangent/shared/contracts.ts"; import type { RemoteAgentEvent } from "@tangent/shared/remoteSubagent.ts"; +import type { RelayRegistry } from "../mcp/relayRegistry.ts"; import { parseThinkingLevel } from "../pi/agentConfig.ts"; import type { AgentDescriptor, ConversationEventSink } from "../pi/types.ts"; import type { RunRegistry, SettledStatus } from "../runs/runRegistry.ts"; import type { SessionAgent, SessionStore } from "../store/sessionStore.ts"; +import { DeliveryQueue, type PendingDelivery } from "./deliveryQueue.ts"; /** Display metadata a caller supplies when registering an external sub-agent. */ export interface RegisterExternalSubagent { @@ -25,6 +27,18 @@ export interface RegisterExternalSubagent { thinkingDepth?: ThinkingLevel; } +/** The callback channel a newly registered sub-agent's far end dials back on. */ +export interface RegisteredCallback { + channelId: string; + secret: string; +} + +/** A registered sub-agent tab and the callback channel opened alongside it. */ +export interface RegisteredExternalSubagent { + id: string; + callback: RegisteredCallback; +} + /** What a caller supplies to open a Run for an external sub-agent's turn. */ export interface OpenExternalRun { /** The far side's own id for this work (an Aquifer World session id). */ @@ -41,6 +55,8 @@ interface ExternalSubagent { template?: string; model?: string; thinkingDepth?: ThinkingLevel; + /** The callback channel opened for this tab, closed when it goes terminal. */ + channelId?: string; createdAt: string; } @@ -72,15 +88,22 @@ function toInfo(subagent: ExternalSubagent): SubagentInfo { * Registry of **external sub-agent** tabs, held in memory and persisted as * roster rows so a restart has something to reattach to. 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 ConversationEventSink}, so an - * external sub-agent renders and persists like a local one. + * `/internal/external-agents` API); the gateway owns the sidebar tab, relays + * streamed events into it via the shared {@link ConversationEventSink}, and holds + * both directions of its transport, so an external sub-agent renders, persists + * and is addressed like a local one. + * + * Both legs are this one gateway's business. Inbound is the event stream the + * driver pushes; outbound is a {@link DeliveryQueue} the driver drains, because + * only the driver holds a route to the runtime — the server hands the work over + * and the driver performs the last hop. The callback channel a far end dials + * back on is opened here too, bound to the participant, and closed when the tab + * goes terminal. * - * 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. Its connector is `external-inbound`: - * the far side is created and destroyed by the bundle tool driving it, so the - * participant is owned rather than attached. Sits alongside {@link + * The gateway stays transport-agnostic and carries no knowledge of what runtime + * backs a tab. Its connector is `external-inbound`: the far side is created and + * destroyed by the bundle tool driving it, so the participant is owned rather + * than attached. Sits alongside {@link * import("../remote/remoteEnvironmentGateway.ts").RemoteEnvironmentGateway} and * {@link import("../pi/piAgentManager.ts").PiAgentManager}. */ @@ -88,6 +111,8 @@ export class ExternalSubagentGateway { private readonly handlers: ConversationEventSink; private readonly runs: RunRegistry; private readonly store: SessionStore; + private readonly relay: RelayRegistry; + private readonly outbound = new DeliveryQueue(); /** Per-session external sub-agent rosters, keyed by sessionId then agentId. */ private readonly sessions = new Map>(); @@ -96,10 +121,12 @@ export class ExternalSubagentGateway { handlers: ConversationEventSink, runs: RunRegistry, store: SessionStore, + relay: RelayRegistry, ) { this.handlers = handlers; this.runs = runs; this.store = store; + this.relay = relay; } /** True when `agentId` is an external sub-agent of `sessionId`. */ @@ -115,16 +142,30 @@ export class ExternalSubagentGateway { } /** - * 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. + * Registers a new external sub-agent tab, assigns it a UUID, opens the + * callback channel its far end dials back on, 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, with the channel. + * + * The channel is opened here rather than by a separate call so that a tab and + * the way back to it are one act: nothing can hold a channel for a + * participant that was never registered, and nothing has to remember to close + * one when the tab ends. * * The tab is persisted as a roster row, so a restart has something to reattach * to. Best-effort: `sessionId` comes from an external caller, and a bogus one * must fail the row rather than the process. */ - register(sessionId: string, spec: RegisterExternalSubagent): { id: string } { + register( + sessionId: string, + spec: RegisterExternalSubagent, + ): RegisteredExternalSubagent { const agentId = randomUUID(); + const channel = this.relay.open({ + sessionId, + label: spec.name, + participantId: agentId, + }); const subagent: ExternalSubagent = { agentId, name: spec.name, @@ -132,6 +173,7 @@ export class ExternalSubagentGateway { template: spec.template, model: spec.model, thinkingDepth: spec.thinkingDepth, + channelId: channel.channelId, createdAt: new Date().toISOString(), }; this.rosterFor(sessionId).set(agentId, subagent); @@ -154,7 +196,32 @@ export class ExternalSubagentGateway { ); }); this.handlers.onSubagentUpdate(sessionId, toInfo(subagent)); - return { id: agentId }; + return { id: agentId, callback: channel }; + } + + /** + * Queues a message for one external sub-agent, to be carried by the driver + * that next asks for it. False when there is no tab to carry it to, or when + * the tab is `detached` — nothing is driving that runtime, so a wake left in + * the queue would be a promise the transport cannot keep. + */ + deliver(sessionId: string, agentId: string, text: string): boolean { + const subagent = this.sessions.get(sessionId)?.get(agentId); + if (!subagent || subagent.status === "detached") return false; + this.outbound.push(sessionId, { agentId, text }); + return true; + } + + /** + * Hands the session's queued messages to its driver, waiting up to + * `timeoutMs` for one to arrive. Empty when nothing is queued in that window, + * so the driver polls in a loop rather than holding one request forever. + */ + takeDeliveries( + sessionId: string, + timeoutMs: number, + ): Promise { + return this.outbound.take(sessionId, timeoutMs); } /** @@ -270,10 +337,11 @@ export class ExternalSubagentGateway { } /** - * Applies a lifecycle status change to a sub-agent tab. A terminal status drops - * the roster entry and settles whatever Run the tab still had open; `detached` - * keeps it, because the point of that state is having something to come back - * to. No-op for an unknown id. + * Applies a lifecycle status change to a sub-agent tab. A terminal status + * drops the roster entry, closes the callback channel, discards whatever was + * still queued for it, and settles whatever Run the tab still had open; + * `detached` keeps it, because the point of that state is having something to + * come back to. No-op for an unknown id. */ setStatus(sessionId: string, agentId: string, status: SubagentStatus): void { const roster = this.sessions.get(sessionId); @@ -283,6 +351,7 @@ export class ExternalSubagentGateway { subagent.status = status; if (isTerminalStatus(status)) { roster.delete(agentId); + this.retire(sessionId, subagent); this.runs.settleOpenFor( sessionId, agentId, @@ -292,6 +361,13 @@ export class ExternalSubagentGateway { this.handlers.onSubagentUpdate(sessionId, toInfo(subagent)); } + /** Releases everything a finished tab was holding open. */ + private retire(sessionId: string, subagent: ExternalSubagent): void { + if (subagent.channelId) this.relay.close(subagent.channelId); + subagent.channelId = undefined; + this.outbound.drop(sessionId, subagent.agentId); + } + /** Returns (creating if needed) the session's external sub-agent roster. */ private rosterFor(sessionId: string): Map { const existing = this.sessions.get(sessionId); diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 021ca00..44601c3 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -12,12 +12,12 @@ import { ConversationRouter } from "./conversation/conversationRouter.ts"; import { MembershipRegistry } from "./conversation/membershipRegistry.ts"; import { ExternalSubagentGateway } from "./external/externalSubagentGateway.ts"; import { RelayRegistry } from "./mcp/relayRegistry.ts"; +import { createRelayReport } from "./mcp/relayReport.ts"; import { errorHandler } from "./middleware/errorHandler.ts"; import { MemoryManager } from "./pi/memory.ts"; import { type ConversationEventSink, PiAgentManager, - PRIME_AGENT_ID, } from "./pi/piAgentManager.ts"; import { TriggerEngine } from "./pi/triggers/triggerEngine.ts"; import { TriggerManager } from "./pi/triggers/triggerManager.ts"; @@ -141,10 +141,22 @@ const remoteGateway = new RemoteEnvironmentGateway( runs, ); +// Generic MCP relay: bridges an external MCP client (dialed by a gateway) back +// into a session. A connector opens a channel for the participant it registers; +// the peer's tool calls arrive on the public /api/mcp route. +const mcpRelay = new RelayRegistry(); + // 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, runs, store); +// tab via the same relay handlers a local sub-agent uses. It holds both legs — +// the inbound stream and the outbound queue its driver drains — plus the +// callback channel each tab's far side dials back on. +const externalGateway = new ExternalSubagentGateway( + agentHandlers, + runs, + store, + mcpRelay, +); // Registry of attached A2A agents: heterogeneous agents that already run as a // service elsewhere, which Tangent dials over the A2A protocol. Their Tasks @@ -172,20 +184,9 @@ const connectors = createConnectorRegistry( // not in the dependency, so it is broken here rather than by an indirection. conversations.useConnectors(connectors); -// Relays a message into a session's Prime. The generic MCP relay's peer is not a -// participant in any Conversation, so its text is delivered rather than posted. -const deliverToPrime = (sessionId: string, text: string): void => { - connectors.resolve(sessionId, PRIME_AGENT_ID).deliver({ - sessionId, - participantId: PRIME_AGENT_ID, - text, - }); -}; - -// 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(); +// Where a relay peer's words land: posted as the participant its channel belongs +// to, or delivered to Prime when no participant owns the channel. +const relayReport = createRelayReport(connectors, conversations); // Drives schedule timers and callback firings, posting prompts into the target's // Conversation. @@ -214,7 +215,7 @@ 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)); +app.use("/api/mcp", createMcpRelayRouter(mcpRelay, relayReport)); // 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. diff --git a/apps/server/src/mcp/channelUrl.ts b/apps/server/src/mcp/channelUrl.ts new file mode 100644 index 0000000..7745f5a --- /dev/null +++ b/apps/server/src/mcp/channelUrl.ts @@ -0,0 +1,20 @@ +import { PUBLIC_URL } from "../config.ts"; + +/** + * Whether a dial-able channel address can be issued at all. The gateway reaches + * Tangent rather than the reverse, so an unset {@link PUBLIC_URL} means a + * channel has no address to be handed out under. + */ +export function canIssueChannelUrl(): boolean { + return Boolean(PUBLIC_URL); +} + +/** + * The address an external MCP client dials for a channel, or nothing when this + * server has not been told its own public base. One shared composition, so every + * issuer agrees on the address a peer is told to dial. + */ +export function channelUrl(channelId: string): string | undefined { + if (!PUBLIC_URL) return undefined; + return `${PUBLIC_URL}/api/mcp/${channelId}`; +} diff --git a/apps/server/src/mcp/mcpRelayServer.ts b/apps/server/src/mcp/mcpRelayServer.ts index d83f7bb..76b9731 100644 --- a/apps/server/src/mcp/mcpRelayServer.ts +++ b/apps/server/src/mcp/mcpRelayServer.ts @@ -1,19 +1,18 @@ import { randomUUID } from "node:crypto"; import type { RelayChannel, RelayRegistry } from "./relayRegistry.ts"; +import type { RelayReport } from "./relayReport.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 + * tools that carry the peer's words back to Prime. It has no knowledge of the * remote runtime on the other end (that lives entirely in the bundle that - * opened the channel). + * opened the channel), and none of how its report reaches the session: it hands + * over the peer's own words and {@link RelayReport} decides where they land. */ -/** 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; @@ -68,7 +67,7 @@ interface Ctx { registry: RelayRegistry; channel: RelayChannel; message: JsonRpcRequest; - deliverToPrime: DeliverToPrime; + report: RelayReport; id: string | number; } @@ -89,7 +88,7 @@ export async function dispatchMcp( registry: RelayRegistry, channel: RelayChannel, message: JsonRpcRequest, - deliverToPrime: DeliverToPrime, + report: RelayReport, ): Promise { const method = String(message.method ?? ""); const id = message.id; @@ -109,7 +108,7 @@ export async function dispatchMcp( error: { code: -32601, message: `method not found: ${method}` }, }; } - return handler({ registry, channel, message, deliverToPrime, id }); + return handler({ registry, channel, message, report, id }); } function handleInitialize({ message, id }: Ctx): JsonRpcResponse { @@ -154,31 +153,29 @@ function callTool( return `Unknown tool: ${name}`; } -function sendToPrimeTool( - { channel, deliverToPrime }: Ctx, +async function sendToPrimeTool( + { channel, report }: Ctx, args: Record, -): string { +): Promise { 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}`, - ); + await report(channel, text); return "Delivered to Prime."; } async function askPrimeTool( - { registry, channel, deliverToPrime }: Ctx, + { registry, channel, report }: 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` + + // The request id is part of what the peer is asking, not framing around it: + // whoever answers has to be told which question they are answering. + await report( + channel, + `${question}\n\n` + `Reply by supplying an answer for request_id "${requestId}".`, ); const deadline = Date.now() + ASK_TIMEOUT_MS; diff --git a/apps/server/src/mcp/relayRegistry.test.ts b/apps/server/src/mcp/relayRegistry.test.ts index 2028084..add3e9e 100644 --- a/apps/server/src/mcp/relayRegistry.test.ts +++ b/apps/server/src/mcp/relayRegistry.test.ts @@ -4,6 +4,17 @@ import { test } from "node:test"; import { dispatchMcp } from "./mcpRelayServer.ts"; import { RelayRegistry } from "./relayRegistry.ts"; +/** A report that records what it was handed instead of entering a session. */ +function captureReport() { + const reported: Array<{ channelId: string; text: string }> = []; + return { + reported, + report: async (channel: { channelId: string }, text: string) => { + reported.push({ channelId: channel.channelId, text }); + }, + }; +} + 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" }); @@ -16,6 +27,15 @@ test("open issues a distinct channel id and secret bound to the session", () => assert.equal(registry.get(b.channelId)?.label, "remote agent"); }); +test("a channel opened for a participant records whose it is", () => { + const registry = new RelayRegistry(); + const owned = registry.open({ sessionId: "s1", participantId: "ext-1" }); + const unowned = registry.open({ sessionId: "s1" }); + + assert.equal(registry.get(owned.channelId)?.participantId, "ext-1"); + assert.equal(registry.get(unowned.channelId)?.participantId, undefined); +}); + test("a channel's credential opens that channel and no other", () => { const registry = new RelayRegistry(); const a = registry.open({ sessionId: "s1" }); @@ -62,7 +82,7 @@ test("tools/list advertises the two relay tools", async () => { registry, channel, { jsonrpc: "2.0", id: 1, method: "tools/list" }, - () => {}, + captureReport().report, ); const tools = (res?.result as { tools: { name: string }[] }).tools; @@ -72,12 +92,12 @@ test("tools/list advertises the two relay tools", async () => { ]); }); -test("send_to_prime relays labeled text to the session's Prime", async () => { +test("send_to_prime reports the peer's own words, unwrapped", async () => { const registry = new RelayRegistry(); const channel = registry.get( registry.open({ sessionId: "s1", label: "explorer" }).channelId, )!; - const delivered: Array<{ sessionId: string; text: string }> = []; + const capture = captureReport(); const res = await dispatchMcp( registry, @@ -88,17 +108,49 @@ test("send_to_prime relays labeled text to the session's Prime", async () => { method: "tools/call", params: { name: "send_to_prime", arguments: { text: "found it" } }, }, - (sessionId, text) => delivered.push({ sessionId, text }), + capture.report, ); - assert.equal(delivered.length, 1); - assert.equal(delivered[0].sessionId, "s1"); - assert.match(delivered[0].text, /explorer/); - assert.match(delivered[0].text, /found it/); + // Framing belongs to whoever lands the text, so what arrives here is exactly + // what the peer said — a bound channel posts it as the participant's own words. + assert.deepEqual(capture.reported, [ + { channelId: channel.channelId, text: "found it" }, + ]); const content = (res?.result as { content: { text: string }[] }).content; assert.match(content[0].text, /Delivered/); }); +test("ask_prime reports the question with the id an answer must name", async () => { + const registry = new RelayRegistry(); + const { channelId } = registry.open({ sessionId: "s1" }); + const channel = registry.get(channelId)!; + const capture = captureReport(); + + const call = dispatchMcp( + registry, + channel, + { + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { name: "ask_prime", arguments: { question: "which zone?" } }, + }, + capture.report, + ); + + // The pending question is registered before the report is awaited, so the + // request id is answerable by the time anyone reads it. + const [pending] = registry.pending(channelId); + assert.equal(pending.question, "which zone?"); + assert.match(capture.reported[0].text, /which zone\?/); + assert.match(capture.reported[0].text, new RegExp(pending.request_id)); + + registry.answer(channelId, pending.request_id, "zone-42"); + const res = await call; + const content = (res?.result as { content: { text: string }[] }).content; + assert.equal(content[0].text, "zone-42"); +}); + test("notifications receive no response body", async () => { const registry = new RelayRegistry(); const channel = registry.get(registry.open({ sessionId: "s1" }).channelId)!; @@ -106,7 +158,7 @@ test("notifications receive no response body", async () => { registry, channel, { jsonrpc: "2.0", method: "notifications/initialized" }, - () => {}, + captureReport().report, ); assert.equal(res, null); }); diff --git a/apps/server/src/mcp/relayRegistry.ts b/apps/server/src/mcp/relayRegistry.ts index 4d36319..34cb007 100644 --- a/apps/server/src/mcp/relayRegistry.ts +++ b/apps/server/src/mcp/relayRegistry.ts @@ -17,6 +17,13 @@ export interface RelayChannel { sessionId: string; /** Human label used when relaying messages to Prime (e.g. the peer's name). */ label: string; + /** + * The Participant this channel speaks for, when one owns it. A channel a + * connector opened for a participant reports **as** that participant, in its + * own Conversation; an unowned one has no standing anywhere and can only be + * relayed to Prime. + */ + participantId?: string; /** The credential this channel — and only this channel — is opened by. */ credential: ConnectorCredential; /** Open questions awaiting an answer, keyed by request id. */ @@ -29,6 +36,8 @@ export interface RelayChannel { export interface OpenChannelInput { sessionId: string; label?: string; + /** The Participant the channel belongs to, when a connector owns it. */ + participantId?: string; } export interface PendingQuestion { @@ -52,6 +61,7 @@ export class RelayRegistry { channelId, sessionId: input.sessionId, label: input.label?.trim() || "remote agent", + participantId: input.participantId, credential, pending: new Map(), answers: new Map(), diff --git a/apps/server/src/mcp/relayReport.test.ts b/apps/server/src/mcp/relayReport.test.ts new file mode 100644 index 0000000..a205035 --- /dev/null +++ b/apps/server/src/mcp/relayReport.test.ts @@ -0,0 +1,125 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { connectorFor, type SubagentInfo } from "@tangent/shared/contracts.ts"; +import type { Server } from "socket.io"; + +import type { ConnectorRegistry } from "../connectors/connectorRegistry.ts"; +import type { DeliveryRequest } from "../connectors/types.ts"; +import { ConversationRouter } from "../conversation/conversationRouter.ts"; +import { MembershipRegistry } from "../conversation/membershipRegistry.ts"; +import { InMemoryMembershipStore } from "../store/inMemoryMembershipStore.ts"; +import { InMemorySessionStore } from "../store/inMemorySessionStore.ts"; +import { RelayRegistry } from "./relayRegistry.ts"; +import { createRelayReport } from "./relayReport.ts"; + +/** + * A report over a real router and membership registry, with the roster faked at + * the connector boundary — so where a peer's words land is decided by the same + * derivation delivery uses. + */ +function makeReport(roster: SubagentInfo[]) { + const sessions = new InMemorySessionStore(); + const memberships = new MembershipRegistry( + sessions, + new InMemoryMembershipStore(), + () => true, + ); + const delivered: DeliveryRequest[] = []; + + const io = { + to: () => ({ emit: () => {} }), + } as unknown as Server; + + const connectors = { + list: () => roster, + resolve: () => ({ + deliver: (request: DeliveryRequest) => { + delivered.push(request); + return { delivered: true }; + }, + }), + } as unknown as ConnectorRegistry; + + const conversations = new ConversationRouter(io, sessions, memberships); + conversations.useConnectors(connectors); + const relay = new RelayRegistry(); + return { + relay, + sessions, + delivered, + report: createRelayReport(connectors, conversations), + }; +} + +function workerRow(id: string, name: string): SubagentInfo { + return { + id, + name, + status: "active", + connector: connectorFor("external-inbound"), + createdAt: "2026-01-01T00:00:00.000Z", + }; +} + +test("a participant's channel posts in its own thread, addressed to Prime", async () => { + const h = makeReport([workerRow("ext-1", "Explorer")]); + await h.sessions.recordAgent("s1", { + id: "ext-1", + role: "subagent", + name: "Explorer", + status: "active", + autoRelayToPrime: true, + connector: connectorFor("external-inbound"), + }); + const { channelId } = h.relay.open({ + sessionId: "s1", + label: "Explorer", + participantId: "ext-1", + }); + + await h.report(h.relay.get(channelId)!, "found the zone"); + + const [message] = await h.sessions.getMessages("s1"); + assert.equal(message.conversationId, "ext-1"); + assert.equal(message.author.id, "ext-1"); + assert.equal(message.author.name, "Explorer"); + assert.equal(message.content, "found the zone", "its own words, unwrapped"); + assert.deepEqual(message.mentions, ["prime"]); + assert.deepEqual( + h.delivered.map((request) => request.participantId), + ["prime"], + "and Prime woke because it was mentioned", + ); +}); + +test("a channel nobody owns still delivers to Prime with the peer's label", async () => { + const h = makeReport([]); + const { channelId } = h.relay.open({ sessionId: "s1", label: "explorer" }); + + await h.report(h.relay.get(channelId)!, "found the zone"); + + assert.equal(h.delivered.length, 1); + assert.equal(h.delivered[0].participantId, "prime"); + assert.match(h.delivered[0].text, /explorer/); + assert.match(h.delivered[0].text, /found the zone/); + assert.deepEqual( + await h.sessions.getMessages("s1"), + [], + "nothing is posted for a peer with no standing anywhere", + ); +}); + +test("a bound channel whose tab has gone falls back rather than losing the text", async () => { + const h = makeReport([]); + const { channelId } = h.relay.open({ + sessionId: "s1", + label: "explorer", + participantId: "ext-gone", + }); + + await h.report(h.relay.get(channelId)!, "last word"); + + assert.equal(h.delivered.length, 1); + assert.match(h.delivered[0].text, /last word/); +}); diff --git a/apps/server/src/mcp/relayReport.ts b/apps/server/src/mcp/relayReport.ts new file mode 100644 index 0000000..2760e69 --- /dev/null +++ b/apps/server/src/mcp/relayReport.ts @@ -0,0 +1,53 @@ +import type { ConnectorRegistry } from "../connectors/connectorRegistry.ts"; +import { subagentAuthor } from "../connectors/participantAuthor.ts"; +import type { ConversationRouter } from "../conversation/conversationRouter.ts"; +import { PRIME_AGENT_ID } from "../pi/types.ts"; +import type { RelayChannel } from "./relayRegistry.ts"; + +/** Carries what a relay peer said back into the session it belongs to. */ +export type RelayReport = ( + channel: RelayChannel, + text: string, +) => Promise; + +/** + * How a peer's words enter the session, in the two cases a channel can be in. + * + * A channel a connector opened **for a participant** reports as that + * participant: the text is posted in its own Conversation addressed to Prime, + * which is the same act `message_prime` performs for a local sub-agent. Its + * words therefore appear in its own tab, are attributed to it, and wake Prime + * because they mention it — not because the relay knows who to poke. + * + * A channel nobody owns has no standing in any Conversation, so there is nowhere + * to post: its text is delivered to Prime with the peer's label, which is what + * the relay has always done. + */ +export function createRelayReport( + connectors: ConnectorRegistry, + conversations: ConversationRouter, +): RelayReport { + return async (channel, text) => { + const author = channel.participantId + ? subagentAuthor(connectors, channel.sessionId, channel.participantId) + : undefined; + + if (!author) { + connectors.resolve(channel.sessionId, PRIME_AGENT_ID).deliver({ + sessionId: channel.sessionId, + participantId: PRIME_AGENT_ID, + text: `Remote agent (${channel.label}) reports:\n\n${text}`, + }); + return; + } + + await conversations.post({ + sessionId: channel.sessionId, + conversationId: author.id, + author, + content: text, + mentions: [PRIME_AGENT_ID], + ingress: "tool", + }); + }; +} diff --git a/apps/server/src/routes/internalAgents.ts b/apps/server/src/routes/internalAgents.ts index 593a9c0..45271b7 100644 --- a/apps/server/src/routes/internalAgents.ts +++ b/apps/server/src/routes/internalAgents.ts @@ -1,5 +1,4 @@ import { - type ChatAuthor, connectorFields, type ConnectorKind, PI_AGENT, @@ -10,6 +9,7 @@ import { z } from "zod"; import type { A2aPeerGateway } from "../a2a/a2aPeerGateway.ts"; import type { ConnectorRegistry } from "../connectors/connectorRegistry.ts"; import { piCredential } from "../connectors/credentials.ts"; +import { subagentAuthor } from "../connectors/participantAuthor.ts"; import type { ConversationRouter } from "../conversation/conversationRouter.ts"; import { requireCredential } from "../middleware/requireCredential.ts"; import { getValidated, validate } from "../middleware/validate.ts"; @@ -249,24 +249,6 @@ async function handleReport( res.json({ ok: true }); } -/** The chat author of a live sub-agent, read from the roster it appears in. */ -function subagentAuthor( - connectors: ConnectorRegistry, - sessionId: string, - agentId: string, -): ChatAuthor | undefined { - const subagent = connectors - .list(sessionId) - .find((candidate) => candidate.id === agentId); - if (!subagent) return undefined; - return { - id: subagent.id, - kind: "agent", - name: subagent.name, - agentRole: "subagent", - }; -} - /** Terminates a sub-agent, optionally marking its work completed. */ function handleKill( connectors: ConnectorRegistry, diff --git a/apps/server/src/routes/internalExternalAgents.ts b/apps/server/src/routes/internalExternalAgents.ts index ecd494d..4d014ec 100644 --- a/apps/server/src/routes/internalExternalAgents.ts +++ b/apps/server/src/routes/internalExternalAgents.ts @@ -4,10 +4,17 @@ import { z } from "zod"; import { externalCredential } from "../connectors/credentials.ts"; import type { ExternalSubagentGateway } from "../external/externalSubagentGateway.ts"; +import { channelUrl } from "../mcp/channelUrl.ts"; import { requireCredential } from "../middleware/requireCredential.ts"; import { getValidated, validate } from "../middleware/validate.ts"; import { parseThinkingLevel } from "../pi/agentConfig.ts"; +/** + * How long a driver's poll is held open before it is answered empty. Well under + * a proxy's idle timeout, so a parked poll is never cut off mid-request. + */ +const DELIVERY_POLL_MS = 25_000; + /** Register body: create an external sub-agent tab with optional display meta. */ const registerSchema = z.object({ sessionId: z.string(), @@ -57,6 +64,53 @@ const statusSchema = z.object({ }); type StatusBody = z.infer; +/** Deliveries body: collect whatever is queued for a session's external tabs. */ +const deliveriesSchema = z.object({ + sessionId: z.string(), +}); +type DeliveriesBody = z.infer; + +/** + * Registers a tab and answers with the callback channel its far side dials + * back on. `callback` is null when this server has no public URL to hand out: + * the tab is still usable one-way, and the caller decides whether that is + * enough for the runtime it is about to create. + */ +function handleRegister( + gateway: ExternalSubagentGateway, + body: RegisterBody, + res: Response, +): void { + const { id, callback } = gateway.register(body.sessionId, { + name: body.name, + template: body.template, + model: body.model, + thinkingDepth: parseThinkingLevel(body.thinkingDepth), + }); + const url = channelUrl(callback.channelId); + res.json({ + subagent: { id }, + callback: url ? { ...callback, url } : null, + }); +} + +/** + * Hands the session's queued messages to its driver. The driver holds the only + * route to the runtime, so it asks for work rather than being dialed; a poll that + * waits out its budget is answered empty and the driver asks again. + */ +async function handleDeliveries( + gateway: ExternalSubagentGateway, + body: DeliveriesBody, + res: Response, +): Promise { + const deliveries = await gateway.takeDeliveries( + body.sessionId, + DELIVERY_POLL_MS, + ); + res.json({ deliveries }); +} + /** Opens a run for a turn of external work, answering with its id. */ function handleOpenRun( gateway: ExternalSubagentGateway, @@ -77,10 +131,11 @@ function handleOpenRun( /** * 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 external - * connector's own credential, which checks the same internal token the other - * internal APIs do; the gateway stays transport-agnostic and - * proprietary-runtime specifics live entirely in the caller. + * runtime's output into it, marks its lifecycle, and collects the messages + * Tangent wants carried to it. Guarded by the external connector's own + * credential, which checks the same internal token the other internal APIs do; + * the gateway stays transport-agnostic and proprietary-runtime specifics live + * entirely in the caller. */ export function createInternalExternalAgentsRouter( gateway: ExternalSubagentGateway, @@ -89,16 +144,14 @@ export function createInternalExternalAgentsRouter( router.use(requireCredential(externalCredential)); - 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("/register", validate({ body: registerSchema }), (req, res) => + handleRegister(gateway, getValidated(req).body, res), + ); + + // The outbound leg. + router.post("/deliveries", validate({ body: deliveriesSchema }), (req, res) => + handleDeliveries(gateway, getValidated(req).body, res), + ); // A turn of external work is a Run: the driving tool declares its start and // end, because only it can see the far side's boundaries. diff --git a/apps/server/src/routes/internalMcpRelay.ts b/apps/server/src/routes/internalMcpRelay.ts index befc729..ade8974 100644 --- a/apps/server/src/routes/internalMcpRelay.ts +++ b/apps/server/src/routes/internalMcpRelay.ts @@ -1,7 +1,7 @@ import { type Request, type Response, Router } from "express"; import { z } from "zod"; -import { PUBLIC_URL } from "../config.ts"; +import { canIssueChannelUrl, channelUrl } from "../mcp/channelUrl.ts"; import type { RelayRegistry } from "../mcp/relayRegistry.ts"; import { requireInternalToken } from "../middleware/requireInternalToken.ts"; import { getValidated, validate } from "../middleware/validate.ts"; @@ -27,7 +27,7 @@ async function handleOpen( body: OpenInput, res: Response, ): Promise { - if (!PUBLIC_URL) { + if (!canIssueChannelUrl()) { res.status(501).json({ error: "TANGENT_PUBLIC_URL is not set. The external MCP client must dial an " + @@ -48,7 +48,7 @@ async function handleOpen( sessionId: body.sessionId, label: body.label, }); - const url = `${PUBLIC_URL}/api/mcp/${channelId}`; + const url = channelUrl(channelId); console.error( `[mcp-relay] opened channel ${channelId} for session ${body.sessionId} ` + `-> ${url}`, diff --git a/apps/server/src/routes/mcp.ts b/apps/server/src/routes/mcp.ts index 65ae727..b046fd6 100644 --- a/apps/server/src/routes/mcp.ts +++ b/apps/server/src/routes/mcp.ts @@ -1,11 +1,12 @@ import { type Request, type Response, Router } from "express"; -import { type DeliverToPrime, dispatchMcp } from "../mcp/mcpRelayServer.ts"; +import { dispatchMcp } from "../mcp/mcpRelayServer.ts"; import type { RelayChannel, RelayRegistry } from "../mcp/relayRegistry.ts"; +import type { RelayReport } from "../mcp/relayReport.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/*`; + * tool calls back into a channel's session. 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 @@ -13,14 +14,14 @@ import type { RelayChannel, RelayRegistry } from "../mcp/relayRegistry.ts"; */ export function createMcpRelayRouter( registry: RelayRegistry, - deliverToPrime: DeliverToPrime, + report: RelayReport, ): 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), + handlePost(registry, report, req, res), ); return router; } @@ -41,7 +42,7 @@ function handleGet(registry: RelayRegistry, req: Request, res: Response): void { async function handlePost( registry: RelayRegistry, - deliverToPrime: DeliverToPrime, + report: RelayReport, req: Request, res: Response, ): Promise { @@ -66,12 +67,7 @@ async function handlePost( return; } - const response = await dispatchMcp( - registry, - channel, - req.body ?? {}, - deliverToPrime, - ); + const response = await dispatchMcp(registry, channel, req.body ?? {}, report); if (response === null) { console.error(`[mcp-relay] ${channelId} -> 202 (notification)`); res.status(202).end(); From 075d3699ff8095ae48927964457d20e765daa589 Mon Sep 17 00:00:00 2001 From: Maksym Yezhov Date: Thu, 13 Aug 2026 11:39:14 -0700 Subject: [PATCH 11/18] - refactor: participant, Membership and Conversation as tables --- apps/server/src/a2a/a2aPeerGateway.test.ts | 7 +- .../src/connectors/a2aConnector.test.ts | 1 + .../src/connectors/connectorRegistry.test.ts | 2 + .../src/conversation/membershipRegistry.ts | 29 +- .../conversation/participantRegistry.test.ts | 121 +++ .../src/conversation/participantRegistry.ts | 96 +++ .../external/externalSubagentGateway.test.ts | 6 +- apps/server/src/index.ts | 16 +- apps/server/src/mcp/relayReport.test.ts | 2 +- apps/server/src/mcp/relayReport.ts | 11 +- apps/server/src/pi/extensions/orchestrator.ts | 18 +- apps/server/src/pi/piAgentManager.test.ts | 6 +- apps/server/src/pi/piAgentManager.ts | 8 + apps/server/src/pi/triggers/triggerEngine.ts | 8 +- .../src/remote/remoteEnvironmentGateway.ts | 16 +- apps/server/src/routes/internalAgents.ts | 33 +- apps/server/src/routes/sessions/handlers.ts | 11 +- apps/server/src/sockets/chat.ts | 16 +- apps/server/src/sockets/chatMemory.ts | 20 +- apps/server/src/sockets/sessionRoster.ts | 21 +- .../db/migrations/0011_small_magneto.sql | 37 + .../db/migrations/meta/0011_snapshot.json | 740 ++++++++++++++++++ .../store/db/migrations/meta/_journal.json | 7 + apps/server/src/store/db/schema.ts | 48 ++ .../src/store/inMemoryParticipantStore.ts | 32 + apps/server/src/store/inMemorySessionStore.ts | 27 +- apps/server/src/store/participantStore.ts | 100 +++ apps/server/src/store/sessionStore.ts | 7 + .../src/store/sqliteParticipantStore.test.ts | 93 +++ .../src/store/sqliteParticipantStore.ts | 170 ++++ .../src/store/sqliteSessionStore.test.ts | 86 +- apps/server/src/store/sqliteSessionStore.ts | 20 +- packages/shared/src/contracts.ts | 34 + 33 files changed, 1768 insertions(+), 81 deletions(-) create mode 100644 apps/server/src/conversation/participantRegistry.test.ts create mode 100644 apps/server/src/conversation/participantRegistry.ts create mode 100644 apps/server/src/store/db/migrations/0011_small_magneto.sql create mode 100644 apps/server/src/store/db/migrations/meta/0011_snapshot.json create mode 100644 apps/server/src/store/inMemoryParticipantStore.ts create mode 100644 apps/server/src/store/participantStore.ts create mode 100644 apps/server/src/store/sqliteParticipantStore.test.ts create mode 100644 apps/server/src/store/sqliteParticipantStore.ts diff --git a/apps/server/src/a2a/a2aPeerGateway.test.ts b/apps/server/src/a2a/a2aPeerGateway.test.ts index d1b4d12..5d27528 100644 --- a/apps/server/src/a2a/a2aPeerGateway.test.ts +++ b/apps/server/src/a2a/a2aPeerGateway.test.ts @@ -4,7 +4,11 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { after, test } from "node:test"; -import type { SubagentInfo, UiCommand } from "@tangent/shared/contracts.ts"; +import { + capabilitiesForRole, + type SubagentInfo, + type UiCommand, +} from "@tangent/shared/contracts.ts"; // Point the session root at a throwaway dir before importing modules that read // config at load time, so a written artifact never touches the repo. @@ -147,6 +151,7 @@ function agentRow(overrides: Partial = {}): SessionAgent { sessionId: "s1", role: "subagent", name: "Weather", + capabilities: capabilitiesForRole(overrides.role ?? "subagent"), status: "detached", connector: { kind: "a2a", diff --git a/apps/server/src/connectors/a2aConnector.test.ts b/apps/server/src/connectors/a2aConnector.test.ts index 81e7474..005b22a 100644 --- a/apps/server/src/connectors/a2aConnector.test.ts +++ b/apps/server/src/connectors/a2aConnector.test.ts @@ -61,6 +61,7 @@ function agentRow(): SessionAgent { sessionId: "s1", role: "subagent", name: "Weather", + capabilities: [], status: "detached", connector: { kind: "a2a", diff --git a/apps/server/src/connectors/connectorRegistry.test.ts b/apps/server/src/connectors/connectorRegistry.test.ts index cc35485..23a7aad 100644 --- a/apps/server/src/connectors/connectorRegistry.test.ts +++ b/apps/server/src/connectors/connectorRegistry.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { + capabilitiesForRole, type ConnectorDescriptor, connectorFields, connectorFor, @@ -59,6 +60,7 @@ function agentRow( sessionId: "s1", role: "subagent", name: id, + capabilities: capabilitiesForRole(overrides.role ?? "subagent"), status: "detached", connector, createdAt: "2026-01-01T00:00:00.000Z", diff --git a/apps/server/src/conversation/membershipRegistry.ts b/apps/server/src/conversation/membershipRegistry.ts index c4dbf16..a806f3f 100644 --- a/apps/server/src/conversation/membershipRegistry.ts +++ b/apps/server/src/conversation/membershipRegistry.ts @@ -36,6 +36,18 @@ const ON_REQUEST = reactionSpec("mentionsMe"); /** A member that has declared it does not act — a display-only external tab. */ const INERT = reactionSpec("never"); +/** + * The id of the roster row holding the `orchestrator` capability, or the + * well-known default when a session has none resolved — the successor to + * treating `PRIME_AGENT_ID` as a reserved id. + */ +function orchestratorFrom(agents: SessionAgent[]): string { + const holder = agents.find((agent) => + agent.capabilities.includes("orchestrator"), + ); + return holder?.id ?? PRIME_AGENT_ID; +} + function membership( sessionId: string, participantId: string, @@ -93,8 +105,14 @@ export class MembershipRegistry { const agents = await this.sessions.listAgents(sessionId); const agent = agents.find((candidate) => candidate.id === conversationId); - const derived = this.derive(sessionId, conversationId, agent); - if (!agent && conversationId !== PRIME_AGENT_ID) return derived; + const orchestratorId = orchestratorFrom(agents); + const derived = this.derive( + sessionId, + conversationId, + agent, + orchestratorId, + ); + if (!agent && conversationId !== orchestratorId) return derived; byConversation.set(conversationId, derived); for (const row of derived) await this.store.put(row); @@ -140,10 +158,11 @@ export class MembershipRegistry { sessionId: string, conversationId: string, agent: SessionAgent | undefined, + orchestratorId: string, ): Membership[] { - if (conversationId === PRIME_AGENT_ID) { + if (conversationId === orchestratorId) { return [ - membership(sessionId, PRIME_AGENT_ID, PRIME_AGENT_ID, ADDRESSABLE), + membership(sessionId, orchestratorId, orchestratorId, ADDRESSABLE), ]; } @@ -152,7 +171,7 @@ export class MembershipRegistry { this.subject(sessionId, conversationId, agent), membership( sessionId, - PRIME_AGENT_ID, + orchestratorId, conversationId, relays ? ORCHESTRATOR : ON_REQUEST, ), diff --git a/apps/server/src/conversation/participantRegistry.test.ts b/apps/server/src/conversation/participantRegistry.test.ts new file mode 100644 index 0000000..9a7a3d3 --- /dev/null +++ b/apps/server/src/conversation/participantRegistry.test.ts @@ -0,0 +1,121 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { connectorFor } from "@tangent/shared/contracts.ts"; + +import { InMemoryParticipantStore } from "../store/inMemoryParticipantStore.ts"; +import { InMemorySessionStore } from "../store/inMemorySessionStore.ts"; +import type { Participant } from "../store/participantStore.ts"; +import { + orchestratorIdFor, + ParticipantRegistry, +} from "./participantRegistry.ts"; + +/** A registry over in-memory stores, roster empty until agents are recorded. */ +function makeRegistry() { + const sessions = new InMemorySessionStore(); + const store = new InMemoryParticipantStore(); + return { + sessions, + store, + registry: new ParticipantRegistry(sessions, store), + }; +} + +test("derives a participant per roster row, persisting the derived rows", async () => { + const { sessions, store, registry } = makeRegistry(); + await sessions.recordAgent("s1", { + id: "prime", + role: "prime", + name: "Prime", + }); + await sessions.recordAgent("s1", { + id: "sub-1", + role: "subagent", + name: "Worker", + }); + + const participants = await registry.listForSession("s1"); + const prime = participants.find((p) => p.id === "prime"); + const sub = participants.find((p) => p.id === "sub-1"); + + assert.equal(prime?.kind, "agent"); + assert.deepEqual(prime?.capabilities, ["orchestrator"]); + assert.deepEqual(sub?.capabilities, []); + // The derivation was persisted, so a later read has a stored row to find. + assert.equal((await store.get("s1", "prime"))?.displayName, "Prime"); +}); + +test("orchestratorId resolves the capability holder", async () => { + const { sessions, registry } = makeRegistry(); + await sessions.recordAgent("s1", { + id: "prime", + role: "prime", + name: "Prime", + }); + assert.equal(await registry.orchestratorId("s1"), "prime"); +}); + +test("orchestrator resolution falls back to the well-known id", async () => { + const { sessions, registry } = makeRegistry(); + assert.equal(await registry.orchestratorId("empty"), "prime"); + assert.equal(await orchestratorIdFor(sessions, "empty"), "prime"); +}); + +test("a stored non-agent participant is returned alongside derived agents", async () => { + const { sessions, store, registry } = makeRegistry(); + await sessions.recordAgent("s1", { + id: "prime", + role: "prime", + name: "Prime", + }); + const human: Participant = { + id: "ada@example.com", + sessionId: "s1", + kind: "human", + displayName: "Ada", + capabilities: [], + presence: "connected", + connector: connectorFor("unresolved"), + createdAt: "2026-01-01T00:00:00.000Z", + }; + await store.put(human); + + const ids = (await registry.listForSession("s1")).map((p) => p.id).sort(); + assert.deepEqual(ids, ["ada@example.com", "prime"]); +}); + +test("the current roster row wins over a stale stored participant", async () => { + const { sessions, store, registry } = makeRegistry(); + await store.put({ + id: "prime", + sessionId: "s1", + kind: "agent", + displayName: "Stale Name", + capabilities: ["orchestrator"], + presence: "connected", + connector: connectorFor("pi-stdio"), + createdAt: "2026-01-01T00:00:00.000Z", + }); + await sessions.recordAgent("s1", { + id: "prime", + role: "prime", + name: "Prime", + }); + + assert.equal((await registry.get("s1", "prime"))?.displayName, "Prime"); +}); + +test("recording an agent dual-writes the participant projection", async () => { + const store = new InMemoryParticipantStore(); + const sessions = new InMemorySessionStore(store); + await sessions.recordAgent("s1", { + id: "prime", + role: "prime", + name: "Prime", + }); + + const mirrored = await store.get("s1", "prime"); + assert.equal(mirrored?.displayName, "Prime"); + assert.deepEqual(mirrored?.capabilities, ["orchestrator"]); +}); diff --git a/apps/server/src/conversation/participantRegistry.ts b/apps/server/src/conversation/participantRegistry.ts new file mode 100644 index 0000000..a58e676 --- /dev/null +++ b/apps/server/src/conversation/participantRegistry.ts @@ -0,0 +1,96 @@ +import { PRIME_AGENT_ID } from "../pi/types.ts"; +import { + type Participant, + participantFromAgent, + type ParticipantStore, +} from "../store/participantStore.ts"; +import type { SessionStore } from "../store/sessionStore.ts"; + +/** + * The id of the Participant holding the `orchestrator` capability — the + * successor to the reserved `PRIME_AGENT_ID`. Resolves from the roster, the + * write authority for this PR, so it is the same fact everywhere. Falls back to + * `PRIME_AGENT_ID` when a session has no resolved orchestrator, so a caller + * addressing "Prime" still reaches the conversation it always did. + */ +export async function orchestratorIdFor( + sessions: Pick, + sessionId: string, +): Promise { + const agents = await sessions.listAgents(sessionId); + const holder = agents.find((agent) => + agent.capabilities.includes("orchestrator"), + ); + return holder?.id ?? PRIME_AGENT_ID; +} + +/** + * The session's Participants. Reads through to a {@link ParticipantStore}, but + * the roster (`session_agents`) stays the write authority for this PR: for every + * agent the current roster row wins, so an agent's participant view is never + * stale, and a row the migration backfill never materialized is persisted on + * first read — the same derive-and-persist shape + * {@link import("./membershipRegistry.ts").MembershipRegistry} uses. Stored + * participants with no roster row (future humans/automations) are returned as + * they stand. + */ +export class ParticipantRegistry { + private readonly sessions: SessionStore; + private readonly store: ParticipantStore; + /** sessionId -> participantId -> participant. */ + private readonly cache = new Map>(); + + constructor(sessions: SessionStore, store: ParticipantStore) { + this.sessions = sessions; + this.store = store; + } + + /** Every Participant in a session, roster rows reconciled and persisted. */ + async listForSession(sessionId: string): Promise { + const byId = await this.load(sessionId); + return [...byId.values()]; + } + + /** One Participant by id, or nothing when neither a row nor an agent exists. */ + async get(sessionId: string, id: string): Promise { + const byId = await this.load(sessionId); + return byId.get(id); + } + + /** + * The id of the Participant holding the `orchestrator` capability — the + * successor to the reserved `PRIME_AGENT_ID`. Falls back to that id when a + * session has no resolved orchestrator yet, so a caller addressing "Prime" + * still reaches the same conversation it always did. + */ + async orchestratorId(sessionId: string): Promise { + const participants = await this.listForSession(sessionId); + const holder = participants.find((participant) => + participant.capabilities.includes("orchestrator"), + ); + return holder?.id ?? PRIME_AGENT_ID; + } + + /** Loads a session's participants once, reconciling them against the roster. */ + private async load(sessionId: string): Promise> { + const cached = this.cache.get(sessionId); + if (cached) return cached; + + const byId = new Map(); + for (const stored of await this.store.listForSession(sessionId)) { + byId.set(stored.id, stored); + } + + for (const agent of await this.sessions.listAgents(sessionId)) { + const derived = participantFromAgent(agent); + const known = byId.get(agent.id); + byId.set(agent.id, derived); + // Persist a row the backfill never wrote; existing rows already hold the + // stable facts (id, kind, capabilities) this PR reads. + if (!known) await this.store.put(derived); + } + + this.cache.set(sessionId, byId); + return byId; + } +} diff --git a/apps/server/src/external/externalSubagentGateway.test.ts b/apps/server/src/external/externalSubagentGateway.test.ts index 43d8502..65fe0a0 100644 --- a/apps/server/src/external/externalSubagentGateway.test.ts +++ b/apps/server/src/external/externalSubagentGateway.test.ts @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import type { SubagentInfo } from "@tangent/shared/contracts.ts"; +import { + capabilitiesForRole, + type SubagentInfo, +} from "@tangent/shared/contracts.ts"; import { RelayRegistry } from "../mcp/relayRegistry.ts"; import type { ConversationEventSink } from "../pi/types.ts"; @@ -43,6 +46,7 @@ function agentRow(id: string, overrides: Partial = {}) { sessionId: "s1", role: "subagent", name: "worker", + capabilities: capabilitiesForRole(overrides.role ?? "subagent"), status: "detached", connector: { kind: "external-inbound", diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 44601c3..d2be64d 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -50,14 +50,20 @@ import { createUiCommandEmitter } from "./sockets/sessionRoster.ts"; import { openDb } from "./store/db/client.ts"; import { FileAgentBundleStore } from "./store/fileAgentBundleStore.ts"; import { SqliteMembershipStore } from "./store/sqliteMembershipStore.ts"; +import { SqliteParticipantStore } from "./store/sqliteParticipantStore.ts"; import { SqliteRunStore } from "./store/sqliteRunStore.ts"; import { SqliteSessionStore } from "./store/sqliteSessionStore.ts"; // Opening the DB applies pending drizzle-kit migrations on startup. The single -// shared connection backs both stores: session metadata for the REST routes and -// socket handlers, runs for the run registry. +// shared connection backs every store: session metadata for the REST routes and +// socket handlers, runs for the run registry, participants for the roster +// projection. const db = openDb(); -const store = new SqliteSessionStore(db); +// The participant projection each roster write mirrors into. `session_agents` +// stays the write authority for this PR; this keeps the `participants` table +// tracking it so Phase 2 consumers read a populated table. +const participants = new SqliteParticipantStore(db); +const store = new SqliteSessionStore(db, participants); // Filesystem-backed marketplace of saved agent bundles. const agentBundleStore = new FileAgentBundleStore(); @@ -99,7 +105,7 @@ const memberships = new MembershipRegistry( const conversations = new ConversationRouter(io, store, memberships); // Surfaces applied memory writes as a highlighted message in Prime's thread. -const onMemoryRemembered = createMemoryRememberedHandler(conversations); +const onMemoryRemembered = createMemoryRememberedHandler(conversations, store); // Shared event sink: a participant's streaming events, roster changes and posted // messages land the same way whether it runs locally (PiAgentManager), in a @@ -186,7 +192,7 @@ conversations.useConnectors(connectors); // Where a relay peer's words land: posted as the participant its channel belongs // to, or delivered to Prime when no participant owns the channel. -const relayReport = createRelayReport(connectors, conversations); +const relayReport = createRelayReport(connectors, conversations, store); // Drives schedule timers and callback firings, posting prompts into the target's // Conversation. diff --git a/apps/server/src/mcp/relayReport.test.ts b/apps/server/src/mcp/relayReport.test.ts index a205035..e5ea0bb 100644 --- a/apps/server/src/mcp/relayReport.test.ts +++ b/apps/server/src/mcp/relayReport.test.ts @@ -48,7 +48,7 @@ function makeReport(roster: SubagentInfo[]) { relay, sessions, delivered, - report: createRelayReport(connectors, conversations), + report: createRelayReport(connectors, conversations, sessions), }; } diff --git a/apps/server/src/mcp/relayReport.ts b/apps/server/src/mcp/relayReport.ts index 2760e69..37bf2c3 100644 --- a/apps/server/src/mcp/relayReport.ts +++ b/apps/server/src/mcp/relayReport.ts @@ -1,7 +1,8 @@ import type { ConnectorRegistry } from "../connectors/connectorRegistry.ts"; import { subagentAuthor } from "../connectors/participantAuthor.ts"; import type { ConversationRouter } from "../conversation/conversationRouter.ts"; -import { PRIME_AGENT_ID } from "../pi/types.ts"; +import { orchestratorIdFor } from "../conversation/participantRegistry.ts"; +import type { SessionStore } from "../store/sessionStore.ts"; import type { RelayChannel } from "./relayRegistry.ts"; /** Carries what a relay peer said back into the session it belongs to. */ @@ -26,16 +27,18 @@ export type RelayReport = ( export function createRelayReport( connectors: ConnectorRegistry, conversations: ConversationRouter, + store: SessionStore, ): RelayReport { return async (channel, text) => { + const orchestratorId = await orchestratorIdFor(store, channel.sessionId); const author = channel.participantId ? subagentAuthor(connectors, channel.sessionId, channel.participantId) : undefined; if (!author) { - connectors.resolve(channel.sessionId, PRIME_AGENT_ID).deliver({ + connectors.resolve(channel.sessionId, orchestratorId).deliver({ sessionId: channel.sessionId, - participantId: PRIME_AGENT_ID, + participantId: orchestratorId, text: `Remote agent (${channel.label}) reports:\n\n${text}`, }); return; @@ -46,7 +49,7 @@ export function createRelayReport( conversationId: author.id, author, content: text, - mentions: [PRIME_AGENT_ID], + mentions: [orchestratorId], ingress: "tool", }); }; diff --git a/apps/server/src/pi/extensions/orchestrator.ts b/apps/server/src/pi/extensions/orchestrator.ts index bb78390..8b36973 100644 --- a/apps/server/src/pi/extensions/orchestrator.ts +++ b/apps/server/src/pi/extensions/orchestrator.ts @@ -8,10 +8,12 @@ * and is never imported by the server itself — only passed as a path to the Pi * subprocess, which loads it with jiti. * - * Role is taken from `TANGENT_AGENT_ROLE`: - * - `prime`: gets tools to spawn / message / kill / list sub-agents, plus - * `read_room`. Prime is the only agent allowed to direct sub-agents. - * - `subagent`: gets only `read_room` so it can read the shared transcript. + * The tool grant is gated on the `orchestrator` capability, carried in + * `TANGENT_AGENT_CAPABILITIES` (comma-separated): + * - holds `orchestrator`: gets tools to spawn / message / kill / list + * sub-agents, plus `read_room`. The orchestrator directs sub-agents. + * - otherwise: gets only `read_room` plus `message_prime`, so it can read the + * shared transcript and report to the orchestrator. * * All tools are thin clients over this server's internal agent API; the server * owns process lifecycle and message routing. @@ -22,7 +24,11 @@ import { Type } from "typebox"; const SESSION_ID = process.env.TANGENT_SESSION_ID ?? ""; const AGENT_ID = process.env.TANGENT_AGENT_ID ?? ""; -const ROLE = process.env.TANGENT_AGENT_ROLE ?? "subagent"; +const CAPABILITIES = (process.env.TANGENT_AGENT_CAPABILITIES ?? "") + .split(",") + .map((capability) => capability.trim()) + .filter(Boolean); +const IS_ORCHESTRATOR = CAPABILITIES.includes("orchestrator"); const INTERNAL_URL = process.env.TANGENT_INTERNAL_URL ?? ""; const INTERNAL_TOKEN = process.env.TANGENT_INTERNAL_TOKEN ?? ""; @@ -93,7 +99,7 @@ export default function (pi: ExtensionAPI) { }, }); - if (ROLE !== "prime") { + if (!IS_ORCHESTRATOR) { // Sub-agent-only: push a directed update to Prime mid-run. Prime is // event-driven and only acts when prompted, so a sub-agent must report // each milestone directly rather than relying on Prime to poll the room. diff --git a/apps/server/src/pi/piAgentManager.test.ts b/apps/server/src/pi/piAgentManager.test.ts index 1bdee84..fc85028 100644 --- a/apps/server/src/pi/piAgentManager.test.ts +++ b/apps/server/src/pi/piAgentManager.test.ts @@ -6,7 +6,10 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, mock, test } from "node:test"; -import { connectorFor } from "@tangent/shared/contracts.ts"; +import { + capabilitiesForRole, + connectorFor, +} from "@tangent/shared/contracts.ts"; import { RunRegistry } from "../runs/runRegistry.ts"; import { InMemoryRunStore } from "../store/inMemoryRunStore.ts"; @@ -113,6 +116,7 @@ function agentRow(overrides: Partial): SessionAgent { sessionId: "s1", role: "subagent", name: "Worker", + capabilities: capabilitiesForRole(overrides.role ?? "subagent"), status: "active", autoRelayToPrime: true, connector: connectorFor("pi-stdio"), diff --git a/apps/server/src/pi/piAgentManager.ts b/apps/server/src/pi/piAgentManager.ts index e1d985d..a00b173 100644 --- a/apps/server/src/pi/piAgentManager.ts +++ b/apps/server/src/pi/piAgentManager.ts @@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto"; import { type AgentActivity, + capabilitiesForRole, type MessageDelivery, RESTORABLE_STATUSES, type RunIngress, @@ -108,6 +109,11 @@ interface SpawnExtras { * (e.g. the current user and the per-session memory), so every agent starts each * session aware of that standing context. Empty preambles are dropped. */ +/** The spawn env's capability list: the role's capabilities, comma-separated. */ +function capabilities(role: AgentDescriptor["role"]): string { + return capabilitiesForRole(role).join(","); +} + function appendPreambles(config: AgentConfig, preambles: string[]): string { const extras = preambles.map((preamble) => preamble.trim()).filter(Boolean); if (extras.length === 0) return config.appendSystemPrompt; @@ -948,6 +954,8 @@ export class PiAgentManager { TANGENT_SESSION_ID: sessionId, TANGENT_AGENT_ID: descriptor.agentId, TANGENT_AGENT_ROLE: descriptor.role, + // Gates the extension's orchestration-tool grant on capability, not role. + TANGENT_AGENT_CAPABILITIES: capabilities(descriptor.role), TANGENT_INTERNAL_URL: INTERNAL_URL, ...piCredential.spawnEnv(), }, diff --git a/apps/server/src/pi/triggers/triggerEngine.ts b/apps/server/src/pi/triggers/triggerEngine.ts index fa2e61f..a783f90 100644 --- a/apps/server/src/pi/triggers/triggerEngine.ts +++ b/apps/server/src/pi/triggers/triggerEngine.ts @@ -12,10 +12,11 @@ import { Cron } from "croner"; import type { Server } from "socket.io"; import type { ConversationRouter } from "../../conversation/conversationRouter.ts"; +import { orchestratorIdFor } from "../../conversation/participantRegistry.ts"; import { roomFor } from "../../sockets/rooms.ts"; import type { SessionStore } from "../../store/sessionStore.ts"; import type { SubagentSpawnRequest } from "../agentConfig.ts"; -import { type PiAgentManager, PRIME_AGENT_ID } from "../piAgentManager.ts"; +import type { PiAgentManager } from "../piAgentManager.ts"; import { resolveTriggerPrompt } from "./handlerRunner.ts"; import type { StoredTrigger, TriggerManager } from "./triggerManager.ts"; @@ -240,12 +241,13 @@ export class TriggerEngine { prompt: string, ): Promise { this.pi.ensure(sessionId, rootPath); + const orchestratorId = await orchestratorIdFor(this.store, sessionId); await this.conversations.post({ sessionId, - conversationId: PRIME_AGENT_ID, + conversationId: orchestratorId, author: triggerAuthor(stored), content: prompt, - mentions: [PRIME_AGENT_ID], + mentions: [orchestratorId], ingress: ingressFor(stored), }); } diff --git a/apps/server/src/remote/remoteEnvironmentGateway.ts b/apps/server/src/remote/remoteEnvironmentGateway.ts index c648d73..f8823b0 100644 --- a/apps/server/src/remote/remoteEnvironmentGateway.ts +++ b/apps/server/src/remote/remoteEnvironmentGateway.ts @@ -29,17 +29,14 @@ import { type ConnectorCredential, remoteEnvCredential, } from "../connectors/credentials.ts"; +import { orchestratorIdFor } from "../conversation/participantRegistry.ts"; import { parseThinkingLevel, resolveSubagentConfig, type SubagentSpawnRequest, } from "../pi/agentConfig.ts"; import type { SpawnedSubagent } from "../pi/piAgentManager.ts"; -import { - type AgentDescriptor, - type ConversationEventSink, - PRIME_AGENT_ID, -} from "../pi/types.ts"; +import type { AgentDescriptor, ConversationEventSink } from "../pi/types.ts"; import type { RunRegistry } from "../runs/runRegistry.ts"; import type { SessionAgent, SessionStore } from "../store/sessionStore.ts"; @@ -388,7 +385,8 @@ export class RemoteEnvironmentGateway { ); socket.on( RemoteEnvEvents.AgentMessage, - (payload: RemoteAgentMessagePayload) => this.handleAgentMessage(payload), + (payload: RemoteAgentMessagePayload) => + void this.handleAgentMessage(payload), ); socket.on( RemoteEnvEvents.RoomRead, @@ -480,7 +478,9 @@ export class RemoteEnvironmentGateway { * `message_prime` uses, so neither transport carries its own copy of "and now * tell Prime". */ - private handleAgentMessage(payload: RemoteAgentMessagePayload): void { + private async handleAgentMessage( + payload: RemoteAgentMessagePayload, + ): Promise { const subagent = this.sessions.get(payload.sessionId)?.get(payload.agentId); if (!subagent) return; this.markAttached(payload.sessionId, subagent); @@ -495,7 +495,7 @@ export class RemoteEnvironmentGateway { agentRole: "subagent", }, content: payload.text, - mentions: [PRIME_AGENT_ID], + mentions: [await orchestratorIdFor(this.store, payload.sessionId)], ingress: "tool", }); } diff --git a/apps/server/src/routes/internalAgents.ts b/apps/server/src/routes/internalAgents.ts index 45271b7..81bffc0 100644 --- a/apps/server/src/routes/internalAgents.ts +++ b/apps/server/src/routes/internalAgents.ts @@ -11,10 +11,10 @@ import type { ConnectorRegistry } from "../connectors/connectorRegistry.ts"; import { piCredential } from "../connectors/credentials.ts"; import { subagentAuthor } from "../connectors/participantAuthor.ts"; import type { ConversationRouter } from "../conversation/conversationRouter.ts"; +import { orchestratorIdFor } from "../conversation/participantRegistry.ts"; import { requireCredential } from "../middleware/requireCredential.ts"; import { getValidated, validate } from "../middleware/validate.ts"; import { parseThinkingLevel } from "../pi/agentConfig.ts"; -import { PRIME_AGENT_ID } from "../pi/types.ts"; import type { SessionStore } from "../store/sessionStore.ts"; /** Spawn a sub-agent; `sessionId` and `name` identify and label it. */ @@ -135,11 +135,16 @@ async function handleSpawn( res.json({ subagent: info }); // Answered first: the sub-agent exists either way, and a failure to post its // first task must not read as a failed spawn Prime might retry. - await postDirective(router, body.sessionId, info.id, body.task).catch( - (err: unknown) => { - console.error(`[agents] initial task for ${info.id} failed:`, err); - }, - ); + const orchestratorId = await orchestratorIdFor(store, body.sessionId); + await postDirective( + router, + body.sessionId, + info.id, + body.task, + orchestratorId, + ).catch((err: unknown) => { + console.error(`[agents] initial task for ${info.id} failed:`, err); + }); } catch (err) { res.status(400).json({ error: (err as Error).message }); } @@ -185,12 +190,13 @@ async function postDirective( sessionId: string, agentId: string, text: string | undefined, + fromConversation: string, ): Promise { if (!text?.trim()) return undefined; const { message, refused } = await router.postToConversation({ sessionId, conversationId: agentId, - fromConversation: PRIME_AGENT_ID, + fromConversation, author: PI_AGENT, content: text, mentions: [agentId], @@ -207,6 +213,7 @@ async function postDirective( * looks exactly like one that worked. */ async function handleMessage( + store: SessionStore, router: ConversationRouter, body: MessageInput, res: Response, @@ -216,6 +223,7 @@ async function handleMessage( body.sessionId, body.agentId, body.text, + await orchestratorIdFor(store, body.sessionId), ); res.json({ ok: !refused, ...(refused ? { error: refused } : {}) }); } @@ -226,6 +234,7 @@ async function handleMessage( * addressing it, not by a dedicated relay. */ async function handleReport( + store: SessionStore, connectors: ConnectorRegistry, router: ConversationRouter, body: ReportInput, @@ -243,7 +252,7 @@ async function handleReport( conversationId: agentId, author, content: text, - mentions: [PRIME_AGENT_ID], + mentions: [await orchestratorIdFor(store, sessionId)], ingress: "tool", }); res.json({ ok: true }); @@ -319,11 +328,17 @@ export function createInternalAgentsRouter( ); router.post("/message", validate({ body: messageSchema }), (req, res) => - handleMessage(conversations, getValidated(req).body, res), + handleMessage( + store, + conversations, + getValidated(req).body, + res, + ), ); router.post("/report", validate({ body: reportSchema }), (req, res) => handleReport( + store, connectors, conversations, getValidated(req).body, diff --git a/apps/server/src/routes/sessions/handlers.ts b/apps/server/src/routes/sessions/handlers.ts index a6148d0..1758e13 100644 --- a/apps/server/src/routes/sessions/handlers.ts +++ b/apps/server/src/routes/sessions/handlers.ts @@ -20,10 +20,10 @@ import { SESSIONS_ROOT, UPLOADS_DIRNAME, } from "../../config.ts"; +import { orchestratorIdFor } from "../../conversation/participantRegistry.ts"; import { installBundle } from "../../pi/config/bundleLoader.ts"; import type { PiAgentManager } from "../../pi/piAgentManager.ts"; import type { TriggerEngine } from "../../pi/triggers/triggerEngine.ts"; -import { PRIME_AGENT_ID } from "../../pi/types.ts"; import type { AgentBundleStore } from "../../store/agentBundleStore.ts"; import { readActivity } from "../../store/chatLog.ts"; import type { SessionStore } from "../../store/sessionStore.ts"; @@ -164,11 +164,12 @@ async function createSessionFromBundle( // (e.g. renders a welcome card). It replays via `chat:history` on join and // renders any `tangent-ui:*` card because the bundle id is already attached. if (config.welcomeMessage) { + const orchestratorId = await orchestratorIdFor(store, sessionId); await store.appendMessage({ id: randomUUID(), sessionId, - conversationId: PRIME_AGENT_ID, - seq: await store.nextSeq(sessionId, PRIME_AGENT_ID), + conversationId: orchestratorId, + seq: await store.nextSeq(sessionId, orchestratorId), author: PI_AGENT, mentions: [], source: sourceFromAuthor(PI_AGENT), @@ -278,7 +279,9 @@ async function activityFor( lastActivityAt, hasError: agents.some((agent) => agent.status === "error"), activeAgentCount: agents.filter( - (agent) => agent.role !== "prime" && agent.status === "active", + (agent) => + !agent.capabilities.includes("orchestrator") && + agent.status === "active", ).length, }; } diff --git a/apps/server/src/sockets/chat.ts b/apps/server/src/sockets/chat.ts index b9526ac..c62ab65 100644 --- a/apps/server/src/sockets/chat.ts +++ b/apps/server/src/sockets/chat.ts @@ -20,8 +20,9 @@ import type { Server, Socket } from "socket.io"; import { resolveUserIdentity } from "../auth/identity.ts"; import type { ConnectorRegistry } from "../connectors/connectorRegistry.ts"; import type { ConversationRouter } from "../conversation/conversationRouter.ts"; +import { orchestratorIdFor } from "../conversation/participantRegistry.ts"; import type { MemoryManager } from "../pi/memory.ts"; -import { type PiAgentManager, PRIME_AGENT_ID } from "../pi/piAgentManager.ts"; +import type { PiAgentManager } from "../pi/piAgentManager.ts"; import type { TriggerEngine } from "../pi/triggers/triggerEngine.ts"; import type { SessionStore } from "../store/sessionStore.ts"; import { @@ -113,7 +114,7 @@ function wireSocket(socket: Socket, deps: ChatHandlerDeps): void { ); socket.on(SocketEvents.MemoryDismiss, (payload: MemoryDismissPayload) => - handleMemoryDismiss(connectors, memory, payload), + handleMemoryDismiss(store, connectors, memory, payload), ); socket.on(SocketEvents.ArtifactPin, (payload: ArtifactPinPayload) => @@ -185,7 +186,7 @@ async function handleChatJoin( replayAgentActivities(socket, pi, session.id); // Surface Prime's current model/thinking (the roster only tracks sub-agents). - emitPrimeSelection(socket, pi, session.id); + await emitPrimeSelection(socket, pi, store, session.id); const triggerRoster: TriggerRosterPayload = { sessionId: session.id, @@ -246,10 +247,11 @@ async function handleChatMessage( return; } - // Target thread: Prime by default, or a specific sub-agent so users can steer - // it from its own tab. - const conversationId = payload.conversationId ?? PRIME_AGENT_ID; - if (conversationId === PRIME_AGENT_ID) + // Target thread: the orchestrator's by default, or a specific sub-agent so + // users can steer it from its own tab. + const orchestratorId = await orchestratorIdFor(store, session.id); + const conversationId = payload.conversationId ?? orchestratorId; + if (conversationId === orchestratorId) pi.ensure(session.id, session.rootPath); await conversations.post({ diff --git a/apps/server/src/sockets/chatMemory.ts b/apps/server/src/sockets/chatMemory.ts index b9f2c60..5285069 100644 --- a/apps/server/src/sockets/chatMemory.ts +++ b/apps/server/src/sockets/chatMemory.ts @@ -10,8 +10,8 @@ import type { Server } from "socket.io"; import type { ConnectorRegistry } from "../connectors/connectorRegistry.ts"; import type { ConversationRouter } from "../conversation/conversationRouter.ts"; +import { orchestratorIdFor } from "../conversation/participantRegistry.ts"; import type { MemoryManager } from "../pi/memory.ts"; -import { PRIME_AGENT_ID } from "../pi/types.ts"; import type { SessionStore } from "../store/sessionStore.ts"; import { roomFor } from "./rooms.ts"; @@ -29,11 +29,12 @@ export type MemoryRememberedHandler = ( /** Builds the {@link MemoryRememberedHandler} bound to the conversation router. */ export function createMemoryRememberedHandler( conversations: ConversationRouter, + store: SessionStore, ): MemoryRememberedHandler { return async (sessionId, scope, text) => { await conversations.post({ sessionId, - conversationId: PRIME_AGENT_ID, + conversationId: await orchestratorIdFor(store, sessionId), author: MEMORY_AUTHOR, content: text, memory: { scope }, @@ -84,9 +85,10 @@ export async function handleMemoryConfirm( suggestion.text, ); await onRemembered(suggestion.sessionId, result.scope, result.added); - connectors.resolve(suggestion.sessionId, PRIME_AGENT_ID).deliver({ + const orchestratorId = await orchestratorIdFor(store, suggestion.sessionId); + connectors.resolve(suggestion.sessionId, orchestratorId).deliver({ sessionId: suggestion.sessionId, - participantId: PRIME_AGENT_ID, + participantId: orchestratorId, text: `The user confirmed your suggestion. It has been stored to ${result.scope} ` + `memory: "${result.added}".`, @@ -94,16 +96,18 @@ export async function handleMemoryConfirm( } /** Tells Prime a suggestion was declined; nothing is written. */ -export function handleMemoryDismiss( +export async function handleMemoryDismiss( + store: SessionStore, connectors: ConnectorRegistry, memory: MemoryManager, payload: MemoryDismissPayload, -): void { +): Promise { const suggestion = memory.takeSuggestion(payload?.suggestionId); if (!suggestion || suggestion.sessionId !== payload.sessionId) return; - connectors.resolve(suggestion.sessionId, PRIME_AGENT_ID).deliver({ + const orchestratorId = await orchestratorIdFor(store, suggestion.sessionId); + connectors.resolve(suggestion.sessionId, orchestratorId).deliver({ sessionId: suggestion.sessionId, - participantId: PRIME_AGENT_ID, + participantId: orchestratorId, text: `The user declined to remember: "${suggestion.text}". Do not store it.`, }); } diff --git a/apps/server/src/sockets/sessionRoster.ts b/apps/server/src/sockets/sessionRoster.ts index e3b198b..3bb0eef 100644 --- a/apps/server/src/sockets/sessionRoster.ts +++ b/apps/server/src/sockets/sessionRoster.ts @@ -12,8 +12,9 @@ import { import type { Server, Socket } from "socket.io"; import type { ConnectorRegistry } from "../connectors/connectorRegistry.ts"; +import { orchestratorIdFor } from "../conversation/participantRegistry.ts"; import { parseThinkingLevel } from "../pi/agentConfig.ts"; -import { type PiAgentManager, PRIME_AGENT_ID } from "../pi/piAgentManager.ts"; +import type { PiAgentManager } from "../pi/piAgentManager.ts"; import type { SessionStore } from "../store/sessionStore.ts"; import { roomFor, SESSIONS_LOBBY } from "./rooms.ts"; @@ -85,13 +86,15 @@ function persistAndBroadcastSelection( io.to(roomFor(sessionId)).emit(SocketEvents.AgentModel, out); } -/** Reads Prime's persisted model/thinking selection, parsing the stored depth. */ +/** Reads the orchestrator's persisted model/thinking selection. */ async function loadPrimeOverride( store: SessionStore, sessionId: string, ): Promise<{ model?: string; thinkingDepth?: ThinkingLevel } | undefined> { const agents = await store.listAgents(sessionId); - const prime = agents.find((agent) => agent.id === PRIME_AGENT_ID); + const prime = agents.find((agent) => + agent.capabilities.includes("orchestrator"), + ); if (!prime) return undefined; return { model: prime.model, @@ -143,16 +146,18 @@ export function replayAgentActivities( } } -/** Emits Prime's current resolved model/thinking to the joining socket. */ -export function emitPrimeSelection( +/** Emits the orchestrator's current resolved model/thinking to the socket. */ +export async function emitPrimeSelection( socket: Socket, pi: PiAgentManager, + store: SessionStore, sessionId: string, -): void { - const selection = pi.getAgentSelection(sessionId, PRIME_AGENT_ID); +): Promise { + const orchestratorId = await orchestratorIdFor(store, sessionId); + const selection = pi.getAgentSelection(sessionId, orchestratorId); const payload: AgentModelPayload = { sessionId, - agentId: PRIME_AGENT_ID, + agentId: orchestratorId, model: selection?.model, thinkingDepth: selection?.thinkingDepth, }; diff --git a/apps/server/src/store/db/migrations/0011_small_magneto.sql b/apps/server/src/store/db/migrations/0011_small_magneto.sql new file mode 100644 index 0000000..ff2f742 --- /dev/null +++ b/apps/server/src/store/db/migrations/0011_small_magneto.sql @@ -0,0 +1,37 @@ +CREATE TABLE `participants` ( + `id` text NOT NULL, + `session_id` text NOT NULL, + `kind` text NOT NULL, + `display_name` text NOT NULL, + `capabilities` text DEFAULT '[]' NOT NULL, + `presence` text DEFAULT 'connected' NOT NULL, + `connector_kind` text, + `connector_lifecycle` text, + `connector_environment_id` text, + `connector_endpoint_url` text, + `agent_payload` text, + `created_at` text NOT NULL, + FOREIGN KEY (`session_id`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `participants_session_idx` ON `participants` (`session_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `participants_session_id` ON `participants` (`session_id`,`id`);--> statement-breakpoint +INSERT OR IGNORE INTO `participants` (`id`, `session_id`, `kind`, `display_name`, `capabilities`, `presence`, `connector_kind`, `connector_lifecycle`, `connector_environment_id`, `connector_endpoint_url`, `agent_payload`, `created_at`) +SELECT `id`, `session_id`, 'agent', `name`, + CASE WHEN `role` = 'prime' THEN '["orchestrator"]' ELSE '[]' END, + 'connected', + `connector_kind`, `connector_lifecycle`, `connector_environment_id`, `connector_endpoint_url`, + json_object( + 'role', `role`, + 'model', `model`, + 'thinkingDepth', `thinking_depth`, + 'template', `template`, + 'tools', CASE WHEN `tools` IS NULL THEN NULL ELSE json(`tools`) END, + 'systemPrompt', `system_prompt`, + 'autoRelayToPrime', CASE WHEN `auto_relay_to_prime` = 1 THEN json('true') ELSE json('false') END, + 'host', `host`, + 'purpose', `purpose`, + 'status', `status` + ), + `created_at` +FROM `session_agents`; \ No newline at end of file diff --git a/apps/server/src/store/db/migrations/meta/0011_snapshot.json b/apps/server/src/store/db/migrations/meta/0011_snapshot.json new file mode 100644 index 0000000..04a08ab --- /dev/null +++ b/apps/server/src/store/db/migrations/meta/0011_snapshot.json @@ -0,0 +1,740 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "01371193-31c7-4470-a5ec-c01cae9e9ab1", + "prevId": "9b69169b-20a1-4c13-bceb-a24dd723c10f", + "tables": { + "conversations": { + "name": "conversations", + "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 + }, + "next_seq": { + "name": "next_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "conversations_session_idx": { + "name": "conversations_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "conversations_session_id": { + "name": "conversations_session_id", + "columns": ["session_id", "id"], + "isUnique": true + } + }, + "foreignKeys": { + "conversations_session_id_sessions_id_fk": { + "name": "conversations_session_id_sessions_id_fk", + "tableFrom": "conversations", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "memberships": { + "name": "memberships", + "columns": { + "participant_id": { + "name": "participant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reaction": { + "name": "reaction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'never'" + }, + "ingress": { + "name": "ingress", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'reaction'" + }, + "transcript_visibility": { + "name": "transcript_visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'shared'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "memberships_session_conversation_idx": { + "name": "memberships_session_conversation_idx", + "columns": ["session_id", "conversation_id"], + "isUnique": false + }, + "memberships_session_conversation_participant": { + "name": "memberships_session_conversation_participant", + "columns": ["session_id", "conversation_id", "participant_id"], + "isUnique": true + } + }, + "foreignKeys": { + "memberships_session_id_sessions_id_fk": { + "name": "memberships_session_id_sessions_id_fk", + "tableFrom": "memberships", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "participants": { + "name": "participants", + "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 + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "presence": { + "name": "presence", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'connected'" + }, + "connector_kind": { + "name": "connector_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_lifecycle": { + "name": "connector_lifecycle", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_environment_id": { + "name": "connector_environment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_endpoint_url": { + "name": "connector_endpoint_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_payload": { + "name": "agent_payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "participants_session_idx": { + "name": "participants_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "participants_session_id": { + "name": "participants_session_id", + "columns": ["session_id", "id"], + "isUnique": true + } + }, + "foreignKeys": { + "participants_session_id_sessions_id_fk": { + "name": "participants_session_id_sessions_id_fk", + "tableFrom": "participants", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "runs": { + "name": "runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "participant_id": { + "name": "participant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "home_conversation_id": { + "name": "home_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "ingress": { + "name": "ingress", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": 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 + }, + "ended_at": { + "name": "ended_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "runs_session_idx": { + "name": "runs_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "runs_session_participant_idx": { + "name": "runs_session_participant_idx", + "columns": ["session_id", "participant_id"], + "isUnique": false + } + }, + "foreignKeys": { + "runs_session_id_sessions_id_fk": { + "name": "runs_session_id_sessions_id_fk", + "tableFrom": "runs", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "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'" + }, + "connector_kind": { + "name": "connector_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_lifecycle": { + "name": "connector_lifecycle", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_environment_id": { + "name": "connector_environment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_endpoint_url": { + "name": "connector_endpoint_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "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 2cd97e5..5474827 100644 --- a/apps/server/src/store/db/migrations/meta/_journal.json +++ b/apps/server/src/store/db/migrations/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1786568496433, "tag": "0010_thick_chat", "breakpoints": true + }, + { + "idx": 11, + "version": "6", + "when": 1786577218176, + "tag": "0011_small_magneto", + "breakpoints": true } ] } diff --git a/apps/server/src/store/db/schema.ts b/apps/server/src/store/db/schema.ts index dd44f1c..7055c86 100644 --- a/apps/server/src/store/db/schema.ts +++ b/apps/server/src/store/db/schema.ts @@ -241,6 +241,53 @@ export const memberships = sqliteTable( ], ); +/** + * A session-scoped actor identity: the unification of today's `ChatAuthor` and + * `SubagentInfo`. `kind` describes role only (`human` | `agent` | `automation`); + * authority rides on `capabilities` (a JSON array — Prime holds `orchestrator`), + * not on a reserved id. The connector facets that used to live on + * `session_agents` move here; the agent-only columns (`role`, `model`, + * `template`, `tools`, `system_prompt`, `auto_relay_to_prime`, `host`, + * `purpose`, `status`) become a per-kind `agent_payload` blob rather than + * participant-shaped columns. + * + * `session_agents` stays the write authority for this PR; these rows are + * backfilled from it and kept in sync on record, and derived read-through for a + * session the backfill never touched. A later cleanup drops `session_agents`. + */ +export const participants = sqliteTable( + "participants", + { + /** Participant id: `prime`, or a sub-agent uuid. Same string as today. */ + id: text("id").notNull(), + sessionId: text("session_id") + .notNull() + .references(() => sessions.id, { onDelete: "cascade" }), + /** `human` | `agent` | `automation`. */ + kind: text("kind").notNull(), + displayName: text("display_name").notNull(), + /** JSON-encoded `Capability[]` (e.g. `["orchestrator"]`); `[]` for none. */ + capabilities: text("capabilities").notNull().default("[]"), + /** `connected` | `away` | `detached`. A default until presence lifecycle. */ + presence: text("presence").notNull().default("connected"), + connectorKind: text("connector_kind"), + connectorLifecycle: text("connector_lifecycle"), + connectorEnvironmentId: text("connector_environment_id"), + connectorEndpointUrl: text("connector_endpoint_url"), + /** + * JSON-encoded kind-specific payload. For an agent: `role`, `model`, + * `thinkingDepth`, `template`, `tools`, `systemPrompt`, `autoRelayToPrime`, + * `host`, `purpose`, `status` — everything that was an agent-only column. + */ + agentPayload: text("agent_payload"), + createdAt: text("created_at").notNull(), + }, + (table) => [ + unique("participants_session_id").on(table.sessionId, table.id), + index("participants_session_idx").on(table.sessionId), + ], +); + /** When each user last opened a session. `user_key` is the email, or `local`. */ export const sessionViews = sqliteTable( "session_views", @@ -263,3 +310,4 @@ export type SessionAgentRow = typeof sessionAgents.$inferSelect; export type RunRow = typeof runs.$inferSelect; export type ConversationRow = typeof conversations.$inferSelect; export type MembershipRow = typeof memberships.$inferSelect; +export type ParticipantRow = typeof participants.$inferSelect; diff --git a/apps/server/src/store/inMemoryParticipantStore.ts b/apps/server/src/store/inMemoryParticipantStore.ts new file mode 100644 index 0000000..0afdee0 --- /dev/null +++ b/apps/server/src/store/inMemoryParticipantStore.ts @@ -0,0 +1,32 @@ +import type { Participant, ParticipantStore } from "./participantStore.ts"; + +/** Key of one participant, matching the table's uniqueness. */ +function keyFor(sessionId: string, id: string): string { + return `${sessionId}\u0000${id}`; +} + +/** + * Process-local {@link ParticipantStore}, mirroring + * {@link import("./inMemoryMembershipStore.ts").InMemoryMembershipStore}. For + * tests and for wiring a participant registry that has no DB to write to. + */ +export class InMemoryParticipantStore implements ParticipantStore { + private readonly participants = new Map(); + + async listForSession(sessionId: string): Promise { + return [...this.participants.values()] + .filter((participant) => participant.sessionId === sessionId) + .sort((a, b) => a.createdAt.localeCompare(b.createdAt)); + } + + async get(sessionId: string, id: string): Promise { + return this.participants.get(keyFor(sessionId, id)); + } + + async put(participant: Participant): Promise { + this.participants.set( + keyFor(participant.sessionId, participant.id), + participant, + ); + } +} diff --git a/apps/server/src/store/inMemorySessionStore.ts b/apps/server/src/store/inMemorySessionStore.ts index db41c6c..b637c95 100644 --- a/apps/server/src/store/inMemorySessionStore.ts +++ b/apps/server/src/store/inMemorySessionStore.ts @@ -2,16 +2,21 @@ import { randomUUID } from "node:crypto"; import { mkdir } from "node:fs/promises"; import path from "node:path"; -import type { - ChatMessage, - ConnectorDescriptor, - PinnedArtifact, - Session, - SessionConfigMeta, - UpdateSessionRequest, +import { + capabilitiesForRole, + type ChatMessage, + type ConnectorDescriptor, + type PinnedArtifact, + type Session, + type SessionConfigMeta, + type UpdateSessionRequest, } from "@tangent/shared/contracts.ts"; import { ARTIFACTS_DIRNAME, SESSIONS_ROOT } from "../config.ts"; +import { + participantFromAgent, + type ParticipantStore, +} from "./participantStore.ts"; import { connectorFromHost, type CreateSessionParams, @@ -62,6 +67,7 @@ function mergeAgent( sessionId, role: agent.role, name: agent.name, + capabilities: capabilitiesForRole(agent.role), status: agent.status ?? prior?.status ?? "active", connector: mergeConnector(agent, prior), createdAt: prior?.createdAt ?? new Date().toISOString(), @@ -76,6 +82,12 @@ export class InMemorySessionStore implements SessionStore { private readonly views = new Map>(); /** Per-conversation `seq` counters, keyed `sessionId/conversationId`. */ private readonly seqs = new Map(); + /** Mirrors each recorded roster row, matching the SQLite store's dual-write. */ + private readonly participants?: ParticipantStore; + + constructor(participants?: ParticipantStore) { + this.participants = participants; + } async listSessions(): Promise { return [...this.sessions.values()].sort((a, b) => @@ -236,6 +248,7 @@ export class InMemorySessionStore implements SessionStore { ? existing.map((a) => (a.id === agent.id ? next : a)) : [...existing, next]; this.agents.set(sessionId, updated); + await this.participants?.put(participantFromAgent(next)); return next; } diff --git a/apps/server/src/store/participantStore.ts b/apps/server/src/store/participantStore.ts new file mode 100644 index 0000000..935d221 --- /dev/null +++ b/apps/server/src/store/participantStore.ts @@ -0,0 +1,100 @@ +import type { + AgentRole, + Capability, + ConnectorDescriptor, + ParticipantKind, + Presence, + SubagentHost, + SubagentStatus, +} from "@tangent/shared/contracts.ts"; + +import type { SessionAgent } from "./sessionStore.ts"; + +/** + * The agent-only facts that used to be columns on `session_agents` and are now a + * per-kind payload on a `Participant` — the shape that stops the roster table + * being agent-shaped. Present only when `kind` is `"agent"`. + */ +export interface AgentPayload { + role: AgentRole; + model?: string; + thinkingDepth?: string; + template?: string; + tools?: string[]; + systemPrompt?: string; + autoRelayToPrime?: boolean; + host?: SubagentHost; + purpose?: string; + status: SubagentStatus; +} + +/** + * A session-scoped actor identity: the unification of today's `ChatAuthor` and + * `SubagentInfo`. `kind` describes role only; authority rides on `capabilities` + * (Prime holds `orchestrator`), not on a reserved id. `connector` is the same + * descriptor a roster row carries; `agent` holds the agent-only payload. + */ +export interface Participant { + id: string; + sessionId: string; + kind: ParticipantKind; + displayName: string; + capabilities: Capability[]; + presence: Presence; + connector: ConnectorDescriptor; + agent?: AgentPayload; + createdAt: string; +} + +/** Folds a roster row's agent-only facts into an {@link AgentPayload}. */ +function agentPayload(agent: SessionAgent): AgentPayload { + return { + role: agent.role, + model: agent.model, + thinkingDepth: agent.thinkingDepth, + template: agent.template, + tools: agent.tools, + systemPrompt: agent.systemPrompt, + autoRelayToPrime: agent.autoRelayToPrime, + host: agent.host, + purpose: agent.purpose, + status: agent.status, + }; +} + +/** + * Builds the {@link Participant} a roster row stands for. `session_agents` is + * the write authority for this PR, so this is the one place a roster row becomes + * a participant — used both by the backfill's read-through derivation and by the + * dual-write on {@link import("./sessionStore.ts").SessionStore.recordAgent}. + */ +export function participantFromAgent(agent: SessionAgent): Participant { + return { + id: agent.id, + sessionId: agent.sessionId, + kind: "agent", + displayName: agent.name, + capabilities: agent.capabilities, + presence: "connected", + connector: agent.connector, + agent: agentPayload(agent), + createdAt: agent.createdAt, + }; +} + +/** + * Durable home of the session's {@link Participant}s. Kept apart from + * {@link import("./sessionStore.ts").SessionStore} for the same reason + * {@link import("./membershipStore.ts").MembershipStore} is: it is read by one + * registry, not by the REST routes. `session_agents` stays the write authority + * for this PR, so these rows are backfilled from it and kept in sync on record; + * a session the backfill never touched is derived read-through. + */ +export interface ParticipantStore { + /** Every participant in a session, oldest first. */ + listForSession(sessionId: string): Promise; + /** One participant by id, or nothing when the row does not exist yet. */ + get(sessionId: string, id: string): Promise; + /** Upserts by `(sessionId, id)`. */ + put(participant: Participant): Promise; +} diff --git a/apps/server/src/store/sessionStore.ts b/apps/server/src/store/sessionStore.ts index b63ab5a..8010f81 100644 --- a/apps/server/src/store/sessionStore.ts +++ b/apps/server/src/store/sessionStore.ts @@ -1,5 +1,6 @@ import { type AgentRole, + type Capability, type ChatMessage, type ConnectorDescriptor, connectorFor, @@ -61,6 +62,12 @@ export interface SessionAgent { sessionId: string; role: AgentRole; name: string; + /** + * The capabilities this participant holds, derived from `role`. Prime carries + * `orchestrator`; a sub-agent carries none. Authority reads this rather than + * comparing the id to a reserved constant. + */ + capabilities: Capability[]; /** The agent's task/description, when known. */ purpose?: string; status: SessionAgentStatus; diff --git a/apps/server/src/store/sqliteParticipantStore.test.ts b/apps/server/src/store/sqliteParticipantStore.test.ts new file mode 100644 index 0000000..2780ee6 --- /dev/null +++ b/apps/server/src/store/sqliteParticipantStore.test.ts @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { after, test } from "node:test"; + +// Point the session root at a throwaway dir before importing modules that read +// config at load time, so `createSession`'s mkdir never touches the repo. +const ROOT = mkdtempSync(path.join(tmpdir(), "participant-store-")); +process.env.SESSIONS_ROOT = ROOT; + +const { connectorFor } = await import("@tangent/shared/contracts.ts"); +const { openDb } = await import("./db/client.ts"); +const { SqliteParticipantStore } = await import("./sqliteParticipantStore.ts"); +const { SqliteSessionStore } = await import("./sqliteSessionStore.ts"); + +type Participant = import("./participantStore.ts").Participant; + +after(() => rmSync(ROOT, { recursive: true, force: true })); + +/** A migrated in-memory DB with one real session for the FK to point at. */ +async function withSession() { + const db = openDb(":memory:"); + const session = await new SqliteSessionStore(db).createSession({ name: "S" }); + return { store: new SqliteParticipantStore(db), sessionId: session.id }; +} + +function participant( + sessionId: string, + overrides: Partial = {}, +): Participant { + return { + id: "p-1", + sessionId, + kind: "agent", + displayName: "Worker", + capabilities: [], + presence: "connected", + connector: connectorFor("pi-stdio"), + createdAt: "2026-01-01T00:00:00.000Z", + ...overrides, + }; +} + +test("put then get round-trips capabilities, connector, and agent payload", async () => { + const { store, sessionId } = await withSession(); + const row = participant(sessionId, { + id: "prime", + displayName: "Prime", + capabilities: ["orchestrator"], + connector: connectorFor("remote-env", "env-1"), + agent: { role: "prime", host: "remote", status: "active", tools: ["a"] }, + }); + + await store.put(row); + const got = await store.get(sessionId, "prime"); + assert.ok(got); + assert.ok(got.agent); + + assert.deepEqual(got.capabilities, ["orchestrator"]); + assert.equal(got.connector.kind, "remote-env"); + assert.equal(got.connector.environmentId, "env-1"); + assert.equal(got.agent.role, "prime"); + assert.deepEqual(got.agent.tools, ["a"]); +}); + +test("put upserts by (session, id) rather than duplicating", async () => { + const { store, sessionId } = await withSession(); + await store.put(participant(sessionId, { displayName: "First" })); + await store.put(participant(sessionId, { displayName: "Second" })); + + const all = await store.listForSession(sessionId); + assert.equal(all.length, 1); + assert.equal(all[0].displayName, "Second"); +}); + +test("listForSession returns a session's rows oldest first", async () => { + const { store, sessionId } = await withSession(); + await store.put( + participant(sessionId, { id: "b", createdAt: "2026-01-02T00:00:00.000Z" }), + ); + await store.put( + participant(sessionId, { id: "a", createdAt: "2026-01-01T00:00:00.000Z" }), + ); + + const ids = (await store.listForSession(sessionId)).map((p) => p.id); + assert.deepEqual(ids, ["a", "b"]); +}); + +test("get returns nothing for a participant that was never written", async () => { + const { store, sessionId } = await withSession(); + assert.equal(await store.get(sessionId, "missing"), undefined); +}); diff --git a/apps/server/src/store/sqliteParticipantStore.ts b/apps/server/src/store/sqliteParticipantStore.ts new file mode 100644 index 0000000..c4d274c --- /dev/null +++ b/apps/server/src/store/sqliteParticipantStore.ts @@ -0,0 +1,170 @@ +import { + type Capability, + type ConnectorDescriptor, + connectorFor, + type ConnectorKind, + type ConnectorLifecycle, + type ParticipantKind, + type Presence, + type SubagentHost, +} from "@tangent/shared/contracts.ts"; +import { and, asc, eq } from "drizzle-orm"; + +import type { Db } from "./db/client.ts"; +import { type ParticipantRow, participants } from "./db/schema.ts"; +import type { + AgentPayload, + Participant, + ParticipantStore, +} from "./participantStore.ts"; +import { connectorFromHost } from "./sessionStore.ts"; + +/** Coerces a stored JSON value to `undefined` when it is absent or SQL null. */ +function orUndefined(value: T | null | undefined): T | undefined { + return value == null ? undefined : value; +} + +/** Parses the JSON `capabilities` array, tolerating a malformed value. */ +function parseCapabilities(raw: string): Capability[] { + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as Capability[]) : []; + } catch { + return []; + } +} + +/** Parses the JSON `agent_payload` blob into an {@link AgentPayload}. */ +function parseAgentPayload(raw: string | null): AgentPayload | undefined { + if (!raw) return undefined; + try { + const p = JSON.parse(raw) as Record; + return { + role: (p.role as AgentPayload["role"]) ?? "subagent", + model: orUndefined(p.model as string | null), + thinkingDepth: orUndefined(p.thinkingDepth as string | null), + template: orUndefined(p.template as string | null), + tools: Array.isArray(p.tools) ? (p.tools as string[]) : undefined, + systemPrompt: orUndefined(p.systemPrompt as string | null), + autoRelayToPrime: orUndefined(p.autoRelayToPrime as boolean | null), + host: orUndefined(p.host as SubagentHost | null), + purpose: orUndefined(p.purpose as string | null), + status: (p.status as AgentPayload["status"]) ?? "active", + }; + } catch { + return undefined; + } +} + +/** + * Reads a participant row's connector facets, falling back to the legacy `host` + * label (kept in `agent_payload`) for rows backfilled before the connector + * columns were ever set — the same read-time fallback the roster store uses. + */ +function toConnector( + row: ParticipantRow, + agent: AgentPayload | undefined, +): ConnectorDescriptor { + if (!row.connectorKind) return connectorFromHost(agent?.host); + return { + ...connectorFor(row.connectorKind as ConnectorKind), + ...(row.connectorLifecycle + ? { lifecycle: row.connectorLifecycle as ConnectorLifecycle } + : {}), + ...(row.connectorEnvironmentId + ? { environmentId: row.connectorEnvironmentId } + : {}), + ...(row.connectorEndpointUrl + ? { endpointUrl: row.connectorEndpointUrl } + : {}), + }; +} + +/** Maps a participants row onto the domain {@link Participant}. */ +function toParticipant(row: ParticipantRow): Participant { + const agent = parseAgentPayload(row.agentPayload); + return { + id: row.id, + sessionId: row.sessionId, + kind: row.kind as ParticipantKind, + displayName: row.displayName, + capabilities: parseCapabilities(row.capabilities), + presence: row.presence as Presence, + connector: toConnector(row, agent), + agent, + createdAt: row.createdAt, + }; +} + +/** The connector columns a participant write persists. */ +function connectorColumns(connector: ConnectorDescriptor) { + return { + connectorKind: connector.kind, + connectorLifecycle: connector.lifecycle, + connectorEnvironmentId: connector.environmentId, + connectorEndpointUrl: connector.endpointUrl, + }; +} + +/** SQLite-backed {@link ParticipantStore} over the shared session metadata DB. */ +export class SqliteParticipantStore implements ParticipantStore { + private readonly db: Db; + + constructor(db: Db) { + this.db = db; + } + + async listForSession(sessionId: string): Promise { + const rows = this.db + .select() + .from(participants) + .where(eq(participants.sessionId, sessionId)) + .orderBy(asc(participants.createdAt)) + .all(); + return rows.map(toParticipant); + } + + async get(sessionId: string, id: string): Promise { + const row = this.db + .select() + .from(participants) + .where( + and(eq(participants.sessionId, sessionId), eq(participants.id, id)), + ) + .get(); + return row ? toParticipant(row) : undefined; + } + + async put(participant: Participant): Promise { + const capabilities = JSON.stringify(participant.capabilities); + const agentPayload = participant.agent + ? JSON.stringify(participant.agent) + : null; + const columns = connectorColumns(participant.connector); + this.db + .insert(participants) + .values({ + id: participant.id, + sessionId: participant.sessionId, + kind: participant.kind, + displayName: participant.displayName, + capabilities, + presence: participant.presence, + ...columns, + agentPayload, + createdAt: participant.createdAt, + }) + .onConflictDoUpdate({ + target: [participants.sessionId, participants.id], + set: { + kind: participant.kind, + displayName: participant.displayName, + capabilities, + presence: participant.presence, + ...columns, + agentPayload, + }, + }) + .run(); + } +} diff --git a/apps/server/src/store/sqliteSessionStore.test.ts b/apps/server/src/store/sqliteSessionStore.test.ts index 8e192ee..b72e6a1 100644 --- a/apps/server/src/store/sqliteSessionStore.test.ts +++ b/apps/server/src/store/sqliteSessionStore.test.ts @@ -1,15 +1,25 @@ import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { after, test } from "node:test"; +import { fileURLToPath } from "node:url"; // Point the session root at a throwaway dir before importing modules that read // config at load time, so `createSession`'s mkdir never touches the repo. const ROOT = mkdtempSync(path.join(tmpdir(), "session-store-")); process.env.SESSIONS_ROOT = ROOT; +const { connectorFor } = await import("@tangent/shared/contracts.ts"); +const { sql } = await import("drizzle-orm"); const { openDb } = await import("./db/client.ts"); +const { SqliteParticipantStore } = await import("./sqliteParticipantStore.ts"); const { SqliteSessionStore } = await import("./sqliteSessionStore.ts"); after(() => rmSync(ROOT, { recursive: true, force: true })); @@ -19,6 +29,19 @@ function newStore() { return new SqliteSessionStore(openDb(":memory:")); } +/** The backfill statement drizzle-kit's 0011 migration appended by hand. */ +function backfillStatement(): string { + const file = fileURLToPath( + new URL("./db/migrations/0011_small_magneto.sql", import.meta.url), + ); + const statement = readFileSync(file, "utf8") + .split("--> statement-breakpoint") + .map((chunk) => chunk.trim()) + .find((chunk) => chunk.startsWith("INSERT OR IGNORE INTO `participants`")); + assert.ok(statement, "0011 carries a participants backfill statement"); + return statement; +} + test("getLastViewedMap is empty before anything is viewed", async () => { const store = newStore(); const session = await store.createSession({ name: "S" }); @@ -299,3 +322,64 @@ test("deleting a session cascades its read state", async () => { await store.deleteSession(session.id); assert.equal((await store.getLastViewedMap("a@x")).size, 0); }); + +test("recordAgent dual-writes the participant projection", async () => { + const db = openDb(":memory:"); + const participants = new SqliteParticipantStore(db); + const store = new SqliteSessionStore(db, participants); + + // createSession seeds the Prime roster row, which mirrors as an orchestrator. + const session = await store.createSession({ name: "S" }); + const prime = await participants.get(session.id, "prime"); + assert.equal(prime?.kind, "agent"); + assert.deepEqual(prime?.capabilities, ["orchestrator"]); + + await store.recordAgent(session.id, { + id: "sub-1", + role: "subagent", + name: "Worker", + host: "remote", + connector: connectorFor("remote-env", "env-1"), + }); + const sub = await participants.get(session.id, "sub-1"); + assert.deepEqual(sub?.capabilities, []); + assert.equal(sub?.connector.environmentId, "env-1"); +}); + +test("the 0011 backfill materializes participants and leaves seq seeding alone", async () => { + const db = openDb(":memory:"); + // A store with no participant projection, so nothing is dual-written: this is + // exactly the pre-0011 shape the migration has to backfill. + const store = new SqliteSessionStore(db); + const session = await store.createSession({ name: "S" }); + await store.recordAgent(session.id, { + id: "sub-1", + role: "subagent", + name: "Worker", + }); + + const participants = new SqliteParticipantStore(db); + assert.equal((await participants.listForSession(session.id)).length, 0); + const countConversations = () => + (db.get(sql`select count(*) as n from conversations`) as { n: number }).n; + const conversationsBefore = countConversations(); + + const statement = backfillStatement(); + db.run(sql.raw(statement)); + + const backfilled = await participants.listForSession(session.id); + assert.deepEqual( + backfilled.map((p) => [p.id, p.capabilities]).sort(), + [ + ["prime", ["orchestrator"]], + ["sub-1", []], + ].sort(), + ); + + // Idempotent: the `INSERT OR IGNORE` re-run adds nothing. + db.run(sql.raw(statement)); + assert.equal((await participants.listForSession(session.id)).length, 2); + + // The promotion never invents `next_seq` rows — seeding stays a read concern. + assert.equal(countConversations(), conversationsBefore); +}); diff --git a/apps/server/src/store/sqliteSessionStore.ts b/apps/server/src/store/sqliteSessionStore.ts index f0c363e..981ddbf 100644 --- a/apps/server/src/store/sqliteSessionStore.ts +++ b/apps/server/src/store/sqliteSessionStore.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { type AgentRole, + capabilitiesForRole, type ChatMessage, type ConnectorDescriptor, connectorFor, @@ -34,6 +35,10 @@ import { sessions, sessionViews, } from "./db/schema.ts"; +import { + participantFromAgent, + type ParticipantStore, +} from "./participantStore.ts"; import { connectorFromHost, type CreateSessionParams, @@ -102,6 +107,7 @@ function toAgent(row: SessionAgentRow): SessionAgent { sessionId: row.sessionId, role: row.role as AgentRole, name: row.name, + capabilities: capabilitiesForRole(row.role as AgentRole), purpose: row.purpose ?? undefined, status: row.status as SessionAgentStatus, model: row.model ?? undefined, @@ -137,9 +143,16 @@ export class SqliteSessionStore implements SessionStore { /** Caches sessionId -> rootPath so `appendMessage` avoids a DB read per line. */ private readonly rootPaths = new Map(); private readonly db: Db; + /** + * The participant projection this store dual-writes on every `recordAgent`, so + * a `participants` row tracks each roster row while `session_agents` stays the + * write authority. Optional so a bare store (e.g. a test) skips the mirror. + */ + private readonly participants?: ParticipantStore; - constructor(db: Db) { + constructor(db: Db, participants?: ParticipantStore) { this.db = db; + this.participants = participants; } async listSessions(): Promise { @@ -449,7 +462,10 @@ export class SqliteSessionStore implements SessionStore { ) .get(); // The row was just upserted, so it always exists here. - return toAgent(row as SessionAgentRow); + const recorded = toAgent(row as SessionAgentRow); + // Mirror it into the participant projection; the roster stays authoritative. + await this.participants?.put(participantFromAgent(recorded)); + return recorded; } async setAgentStatus( diff --git a/packages/shared/src/contracts.ts b/packages/shared/src/contracts.ts index ac8e8a4..553af16 100644 --- a/packages/shared/src/contracts.ts +++ b/packages/shared/src/contracts.ts @@ -683,6 +683,40 @@ export type ReactionSpec = string; */ export type TranscriptVisibility = "shared" | "summarized" | "opaque"; +/** + * The kind of a {@link Participant}: a person (`human`), a coding agent + * (`agent`), or an ingress-driven actor with no interactive presence + * (`automation`). Kinds describe role only and never placement — a + * heterogeneous agent reached over A2A is an `agent`, not a distinct kind. + * Authority rides on {@link Capability}, not on kind. + */ +export type ParticipantKind = "human" | "agent" | "automation"; + +/** + * An ability a Participant holds independently of its kind. `orchestrator` is + * the one Prime carries today: it grants the spawn/message/list tools and marks + * the at-most-one Participant a Session directs its sub-agents through. + * Designation is by capability, not by a reserved id. + */ +export type Capability = "orchestrator"; + +/** + * Whether a Participant is reachable right now: `connected`, `away`, or + * `detached` — the same "the far end is gone" state 1.4 gave attached + * connectors. A stored default until presence lifecycle (2.2) makes it live. + */ +export type Presence = "connected" | "away" | "detached"; + +/** + * The capabilities an agent's `role` carries. Prime holds `orchestrator` — the + * successor to `PRIME_AGENT_ID` being a reserved id, so authority reads a + * capability rather than comparing an id to a constant — and a sub-agent holds + * none. A Session designates at most one orchestrator by convention. + */ +export function capabilitiesForRole(role: AgentRole): Capability[] { + return role === "prime" ? ["orchestrator"] : []; +} + /** 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`. */ From 73033bff377fcdba265e0ec432f51cd755b65524 Mon Sep 17 00:00:00 2001 From: Maksym Yezhov Date: Thu, 13 Aug 2026 12:30:57 -0700 Subject: [PATCH 12/18] - refactor: Human Participants, invitation, presence, revocation --- .../src/conversation/membershipRegistry.ts | 9 + .../src/conversation/participantRegistry.ts | 10 + .../conversation/participantService.test.ts | 241 ++++++ .../src/conversation/participantService.ts | 309 ++++++++ apps/server/src/index.ts | 68 +- apps/server/src/pi/triggers/triggerEngine.ts | 23 + apps/server/src/routes/sessions/index.ts | 4 + .../src/routes/sessions/participants.test.ts | 209 +++++ .../src/routes/sessions/participants.ts | 296 +++++++ apps/server/src/routes/sessions/schemas.ts | 38 + apps/server/src/sockets/chat.ts | 62 +- apps/server/src/sockets/chatMemory.ts | 10 + .../src/sockets/presenceTracker.test.ts | 37 + apps/server/src/sockets/presenceTracker.ts | 53 ++ .../db/migrations/0012_previous_blizzard.sql | 1 + .../db/migrations/meta/0012_snapshot.json | 747 ++++++++++++++++++ .../store/db/migrations/meta/_journal.json | 7 + .../src/store/db/migrations0012.test.ts | 37 + apps/server/src/store/db/schema.ts | 6 + .../src/store/inMemoryMembershipStore.ts | 38 + .../src/store/inMemoryParticipantStore.ts | 22 + apps/server/src/store/membershipStore.ts | 17 + apps/server/src/store/participantStore.ts | 22 +- .../src/store/sqliteMembershipStore.test.ts | 40 + .../server/src/store/sqliteMembershipStore.ts | 56 +- .../src/store/sqliteParticipantStore.test.ts | 50 ++ .../src/store/sqliteParticipantStore.ts | 27 + packages/shared/src/contracts.ts | 42 + 28 files changed, 2467 insertions(+), 14 deletions(-) create mode 100644 apps/server/src/conversation/participantService.test.ts create mode 100644 apps/server/src/conversation/participantService.ts create mode 100644 apps/server/src/routes/sessions/participants.test.ts create mode 100644 apps/server/src/routes/sessions/participants.ts create mode 100644 apps/server/src/sockets/presenceTracker.test.ts create mode 100644 apps/server/src/sockets/presenceTracker.ts create mode 100644 apps/server/src/store/db/migrations/0012_previous_blizzard.sql create mode 100644 apps/server/src/store/db/migrations/meta/0012_snapshot.json create mode 100644 apps/server/src/store/db/migrations0012.test.ts diff --git a/apps/server/src/conversation/membershipRegistry.ts b/apps/server/src/conversation/membershipRegistry.ts index a806f3f..38f627d 100644 --- a/apps/server/src/conversation/membershipRegistry.ts +++ b/apps/server/src/conversation/membershipRegistry.ts @@ -134,6 +134,15 @@ export class MembershipRegistry { return members.find((member) => member.participantId === participantId); } + /** + * Drops a session's cached memberships so the next read reloads from the + * store. Called after a membership is joined, left, muted or removed out of + * band, so a change is not hidden behind the derive-once cache. + */ + invalidate(sessionId: string): void { + this.cache.delete(sessionId); + } + /** The session's memberships, indexed by conversation on first use. */ private async load(sessionId: string): Promise> { const cached = this.cache.get(sessionId); diff --git a/apps/server/src/conversation/participantRegistry.ts b/apps/server/src/conversation/participantRegistry.ts index a58e676..5675174 100644 --- a/apps/server/src/conversation/participantRegistry.ts +++ b/apps/server/src/conversation/participantRegistry.ts @@ -51,6 +51,16 @@ export class ParticipantRegistry { return [...byId.values()]; } + /** + * Drops a session's cached participants so the next read reloads from the + * store. Called after a write the registry did not make itself — an + * invitation, a revocation, a presence transition — so a human added out of + * band is not hidden behind a stale cache. + */ + invalidate(sessionId: string): void { + this.cache.delete(sessionId); + } + /** One Participant by id, or nothing when neither a row nor an agent exists. */ async get(sessionId: string, id: string): Promise { const byId = await this.load(sessionId); diff --git a/apps/server/src/conversation/participantService.test.ts b/apps/server/src/conversation/participantService.test.ts new file mode 100644 index 0000000..4753dc1 --- /dev/null +++ b/apps/server/src/conversation/participantService.test.ts @@ -0,0 +1,241 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { after, test } from "node:test"; + +// Point the session root at a throwaway dir before importing modules that read +// config at load time, so `createSession`'s mkdir never touches the repo. +const ROOT = mkdtempSync(path.join(tmpdir(), "participant-service-")); +process.env.SESSIONS_ROOT = ROOT; + +const { ParticipantService } = await import("./participantService.ts"); +const { ParticipantRegistry } = await import("./participantRegistry.ts"); +const { MembershipRegistry } = await import("./membershipRegistry.ts"); +const { RunRegistry } = await import("../runs/runRegistry.ts"); +const { InMemorySessionStore } = + await import("../store/inMemorySessionStore.ts"); +const { InMemoryParticipantStore } = + await import("../store/inMemoryParticipantStore.ts"); +const { InMemoryMembershipStore } = + await import("../store/inMemoryMembershipStore.ts"); +const { InMemoryRunStore } = await import("../store/inMemoryRunStore.ts"); + +type ConnectorRegistry = + import("../connectors/connectorRegistry.ts").ConnectorRegistry; +type ParticipantPresencePayload = + import("@tangent/shared/contracts.ts").ParticipantPresencePayload; + +after(() => rmSync(ROOT, { recursive: true, force: true })); + +/** A real service over in-memory stores, plus the seams a test asserts on. */ +async function harness() { + const participantStore = new InMemoryParticipantStore(); + const sessions = new InMemorySessionStore(participantStore); + const membershipStore = new InMemoryMembershipStore(); + const participantRegistry = new ParticipantRegistry( + sessions, + participantStore, + ); + const memberships = new MembershipRegistry( + sessions, + membershipStore, + () => true, + ); + const runs = new RunRegistry(new InMemoryRunStore()); + const cancelled: string[] = []; + const presenceEvents: ParticipantPresencePayload[] = []; + const connectors = { + cancelRun: (req: { participantId: string }) => { + cancelled.push(req.participantId); + return { cancelled: true }; + }, + } as unknown as ConnectorRegistry; + const service = new ParticipantService( + participantStore, + membershipStore, + participantRegistry, + memberships, + runs, + connectors, + (payload) => presenceEvents.push(payload), + ); + const session = await sessions.createSession({ name: "S" }); + return { + service, + sessions, + participantStore, + membershipStore, + memberships, + runs, + session, + cancelled, + presenceEvents, + }; +} + +test("invite creates an away human keyed by email, with memberships", async () => { + const { service, session } = await harness(); + + const participant = await service.invite(session.id, { + email: "a@shopify.com", + conversationIds: ["prime"], + }); + + assert.equal(participant.id, "a@shopify.com"); + assert.equal(participant.kind, "human"); + assert.equal(participant.presence, "away"); + const memberships = await service.membershipsOf(session.id, "a@shopify.com"); + assert.deepEqual( + memberships.map((m) => m.conversationId), + ["prime"], + ); +}); + +test("inviting into Prime's conversation keeps Prime a member", async () => { + const { service, memberships, session } = await harness(); + + await service.invite(session.id, { + email: "a@shopify.com", + conversationIds: ["prime"], + }); + + const members = await memberships.membersOf(session.id, "prime"); + const ids = members.map((m) => m.participantId).sort(); + assert.deepEqual(ids, ["a@shopify.com", "prime"]); +}); + +test("an invited human's membership is inert, so fan-out never wakes them", async () => { + const { service, memberships, session } = await harness(); + + await service.invite(session.id, { + email: "a@shopify.com", + conversationIds: ["prime"], + }); + + const membership = await memberships.memberIn( + session.id, + "prime", + "a@shopify.com", + ); + assert.equal(membership?.reaction, "never"); +}); + +test("revoke removes memberships, retains the row, and broadcasts detached", async () => { + const { service, session, presenceEvents } = await harness(); + await service.invite(session.id, { + email: "a@shopify.com", + conversationIds: ["prime"], + }); + + await service.revoke(session.id, "a@shopify.com"); + + const participant = await service.get(session.id, "a@shopify.com"); + assert.ok(participant, "a revoked participant is retained"); + assert.ok(participant.revokedAt); + assert.equal(participant.presence, "detached"); + assert.deepEqual( + await service.membershipsOf(session.id, "a@shopify.com"), + [], + ); + assert.deepEqual(presenceEvents.at(-1), { + sessionId: session.id, + participantId: "a@shopify.com", + presence: "detached", + }); +}); + +test("re-inviting a revoked human clears the revocation", async () => { + const { service, session } = await harness(); + await service.invite(session.id, { email: "a@shopify.com" }); + await service.revoke(session.id, "a@shopify.com"); + + await service.invite(session.id, { email: "a@shopify.com" }); + + const participant = await service.get(session.id, "a@shopify.com"); + assert.equal(participant?.revokedAt, undefined); + assert.equal(participant?.presence, "away"); +}); + +test("join then leave adds and removes a single membership", async () => { + const { service, session } = await harness(); + await service.invite(session.id, { email: "a@shopify.com" }); + + await service.join(session.id, "a@shopify.com", "prime"); + assert.equal( + (await service.membershipsOf(session.id, "a@shopify.com")).length, + 1, + ); + + await service.leave(session.id, "a@shopify.com", "prime"); + assert.deepEqual( + await service.membershipsOf(session.id, "a@shopify.com"), + [], + ); +}); + +test("closeConversation ends memberships and settles the open run", async () => { + const { service, memberships, membershipStore, runs, session, cancelled } = + await harness(); + // Materialize the conversation's base memberships, then open a run on it. + await memberships.membersOf(session.id, "prime"); + runs.open({ + sessionId: session.id, + participantId: "prime", + ingress: "reaction", + }); + + await service.closeConversation(session.id, "prime"); + + assert.equal(runs.current(session.id, "prime"), undefined); + assert.ok(cancelled.includes("prime")); + // The stored rows are gone; the registry would re-derive a live agent's own + // membership, which is why the store is what a close is asserted against. + assert.deepEqual( + await membershipStore.listForConversation(session.id, "prime"), + [], + ); +}); + +test("ensureAutomation is idempotent and materializes an inert member", async () => { + const { service, memberships, participantStore, session } = await harness(); + + await service.ensureAutomation(session.id, "memory", "Memory", "reaction"); + await service.ensureAutomation(session.id, "memory", "Memory", "reaction"); + + const memory = await participantStore.get(session.id, "memory"); + assert.equal(memory?.kind, "automation"); + const membership = await memberships.memberIn(session.id, "prime", "memory"); + assert.equal(membership?.reaction, "never"); + assert.equal(membership?.ingress, "reaction"); +}); + +test("setPresence writes, broadcasts, and skips a no-op transition", async () => { + const { service, session, presenceEvents } = await harness(); + await service.invite(session.id, { email: "a@shopify.com" }); + + await service.setPresence(session.id, "a@shopify.com", "connected"); + await service.setPresence(session.id, "a@shopify.com", "connected"); + + const participant = await service.get(session.id, "a@shopify.com"); + assert.equal(participant?.presence, "connected"); + const forThisPerson = presenceEvents.filter( + (p) => p.participantId === "a@shopify.com", + ); + assert.equal( + forThisPerson.length, + 1, + "an unchanged presence is not re-broadcast", + ); +}); + +test("setPresence ignores a revoked participant", async () => { + const { service, session } = await harness(); + await service.invite(session.id, { email: "a@shopify.com" }); + await service.revoke(session.id, "a@shopify.com"); + + await service.setPresence(session.id, "a@shopify.com", "connected"); + + const participant = await service.get(session.id, "a@shopify.com"); + assert.equal(participant?.presence, "detached"); +}); diff --git a/apps/server/src/conversation/participantService.ts b/apps/server/src/conversation/participantService.ts new file mode 100644 index 0000000..6185512 --- /dev/null +++ b/apps/server/src/conversation/participantService.ts @@ -0,0 +1,309 @@ +import { + connectorFor, + type ParticipantKind, + type ParticipantPresencePayload, + type Presence, + type RunIngress, +} from "@tangent/shared/contracts.ts"; + +import type { ConnectorRegistry } from "../connectors/connectorRegistry.ts"; +import type { RunRegistry } from "../runs/runRegistry.ts"; +import type { Membership, MembershipStore } from "../store/membershipStore.ts"; +import type { + Participant, + ParticipantStore, +} from "../store/participantStore.ts"; +import type { MembershipRegistry } from "./membershipRegistry.ts"; +import type { ParticipantRegistry } from "./participantRegistry.ts"; +import { reactionSpec } from "./reaction.ts"; + +/** What a person or another participant reacts to in a Conversation it is in. */ +const ADDRESSABLE = reactionSpec("fromHumans", "mentionsMe"); + +/** + * A member that never reacts. Humans and automations hold this in the + * session-scoped transport: a human reads the room broadcast rather than being + * woken through a connector, and an automation is an ingress source, not a + * reactor. Anything reactive on a connector-less participant would fire the + * fallback connector's "not available" notice. 2.3's rooms make a human's + * reaction meaningful. + */ +const INERT = reactionSpec("never"); + +/** The presence an invited-but-not-yet-connected person holds. */ +const INVITED_PRESENCE: Presence = "away"; + +/** Details of a person being invited into a session. */ +export interface InviteInput { + /** The person's email — their server-resolved, stable Participant id. */ + email: string; + displayName?: string; + /** Conversations to grant Membership in; empty means the session only. */ + conversationIds?: string[]; +} + +/** The reaction a freshly-joined participant of a given kind holds. */ +function reactionFor(kind: ParticipantKind | undefined): string { + return kind === "agent" ? ADDRESSABLE : INERT; +} + +/** + * The lifecycle of a session's Participants and their Memberships: who is in a + * session, which Conversations they hold Membership in, whether they are present + * right now, and when they leave. This is the first runtime consumer of + * {@link ParticipantRegistry} — 2.1 left it unit-tested only. + * + * `session_agents` stays the write authority for agents; this service owns the + * rows agents never produce — humans and automations — and the Membership edits + * (join, leave, mute, close) that are nobody's to make until a Conversation can + * hold more than one actor. + */ +export class ParticipantService { + private readonly participants: ParticipantStore; + private readonly memberships: MembershipStore; + private readonly participantRegistry: ParticipantRegistry; + private readonly membershipRegistry: MembershipRegistry; + private readonly runs: RunRegistry; + private readonly connectors: ConnectorRegistry; + private readonly onPresence: + | ((payload: ParticipantPresencePayload) => void) + | undefined; + + constructor( + participants: ParticipantStore, + memberships: MembershipStore, + participantRegistry: ParticipantRegistry, + membershipRegistry: MembershipRegistry, + runs: RunRegistry, + connectors: ConnectorRegistry, + onPresence?: (payload: ParticipantPresencePayload) => void, + ) { + this.participants = participants; + this.memberships = memberships; + this.participantRegistry = participantRegistry; + this.membershipRegistry = membershipRegistry; + this.runs = runs; + this.connectors = connectors; + this.onPresence = onPresence; + } + + /** Every Participant in a session, roster rows reconciled, revoked included. */ + async list(sessionId: string): Promise { + return this.participantRegistry.listForSession(sessionId); + } + + /** One Participant, or nothing when the session holds no such id. */ + async get(sessionId: string, id: string): Promise { + return this.participantRegistry.get(sessionId, id); + } + + /** The Memberships one Participant holds across the session. */ + async membershipsOf( + sessionId: string, + participantId: string, + ): Promise { + const all = await this.memberships.listForSession(sessionId); + return all.filter( + (membership) => membership.participantId === participantId, + ); + } + + /** + * Invites a person into the session: a Human Participant keyed by their email, + * plus a Membership in each granted Conversation. Re-inviting a revoked person + * clears the revocation rather than orphaning the old row. + */ + async invite(sessionId: string, input: InviteInput): Promise { + const existing = await this.participants.get(sessionId, input.email); + const participant: Participant = { + id: input.email, + sessionId, + kind: "human", + displayName: input.displayName ?? input.email, + capabilities: [], + presence: INVITED_PRESENCE, + connector: connectorFor("unresolved"), + revokedAt: undefined, + createdAt: existing?.createdAt ?? new Date().toISOString(), + }; + await this.participants.put(participant); + for (const conversationId of input.conversationIds ?? []) + await this.addMembership(sessionId, input.email, conversationId, INERT); + this.invalidate(sessionId); + return participant; + } + + /** + * Revokes a Participant: removes every Membership and stamps the row revoked + * (dropping its capabilities) rather than deleting it, so a transcript keeps + * its attributions. Marks them detached so clients stop showing them present. + */ + async revoke(sessionId: string, participantId: string): Promise { + for (const membership of await this.membershipsOf(sessionId, participantId)) + await this.memberships.remove( + sessionId, + membership.conversationId, + participantId, + ); + await this.participants.revoke(sessionId, participantId); + await this.participants.updatePresence( + sessionId, + participantId, + "detached", + ); + this.invalidate(sessionId); + this.onPresence?.({ sessionId, participantId, presence: "detached" }); + } + + /** Adds a Participant to a Conversation with its kind's default reaction. */ + async join( + sessionId: string, + participantId: string, + conversationId: string, + ): Promise { + const participant = await this.participants.get(sessionId, participantId); + await this.addMembership( + sessionId, + participantId, + conversationId, + reactionFor(participant?.kind), + ); + this.membershipRegistry.invalidate(sessionId); + } + + /** Removes a Participant from one Conversation, leaving the others intact. */ + async leave( + sessionId: string, + participantId: string, + conversationId: string, + ): Promise { + await this.memberships.remove(sessionId, conversationId, participantId); + this.membershipRegistry.invalidate(sessionId); + } + + /** + * Mutes or unmutes an agent's Membership: a muted member never reacts; + * unmuting restores the addressable default. Only agent Memberships are + * connector-woken, so muting one is what stops it — a human's delivery is the + * room, not a reaction. + */ + async setMuted( + sessionId: string, + participantId: string, + conversationId: string, + muted: boolean, + ): Promise { + const existing = await this.memberships.get( + sessionId, + conversationId, + participantId, + ); + if (!existing) return undefined; + const next: Membership = { + ...existing, + reaction: muted ? INERT : ADDRESSABLE, + }; + await this.memberships.put(next); + this.membershipRegistry.invalidate(sessionId); + return next; + } + + /** + * Closes a Conversation: ends every Membership and settles its open Runs. + * Identity is still one string this PR, so the subject Participant's id is the + * Conversation id — its open Run is the Conversation's. + */ + async closeConversation( + sessionId: string, + conversationId: string, + ): Promise { + const members = await this.memberships.listForConversation( + sessionId, + conversationId, + ); + for (const member of members) + await this.memberships.remove( + sessionId, + conversationId, + member.participantId, + ); + this.connectors.cancelRun({ sessionId, participantId: conversationId }); + this.runs.settleOpenFor(sessionId, conversationId, "cancelled"); + this.membershipRegistry.invalidate(sessionId); + } + + /** + * Materializes an Automation Participant (memory, a trigger) and its inert + * Membership in the orchestrator's Conversation, once per session. The synthetic + * `MEMORY_AUTHOR` / `TRIGGER_AUTHOR` still author the Messages; this makes the + * actor behind them a real, listable Participant with an ingress. + */ + async ensureAutomation( + sessionId: string, + id: string, + displayName: string, + ingress: RunIngress, + ): Promise { + if (await this.participants.get(sessionId, id)) return; + await this.participants.put({ + id, + sessionId, + kind: "automation", + displayName, + capabilities: [], + presence: "connected", + connector: connectorFor("unresolved"), + createdAt: new Date().toISOString(), + }); + const orchestratorId = + await this.participantRegistry.orchestratorId(sessionId); + await this.addMembership(sessionId, id, orchestratorId, INERT, ingress); + this.invalidate(sessionId); + } + + /** + * Records a Participant's live {@link Presence} and broadcasts it. A revoked or + * absent Participant, or one already in this presence, is left alone. + */ + async setPresence( + sessionId: string, + participantId: string, + presence: Presence, + ): Promise { + const existing = await this.participants.get(sessionId, participantId); + if (!existing || existing.revokedAt) return; + if (existing.presence === presence) return; + await this.participants.updatePresence(sessionId, participantId, presence); + this.participantRegistry.invalidate(sessionId); + this.onPresence?.({ sessionId, participantId, presence }); + } + + /** + * Persists one Membership. Derives the Conversation's base Memberships first: + * `MembershipRegistry.membersOf` returns stored rows verbatim once any exist, + * so writing a row before the orchestrator/subject rows are persisted would + * drop them from the set. + */ + private async addMembership( + sessionId: string, + participantId: string, + conversationId: string, + reaction: string, + ingress: RunIngress = "reaction", + ): Promise { + await this.membershipRegistry.membersOf(sessionId, conversationId); + await this.memberships.put({ + sessionId, + participantId, + conversationId, + reaction, + ingress, + transcriptVisibility: "shared", + }); + } + + private invalidate(sessionId: string): void { + this.participantRegistry.invalidate(sessionId); + this.membershipRegistry.invalidate(sessionId); + } +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index d2be64d..c7f4d98 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -10,6 +10,8 @@ import { PORT } from "./config.ts"; import { createConnectorRegistry } from "./connectors/connectorRegistry.ts"; import { ConversationRouter } from "./conversation/conversationRouter.ts"; import { MembershipRegistry } from "./conversation/membershipRegistry.ts"; +import { ParticipantRegistry } from "./conversation/participantRegistry.ts"; +import { ParticipantService } from "./conversation/participantService.ts"; import { ExternalSubagentGateway } from "./external/externalSubagentGateway.ts"; import { RelayRegistry } from "./mcp/relayRegistry.ts"; import { createRelayReport } from "./mcp/relayReport.ts"; @@ -46,6 +48,10 @@ import { createMemoryRememberedHandler, createMemorySuggestionHandler, } from "./sockets/chatMemory.ts"; +import { + createParticipantPresenceEmitter, + PresenceTracker, +} from "./sockets/presenceTracker.ts"; import { createUiCommandEmitter } from "./sockets/sessionRoster.ts"; import { openDb } from "./store/db/client.ts"; import { FileAgentBundleStore } from "./store/fileAgentBundleStore.ts"; @@ -93,20 +99,21 @@ const emitUiCommand = createUiCommandEmitter(io); // from the agent roster on a cache miss, so a session the backfill never touched // still resolves; `acceptsDelivery` is read lazily because the registry it comes // from is built below. -const memberships = new MembershipRegistry( - store, - new SqliteMembershipStore(db), - (kind) => connectors.acceptsDelivery(kind), +const membershipStore = new SqliteMembershipStore(db); +const memberships = new MembershipRegistry(store, membershipStore, (kind) => + connectors.acceptsDelivery(kind), ); +// The read surface over the `participants` table, reconciling stored humans and +// automations with the agent roster. 2.1 left it unit-tested only; this PR is +// its first runtime consumer. +const participantRegistry = new ParticipantRegistry(store, participants); + // The one way a Message enters a Conversation: persist, broadcast, then deliver // to whoever reacts. Every entry point — a human turn, a trigger firing, a tool // call, a finalized agent turn — goes through it. const conversations = new ConversationRouter(io, store, memberships); -// Surfaces applied memory writes as a highlighted message in Prime's thread. -const onMemoryRemembered = createMemoryRememberedHandler(conversations, store); - // Shared event sink: a participant's streaming events, roster changes and posted // messages land the same way whether it runs locally (PiAgentManager), in a // remote environment, or entirely outside Tangent. @@ -190,13 +197,47 @@ const connectors = createConnectorRegistry( // not in the dependency, so it is broken here rather than by an indirection. conversations.useConnectors(connectors); +// Broadcasts a participant's live presence to its session room. +const emitParticipantPresence = createParticipantPresenceEmitter(io); + +// The lifecycle of humans, automations and their memberships: invitation, +// presence, revocation, and the membership edits (join, leave, mute, close) a +// multi-actor Conversation needs. Owns the rows the agent roster never writes. +const participantService = new ParticipantService( + participants, + membershipStore, + participantRegistry, + memberships, + runs, + connectors, + emitParticipantPresence, +); + +// Refcounts each participant's live sockets so presence follows the person. +const presence = new PresenceTracker(); + +// Surfaces applied memory writes as a highlighted message in Prime's thread, +// authored by the memory Automation Participant this ensures exists. +const onMemoryRemembered = createMemoryRememberedHandler( + conversations, + store, + participantService, +); + // Where a relay peer's words land: posted as the participant its channel belongs // to, or delivered to Prime when no participant owns the channel. const relayReport = createRelayReport(connectors, conversations, store); // Drives schedule timers and callback firings, posting prompts into the target's // Conversation. -const triggerEngine = new TriggerEngine(io, store, pi, triggers, conversations); +const triggerEngine = new TriggerEngine( + io, + store, + pi, + triggers, + conversations, + participantService, +); app.get("/api/health", (req, res) => { const cookies = Object.fromEntries( @@ -216,7 +257,14 @@ app.get("/api/health", (req, res) => { app.use( "/api/sessions", - createSessionsRouter(store, pi, triggers, triggerEngine, agentBundleStore), + createSessionsRouter( + store, + pi, + triggers, + triggerEngine, + agentBundleStore, + participantService, + ), ); app.use("/api/agent-bundles", createAgentBundlesRouter(agentBundleStore)); app.use("/api/global-memory", createGlobalMemoryRouter(memory)); @@ -272,6 +320,8 @@ registerChatHandlers({ onRemembered: onMemoryRemembered, triggerEngine, emitUiCommand, + participantService, + presence, }); httpServer.listen(PORT, () => { diff --git a/apps/server/src/pi/triggers/triggerEngine.ts b/apps/server/src/pi/triggers/triggerEngine.ts index a783f90..6b60e80 100644 --- a/apps/server/src/pi/triggers/triggerEngine.ts +++ b/apps/server/src/pi/triggers/triggerEngine.ts @@ -13,6 +13,7 @@ import type { Server } from "socket.io"; import type { ConversationRouter } from "../../conversation/conversationRouter.ts"; import { orchestratorIdFor } from "../../conversation/participantRegistry.ts"; +import type { ParticipantService } from "../../conversation/participantService.ts"; import { roomFor } from "../../sockets/rooms.ts"; import type { SessionStore } from "../../store/sessionStore.ts"; import type { SubagentSpawnRequest } from "../agentConfig.ts"; @@ -85,6 +86,7 @@ export class TriggerEngine { private readonly pi: PiAgentManager; private readonly triggers: TriggerManager; private readonly conversations: ConversationRouter; + private readonly participants: ParticipantService | undefined; constructor( io: Server, @@ -92,12 +94,14 @@ export class TriggerEngine { pi: PiAgentManager, triggers: TriggerManager, conversations: ConversationRouter, + participants?: ParticipantService, ) { this.io = io; this.store = store; this.pi = pi; this.triggers = triggers; this.conversations = conversations; + this.participants = participants; } /** Seeds a bundle's triggers into a new session and arms its schedules. */ @@ -242,6 +246,7 @@ export class TriggerEngine { ): Promise { this.pi.ensure(sessionId, rootPath); const orchestratorId = await orchestratorIdFor(this.store, sessionId); + await this.ensureTriggerParticipant(sessionId, stored); await this.conversations.post({ sessionId, conversationId: orchestratorId, @@ -265,6 +270,7 @@ export class TriggerEngine { prompt: string, ): Promise { const { agentId } = this.ensureSubagent(sessionId, rootPath, stored); + await this.ensureTriggerParticipant(sessionId, stored); await this.conversations.post({ sessionId, conversationId: agentId, @@ -275,6 +281,23 @@ export class TriggerEngine { }); } + /** + * Materializes the trigger Automation Participant once per session, so the + * actor behind a firing is a real, listable row. The delivered Message still + * carries the TRIGGER_AUTHOR label (its title-specific name). + */ + private async ensureTriggerParticipant( + sessionId: string, + stored: StoredTrigger, + ): Promise { + await this.participants?.ensureAutomation( + sessionId, + TRIGGER_AUTHOR.id, + TRIGGER_AUTHOR.name, + ingressFor(stored), + ); + } + /** * Eagerly spawns a freshly created `subagent`-target trigger's sub-agent so it * exists before the first firing. No-op for a Prime-target or unknown trigger. diff --git a/apps/server/src/routes/sessions/index.ts b/apps/server/src/routes/sessions/index.ts index f421a11..0cfd48a 100644 --- a/apps/server/src/routes/sessions/index.ts +++ b/apps/server/src/routes/sessions/index.ts @@ -1,5 +1,6 @@ import { type Request, type Response, Router } from "express"; +import type { ParticipantService } from "../../conversation/participantService.ts"; import { getValidated, validate } from "../../middleware/validate.ts"; import type { PiAgentManager } from "../../pi/piAgentManager.ts"; import type { TriggerEngine } from "../../pi/triggers/triggerEngine.ts"; @@ -17,6 +18,7 @@ import { handleUploadFiles, uploadFiles, } from "./handlers.ts"; +import { registerParticipantRoutes } from "./participants.ts"; import type { CreateSessionInput, SessionParams, @@ -134,6 +136,7 @@ export function createSessionsRouter( triggers: TriggerManager, triggerEngine: TriggerEngine, agentBundleStore: AgentBundleStore, + participants: ParticipantService, ): Router { const router = Router(); @@ -147,6 +150,7 @@ export function createSessionsRouter( registerSessionItemRoutes(router, store, pi, triggerEngine); registerSessionActivityRoutes(router, store); registerTriggerRoutes(router, store, triggers, triggerEngine); + registerParticipantRoutes(router, store, participants); // Declared after the trigger routes so the `*splat` catch-all doesn't shadow // the more specific `/:id/triggers/...` paths. diff --git a/apps/server/src/routes/sessions/participants.test.ts b/apps/server/src/routes/sessions/participants.test.ts new file mode 100644 index 0000000..13af518 --- /dev/null +++ b/apps/server/src/routes/sessions/participants.test.ts @@ -0,0 +1,209 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { after, test } from "node:test"; + +// Point the session root at a throwaway dir before importing modules that read +// config at load time, so `createSession`'s mkdir never touches the repo. +const ROOT = mkdtempSync(path.join(tmpdir(), "participants-rest-")); +process.env.SESSIONS_ROOT = ROOT; + +const express = (await import("express")).default; +const { Router } = await import("express"); +const { registerParticipantRoutes } = await import("./participants.ts"); +const { inviteParticipantSchema } = await import("./schemas.ts"); +const { ParticipantService } = + await import("../../conversation/participantService.ts"); +const { ParticipantRegistry } = + await import("../../conversation/participantRegistry.ts"); +const { MembershipRegistry } = + await import("../../conversation/membershipRegistry.ts"); +const { RunRegistry } = await import("../../runs/runRegistry.ts"); +const { InMemorySessionStore } = + await import("../../store/inMemorySessionStore.ts"); +const { InMemoryParticipantStore } = + await import("../../store/inMemoryParticipantStore.ts"); +const { InMemoryMembershipStore } = + await import("../../store/inMemoryMembershipStore.ts"); +const { InMemoryRunStore } = await import("../../store/inMemoryRunStore.ts"); + +type ConnectorRegistry = + import("../../connectors/connectorRegistry.ts").ConnectorRegistry; + +const cleanups: (() => void)[] = []; +after(() => { + for (const cleanup of cleanups) cleanup(); + rmSync(ROOT, { recursive: true, force: true }); +}); + +/** A running express app mounting the participant routes over real stores. */ +async function serve() { + const participantStore = new InMemoryParticipantStore(); + const sessions = new InMemorySessionStore(participantStore); + const membershipStore = new InMemoryMembershipStore(); + const participantRegistry = new ParticipantRegistry( + sessions, + participantStore, + ); + const memberships = new MembershipRegistry( + sessions, + membershipStore, + () => true, + ); + const runs = new RunRegistry(new InMemoryRunStore()); + const connectors = { + cancelRun: () => ({ cancelled: true }), + } as unknown as ConnectorRegistry; + const service = new ParticipantService( + participantStore, + membershipStore, + participantRegistry, + memberships, + runs, + connectors, + ); + const session = await sessions.createSession({ name: "S" }); + + const app = express(); + app.use(express.json()); + const router = Router(); + registerParticipantRoutes(router, sessions, service); + app.use("/api/sessions", router); + + const server = app.listen(0); + await new Promise((resolve) => server.once("listening", resolve)); + cleanups.push(() => server.close()); + const { port } = server.address() as AddressInfo; + const base = `http://127.0.0.1:${port}/api/sessions`; + + const call = async ( + method: string, + pathname: string, + body?: unknown, + ): Promise<{ status: number; json: T }> => { + const res = await fetch(`${base}${pathname}`, { + method, + headers: body ? { "content-type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + return { + status: res.status, + json: (text ? JSON.parse(text) : undefined) as T, + }; + }; + + return { call, sessionId: session.id }; +} + +test("inviteParticipantSchema requires a valid email", () => { + assert.equal(inviteParticipantSchema.safeParse({}).success, false); + assert.equal( + inviteParticipantSchema.safeParse({ email: "nope" }).success, + false, + ); + assert.equal( + inviteParticipantSchema.safeParse({ email: "a@shopify.com" }).success, + true, + ); +}); + +test("GET participants lists the roster, including Prime", async () => { + const { call, sessionId } = await serve(); + + const { status, json } = await call<{ participants: { id: string }[] }>( + "GET", + `/${sessionId}/participants`, + ); + + assert.equal(status, 200); + assert.ok(json.participants.map((p) => p.id).includes("prime")); +}); + +test("POST invite then GET shows the human as away", async () => { + const { call, sessionId } = await serve(); + + const invited = await call<{ + participant: { kind: string; presence: string }; + }>("POST", `/${sessionId}/participants`, { + email: "a@shopify.com", + conversationIds: ["prime"], + }); + assert.equal(invited.status, 201); + assert.equal(invited.json.participant.kind, "human"); + assert.equal(invited.json.participant.presence, "away"); + + const list = await call<{ + participants: { + id: string; + memberships: { conversationId: string }[]; + }[]; + }>("GET", `/${sessionId}/participants`); + const human = list.json.participants.find((p) => p.id === "a@shopify.com"); + assert.ok(human); + assert.deepEqual( + human.memberships.map((m) => m.conversationId), + ["prime"], + ); +}); + +test("membership join then leave, and revoke, drive the right statuses", async () => { + const { call, sessionId } = await serve(); + await call("POST", `/${sessionId}/participants`, { email: "a@shopify.com" }); + + const joined = await call( + "POST", + `/${sessionId}/participants/a@shopify.com/memberships`, + { conversationId: "prime" }, + ); + assert.equal(joined.status, 201); + + const left = await call( + "DELETE", + `/${sessionId}/participants/a@shopify.com/memberships/prime`, + ); + assert.equal(left.status, 204); + + const revoked = await call( + "DELETE", + `/${sessionId}/participants/a@shopify.com`, + ); + assert.equal(revoked.status, 204); +}); + +test("muting a human membership is refused", async () => { + const { call, sessionId } = await serve(); + await call("POST", `/${sessionId}/participants`, { email: "a@shopify.com" }); + await call("POST", `/${sessionId}/participants/a@shopify.com/memberships`, { + conversationId: "prime", + }); + + const muted = await call( + "PATCH", + `/${sessionId}/participants/a@shopify.com/memberships/prime`, + { muted: true }, + ); + + assert.equal(muted.status, 400); +}); + +test("routes 404 for an unknown session", async () => { + const { call } = await serve(); + const { status, json } = await call<{ error: string }>( + "GET", + "/missing/participants", + ); + assert.equal(status, 404); + assert.deepEqual(json, { error: "Session not found" }); +}); + +test("revoking an unknown participant 404s", async () => { + const { call, sessionId } = await serve(); + const { status } = await call( + "DELETE", + `/${sessionId}/participants/ghost@shopify.com`, + ); + assert.equal(status, 404); +}); diff --git a/apps/server/src/routes/sessions/participants.ts b/apps/server/src/routes/sessions/participants.ts new file mode 100644 index 0000000..60d2761 --- /dev/null +++ b/apps/server/src/routes/sessions/participants.ts @@ -0,0 +1,296 @@ +import type { + MembershipView, + ParticipantKind, + ParticipantView, +} from "@tangent/shared/contracts.ts"; +import { type Request, type Response, Router } from "express"; + +import type { ParticipantService } from "../../conversation/participantService.ts"; +import { reactionSpec } from "../../conversation/reaction.ts"; +import { getValidated, validate } from "../../middleware/validate.ts"; +import type { Membership } from "../../store/membershipStore.ts"; +import type { Participant } from "../../store/participantStore.ts"; +import type { SessionStore } from "../../store/sessionStore.ts"; +import type { + InviteParticipantInput, + JoinMembershipInput, + MembershipParams, + MuteMembershipInput, + ParticipantParams, + SessionParams, +} from "./schemas.ts"; +import { + inviteParticipantSchema, + joinMembershipSchema, + membershipParamsSchema, + muteMembershipSchema, + participantParamsSchema, + sessionParamsSchema, +} from "./schemas.ts"; +import { loadSession } from "./utils.ts"; + +const NEVER = reactionSpec("never"); + +/** Projects a {@link Participant} onto the REST DTO, hiding internal payloads. */ +function toParticipantView(participant: Participant): ParticipantView { + return { + id: participant.id, + sessionId: participant.sessionId, + kind: participant.kind, + displayName: participant.displayName, + capabilities: participant.capabilities, + presence: participant.presence, + revokedAt: participant.revokedAt, + createdAt: participant.createdAt, + }; +} + +/** + * Projects a {@link Membership} onto its DTO. Only an agent's Membership is + * connector-woken, so only it can be muted; a human or automation is inert by + * design, not muted, and reads back unmuted. + */ +function toMembershipView( + membership: Membership, + kind: ParticipantKind, +): MembershipView { + return { + conversationId: membership.conversationId, + reaction: membership.reaction, + ingress: membership.ingress, + muted: kind === "agent" && membership.reaction === NEVER, + }; +} + +/** Responds `404 { error: "Participant not found" }`, matching `loadSession`. */ +function participantNotFound(res: Response): void { + res.status(404).json({ error: "Participant not found" }); +} + +/** `GET /:id/participants` → every Participant with its Memberships. */ +async function handleListParticipants( + store: SessionStore, + participants: ParticipantService, + id: string, + res: Response, +): Promise { + const session = await loadSession(store, res, id); + if (!session) return; + const rows = await participants.list(session.id); + const views = await Promise.all( + rows.map(async (participant) => ({ + ...toParticipantView(participant), + memberships: ( + await participants.membershipsOf(session.id, participant.id) + ).map((membership) => toMembershipView(membership, participant.kind)), + })), + ); + res.json({ participants: views }); +} + +/** `POST /:id/participants` → invite a person by email. */ +async function handleInviteParticipant( + store: SessionStore, + participants: ParticipantService, + id: string, + body: InviteParticipantInput, + res: Response, +): Promise { + const session = await loadSession(store, res, id); + if (!session) return; + const participant = await participants.invite(session.id, body); + res.status(201).json({ participant: toParticipantView(participant) }); +} + +/** `DELETE /:id/participants/:participantId` → revoke. */ +async function handleRevokeParticipant( + store: SessionStore, + participants: ParticipantService, + params: ParticipantParams, + res: Response, +): Promise { + const session = await loadSession(store, res, params.id); + if (!session) return; + if (!(await participants.get(session.id, params.participantId))) + return participantNotFound(res); + await participants.revoke(session.id, params.participantId); + res.status(204).end(); +} + +/** `POST /:id/participants/:participantId/memberships` → join a Conversation. */ +async function handleJoinMembership( + store: SessionStore, + participants: ParticipantService, + params: ParticipantParams, + body: JoinMembershipInput, + res: Response, +): Promise { + const session = await loadSession(store, res, params.id); + if (!session) return; + const participant = await participants.get(session.id, params.participantId); + if (!participant) return participantNotFound(res); + await participants.join( + session.id, + params.participantId, + body.conversationId, + ); + const memberships = await participants.membershipsOf( + session.id, + params.participantId, + ); + const joined = memberships.find( + (membership) => membership.conversationId === body.conversationId, + ); + res.status(201).json({ + membership: joined ? toMembershipView(joined, participant.kind) : null, + }); +} + +/** + * `DELETE /:id/participants/:participantId/memberships/:conversationId` → + * leave one Conversation, leaving the participant's others intact. + */ +async function handleLeaveMembership( + store: SessionStore, + participants: ParticipantService, + params: MembershipParams, + res: Response, +): Promise { + const session = await loadSession(store, res, params.id); + if (!session) return; + await participants.leave( + session.id, + params.participantId, + params.conversationId, + ); + res.status(204).end(); +} + +/** + * `PATCH /:id/participants/:participantId/memberships/:conversationId` → + * mute/unmute. Only agent Memberships are connector-woken, so muting is + * refused for a human or automation. + */ +async function handleMuteMembership( + store: SessionStore, + participants: ParticipantService, + params: MembershipParams, + body: MuteMembershipInput, + res: Response, +): Promise { + const session = await loadSession(store, res, params.id); + if (!session) return; + const participant = await participants.get(session.id, params.participantId); + if (!participant) return participantNotFound(res); + if (participant.kind !== "agent") { + res.status(400).json({ error: "Only agent memberships can be muted" }); + return; + } + const membership = await participants.setMuted( + session.id, + params.participantId, + params.conversationId, + body.muted, + ); + if (!membership) { + res.status(404).json({ error: "Membership not found" }); + return; + } + res.json({ membership: toMembershipView(membership, participant.kind) }); +} + +/** Registers the participant collection + item routes (list, invite, revoke). */ +function registerParticipantItemRoutes( + router: Router, + store: SessionStore, + participants: ParticipantService, +): void { + router.get( + "/:id/participants", + validate({ params: sessionParamsSchema }), + (req: Request, res: Response) => + handleListParticipants( + store, + participants, + getValidated(req).params.id, + res, + ), + ); + + router.post( + "/:id/participants", + validate({ params: sessionParamsSchema, body: inviteParticipantSchema }), + (req: Request, res: Response) => { + const { params, body } = getValidated< + InviteParticipantInput, + SessionParams + >(req); + return handleInviteParticipant(store, participants, params.id, body, res); + }, + ); + + router.delete( + "/:id/participants/:participantId", + validate({ params: participantParamsSchema }), + (req: Request, res: Response) => + handleRevokeParticipant( + store, + participants, + getValidated(req).params, + res, + ), + ); +} + +/** Registers the membership routes (join, leave, mute) under a participant. */ +function registerMembershipRoutes( + router: Router, + store: SessionStore, + participants: ParticipantService, +): void { + router.post( + "/:id/participants/:participantId/memberships", + validate({ params: participantParamsSchema, body: joinMembershipSchema }), + (req: Request, res: Response) => { + const { params, body } = getValidated< + JoinMembershipInput, + ParticipantParams + >(req); + return handleJoinMembership(store, participants, params, body, res); + }, + ); + + router.delete( + "/:id/participants/:participantId/memberships/:conversationId", + validate({ params: membershipParamsSchema }), + (req: Request, res: Response) => + handleLeaveMembership( + store, + participants, + getValidated(req).params, + res, + ), + ); + + router.patch( + "/:id/participants/:participantId/memberships/:conversationId", + validate({ params: membershipParamsSchema, body: muteMembershipSchema }), + (req: Request, res: Response) => { + const { params, body } = getValidated< + MuteMembershipInput, + MembershipParams + >(req); + return handleMuteMembership(store, participants, params, body, res); + }, + ); +} + +/** Registers the participant + membership management routes on a session. */ +export function registerParticipantRoutes( + router: Router, + store: SessionStore, + participants: ParticipantService, +): void { + registerParticipantItemRoutes(router, store, participants); + registerMembershipRoutes(router, store, participants); +} diff --git a/apps/server/src/routes/sessions/schemas.ts b/apps/server/src/routes/sessions/schemas.ts index 8214359..c0d93d6 100644 --- a/apps/server/src/routes/sessions/schemas.ts +++ b/apps/server/src/routes/sessions/schemas.ts @@ -75,3 +75,41 @@ export const callbackParamsSchema = z.object({ secret: z.string(), }); export type CallbackParams = z.infer; + +/** + * Invite-participant body. The email is the person's server-resolved id, so it + * matches the id their connected socket already authors under. + */ +export const inviteParticipantSchema = z.object({ + email: z.string().email(), + displayName: z.string().optional(), + conversationIds: z.array(z.string()).optional(), +}); +export type InviteParticipantInput = z.infer; + +/** `:id/:participantId` route params for participant management. */ +export const participantParamsSchema = z.object({ + id: z.string(), + participantId: z.string(), +}); +export type ParticipantParams = z.infer; + +/** Join-membership body: the Conversation to grant Membership in. */ +export const joinMembershipSchema = z.object({ + conversationId: z.string().min(1), +}); +export type JoinMembershipInput = z.infer; + +/** `:id/:participantId/memberships/:conversationId` route params. */ +export const membershipParamsSchema = z.object({ + id: z.string(), + participantId: z.string(), + conversationId: z.string(), +}); +export type MembershipParams = z.infer; + +/** Mute/unmute body. */ +export const muteMembershipSchema = z.object({ + muted: z.boolean(), +}); +export type MuteMembershipInput = z.infer; diff --git a/apps/server/src/sockets/chat.ts b/apps/server/src/sockets/chat.ts index c62ab65..0172cfa 100644 --- a/apps/server/src/sockets/chat.ts +++ b/apps/server/src/sockets/chat.ts @@ -21,6 +21,7 @@ import { resolveUserIdentity } from "../auth/identity.ts"; import type { ConnectorRegistry } from "../connectors/connectorRegistry.ts"; import type { ConversationRouter } from "../conversation/conversationRouter.ts"; import { orchestratorIdFor } from "../conversation/participantRegistry.ts"; +import type { ParticipantService } from "../conversation/participantService.ts"; import type { MemoryManager } from "../pi/memory.ts"; import type { PiAgentManager } from "../pi/piAgentManager.ts"; import type { TriggerEngine } from "../pi/triggers/triggerEngine.ts"; @@ -36,6 +37,7 @@ import { type MemoryRememberedHandler, } from "./chatMemory.ts"; import { type MentionCandidate, resolveMentions } from "./mentions.ts"; +import type { PresenceTracker } from "./presenceTracker.ts"; import { roomFor } from "./rooms.ts"; import { emitPrimeSelection, @@ -57,6 +59,8 @@ export interface ChatHandlerDeps { onRemembered: MemoryRememberedHandler; triggerEngine: TriggerEngine; emitUiCommand: UiCommandEmitter; + participantService: ParticipantService; + presence: PresenceTracker; } /** @@ -93,9 +97,16 @@ function wireSocket(socket: Socket, deps: ChatHandlerDeps): void { // client never gets a say in who its messages are attributed to. const author = resolveSocketAuthor(socket.handshake.headers.cookie); - socket.on(SocketEvents.ChatJoin, (payload: ChatJoinPayload) => - handleChatJoin(socket, store, pi, connectors, triggerEngine, payload), - ); + // (sessionId\0participantId) pairs whose presence this socket is holding up, + // so the last of a person's tabs to close is what marks them detached. + const tracked = new Set(); + + socket.on(SocketEvents.ChatJoin, (payload: ChatJoinPayload) => { + void handleChatJoin(socket, store, pi, connectors, triggerEngine, payload); + void markPresent(deps, author, payload?.sessionId, tracked); + }); + + socket.on("disconnect", () => markAbsent(deps, tracked)); socket.on(SocketEvents.ChatMessage, (payload: ChatMessagePayload) => handleChatMessage(socket, deps, author, payload), @@ -225,6 +236,51 @@ export function resolveSocketAuthor(cookieHeader: string | undefined) { return humanAuthor(resolveUserIdentity(cookieHeader) ?? DEFAULT_USER); } +/** Key of the presence a socket holds for one participant in one session. */ +function presenceKey(sessionId: string, participantId: string): string { + return `${sessionId}\u0000${participantId}`; +} + +/** + * Marks a connecting human present, if they are an invited Participant of this + * session. Only their first live socket transitions them to `connected`; a + * non-participant (a session's owner who was never invited) has no presence. + */ +async function markPresent( + deps: ChatHandlerDeps, + author: ChatAuthor, + sessionId: string | undefined, + tracked: Set, +): Promise { + if (author.kind !== "human" || !sessionId) return; + const participant = await deps.participantService.get(sessionId, author.id); + if (!participant) return; + const key = presenceKey(sessionId, author.id); + if (tracked.has(key)) return; + tracked.add(key); + if (deps.presence.arrive(sessionId, author.id)) + await deps.participantService.setPresence( + sessionId, + author.id, + "connected", + ); +} + +/** Marks a disconnecting socket's participants detached once their last tab closes. */ +function markAbsent(deps: ChatHandlerDeps, tracked: Set): void { + for (const key of tracked) { + const sep = key.indexOf("\u0000"); + const sessionId = key.slice(0, sep); + const participantId = key.slice(sep + 1); + if (deps.presence.depart(sessionId, participantId)) + void deps.participantService.setPresence( + sessionId, + participantId, + "detached", + ); + } +} + /** * Posts a human message into the conversation it was typed in. Whoever reacts to * it runs: Prime because a human talking in its thread is what it reacts to, a diff --git a/apps/server/src/sockets/chatMemory.ts b/apps/server/src/sockets/chatMemory.ts index 5285069..8fc9237 100644 --- a/apps/server/src/sockets/chatMemory.ts +++ b/apps/server/src/sockets/chatMemory.ts @@ -11,6 +11,7 @@ import type { Server } from "socket.io"; import type { ConnectorRegistry } from "../connectors/connectorRegistry.ts"; import type { ConversationRouter } from "../conversation/conversationRouter.ts"; import { orchestratorIdFor } from "../conversation/participantRegistry.ts"; +import type { ParticipantService } from "../conversation/participantService.ts"; import type { MemoryManager } from "../pi/memory.ts"; import type { SessionStore } from "../store/sessionStore.ts"; import { roomFor } from "./rooms.ts"; @@ -30,8 +31,17 @@ export type MemoryRememberedHandler = ( export function createMemoryRememberedHandler( conversations: ConversationRouter, store: SessionStore, + participants: ParticipantService, ): MemoryRememberedHandler { return async (sessionId, scope, text) => { + // Memory is an Automation Participant: the row it authors from is real and + // listable, even though the Message still carries the MEMORY_AUTHOR label. + await participants.ensureAutomation( + sessionId, + MEMORY_AUTHOR.id, + MEMORY_AUTHOR.name, + "reaction", + ); await conversations.post({ sessionId, conversationId: await orchestratorIdFor(store, sessionId), diff --git a/apps/server/src/sockets/presenceTracker.test.ts b/apps/server/src/sockets/presenceTracker.test.ts new file mode 100644 index 0000000..b3dd0b7 --- /dev/null +++ b/apps/server/src/sockets/presenceTracker.test.ts @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { PresenceTracker } from "./presenceTracker.ts"; + +test("arrive is true only on the first socket, depart only on the last", () => { + const tracker = new PresenceTracker(); + + assert.equal(tracker.arrive("s", "p"), true, "first socket connects them"); + assert.equal(tracker.arrive("s", "p"), false, "a second tab is not a change"); + assert.equal(tracker.depart("s", "p"), false, "one tab remains open"); + assert.equal(tracker.depart("s", "p"), true, "the last tab detaches them"); +}); + +test("presence is refcounted per (session, participant)", () => { + const tracker = new PresenceTracker(); + + assert.equal(tracker.arrive("s1", "p"), true); + assert.equal( + tracker.arrive("s2", "p"), + true, + "a different session is separate", + ); + assert.equal(tracker.depart("s1", "p"), true); + assert.equal(tracker.arrive("s1", "p"), true, "and reconnects independently"); +}); + +test("departing an untracked participant does not underflow the count", () => { + const tracker = new PresenceTracker(); + + assert.equal(tracker.depart("s", "ghost"), true); + assert.equal( + tracker.arrive("s", "ghost"), + true, + "the next arrive is still first", + ); +}); diff --git a/apps/server/src/sockets/presenceTracker.ts b/apps/server/src/sockets/presenceTracker.ts new file mode 100644 index 0000000..30e766c --- /dev/null +++ b/apps/server/src/sockets/presenceTracker.ts @@ -0,0 +1,53 @@ +import { + type ParticipantPresencePayload, + SocketEvents, +} from "@tangent/shared/contracts.ts"; +import type { Server } from "socket.io"; + +import { roomFor } from "./rooms.ts"; + +/** Broadcasts a Participant's presence transition to its session room. */ +export function createParticipantPresenceEmitter( + io: Server, +): (payload: ParticipantPresencePayload) => void { + return (payload) => { + io.to(roomFor(payload.sessionId)).emit( + SocketEvents.ParticipantPresence, + payload, + ); + }; +} + +/** + * Refcounts a Participant's live sockets so presence tracks the person, not one + * tab: arriving on the first socket and departing on the last are the only two + * transitions that matter. A person with two tabs open stays present until both + * close, which is what makes `detached` mean "gone" rather than "switched tab". + */ +export class PresenceTracker { + private readonly counts = new Map(); + + private key(sessionId: string, participantId: string): string { + return `${sessionId}\u0000${participantId}`; + } + + /** Registers a socket for a participant; true when it is their first. */ + arrive(sessionId: string, participantId: string): boolean { + const key = this.key(sessionId, participantId); + const next = (this.counts.get(key) ?? 0) + 1; + this.counts.set(key, next); + return next === 1; + } + + /** Deregisters a socket for a participant; true when it was their last. */ + depart(sessionId: string, participantId: string): boolean { + const key = this.key(sessionId, participantId); + const next = (this.counts.get(key) ?? 0) - 1; + if (next <= 0) { + this.counts.delete(key); + return true; + } + this.counts.set(key, next); + return false; + } +} diff --git a/apps/server/src/store/db/migrations/0012_previous_blizzard.sql b/apps/server/src/store/db/migrations/0012_previous_blizzard.sql new file mode 100644 index 0000000..2bbe3e3 --- /dev/null +++ b/apps/server/src/store/db/migrations/0012_previous_blizzard.sql @@ -0,0 +1 @@ +ALTER TABLE `participants` ADD `revoked_at` text; \ No newline at end of file diff --git a/apps/server/src/store/db/migrations/meta/0012_snapshot.json b/apps/server/src/store/db/migrations/meta/0012_snapshot.json new file mode 100644 index 0000000..8aaf5a9 --- /dev/null +++ b/apps/server/src/store/db/migrations/meta/0012_snapshot.json @@ -0,0 +1,747 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "5862359b-4eed-43d0-94a0-6d9a9e2dad97", + "prevId": "01371193-31c7-4470-a5ec-c01cae9e9ab1", + "tables": { + "conversations": { + "name": "conversations", + "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 + }, + "next_seq": { + "name": "next_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "conversations_session_idx": { + "name": "conversations_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "conversations_session_id": { + "name": "conversations_session_id", + "columns": ["session_id", "id"], + "isUnique": true + } + }, + "foreignKeys": { + "conversations_session_id_sessions_id_fk": { + "name": "conversations_session_id_sessions_id_fk", + "tableFrom": "conversations", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "memberships": { + "name": "memberships", + "columns": { + "participant_id": { + "name": "participant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reaction": { + "name": "reaction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'never'" + }, + "ingress": { + "name": "ingress", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'reaction'" + }, + "transcript_visibility": { + "name": "transcript_visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'shared'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "memberships_session_conversation_idx": { + "name": "memberships_session_conversation_idx", + "columns": ["session_id", "conversation_id"], + "isUnique": false + }, + "memberships_session_conversation_participant": { + "name": "memberships_session_conversation_participant", + "columns": ["session_id", "conversation_id", "participant_id"], + "isUnique": true + } + }, + "foreignKeys": { + "memberships_session_id_sessions_id_fk": { + "name": "memberships_session_id_sessions_id_fk", + "tableFrom": "memberships", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "participants": { + "name": "participants", + "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 + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "presence": { + "name": "presence", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'connected'" + }, + "connector_kind": { + "name": "connector_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_lifecycle": { + "name": "connector_lifecycle", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_environment_id": { + "name": "connector_environment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_endpoint_url": { + "name": "connector_endpoint_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_payload": { + "name": "agent_payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "participants_session_idx": { + "name": "participants_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "participants_session_id": { + "name": "participants_session_id", + "columns": ["session_id", "id"], + "isUnique": true + } + }, + "foreignKeys": { + "participants_session_id_sessions_id_fk": { + "name": "participants_session_id_sessions_id_fk", + "tableFrom": "participants", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "runs": { + "name": "runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "participant_id": { + "name": "participant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "home_conversation_id": { + "name": "home_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "ingress": { + "name": "ingress", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": 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 + }, + "ended_at": { + "name": "ended_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "runs_session_idx": { + "name": "runs_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "runs_session_participant_idx": { + "name": "runs_session_participant_idx", + "columns": ["session_id", "participant_id"], + "isUnique": false + } + }, + "foreignKeys": { + "runs_session_id_sessions_id_fk": { + "name": "runs_session_id_sessions_id_fk", + "tableFrom": "runs", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "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'" + }, + "connector_kind": { + "name": "connector_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_lifecycle": { + "name": "connector_lifecycle", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_environment_id": { + "name": "connector_environment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_endpoint_url": { + "name": "connector_endpoint_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "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 5474827..a803c1a 100644 --- a/apps/server/src/store/db/migrations/meta/_journal.json +++ b/apps/server/src/store/db/migrations/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1786577218176, "tag": "0011_small_magneto", "breakpoints": true + }, + { + "idx": 12, + "version": "6", + "when": 1786644715013, + "tag": "0012_previous_blizzard", + "breakpoints": true } ] } diff --git a/apps/server/src/store/db/migrations0012.test.ts b/apps/server/src/store/db/migrations0012.test.ts new file mode 100644 index 0000000..a2ae996 --- /dev/null +++ b/apps/server/src/store/db/migrations0012.test.ts @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { after, test } from "node:test"; + +// Point the session root at a throwaway dir before importing modules that read +// config at load time. +const ROOT = mkdtempSync(path.join(tmpdir(), "migration-0012-")); +process.env.SESSIONS_ROOT = ROOT; + +const { openDb } = await import("./client.ts"); +const { SqliteSessionStore } = await import("../sqliteSessionStore.ts"); +const { SqliteParticipantStore } = await import("../sqliteParticipantStore.ts"); + +after(() => rmSync(ROOT, { recursive: true, force: true })); + +test("0012 adds a nullable revoked_at without touching existing rows", async () => { + const db = openDb(":memory:"); + const store = new SqliteParticipantStore(db); + // createSession dual-writes a `prime` participant with no revoked_at set — + // exactly the shape a row backfilled before 0012 has. + const session = await new SqliteSessionStore(db, store).createSession({ + name: "S", + }); + + const prime = await store.get(session.id, "prime"); + assert.ok(prime, "the pre-existing participant still reads back"); + assert.equal(prime.revokedAt, undefined, "a legacy row is not revoked"); + + const columns = db.$client + .prepare("PRAGMA table_info(participants)") + .all() as { name: string; notnull: number }[]; + const revoked = columns.find((column) => column.name === "revoked_at"); + assert.ok(revoked, "the migration added the column"); + assert.equal(revoked.notnull, 0, "and left it nullable"); +}); diff --git a/apps/server/src/store/db/schema.ts b/apps/server/src/store/db/schema.ts index 7055c86..7e8a246 100644 --- a/apps/server/src/store/db/schema.ts +++ b/apps/server/src/store/db/schema.ts @@ -280,6 +280,12 @@ export const participants = sqliteTable( * `host`, `purpose`, `status` — everything that was an agent-only column. */ agentPayload: text("agent_payload"), + /** + * ISO-8601 timestamp set when a Participant is revoked from the session. A + * revoked row is retained so a transcript keeps its attributions and a + * person who left is not an unresolvable id; null means active. + */ + revokedAt: text("revoked_at"), createdAt: text("created_at").notNull(), }, (table) => [ diff --git a/apps/server/src/store/inMemoryMembershipStore.ts b/apps/server/src/store/inMemoryMembershipStore.ts index 4ea9c3c..8984975 100644 --- a/apps/server/src/store/inMemoryMembershipStore.ts +++ b/apps/server/src/store/inMemoryMembershipStore.ts @@ -3,6 +3,15 @@ import type { Membership, MembershipStore } from "./membershipStore.ts"; /** Key of one membership, matching the table's uniqueness. */ function keyFor(membership: Membership): string { const { sessionId, conversationId, participantId } = membership; + return keyOf(sessionId, conversationId, participantId); +} + +/** Key from the parts, for the lookups that do not hold a whole membership. */ +function keyOf( + sessionId: string, + conversationId: string, + participantId: string, +): string { return `${sessionId}\u0000${conversationId}\u0000${participantId}`; } @@ -20,7 +29,36 @@ export class InMemoryMembershipStore implements MembershipStore { ); } + async listForConversation( + sessionId: string, + conversationId: string, + ): Promise { + return [...this.memberships.values()].filter( + (membership) => + membership.sessionId === sessionId && + membership.conversationId === conversationId, + ); + } + + async get( + sessionId: string, + conversationId: string, + participantId: string, + ): Promise { + return this.memberships.get( + keyOf(sessionId, conversationId, participantId), + ); + } + async put(membership: Membership): Promise { this.memberships.set(keyFor(membership), membership); } + + async remove( + sessionId: string, + conversationId: string, + participantId: string, + ): Promise { + this.memberships.delete(keyOf(sessionId, conversationId, participantId)); + } } diff --git a/apps/server/src/store/inMemoryParticipantStore.ts b/apps/server/src/store/inMemoryParticipantStore.ts index 0afdee0..89f668c 100644 --- a/apps/server/src/store/inMemoryParticipantStore.ts +++ b/apps/server/src/store/inMemoryParticipantStore.ts @@ -1,3 +1,5 @@ +import type { Presence } from "@tangent/shared/contracts.ts"; + import type { Participant, ParticipantStore } from "./participantStore.ts"; /** Key of one participant, matching the table's uniqueness. */ @@ -29,4 +31,24 @@ export class InMemoryParticipantStore implements ParticipantStore { participant, ); } + + async updatePresence( + sessionId: string, + id: string, + presence: Presence, + ): Promise { + const existing = this.participants.get(keyFor(sessionId, id)); + if (!existing) return; + this.participants.set(keyFor(sessionId, id), { ...existing, presence }); + } + + async revoke(sessionId: string, id: string): Promise { + const existing = this.participants.get(keyFor(sessionId, id)); + if (!existing) return; + this.participants.set(keyFor(sessionId, id), { + ...existing, + capabilities: [], + revokedAt: new Date().toISOString(), + }); + } } diff --git a/apps/server/src/store/membershipStore.ts b/apps/server/src/store/membershipStore.ts index e40d420..f9fe455 100644 --- a/apps/server/src/store/membershipStore.ts +++ b/apps/server/src/store/membershipStore.ts @@ -28,6 +28,23 @@ export interface Membership { export interface MembershipStore { /** Every membership in a session, so a registry can seed one lookup. */ listForSession(sessionId: string): Promise; + /** Every membership in one Conversation, so closing it can end each. */ + listForConversation( + sessionId: string, + conversationId: string, + ): Promise; + /** One membership, or nothing when the participant holds none here. */ + get( + sessionId: string, + conversationId: string, + participantId: string, + ): Promise; /** Upserts by `(sessionId, conversationId, participantId)`. */ put(membership: Membership): Promise; + /** Removes one membership. A no-op when there is none to remove. */ + remove( + sessionId: string, + conversationId: string, + participantId: string, + ): Promise; } diff --git a/apps/server/src/store/participantStore.ts b/apps/server/src/store/participantStore.ts index 935d221..bee0a63 100644 --- a/apps/server/src/store/participantStore.ts +++ b/apps/server/src/store/participantStore.ts @@ -43,6 +43,11 @@ export interface Participant { presence: Presence; connector: ConnectorDescriptor; agent?: AgentPayload; + /** + * Set once the Participant has been revoked from the session. The row is kept + * so its transcript attributions still resolve; `undefined` means active. + */ + revokedAt?: string; createdAt: string; } @@ -75,7 +80,10 @@ export function participantFromAgent(agent: SessionAgent): Participant { kind: "agent", displayName: agent.name, capabilities: agent.capabilities, - presence: "connected", + // Presence follows the roster's lifecycle: a detached tab (1.4) is a + // participant whose far end is gone, the same question a human's presence + // asks. Every other status is a runtime that is reachable. + presence: agent.status === "detached" ? "detached" : "connected", connector: agent.connector, agent: agentPayload(agent), createdAt: agent.createdAt, @@ -97,4 +105,16 @@ export interface ParticipantStore { get(sessionId: string, id: string): Promise; /** Upserts by `(sessionId, id)`. */ put(participant: Participant): Promise; + /** Moves a participant to a new {@link Presence}. A no-op if the row is gone. */ + updatePresence( + sessionId: string, + id: string, + presence: Presence, + ): Promise; + /** + * Marks a participant revoked (stamps `revokedAt`, drops its capabilities) so + * it no longer acts, without deleting the row — its transcript attributions + * must still resolve. A no-op if the row is gone. + */ + revoke(sessionId: string, id: string): Promise; } diff --git a/apps/server/src/store/sqliteMembershipStore.test.ts b/apps/server/src/store/sqliteMembershipStore.test.ts index d51ab52..20e4d00 100644 --- a/apps/server/src/store/sqliteMembershipStore.test.ts +++ b/apps/server/src/store/sqliteMembershipStore.test.ts @@ -89,3 +89,43 @@ test("deleting a session takes its memberships with it", async () => { assert.deepEqual(await store.listForSession(session.id), []); }); + +test("get returns one membership by its full key, or nothing", async () => { + const { store, sessionId } = await newStore(); + await store.put(membership({ sessionId, conversationId: "sub-1" })); + + assert.deepEqual(await store.get(sessionId, "sub-1", "prime"), { + ...membership({ sessionId, conversationId: "sub-1" }), + }); + assert.equal(await store.get(sessionId, "sub-1", "nobody"), undefined); + assert.equal(await store.get(sessionId, "other", "prime"), undefined); +}); + +test("listForConversation returns only that conversation's members", async () => { + const { store, sessionId } = await newStore(); + await store.put( + membership({ sessionId, conversationId: "sub-1", participantId: "sub-1" }), + ); + await store.put( + membership({ sessionId, conversationId: "sub-1", participantId: "prime" }), + ); + await store.put( + membership({ sessionId, conversationId: "prime", participantId: "prime" }), + ); + + const members = await store.listForConversation(sessionId, "sub-1"); + assert.deepEqual(members.map((m) => m.participantId).sort(), [ + "prime", + "sub-1", + ]); +}); + +test("remove deletes one membership and no-ops when absent", async () => { + const { store, sessionId } = await newStore(); + await store.put(membership({ sessionId, conversationId: "sub-1" })); + + await store.remove(sessionId, "sub-1", "prime"); + assert.deepEqual(await store.listForSession(sessionId), []); + + await store.remove(sessionId, "sub-1", "prime"); +}); diff --git a/apps/server/src/store/sqliteMembershipStore.ts b/apps/server/src/store/sqliteMembershipStore.ts index d4a9f74..5f0e031 100644 --- a/apps/server/src/store/sqliteMembershipStore.ts +++ b/apps/server/src/store/sqliteMembershipStore.ts @@ -2,7 +2,7 @@ import type { RunIngress, TranscriptVisibility, } from "@tangent/shared/contracts.ts"; -import { asc, eq } from "drizzle-orm"; +import { and, asc, eq } from "drizzle-orm"; import type { Db } from "./db/client.ts"; import { type MembershipRow, memberships } from "./db/schema.ts"; @@ -38,6 +38,43 @@ export class SqliteMembershipStore implements MembershipStore { return rows.map(toMembership); } + async listForConversation( + sessionId: string, + conversationId: string, + ): Promise { + const rows = this.db + .select() + .from(memberships) + .where( + and( + eq(memberships.sessionId, sessionId), + eq(memberships.conversationId, conversationId), + ), + ) + .orderBy(asc(memberships.createdAt)) + .all(); + return rows.map(toMembership); + } + + async get( + sessionId: string, + conversationId: string, + participantId: string, + ): Promise { + const row = this.db + .select() + .from(memberships) + .where( + and( + eq(memberships.sessionId, sessionId), + eq(memberships.conversationId, conversationId), + eq(memberships.participantId, participantId), + ), + ) + .get(); + return row ? toMembership(row) : undefined; + } + async put(membership: Membership): Promise { const { reaction, ingress, transcriptVisibility } = membership; this.db @@ -61,4 +98,21 @@ export class SqliteMembershipStore implements MembershipStore { }) .run(); } + + async remove( + sessionId: string, + conversationId: string, + participantId: string, + ): Promise { + this.db + .delete(memberships) + .where( + and( + eq(memberships.sessionId, sessionId), + eq(memberships.conversationId, conversationId), + eq(memberships.participantId, participantId), + ), + ) + .run(); + } } diff --git a/apps/server/src/store/sqliteParticipantStore.test.ts b/apps/server/src/store/sqliteParticipantStore.test.ts index 2780ee6..5b2ef60 100644 --- a/apps/server/src/store/sqliteParticipantStore.test.ts +++ b/apps/server/src/store/sqliteParticipantStore.test.ts @@ -91,3 +91,53 @@ test("get returns nothing for a participant that was never written", async () => const { store, sessionId } = await withSession(); assert.equal(await store.get(sessionId, "missing"), undefined); }); + +test("revokedAt round-trips and defaults to undefined", async () => { + const { store, sessionId } = await withSession(); + await store.put(participant(sessionId)); + + const active = await store.get(sessionId, "p-1"); + assert.equal(active?.revokedAt, undefined); + + await store.put( + participant(sessionId, { revokedAt: "2026-02-02T00:00:00.000Z" }), + ); + const revoked = await store.get(sessionId, "p-1"); + assert.equal(revoked?.revokedAt, "2026-02-02T00:00:00.000Z"); +}); + +test("updatePresence moves only the presence column", async () => { + const { store, sessionId } = await withSession(); + await store.put(participant(sessionId, { capabilities: ["orchestrator"] })); + + await store.updatePresence(sessionId, "p-1", "detached"); + + const got = await store.get(sessionId, "p-1"); + assert.equal(got?.presence, "detached"); + assert.deepEqual(got?.capabilities, ["orchestrator"]); +}); + +test("revoke stamps revokedAt and clears capabilities, keeping the row", async () => { + const { store, sessionId } = await withSession(); + await store.put( + participant(sessionId, { kind: "human", capabilities: ["orchestrator"] }), + ); + + await store.revoke(sessionId, "p-1"); + + const got = await store.get(sessionId, "p-1"); + assert.ok(got, "a revoked participant is retained, not deleted"); + assert.ok(got.revokedAt, "revocation is stamped"); + assert.deepEqual( + got.capabilities, + [], + "a revoked participant loses authority", + ); +}); + +test("updatePresence and revoke are no-ops for an absent row", async () => { + const { store, sessionId } = await withSession(); + await store.updatePresence(sessionId, "ghost", "away"); + await store.revoke(sessionId, "ghost"); + assert.equal(await store.get(sessionId, "ghost"), undefined); +}); diff --git a/apps/server/src/store/sqliteParticipantStore.ts b/apps/server/src/store/sqliteParticipantStore.ts index c4d274c..12600da 100644 --- a/apps/server/src/store/sqliteParticipantStore.ts +++ b/apps/server/src/store/sqliteParticipantStore.ts @@ -92,6 +92,7 @@ function toParticipant(row: ParticipantRow): Participant { presence: row.presence as Presence, connector: toConnector(row, agent), agent, + revokedAt: orUndefined(row.revokedAt), createdAt: row.createdAt, }; } @@ -152,6 +153,7 @@ export class SqliteParticipantStore implements ParticipantStore { presence: participant.presence, ...columns, agentPayload, + revokedAt: participant.revokedAt ?? null, createdAt: participant.createdAt, }) .onConflictDoUpdate({ @@ -163,8 +165,33 @@ export class SqliteParticipantStore implements ParticipantStore { presence: participant.presence, ...columns, agentPayload, + revokedAt: participant.revokedAt ?? null, }, }) .run(); } + + async updatePresence( + sessionId: string, + id: string, + presence: Presence, + ): Promise { + this.db + .update(participants) + .set({ presence }) + .where( + and(eq(participants.sessionId, sessionId), eq(participants.id, id)), + ) + .run(); + } + + async revoke(sessionId: string, id: string): Promise { + this.db + .update(participants) + .set({ revokedAt: new Date().toISOString(), capabilities: "[]" }) + .where( + and(eq(participants.sessionId, sessionId), eq(participants.id, id)), + ) + .run(); + } } diff --git a/packages/shared/src/contracts.ts b/packages/shared/src/contracts.ts index 553af16..9218734 100644 --- a/packages/shared/src/contracts.ts +++ b/packages/shared/src/contracts.ts @@ -717,6 +717,36 @@ export function capabilitiesForRole(role: AgentRole): Capability[] { return role === "prime" ? ["orchestrator"] : []; } +/** + * A Participant as returned by the REST surface (`/api/sessions/:id/participants`): + * the durable actor identity, without the internal `agent_payload` or connector + * facets. `revokedAt` is set once a person has been removed from the session — + * the row is kept so its transcript attributions still resolve. + */ +export interface ParticipantView { + id: string; + sessionId: string; + kind: ParticipantKind; + displayName: string; + capabilities: Capability[]; + presence: Presence; + /** ISO-8601 timestamp; set once the Participant has been revoked. */ + revokedAt?: string; + createdAt: string; +} + +/** + * One of a Participant's Memberships, as returned alongside a + * {@link ParticipantView}. `muted` is the read of a `reaction` that has been set + * to `never`, so a client need not know the reaction vocabulary to show it. + */ +export interface MembershipView { + conversationId: string; + reaction: ReactionSpec; + ingress: RunIngress; + muted: boolean; +} + /** 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`. */ @@ -1046,6 +1076,17 @@ export interface SubagentUpdatePayload { subagent: SubagentInfo; } +/** + * A Participant's live {@link Presence} transition (server -> client): a human + * connecting, going away, or dropping ("closed laptop"). Broadcast to the + * session room so a client can show who is currently reachable. + */ +export interface ParticipantPresencePayload { + sessionId: string; + participantId: string; + presence: Presence; +} + /** * Emitted (server -> client) when the agent suggests remembering something that * needs user confirmation before it is applied (agent-initiated global memory). @@ -1206,6 +1247,7 @@ export const SocketEvents = { AgentQueue: "agent:queue", SubagentRoster: "subagent:roster", SubagentUpdate: "subagent:update", + ParticipantPresence: "participant:presence", MemorySuggestion: "memory:suggestion", MemoryConfirm: "memory:confirm", MemoryDismiss: "memory:dismiss", From 8c89b490f61c4fca7fbb8de43ed9b7e1e5f1623e Mon Sep 17 00:00:00 2001 From: Maksym Yezhov Date: Thu, 13 Aug 2026 15:01:28 -0700 Subject: [PATCH 13/18] - refactor: backend - Room per Conversation --- apps/server/src/config.ts | 12 ++ .../src/conversation/conversationRouter.ts | 4 +- apps/server/src/sockets/agentEvents.ts | 4 +- apps/server/src/sockets/chat.test.ts | 109 ++++++++++++ apps/server/src/sockets/chat.ts | 163 +++++++++++++++-- apps/server/src/sockets/rooms.test.ts | 30 ++++ apps/server/src/sockets/rooms.ts | 30 ++++ apps/server/src/sockets/roomsRollback.test.ts | 12 ++ apps/server/src/store/inMemorySessionStore.ts | 9 + apps/server/src/store/sessionStore.ts | 9 + apps/server/src/store/sqliteSessionStore.ts | 10 ++ .../features/chat/components/SessionChat.tsx | 8 +- .../chat/components/tabs/AssetTabContent.tsx | 6 +- .../chat/components/tabs/SubagentTabView.tsx | 6 +- .../src/features/chat/hooks/useSessionChat.ts | 167 +++++++++++++++--- packages/shared/src/contracts.ts | 26 +++ 16 files changed, 556 insertions(+), 49 deletions(-) create mode 100644 apps/server/src/sockets/chat.test.ts create mode 100644 apps/server/src/sockets/rooms.test.ts create mode 100644 apps/server/src/sockets/roomsRollback.test.ts diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 293add0..dfa13e6 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -123,6 +123,18 @@ export const PI_THINKING = process.env.PI_THINKING ?? DEFAULT_THINKING_LEVEL; */ export const PI_DEBUG = !/^(0|false|no)$/i.test(process.env.PI_DEBUG ?? ""); +/** + * When enabled (the default), a Message is delivered to a room per Conversation + * rather than one room per session: a socket joins only the Conversation rooms + * its Participant is authorized for, so who receives a Message is a server-side + * decision derived from Membership rather than a client-side render filter. Set + * `ROOM_PER_CONVERSATION=0/false/no` to fall back to the session-scoped room — + * a rollback for this PR only; a later cleanup removes the flag. + */ +export const ROOM_PER_CONVERSATION = !/^(0|false|no)$/i.test( + process.env.ROOM_PER_CONVERSATION ?? "", +); + /** * Base URL the orchestrator extension (running inside each Pi process) uses to * reach this server's internal agent API. Defaults to loopback on {@link PORT}. diff --git a/apps/server/src/conversation/conversationRouter.ts b/apps/server/src/conversation/conversationRouter.ts index 5b916da..9d4f4b6 100644 --- a/apps/server/src/conversation/conversationRouter.ts +++ b/apps/server/src/conversation/conversationRouter.ts @@ -16,7 +16,7 @@ import { import type { Server } from "socket.io"; import type { ConnectorRegistry } from "../connectors/connectorRegistry.ts"; -import { roomFor } from "../sockets/rooms.ts"; +import { messageRoomFor } from "../sockets/rooms.ts"; import type { Membership } from "../store/membershipStore.ts"; import type { SessionStore } from "../store/sessionStore.ts"; import { FanOutEngine, type FanOutResult } from "./fanOut.ts"; @@ -269,7 +269,7 @@ export class ConversationRouter { ): void { if (override) return override(message); this.io - .to(roomFor(message.sessionId)) + .to(messageRoomFor(message.sessionId, message.conversationId)) .emit(SocketEvents.ChatMessage, message); } diff --git a/apps/server/src/sockets/agentEvents.ts b/apps/server/src/sockets/agentEvents.ts index cb5413a..f872ad5 100644 --- a/apps/server/src/sockets/agentEvents.ts +++ b/apps/server/src/sockets/agentEvents.ts @@ -27,7 +27,7 @@ import type { SubagentUpdateHandler, } from "../pi/types.ts"; import type { SessionStore } from "../store/sessionStore.ts"; -import { roomFor, SESSIONS_LOBBY } from "./rooms.ts"; +import { messageRoomFor, roomFor, SESSIONS_LOBBY } from "./rooms.ts"; /** Resolves the chat author for an agent: Prime is fixed, sub-agents per id. */ function authorFor(agent: AgentDescriptor): ChatAuthor { @@ -239,7 +239,7 @@ export function createAgentEventHandler( ): AgentEventHandler { return (sessionId, agent, event) => { const ctx: EmitContext = { - room: roomFor(sessionId), + room: messageRoomFor(sessionId, agent.agentId), sessionId, conversationId: agent.agentId, author: authorFor(agent), diff --git a/apps/server/src/sockets/chat.test.ts b/apps/server/src/sockets/chat.test.ts new file mode 100644 index 0000000..c876105 --- /dev/null +++ b/apps/server/src/sockets/chat.test.ts @@ -0,0 +1,109 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + type ChatAuthor, + connectorFor, + humanAuthor, + type Session, + type UserIdentity, +} from "@tangent/shared/contracts.ts"; + +import type { Membership } from "../store/membershipStore.ts"; +import type { SessionAgent } from "../store/sessionStore.ts"; +import { authorizedConversations } from "./chat.ts"; + +const OWNER: UserIdentity = { + email: "owner@example.com", + first_name: "Olive", + last_name: "Owner", +}; + +function session(user?: UserIdentity): Session { + return { + id: "s1", + name: "Session", + rootPath: "/tmp/s1", + status: "created", + user, + archived: false, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }; +} + +function agent(id: string): SessionAgent { + return { + id, + sessionId: "s1", + role: id === "prime" ? "prime" : "subagent", + name: id, + capabilities: id === "prime" ? ["orchestrator"] : [], + status: "active", + connector: connectorFor("pi-stdio"), + createdAt: "2026-01-01T00:00:00.000Z", + }; +} + +function membership(participantId: string, conversationId: string): Membership { + return { + sessionId: "s1", + participantId, + conversationId, + reaction: "never", + ingress: "reaction", + transcriptVisibility: "shared", + }; +} + +const AGENTS = [agent("prime"), agent("sub-1"), agent("sub-2")]; + +test("the session owner is authorized for every Conversation", () => { + const author = humanAuthor(OWNER); + const authorized = authorizedConversations( + author, + session(OWNER), + AGENTS, + [], + ); + assert.deepEqual([...authorized].sort(), ["prime", "sub-1", "sub-2"]); +}); + +test("with no resolved creator, any human is treated as the owner", () => { + const author = humanAuthor({ + email: "someone@example.com", + first_name: "Sam", + last_name: "One", + }); + const authorized = authorizedConversations( + author, + session(undefined), + AGENTS, + [], + ); + assert.deepEqual([...authorized].sort(), ["prime", "sub-1", "sub-2"]); +}); + +test("an invited human sees only the Conversations it holds a Membership in", () => { + const guest: ChatAuthor = humanAuthor({ + email: "guest@example.com", + first_name: "Gwen", + last_name: "Guest", + }); + const authorized = authorizedConversations(guest, session(OWNER), AGENTS, [ + membership("guest@example.com", "sub-1"), + ]); + assert.deepEqual([...authorized], ["sub-1"]); +}); + +test("a Membership in an unknown Conversation is ignored", () => { + const guest = humanAuthor({ + email: "guest@example.com", + first_name: "Gwen", + last_name: "Guest", + }); + const authorized = authorizedConversations(guest, session(OWNER), AGENTS, [ + membership("guest@example.com", "gone"), + ]); + assert.equal(authorized.size, 0); +}); diff --git a/apps/server/src/sockets/chat.ts b/apps/server/src/sockets/chat.ts index 0172cfa..7fd5749 100644 --- a/apps/server/src/sockets/chat.ts +++ b/apps/server/src/sockets/chat.ts @@ -5,12 +5,16 @@ import { type ArtifactUnpinPayload, type ChatAuthor, type ChatJoinPayload, + type ChatMessage, type ChatMessagePayload, + type ConversationHistoryPayload, + type ConversationSubscribePayload, DEFAULT_USER, humanAuthor, type MemoryConfirmPayload, type MemoryDismissPayload, PI_AGENT, + type Session, SocketEvents, type SubagentRosterPayload, type TriggerRosterPayload, @@ -18,6 +22,7 @@ import { import type { Server, Socket } from "socket.io"; import { resolveUserIdentity } from "../auth/identity.ts"; +import { ROOM_PER_CONVERSATION } from "../config.ts"; import type { ConnectorRegistry } from "../connectors/connectorRegistry.ts"; import type { ConversationRouter } from "../conversation/conversationRouter.ts"; import { orchestratorIdFor } from "../conversation/participantRegistry.ts"; @@ -25,7 +30,8 @@ import type { ParticipantService } from "../conversation/participantService.ts"; import type { MemoryManager } from "../pi/memory.ts"; import type { PiAgentManager } from "../pi/piAgentManager.ts"; import type { TriggerEngine } from "../pi/triggers/triggerEngine.ts"; -import type { SessionStore } from "../store/sessionStore.ts"; +import type { Membership } from "../store/membershipStore.ts"; +import type { SessionAgent, SessionStore } from "../store/sessionStore.ts"; import { handleArtifactPin, handleArtifactUnpin, @@ -38,7 +44,7 @@ import { } from "./chatMemory.ts"; import { type MentionCandidate, resolveMentions } from "./mentions.ts"; import type { PresenceTracker } from "./presenceTracker.ts"; -import { roomFor } from "./rooms.ts"; +import { roomFor, roomForConversation } from "./rooms.ts"; import { emitPrimeSelection, ensureSessionAgents, @@ -88,10 +94,9 @@ function handleAgentAbort( console.log(`[runs] cancel refused for ${participantId}: ${reason}`); } -/** Wires one connected socket's chat/agent/memory/artifact listeners. */ +/** Wires one connected socket's chat and agent listeners. */ function wireSocket(socket: Socket, deps: ChatHandlerDeps): void { - const { io, store, pi, connectors, memory } = deps; - const { onRemembered, triggerEngine, emitUiCommand } = deps; + const { io, store, pi, connectors } = deps; // Resolved once per connection: the identity is the connection's, and the // client never gets a say in who its messages are attributed to. @@ -102,10 +107,19 @@ function wireSocket(socket: Socket, deps: ChatHandlerDeps): void { const tracked = new Set(); socket.on(SocketEvents.ChatJoin, (payload: ChatJoinPayload) => { - void handleChatJoin(socket, store, pi, connectors, triggerEngine, payload); + void handleChatJoin(socket, deps, author, payload); void markPresent(deps, author, payload?.sessionId, tracked); }); + // Only meaningful under per-Conversation rooms; the session-scoped rollback + // already delivers every Conversation over the one room the socket joined. + if (ROOM_PER_CONVERSATION) + socket.on( + SocketEvents.ConversationSubscribe, + (payload: ConversationSubscribePayload) => + void handleConversationSubscribe(socket, deps, author, payload), + ); + socket.on("disconnect", () => markAbsent(deps, tracked)); socket.on(SocketEvents.ChatMessage, (payload: ChatMessagePayload) => @@ -120,6 +134,13 @@ function wireSocket(socket: Socket, deps: ChatHandlerDeps): void { handleAgentSetModel(io, store, pi, payload), ); + wireMemoryAndArtifacts(socket, deps); +} + +/** Wires the memory, artifact, session-status, and reserved terminal listeners. */ +function wireMemoryAndArtifacts(socket: Socket, deps: ChatHandlerDeps): void { + const { store, pi, connectors, memory, onRemembered, emitUiCommand } = deps; + socket.on(SocketEvents.MemoryConfirm, (payload: MemoryConfirmPayload) => handleMemoryConfirm(store, connectors, memory, onRemembered, payload), ); @@ -163,18 +184,19 @@ export function registerChatHandlers(deps: ChatHandlerDeps): void { /** Joins the session room, then replays history and the sub-agent roster. */ async function handleChatJoin( socket: Socket, - store: SessionStore, - pi: PiAgentManager, - connectors: ConnectorRegistry, - triggerEngine: TriggerEngine, + deps: ChatHandlerDeps, + author: ChatAuthor, payload: ChatJoinPayload, ): Promise { + const { store, pi, connectors, triggerEngine } = deps; const session = await store.getSession(payload?.sessionId); if (!session) { socket.emit("error", { message: "Session not found" }); return; } + // The session room still carries session-level events (roster, presence, + // triggers, artifacts); per-Conversation rooms carry the Messages. await socket.join(roomFor(session.id)); // Lazily (re)spawn Prime and revive the session's sub-agents in case the @@ -184,8 +206,11 @@ async function handleChatJoin( // Re-arm the session's schedule triggers (idempotent) and surface the roster. triggerEngine.sync(session.id, session.rootPath); - const history = await store.getMessages(session.id); - socket.emit(SocketEvents.ChatHistory, history); + const authorized = await joinAuthorized(socket, deps, author, session); + socket.emit( + SocketEvents.ChatHistory, + await authorizedHistory(store, session.id, authorized), + ); const roster: SubagentRosterPayload = { sessionId: session.id, @@ -208,6 +233,120 @@ async function handleChatJoin( await replayArtifacts(socket, store, session.id); } +/** + * Adds a socket to a Conversation that appeared after it joined — a newly + * spawned sub-agent the client only learns about from a `subagent:update` — and + * replies with that Conversation's history. Authorization is the same gate the + * join uses. A no-op under the session-scoped rollback, where the one room + * already covers every Conversation. + */ +async function handleConversationSubscribe( + socket: Socket, + deps: ChatHandlerDeps, + author: ChatAuthor, + payload: ConversationSubscribePayload, +): Promise { + const { sessionId, conversationId } = payload ?? EMPTY_SUBSCRIBE; + if (!sessionId || !conversationId) return; + const session = await deps.store.getSession(sessionId); + if (!session) return; + + const authorized = await joinAuthorized(socket, deps, author, session); + if (!authorized.has(conversationId)) return; + + const history: ConversationHistoryPayload = { + conversationId, + messages: await deps.store.getConversationMessages( + session.id, + conversationId, + ), + }; + socket.emit(SocketEvents.ConversationHistory, history); +} + +/** Absent-payload default so the subscribe guard reads without optional chains. */ +const EMPTY_SUBSCRIBE: ConversationSubscribePayload = { + sessionId: "", + conversationId: "", +}; + +/** + * Joins the socket to each Conversation room it is authorized for and returns + * that set, so history can be scoped to the same Conversations. No rooms are + * joined under the session-scoped rollback; the returned set still scopes + * history should it be consulted. + */ +async function joinAuthorized( + socket: Socket, + deps: ChatHandlerDeps, + author: ChatAuthor, + session: Session, +): Promise> { + const agents = await deps.store.listAgents(session.id); + const memberships = + author.kind === "human" + ? await deps.participantService.membershipsOf(session.id, author.id) + : []; + const authorized = authorizedConversations( + author, + session, + agents, + memberships, + ); + if (ROOM_PER_CONVERSATION) + for (const conversationId of authorized) + await socket.join(roomForConversation(session.id, conversationId)); + return authorized; +} + +/** + * The join-time history seed, scoped to the Conversations this socket is + * authorized for. Unfiltered under the rollback, so the session-scoped + * transport still receives the whole transcript. + */ +async function authorizedHistory( + store: SessionStore, + sessionId: string, + authorized: Set, +): Promise { + const history = await store.getMessages(sessionId); + if (!ROOM_PER_CONVERSATION) return history; + return history.filter((message) => authorized.has(message.conversationId)); +} + +/** + * The Conversations a socket may receive Messages for. The session owner (its + * creator, or any human when auth is disabled) sees every Conversation; an + * invited human sees only the ones it holds a Membership in. This is the D1 + * gate: who receives a Message is derived from Membership and ownership, not + * from a client-side render filter over one shared room. + */ +export function authorizedConversations( + author: ChatAuthor, + session: Session, + agents: SessionAgent[], + memberships: Membership[], +): Set { + const conversationIds = new Set(agents.map((agent) => agent.id)); + if (isSessionOwner(author, session)) return conversationIds; + + const held = new Set(); + for (const membership of memberships) + if (conversationIds.has(membership.conversationId)) + held.add(membership.conversationId); + return held; +} + +/** + * Whether this socket's author owns the session. The owner is the creator + * (`session.user`); with auth disabled no creator is resolved, so any human is + * treated as the owner — matching the single-user local default. + */ +function isSessionOwner(author: ChatAuthor, session: Session): boolean { + if (author.kind !== "human") return false; + return session.user ? author.id === session.user.email : true; +} + /** * Who a message in this session can address: Prime plus every sub-agent any * connector holds. Names come from the live roster, so a mention resolves diff --git a/apps/server/src/sockets/rooms.test.ts b/apps/server/src/sockets/rooms.test.ts new file mode 100644 index 0000000..764e300 --- /dev/null +++ b/apps/server/src/sockets/rooms.test.ts @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + messageRoomFor, + roomFor, + roomForConversation, + SESSIONS_LOBBY, +} from "./rooms.ts"; + +test("a session's room and its per-Conversation rooms are distinct", () => { + assert.equal(roomFor("s1"), "session:s1"); + assert.equal(roomForConversation("s1", "prime"), "conv:s1:prime"); + assert.notEqual(roomForConversation("s1", "prime"), roomFor("s1")); + assert.notEqual(SESSIONS_LOBBY, roomFor("s1")); +}); + +test("a Conversation room is scoped by session id", () => { + assert.notEqual( + roomForConversation("s1", "prime"), + roomForConversation("s2", "prime"), + ); +}); + +test("messageRoomFor delivers per Conversation when the flag is on (default)", () => { + assert.equal( + messageRoomFor("s1", "sub-1"), + roomForConversation("s1", "sub-1"), + ); +}); diff --git a/apps/server/src/sockets/rooms.ts b/apps/server/src/sockets/rooms.ts index 124c290..e420d1d 100644 --- a/apps/server/src/sockets/rooms.ts +++ b/apps/server/src/sockets/rooms.ts @@ -1,8 +1,38 @@ +import { ROOM_PER_CONVERSATION } from "../config.ts"; + /** The Socket.IO room every client viewing one session joins. */ export function roomFor(sessionId: string): string { return `session:${sessionId}`; } +/** + * The Socket.IO room a single Conversation's Messages are delivered to. A socket + * joins one per Conversation its Participant is authorized for, so delivery is a + * server-side decision rather than a client-side filter over one shared room. + * Scoped by session id because a Conversation id is only unique within one. + */ +export function roomForConversation( + sessionId: string, + conversationId: string, +): string { + return `conv:${sessionId}:${conversationId}`; +} + +/** + * Where a Message (or an agent's streaming event) for one Conversation is + * broadcast: the per-Conversation room when {@link ROOM_PER_CONVERSATION} is on, + * or the session-wide room when it has been rolled back. Every session-level + * event (roster, presence, triggers, artifacts) always uses {@link roomFor}. + */ +export function messageRoomFor( + sessionId: string, + conversationId: string, +): string { + return ROOM_PER_CONVERSATION + ? roomForConversation(sessionId, conversationId) + : roomFor(sessionId); +} + /** * Shared room every client viewing a session list (the switcher, the sessions * table) joins to receive live run-status updates for all sessions at once, diff --git a/apps/server/src/sockets/roomsRollback.test.ts b/apps/server/src/sockets/roomsRollback.test.ts new file mode 100644 index 0000000..7add2dc --- /dev/null +++ b/apps/server/src/sockets/roomsRollback.test.ts @@ -0,0 +1,12 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +// Node runs each test file in its own process, so setting the rollback flag +// here only affects this file. `rooms.ts` (via config) reads it at import, so it +// must be imported dynamically, after the env is set. +process.env.ROOM_PER_CONVERSATION = "0"; + +test("messageRoomFor falls back to the session room when rolled back", async () => { + const { messageRoomFor, roomFor } = await import("./rooms.ts"); + assert.equal(messageRoomFor("s1", "sub-1"), roomFor("s1")); +}); diff --git a/apps/server/src/store/inMemorySessionStore.ts b/apps/server/src/store/inMemorySessionStore.ts index b637c95..d7a996c 100644 --- a/apps/server/src/store/inMemorySessionStore.ts +++ b/apps/server/src/store/inMemorySessionStore.ts @@ -178,6 +178,15 @@ export class InMemorySessionStore implements SessionStore { return this.messages.get(sessionId) ?? []; } + async getConversationMessages( + sessionId: string, + conversationId: string, + ): Promise { + return (this.messages.get(sessionId) ?? []) + .filter((message) => message.conversationId === conversationId) + .sort((a, b) => a.seq - b.seq || a.id.localeCompare(b.id)); + } + async appendMessage(message: ChatMessage): Promise { const existing = this.messages.get(message.sessionId); if (existing) { diff --git a/apps/server/src/store/sessionStore.ts b/apps/server/src/store/sessionStore.ts index 8010f81..7d771f7 100644 --- a/apps/server/src/store/sessionStore.ts +++ b/apps/server/src/store/sessionStore.ts @@ -140,6 +140,15 @@ export interface SessionStore { deleteSession(id: string): Promise; getMessages(sessionId: string): Promise; + /** + * One Conversation's messages, in `seq` order. The per-Conversation read the + * room-per-Conversation transport uses to seed a single thread's history on + * subscribe, rather than merging the whole session's transcript. + */ + getConversationMessages( + sessionId: string, + conversationId: string, + ): Promise; appendMessage(message: ChatMessage): Promise; /** * Allocates the next `seq` in a Conversation. The single allocator: {@link diff --git a/apps/server/src/store/sqliteSessionStore.ts b/apps/server/src/store/sqliteSessionStore.ts index 981ddbf..10896e0 100644 --- a/apps/server/src/store/sqliteSessionStore.ts +++ b/apps/server/src/store/sqliteSessionStore.ts @@ -24,6 +24,7 @@ import { appendMessage as appendChatMessage, highestSeq, readAllMessages, + readMessages, } from "./chatLog.ts"; import type { Db } from "./db/client.ts"; import { @@ -286,6 +287,15 @@ export class SqliteSessionStore implements SessionStore { return readAllMessages(rootPath); } + async getConversationMessages( + sessionId: string, + conversationId: string, + ): Promise { + const rootPath = await this.rootPathFor(sessionId); + if (!rootPath) return []; + return readMessages(rootPath, conversationId); + } + async appendMessage(message: ChatMessage): Promise { const rootPath = await this.rootPathFor(message.sessionId); if (!rootPath) { diff --git a/apps/web/src/features/chat/components/SessionChat.tsx b/apps/web/src/features/chat/components/SessionChat.tsx index 17f4176..5ef4e4b 100644 --- a/apps/web/src/features/chat/components/SessionChat.tsx +++ b/apps/web/src/features/chat/components/SessionChat.tsx @@ -37,7 +37,7 @@ interface SessionChatProps { export function SessionChat({ sessionId }: SessionChatProps) { const { - messages, + messagesFor, subagents, triggers, artifacts, @@ -97,9 +97,7 @@ export function SessionChat({ sessionId }: SessionChatProps) { activeTab === CHAT_TAB_VALUE ? PI_AGENT.id : activeTab; // The Chat tab is Prime's main thread; each sub-agent has its own thread tab. - const primeMessages = messages.filter( - (m) => m.conversationId === PI_AGENT.id, - ); + const primeMessages = messagesFor(PI_AGENT.id); const busySubagents = subagents .filter((s) => isConversationBusy(s.id)) @@ -135,7 +133,7 @@ export function SessionChat({ sessionId }: SessionChatProps) { sessionId, subagents, triggers, - messages, + messagesFor, currentAuthorId, bundleId, connected, diff --git a/apps/web/src/features/chat/components/tabs/AssetTabContent.tsx b/apps/web/src/features/chat/components/tabs/AssetTabContent.tsx index f6ead92..a2d88b4 100644 --- a/apps/web/src/features/chat/components/tabs/AssetTabContent.tsx +++ b/apps/web/src/features/chat/components/tabs/AssetTabContent.tsx @@ -19,7 +19,7 @@ interface AssetTabContentProps { sessionId: string; subagents: SubagentInfo[]; triggers: Trigger[]; - messages: ChatMessage[]; + messagesFor: (conversationId: string) => ChatMessage[]; currentAuthorId: string; bundleId?: string; connected: boolean; @@ -55,7 +55,7 @@ export function AssetTabContent({ sessionId, subagents, triggers, - messages, + messagesFor, currentAuthorId, bundleId, connected, @@ -82,7 +82,7 @@ export function AssetTabContent({ sessionId={sessionId} agentId={tab.agentId} name={info?.name ?? tab.title} - messages={messages} + messages={messagesFor(tab.agentId)} currentAuthorId={currentAuthorId} bundleId={bundleId} historyLoaded={historyLoaded} diff --git a/apps/web/src/features/chat/components/tabs/SubagentTabView.tsx b/apps/web/src/features/chat/components/tabs/SubagentTabView.tsx index dddcab4..0d6f8b0 100644 --- a/apps/web/src/features/chat/components/tabs/SubagentTabView.tsx +++ b/apps/web/src/features/chat/components/tabs/SubagentTabView.tsx @@ -23,7 +23,7 @@ interface SubagentTabViewProps { agentId: string; /** Display name, used in the stop control's label. */ name: string; - /** All chat messages; filtered to this sub-agent's conversation. */ + /** This sub-agent's conversation messages, already scoped by the server room. */ messages: ChatMessage[]; currentAuthorId: string; /** Bundle this session was created from; enables `tangent-ui:` components. */ @@ -86,13 +86,11 @@ export function SubagentTabView({ pinnedPaths, onTogglePinArtifact, }: SubagentTabViewProps) { - const visibleMessages = messages.filter((m) => m.conversationId === agentId); - return ( ; + +/** Stable empty result so an unknown Conversation doesn't churn renders. */ +const NO_MESSAGES: ChatMessage[] = []; + +/** Within one Conversation, order by `seq`; `id` breaks a tie deterministically. */ +function bySeq(a: ChatMessage, b: ChatMessage): number { + return a.seq - b.seq || a.id.localeCompare(b.id); +} + +/** Groups a flat history into per-Conversation buckets, each sorted by `seq`. */ +function groupByConversation(history: ChatMessage[]): MessageMap { + const map: MessageMap = new Map(); + for (const message of history) { + const bucket = map.get(message.conversationId); + if (bucket) bucket.push(message); + else map.set(message.conversationId, [message]); + } + for (const bucket of map.values()) bucket.sort(bySeq); + return map; +} + +/** Merges one Conversation's history in, deduped by id (incoming wins). */ +function mergeConversation( + prev: MessageMap, + conversationId: string, + incoming: ChatMessage[], +): MessageMap { + const byId = new Map(); + for (const message of prev.get(conversationId) ?? []) + byId.set(message.id, message); + for (const message of incoming) byId.set(message.id, message); + const next = new Map(prev); + next.set(conversationId, [...byId.values()].sort(bySeq)); + return next; +} + +/** Appends a message to its Conversation bucket, returning a new map. */ +function appendToConversation( + prev: MessageMap, + message: ChatMessage, +): MessageMap { + const next = new Map(prev); + const bucket = next.get(message.conversationId) ?? NO_MESSAGES; + next.set(message.conversationId, [...bucket, message]); + return next; +} + +/** Replaces one message by id in a Conversation bucket via an updater. */ +function editInConversation( + prev: MessageMap, + conversationId: string, + messageId: string, + update: (message: ChatMessage) => ChatMessage, +): MessageMap { + const bucket = prev.get(conversationId); + if (!bucket) return prev; + const next = new Map(prev); + next.set( + conversationId, + bucket.map((m) => (m.id === messageId ? update(m) : m)), + ); + return next; +} + /** * Manages a single Socket.IO connection for one session's chat room. * @@ -85,7 +153,12 @@ export interface AgentModelSelection { * a given room. */ export function useSessionChat(sessionId: string) { - const [messages, setMessages] = useState([]); + // Messages bucketed per Conversation. The server delivers each Conversation's + // Messages to a room the socket joins only if authorized, so a bucket exists + // only for a Conversation this client may see — the client no longer filters + // one shared stream. + const [messagesByConversation, setMessagesByConversation] = + useState(() => new Map()); const [subagents, setSubagents] = useState([]); // Per-agent model/thinking selection, keyed by agent id (`"prime"` or a // sub-agent id). Seeded from the roster (sub-agents) and the `agent:model` @@ -118,8 +191,14 @@ export function useSessionChat(sessionId: string) { Map >(() => new Map()); const socketRef = useRef(null); - // Maps an in-flight message id to its conversation so `agent:error` (which - // only carries a messageId) can clear the right thread's streaming state. + // Conversations this socket has already been subscribed to since the last + // connect (seeded from the join-time roster). A sub-agent that spawns later + // arrives as a `subagent:update` for an unseen id, which triggers a + // `conversation:subscribe` so its room and history are joined on demand. + const subscribedConversations = useRef>(new Set()); + // Maps an in-flight message id to its conversation so `agent:delta` / + // `agent:thinking` / `agent:error` (which only carry a messageId) can reach + // the right thread's bucket and streaming state. const conversationByMessageId = useRef>(new Map()); // The run each conversation is currently working under, so `abort` can name // the run it means. Refs, not state: nothing renders from either, and the @@ -199,7 +278,7 @@ export function useSessionChat(sessionId: string) { // Reset on (re)connect rather than synchronously in the effect body so we // don't trigger cascading renders; history and roster repopulate via the // ChatHistory and SubagentRoster events the server sends on join. - setMessages([]); + setMessagesByConversation(new Map()); setHistoryLoaded(false); setSubagents([]); setModelByAgent(new Map()); @@ -213,6 +292,9 @@ export function useSessionChat(sessionId: string) { conversationByMessageId.current.clear(); runIdByConversation.current.clear(); streamingRuns.current.clear(); + // Prime is joined server-side at chat:join; seed it so a later update for + // it doesn't re-subscribe redundantly. + subscribedConversations.current = new Set([PI_AGENT.id]); // Reset the published statuses; the join snapshot (roster + replayed // activity) republishes them. Prime is present immediately. streaming.clear(); @@ -231,6 +313,7 @@ export function useSessionChat(sessionId: string) { conversationByMessageId.current.clear(); runIdByConversation.current.clear(); streamingRuns.current.clear(); + subscribedConversations.current.clear(); // Nothing is running while disconnected; clear the busy inputs and // republish every known agent as idle (keeping their lifecycle status). streaming.clear(); @@ -238,12 +321,23 @@ export function useSessionChat(sessionId: string) { publishAll(); }); + // Join-time seed of every authorized Conversation, grouped into buckets. socket.on(SocketEvents.ChatHistory, (history: ChatMessage[]) => { - setMessages(history); + setMessagesByConversation(groupByConversation(history)); setHistoryLoaded(true); }); + // A Conversation subscribed to after join (a newly spawned sub-agent): + // merge its log into its bucket without disturbing the others. + socket.on( + SocketEvents.ConversationHistory, + ({ conversationId, messages }: ConversationHistoryPayload) => { + setMessagesByConversation((prev) => + mergeConversation(prev, conversationId, messages), + ); + }, + ); socket.on(SocketEvents.ChatMessage, (message: ChatMessage) => { - setMessages((prev) => [...prev, message]); + setMessagesByConversation((prev) => appendToConversation(prev, message)); }); // An agent begins a (new) message: append an empty placeholder we fill via @@ -266,19 +360,25 @@ export function useSessionChat(sessionId: string) { next.add(message.id); return next; }); - setMessages((prev) => [...prev, message]); + setMessagesByConversation((prev) => + appendToConversation(prev, message), + ); streaming.add(message.conversationId); publish(message.conversationId); }, ); - // Streamed token: append it to the matching in-flight message. + // Streamed token: append it to the matching in-flight message, routed to its + // Conversation bucket via the id-to-conversation map set at `agent:start`. socket.on( SocketEvents.AgentDelta, ({ messageId, delta }: AgentDeltaPayload) => { - setMessages((prev) => - prev.map((m) => - m.id === messageId ? { ...m, content: m.content + delta } : m, - ), + const conversationId = conversationByMessageId.current.get(messageId); + if (!conversationId) return; + setMessagesByConversation((prev) => + editInConversation(prev, conversationId, messageId, (m) => ({ + ...m, + content: m.content + delta, + })), ); }, ); @@ -286,12 +386,13 @@ export function useSessionChat(sessionId: string) { socket.on( SocketEvents.AgentThinking, ({ messageId, delta }: AgentThinkingPayload) => { - setMessages((prev) => - prev.map((m) => - m.id === messageId - ? { ...m, thinking: (m.thinking ?? "") + delta } - : m, - ), + const conversationId = conversationByMessageId.current.get(messageId); + if (!conversationId) return; + setMessagesByConversation((prev) => + editInConversation(prev, conversationId, messageId, (m) => ({ + ...m, + thinking: (m.thinking ?? "") + delta, + })), ); }, ); @@ -313,8 +414,13 @@ export function useSessionChat(sessionId: string) { next.delete(message.id); return next; }); - setMessages((prev) => - prev.map((m) => (m.id === message.id ? message : m)), + setMessagesByConversation((prev) => + editInConversation( + prev, + message.conversationId, + message.id, + () => message, + ), ); streaming.delete(message.conversationId); publish(message.conversationId); @@ -389,6 +495,9 @@ export function useSessionChat(sessionId: string) { }); for (const s of roster) { statuses.set(s.id, s.status); + // Joined server-side at chat:join; record it so a later update for it + // does not re-subscribe. + subscribedConversations.current.add(s.id); publish(s.id); } }, @@ -397,6 +506,16 @@ export function useSessionChat(sessionId: string) { socket.on( SocketEvents.SubagentUpdate, ({ subagent }: SubagentUpdatePayload) => { + // A sub-agent this client hasn't been subscribed to yet spawned after + // join: subscribe so its per-Conversation room and history are joined. + if (!subscribedConversations.current.has(subagent.id)) { + subscribedConversations.current.add(subagent.id); + const payload: ConversationSubscribePayload = { + sessionId, + conversationId: subagent.id, + }; + socket.emit(SocketEvents.ConversationSubscribe, payload); + } setSubagents((prev) => { const next = prev.filter((s) => s.id !== subagent.id); next.push(subagent); @@ -600,6 +719,12 @@ export function useSessionChat(sessionId: string) { return streamingMessageIds.has(messageId); } + // One Conversation's messages, in `seq` order, or an empty list when this + // client holds no bucket for it (never subscribed / not authorized). + function messagesFor(conversationId: string): ChatMessage[] { + return messagesByConversation.get(conversationId) ?? NO_MESSAGES; + } + // Removes a sub-agent from the local roster (e.g. dismissing a killed agent // from the sidebar). The server still tracks it, so it reappears on the next // `subagent:roster` snapshot after a reconnect. @@ -608,7 +733,7 @@ export function useSessionChat(sessionId: string) { } return { - messages, + messagesFor, subagents, triggers, artifacts, diff --git a/packages/shared/src/contracts.ts b/packages/shared/src/contracts.ts index 9218734..0301cc6 100644 --- a/packages/shared/src/contracts.ts +++ b/packages/shared/src/contracts.ts @@ -936,6 +936,30 @@ export interface ChatJoinPayload { sessionId: string; } +/** + * Payload sent by the client to subscribe to one Conversation that appeared + * after it joined (a newly spawned sub-agent). The server authorizes the + * subscription against Membership (or session ownership), joins the socket to + * that Conversation's room, and replies with its history. Conversations present + * at join time are subscribed server-side, so the client only sends this for + * ones it learns about later. + */ +export interface ConversationSubscribePayload { + sessionId: string; + conversationId: string; +} + +/** + * One Conversation's history, sent in reply to a {@link + * ConversationSubscribePayload}. Distinct from `chat:history` (the join-time + * bulk seed of every authorized Conversation) so a late subscription merges one + * thread's log without disturbing the rest. + */ +export interface ConversationHistoryPayload { + conversationId: string; + messages: ChatMessage[]; +} + /** * How a chat message is delivered when its target agent is mid-run: * - `"auto"`: normal prompt (queued by Pi as a follow-up only if busy). @@ -1234,6 +1258,8 @@ export const SocketEvents = { ChatJoin: "chat:join", ChatHistory: "chat:history", ChatMessage: "chat:message", + ConversationSubscribe: "conversation:subscribe", + ConversationHistory: "conversation:history", TerminalData: "terminal:data", AgentStart: "agent:start", AgentDelta: "agent:delta", From 8072ffd4d1f18a7ed6b04365a86d9df3d70483c6 Mon Sep 17 00:00:00 2001 From: Maksym Yezhov Date: Thu, 13 Aug 2026 16:05:48 -0700 Subject: [PATCH 14/18] - refactor: Conversation id decoupled from agent id --- apps/server/src/a2a/a2aPeerGateway.test.ts | 1 + apps/server/src/a2a/a2aPeerGateway.ts | 17 +- .../src/connectors/a2aConnector.test.ts | 1 + .../src/connectors/connectorRegistry.test.ts | 4 + apps/server/src/connectors/refusal.ts | 2 +- apps/server/src/connectors/types.ts | 7 + .../src/conversation/conversationRouter.ts | 24 +- apps/server/src/conversation/fanOut.ts | 1 + .../conversation/membershipRegistry.test.ts | 9 + .../src/conversation/membershipRegistry.ts | 94 ++- .../conversation/participantRegistry.test.ts | 45 ++ .../src/conversation/participantRegistry.ts | 56 ++ .../conversation/participantService.test.ts | 9 +- .../src/conversation/participantService.ts | 14 +- .../external/externalSubagentGateway.test.ts | 1 + .../src/external/externalSubagentGateway.ts | 15 +- apps/server/src/mcp/relayReport.test.ts | 2 + apps/server/src/mcp/relayReport.ts | 11 +- apps/server/src/pi/piAgentManager.test.ts | 1 + apps/server/src/pi/piAgentManager.ts | 39 +- apps/server/src/pi/triggers/triggerEngine.ts | 31 +- apps/server/src/pi/types.ts | 13 + apps/server/src/pi/utils.ts | 8 +- .../src/remote/remoteEnvironmentGateway.ts | 23 +- apps/server/src/routes/internalAgents.ts | 23 +- apps/server/src/routes/sessions/handlers.ts | 33 +- apps/server/src/runs/runRegistry.ts | 6 +- apps/server/src/sockets/agentEvents.ts | 11 +- apps/server/src/sockets/chat.test.ts | 1 + apps/server/src/sockets/chat.ts | 54 +- apps/server/src/sockets/chatMemory.ts | 7 +- apps/server/src/sockets/sessionRoster.ts | 10 +- apps/server/src/store/chatLog.ts | 3 +- .../store/db/migrations/0013_chief_veda.sql | 3 + .../db/migrations/meta/0013_snapshot.json | 759 ++++++++++++++++++ .../store/db/migrations/meta/_journal.json | 7 + apps/server/src/store/db/schema.ts | 16 +- apps/server/src/store/inMemorySessionStore.ts | 35 +- apps/server/src/store/sessionStore.ts | 15 + .../src/store/sqliteSessionStore.test.ts | 114 +++ apps/server/src/store/sqliteSessionStore.ts | 152 +++- .../chat/components/PrimeChatPanel.tsx | 12 +- .../features/chat/components/SessionChat.tsx | 17 +- .../composer/ActiveTasksIndicator.tsx | 4 +- .../chat/components/message/ChatMessage.tsx | 5 +- .../components/message/ChatMessageList.tsx | 7 + .../chat/components/message/messageOrigin.ts | 11 +- .../chat/components/tabs/AssetTabContent.tsx | 17 +- .../chat/components/tabs/SubagentTabView.tsx | 6 +- .../src/features/chat/hooks/useSessionChat.ts | 101 ++- apps/web/src/features/chat/model/agents.ts | 6 +- packages/shared/src/contracts.ts | 20 +- 52 files changed, 1709 insertions(+), 174 deletions(-) create mode 100644 apps/server/src/store/db/migrations/0013_chief_veda.sql create mode 100644 apps/server/src/store/db/migrations/meta/0013_snapshot.json diff --git a/apps/server/src/a2a/a2aPeerGateway.test.ts b/apps/server/src/a2a/a2aPeerGateway.test.ts index 5d27528..eec4151 100644 --- a/apps/server/src/a2a/a2aPeerGateway.test.ts +++ b/apps/server/src/a2a/a2aPeerGateway.test.ts @@ -160,6 +160,7 @@ function agentRow(overrides: Partial = {}): SessionAgent { credentialScheme: "peer-bearer", endpointUrl: ENDPOINT, }, + homeConversationId: "peer-1", createdAt: "2026-01-01T00:00:00.000Z", ...overrides, }; diff --git a/apps/server/src/a2a/a2aPeerGateway.ts b/apps/server/src/a2a/a2aPeerGateway.ts index dcb3f7e..682efd1 100644 --- a/apps/server/src/a2a/a2aPeerGateway.ts +++ b/apps/server/src/a2a/a2aPeerGateway.ts @@ -51,6 +51,8 @@ export interface SendToPeer { /** An attached peer's tab, plus what its in-flight turn needs. */ interface A2aTab { agentId: string; + /** The Conversation this peer's tab lives in, distinct from its agent id. */ + homeConversationId: string; name: string; status: SubagentStatus; endpointUrl: string; @@ -78,6 +80,7 @@ interface Turn { function toInfo(tab: A2aTab): SubagentInfo { return { id: tab.agentId, + conversationId: tab.homeConversationId, name: tab.name, status: tab.status, connector: { ...connectorFor("a2a"), endpointUrl: tab.endpointUrl }, @@ -160,6 +163,7 @@ export class A2aPeerGateway { const peer = await this.connect(spec.endpointUrl, headers); const tab: A2aTab = { agentId: randomUUID(), + homeConversationId: randomUUID(), name: spec.name?.trim() || peer.card.name, status: "active", endpointUrl: spec.endpointUrl, @@ -174,6 +178,7 @@ export class A2aPeerGateway { purpose: peer.card.description, status: tab.status, connector: { ...connectorFor("a2a"), endpointUrl: tab.endpointUrl }, + homeConversationId: tab.homeConversationId, }); const info = toInfo(tab); this.handlers.onSubagentUpdate(sessionId, info); @@ -195,6 +200,7 @@ export class A2aPeerGateway { const tab: A2aTab = { agentId: agent.id, + homeConversationId: agent.homeConversationId, name: agent.name, status: "detached", endpointUrl, @@ -298,6 +304,7 @@ export class A2aPeerGateway { run: this.runs.open({ sessionId: input.sessionId, participantId: tab.agentId, + homeConversationId: tab.homeConversationId, ingress: input.ingress ?? "reaction", externalId: tab.taskId, }), @@ -460,9 +467,10 @@ export class A2aPeerGateway { /** Says in the peer's own thread why a message went nowhere. */ private refuse(input: SendToPeer, reason: string): void { + const tab = this.tabFor(input.sessionId, input.participantId); this.handlers.onAgentMessage({ sessionId: input.sessionId, - conversationId: input.participantId, + conversationId: tab?.homeConversationId ?? input.participantId, author: SYSTEM_AUTHOR, content: reason, }); @@ -507,5 +515,10 @@ export class A2aPeerGateway { /** Builds the agent descriptor a relayed event is tagged with. */ function descriptorFor(tab: A2aTab): AgentDescriptor { - return { agentId: tab.agentId, role: "subagent", name: tab.name }; + return { + agentId: tab.agentId, + role: "subagent", + name: tab.name, + homeConversationId: tab.homeConversationId, + }; } diff --git a/apps/server/src/connectors/a2aConnector.test.ts b/apps/server/src/connectors/a2aConnector.test.ts index 005b22a..7b37b79 100644 --- a/apps/server/src/connectors/a2aConnector.test.ts +++ b/apps/server/src/connectors/a2aConnector.test.ts @@ -70,6 +70,7 @@ function agentRow(): SessionAgent { credentialScheme: "peer-bearer", endpointUrl: "https://agent.example.com", }, + homeConversationId: "peer-1", createdAt: "2026-01-01T00:00:00.000Z", }; } diff --git a/apps/server/src/connectors/connectorRegistry.test.ts b/apps/server/src/connectors/connectorRegistry.test.ts index 23a7aad..27be67e 100644 --- a/apps/server/src/connectors/connectorRegistry.test.ts +++ b/apps/server/src/connectors/connectorRegistry.test.ts @@ -42,6 +42,7 @@ function rosterEntry( ): SubagentInfo { return { id, + conversationId: id, name: id, status: "active", ...connectorFields(kind), @@ -63,6 +64,7 @@ function agentRow( capabilities: capabilitiesForRole(overrides.role ?? "subagent"), status: "detached", connector, + homeConversationId: id, createdAt: "2026-01-01T00:00:00.000Z", ...overrides, }; @@ -387,6 +389,7 @@ test("revive routes each persisted row to the connector that recorded it", () => assert.deepEqual(h.externalGateway.listSubagents("s1"), [ { id: "ext-1", + conversationId: "ext-1", name: "ext-1", status: "detached", ...connectorFields("external-inbound"), @@ -425,6 +428,7 @@ test("an attached row is restored too: what a revive means is the connector's ca assert.deepEqual(h.a2aGateway.listSubagents("s1"), [ { id: "peer-1", + conversationId: "peer-1", name: "peer-1", status: "detached", connector: { diff --git a/apps/server/src/connectors/refusal.ts b/apps/server/src/connectors/refusal.ts index df796a0..4bb279e 100644 --- a/apps/server/src/connectors/refusal.ts +++ b/apps/server/src/connectors/refusal.ts @@ -15,7 +15,7 @@ export function refuseDelivery( ): DeliveryResult { handlers.onAgentMessage({ sessionId: request.sessionId, - conversationId: request.participantId, + conversationId: request.conversationId ?? request.participantId, author: SYSTEM_AUTHOR, content: reason, }); diff --git a/apps/server/src/connectors/types.ts b/apps/server/src/connectors/types.ts index 3f4f6e3..a5583ce 100644 --- a/apps/server/src/connectors/types.ts +++ b/apps/server/src/connectors/types.ts @@ -20,6 +20,13 @@ export interface DeliveryRequest { sessionId: string; participantId: string; text: string; + /** + * The Conversation this delivery is on behalf of — where a refusal surfaces + * when the transport cannot carry it. Set by the fan-out engine to the + * Conversation the reaction is happening in; a direct delivery that omits it + * falls back to the participant's own id (its legacy home). + */ + conversationId?: string; delivery?: MessageDelivery; /** * What this delivery counts as when it starts a Run. Defaults to `reaction`, diff --git a/apps/server/src/conversation/conversationRouter.ts b/apps/server/src/conversation/conversationRouter.ts index 9d4f4b6..e5f975a 100644 --- a/apps/server/src/conversation/conversationRouter.ts +++ b/apps/server/src/conversation/conversationRouter.ts @@ -21,6 +21,7 @@ import type { Membership } from "../store/membershipStore.ts"; import type { SessionStore } from "../store/sessionStore.ts"; import { FanOutEngine, type FanOutResult } from "./fanOut.ts"; import type { MembershipRegistry } from "./membershipRegistry.ts"; +import { participantForConversation } from "./participantRegistry.ts"; /** * Everything a Message needs beyond its envelope defaults. `seq` comes from the @@ -150,18 +151,20 @@ function frameFor(message: ChatMessage, recipient: Membership): string { } /** - * The text one recipient's transport receives for a Message. A participant - * reading its own Conversation gets the content as written; one woken from - * another Conversation gets the provenance framing that used to be baked into a - * wrapped relay string. Framing is a projection, so what is persisted stays the - * author's own words. + * The text one recipient's transport receives for a Message. The participant + * that owns the Conversation (its subject) gets the content as written; one + * woken from another Conversation gets the provenance framing that used to be + * baked into a wrapped relay string. Ownership is passed in rather than inferred + * from the id, now that a Conversation id no longer equals its owner's id. + * Framing is a projection, so what is persisted stays the author's own words. */ export function deliveryText( message: ChatMessage, recipient: Membership, + ownerParticipantId: string, ): string { const body = withAttachments(message.content, message.attachments); - if (message.conversationId === recipient.participantId) return body; + if (recipient.participantId === ownerParticipantId) return body; return `${frameFor(message, recipient)}\n\n${body}`; } @@ -221,11 +224,18 @@ export class ConversationRouter { return { message, woke: [], refused: [] }; } + // The subject of this Conversation reads it plain; everyone else woken from + // it gets provenance framing. Resolve the owner once for the whole fan-out. + const owner = await participantForConversation( + this.store, + message.sessionId, + message.conversationId, + ); const outcome = await this.engine.fanOut({ message, ingress: input.ingress, delivery: input.delivery, - project: deliveryText, + project: (msg, recipient) => deliveryText(msg, recipient, owner), }); return { message, ...outcome }; } diff --git a/apps/server/src/conversation/fanOut.ts b/apps/server/src/conversation/fanOut.ts index 46e40da..c5d73f7 100644 --- a/apps/server/src/conversation/fanOut.ts +++ b/apps/server/src/conversation/fanOut.ts @@ -199,6 +199,7 @@ export class FanOutEngine { .deliver({ sessionId: message.sessionId, participantId: member.participantId, + conversationId: message.conversationId, text: request.project(message, member), ingress: request.ingress ?? member.ingress, delivery: request.delivery, diff --git a/apps/server/src/conversation/membershipRegistry.test.ts b/apps/server/src/conversation/membershipRegistry.test.ts index 0866896..8a29de5 100644 --- a/apps/server/src/conversation/membershipRegistry.test.ts +++ b/apps/server/src/conversation/membershipRegistry.test.ts @@ -41,6 +41,7 @@ test("an auto-relaying sub-agent's conversation puts Prime on its run ends", asy name: "Worker", status: "active", autoRelayToPrime: true, + homeConversationId: "sub-1", connector: connectorFor("pi-stdio"), }); @@ -60,6 +61,7 @@ test("a sub-agent that does not auto-relay is reachable only by being addressed" name: "Worker", status: "active", autoRelayToPrime: false, + homeConversationId: "sub-1", connector: connectorFor("pi-stdio"), }); @@ -78,6 +80,7 @@ test("a participant nothing can deliver to declares that it never reacts", async role: "subagent", name: "External", status: "active", + homeConversationId: "tab-1", connector: connectorFor("external-inbound"), }); @@ -97,6 +100,7 @@ test("an external worker is addressable but sees none of the transcript", async role: "subagent", name: "External", status: "active", + homeConversationId: "tab-1", connector: connectorFor("external-inbound"), }); @@ -118,6 +122,7 @@ test("an A2A peer is addressable but sees none of the transcript", async () => { role: "subagent", name: "Weather", status: "active", + homeConversationId: "peer-1", connector: connectorFor("a2a"), }); @@ -157,6 +162,7 @@ test("a derived conversation is persisted, so it is derived once", async () => { name: "Worker", status: "active", autoRelayToPrime: false, + homeConversationId: "sub-1", connector: connectorFor("pi-stdio"), }); @@ -179,6 +185,7 @@ test("a stored membership wins over what the roster would derive", async () => { name: "Worker", status: "active", autoRelayToPrime: true, + homeConversationId: "sub-1", connector: connectorFor("pi-stdio"), }); await h.store.put({ @@ -203,6 +210,7 @@ test("membership answers whether one participant stands in a conversation", asyn name: "Worker", status: "active", autoRelayToPrime: true, + homeConversationId: "sub-1", connector: connectorFor("pi-stdio"), }); @@ -230,6 +238,7 @@ test("a spawn whose row has not landed yet still resolves, without being kept", name: "Worker", status: "active", autoRelayToPrime: false, + homeConversationId: "sub-1", connector: connectorFor("pi-stdio"), }); diff --git a/apps/server/src/conversation/membershipRegistry.ts b/apps/server/src/conversation/membershipRegistry.ts index 38f627d..c0f4b4a 100644 --- a/apps/server/src/conversation/membershipRegistry.ts +++ b/apps/server/src/conversation/membershipRegistry.ts @@ -36,18 +36,6 @@ const ON_REQUEST = reactionSpec("mentionsMe"); /** A member that has declared it does not act — a display-only external tab. */ const INERT = reactionSpec("never"); -/** - * The id of the roster row holding the `orchestrator` capability, or the - * well-known default when a session has none resolved — the successor to - * treating `PRIME_AGENT_ID` as a reserved id. - */ -function orchestratorFrom(agents: SessionAgent[]): string { - const holder = agents.find((agent) => - agent.capabilities.includes("orchestrator"), - ); - return holder?.id ?? PRIME_AGENT_ID; -} - function membership( sessionId: string, participantId: string, @@ -103,22 +91,42 @@ export class MembershipRegistry { const known = byConversation.get(conversationId); if (known) return known; - const agents = await this.sessions.listAgents(sessionId); - const agent = agents.find((candidate) => candidate.id === conversationId); - const orchestratorId = orchestratorFrom(agents); - const derived = this.derive( - sessionId, - conversationId, - agent, - orchestratorId, - ); - if (!agent && conversationId !== orchestratorId) return derived; + const roster = await this.roster(sessionId, conversationId); + const derived = this.derive(sessionId, conversationId, roster); + // A conversation with no owner that is not the orchestrator's own is a spawn + // whose row has not landed: return provisionally, neither cached nor stored. + if (!roster.owner && conversationId !== roster.orchestratorHome) + return derived; byConversation.set(conversationId, derived); for (const row of derived) await this.store.put(row); return derived; } + /** + * Resolves a Conversation's owner and the orchestrator's identity/home from + * the roster. The owner is found by its `homeConversationId`, not by an id + * that equals the Conversation — the two are no longer the same string. + */ + private async roster( + sessionId: string, + conversationId: string, + ): Promise { + const agents = await this.sessions.listAgents(sessionId); + const owner = agents.find( + (candidate) => candidate.homeConversationId === conversationId, + ); + const orchestrator = agents.find((agent) => + agent.capabilities.includes("orchestrator"), + ); + const orchestratorId = orchestrator?.id ?? PRIME_AGENT_ID; + return { + owner, + orchestratorId, + orchestratorHome: orchestrator?.homeConversationId ?? orchestratorId, + }; + } + /** * The standing one participant holds in a Conversation, or nothing when it * holds none. This is the check delivery already makes, read in the other @@ -166,18 +174,17 @@ export class MembershipRegistry { private derive( sessionId: string, conversationId: string, - agent: SessionAgent | undefined, - orchestratorId: string, + { owner, orchestratorId, orchestratorHome }: RosterContext, ): Membership[] { - if (conversationId === orchestratorId) { + if (conversationId === orchestratorHome) { return [ - membership(sessionId, orchestratorId, orchestratorId, ADDRESSABLE), + membership(sessionId, orchestratorId, conversationId, ADDRESSABLE), ]; } - const relays = agent?.autoRelayToPrime ?? true; + const relays = owner?.autoRelayToPrime ?? true; return [ - this.subject(sessionId, conversationId, agent), + this.subject(sessionId, conversationId, owner), membership( sessionId, orchestratorId, @@ -199,24 +206,29 @@ export class MembershipRegistry { private subject( sessionId: string, conversationId: string, - agent: SessionAgent | undefined, + owner: SessionAgent | undefined, ): Membership { - const kind = agent?.connector.kind; - if (kind && !this.acceptsDelivery(kind)) { - return membership( - sessionId, - conversationId, - conversationId, - INERT, - "opaque", - ); - } + // Legacy fallback: a Conversation the mapping never covered is keyed by its + // owner's id, so the owner is the conversation id itself. + if (!owner) + return membership(sessionId, conversationId, conversationId, ADDRESSABLE); + + const kind = owner.connector.kind; + if (!this.acceptsDelivery(kind)) + return membership(sessionId, owner.id, conversationId, INERT, "opaque"); return membership( sessionId, - conversationId, + owner.id, conversationId, ADDRESSABLE, - kind ? DEFAULT_TRANSCRIPT_VISIBILITY[kind] : "shared", + DEFAULT_TRANSCRIPT_VISIBILITY[kind], ); } } + +/** The roster facts a Conversation's memberships are derived from. */ +interface RosterContext { + owner: SessionAgent | undefined; + orchestratorId: string; + orchestratorHome: string; +} diff --git a/apps/server/src/conversation/participantRegistry.test.ts b/apps/server/src/conversation/participantRegistry.test.ts index 9a7a3d3..7ab0226 100644 --- a/apps/server/src/conversation/participantRegistry.test.ts +++ b/apps/server/src/conversation/participantRegistry.test.ts @@ -7,7 +7,10 @@ import { InMemoryParticipantStore } from "../store/inMemoryParticipantStore.ts"; import { InMemorySessionStore } from "../store/inMemorySessionStore.ts"; import type { Participant } from "../store/participantStore.ts"; import { + homeConversationFor, + orchestratorConversationFor, orchestratorIdFor, + participantForConversation, ParticipantRegistry, } from "./participantRegistry.ts"; @@ -106,6 +109,48 @@ test("the current roster row wins over a stale stored participant", async () => assert.equal((await registry.get("s1", "prime"))?.displayName, "Prime"); }); +test("home conversation resolves and reverses through the mapping", async () => { + const { sessions } = makeRegistry(); + const recorded = await sessions.recordAgent("s1", { + id: "sub-1", + role: "subagent", + name: "Worker", + }); + + const home = await homeConversationFor(sessions, "s1", "sub-1"); + assert.equal(home, recorded.homeConversationId); + assert.notEqual(home, "sub-1", "a new agent's conversation is a fresh id"); + assert.equal( + await participantForConversation(sessions, "s1", home), + "sub-1", + "the reverse map recovers the owning participant", + ); +}); + +test("resolution falls back to the id for an unmapped agent or conversation", async () => { + const { sessions } = makeRegistry(); + assert.equal(await homeConversationFor(sessions, "s1", "ghost"), "ghost"); + assert.equal( + await participantForConversation(sessions, "s1", "ghost"), + "ghost", + ); +}); + +test("orchestratorConversationFor resolves the capability holder's home", async () => { + const { sessions } = makeRegistry(); + const prime = await sessions.recordAgent("s1", { + id: "prime", + role: "prime", + name: "Prime", + }); + assert.equal( + await orchestratorConversationFor(sessions, "s1"), + prime.homeConversationId, + ); + // An empty session has no orchestrator to resolve, so it falls back. + assert.equal(await orchestratorConversationFor(sessions, "empty"), "prime"); +}); + test("recording an agent dual-writes the participant projection", async () => { const store = new InMemoryParticipantStore(); const sessions = new InMemorySessionStore(store); diff --git a/apps/server/src/conversation/participantRegistry.ts b/apps/server/src/conversation/participantRegistry.ts index 5675174..ef4b53c 100644 --- a/apps/server/src/conversation/participantRegistry.ts +++ b/apps/server/src/conversation/participantRegistry.ts @@ -24,6 +24,54 @@ export async function orchestratorIdFor( return holder?.id ?? PRIME_AGENT_ID; } +/** + * The Conversation an agent posts into — the mapping that lets a Conversation id + * stop naming an agent. Falls back to the agent's own id for a legacy agent the + * `conversations` table never mapped, whose transcript is keyed that way. + */ +export async function homeConversationFor( + sessions: Pick, + sessionId: string, + agentId: string, +): Promise { + const agents = await sessions.listAgents(sessionId); + const agent = agents.find((candidate) => candidate.id === agentId); + return agent?.homeConversationId ?? agentId; +} + +/** + * The participant that owns a Conversation as its home thread — the reverse of + * {@link homeConversationFor}, for cancel/abort and delivery framing. Falls back + * to the Conversation id itself for a legacy thread the mapping never covered. + */ +export async function participantForConversation( + sessions: Pick, + sessionId: string, + conversationId: string, +): Promise { + const agents = await sessions.listAgents(sessionId); + const owner = agents.find( + (candidate) => candidate.homeConversationId === conversationId, + ); + return owner?.id ?? conversationId; +} + +/** + * The orchestrator's home Conversation — where a message addressed to "Prime" + * lands. The successor to using the orchestrator's participant id as a + * conversation id, now that the two are distinct. + */ +export async function orchestratorConversationFor( + sessions: Pick, + sessionId: string, +): Promise { + const agents = await sessions.listAgents(sessionId); + const holder = agents.find((agent) => + agent.capabilities.includes("orchestrator"), + ); + return holder?.homeConversationId ?? holder?.id ?? PRIME_AGENT_ID; +} + /** * The session's Participants. Reads through to a {@link ParticipantStore}, but * the roster (`session_agents`) stays the write authority for this PR: for every @@ -81,6 +129,14 @@ export class ParticipantRegistry { return holder?.id ?? PRIME_AGENT_ID; } + /** + * The participant that owns a Conversation as its home thread — for cancel and + * close, which act on the participant, not the Conversation id. + */ + async ownerOf(sessionId: string, conversationId: string): Promise { + return participantForConversation(this.sessions, sessionId, conversationId); + } + /** Loads a session's participants once, reconciling them against the roster. */ private async load(sessionId: string): Promise> { const cached = this.cache.get(sessionId); diff --git a/apps/server/src/conversation/participantService.test.ts b/apps/server/src/conversation/participantService.test.ts index 4753dc1..4caee49 100644 --- a/apps/server/src/conversation/participantService.test.ts +++ b/apps/server/src/conversation/participantService.test.ts @@ -93,14 +93,17 @@ test("invite creates an away human keyed by email, with memberships", async () = }); test("inviting into Prime's conversation keeps Prime a member", async () => { - const { service, memberships, session } = await harness(); + const { service, memberships, sessions, session } = await harness(); + // Prime's home Conversation is a minted id now, not the reserved "prime". + const [prime] = await sessions.listAgents(session.id); + const primeConversationId = prime.homeConversationId; await service.invite(session.id, { email: "a@shopify.com", - conversationIds: ["prime"], + conversationIds: [primeConversationId], }); - const members = await memberships.membersOf(session.id, "prime"); + const members = await memberships.membersOf(session.id, primeConversationId); const ids = members.map((m) => m.participantId).sort(); assert.deepEqual(ids, ["a@shopify.com", "prime"]); }); diff --git a/apps/server/src/conversation/participantService.ts b/apps/server/src/conversation/participantService.ts index 6185512..6507334 100644 --- a/apps/server/src/conversation/participantService.ts +++ b/apps/server/src/conversation/participantService.ts @@ -209,9 +209,9 @@ export class ParticipantService { } /** - * Closes a Conversation: ends every Membership and settles its open Runs. - * Identity is still one string this PR, so the subject Participant's id is the - * Conversation id — its open Run is the Conversation's. + * Closes a Conversation: ends every Membership and settles its open Runs. The + * open Run belongs to the Conversation's subject participant, which is no + * longer the Conversation id itself — so reverse-map before cancelling. */ async closeConversation( sessionId: string, @@ -227,8 +227,12 @@ export class ParticipantService { conversationId, member.participantId, ); - this.connectors.cancelRun({ sessionId, participantId: conversationId }); - this.runs.settleOpenFor(sessionId, conversationId, "cancelled"); + const owner = await this.participantRegistry.ownerOf( + sessionId, + conversationId, + ); + this.connectors.cancelRun({ sessionId, participantId: owner }); + this.runs.settleOpenFor(sessionId, owner, "cancelled"); this.membershipRegistry.invalidate(sessionId); } diff --git a/apps/server/src/external/externalSubagentGateway.test.ts b/apps/server/src/external/externalSubagentGateway.test.ts index 65fe0a0..4fab62c 100644 --- a/apps/server/src/external/externalSubagentGateway.test.ts +++ b/apps/server/src/external/externalSubagentGateway.test.ts @@ -54,6 +54,7 @@ function agentRow(id: string, overrides: Partial = {}) { spawnAuthority: "bundle-tool", credentialScheme: "internal-bearer", }, + homeConversationId: id, createdAt: "2026-01-01T00:00:00.000Z", ...overrides, } satisfies SessionAgent; diff --git a/apps/server/src/external/externalSubagentGateway.ts b/apps/server/src/external/externalSubagentGateway.ts index f47ed08..d9c1db7 100644 --- a/apps/server/src/external/externalSubagentGateway.ts +++ b/apps/server/src/external/externalSubagentGateway.ts @@ -50,6 +50,8 @@ export interface OpenExternalRun { /** An external sub-agent tab, tracked in the gateway roster (display only). */ interface ExternalSubagent { agentId: string; + /** The Conversation this tab lives in, distinct from its agent id. */ + homeConversationId: string; name: string; status: SubagentStatus; template?: string; @@ -74,6 +76,7 @@ function belongsTo( function toInfo(subagent: ExternalSubagent): SubagentInfo { return { id: subagent.agentId, + conversationId: subagent.homeConversationId, name: subagent.name, status: subagent.status, ...connectorFields("external-inbound"), @@ -161,6 +164,7 @@ export class ExternalSubagentGateway { spec: RegisterExternalSubagent, ): RegisteredExternalSubagent { const agentId = randomUUID(); + const homeConversationId = randomUUID(); const channel = this.relay.open({ sessionId, label: spec.name, @@ -168,6 +172,7 @@ export class ExternalSubagentGateway { }); const subagent: ExternalSubagent = { agentId, + homeConversationId, name: spec.name, status: "active", template: spec.template, @@ -188,6 +193,7 @@ export class ExternalSubagentGateway { template: subagent.template, host: "external", connector: connectorFor("external-inbound"), + homeConversationId, }) .catch((err: unknown) => { console.error( @@ -237,6 +243,7 @@ export class ExternalSubagentGateway { const subagent: ExternalSubagent = { agentId: agent.id, + homeConversationId: agent.homeConversationId, name: agent.name, status: "detached", template: agent.template, @@ -267,6 +274,7 @@ export class ExternalSubagentGateway { return this.runs.open({ sessionId, participantId: agentId, + homeConversationId: subagent.homeConversationId, ingress: "tool", externalId: input.externalId, cursor: input.cursor, @@ -379,6 +387,11 @@ export class ExternalSubagentGateway { /** Builds the agent descriptor a relayed event is tagged with. */ private descriptorFor(subagent: ExternalSubagent): AgentDescriptor { - return { agentId: subagent.agentId, role: "subagent", name: subagent.name }; + return { + agentId: subagent.agentId, + role: "subagent", + name: subagent.name, + homeConversationId: subagent.homeConversationId, + }; } } diff --git a/apps/server/src/mcp/relayReport.test.ts b/apps/server/src/mcp/relayReport.test.ts index e5ea0bb..096ec31 100644 --- a/apps/server/src/mcp/relayReport.test.ts +++ b/apps/server/src/mcp/relayReport.test.ts @@ -55,6 +55,7 @@ function makeReport(roster: SubagentInfo[]) { function workerRow(id: string, name: string): SubagentInfo { return { id, + conversationId: id, name, status: "active", connector: connectorFor("external-inbound"), @@ -70,6 +71,7 @@ test("a participant's channel posts in its own thread, addressed to Prime", asyn name: "Explorer", status: "active", autoRelayToPrime: true, + homeConversationId: "ext-1", connector: connectorFor("external-inbound"), }); const { channelId } = h.relay.open({ diff --git a/apps/server/src/mcp/relayReport.ts b/apps/server/src/mcp/relayReport.ts index 37bf2c3..afab989 100644 --- a/apps/server/src/mcp/relayReport.ts +++ b/apps/server/src/mcp/relayReport.ts @@ -1,7 +1,10 @@ import type { ConnectorRegistry } from "../connectors/connectorRegistry.ts"; import { subagentAuthor } from "../connectors/participantAuthor.ts"; import type { ConversationRouter } from "../conversation/conversationRouter.ts"; -import { orchestratorIdFor } from "../conversation/participantRegistry.ts"; +import { + homeConversationFor, + orchestratorIdFor, +} from "../conversation/participantRegistry.ts"; import type { SessionStore } from "../store/sessionStore.ts"; import type { RelayChannel } from "./relayRegistry.ts"; @@ -46,7 +49,11 @@ export function createRelayReport( await conversations.post({ sessionId: channel.sessionId, - conversationId: author.id, + conversationId: await homeConversationFor( + store, + channel.sessionId, + author.id, + ), author, content: text, mentions: [orchestratorId], diff --git a/apps/server/src/pi/piAgentManager.test.ts b/apps/server/src/pi/piAgentManager.test.ts index fc85028..f077645 100644 --- a/apps/server/src/pi/piAgentManager.test.ts +++ b/apps/server/src/pi/piAgentManager.test.ts @@ -120,6 +120,7 @@ function agentRow(overrides: Partial): SessionAgent { status: "active", autoRelayToPrime: true, connector: connectorFor("pi-stdio"), + homeConversationId: "agent-1", createdAt: new Date().toISOString(), ...overrides, }; diff --git a/apps/server/src/pi/piAgentManager.ts b/apps/server/src/pi/piAgentManager.ts index a00b173..f208a9b 100644 --- a/apps/server/src/pi/piAgentManager.ts +++ b/apps/server/src/pi/piAgentManager.ts @@ -556,6 +556,7 @@ export class PiAgentManager { config?: ResolvedSessionConfig, primeOverride?: AgentModelSelection, user?: UserIdentity, + homeConversationId: string = PRIME_AGENT_ID, ): void { const existing = this.sessions.get(sessionId); if (existing?.agents.has(PRIME_AGENT_ID)) return; @@ -577,7 +578,12 @@ export class PiAgentManager { this.spawnAgent( sessionId, session, - { agentId: PRIME_AGENT_ID, role: "prime", name: "Prime" }, + { + agentId: PRIME_AGENT_ID, + role: "prime", + name: "Prime", + homeConversationId, + }, withModelSelection(primeConfig, primeOverride), ); } @@ -606,6 +612,7 @@ export class PiAgentManager { role: "subagent", name: agent.name, template: agent.template, + homeConversationId: agent.homeConversationId, autoRelayToPrime: agent.autoRelayToPrime ?? true, }, this.reconstructSubagentConfig(session, agent), @@ -698,6 +705,7 @@ export class PiAgentManager { role: existing.role, name: existing.name, template: existing.template, + homeConversationId: existing.homeConversationId, }; const nextConfig = withModelSelection(existing.config, selection); @@ -738,6 +746,10 @@ export class PiAgentManager { } const agentId = randomUUID(); + // Mint the sub-agent's Conversation up front so its roster update — and the + // subscribe the client makes off it — already carry a Conversation id that + // is not the agent's id. The store persists this mapping on `recordAgent`. + const homeConversationId = randomUUID(); const config = resolveSubagentConfig(request, { templates: session.config?.templates, defaults: session.config?.subagentDefaults, @@ -752,6 +764,7 @@ export class PiAgentManager { role: "subagent", name: request.name, template: request.template, + homeConversationId, autoRelayToPrime, }, config, @@ -783,10 +796,17 @@ export class PiAgentManager { const { sessionId, agentId, text } = options; const agent = this.sessions.get(sessionId)?.agents.get(agentId); if (!agent) { - // No participant, so no Run: this error belongs to no unit of work. + // No participant, so no Run: this error belongs to no unit of work. With + // no roster row there is no home Conversation to resolve, so the error is + // tagged with the agent's id (its legacy home) as a best effort. this.handlers.onAgentEvent( sessionId, - { agentId, role: "prime", name: "Prime" }, + { + agentId, + role: "prime", + name: "Prime", + homeConversationId: agentId, + }, { type: "error", message: `Agent ${agentId} is not available.` }, ); return; @@ -794,7 +814,12 @@ export class PiAgentManager { if (!agent.busy) { const ingress = options.ingress ?? "reaction"; - this.runs.open({ sessionId, participantId: agentId, ingress }); + this.runs.open({ + sessionId, + participantId: agentId, + homeConversationId: agent.homeConversationId, + ingress, + }); } this.writePrompt(sessionId, agent, text, options.delivery ?? "auto"); @@ -968,6 +993,7 @@ export class PiAgentManager { role: descriptor.role, name: descriptor.name, template: descriptor.template, + homeConversationId: descriptor.homeConversationId, status: "active", createdAt: new Date().toISOString(), config, @@ -1094,6 +1120,7 @@ export class PiAgentManager { role: agent.role, name: agent.name, template: agent.template, + homeConversationId: agent.homeConversationId, autoRelayToPrime: agent.autoRelayToPrime, }; const { config } = agent; @@ -1165,6 +1192,7 @@ export class PiAgentManager { this.runs.open({ sessionId, participantId: agent.agentId, + homeConversationId: agent.homeConversationId, ingress: "reaction", }); } @@ -1383,7 +1411,7 @@ export class PiAgentManager { for (const agent of session.agents.values()) { if (agent.lastActivity) { entries.push({ - conversationId: agent.agentId, + conversationId: agent.homeConversationId, activity: agent.lastActivity, }); } @@ -1410,6 +1438,7 @@ export class PiAgentManager { agentId: agent.agentId, role: agent.role, name: agent.name, + homeConversationId: agent.homeConversationId, }; this.emit(sessionId, descriptor, { type: "error", messageId, message }); this.notifyStatus(sessionId); diff --git a/apps/server/src/pi/triggers/triggerEngine.ts b/apps/server/src/pi/triggers/triggerEngine.ts index 6b60e80..ea74fe0 100644 --- a/apps/server/src/pi/triggers/triggerEngine.ts +++ b/apps/server/src/pi/triggers/triggerEngine.ts @@ -12,7 +12,11 @@ import { Cron } from "croner"; import type { Server } from "socket.io"; import type { ConversationRouter } from "../../conversation/conversationRouter.ts"; -import { orchestratorIdFor } from "../../conversation/participantRegistry.ts"; +import { + homeConversationFor, + orchestratorConversationFor, + orchestratorIdFor, +} from "../../conversation/participantRegistry.ts"; import type { ParticipantService } from "../../conversation/participantService.ts"; import { roomFor } from "../../sockets/rooms.ts"; import type { SessionStore } from "../../store/sessionStore.ts"; @@ -244,12 +248,23 @@ export class TriggerEngine { stored: StoredTrigger, prompt: string, ): Promise { - this.pi.ensure(sessionId, rootPath); const orchestratorId = await orchestratorIdFor(this.store, sessionId); + const primaryConversationId = await orchestratorConversationFor( + this.store, + sessionId, + ); + this.pi.ensure( + sessionId, + rootPath, + undefined, + undefined, + undefined, + primaryConversationId, + ); await this.ensureTriggerParticipant(sessionId, stored); await this.conversations.post({ sessionId, - conversationId: orchestratorId, + conversationId: primaryConversationId, author: triggerAuthor(stored), content: prompt, mentions: [orchestratorId], @@ -269,11 +284,19 @@ export class TriggerEngine { stored: StoredTrigger, prompt: string, ): Promise { + this.pi.ensure( + sessionId, + rootPath, + undefined, + undefined, + undefined, + await orchestratorConversationFor(this.store, sessionId), + ); const { agentId } = this.ensureSubagent(sessionId, rootPath, stored); await this.ensureTriggerParticipant(sessionId, stored); await this.conversations.post({ sessionId, - conversationId: agentId, + conversationId: await homeConversationFor(this.store, sessionId, agentId), author: triggerAuthor(stored), content: prompt, mentions: [agentId], diff --git a/apps/server/src/pi/types.ts b/apps/server/src/pi/types.ts index 11f3666..74f86f0 100644 --- a/apps/server/src/pi/types.ts +++ b/apps/server/src/pi/types.ts @@ -57,6 +57,12 @@ export interface AgentDescriptor { agentId: string; role: AgentRole; name: string; + /** + * The Conversation the agent's messages land in — decoupled from + * {@link AgentDescriptor.agentId} so a Conversation id no longer names an + * agent. Where the event handler tags the message and picks its room. + */ + homeConversationId: string; } export type AgentEventHandler = ( @@ -119,6 +125,13 @@ export interface AgentProcess { role: AgentRole; name: string; template?: string; + /** + * The Conversation this agent posts into. Minted fresh when the agent is + * spawned (Prime's resolved from the store, a sub-agent's from its spawner) so + * it is distinct from {@link AgentProcess.agentId}; falls back to the agent id + * for a legacy agent whose transcript is keyed that way. + */ + homeConversationId: string; status: SubagentStatus; createdAt: string; /** diff --git a/apps/server/src/pi/utils.ts b/apps/server/src/pi/utils.ts index 72ba2d5..8ac290e 100644 --- a/apps/server/src/pi/utils.ts +++ b/apps/server/src/pi/utils.ts @@ -141,13 +141,19 @@ export function readDelta( /** Builds the descriptor that tags events with their producing agent. */ export function toDescriptor(agent: AgentProcess): AgentDescriptor { - return { agentId: agent.agentId, role: agent.role, name: agent.name }; + return { + agentId: agent.agentId, + role: agent.role, + name: agent.name, + homeConversationId: agent.homeConversationId, + }; } /** Maps an internal process record to the roster shape exposed to the UI. */ export function toSubagentInfo(agent: AgentProcess): SubagentInfo { return { id: agent.agentId, + conversationId: agent.homeConversationId, name: agent.name, status: agent.status, ...connectorFields("pi-stdio"), diff --git a/apps/server/src/remote/remoteEnvironmentGateway.ts b/apps/server/src/remote/remoteEnvironmentGateway.ts index f8823b0..6e37b4d 100644 --- a/apps/server/src/remote/remoteEnvironmentGateway.ts +++ b/apps/server/src/remote/remoteEnvironmentGateway.ts @@ -64,6 +64,8 @@ interface RemoteEnvConnection { /** A sub-agent hosted in a remote environment, tracked in the gateway roster. */ interface RemoteSubagent { agentId: string; + /** The Conversation this sub-agent's tab lives in, distinct from its id. */ + homeConversationId: string; name: string; status: SubagentStatus; template?: string; @@ -88,6 +90,7 @@ function clampLimit(limit: number | undefined): number { function toInfo(subagent: RemoteSubagent): SubagentInfo { return { id: subagent.agentId, + conversationId: subagent.homeConversationId, name: subagent.name, status: subagent.status, ...connectorFields("remote-env", subagent.environmentId), @@ -176,12 +179,14 @@ export class RemoteEnvironmentGateway { } const agentId = randomUUID(); + const homeConversationId = randomUUID(); const config = resolveSubagentConfig(request); const autoRelayToPrime = request.autoRelayToPrime ?? true; const tools = [...config.tools]; const subagent: RemoteSubagent = { agentId, + homeConversationId, name: request.name, status: "active", template: request.template, @@ -236,6 +241,7 @@ export class RemoteEnvironmentGateway { const run = this.runs.open({ sessionId, participantId: agentId, + homeConversationId: this.homeConversationOf(sessionId, agentId), ingress: options.ingress ?? "reaction", }); const command: RemoteMessageCommand = { @@ -269,6 +275,7 @@ export class RemoteEnvironmentGateway { const subagent: RemoteSubagent = { agentId: agent.id, + homeConversationId: agent.homeConversationId, name: agent.name, status: "detached", template: agent.template, @@ -333,6 +340,13 @@ export class RemoteEnvironmentGateway { } /** Returns (creating if needed) the session's remote sub-agent roster. */ + /** A sub-agent's home Conversation, falling back to its id for a legacy row. */ + private homeConversationOf(sessionId: string, agentId: string): string { + return ( + this.sessions.get(sessionId)?.get(agentId)?.homeConversationId ?? agentId + ); + } + private rosterFor(sessionId: string): Map { const existing = this.sessions.get(sessionId); if (existing) return existing; @@ -343,7 +357,12 @@ export class RemoteEnvironmentGateway { /** Builds the agent descriptor a relayed event is tagged with. */ private descriptorFor(subagent: RemoteSubagent): AgentDescriptor { - return { agentId: subagent.agentId, role: "subagent", name: subagent.name }; + return { + agentId: subagent.agentId, + role: "subagent", + name: subagent.name, + homeConversationId: subagent.homeConversationId, + }; } /** Creates the `/remote-env` namespace with auth + connection handlers. */ @@ -487,7 +506,7 @@ export class RemoteEnvironmentGateway { this.handlers.onAgentMessage({ sessionId: payload.sessionId, - conversationId: payload.agentId, + conversationId: subagent.homeConversationId, author: { id: subagent.agentId, kind: "agent", diff --git a/apps/server/src/routes/internalAgents.ts b/apps/server/src/routes/internalAgents.ts index 81bffc0..2a0691f 100644 --- a/apps/server/src/routes/internalAgents.ts +++ b/apps/server/src/routes/internalAgents.ts @@ -11,7 +11,11 @@ import type { ConnectorRegistry } from "../connectors/connectorRegistry.ts"; import { piCredential } from "../connectors/credentials.ts"; import { subagentAuthor } from "../connectors/participantAuthor.ts"; import type { ConversationRouter } from "../conversation/conversationRouter.ts"; -import { orchestratorIdFor } from "../conversation/participantRegistry.ts"; +import { + homeConversationFor, + orchestratorConversationFor, + orchestratorIdFor, +} from "../conversation/participantRegistry.ts"; import { requireCredential } from "../middleware/requireCredential.ts"; import { getValidated, validate } from "../middleware/validate.ts"; import { parseThinkingLevel } from "../pi/agentConfig.ts"; @@ -131,17 +135,22 @@ async function handleSpawn( autoRelayToPrime, host, connector: info.connector, + homeConversationId: info.conversationId, }); res.json({ subagent: info }); // Answered first: the sub-agent exists either way, and a failure to post its // first task must not read as a failed spawn Prime might retry. - const orchestratorId = await orchestratorIdFor(store, body.sessionId); + const fromConversation = await orchestratorConversationFor( + store, + body.sessionId, + ); await postDirective( router, body.sessionId, info.id, + info.conversationId, body.task, - orchestratorId, + fromConversation, ).catch((err: unknown) => { console.error(`[agents] initial task for ${info.id} failed:`, err); }); @@ -189,13 +198,14 @@ async function postDirective( router: ConversationRouter, sessionId: string, agentId: string, + conversationId: string, text: string | undefined, fromConversation: string, ): Promise { if (!text?.trim()) return undefined; const { message, refused } = await router.postToConversation({ sessionId, - conversationId: agentId, + conversationId, fromConversation, author: PI_AGENT, content: text, @@ -222,8 +232,9 @@ async function handleMessage( router, body.sessionId, body.agentId, + await homeConversationFor(store, body.sessionId, body.agentId), body.text, - await orchestratorIdFor(store, body.sessionId), + await orchestratorConversationFor(store, body.sessionId), ); res.json({ ok: !refused, ...(refused ? { error: refused } : {}) }); } @@ -249,7 +260,7 @@ async function handleReport( await router.post({ sessionId, - conversationId: agentId, + conversationId: await homeConversationFor(store, sessionId, agentId), author, content: text, mentions: [await orchestratorIdFor(store, sessionId)], diff --git a/apps/server/src/routes/sessions/handlers.ts b/apps/server/src/routes/sessions/handlers.ts index 1758e13..0dca81a 100644 --- a/apps/server/src/routes/sessions/handlers.ts +++ b/apps/server/src/routes/sessions/handlers.ts @@ -20,7 +20,7 @@ import { SESSIONS_ROOT, UPLOADS_DIRNAME, } from "../../config.ts"; -import { orchestratorIdFor } from "../../conversation/participantRegistry.ts"; +import { orchestratorConversationFor } from "../../conversation/participantRegistry.ts"; import { installBundle } from "../../pi/config/bundleLoader.ts"; import type { PiAgentManager } from "../../pi/piAgentManager.ts"; import type { TriggerEngine } from "../../pi/triggers/triggerEngine.ts"; @@ -163,13 +163,16 @@ async function createSessionFromBundle( // Pre-seed Prime's first message so the bundle's agent "speaks first" // (e.g. renders a welcome card). It replays via `chat:history` on join and // renders any `tangent-ui:*` card because the bundle id is already attached. + const primaryConversationId = await orchestratorConversationFor( + store, + sessionId, + ); if (config.welcomeMessage) { - const orchestratorId = await orchestratorIdFor(store, sessionId); await store.appendMessage({ id: randomUUID(), sessionId, - conversationId: orchestratorId, - seq: await store.nextSeq(sessionId, orchestratorId), + conversationId: primaryConversationId, + seq: await store.nextSeq(sessionId, primaryConversationId), author: PI_AGENT, mentions: [], source: sourceFromAuthor(PI_AGENT), @@ -178,7 +181,14 @@ async function createSessionFromBundle( }); } - pi.ensure(sessionId, rootPath, config, undefined, user); + pi.ensure( + sessionId, + rootPath, + config, + undefined, + user, + primaryConversationId, + ); res.status(201).json({ session: withConfig }); } catch (err) { await store.deleteSession(sessionId); @@ -270,10 +280,15 @@ async function activityFor( session: Session, lastViewedAt: string | undefined, ): Promise { - const [{ unreadCount, lastActivityAt }, agents] = await Promise.all([ - readActivity(session.rootPath, lastViewedAt), - store.listAgents(session.id), - ]); + const agents = await store.listAgents(session.id); + const primaryConversationId = agents.find((agent) => + agent.capabilities.includes("orchestrator"), + )?.homeConversationId; + const { unreadCount, lastActivityAt } = await readActivity( + session.rootPath, + lastViewedAt, + primaryConversationId, + ); return { unreadCount, lastActivityAt, diff --git a/apps/server/src/runs/runRegistry.ts b/apps/server/src/runs/runRegistry.ts index c724fe5..3b7592b 100644 --- a/apps/server/src/runs/runRegistry.ts +++ b/apps/server/src/runs/runRegistry.ts @@ -13,7 +13,11 @@ import type { RunStore } from "../store/runStore.ts"; export interface OpenRunInput { sessionId: string; participantId: string; - /** Defaults to `participantId`: a conversation is agent-keyed for now. */ + /** + * The Conversation this Run's output lands in. Defaults to `participantId` + * only as a legacy fallback (an agent whose Conversation is still keyed by its + * own id); a spawner that minted a distinct Conversation passes it explicitly. + */ homeConversationId?: string; ingress: RunIngress; /** The far side's own id for this work, when the connector has one. */ diff --git a/apps/server/src/sockets/agentEvents.ts b/apps/server/src/sockets/agentEvents.ts index f872ad5..c0c70ac 100644 --- a/apps/server/src/sockets/agentEvents.ts +++ b/apps/server/src/sockets/agentEvents.ts @@ -228,9 +228,10 @@ function emitQueue( * Builds the handler that relays agents' streaming events to the matching * session room, dispatching each event variant to its emit helper. * - * Each message is tagged with the producing agent's id as its `conversationId` - * so the client can bucket it into the right transcript (Prime's main thread or - * a sub-agent's drill-in thread). Reasoning streams for every agent. + * Each message is tagged with the producing agent's home Conversation as its + * `conversationId` — a Conversation id, no longer the agent's id — so the client + * buckets it into the right transcript (the primary thread or a sub-agent's + * drill-in thread). Reasoning streams for every agent. */ export function createAgentEventHandler( io: Server, @@ -239,9 +240,9 @@ export function createAgentEventHandler( ): AgentEventHandler { return (sessionId, agent, event) => { const ctx: EmitContext = { - room: messageRoomFor(sessionId, agent.agentId), + room: messageRoomFor(sessionId, agent.homeConversationId), sessionId, - conversationId: agent.agentId, + conversationId: agent.homeConversationId, author: authorFor(agent), runId: event.runId, }; diff --git a/apps/server/src/sockets/chat.test.ts b/apps/server/src/sockets/chat.test.ts index c876105..d3388ac 100644 --- a/apps/server/src/sockets/chat.test.ts +++ b/apps/server/src/sockets/chat.test.ts @@ -41,6 +41,7 @@ function agent(id: string): SessionAgent { capabilities: id === "prime" ? ["orchestrator"] : [], status: "active", connector: connectorFor("pi-stdio"), + homeConversationId: id, createdAt: "2026-01-01T00:00:00.000Z", }; } diff --git a/apps/server/src/sockets/chat.ts b/apps/server/src/sockets/chat.ts index 7fd5749..65cf940 100644 --- a/apps/server/src/sockets/chat.ts +++ b/apps/server/src/sockets/chat.ts @@ -25,7 +25,10 @@ import { resolveUserIdentity } from "../auth/identity.ts"; import { ROOM_PER_CONVERSATION } from "../config.ts"; import type { ConnectorRegistry } from "../connectors/connectorRegistry.ts"; import type { ConversationRouter } from "../conversation/conversationRouter.ts"; -import { orchestratorIdFor } from "../conversation/participantRegistry.ts"; +import { + orchestratorConversationFor, + participantForConversation, +} from "../conversation/participantRegistry.ts"; import type { ParticipantService } from "../conversation/participantService.ts"; import type { MemoryManager } from "../pi/memory.ts"; import type { PiAgentManager } from "../pi/piAgentManager.ts"; @@ -77,14 +80,22 @@ export interface ChatHandlerDeps { * to stop something that isn't stoppable, and a system message in the thread * would be noise. */ -function handleAgentAbort( +async function handleAgentAbort( + store: SessionStore, connectors: ConnectorRegistry, payload: AgentAbortPayload, -): void { +): Promise { const sessionId = payload?.sessionId; - const participantId = payload?.conversationId; - if (!sessionId || !participantId) return; + const conversationId = payload?.conversationId; + if (!sessionId || !conversationId) return; + // The client cancels by Conversation; a Run belongs to the participant that + // owns it, so map back before asking its connector to stop. + const participantId = await participantForConversation( + store, + sessionId, + conversationId, + ); const { cancelled, reason } = connectors.cancelRun({ sessionId, participantId, @@ -126,8 +137,10 @@ function wireSocket(socket: Socket, deps: ChatHandlerDeps): void { handleChatMessage(socket, deps, author, payload), ); - socket.on(SocketEvents.AgentAbort, (payload: AgentAbortPayload) => - handleAgentAbort(connectors, payload), + socket.on( + SocketEvents.AgentAbort, + (payload: AgentAbortPayload) => + void handleAgentAbort(store, connectors, payload), ); socket.on(SocketEvents.AgentSetModel, (payload: AgentSetModelPayload) => @@ -215,6 +228,7 @@ async function handleChatJoin( const roster: SubagentRosterPayload = { sessionId: session.id, subagents: connectors.list(session.id), + primaryConversationId: await orchestratorConversationFor(store, session.id), }; socket.emit(SocketEvents.SubagentRoster, roster); @@ -327,7 +341,9 @@ export function authorizedConversations( agents: SessionAgent[], memberships: Membership[], ): Set { - const conversationIds = new Set(agents.map((agent) => agent.id)); + const conversationIds = new Set( + agents.map((agent) => agent.homeConversationId), + ); if (isSessionOwner(author, session)) return conversationIds; const held = new Set(); @@ -442,12 +458,22 @@ async function handleChatMessage( return; } - // Target thread: the orchestrator's by default, or a specific sub-agent so - // users can steer it from its own tab. - const orchestratorId = await orchestratorIdFor(store, session.id); - const conversationId = payload.conversationId ?? orchestratorId; - if (conversationId === orchestratorId) - pi.ensure(session.id, session.rootPath); + // Target thread: the orchestrator's home Conversation by default, or a + // specific sub-agent so users can steer it from its own tab. + const primaryConversationId = await orchestratorConversationFor( + store, + session.id, + ); + const conversationId = payload.conversationId ?? primaryConversationId; + if (conversationId === primaryConversationId) + pi.ensure( + session.id, + session.rootPath, + undefined, + undefined, + undefined, + primaryConversationId, + ); await conversations.post({ sessionId: session.id, diff --git a/apps/server/src/sockets/chatMemory.ts b/apps/server/src/sockets/chatMemory.ts index 8fc9237..452b176 100644 --- a/apps/server/src/sockets/chatMemory.ts +++ b/apps/server/src/sockets/chatMemory.ts @@ -10,7 +10,10 @@ import type { Server } from "socket.io"; import type { ConnectorRegistry } from "../connectors/connectorRegistry.ts"; import type { ConversationRouter } from "../conversation/conversationRouter.ts"; -import { orchestratorIdFor } from "../conversation/participantRegistry.ts"; +import { + orchestratorConversationFor, + orchestratorIdFor, +} from "../conversation/participantRegistry.ts"; import type { ParticipantService } from "../conversation/participantService.ts"; import type { MemoryManager } from "../pi/memory.ts"; import type { SessionStore } from "../store/sessionStore.ts"; @@ -44,7 +47,7 @@ export function createMemoryRememberedHandler( ); await conversations.post({ sessionId, - conversationId: await orchestratorIdFor(store, sessionId), + conversationId: await orchestratorConversationFor(store, sessionId), author: MEMORY_AUTHOR, content: text, memory: { scope }, diff --git a/apps/server/src/sockets/sessionRoster.ts b/apps/server/src/sockets/sessionRoster.ts index 3bb0eef..20028ef 100644 --- a/apps/server/src/sockets/sessionRoster.ts +++ b/apps/server/src/sockets/sessionRoster.ts @@ -12,7 +12,10 @@ import { import type { Server, Socket } from "socket.io"; import type { ConnectorRegistry } from "../connectors/connectorRegistry.ts"; -import { orchestratorIdFor } from "../conversation/participantRegistry.ts"; +import { + orchestratorConversationFor, + orchestratorIdFor, +} from "../conversation/participantRegistry.ts"; import { parseThinkingLevel } from "../pi/agentConfig.ts"; import type { PiAgentManager } from "../pi/piAgentManager.ts"; import type { SessionStore } from "../store/sessionStore.ts"; @@ -115,12 +118,17 @@ export async function ensureSessionAgents( session: Session, ): Promise { const primeOverride = await loadPrimeOverride(store, session.id); + const primaryConversationId = await orchestratorConversationFor( + store, + session.id, + ); pi.ensure( session.id, session.rootPath, undefined, primeOverride, session.user, + primaryConversationId, ); const persistedAgents = await store.listAgents(session.id); connectors.revive(session.id, persistedAgents); diff --git a/apps/server/src/store/chatLog.ts b/apps/server/src/store/chatLog.ts index 841014a..00acc15 100644 --- a/apps/server/src/store/chatLog.ts +++ b/apps/server/src/store/chatLog.ts @@ -148,11 +148,12 @@ export interface ChatActivity { export async function readActivity( rootPath: string, since?: string, + primaryConversationId: string = PI_AGENT.id, ): Promise { const messages = await readAllMessages(rootPath); const isUnread = (message: ChatMessage): boolean => message.author.kind === "agent" && - message.conversationId === PI_AGENT.id && + message.conversationId === primaryConversationId && (!since || message.createdAt > since); const unreadCount = messages.filter(isUnread).length; const last = messages.at(-1); diff --git a/apps/server/src/store/db/migrations/0013_chief_veda.sql b/apps/server/src/store/db/migrations/0013_chief_veda.sql new file mode 100644 index 0000000..ba13bb2 --- /dev/null +++ b/apps/server/src/store/db/migrations/0013_chief_veda.sql @@ -0,0 +1,3 @@ +ALTER TABLE `conversations` ADD `agent_id` text;--> statement-breakpoint +CREATE INDEX `conversations_session_agent_idx` ON `conversations` (`session_id`,`agent_id`);--> statement-breakpoint +UPDATE `conversations` SET `agent_id` = `id` WHERE `agent_id` IS NULL; \ No newline at end of file diff --git a/apps/server/src/store/db/migrations/meta/0013_snapshot.json b/apps/server/src/store/db/migrations/meta/0013_snapshot.json new file mode 100644 index 0000000..f72c93e --- /dev/null +++ b/apps/server/src/store/db/migrations/meta/0013_snapshot.json @@ -0,0 +1,759 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "90d55ec2-f656-456a-9c19-ad35af0d88d6", + "prevId": "5862359b-4eed-43d0-94a0-6d9a9e2dad97", + "tables": { + "conversations": { + "name": "conversations", + "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 + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_seq": { + "name": "next_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "conversations_session_idx": { + "name": "conversations_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "conversations_session_agent_idx": { + "name": "conversations_session_agent_idx", + "columns": ["session_id", "agent_id"], + "isUnique": false + }, + "conversations_session_id": { + "name": "conversations_session_id", + "columns": ["session_id", "id"], + "isUnique": true + } + }, + "foreignKeys": { + "conversations_session_id_sessions_id_fk": { + "name": "conversations_session_id_sessions_id_fk", + "tableFrom": "conversations", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "memberships": { + "name": "memberships", + "columns": { + "participant_id": { + "name": "participant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reaction": { + "name": "reaction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'never'" + }, + "ingress": { + "name": "ingress", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'reaction'" + }, + "transcript_visibility": { + "name": "transcript_visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'shared'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "memberships_session_conversation_idx": { + "name": "memberships_session_conversation_idx", + "columns": ["session_id", "conversation_id"], + "isUnique": false + }, + "memberships_session_conversation_participant": { + "name": "memberships_session_conversation_participant", + "columns": ["session_id", "conversation_id", "participant_id"], + "isUnique": true + } + }, + "foreignKeys": { + "memberships_session_id_sessions_id_fk": { + "name": "memberships_session_id_sessions_id_fk", + "tableFrom": "memberships", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "participants": { + "name": "participants", + "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 + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "presence": { + "name": "presence", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'connected'" + }, + "connector_kind": { + "name": "connector_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_lifecycle": { + "name": "connector_lifecycle", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_environment_id": { + "name": "connector_environment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_endpoint_url": { + "name": "connector_endpoint_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_payload": { + "name": "agent_payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "participants_session_idx": { + "name": "participants_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "participants_session_id": { + "name": "participants_session_id", + "columns": ["session_id", "id"], + "isUnique": true + } + }, + "foreignKeys": { + "participants_session_id_sessions_id_fk": { + "name": "participants_session_id_sessions_id_fk", + "tableFrom": "participants", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "runs": { + "name": "runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "participant_id": { + "name": "participant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "home_conversation_id": { + "name": "home_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "ingress": { + "name": "ingress", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": 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 + }, + "ended_at": { + "name": "ended_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "runs_session_idx": { + "name": "runs_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "runs_session_participant_idx": { + "name": "runs_session_participant_idx", + "columns": ["session_id", "participant_id"], + "isUnique": false + } + }, + "foreignKeys": { + "runs_session_id_sessions_id_fk": { + "name": "runs_session_id_sessions_id_fk", + "tableFrom": "runs", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "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'" + }, + "connector_kind": { + "name": "connector_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_lifecycle": { + "name": "connector_lifecycle", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_environment_id": { + "name": "connector_environment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_endpoint_url": { + "name": "connector_endpoint_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "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 a803c1a..7fb9b32 100644 --- a/apps/server/src/store/db/migrations/meta/_journal.json +++ b/apps/server/src/store/db/migrations/meta/_journal.json @@ -92,6 +92,13 @@ "when": 1786644715013, "tag": "0012_previous_blizzard", "breakpoints": true + }, + { + "idx": 13, + "version": "6", + "when": 1786650882282, + "tag": "0013_chief_veda", + "breakpoints": true } ] } diff --git a/apps/server/src/store/db/schema.ts b/apps/server/src/store/db/schema.ts index 7e8a246..04dfa4c 100644 --- a/apps/server/src/store/db/schema.ts +++ b/apps/server/src/store/db/schema.ts @@ -184,11 +184,24 @@ export const runs = sqliteTable( export const conversations = sqliteTable( "conversations", { - /** Conversation id: an agent id today (`prime` or a sub-agent uuid). */ + /** + * Conversation id. A fresh uuid for a Conversation minted after 2.4; an + * agent id (`prime` or a sub-agent uuid) for a legacy row, whose JSONL log + * is named the same and stays valid because {@link conversations.agentId} + * maps it. No longer names an agent for new Conversations. + */ id: text("id").notNull(), sessionId: text("session_id") .notNull() .references(() => sessions.id, { onDelete: "cascade" }), + /** + * The participant that owns this Conversation as its home thread (an agent + * id). The mapping that lets a Conversation id be distinct from the agent's + * id: a message an agent produces lands in the Conversation whose `agentId` + * is that agent, and a legacy row where `id == agentId` maps to itself. + * Null only until the 0013 backfill sets `agent_id = id`. + */ + agentId: text("agent_id"), /** The next `seq` to hand out; incremented as each is allocated. */ nextSeq: integer("next_seq").notNull().default(1), createdAt: text("created_at").notNull(), @@ -196,6 +209,7 @@ export const conversations = sqliteTable( (table) => [ unique("conversations_session_id").on(table.sessionId, table.id), index("conversations_session_idx").on(table.sessionId), + index("conversations_session_agent_idx").on(table.sessionId, table.agentId), ], ); diff --git a/apps/server/src/store/inMemorySessionStore.ts b/apps/server/src/store/inMemorySessionStore.ts index d7a996c..d92c5f8 100644 --- a/apps/server/src/store/inMemorySessionStore.ts +++ b/apps/server/src/store/inMemorySessionStore.ts @@ -59,7 +59,7 @@ function mergeAgent( sessionId: string, agent: RecordAgentInput, prior: SessionAgent | undefined, -): SessionAgent { +): Omit { return { ...prior, ...definedAgentFields(agent), @@ -82,6 +82,8 @@ export class InMemorySessionStore implements SessionStore { private readonly views = new Map>(); /** Per-conversation `seq` counters, keyed `sessionId/conversationId`. */ private readonly seqs = new Map(); + /** Agent → home Conversation id, keyed by session; mirrors `conversations`. */ + private readonly homeConversations = new Map>(); /** Mirrors each recorded roster row, matching the SQLite store's dual-write. */ private readonly participants?: ParticipantStore; @@ -171,6 +173,7 @@ export class InMemorySessionStore implements SessionStore { } this.artifacts.delete(id); this.agents.delete(id); + this.homeConversations.delete(id); return this.sessions.delete(id); } @@ -252,7 +255,12 @@ export class InMemorySessionStore implements SessionStore { ): Promise { const existing = this.agents.get(sessionId) ?? []; const prior = existing.find((a) => a.id === agent.id); - const next = mergeAgent(sessionId, agent, prior); + const homeConversationId = this.ensureHomeConversation( + sessionId, + agent.id, + prior?.homeConversationId ?? agent.homeConversationId, + ); + const next = { ...mergeAgent(sessionId, agent, prior), homeConversationId }; const updated = prior ? existing.map((a) => (a.id === agent.id ? next : a)) : [...existing, next]; @@ -261,6 +269,29 @@ export class InMemorySessionStore implements SessionStore { return next; } + /** + * Resolves an agent's home Conversation, minting one when it has none. A + * caller-supplied id (a spawner minting up front) wins; a legacy agent whose + * transcript is already keyed by its own id keeps that id; anything else gets + * a fresh one so a Conversation id stops naming an agent. + */ + private ensureHomeConversation( + sessionId: string, + agentId: string, + provided: string | undefined, + ): string { + const map = this.homeConversations.get(sessionId) ?? new Map(); + const existing = map.get(agentId); + if (existing) return existing; + + const held = this.messages.get(sessionId) ?? []; + const legacy = held.some((message) => message.conversationId === agentId); + const resolved = provided ?? (legacy ? agentId : randomUUID()); + map.set(agentId, resolved); + this.homeConversations.set(sessionId, map); + return resolved; + } + async setAgentStatus( sessionId: string, agentId: string, diff --git a/apps/server/src/store/sessionStore.ts b/apps/server/src/store/sessionStore.ts index 7d771f7..a348ae3 100644 --- a/apps/server/src/store/sessionStore.ts +++ b/apps/server/src/store/sessionStore.ts @@ -90,6 +90,14 @@ export interface SessionAgent { host?: SubagentHost; /** The connector that runs the agent; derived from `host` on legacy rows. */ connector: ConnectorDescriptor; + /** + * The Conversation this agent posts into as its home thread. A fresh id for an + * agent created after 2.4 (so a Conversation id no longer names an agent); the + * agent's own id for a legacy row, whose JSONL log is named that way and stays + * valid because the `conversations` table maps it. Resolved from that table on + * read, falling back to {@link SessionAgent.id}. + */ + homeConversationId: string; createdAt: string; } @@ -114,6 +122,13 @@ export interface RecordAgentInput { host?: SubagentHost; /** The connector running the agent; omitted leaves the stored one in place. */ connector?: ConnectorDescriptor; + /** + * The Conversation id to mint for this agent's home thread. Supplied by a + * spawner that mints the id up front (so its roster update and the subscribe + * that follows carry it); omitted for Prime and legacy revives, where the + * store resolves-or-mints the mapping itself. + */ + homeConversationId?: string; } /** diff --git a/apps/server/src/store/sqliteSessionStore.test.ts b/apps/server/src/store/sqliteSessionStore.test.ts index b72e6a1..6863802 100644 --- a/apps/server/src/store/sqliteSessionStore.test.ts +++ b/apps/server/src/store/sqliteSessionStore.test.ts @@ -73,6 +73,120 @@ test("markViewed upserts and isolates read state per user", async () => { assert.equal(a.get(session.id), "2026-03-01T00:00:00.000Z"); }); +/** The `agent_id` backfill statement drizzle-kit's 0013 migration appended. */ +function agentIdBackfillStatement(): string { + const file = fileURLToPath( + new URL("./db/migrations/0013_chief_veda.sql", import.meta.url), + ); + const statement = readFileSync(file, "utf8") + .split("--> statement-breakpoint") + .map((chunk) => chunk.trim()) + .find((chunk) => chunk.startsWith("UPDATE `conversations`")); + assert.ok(statement, "0013 carries an agent_id backfill statement"); + return statement; +} + +test("recordAgent mints a fresh home conversation for a new agent id", async () => { + const store = newStore(); + const session = await store.createSession({ name: "S" }); + + const recorded = await store.recordAgent(session.id, { + id: "sub-1", + role: "subagent", + name: "Worker", + connector: connectorFor("pi-stdio"), + }); + + assert.notEqual( + recorded.homeConversationId, + "sub-1", + "a Conversation id no longer names the agent that owns it", + ); + // Idempotent: re-recording (a revive) resolves the same id, never re-mints. + const again = await store.recordAgent(session.id, { + id: "sub-1", + role: "subagent", + name: "Worker", + status: "active", + connector: connectorFor("pi-stdio"), + }); + assert.equal(again.homeConversationId, recorded.homeConversationId); + const agents = await store.listAgents(session.id); + assert.equal( + agents.find((a) => a.id === "sub-1")?.homeConversationId, + recorded.homeConversationId, + "listAgents resolves the same mapping", + ); +}); + +test("recordAgent honors a spawner-supplied home conversation id", async () => { + const store = newStore(); + const session = await store.createSession({ name: "S" }); + + const recorded = await store.recordAgent(session.id, { + id: "sub-1", + role: "subagent", + name: "Worker", + homeConversationId: "conv-abc", + connector: connectorFor("pi-stdio"), + }); + + assert.equal(recorded.homeConversationId, "conv-abc"); +}); + +test("a legacy .jsonl keeps the agent id as its home conversation", async () => { + const store = newStore(); + const session = await store.createSession({ name: "S" }); + + // A transcript an older build left keyed by the agent's own id: its home + // conversation must stay that id so the file is never orphaned. + const dir = path.join(session.rootPath, ".tangent", "chats"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + path.join(dir, "legacy-1.jsonl"), + `${JSON.stringify({ + id: "m1", + sessionId: session.id, + conversationId: "legacy-1", + author: { id: "legacy-1", kind: "agent", name: "Worker" }, + content: "hi", + createdAt: "2026-01-01T00:00:00.000Z", + })}\n`, + ); + + const recorded = await store.recordAgent(session.id, { + id: "legacy-1", + role: "subagent", + name: "Worker", + connector: connectorFor("pi-stdio"), + }); + + assert.equal(recorded.homeConversationId, "legacy-1"); +}); + +test("the 0013 backfill claims pre-existing conversation rows for their agent", async () => { + const db = openDb(":memory:"); + const store = new SqliteSessionStore(db); + const session = await store.createSession({ name: "S" }); + + // A pre-0013 counter row: keyed by an agent id, with no owner recorded yet. + db.run( + sql`INSERT INTO conversations (id, session_id, agent_id, next_seq, created_at) + VALUES ('prime', ${session.id}, NULL, 1, '2026-01-01T00:00:00.000Z')`, + ); + + db.run(sql.raw(agentIdBackfillStatement())); + + const row = db.get( + sql`SELECT agent_id FROM conversations WHERE id = 'prime' AND session_id = ${session.id}`, + ) as { agent_id: string | null } | undefined; + assert.equal( + row?.agent_id, + "prime", + "a legacy row adopts its own id as its owner", + ); +}); + test("recordAgent round-trips a connector descriptor", async () => { const store = newStore(); const session = await store.createSession({ name: "S" }); diff --git a/apps/server/src/store/sqliteSessionStore.ts b/apps/server/src/store/sqliteSessionStore.ts index 10896e0..0abfd3c 100644 --- a/apps/server/src/store/sqliteSessionStore.ts +++ b/apps/server/src/store/sqliteSessionStore.ts @@ -101,8 +101,16 @@ function connectorColumns(connector: ConnectorDescriptor | undefined) { }; } -/** Maps a session_agents row onto the {@link SessionAgent} domain type. */ -function toAgent(row: SessionAgentRow): SessionAgent { +/** + * Maps a session_agents row onto the {@link SessionAgent} domain type. The home + * Conversation is resolved from the `conversations` table by the caller and + * passed in (falling back to the agent's own id for a legacy row the mapping + * never covered). + */ +function toAgent( + row: SessionAgentRow, + homeConversationId: string, +): SessionAgent { return { id: row.id, sessionId: row.sessionId, @@ -119,6 +127,7 @@ function toAgent(row: SessionAgentRow): SessionAgent { autoRelayToPrime: row.autoRelayToPrime, host: row.host as SubagentHost, connector: toConnector(row), + homeConversationId, createdAt: row.createdAt, }; } @@ -361,16 +370,14 @@ export class SqliteSessionStore implements SessionStore { const rootPath = await this.rootPathFor(sessionId); const occupied = rootPath ? await highestSeq(rootPath, conversationId) : 0; - this.db - .insert(conversations) - .values({ - id: conversationId, - sessionId, - nextSeq: occupied + 1, - createdAt: new Date().toISOString(), - }) - .onConflictDoNothing() - .run(); + // A row seeded here (rather than by a mint) is a legacy conversation the + // migration never covered — its id is the agent's own id, so it owns itself. + this.insertConversation( + sessionId, + conversationId, + conversationId, + occupied + 1, + ); } async getArtifacts(sessionId: string): Promise { @@ -472,12 +479,118 @@ export class SqliteSessionStore implements SessionStore { ) .get(); // The row was just upserted, so it always exists here. - const recorded = toAgent(row as SessionAgentRow); + const homeConversationId = await this.ensureHomeConversation( + sessionId, + agent.id, + agent.homeConversationId, + ); + const recorded = toAgent(row as SessionAgentRow, homeConversationId); // Mirror it into the participant projection; the roster stays authoritative. await this.participants?.put(participantFromAgent(recorded)); return recorded; } + /** + * Resolves an agent's home Conversation, minting one when it has none. Lookup + * order: an existing owner row (`agent_id == agentId`) wins; a legacy row + * keyed by the agent's own id is adopted as its owner; a caller-supplied id (a + * spawner minting up front) is inserted; a legacy JSONL log already on disk + * keeps the agent's id so its transcript stays addressable; otherwise a fresh + * id is minted so a Conversation id stops naming an agent. Idempotent: a + * revive or re-record resolves the same id instead of minting a second. + */ + private async ensureHomeConversation( + sessionId: string, + agentId: string, + provided: string | undefined, + ): Promise { + const owned = this.db + .select({ id: conversations.id }) + .from(conversations) + .where( + and( + eq(conversations.sessionId, sessionId), + eq(conversations.agentId, agentId), + ), + ) + .get(); + if (owned) return owned.id; + + const legacyRow = this.db + .select({ id: conversations.id }) + .from(conversations) + .where( + and( + eq(conversations.sessionId, sessionId), + eq(conversations.id, agentId), + ), + ) + .get(); + if (legacyRow) { + this.db + .update(conversations) + .set({ agentId }) + .where( + and( + eq(conversations.sessionId, sessionId), + eq(conversations.id, agentId), + ), + ) + .run(); + return agentId; + } + + if (provided) { + this.insertConversation(sessionId, provided, agentId, 1); + return provided; + } + + const rootPath = await this.rootPathFor(sessionId); + const occupied = rootPath ? await highestSeq(rootPath, agentId) : 0; + if (occupied > 0) { + this.insertConversation(sessionId, agentId, agentId, occupied + 1); + return agentId; + } + + const fresh = randomUUID(); + this.insertConversation(sessionId, fresh, agentId, 1); + return fresh; + } + + /** Inserts a conversation counter row that maps `agentId` to `id`. */ + private insertConversation( + sessionId: string, + id: string, + agentId: string, + nextSeq: number, + ): void { + this.db + .insert(conversations) + .values({ + id, + sessionId, + agentId, + nextSeq, + createdAt: new Date().toISOString(), + }) + .onConflictDoNothing() + .run(); + } + + /** Maps each agent to its home Conversation id for one session. */ + private homeConversationMap(sessionId: string): Map { + const rows = this.db + .select({ id: conversations.id, agentId: conversations.agentId }) + .from(conversations) + .where(eq(conversations.sessionId, sessionId)) + .all(); + const byAgent = new Map(); + for (const row of rows) { + if (row.agentId) byAgent.set(row.agentId, row.id); + } + return byAgent; + } + async setAgentStatus( sessionId: string, agentId: string, @@ -502,7 +615,8 @@ export class SqliteSessionStore implements SessionStore { .where(eq(sessionAgents.sessionId, sessionId)) .orderBy(asc(sessionAgents.createdAt)) .all(); - return rows.map(toAgent); + const homes = this.homeConversationMap(sessionId); + return rows.map((row) => toAgent(row, homes.get(row.id) ?? row.id)); } async detachActiveSubagents(): Promise { @@ -534,7 +648,15 @@ export class SqliteSessionStore implements SessionStore { ) .orderBy(asc(sessionAgents.createdAt)) .all(); - return rows.map(toAgent); + const homesBySession = new Map>(); + return rows.map((row) => { + let homes = homesBySession.get(row.sessionId); + if (!homes) { + homes = this.homeConversationMap(row.sessionId); + homesBySession.set(row.sessionId, homes); + } + return toAgent(row, homes.get(row.id) ?? row.id); + }); } async markViewed( diff --git a/apps/web/src/features/chat/components/PrimeChatPanel.tsx b/apps/web/src/features/chat/components/PrimeChatPanel.tsx index d01036f..94a6bd8 100644 --- a/apps/web/src/features/chat/components/PrimeChatPanel.tsx +++ b/apps/web/src/features/chat/components/PrimeChatPanel.tsx @@ -32,6 +32,8 @@ type SendFn = ( interface PrimeChatPanelProps { sessionId: string; + /** The orchestrator's home Conversation this panel sends/aborts against. */ + primaryConversationId: string; messages: ChatMessage[]; currentAuthorId: string; bundleId?: string; @@ -43,7 +45,7 @@ interface PrimeChatPanelProps { memorySuggestions: MemorySuggestionPayload[]; confirmMemory: (suggestionId: string) => void; dismissMemory: (suggestionId: string) => void; - busySubagents: { id: string; name: string }[]; + busySubagents: { id: string; name: string; conversationId: string }[]; armedTriggers: Trigger[]; subagents: SubagentInfo[]; assets: Asset[]; @@ -60,6 +62,7 @@ interface PrimeChatPanelProps { export function PrimeChatPanel({ sessionId, + primaryConversationId, messages, currentAuthorId, bundleId, @@ -91,6 +94,7 @@ export function PrimeChatPanel({ sessionId={sessionId} messages={messages} currentAuthorId={currentAuthorId} + primaryConversationId={primaryConversationId} activity={activity} historyLoaded={historyLoaded} bundleId={bundleId} @@ -136,10 +140,10 @@ export function PrimeChatPanel({ agentId={PI_AGENT.id} disabled={!connected} agentBusy={agentBusy} - onAbort={() => abort(PI_AGENT.id)} + onAbort={() => abort(primaryConversationId)} onSubmit={(content, { delivery, attachments }) => send(content, { - conversationId: PI_AGENT.id, + conversationId: primaryConversationId, delivery, attachments, }) @@ -151,7 +155,7 @@ export function PrimeChatPanel({ interface PrimeComposerFooterProps { sessionId: string; - busySubagents: { id: string; name: string }[]; + busySubagents: { id: string; name: string; conversationId: string }[]; armedTriggers: Trigger[]; subagents: SubagentInfo[]; assets: Asset[]; diff --git a/apps/web/src/features/chat/components/SessionChat.tsx b/apps/web/src/features/chat/components/SessionChat.tsx index 5ef4e4b..4aad338 100644 --- a/apps/web/src/features/chat/components/SessionChat.tsx +++ b/apps/web/src/features/chat/components/SessionChat.tsx @@ -39,6 +39,8 @@ export function SessionChat({ sessionId }: SessionChatProps) { const { messagesFor, subagents, + primaryConversationId, + conversationForAgent, triggers, artifacts, pinnedPaths, @@ -97,11 +99,15 @@ export function SessionChat({ sessionId }: SessionChatProps) { activeTab === CHAT_TAB_VALUE ? PI_AGENT.id : activeTab; // The Chat tab is Prime's main thread; each sub-agent has its own thread tab. - const primeMessages = messagesFor(PI_AGENT.id); + const primeMessages = messagesFor(primaryConversationId); const busySubagents = subagents - .filter((s) => isConversationBusy(s.id)) - .map((s) => ({ id: s.id, name: s.name })); + .filter((s) => isConversationBusy(s.conversationId)) + .map((s) => ({ + id: s.id, + name: s.name, + conversationId: s.conversationId, + })); const armedTriggers = triggers.filter((t) => t.enabled); // Opening an artifact from a chat chip mirrors opening it from the sidebar: a @@ -132,6 +138,8 @@ export function SessionChat({ sessionId }: SessionChatProps) { const sharedTabProps = { sessionId, subagents, + conversationForAgent, + primaryConversationId, triggers, messagesFor, currentAuthorId, @@ -204,13 +212,14 @@ export function SessionChat({ sessionId }: SessionChatProps) { { event.stopPropagation(); - onAbort(agent.id); + onAbort(agent.conversationId); }} /> diff --git a/apps/web/src/features/chat/components/message/ChatMessage.tsx b/apps/web/src/features/chat/components/message/ChatMessage.tsx index e74af96..63ae33b 100644 --- a/apps/web/src/features/chat/components/message/ChatMessage.tsx +++ b/apps/web/src/features/chat/components/message/ChatMessage.tsx @@ -25,6 +25,8 @@ import { ThinkingOnlyMessage } from "./ThinkingOnlyMessage"; interface ChatMessageProps { sessionId: string; message: ChatMessageType; + /** The orchestrator's home Conversation, for the "from Prime's thread" label. */ + primaryConversationId: string; isOwn: boolean; /** Whether this message is still receiving streamed deltas. */ isStreaming?: boolean; @@ -45,6 +47,7 @@ interface ChatMessageProps { function ChatMessageContent({ sessionId, message, + primaryConversationId, isOwn, isStreaming = false, bundleId, @@ -100,7 +103,7 @@ function ChatMessageContent({ roleLabel={roleLabel} createdAt={message.createdAt} content={message.content} - origin={originLabelFor(message)} + origin={originLabelFor(message, primaryConversationId)} onCollapse={onCollapse} /> } diff --git a/apps/web/src/features/chat/components/message/ChatMessageList.tsx b/apps/web/src/features/chat/components/message/ChatMessageList.tsx index 3d6c96b..7ccf879 100644 --- a/apps/web/src/features/chat/components/message/ChatMessageList.tsx +++ b/apps/web/src/features/chat/components/message/ChatMessageList.tsx @@ -20,6 +20,8 @@ interface ChatMessageListProps { sessionId: string; messages: ChatMessageType[]; currentAuthorId: string; + /** The orchestrator's home Conversation, for the "from Prime's thread" label. */ + primaryConversationId: string; /** Ephemeral agent activity for this thread, or null when idle/streaming. */ activity?: AgentActivity | null; /** Whether the room's history snapshot has arrived; gates loader vs empty. */ @@ -52,6 +54,7 @@ interface RowContentProps { row: Row; sessionId: string; currentAuthorId: string; + primaryConversationId: string; bundleId?: string; onSendPrompt?: (text: string) => void; onOpenArtifact?: (url: string, title: string) => void; @@ -66,6 +69,7 @@ function RowContent({ row, sessionId, currentAuthorId, + primaryConversationId, bundleId, onSendPrompt, onOpenArtifact, @@ -81,6 +85,7 @@ function RowContent({ string; + primaryConversationId: string; triggers: Trigger[]; messagesFor: (conversationId: string) => ChatMessage[]; currentAuthorId: string; @@ -54,6 +56,8 @@ export function AssetTabContent({ tab, sessionId, subagents, + conversationForAgent, + primaryConversationId, triggers, messagesFor, currentAuthorId, @@ -77,31 +81,34 @@ export function AssetTabContent({ case "agent": { const info = subagents.find((s) => s.id === tab.agentId); const model = getAgentModel(tab.agentId); + const conversationId = + info?.conversationId ?? conversationForAgent(tab.agentId); return ( setAgentModel(tab.agentId, selection)} - onAbort={() => abort(tab.agentId)} + onAbort={() => abort(conversationId)} onRemove={() => { dismissSubagent(tab.agentId); closeAsset(tab.id); }} onSubmit={(content, { delivery, attachments }) => send(content, { - conversationId: tab.agentId, + conversationId, delivery, attachments, }) diff --git a/apps/web/src/features/chat/components/tabs/SubagentTabView.tsx b/apps/web/src/features/chat/components/tabs/SubagentTabView.tsx index 0d6f8b0..a71e4c2 100644 --- a/apps/web/src/features/chat/components/tabs/SubagentTabView.tsx +++ b/apps/web/src/features/chat/components/tabs/SubagentTabView.tsx @@ -19,8 +19,10 @@ import { ChatMessageList } from "../message/ChatMessageList"; interface SubagentTabViewProps { sessionId: string; - /** The sub-agent (and conversation) this tab is dedicated to. */ + /** The sub-agent this tab is dedicated to. */ agentId: string; + /** The orchestrator's home Conversation, for the "from Prime's thread" label. */ + primaryConversationId: string; /** Display name, used in the stop control's label. */ name: string; /** This sub-agent's conversation messages, already scoped by the server room. */ @@ -67,6 +69,7 @@ interface SubagentTabViewProps { export function SubagentTabView({ sessionId, agentId, + primaryConversationId, messages, currentAuthorId, bundleId, @@ -92,6 +95,7 @@ export function SubagentTabView({ sessionId={sessionId} messages={messages} currentAuthorId={currentAuthorId} + primaryConversationId={primaryConversationId} activity={activity} historyLoaded={historyLoaded} bundleId={bundleId} diff --git a/apps/web/src/features/chat/hooks/useSessionChat.ts b/apps/web/src/features/chat/hooks/useSessionChat.ts index bd84567..937a15c 100644 --- a/apps/web/src/features/chat/hooks/useSessionChat.ts +++ b/apps/web/src/features/chat/hooks/useSessionChat.ts @@ -79,7 +79,7 @@ export interface AgentModelSelection { thinkingDepth?: ThinkingLevel; } -/** Messages bucketed by the Conversation (agent id) they belong to. */ +/** Messages bucketed by the `conversationId` they belong to. */ type MessageMap = Map; /** Stable empty result so an unknown Conversation doesn't churn renders. */ @@ -160,6 +160,19 @@ export function useSessionChat(sessionId: string) { const [messagesByConversation, setMessagesByConversation] = useState(() => new Map()); const [subagents, setSubagents] = useState([]); + // The orchestrator's home Conversation — the primary ("Prime") thread the main + // Chat tab renders. Server-derived from the `orchestrator` capability (roster + // payload) so the client no longer privileges the reserved `"prime"` id. + // Seeded with `PI_AGENT.id` for a legacy session whose Prime keeps that id. + const [primaryConversationId, setPrimaryConversationId] = useState( + PI_AGENT.id, + ); + // Maps an agent id to the Conversation its thread lives in (`SubagentInfo.id → + // conversationId`, plus Prime → `primaryConversationId`). Components resolve an + // agent tab/card to its Conversation through this rather than assuming equality. + const [conversationByAgent, setConversationByAgent] = useState< + Map + >(() => new Map()); // Per-agent model/thinking selection, keyed by agent id (`"prime"` or a // sub-agent id). Seeded from the roster (sub-agents) and the `agent:model` // event (Prime), and updated as either changes. @@ -196,6 +209,11 @@ export function useSessionChat(sessionId: string) { // arrives as a `subagent:update` for an unseen id, which triggers a // `conversation:subscribe` so its room and history are joined on demand. const subscribedConversations = useRef>(new Set()); + // Reverse of `conversationByAgent` (conversationId → agent id), used only to + // key the shared agent-status cache — which `useAgentStatus` reads by agent id + // — from socket events that now carry a decoupled conversation id. A ref: no + // render depends on it; it mirrors the roster as it arrives. + const agentByConversation = useRef>(new Map()); // Maps an in-flight message id to its conversation so `agent:delta` / // `agent:thinking` / `agent:error` (which only carry a messageId) can reach // the right thread's bucket and streaming state. @@ -250,6 +268,12 @@ export function useSessionChat(sessionId: string) { ); }; + // Resolves the agent that owns a Conversation, so a socket event carrying a + // decoupled conversation id keys the agent-status cache (read by agent id). + // Falls back to the id itself for a legacy agent whose ids still coincide. + const agentIdOf = (conversationId: string) => + agentByConversation.current.get(conversationId) ?? conversationId; + // Republishes Prime plus every known sub-agent (e.g. after a (re)connect or // disconnect, when the busy/activity inputs reset for all of them at once). const publishAll = () => { @@ -281,6 +305,8 @@ export function useSessionChat(sessionId: string) { setMessagesByConversation(new Map()); setHistoryLoaded(false); setSubagents([]); + setPrimaryConversationId(PI_AGENT.id); + setConversationByAgent(new Map()); setModelByAgent(new Map()); setTriggers([]); setArtifacts([]); @@ -292,8 +318,10 @@ export function useSessionChat(sessionId: string) { conversationByMessageId.current.clear(); runIdByConversation.current.clear(); streamingRuns.current.clear(); - // Prime is joined server-side at chat:join; seed it so a later update for - // it doesn't re-subscribe redundantly. + agentByConversation.current.clear(); + // Prime is joined server-side at chat:join; seed its id so a later update + // for it doesn't re-subscribe. The roster replaces this with Prime's real + // home conversation once it arrives. subscribedConversations.current = new Set([PI_AGENT.id]); // Reset the published statuses; the join snapshot (roster + replayed // activity) republishes them. Prime is present immediately. @@ -363,8 +391,9 @@ export function useSessionChat(sessionId: string) { setMessagesByConversation((prev) => appendToConversation(prev, message), ); - streaming.add(message.conversationId); - publish(message.conversationId); + const agentId = agentIdOf(message.conversationId); + streaming.add(agentId); + publish(agentId); }, ); // Streamed token: append it to the matching in-flight message, routed to its @@ -422,8 +451,9 @@ export function useSessionChat(sessionId: string) { () => message, ), ); - streaming.delete(message.conversationId); - publish(message.conversationId); + const agentId = agentIdOf(message.conversationId); + streaming.delete(agentId); + publish(agentId); }); // The agent's run-level activity changed: a non-null activity surfaces the // ephemeral spinner bubble; null clears it (message streaming / run idle). @@ -440,12 +470,13 @@ export function useSessionChat(sessionId: string) { } return next; }); + const agentId = agentIdOf(conversationId); if (activity) { - activities.set(conversationId, activity); + activities.set(agentId, activity); } else { - activities.delete(conversationId); + activities.delete(agentId); } - publish(conversationId); + publish(agentId); }, ); socket.on( @@ -472,9 +503,10 @@ export function useSessionChat(sessionId: string) { next.delete(conversationId); return next; }); - streaming.delete(conversationId); - activities.delete(conversationId); - publish(conversationId); + const agentId = agentIdOf(conversationId); + streaming.delete(agentId); + activities.delete(agentId); + publish(agentId); } console.error("[chat] agent error:", message); }, @@ -484,8 +516,23 @@ export function useSessionChat(sessionId: string) { // sub-agent's model/thinking selection. socket.on( SocketEvents.SubagentRoster, - ({ subagents: roster }: SubagentRosterPayload) => { + ({ + subagents: roster, + primaryConversationId: primary, + }: SubagentRosterPayload) => { setSubagents(roster); + setPrimaryConversationId(primary); + setConversationByAgent(() => { + const next = new Map([[PI_AGENT.id, primary]]); + for (const s of roster) next.set(s.id, s.conversationId); + return next; + }); + agentByConversation.current = new Map([[primary, PI_AGENT.id]]); + for (const s of roster) + agentByConversation.current.set(s.conversationId, s.id); + // Prime's home is joined server-side at chat:join; record it so a later + // roster/update for it does not re-subscribe. + subscribedConversations.current.add(primary); setModelByAgent((prev) => { const next = new Map(prev); for (const s of roster) { @@ -497,7 +544,7 @@ export function useSessionChat(sessionId: string) { statuses.set(s.id, s.status); // Joined server-side at chat:join; record it so a later update for it // does not re-subscribe. - subscribedConversations.current.add(s.id); + subscribedConversations.current.add(s.conversationId); publish(s.id); } }, @@ -508,14 +555,18 @@ export function useSessionChat(sessionId: string) { ({ subagent }: SubagentUpdatePayload) => { // A sub-agent this client hasn't been subscribed to yet spawned after // join: subscribe so its per-Conversation room and history are joined. - if (!subscribedConversations.current.has(subagent.id)) { - subscribedConversations.current.add(subagent.id); + if (!subscribedConversations.current.has(subagent.conversationId)) { + subscribedConversations.current.add(subagent.conversationId); const payload: ConversationSubscribePayload = { sessionId, - conversationId: subagent.id, + conversationId: subagent.conversationId, }; socket.emit(SocketEvents.ConversationSubscribe, payload); } + setConversationByAgent((prev) => + new Map(prev).set(subagent.id, subagent.conversationId), + ); + agentByConversation.current.set(subagent.conversationId, subagent.id); setSubagents((prev) => { const next = prev.filter((s) => s.id !== subagent.id); next.push(subagent); @@ -612,7 +663,7 @@ export function useSessionChat(sessionId: string) { const payload: ChatMessagePayload = { sessionId, content: trimmed, - conversationId: options?.conversationId ?? PI_AGENT.id, + conversationId: options?.conversationId ?? primaryConversationId, delivery: options?.delivery ?? "auto", ...(hasAttachments ? { attachments } : {}), }; @@ -725,6 +776,14 @@ export function useSessionChat(sessionId: string) { return messagesByConversation.get(conversationId) ?? NO_MESSAGES; } + // The Conversation an agent's thread lives in, or the agent id itself when the + // roster hasn't mapped it yet (a legacy agent whose ids coincide, or Prime + // before the roster arrives). Components resolve an agent tab/card to its + // thread through this rather than assuming `conversationId === agentId`. + function conversationForAgent(agentId: string): string { + return conversationByAgent.get(agentId) ?? agentId; + } + // Removes a sub-agent from the local roster (e.g. dismissing a killed agent // from the sidebar). The server still tracks it, so it reappears on the next // `subagent:roster` snapshot after a reconnect. @@ -735,6 +794,8 @@ export function useSessionChat(sessionId: string) { return { messagesFor, subagents, + primaryConversationId, + conversationForAgent, triggers, artifacts, pinnedPaths, @@ -746,7 +807,7 @@ export function useSessionChat(sessionId: string) { confirmMemory, dismissMemory, // The main thread's busy state drives the header/input; Prime owns it. - agentBusy: isConversationBusy(PI_AGENT.id), + agentBusy: isConversationBusy(primaryConversationId), isConversationBusy, getActivity, isMessageStreaming, diff --git a/apps/web/src/features/chat/model/agents.ts b/apps/web/src/features/chat/model/agents.ts index 7e7dd39..a1e2499 100644 --- a/apps/web/src/features/chat/model/agents.ts +++ b/apps/web/src/features/chat/model/agents.ts @@ -11,7 +11,11 @@ import { * status indicator reflects the agent's lifecycle. */ export interface Agent { - /** Stable id; also the agent's `ChatAuthor.id` / conversation id. */ + /** + * Stable id; the agent's `ChatAuthor.id`. No longer the conversation id — a + * thread is resolved to its Conversation through the roster (`conversationId`) + * / `conversationForAgent`, decoupled since 2.4. + */ id: string; name: string; kind: "prime" | "subagent"; diff --git a/packages/shared/src/contracts.ts b/packages/shared/src/contracts.ts index 0301cc6..dd9d349 100644 --- a/packages/shared/src/contracts.ts +++ b/packages/shared/src/contracts.ts @@ -751,6 +751,13 @@ export interface MembershipView { export interface SubagentInfo { /** Stable id; also used as the sub-agent's `ChatAuthor.id`. */ id: string; + /** + * The Conversation this sub-agent's thread lives in — what the client + * subscribes to and buckets its messages under. Decoupled from {@link + * SubagentInfo.id}: a fresh id for a sub-agent spawned after 2.4, the agent's + * own id for a legacy one whose transcript is keyed that way. + */ + conversationId: string; name: string; status: SubagentStatus; /** The connector that runs the sub-agent. */ @@ -821,9 +828,10 @@ export interface ChatMessage { id: string; sessionId: string; /** - * The agent process this message belongs to: `"prime"` for the shared - * human/Prime thread, or a sub-agent's id for that sub-agent's thread. Drives - * which transcript the client buckets the message into. + * The Conversation this message belongs to — which transcript the client + * buckets it into. A Conversation id in its own right, no longer an agent's + * id: mapped to its owning participant through the `conversations` table, so a + * thread can outlive or hold more than the one agent it started with. */ conversationId: string; /** @@ -1092,6 +1100,12 @@ export interface AgentQueuePayload { export interface SubagentRosterPayload { sessionId: string; subagents: SubagentInfo[]; + /** + * The orchestrator's home Conversation — the primary ("Prime") thread the UI + * renders in its main tab. Server-derived from the `orchestrator` capability + * so the client no longer privileges a reserved `"prime"` id. + */ + primaryConversationId: string; } /** A single sub-agent's spawn or status change. Upserted by `id` on the client. */ From 318f7e37287455aca38c0cd080d753a512a2ae86 Mon Sep 17 00:00:00 2001 From: Maksym Yezhov Date: Fri, 14 Aug 2026 12:43:49 -0700 Subject: [PATCH 15/18] refactor: Resource catalog + ResourceReferences tables, backfill, write-through --- .../conversation/conversationRouter.test.ts | 57 +- .../src/conversation/conversationRouter.ts | 66 ++ .../src/conversation/resourceCatalog.ts | 57 ++ apps/server/src/index.ts | 19 +- .../0014_powerful_hannibal_king.sql | 26 + .../db/migrations/meta/0014_snapshot.json | 914 ++++++++++++++++++ .../store/db/migrations/meta/_journal.json | 7 + apps/server/src/store/db/schema.ts | 78 ++ .../server/src/store/inMemoryResourceStore.ts | 99 ++ apps/server/src/store/inMemorySessionStore.ts | 13 +- apps/server/src/store/resourceStore.ts | 78 ++ .../src/store/sqliteResourceStore.test.ts | 134 +++ apps/server/src/store/sqliteResourceStore.ts | 134 +++ .../src/store/sqliteSessionStore.test.ts | 32 + apps/server/src/store/sqliteSessionStore.ts | 24 +- 15 files changed, 1731 insertions(+), 7 deletions(-) create mode 100644 apps/server/src/conversation/resourceCatalog.ts create mode 100644 apps/server/src/store/db/migrations/0014_powerful_hannibal_king.sql create mode 100644 apps/server/src/store/db/migrations/meta/0014_snapshot.json create mode 100644 apps/server/src/store/inMemoryResourceStore.ts create mode 100644 apps/server/src/store/resourceStore.ts create mode 100644 apps/server/src/store/sqliteResourceStore.test.ts create mode 100644 apps/server/src/store/sqliteResourceStore.ts diff --git a/apps/server/src/conversation/conversationRouter.test.ts b/apps/server/src/conversation/conversationRouter.test.ts index 1092ce0..1f79b6e 100644 --- a/apps/server/src/conversation/conversationRouter.test.ts +++ b/apps/server/src/conversation/conversationRouter.test.ts @@ -11,9 +11,11 @@ import type { Server } from "socket.io"; import type { ConnectorRegistry } from "../connectors/connectorRegistry.ts"; import type { DeliveryRequest } from "../connectors/types.ts"; import { InMemoryMembershipStore } from "../store/inMemoryMembershipStore.ts"; +import { InMemoryResourceStore } from "../store/inMemoryResourceStore.ts"; import { InMemorySessionStore } from "../store/inMemorySessionStore.ts"; import { ConversationRouter } from "./conversationRouter.ts"; import { MembershipRegistry } from "./membershipRegistry.ts"; +import { ResourceCatalog } from "./resourceCatalog.ts"; const WORKER: ChatAuthor = { id: "sub-1", @@ -52,9 +54,15 @@ function makeRouter() { }), } as unknown as ConnectorRegistry; - const router = new ConversationRouter(io, sessions, memberships); + const resourceStore = new InMemoryResourceStore(); + const router = new ConversationRouter( + io, + sessions, + memberships, + new ResourceCatalog(resourceStore), + ); router.useConnectors(connectors); - return { router, sessions, emitted, delivered }; + return { router, sessions, emitted, delivered, resourceStore }; } /** A persisted sub-agent, so its Conversation's memberships derive from a row. */ @@ -140,6 +148,51 @@ test("a participant with no membership there posts nothing at all", async () => assert.deepEqual(h.emitted, [], "nothing reached the room either"); }); +test("an attachment on a post is catalogued and referenced into its conversation", async () => { + const h = makeRouter(); + await withWorker(h.sessions); + + await h.router.post({ + sessionId: "s1", + conversationId: "sub-1", + author: WORKER, + content: "here is the data", + attachments: [ + { + name: "data.csv", + path: "uploads/data.csv", + size: 128, + contentType: "text/csv", + }, + ], + }); + + const referenced = await h.resourceStore.listForConversation("s1", "sub-1"); + assert.equal(referenced.length, 1); + assert.equal(referenced[0].kind, "attachment"); + assert.equal(referenced[0].uri, "uploads/data.csv"); + assert.equal(referenced[0].authorParticipantId, "sub-1"); + assert.deepEqual(referenced[0].meta, { size: 128, contentType: "text/csv" }); +}); + +test("a memory write on a post is catalogued and referenced into its conversation", async () => { + const h = makeRouter(); + await withWorker(h.sessions); + + await h.router.post({ + sessionId: "s1", + conversationId: "sub-1", + author: WORKER, + content: "remembered a preference", + memory: { scope: "session" }, + }); + + const referenced = await h.resourceStore.listForConversation("s1", "sub-1"); + assert.equal(referenced.length, 1); + assert.equal(referenced[0].kind, "memory"); + assert.equal(referenced[0].uri, "memory://session"); +}); + test("posting into the conversation it was written from is an ordinary post", async () => { const h = makeRouter(); await withWorker(h.sessions); diff --git a/apps/server/src/conversation/conversationRouter.ts b/apps/server/src/conversation/conversationRouter.ts index e5f975a..a0c2ba8 100644 --- a/apps/server/src/conversation/conversationRouter.ts +++ b/apps/server/src/conversation/conversationRouter.ts @@ -18,10 +18,12 @@ import type { Server } from "socket.io"; import type { ConnectorRegistry } from "../connectors/connectorRegistry.ts"; import { messageRoomFor } from "../sockets/rooms.ts"; import type { Membership } from "../store/membershipStore.ts"; +import type { CatalogInput } from "../store/resourceStore.ts"; import type { SessionStore } from "../store/sessionStore.ts"; import { FanOutEngine, type FanOutResult } from "./fanOut.ts"; import type { MembershipRegistry } from "./membershipRegistry.ts"; import { participantForConversation } from "./participantRegistry.ts"; +import type { ResourceCatalog } from "./resourceCatalog.ts"; /** * Everything a Message needs beyond its envelope defaults. `seq` comes from the @@ -168,6 +170,38 @@ export function deliveryText( return `${frameFor(message, recipient)}\n\n${body}`; } +/** The catalog entry a human attachment on a Message stands for. */ +function attachmentResource( + message: ChatMessage, + attachment: Attachment, +): CatalogInput { + const meta: Record = { size: attachment.size }; + if (attachment.contentType) meta.contentType = attachment.contentType; + return { + sessionId: message.sessionId, + kind: "attachment", + name: attachment.name, + uri: attachment.path, + authorParticipantId: message.author.id, + meta, + }; +} + +/** The catalog entry a memory write surfaced in a Message stands for. */ +function memoryResource( + message: ChatMessage, + scope: MemoryScope, +): CatalogInput { + return { + sessionId: message.sessionId, + kind: "memory", + name: scope === "global" ? "Global memory" : "Session memory", + uri: `memory://${scope}`, + authorParticipantId: message.author.id, + meta: { scope }, + }; +} + /** * The one way a Message enters a Conversation: allocate its ordinal, persist it, * broadcast it, then let the fan-out engine decide who reacts. Nothing else @@ -178,6 +212,13 @@ export class ConversationRouter { private readonly io: Server; private readonly store: SessionStore; private readonly memberships: MembershipRegistry; + /** + * Catalogs the content a Message carries — attachments and memory writes — and + * references it into this Conversation, so it surfaces as citable content + * regardless of which connector produced it. Optional so a bare router (e.g. a + * test) skips the mirror. + */ + private readonly resources?: ResourceCatalog; private readonly engine: FanOutEngine; private connectors?: ConnectorRegistry; @@ -185,10 +226,12 @@ export class ConversationRouter { io: Server, store: SessionStore, memberships: MembershipRegistry, + resources?: ResourceCatalog, ) { this.io = io; this.store = store; this.memberships = memberships; + this.resources = resources; this.engine = new FanOutEngine( memberships, () => this.requireConnectors(), @@ -219,6 +262,7 @@ export class ConversationRouter { const message = buildMessage({ ...input, seq }); // Persist before broadcasting so a reconnecting client sees it in history. await this.store.appendMessage(message); + await this.catalogContent(message); this.broadcast(message, input.broadcast); if (input.provokes === false) { return { message, woke: [], refused: [] }; @@ -273,6 +317,28 @@ export class ConversationRouter { }; } + /** + * Mirrors the content a Message carries into the resource catalog and + * references it into this Conversation: each human attachment, and a memory + * write's surfaced document. The bytes are untouched — this only records that + * the content exists and appears here. + */ + private async catalogContent(message: ChatMessage): Promise { + if (!this.resources) return; + for (const attachment of message.attachments ?? []) { + await this.resources.catalogIn( + message.conversationId, + attachmentResource(message, attachment), + ); + } + if (message.memory) { + await this.resources.catalogIn( + message.conversationId, + memoryResource(message, message.memory.scope), + ); + } + } + private broadcast( message: ChatMessage, override?: (message: ChatMessage) => void, diff --git a/apps/server/src/conversation/resourceCatalog.ts b/apps/server/src/conversation/resourceCatalog.ts new file mode 100644 index 0000000..492af26 --- /dev/null +++ b/apps/server/src/conversation/resourceCatalog.ts @@ -0,0 +1,57 @@ +import type { + CatalogInput, + Resource, + ResourceStore, +} from "../store/resourceStore.ts"; + +/** + * The one way content enters the catalog. Fronts a + * {@link import("../store/resourceStore.ts").ResourceStore} the way + * {@link import("./membershipRegistry.ts").MembershipRegistry} fronts a + * membership store, so the write paths (a pinned artifact, a message + * attachment, a memory write) catalogue and reference content without touching + * the store directly. + * + * A reference governs surfacing and citation, not filesystem access: it records + * that a resource appears in a Conversation, and does not interpose on the tool + * reads and writes an agent makes against the session root. + */ +export class ResourceCatalog { + private readonly store: ResourceStore; + + constructor(store: ResourceStore) { + this.store = store; + } + + /** Catalogs content, returning the stored resource (id stable per uri). */ + async catalog(input: CatalogInput): Promise { + return this.store.catalog(input); + } + + /** Catalogs content and surfaces it in one Conversation, in one call. */ + async catalogIn( + conversationId: string, + input: CatalogInput, + ): Promise { + const resource = await this.store.catalog(input); + await this.store.reference({ + sessionId: input.sessionId, + conversationId, + resourceId: resource.id, + }); + return resource; + } + + /** Drops a catalogued resource by `(sessionId, uri)`, cascading references. */ + async remove(sessionId: string, uri: string): Promise { + await this.store.remove(sessionId, uri); + } + + /** Every resource referenced in one Conversation. */ + async listForConversation( + sessionId: string, + conversationId: string, + ): Promise { + return this.store.listForConversation(sessionId, conversationId); + } +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index c7f4d98..0c745a2 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -12,6 +12,7 @@ import { ConversationRouter } from "./conversation/conversationRouter.ts"; import { MembershipRegistry } from "./conversation/membershipRegistry.ts"; import { ParticipantRegistry } from "./conversation/participantRegistry.ts"; import { ParticipantService } from "./conversation/participantService.ts"; +import { ResourceCatalog } from "./conversation/resourceCatalog.ts"; import { ExternalSubagentGateway } from "./external/externalSubagentGateway.ts"; import { RelayRegistry } from "./mcp/relayRegistry.ts"; import { createRelayReport } from "./mcp/relayReport.ts"; @@ -57,6 +58,7 @@ import { openDb } from "./store/db/client.ts"; import { FileAgentBundleStore } from "./store/fileAgentBundleStore.ts"; import { SqliteMembershipStore } from "./store/sqliteMembershipStore.ts"; import { SqliteParticipantStore } from "./store/sqliteParticipantStore.ts"; +import { SqliteResourceStore } from "./store/sqliteResourceStore.ts"; import { SqliteRunStore } from "./store/sqliteRunStore.ts"; import { SqliteSessionStore } from "./store/sqliteSessionStore.ts"; @@ -69,7 +71,11 @@ const db = openDb(); // stays the write authority for this PR; this keeps the `participants` table // tracking it so Phase 2 consumers read a populated table. const participants = new SqliteParticipantStore(db); -const store = new SqliteSessionStore(db, participants); +// The resource catalog every content path mirrors into: a pinned artifact, a +// message attachment, a memory write. Additive for now — the existing stores +// stay authoritative and this table tracks them so a resource is citable. +const resourceStore = new SqliteResourceStore(db); +const store = new SqliteSessionStore(db, participants, resourceStore); // Filesystem-backed marketplace of saved agent bundles. const agentBundleStore = new FileAgentBundleStore(); @@ -111,8 +117,15 @@ const participantRegistry = new ParticipantRegistry(store, participants); // The one way a Message enters a Conversation: persist, broadcast, then deliver // to whoever reacts. Every entry point — a human turn, a trigger firing, a tool -// call, a finalized agent turn — goes through it. -const conversations = new ConversationRouter(io, store, memberships); +// call, a finalized agent turn — goes through it. It also mirrors the content a +// Message carries (attachments, memory writes) into the resource catalog. +const resourceCatalog = new ResourceCatalog(resourceStore); +const conversations = new ConversationRouter( + io, + store, + memberships, + resourceCatalog, +); // Shared event sink: a participant's streaming events, roster changes and posted // messages land the same way whether it runs locally (PiAgentManager), in a diff --git a/apps/server/src/store/db/migrations/0014_powerful_hannibal_king.sql b/apps/server/src/store/db/migrations/0014_powerful_hannibal_king.sql new file mode 100644 index 0000000..f46486f --- /dev/null +++ b/apps/server/src/store/db/migrations/0014_powerful_hannibal_king.sql @@ -0,0 +1,26 @@ +CREATE TABLE `resource_references` ( + `session_id` text NOT NULL, + `conversation_id` text NOT NULL, + `resource_id` text NOT NULL, + `created_at` text NOT NULL, + FOREIGN KEY (`session_id`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`resource_id`) REFERENCES `resources`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `resource_references_conversation_idx` ON `resource_references` (`session_id`,`conversation_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `resource_references_conversation_resource` ON `resource_references` (`conversation_id`,`resource_id`);--> statement-breakpoint +CREATE TABLE `resources` ( + `id` text PRIMARY KEY NOT NULL, + `session_id` text NOT NULL, + `kind` text NOT NULL, + `name` text NOT NULL, + `uri` text NOT NULL, + `author_participant_id` text, + `meta` text, + `created_at` text NOT NULL, + FOREIGN KEY (`session_id`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `resources_session_idx` ON `resources` (`session_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `resources_session_uri` ON `resources` (`session_id`,`uri`);--> statement-breakpoint +INSERT OR IGNORE INTO `resources` (`id`, `session_id`, `kind`, `name`, `uri`, `author_participant_id`, `meta`, `created_at`) SELECT lower(hex(randomblob(16))), `session_id`, 'artifact', `title`, `path`, NULL, NULL, `pinned_at` FROM `session_assets`; \ No newline at end of file diff --git a/apps/server/src/store/db/migrations/meta/0014_snapshot.json b/apps/server/src/store/db/migrations/meta/0014_snapshot.json new file mode 100644 index 0000000..bc7e626 --- /dev/null +++ b/apps/server/src/store/db/migrations/meta/0014_snapshot.json @@ -0,0 +1,914 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "aa8a0a85-6e01-4a97-95bc-f29287e10a80", + "prevId": "90d55ec2-f656-456a-9c19-ad35af0d88d6", + "tables": { + "conversations": { + "name": "conversations", + "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 + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_seq": { + "name": "next_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "conversations_session_idx": { + "name": "conversations_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "conversations_session_agent_idx": { + "name": "conversations_session_agent_idx", + "columns": ["session_id", "agent_id"], + "isUnique": false + }, + "conversations_session_id": { + "name": "conversations_session_id", + "columns": ["session_id", "id"], + "isUnique": true + } + }, + "foreignKeys": { + "conversations_session_id_sessions_id_fk": { + "name": "conversations_session_id_sessions_id_fk", + "tableFrom": "conversations", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "memberships": { + "name": "memberships", + "columns": { + "participant_id": { + "name": "participant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reaction": { + "name": "reaction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'never'" + }, + "ingress": { + "name": "ingress", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'reaction'" + }, + "transcript_visibility": { + "name": "transcript_visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'shared'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "memberships_session_conversation_idx": { + "name": "memberships_session_conversation_idx", + "columns": ["session_id", "conversation_id"], + "isUnique": false + }, + "memberships_session_conversation_participant": { + "name": "memberships_session_conversation_participant", + "columns": ["session_id", "conversation_id", "participant_id"], + "isUnique": true + } + }, + "foreignKeys": { + "memberships_session_id_sessions_id_fk": { + "name": "memberships_session_id_sessions_id_fk", + "tableFrom": "memberships", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "participants": { + "name": "participants", + "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 + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "presence": { + "name": "presence", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'connected'" + }, + "connector_kind": { + "name": "connector_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_lifecycle": { + "name": "connector_lifecycle", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_environment_id": { + "name": "connector_environment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_endpoint_url": { + "name": "connector_endpoint_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_payload": { + "name": "agent_payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "participants_session_idx": { + "name": "participants_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "participants_session_id": { + "name": "participants_session_id", + "columns": ["session_id", "id"], + "isUnique": true + } + }, + "foreignKeys": { + "participants_session_id_sessions_id_fk": { + "name": "participants_session_id_sessions_id_fk", + "tableFrom": "participants", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "resource_references": { + "name": "resource_references", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "resource_references_conversation_idx": { + "name": "resource_references_conversation_idx", + "columns": ["session_id", "conversation_id"], + "isUnique": false + }, + "resource_references_conversation_resource": { + "name": "resource_references_conversation_resource", + "columns": ["conversation_id", "resource_id"], + "isUnique": true + } + }, + "foreignKeys": { + "resource_references_session_id_sessions_id_fk": { + "name": "resource_references_session_id_sessions_id_fk", + "tableFrom": "resource_references", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resource_references_resource_id_resources_id_fk": { + "name": "resource_references_resource_id_resources_id_fk", + "tableFrom": "resource_references", + "tableTo": "resources", + "columnsFrom": ["resource_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "resources": { + "name": "resources", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_participant_id": { + "name": "author_participant_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "meta": { + "name": "meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "resources_session_idx": { + "name": "resources_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "resources_session_uri": { + "name": "resources_session_uri", + "columns": ["session_id", "uri"], + "isUnique": true + } + }, + "foreignKeys": { + "resources_session_id_sessions_id_fk": { + "name": "resources_session_id_sessions_id_fk", + "tableFrom": "resources", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "runs": { + "name": "runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "participant_id": { + "name": "participant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "home_conversation_id": { + "name": "home_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "ingress": { + "name": "ingress", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": 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 + }, + "ended_at": { + "name": "ended_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "runs_session_idx": { + "name": "runs_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "runs_session_participant_idx": { + "name": "runs_session_participant_idx", + "columns": ["session_id", "participant_id"], + "isUnique": false + } + }, + "foreignKeys": { + "runs_session_id_sessions_id_fk": { + "name": "runs_session_id_sessions_id_fk", + "tableFrom": "runs", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "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'" + }, + "connector_kind": { + "name": "connector_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_lifecycle": { + "name": "connector_lifecycle", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_environment_id": { + "name": "connector_environment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_endpoint_url": { + "name": "connector_endpoint_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "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 7fb9b32..0f00f8a 100644 --- a/apps/server/src/store/db/migrations/meta/_journal.json +++ b/apps/server/src/store/db/migrations/meta/_journal.json @@ -99,6 +99,13 @@ "when": 1786650882282, "tag": "0013_chief_veda", "breakpoints": true + }, + { + "idx": 14, + "version": "6", + "when": 1786661064702, + "tag": "0014_powerful_hannibal_king", + "breakpoints": true } ] } diff --git a/apps/server/src/store/db/schema.ts b/apps/server/src/store/db/schema.ts index 04dfa4c..25eabd9 100644 --- a/apps/server/src/store/db/schema.ts +++ b/apps/server/src/store/db/schema.ts @@ -308,6 +308,82 @@ export const participants = sqliteTable( ], ); +/** + * A catalogued piece of content in a session, regardless of which connector or + * mechanism produced it: a pinned `artifact`, a human `attachment`, a `memory` + * document, or a workspace `file`. The unification of `session_assets`, + * `Attachment` (embedded in JSONL), and the memory files, so "what content does + * this session hold, and who authored it" has one answer. + * + * The bytes stay where they are (on disk under the session root, or in a memory + * markdown file); this row is the catalog entry that points at them by `uri`. + * Additive for this PR — the mechanisms above stay the write authority and + * mirror into this table; a later cleanup can fold them onto it. + */ +export const resources = sqliteTable( + "resources", + { + /** Resource id: a fresh uuid, or an opaque id for a backfilled row. */ + id: text("id").primaryKey(), + sessionId: text("session_id") + .notNull() + .references(() => sessions.id, { onDelete: "cascade" }), + /** `file` | `memory` | `attachment` | `artifact`. */ + kind: text("kind").notNull(), + /** Display name / title. */ + name: text("name").notNull(), + /** + * Where the content lives: a path relative to the session root (e.g. + * `artifacts/report.html`, `uploads/data.csv`) or a `memory://session` / + * `memory://global` scheme for a memory document. + */ + uri: text("uri").notNull(), + /** The participant that produced it; null for a backfilled/legacy row. */ + authorParticipantId: text("author_participant_id"), + /** Kind-specific JSON blob (e.g. `contentType`, `size`, `scope`). */ + meta: text("meta"), + createdAt: text("created_at").notNull(), + }, + (table) => [ + unique("resources_session_uri").on(table.sessionId, table.uri), + index("resources_session_idx").on(table.sessionId), + ], +); + +/** + * A {@link resources} entry surfaced in one Conversation: the answer to "should + * this content appear in this thread, regardless of which connector produced + * it". A reference governs surfacing and citation, not filesystem access — + * agents read the workspace through tools against the session root and a + * reference does not interpose on a read or write. + * + * `session_id` is here because a conversation id is only unique within a + * session (as on `memberships`), and for the cascade delete. + */ +export const resourceReferences = sqliteTable( + "resource_references", + { + sessionId: text("session_id") + .notNull() + .references(() => sessions.id, { onDelete: "cascade" }), + conversationId: text("conversation_id").notNull(), + resourceId: text("resource_id") + .notNull() + .references(() => resources.id, { onDelete: "cascade" }), + createdAt: text("created_at").notNull(), + }, + (table) => [ + unique("resource_references_conversation_resource").on( + table.conversationId, + table.resourceId, + ), + index("resource_references_conversation_idx").on( + table.sessionId, + table.conversationId, + ), + ], +); + /** When each user last opened a session. `user_key` is the email, or `local`. */ export const sessionViews = sqliteTable( "session_views", @@ -331,3 +407,5 @@ export type RunRow = typeof runs.$inferSelect; export type ConversationRow = typeof conversations.$inferSelect; export type MembershipRow = typeof memberships.$inferSelect; export type ParticipantRow = typeof participants.$inferSelect; +export type ResourceRow = typeof resources.$inferSelect; +export type ResourceReferenceRow = typeof resourceReferences.$inferSelect; diff --git a/apps/server/src/store/inMemoryResourceStore.ts b/apps/server/src/store/inMemoryResourceStore.ts new file mode 100644 index 0000000..551cf33 --- /dev/null +++ b/apps/server/src/store/inMemoryResourceStore.ts @@ -0,0 +1,99 @@ +import { randomUUID } from "node:crypto"; + +import type { + CatalogInput, + Resource, + ResourceReference, + ResourceStore, +} from "./resourceStore.ts"; + +/** Key of one resource, matching the table's `(session_id, uri)` uniqueness. */ +function uriKey(sessionId: string, uri: string): string { + return `${sessionId}\u0000${uri}`; +} + +/** Key of one reference, matching `(conversation_id, resource_id)`. */ +function refKey(conversationId: string, resourceId: string): string { + return `${conversationId}\u0000${resourceId}`; +} + +/** + * Process-local {@link ResourceStore}, mirroring + * {@link import("./inMemoryMembershipStore.ts").InMemoryMembershipStore}. For + * tests and for a bare store with no DB to write to. Insertion order stands in + * for the `createdAt` ordering the SQLite store gets from its column. + */ +export class InMemoryResourceStore implements ResourceStore { + /** resource id -> resource, in insertion order. */ + private readonly byId = new Map(); + /** `(session, uri)` -> resource id, so a re-catalogue keeps the id. */ + private readonly byUri = new Map(); + /** `(conversation, resource)` -> reference, in insertion order. */ + private readonly refs = new Map(); + + async catalog(input: CatalogInput): Promise { + const key = uriKey(input.sessionId, input.uri); + const existingId = this.byUri.get(key); + if (existingId) { + const prior = this.byId.get(existingId) as Resource; + const updated: Resource = { + ...prior, + kind: input.kind, + name: input.name, + authorParticipantId: input.authorParticipantId, + meta: input.meta, + }; + this.byId.set(existingId, updated); + return updated; + } + const resource: Resource = { + id: randomUUID(), + sessionId: input.sessionId, + kind: input.kind, + name: input.name, + uri: input.uri, + authorParticipantId: input.authorParticipantId, + meta: input.meta, + createdAt: new Date().toISOString(), + }; + this.byId.set(resource.id, resource); + this.byUri.set(key, resource.id); + return resource; + } + + async remove(sessionId: string, uri: string): Promise { + const key = uriKey(sessionId, uri); + const id = this.byUri.get(key); + if (!id) return; + this.byUri.delete(key); + this.byId.delete(id); + for (const [refId, ref] of this.refs) { + if (ref.resourceId === id) this.refs.delete(refId); + } + } + + async reference(ref: ResourceReference): Promise { + const key = refKey(ref.conversationId, ref.resourceId); + if (!this.refs.has(key)) this.refs.set(key, ref); + } + + async listForSession(sessionId: string): Promise { + return [...this.byId.values()].filter( + (resource) => resource.sessionId === sessionId, + ); + } + + async listForConversation( + sessionId: string, + conversationId: string, + ): Promise { + const out: Resource[] = []; + for (const ref of this.refs.values()) { + if (ref.sessionId !== sessionId) continue; + if (ref.conversationId !== conversationId) continue; + const resource = this.byId.get(ref.resourceId); + if (resource) out.push(resource); + } + return out; + } +} diff --git a/apps/server/src/store/inMemorySessionStore.ts b/apps/server/src/store/inMemorySessionStore.ts index d92c5f8..e3ef5b6 100644 --- a/apps/server/src/store/inMemorySessionStore.ts +++ b/apps/server/src/store/inMemorySessionStore.ts @@ -17,6 +17,7 @@ import { participantFromAgent, type ParticipantStore, } from "./participantStore.ts"; +import type { ResourceStore } from "./resourceStore.ts"; import { connectorFromHost, type CreateSessionParams, @@ -86,9 +87,12 @@ export class InMemorySessionStore implements SessionStore { private readonly homeConversations = new Map>(); /** Mirrors each recorded roster row, matching the SQLite store's dual-write. */ private readonly participants?: ParticipantStore; + /** Mirrors each pinned artifact into the resource catalog, when present. */ + private readonly resources?: ResourceStore; - constructor(participants?: ParticipantStore) { + constructor(participants?: ParticipantStore, resources?: ResourceStore) { this.participants = participants; + this.resources = resources; } async listSessions(): Promise { @@ -236,6 +240,12 @@ export class InMemorySessionStore implements SessionStore { ? existing.map((a) => (a.path === artifact.path ? next : a)) : [...existing, next]; this.artifacts.set(sessionId, updated); + await this.resources?.catalog({ + sessionId, + kind: "artifact", + name: artifact.title, + uri: artifact.path, + }); return updated; } @@ -246,6 +256,7 @@ export class InMemorySessionStore implements SessionStore { const existing = this.artifacts.get(sessionId) ?? []; const updated = existing.filter((a) => a.path !== path); this.artifacts.set(sessionId, updated); + await this.resources?.remove(sessionId, path); return updated; } diff --git a/apps/server/src/store/resourceStore.ts b/apps/server/src/store/resourceStore.ts new file mode 100644 index 0000000..20439d0 --- /dev/null +++ b/apps/server/src/store/resourceStore.ts @@ -0,0 +1,78 @@ +/** What a catalogued resource is: content the session holds, by origin. */ +export type ResourceKind = "file" | "memory" | "attachment" | "artifact"; + +/** + * A catalogued piece of content in a session — a pinned `artifact`, a human + * `attachment`, a `memory` document, or a workspace `file` — regardless of which + * connector or mechanism produced it. The bytes stay where they are; this is the + * catalog entry that points at them by {@link Resource.uri}. + */ +export interface Resource { + id: string; + sessionId: string; + kind: ResourceKind; + /** Display name / title. */ + name: string; + /** + * Where the content lives: a path relative to the session root (e.g. + * `artifacts/report.html`) or a `memory://session` / `memory://global` scheme. + */ + uri: string; + /** The participant that produced it, when known. */ + authorParticipantId?: string; + /** Kind-specific facts (e.g. `contentType`, `size`, `scope`). */ + meta?: Record; + createdAt: string; +} + +/** + * Fields accepted when cataloguing. The store assigns `id` and `createdAt` the + * first time a `(sessionId, uri)` is seen and preserves them on re-catalogue. + */ +export interface CatalogInput { + sessionId: string; + kind: ResourceKind; + name: string; + uri: string; + authorParticipantId?: string; + meta?: Record; +} + +/** A {@link Resource} surfaced in one Conversation. */ +export interface ResourceReference { + sessionId: string; + conversationId: string; + resourceId: string; +} + +/** + * Durable home of the session's {@link Resource}s and the Conversations they are + * referenced in. Kept apart from + * {@link import("./sessionStore.ts").SessionStore} for the same reason + * {@link import("./membershipStore.ts").MembershipStore} is: it is written + * through by a small catalog on the content paths, not read by the REST routes. + * The existing mechanisms (`session_assets`, message attachments, memory files) + * stay the write authority for this PR and mirror into it. + */ +export interface ResourceStore { + /** + * Upserts a resource by `(sessionId, uri)`, returning the stored row. + * Re-cataloguing a known uri refreshes `name`/`authorParticipantId`/`meta` + * while keeping the original `id` and `createdAt`. + */ + catalog(input: CatalogInput): Promise; + /** + * Removes a resource by `(sessionId, uri)`, cascading its references. A no-op + * when nothing is catalogued there. + */ + remove(sessionId: string, uri: string): Promise; + /** Upserts a reference by `(conversationId, resourceId)`. */ + reference(ref: ResourceReference): Promise; + /** Every catalogued resource in a session, oldest first. */ + listForSession(sessionId: string): Promise; + /** Every resource referenced in one Conversation, oldest referenced first. */ + listForConversation( + sessionId: string, + conversationId: string, + ): Promise; +} diff --git a/apps/server/src/store/sqliteResourceStore.test.ts b/apps/server/src/store/sqliteResourceStore.test.ts new file mode 100644 index 0000000..6ef1c93 --- /dev/null +++ b/apps/server/src/store/sqliteResourceStore.test.ts @@ -0,0 +1,134 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { after, test } from "node:test"; + +// Point the session root at a throwaway dir before importing modules that read +// config at load time, so `createSession`'s mkdir never touches the repo. +const ROOT = mkdtempSync(path.join(tmpdir(), "resource-store-")); +process.env.SESSIONS_ROOT = ROOT; + +const { openDb } = await import("./db/client.ts"); +const { SqliteResourceStore } = await import("./sqliteResourceStore.ts"); +const { SqliteSessionStore } = await import("./sqliteSessionStore.ts"); + +after(() => rmSync(ROOT, { recursive: true, force: true })); + +/** A fresh in-memory DB with a session row the resources can hang off of. */ +async function fixture() { + const db = openDb(":memory:"); + const sessions = new SqliteSessionStore(db); + const resources = new SqliteResourceStore(db); + const session = await sessions.createSession({ name: "S" }); + return { sessions, resources, sessionId: session.id }; +} + +test("catalog dedupes by (session, uri), refreshing in place", async () => { + const { resources, sessionId } = await fixture(); + + const first = await resources.catalog({ + sessionId, + kind: "artifact", + name: "Report", + uri: "artifacts/report.html", + }); + const second = await resources.catalog({ + sessionId, + kind: "artifact", + name: "Report (v2)", + uri: "artifacts/report.html", + }); + + // Same uri keeps the id and createdAt; the mutable fields are refreshed. + assert.equal(second.id, first.id); + assert.equal(second.createdAt, first.createdAt); + assert.equal(second.name, "Report (v2)"); + + const all = await resources.listForSession(sessionId); + assert.equal(all.length, 1); +}); + +test("meta round-trips through the JSON column", async () => { + const { resources, sessionId } = await fixture(); + + await resources.catalog({ + sessionId, + kind: "attachment", + name: "data.csv", + uri: "uploads/data.csv", + authorParticipantId: "prime", + meta: { contentType: "text/csv", size: 42 }, + }); + + const [read] = await resources.listForSession(sessionId); + assert.equal(read.authorParticipantId, "prime"); + assert.deepEqual(read.meta, { contentType: "text/csv", size: 42 }); +}); + +test("reference dedupes by (conversation, resource) and scopes by conversation", async () => { + const { resources, sessionId } = await fixture(); + const resource = await resources.catalog({ + sessionId, + kind: "artifact", + name: "Report", + uri: "artifacts/report.html", + }); + + await resources.reference({ + sessionId, + conversationId: "c1", + resourceId: resource.id, + }); + await resources.reference({ + sessionId, + conversationId: "c1", + resourceId: resource.id, + }); + + const inC1 = await resources.listForConversation(sessionId, "c1"); + assert.equal(inC1.length, 1); + assert.equal(inC1[0].id, resource.id); + + // A conversation with no reference to it sees nothing — a reference surfaces + // content in one thread, not the whole session. + const inC2 = await resources.listForConversation(sessionId, "c2"); + assert.equal(inC2.length, 0); +}); + +test("remove drops the resource and cascades its references", async () => { + const { resources, sessionId } = await fixture(); + const resource = await resources.catalog({ + sessionId, + kind: "artifact", + name: "Report", + uri: "artifacts/report.html", + }); + await resources.reference({ + sessionId, + conversationId: "c1", + resourceId: resource.id, + }); + + await resources.remove(sessionId, "artifacts/report.html"); + + assert.equal((await resources.listForSession(sessionId)).length, 0); + assert.equal( + (await resources.listForConversation(sessionId, "c1")).length, + 0, + ); +}); + +test("deleting a session cascades its resources", async () => { + const { sessions, resources, sessionId } = await fixture(); + await resources.catalog({ + sessionId, + kind: "artifact", + name: "Report", + uri: "artifacts/report.html", + }); + + await sessions.deleteSession(sessionId); + + assert.equal((await resources.listForSession(sessionId)).length, 0); +}); diff --git a/apps/server/src/store/sqliteResourceStore.ts b/apps/server/src/store/sqliteResourceStore.ts new file mode 100644 index 0000000..a2ab84f --- /dev/null +++ b/apps/server/src/store/sqliteResourceStore.ts @@ -0,0 +1,134 @@ +import { randomUUID } from "node:crypto"; + +import { and, asc, eq } from "drizzle-orm"; + +import type { Db } from "./db/client.ts"; +import { + resourceReferences, + type ResourceRow, + resources, +} from "./db/schema.ts"; +import type { + CatalogInput, + Resource, + ResourceKind, + ResourceReference, + ResourceStore, +} from "./resourceStore.ts"; + +/** Maps a resources row onto the domain {@link Resource}, parsing `meta`. */ +function toResource(row: ResourceRow): Resource { + return { + id: row.id, + sessionId: row.sessionId, + kind: row.kind as ResourceKind, + name: row.name, + uri: row.uri, + authorParticipantId: row.authorParticipantId ?? undefined, + meta: parseMeta(row.meta), + createdAt: row.createdAt, + }; +} + +/** Parses the JSON-encoded `meta` column into an object, else undefined. */ +function parseMeta(raw: string | null): Record | undefined { + if (!raw) return undefined; + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" + ? (parsed as Record) + : undefined; + } catch { + return undefined; + } +} + +/** SQLite-backed {@link ResourceStore} over the shared session metadata DB. */ +export class SqliteResourceStore implements ResourceStore { + private readonly db: Db; + + constructor(db: Db) { + this.db = db; + } + + async catalog(input: CatalogInput): Promise { + const meta = input.meta ? JSON.stringify(input.meta) : null; + const author = input.authorParticipantId ?? null; + // Re-cataloguing a known (session, uri) refreshes the mutable fields while + // keeping the original id and createdAt; a new uri inserts a fresh row. + const row = this.db + .insert(resources) + .values({ + id: randomUUID(), + sessionId: input.sessionId, + kind: input.kind, + name: input.name, + uri: input.uri, + authorParticipantId: author, + meta, + createdAt: new Date().toISOString(), + }) + .onConflictDoUpdate({ + target: [resources.sessionId, resources.uri], + set: { + kind: input.kind, + name: input.name, + authorParticipantId: author, + meta, + }, + }) + .returning() + .get(); + return toResource(row); + } + + async remove(sessionId: string, uri: string): Promise { + // References cascade on the resource FK, so deleting the row is enough. + this.db + .delete(resources) + .where(and(eq(resources.sessionId, sessionId), eq(resources.uri, uri))) + .run(); + } + + async reference(ref: ResourceReference): Promise { + this.db + .insert(resourceReferences) + .values({ + sessionId: ref.sessionId, + conversationId: ref.conversationId, + resourceId: ref.resourceId, + createdAt: new Date().toISOString(), + }) + .onConflictDoNothing() + .run(); + } + + async listForSession(sessionId: string): Promise { + const rows = this.db + .select() + .from(resources) + .where(eq(resources.sessionId, sessionId)) + .orderBy(asc(resources.createdAt)) + .all(); + return rows.map(toResource); + } + + async listForConversation( + sessionId: string, + conversationId: string, + ): Promise { + const rows = this.db + .select({ resource: resources }) + .from(resourceReferences) + .innerJoin(resources, eq(resourceReferences.resourceId, resources.id)) + .where( + and( + eq(resourceReferences.sessionId, sessionId), + eq(resourceReferences.conversationId, conversationId), + ), + ) + .orderBy(asc(resourceReferences.createdAt)) + .all(); + return rows.map((row) => toResource(row.resource)); + } +} diff --git a/apps/server/src/store/sqliteSessionStore.test.ts b/apps/server/src/store/sqliteSessionStore.test.ts index 6863802..7a1fb53 100644 --- a/apps/server/src/store/sqliteSessionStore.test.ts +++ b/apps/server/src/store/sqliteSessionStore.test.ts @@ -20,6 +20,7 @@ const { connectorFor } = await import("@tangent/shared/contracts.ts"); const { sql } = await import("drizzle-orm"); const { openDb } = await import("./db/client.ts"); const { SqliteParticipantStore } = await import("./sqliteParticipantStore.ts"); +const { SqliteResourceStore } = await import("./sqliteResourceStore.ts"); const { SqliteSessionStore } = await import("./sqliteSessionStore.ts"); after(() => rmSync(ROOT, { recursive: true, force: true })); @@ -497,3 +498,34 @@ test("the 0011 backfill materializes participants and leaves seq seeding alone", // The promotion never invents `next_seq` rows — seeding stays a read concern. assert.equal(countConversations(), conversationsBefore); }); + +test("pinning an artifact mirrors it into the resource catalog", async () => { + const db = openDb(":memory:"); + const resources = new SqliteResourceStore(db); + const store = new SqliteSessionStore(db, undefined, resources); + const session = await store.createSession({ name: "S" }); + + await store.pinArtifact(session.id, { + path: "artifacts/report.html", + title: "Report", + }); + + const catalogued = await resources.listForSession(session.id); + assert.equal(catalogued.length, 1); + assert.equal(catalogued[0].kind, "artifact"); + assert.equal(catalogued[0].uri, "artifacts/report.html"); + assert.equal(catalogued[0].name, "Report"); + + // Re-pinning refreshes the title in place rather than duplicating. + await store.pinArtifact(session.id, { + path: "artifacts/report.html", + title: "Report (final)", + }); + const afterRepin = await resources.listForSession(session.id); + assert.equal(afterRepin.length, 1); + assert.equal(afterRepin[0].name, "Report (final)"); + + // Unpinning removes the catalog entry too. + await store.unpinArtifact(session.id, "artifacts/report.html"); + assert.equal((await resources.listForSession(session.id)).length, 0); +}); diff --git a/apps/server/src/store/sqliteSessionStore.ts b/apps/server/src/store/sqliteSessionStore.ts index 0abfd3c..4411100 100644 --- a/apps/server/src/store/sqliteSessionStore.ts +++ b/apps/server/src/store/sqliteSessionStore.ts @@ -40,6 +40,7 @@ import { participantFromAgent, type ParticipantStore, } from "./participantStore.ts"; +import type { ResourceStore } from "./resourceStore.ts"; import { connectorFromHost, type CreateSessionParams, @@ -159,10 +160,22 @@ export class SqliteSessionStore implements SessionStore { * write authority. Optional so a bare store (e.g. a test) skips the mirror. */ private readonly participants?: ParticipantStore; + /** + * The resource catalog a pinned artifact mirrors into, so an artifact is a + * catalogued, citable resource on the same path a peer's output takes. The + * `session_assets` table stays the write authority for the pin itself; + * optional so a bare store (e.g. a test) skips the mirror. + */ + private readonly resources?: ResourceStore; - constructor(db: Db, participants?: ParticipantStore) { + constructor( + db: Db, + participants?: ParticipantStore, + resources?: ResourceStore, + ) { this.db = db; this.participants = participants; + this.resources = resources; } async listSessions(): Promise { @@ -403,6 +416,14 @@ export class SqliteSessionStore implements SessionStore { set: { title: artifact.title }, }) .run(); + // Mirror the pin into the resource catalog so an artifact is citable + // content; the pin row stays authoritative for the asset list. + await this.resources?.catalog({ + sessionId, + kind: "artifact", + name: artifact.title, + uri: artifact.path, + }); return this.readArtifacts(sessionId); } @@ -419,6 +440,7 @@ export class SqliteSessionStore implements SessionStore { ), ) .run(); + await this.resources?.remove(sessionId, artifactPath); return this.readArtifacts(sessionId); } From 928cf4dd86810ea5abb1a9a1dd3db0acd39b2ab2 Mon Sep 17 00:00:00 2001 From: Maksym Yezhov Date: Fri, 14 Aug 2026 14:23:15 -0700 Subject: [PATCH 16/18] - refactor: Per-Membership grants + workspace-file catalog entries + web surfacing --- .../src/conversation/resourceCatalog.test.ts | 94 ++ .../src/conversation/resourceCatalog.ts | 48 + .../server/src/conversation/workspaceFiles.ts | 57 + apps/server/src/index.ts | 1 + apps/server/src/routes/sessions/index.ts | 4 + apps/server/src/routes/sessions/resources.ts | 45 + .../db/migrations/0015_lonely_annihilus.sql | 12 + .../db/migrations/meta/0015_snapshot.json | 989 ++++++++++++++++++ .../store/db/migrations/meta/_journal.json | 7 + apps/server/src/store/db/schema.ts | 39 + .../server/src/store/inMemoryResourceStore.ts | 51 + apps/server/src/store/resourceStore.ts | 62 +- .../src/store/sqliteResourceStore.test.ts | 80 ++ apps/server/src/store/sqliteResourceStore.ts | 63 ++ .../features/chat/components/SessionChat.tsx | 21 +- .../sidebar/resources/ResourceList.tsx | 99 ++ .../components/windows/ResourcesWindow.tsx | 8 + .../windows/ResourcesWindowHeader.tsx | 22 + .../windows/SessionChatWindowsContext.ts | 5 + .../windows/useSessionChatWindows.tsx | 10 +- .../src/features/chat/hooks/useSessionChat.ts | 11 + .../chat/hooks/useSessionResources.ts | 18 + apps/web/src/features/chat/model/resources.ts | 31 + .../src/features/sessions/api/sessionsApi.ts | 15 + .../sessions/model/sessionQueryKeys.ts | 1 + packages/shared/src/contracts.ts | 33 + 26 files changed, 1799 insertions(+), 27 deletions(-) create mode 100644 apps/server/src/conversation/resourceCatalog.test.ts create mode 100644 apps/server/src/conversation/workspaceFiles.ts create mode 100644 apps/server/src/routes/sessions/resources.ts create mode 100644 apps/server/src/store/db/migrations/0015_lonely_annihilus.sql create mode 100644 apps/server/src/store/db/migrations/meta/0015_snapshot.json create mode 100644 apps/web/src/features/chat/components/sidebar/resources/ResourceList.tsx create mode 100644 apps/web/src/features/chat/components/windows/ResourcesWindow.tsx create mode 100644 apps/web/src/features/chat/components/windows/ResourcesWindowHeader.tsx create mode 100644 apps/web/src/features/chat/hooks/useSessionResources.ts create mode 100644 apps/web/src/features/chat/model/resources.ts diff --git a/apps/server/src/conversation/resourceCatalog.test.ts b/apps/server/src/conversation/resourceCatalog.test.ts new file mode 100644 index 0000000..ad7b383 --- /dev/null +++ b/apps/server/src/conversation/resourceCatalog.test.ts @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { after, test } from "node:test"; + +import { InMemoryResourceStore } from "../store/inMemoryResourceStore.ts"; +import { ResourceCatalog } from "./resourceCatalog.ts"; +import { catalogWorkspaceFiles } from "./workspaceFiles.ts"; + +const ROOTS: string[] = []; +after(() => { + for (const root of ROOTS) rmSync(root, { recursive: true, force: true }); +}); + +/** A session root with the given files written under it (paths relative to root). */ +function workspace(files: Record): { + id: string; + rootPath: string; +} { + const rootPath = mkdtempSync(path.join(tmpdir(), "workspace-")); + ROOTS.push(rootPath); + for (const [rel, body] of Object.entries(files)) { + const full = path.join(rootPath, rel); + mkdirSync(path.dirname(full), { recursive: true }); + writeFileSync(full, body); + } + return { id: "s1", rootPath }; +} + +test("surfacedFor is default-permissive until a grant narrows it", async () => { + const catalog = new ResourceCatalog(new InMemoryResourceStore()); + const a = await catalog.catalogIn("c1", { + sessionId: "s1", + kind: "artifact", + name: "A", + uri: "artifacts/a.html", + }); + const b = await catalog.catalogIn("c1", { + sessionId: "s1", + kind: "artifact", + name: "B", + uri: "artifacts/b.html", + }); + + // No grants: the membership surfaces the whole reference set. + const before = await catalog.surfacedFor("s1", "c1", "ben"); + assert.deepEqual(before.map((r) => r.id).sort(), [a.id, b.id].sort()); + + // A grant to ben narrows ben's view to the granted subset only. + await catalog.grant({ + sessionId: "s1", + conversationId: "c1", + participantId: "ben", + resourceId: a.id, + }); + const forBen = await catalog.surfacedFor("s1", "c1", "ben"); + assert.deepEqual( + forBen.map((r) => r.id), + [a.id], + ); + + // Another participant with no grants still sees everything (default-permissive). + const forAna = await catalog.surfacedFor("s1", "c1", "ana"); + assert.equal(forAna.length, 2); +}); + +test("catalogWorkspaceFiles catalogs files without downgrading known kinds", async () => { + const catalog = new ResourceCatalog(new InMemoryResourceStore()); + const session = workspace({ + "artifacts/report.html": "

hi

", + "artifacts/nested/data.json": "{}", + "uploads/notes.txt": "notes", + }); + + // A path already catalogued as an artifact must keep its kind after a scan. + const pinned = await catalog.catalog({ + sessionId: session.id, + kind: "artifact", + name: "Report", + uri: "artifacts/report.html", + }); + + await catalogWorkspaceFiles(catalog, session); + + const all = await catalog.listForSession(session.id); + const byUri = new Map(all.map((r) => [r.uri, r])); + assert.equal(byUri.get("artifacts/report.html")?.kind, "artifact"); + assert.equal(byUri.get("artifacts/report.html")?.id, pinned.id); + assert.equal(byUri.get("artifacts/nested/data.json")?.kind, "file"); + assert.equal(byUri.get("uploads/notes.txt")?.kind, "file"); + assert.equal(all.length, 3); +}); diff --git a/apps/server/src/conversation/resourceCatalog.ts b/apps/server/src/conversation/resourceCatalog.ts index 492af26..bc11e1e 100644 --- a/apps/server/src/conversation/resourceCatalog.ts +++ b/apps/server/src/conversation/resourceCatalog.ts @@ -1,6 +1,7 @@ import type { CatalogInput, Resource, + ResourceGrant, ResourceStore, } from "../store/resourceStore.ts"; @@ -42,11 +43,31 @@ export class ResourceCatalog { return resource; } + /** Catalogs content without overwriting an entry already at that uri. */ + async catalogIfAbsent(input: CatalogInput): Promise { + return this.store.catalogIfAbsent(input); + } + /** Drops a catalogued resource by `(sessionId, uri)`, cascading references. */ async remove(sessionId: string, uri: string): Promise { await this.store.remove(sessionId, uri); } + /** Grants one Participant sight of one referenced resource in a Conversation. */ + async grant(grant: ResourceGrant): Promise { + await this.store.grant(grant); + } + + /** Revokes a per-Membership grant. */ + async revoke(grant: ResourceGrant): Promise { + await this.store.revoke(grant); + } + + /** Every catalogued resource in a session. */ + async listForSession(sessionId: string): Promise { + return this.store.listForSession(sessionId); + } + /** Every resource referenced in one Conversation. */ async listForConversation( sessionId: string, @@ -54,4 +75,31 @@ export class ResourceCatalog { ): Promise { return this.store.listForConversation(sessionId, conversationId); } + + /** + * The resources one Membership may be shown and may cite in a Conversation. + * Default-permissive: a Membership with no grants surfaces the Conversation's + * whole reference set, so behaviour is unchanged until grants are written and + * enforced (the latter lands with the multi-party transcript UI, 2.6). This is + * the one place unified-model §4.4's "should this surface for this + * Participant" is answered — it is not yet consulted by delivery. + */ + async surfacedFor( + sessionId: string, + conversationId: string, + participantId: string, + ): Promise { + const referenced = await this.store.listForConversation( + sessionId, + conversationId, + ); + const granted = await this.store.listGrants( + sessionId, + conversationId, + participantId, + ); + if (granted.length === 0) return referenced; + const allowed = new Set(granted); + return referenced.filter((resource) => allowed.has(resource.id)); + } } diff --git a/apps/server/src/conversation/workspaceFiles.ts b/apps/server/src/conversation/workspaceFiles.ts new file mode 100644 index 0000000..17e3fd8 --- /dev/null +++ b/apps/server/src/conversation/workspaceFiles.ts @@ -0,0 +1,57 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import { ARTIFACTS_DIRNAME, UPLOADS_DIRNAME } from "../config.ts"; +import type { ResourceCatalog } from "./resourceCatalog.ts"; + +/** The session-root subtrees a workspace file is served from and catalogued in. */ +const SCANNED_DIRS = [ARTIFACTS_DIRNAME, UPLOADS_DIRNAME]; + +/** Recursively collects every regular file under `dir`, absolute paths. */ +async function filesUnder(dir: string): Promise { + const entries = await fs + .readdir(dir, { withFileTypes: true }) + .catch(() => []); + const nested = await Promise.all( + entries.map((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) return filesUnder(full); + if (entry.isFile()) return Promise.resolve([full]); + return Promise.resolve([]); + }), + ); + return nested.flat(); +} + +/** + * Catalogs the session's workspace files (under `artifacts/` and `uploads/`) as + * `file` {@link import("@tangent/shared/contracts.ts").Resource}s, so content an + * agent produced on disk is citable content like a pinned artifact is. Uses + * insert-if-absent so a path already catalogued as an `artifact` or `attachment` + * keeps its kind — the pin/attachment mechanisms stay authoritative for those. + * + * Files are session-scoped and tied to no thread, so they are catalogued but not + * referenced into a Conversation. Called on the read path (scan-then-list), not + * from a background watcher. + */ +export async function catalogWorkspaceFiles( + catalog: ResourceCatalog, + session: { id: string; rootPath: string }, +): Promise { + const roots = SCANNED_DIRS.map((dir) => path.join(session.rootPath, dir)); + const found = (await Promise.all(roots.map(filesUnder))).flat(); + await Promise.all( + found.map(async (absolute) => { + const uri = path + .relative(session.rootPath, absolute) + .split(path.sep) + .join("/"); + await catalog.catalogIfAbsent({ + sessionId: session.id, + kind: "file", + name: path.basename(absolute), + uri, + }); + }), + ); +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 0c745a2..fc90a49 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -277,6 +277,7 @@ app.use( triggerEngine, agentBundleStore, participantService, + resourceCatalog, ), ); app.use("/api/agent-bundles", createAgentBundlesRouter(agentBundleStore)); diff --git a/apps/server/src/routes/sessions/index.ts b/apps/server/src/routes/sessions/index.ts index 0cfd48a..4a5ce49 100644 --- a/apps/server/src/routes/sessions/index.ts +++ b/apps/server/src/routes/sessions/index.ts @@ -1,6 +1,7 @@ import { type Request, type Response, Router } from "express"; import type { ParticipantService } from "../../conversation/participantService.ts"; +import type { ResourceCatalog } from "../../conversation/resourceCatalog.ts"; import { getValidated, validate } from "../../middleware/validate.ts"; import type { PiAgentManager } from "../../pi/piAgentManager.ts"; import type { TriggerEngine } from "../../pi/triggers/triggerEngine.ts"; @@ -19,6 +20,7 @@ import { uploadFiles, } from "./handlers.ts"; import { registerParticipantRoutes } from "./participants.ts"; +import { registerResourceRoutes } from "./resources.ts"; import type { CreateSessionInput, SessionParams, @@ -137,6 +139,7 @@ export function createSessionsRouter( triggerEngine: TriggerEngine, agentBundleStore: AgentBundleStore, participants: ParticipantService, + resources: ResourceCatalog, ): Router { const router = Router(); @@ -151,6 +154,7 @@ export function createSessionsRouter( registerSessionActivityRoutes(router, store); registerTriggerRoutes(router, store, triggers, triggerEngine); registerParticipantRoutes(router, store, participants); + registerResourceRoutes(router, store, resources); // Declared after the trigger routes so the `*splat` catch-all doesn't shadow // the more specific `/:id/triggers/...` paths. diff --git a/apps/server/src/routes/sessions/resources.ts b/apps/server/src/routes/sessions/resources.ts new file mode 100644 index 0000000..a762ac9 --- /dev/null +++ b/apps/server/src/routes/sessions/resources.ts @@ -0,0 +1,45 @@ +import { type Request, type Response, Router } from "express"; + +import type { ResourceCatalog } from "../../conversation/resourceCatalog.ts"; +import { catalogWorkspaceFiles } from "../../conversation/workspaceFiles.ts"; +import { getValidated, validate } from "../../middleware/validate.ts"; +import type { SessionStore } from "../../store/sessionStore.ts"; +import type { SessionParams } from "./schemas.ts"; +import { sessionParamsSchema } from "./schemas.ts"; +import { loadSession } from "./utils.ts"; + +/** + * `GET /:id/resources` → the session's catalogued content. Scans the workspace + * for `file` resources first (scan-then-list) so the returned catalog reflects + * what is on disk at request time, then returns every catalogued resource. + */ +async function handleListResources( + store: SessionStore, + resources: ResourceCatalog, + id: string, + res: Response, +): Promise { + const session = await loadSession(store, res, id); + if (!session) return; + await catalogWorkspaceFiles(resources, session); + res.json({ resources: await resources.listForSession(session.id) }); +} + +/** Registers the resource catalog read route on a session. */ +export function registerResourceRoutes( + router: Router, + store: SessionStore, + resources: ResourceCatalog, +): void { + router.get( + "/:id/resources", + validate({ params: sessionParamsSchema }), + (req: Request, res: Response) => + handleListResources( + store, + resources, + getValidated(req).params.id, + res, + ), + ); +} diff --git a/apps/server/src/store/db/migrations/0015_lonely_annihilus.sql b/apps/server/src/store/db/migrations/0015_lonely_annihilus.sql new file mode 100644 index 0000000..266ec18 --- /dev/null +++ b/apps/server/src/store/db/migrations/0015_lonely_annihilus.sql @@ -0,0 +1,12 @@ +CREATE TABLE `resource_grants` ( + `session_id` text NOT NULL, + `conversation_id` text NOT NULL, + `participant_id` text NOT NULL, + `resource_id` text NOT NULL, + `created_at` text NOT NULL, + FOREIGN KEY (`session_id`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`resource_id`) REFERENCES `resources`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `resource_grants_conversation_participant_idx` ON `resource_grants` (`session_id`,`conversation_id`,`participant_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `resource_grants_conversation_participant_resource` ON `resource_grants` (`conversation_id`,`participant_id`,`resource_id`); \ No newline at end of file diff --git a/apps/server/src/store/db/migrations/meta/0015_snapshot.json b/apps/server/src/store/db/migrations/meta/0015_snapshot.json new file mode 100644 index 0000000..a90d4dd --- /dev/null +++ b/apps/server/src/store/db/migrations/meta/0015_snapshot.json @@ -0,0 +1,989 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "e406fb54-8d67-4e90-ae00-cfa749a99e88", + "prevId": "aa8a0a85-6e01-4a97-95bc-f29287e10a80", + "tables": { + "conversations": { + "name": "conversations", + "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 + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_seq": { + "name": "next_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "conversations_session_idx": { + "name": "conversations_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "conversations_session_agent_idx": { + "name": "conversations_session_agent_idx", + "columns": ["session_id", "agent_id"], + "isUnique": false + }, + "conversations_session_id": { + "name": "conversations_session_id", + "columns": ["session_id", "id"], + "isUnique": true + } + }, + "foreignKeys": { + "conversations_session_id_sessions_id_fk": { + "name": "conversations_session_id_sessions_id_fk", + "tableFrom": "conversations", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "memberships": { + "name": "memberships", + "columns": { + "participant_id": { + "name": "participant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reaction": { + "name": "reaction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'never'" + }, + "ingress": { + "name": "ingress", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'reaction'" + }, + "transcript_visibility": { + "name": "transcript_visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'shared'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "memberships_session_conversation_idx": { + "name": "memberships_session_conversation_idx", + "columns": ["session_id", "conversation_id"], + "isUnique": false + }, + "memberships_session_conversation_participant": { + "name": "memberships_session_conversation_participant", + "columns": ["session_id", "conversation_id", "participant_id"], + "isUnique": true + } + }, + "foreignKeys": { + "memberships_session_id_sessions_id_fk": { + "name": "memberships_session_id_sessions_id_fk", + "tableFrom": "memberships", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "participants": { + "name": "participants", + "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 + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "presence": { + "name": "presence", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'connected'" + }, + "connector_kind": { + "name": "connector_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_lifecycle": { + "name": "connector_lifecycle", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_environment_id": { + "name": "connector_environment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_endpoint_url": { + "name": "connector_endpoint_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_payload": { + "name": "agent_payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "participants_session_idx": { + "name": "participants_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "participants_session_id": { + "name": "participants_session_id", + "columns": ["session_id", "id"], + "isUnique": true + } + }, + "foreignKeys": { + "participants_session_id_sessions_id_fk": { + "name": "participants_session_id_sessions_id_fk", + "tableFrom": "participants", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "resource_grants": { + "name": "resource_grants", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "participant_id": { + "name": "participant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "resource_grants_conversation_participant_idx": { + "name": "resource_grants_conversation_participant_idx", + "columns": ["session_id", "conversation_id", "participant_id"], + "isUnique": false + }, + "resource_grants_conversation_participant_resource": { + "name": "resource_grants_conversation_participant_resource", + "columns": ["conversation_id", "participant_id", "resource_id"], + "isUnique": true + } + }, + "foreignKeys": { + "resource_grants_session_id_sessions_id_fk": { + "name": "resource_grants_session_id_sessions_id_fk", + "tableFrom": "resource_grants", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resource_grants_resource_id_resources_id_fk": { + "name": "resource_grants_resource_id_resources_id_fk", + "tableFrom": "resource_grants", + "tableTo": "resources", + "columnsFrom": ["resource_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "resource_references": { + "name": "resource_references", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "resource_references_conversation_idx": { + "name": "resource_references_conversation_idx", + "columns": ["session_id", "conversation_id"], + "isUnique": false + }, + "resource_references_conversation_resource": { + "name": "resource_references_conversation_resource", + "columns": ["conversation_id", "resource_id"], + "isUnique": true + } + }, + "foreignKeys": { + "resource_references_session_id_sessions_id_fk": { + "name": "resource_references_session_id_sessions_id_fk", + "tableFrom": "resource_references", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resource_references_resource_id_resources_id_fk": { + "name": "resource_references_resource_id_resources_id_fk", + "tableFrom": "resource_references", + "tableTo": "resources", + "columnsFrom": ["resource_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "resources": { + "name": "resources", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_participant_id": { + "name": "author_participant_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "meta": { + "name": "meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "resources_session_idx": { + "name": "resources_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "resources_session_uri": { + "name": "resources_session_uri", + "columns": ["session_id", "uri"], + "isUnique": true + } + }, + "foreignKeys": { + "resources_session_id_sessions_id_fk": { + "name": "resources_session_id_sessions_id_fk", + "tableFrom": "resources", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "runs": { + "name": "runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "participant_id": { + "name": "participant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "home_conversation_id": { + "name": "home_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "ingress": { + "name": "ingress", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": 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 + }, + "ended_at": { + "name": "ended_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "runs_session_idx": { + "name": "runs_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "runs_session_participant_idx": { + "name": "runs_session_participant_idx", + "columns": ["session_id", "participant_id"], + "isUnique": false + } + }, + "foreignKeys": { + "runs_session_id_sessions_id_fk": { + "name": "runs_session_id_sessions_id_fk", + "tableFrom": "runs", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "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'" + }, + "connector_kind": { + "name": "connector_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_lifecycle": { + "name": "connector_lifecycle", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_environment_id": { + "name": "connector_environment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_endpoint_url": { + "name": "connector_endpoint_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "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 0f00f8a..de73c8a 100644 --- a/apps/server/src/store/db/migrations/meta/_journal.json +++ b/apps/server/src/store/db/migrations/meta/_journal.json @@ -106,6 +106,13 @@ "when": 1786661064702, "tag": "0014_powerful_hannibal_king", "breakpoints": true + }, + { + "idx": 15, + "version": "6", + "when": 1786724979840, + "tag": "0015_lonely_annihilus", + "breakpoints": true } ] } diff --git a/apps/server/src/store/db/schema.ts b/apps/server/src/store/db/schema.ts index 25eabd9..daa06db 100644 --- a/apps/server/src/store/db/schema.ts +++ b/apps/server/src/store/db/schema.ts @@ -384,6 +384,44 @@ export const resourceReferences = sqliteTable( ], ); +/** + * Which of a Conversation's {@link resourceReferences} one Participant may be + * shown and may cite — the per-Membership grant of unified-model §4.4. Keyed by + * `(conversation_id, participant_id, resource_id)` because a grant refines a + * `(Participant, Conversation)` Membership's view of a single resource. + * + * Default-permissive: a Membership with no rows here surfaces the whole + * reference set, so an empty table changes nothing. This is a surfacing and + * citation decision, not a filesystem gate (§10), and delivery does not yet + * consult it — enforcement lands with the multi-party transcript UI (2.6). + */ +export const resourceGrants = sqliteTable( + "resource_grants", + { + sessionId: text("session_id") + .notNull() + .references(() => sessions.id, { onDelete: "cascade" }), + conversationId: text("conversation_id").notNull(), + participantId: text("participant_id").notNull(), + resourceId: text("resource_id") + .notNull() + .references(() => resources.id, { onDelete: "cascade" }), + createdAt: text("created_at").notNull(), + }, + (table) => [ + unique("resource_grants_conversation_participant_resource").on( + table.conversationId, + table.participantId, + table.resourceId, + ), + index("resource_grants_conversation_participant_idx").on( + table.sessionId, + table.conversationId, + table.participantId, + ), + ], +); + /** When each user last opened a session. `user_key` is the email, or `local`. */ export const sessionViews = sqliteTable( "session_views", @@ -409,3 +447,4 @@ export type MembershipRow = typeof memberships.$inferSelect; export type ParticipantRow = typeof participants.$inferSelect; export type ResourceRow = typeof resources.$inferSelect; export type ResourceReferenceRow = typeof resourceReferences.$inferSelect; +export type ResourceGrantRow = typeof resourceGrants.$inferSelect; diff --git a/apps/server/src/store/inMemoryResourceStore.ts b/apps/server/src/store/inMemoryResourceStore.ts index 551cf33..a7a64e7 100644 --- a/apps/server/src/store/inMemoryResourceStore.ts +++ b/apps/server/src/store/inMemoryResourceStore.ts @@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto"; import type { CatalogInput, Resource, + ResourceGrant, ResourceReference, ResourceStore, } from "./resourceStore.ts"; @@ -17,6 +18,15 @@ function refKey(conversationId: string, resourceId: string): string { return `${conversationId}\u0000${resourceId}`; } +/** Key of one grant, matching `(conversation_id, participant_id, resource_id)`. */ +function grantKey( + conversationId: string, + participantId: string, + resourceId: string, +): string { + return `${conversationId}\u0000${participantId}\u0000${resourceId}`; +} + /** * Process-local {@link ResourceStore}, mirroring * {@link import("./inMemoryMembershipStore.ts").InMemoryMembershipStore}. For @@ -30,6 +40,8 @@ export class InMemoryResourceStore implements ResourceStore { private readonly byUri = new Map(); /** `(conversation, resource)` -> reference, in insertion order. */ private readonly refs = new Map(); + /** `(conversation, participant, resource)` -> grant, in insertion order. */ + private readonly grants = new Map(); async catalog(input: CatalogInput): Promise { const key = uriKey(input.sessionId, input.uri); @@ -61,6 +73,12 @@ export class InMemoryResourceStore implements ResourceStore { return resource; } + async catalogIfAbsent(input: CatalogInput): Promise { + const existingId = this.byUri.get(uriKey(input.sessionId, input.uri)); + if (existingId) return this.byId.get(existingId) as Resource; + return this.catalog(input); + } + async remove(sessionId: string, uri: string): Promise { const key = uriKey(sessionId, uri); const id = this.byUri.get(key); @@ -70,6 +88,9 @@ export class InMemoryResourceStore implements ResourceStore { for (const [refId, ref] of this.refs) { if (ref.resourceId === id) this.refs.delete(refId); } + for (const [gKey, grant] of this.grants) { + if (grant.resourceId === id) this.grants.delete(gKey); + } } async reference(ref: ResourceReference): Promise { @@ -77,6 +98,36 @@ export class InMemoryResourceStore implements ResourceStore { if (!this.refs.has(key)) this.refs.set(key, ref); } + async grant(grant: ResourceGrant): Promise { + const key = grantKey( + grant.conversationId, + grant.participantId, + grant.resourceId, + ); + if (!this.grants.has(key)) this.grants.set(key, grant); + } + + async revoke(grant: ResourceGrant): Promise { + this.grants.delete( + grantKey(grant.conversationId, grant.participantId, grant.resourceId), + ); + } + + async listGrants( + sessionId: string, + conversationId: string, + participantId: string, + ): Promise { + const out: string[] = []; + for (const grant of this.grants.values()) { + if (grant.sessionId !== sessionId) continue; + if (grant.conversationId !== conversationId) continue; + if (grant.participantId !== participantId) continue; + out.push(grant.resourceId); + } + return out; + } + async listForSession(sessionId: string): Promise { return [...this.byId.values()].filter( (resource) => resource.sessionId === sessionId, diff --git a/apps/server/src/store/resourceStore.ts b/apps/server/src/store/resourceStore.ts index 20439d0..1275fbd 100644 --- a/apps/server/src/store/resourceStore.ts +++ b/apps/server/src/store/resourceStore.ts @@ -1,29 +1,8 @@ -/** What a catalogued resource is: content the session holds, by origin. */ -export type ResourceKind = "file" | "memory" | "attachment" | "artifact"; +import type { Resource, ResourceKind } from "@tangent/shared/contracts.ts"; -/** - * A catalogued piece of content in a session — a pinned `artifact`, a human - * `attachment`, a `memory` document, or a workspace `file` — regardless of which - * connector or mechanism produced it. The bytes stay where they are; this is the - * catalog entry that points at them by {@link Resource.uri}. - */ -export interface Resource { - id: string; - sessionId: string; - kind: ResourceKind; - /** Display name / title. */ - name: string; - /** - * Where the content lives: a path relative to the session root (e.g. - * `artifacts/report.html`) or a `memory://session` / `memory://global` scheme. - */ - uri: string; - /** The participant that produced it, when known. */ - authorParticipantId?: string; - /** Kind-specific facts (e.g. `contentType`, `size`, `scope`). */ - meta?: Record; - createdAt: string; -} +// The wire-facing shape lives in `@tangent/shared` now that the web surfaces the +// catalog; re-exported here so server modules keep their existing import site. +export type { Resource, ResourceKind } from "@tangent/shared/contracts.ts"; /** * Fields accepted when cataloguing. The store assigns `id` and `createdAt` the @@ -45,6 +24,19 @@ export interface ResourceReference { resourceId: string; } +/** + * A per-Membership grant: one Participant may be shown and may cite one of a + * Conversation's referenced {@link Resource}s. The refinement of a Membership's + * view; default-permissive, so a Membership with no grants surfaces every + * reference (unified-model §4.4). + */ +export interface ResourceGrant { + sessionId: string; + conversationId: string; + participantId: string; + resourceId: string; +} + /** * Durable home of the session's {@link Resource}s and the Conversations they are * referenced in. Kept apart from @@ -61,6 +53,13 @@ export interface ResourceStore { * while keeping the original `id` and `createdAt`. */ catalog(input: CatalogInput): Promise; + /** + * Catalogs a resource only when `(sessionId, uri)` is not already known, + * returning the stored row either way. Unlike {@link ResourceStore.catalog} + * this never overwrites an existing entry, so a workspace-file scan cannot + * downgrade a path already catalogued as an `artifact` or `attachment`. + */ + catalogIfAbsent(input: CatalogInput): Promise; /** * Removes a resource by `(sessionId, uri)`, cascading its references. A no-op * when nothing is catalogued there. @@ -68,6 +67,19 @@ export interface ResourceStore { remove(sessionId: string, uri: string): Promise; /** Upserts a reference by `(conversationId, resourceId)`. */ reference(ref: ResourceReference): Promise; + /** + * Grants one Participant sight of one referenced resource in a Conversation. + * Idempotent by `(conversationId, participantId, resourceId)`. + */ + grant(grant: ResourceGrant): Promise; + /** Revokes a grant. A no-op when there is none to revoke. */ + revoke(grant: ResourceGrant): Promise; + /** The resource ids one Membership has been granted, if any. */ + listGrants( + sessionId: string, + conversationId: string, + participantId: string, + ): Promise; /** Every catalogued resource in a session, oldest first. */ listForSession(sessionId: string): Promise; /** Every resource referenced in one Conversation, oldest referenced first. */ diff --git a/apps/server/src/store/sqliteResourceStore.test.ts b/apps/server/src/store/sqliteResourceStore.test.ts index 6ef1c93..fc40084 100644 --- a/apps/server/src/store/sqliteResourceStore.test.ts +++ b/apps/server/src/store/sqliteResourceStore.test.ts @@ -132,3 +132,83 @@ test("deleting a session cascades its resources", async () => { assert.equal((await resources.listForSession(sessionId)).length, 0); }); + +test("catalogIfAbsent inserts once and never downgrades a known uri", async () => { + const { resources, sessionId } = await fixture(); + + // A pin catalogues the path as an `artifact`. + const pinned = await resources.catalog({ + sessionId, + kind: "artifact", + name: "Report", + uri: "artifacts/report.html", + }); + + // A later workspace scan of the same path must keep the artifact's kind/id. + const scanned = await resources.catalogIfAbsent({ + sessionId, + kind: "file", + name: "report.html", + uri: "artifacts/report.html", + }); + assert.equal(scanned.id, pinned.id); + assert.equal(scanned.kind, "artifact"); + + // A path nothing else catalogued is inserted as a `file`. + const fresh = await resources.catalogIfAbsent({ + sessionId, + kind: "file", + name: "notes.md", + uri: "artifacts/notes.md", + }); + assert.equal(fresh.kind, "file"); + assert.equal((await resources.listForSession(sessionId)).length, 2); +}); + +test("grants dedupe by (conversation, participant, resource) and revoke", async () => { + const { resources, sessionId } = await fixture(); + const resource = await resources.catalog({ + sessionId, + kind: "artifact", + name: "Report", + uri: "artifacts/report.html", + }); + const grant = { + sessionId, + conversationId: "c1", + participantId: "ben", + resourceId: resource.id, + }; + + await resources.grant(grant); + await resources.grant(grant); + assert.deepEqual(await resources.listGrants(sessionId, "c1", "ben"), [ + resource.id, + ]); + + // A different participant in the same conversation has no grants of its own. + assert.deepEqual(await resources.listGrants(sessionId, "c1", "ana"), []); + + await resources.revoke(grant); + assert.deepEqual(await resources.listGrants(sessionId, "c1", "ben"), []); +}); + +test("removing a resource cascades its grants", async () => { + const { resources, sessionId } = await fixture(); + const resource = await resources.catalog({ + sessionId, + kind: "artifact", + name: "Report", + uri: "artifacts/report.html", + }); + await resources.grant({ + sessionId, + conversationId: "c1", + participantId: "ben", + resourceId: resource.id, + }); + + await resources.remove(sessionId, "artifacts/report.html"); + + assert.deepEqual(await resources.listGrants(sessionId, "c1", "ben"), []); +}); diff --git a/apps/server/src/store/sqliteResourceStore.ts b/apps/server/src/store/sqliteResourceStore.ts index a2ab84f..d0e9c4c 100644 --- a/apps/server/src/store/sqliteResourceStore.ts +++ b/apps/server/src/store/sqliteResourceStore.ts @@ -4,6 +4,7 @@ import { and, asc, eq } from "drizzle-orm"; import type { Db } from "./db/client.ts"; import { + resourceGrants, resourceReferences, type ResourceRow, resources, @@ -11,6 +12,7 @@ import { import type { CatalogInput, Resource, + ResourceGrant, ResourceKind, ResourceReference, ResourceStore, @@ -82,6 +84,21 @@ export class SqliteResourceStore implements ResourceStore { return toResource(row); } + async catalogIfAbsent(input: CatalogInput): Promise { + const existing = this.db + .select() + .from(resources) + .where( + and( + eq(resources.sessionId, input.sessionId), + eq(resources.uri, input.uri), + ), + ) + .get(); + if (existing) return toResource(existing); + return this.catalog(input); + } + async remove(sessionId: string, uri: string): Promise { // References cascade on the resource FK, so deleting the row is enough. this.db @@ -103,6 +120,52 @@ export class SqliteResourceStore implements ResourceStore { .run(); } + async grant(grant: ResourceGrant): Promise { + this.db + .insert(resourceGrants) + .values({ + sessionId: grant.sessionId, + conversationId: grant.conversationId, + participantId: grant.participantId, + resourceId: grant.resourceId, + createdAt: new Date().toISOString(), + }) + .onConflictDoNothing() + .run(); + } + + async revoke(grant: ResourceGrant): Promise { + this.db + .delete(resourceGrants) + .where( + and( + eq(resourceGrants.conversationId, grant.conversationId), + eq(resourceGrants.participantId, grant.participantId), + eq(resourceGrants.resourceId, grant.resourceId), + ), + ) + .run(); + } + + async listGrants( + sessionId: string, + conversationId: string, + participantId: string, + ): Promise { + const rows = this.db + .select({ resourceId: resourceGrants.resourceId }) + .from(resourceGrants) + .where( + and( + eq(resourceGrants.sessionId, sessionId), + eq(resourceGrants.conversationId, conversationId), + eq(resourceGrants.participantId, participantId), + ), + ) + .all(); + return rows.map((row) => row.resourceId); + } + async listForSession(sessionId: string): Promise { const rows = this.db .select() diff --git a/apps/web/src/features/chat/components/SessionChat.tsx b/apps/web/src/features/chat/components/SessionChat.tsx index 4aad338..6f7bbfa 100644 --- a/apps/web/src/features/chat/components/SessionChat.tsx +++ b/apps/web/src/features/chat/components/SessionChat.tsx @@ -1,3 +1,4 @@ +import type { Resource } from "@tangent/shared/contracts"; import { PI_AGENT } from "@tangent/shared/contracts"; import { Icon } from "@tangent/ui-primitives/icon"; import { BlockStack, InlineStack } from "@tangent/ui-primitives/layout"; @@ -19,10 +20,12 @@ import { useAssetTabs, } from "@/features/chat/hooks/useAssetTabs"; import { useSessionChat } from "@/features/chat/hooks/useSessionChat"; +import { useSessionResources } from "@/features/chat/hooks/useSessionResources"; import { type Agent, buildAgents } from "@/features/chat/model/agents"; import { buildAssets } from "@/features/chat/model/assets"; import { useSession } from "@/features/sessions/hooks/useSession"; -import { isViewableArtifact } from "@/shared/lib/markdown/artifact"; +import { apiUrl } from "@/shared/lib/basePath"; +import { isViewableArtifact, resolveUrl } from "@/shared/lib/markdown/artifact"; import { PrimeChatPanel } from "./PrimeChatPanel"; import { SessionCard } from "./sidebar/sessions/SessionCard"; @@ -79,6 +82,11 @@ export function SessionChat({ sessionId }: SessionChatProps) { // The session's pages, files, and triggers as one uniform list of cards. const assets = buildAssets({ sessionId, artifacts, triggers }); + // The catalogued content (artifacts, attachments, memory, workspace files) + // surfaced read-only in the Resources panel, fetched over REST and refreshed + // by useSessionChat when a socket signal implies the catalog changed. + const { data: resources = [] } = useSessionResources(sessionId); + // Prime first, then the live sub-agent roster, surfaced as sidebar cards. const agents = buildAgents(subagents); @@ -122,6 +130,15 @@ export function SessionChat({ sessionId }: SessionChatProps) { }); }; + // Opening a viewable resource resolves its workspace-relative uri to the file + // API url and reuses the artifact tab, so a catalogued file opens the same way + // a pinned artifact does. + const openResourceTab = (resource: Resource) => { + const base = apiUrl(`/api/sessions/${sessionId}/files`); + const url = resolveUrl(resource.uri, base) ?? resource.uri; + openArtifactTab(url, resource.name); + }; + // Pin an artifact if it isn't already pinned, else unpin it. The chip's // pinned state and the sidebar list both update via the `artifacts.update` // directive once the server confirms. @@ -169,6 +186,7 @@ export function SessionChat({ sessionId }: SessionChatProps) { selectedAgentId, activeTab, assets, + resources, onOpenAgent: openAgentTab, onRemoveAgent: (agent) => { dismissSubagent(agent.id); @@ -176,6 +194,7 @@ export function SessionChat({ sessionId }: SessionChatProps) { }, onOpenAsset: openAsset, onUnpinArtifact: unpinArtifact, + onOpenResource: openResourceTab, }} > diff --git a/apps/web/src/features/chat/components/sidebar/resources/ResourceList.tsx b/apps/web/src/features/chat/components/sidebar/resources/ResourceList.tsx new file mode 100644 index 0000000..1c1193b --- /dev/null +++ b/apps/web/src/features/chat/components/sidebar/resources/ResourceList.tsx @@ -0,0 +1,99 @@ +import type { Resource } from "@tangent/shared/contracts"; +import { Box } from "@tangent/ui-primitives/box"; +import { Icon } from "@tangent/ui-primitives/icon"; +import { BlockStack, InlineStack } from "@tangent/ui-primitives/layout"; +import { Text } from "@tangent/ui-primitives/typography"; + +import { + RESOURCE_ICON, + resourceSubtitle, +} from "@/features/chat/model/resources"; +import { EmptyState } from "@/shared/ui/patterns/empty-state"; +import { ListRow } from "@/shared/ui/patterns/list-row"; +import { Truncating } from "@/shared/ui/patterns/truncating"; + +interface ResourceListProps { + resources: Resource[]; + /** + * Opens a resource whose bytes are viewable (a `file` or `artifact`). Omitted + * for kinds with no viewer (`memory`, `attachment`), which read as inert rows. + */ + onOpen?: (resource: Resource) => void; +} + +/** Whether a resource kind has a viewer the list can open. */ +function isOpenable(resource: Resource): boolean { + return resource.kind === "file" || resource.kind === "artifact"; +} + +/** + * Read-only sidebar list of the session's catalogued content — pinned + * artifacts, human attachments, memory documents, and workspace files — surfaced + * from the resource catalog regardless of which mechanism produced it. A + * viewable `file`/`artifact` opens its tab; other kinds are display-only. The + * pin/unpin flow stays on the Assets list; this view only surfaces the catalog. + */ +export function ResourceList({ resources, onOpen }: ResourceListProps) { + if (resources.length === 0) { + return ( + + + + ); + } + + return ( + + + {resources.map((resource) => { + const openable = onOpen && isOpenable(resource); + return ( + onOpen?.(resource) : undefined} + prefix={ + + + + + + } + > + + + {resourceSubtitle(resource)} + + + + {resource.name} + + + + + ); + })} + + + ); +} diff --git a/apps/web/src/features/chat/components/windows/ResourcesWindow.tsx b/apps/web/src/features/chat/components/windows/ResourcesWindow.tsx new file mode 100644 index 0000000..d0a25e1 --- /dev/null +++ b/apps/web/src/features/chat/components/windows/ResourcesWindow.tsx @@ -0,0 +1,8 @@ +import { ResourceList } from "../sidebar/resources/ResourceList"; +import { useSessionChatWindowsContext } from "./SessionChatWindowsContext"; + +export function ResourcesWindow() { + const { resources, onOpenResource } = useSessionChatWindowsContext(); + + return ; +} diff --git a/apps/web/src/features/chat/components/windows/ResourcesWindowHeader.tsx b/apps/web/src/features/chat/components/windows/ResourcesWindowHeader.tsx new file mode 100644 index 0000000..7d1021b --- /dev/null +++ b/apps/web/src/features/chat/components/windows/ResourcesWindowHeader.tsx @@ -0,0 +1,22 @@ +import { Text } from "@tangent/ui-primitives/typography"; + +import { useSessionChatWindowsContext } from "./SessionChatWindowsContext"; +import { WindowHeaderContent } from "./WindowHeaderContent"; + +export function ResourcesWindowHeader() { + const { resources } = useSessionChatWindowsContext(); + + return ( + 0 ? ( + + {resources.length} + + ) : null + } + /> + ); +} diff --git a/apps/web/src/features/chat/components/windows/SessionChatWindowsContext.ts b/apps/web/src/features/chat/components/windows/SessionChatWindowsContext.ts index 2917814..ebcb7ff 100644 --- a/apps/web/src/features/chat/components/windows/SessionChatWindowsContext.ts +++ b/apps/web/src/features/chat/components/windows/SessionChatWindowsContext.ts @@ -1,3 +1,4 @@ +import type { Resource } from "@tangent/shared/contracts"; import { createContext, useContext } from "react"; import type { Agent } from "@/features/chat/model/agents"; @@ -15,10 +16,14 @@ export interface SessionChatWindowsValue { selectedAgentId: string | null; activeTab: string; assets: Asset[]; + /** The session's catalogued content, surfaced read-only in the Resources panel. */ + resources: Resource[]; onOpenAgent: (agent: Agent) => void; onRemoveAgent: (agent: Agent) => void; onOpenAsset: (asset: Asset) => void; onUnpinArtifact: (path: string) => void; + /** Opens a viewable (`file`/`artifact`) resource in its own tab. */ + onOpenResource: (resource: Resource) => void; } export const SessionChatWindowsContext = createContext< diff --git a/apps/web/src/features/chat/components/windows/useSessionChatWindows.tsx b/apps/web/src/features/chat/components/windows/useSessionChatWindows.tsx index 2dd3673..d75e12b 100644 --- a/apps/web/src/features/chat/components/windows/useSessionChatWindows.tsx +++ b/apps/web/src/features/chat/components/windows/useSessionChatWindows.tsx @@ -5,6 +5,8 @@ import { AgentsWindow } from "./AgentsWindow"; import { AgentsWindowHeader } from "./AgentsWindowHeader"; import { AssetsWindow } from "./AssetsWindow"; import { AssetsWindowHeader } from "./AssetsWindowHeader"; +import { ResourcesWindow } from "./ResourcesWindow"; +import { ResourcesWindowHeader } from "./ResourcesWindowHeader"; import { SessionSwitcherWindow } from "./SessionSwitcherWindow"; import { WindowHeaderContent } from "./WindowHeaderContent"; @@ -16,7 +18,7 @@ const SHARED_OPTIONS = { } satisfies Partial; /** - * Opens the three SessionChat panels (Session, Agents, Assets) as docked + * Opens the SessionChat panels (Agents, Assets, Resources, Sessions) as docked * windows exactly once. Content reads live state from {@link * useSessionChatWindowsContext}, so opening once is enough — re-opening with the * same id would re-run `bringToFront` and churn the z-order every render. @@ -36,6 +38,12 @@ export function useSessionChatWindows() { title: "Assets", header: , }); + store.openWindow(, { + ...SHARED_OPTIONS, + id: "resources", + title: "Resources", + header: , + }); store.openWindow(, { ...SHARED_OPTIONS, id: "sessions", diff --git a/apps/web/src/features/chat/hooks/useSessionChat.ts b/apps/web/src/features/chat/hooks/useSessionChat.ts index 937a15c..c6a09bd 100644 --- a/apps/web/src/features/chat/hooks/useSessionChat.ts +++ b/apps/web/src/features/chat/hooks/useSessionChat.ts @@ -366,6 +366,13 @@ export function useSessionChat(sessionId: string) { ); socket.on(SocketEvents.ChatMessage, (message: ChatMessage) => { setMessagesByConversation((prev) => appendToConversation(prev, message)); + // A message carrying an attachment or a memory write is catalogued + // server-side; refetch the resource list so the catalog view reflects it. + if (message.attachments?.length || message.memory) { + void queryClient.invalidateQueries({ + queryKey: SessionQueryKeys.Resources(sessionId), + }); + } }); // An agent begins a (new) message: append an empty placeholder we fill via @@ -626,6 +633,10 @@ export function useSessionChat(sessionId: string) { socket.on(SocketEvents.UiCommand, ({ command }: UiCommandPayload) => { if (command.kind === "artifacts.update") { setArtifacts(command.artifacts); + // A pin/unpin mirrors into the resource catalog; refetch its view. + void queryClient.invalidateQueries({ + queryKey: SessionQueryKeys.Resources(sessionId), + }); return; } dispatchUiCommand(command); diff --git a/apps/web/src/features/chat/hooks/useSessionResources.ts b/apps/web/src/features/chat/hooks/useSessionResources.ts new file mode 100644 index 0000000..6c15e88 --- /dev/null +++ b/apps/web/src/features/chat/hooks/useSessionResources.ts @@ -0,0 +1,18 @@ +import { useQuery } from "@tanstack/react-query"; + +import { listResources } from "@/features/sessions/api/sessionsApi"; +import { SessionQueryKeys } from "@/features/sessions/model/sessionQueryKeys"; + +/** + * The session's catalogued resources (artifacts, attachments, memory documents, + * workspace files). Fetched over REST and kept fresh by invalidating its key + * from {@link import("./useSessionChat").useSessionChat} whenever a socket + * signal implies the catalog changed (a pin, an attachment, a memory write). + */ +export function useSessionResources(sessionId: string) { + return useQuery({ + queryKey: SessionQueryKeys.Resources(sessionId), + queryFn: () => listResources(sessionId), + enabled: sessionId.length > 0, + }); +} diff --git a/apps/web/src/features/chat/model/resources.ts b/apps/web/src/features/chat/model/resources.ts new file mode 100644 index 0000000..98f990c --- /dev/null +++ b/apps/web/src/features/chat/model/resources.ts @@ -0,0 +1,31 @@ +import type { Resource, ResourceKind } from "@tangent/shared/contracts"; +import type { IconName } from "@tangent/ui-primitives/icon"; + +/** Leading icon per catalogued-resource kind. */ +export const RESOURCE_ICON: Record = { + artifact: "FileText", + file: "File", + attachment: "Paperclip", + memory: "Brain", +}; + +/** Human-readable label for a resource's kind, shown as its subtitle. */ +export function resourceKindLabel(kind: ResourceKind): string { + switch (kind) { + case "artifact": + return "Artifact"; + case "file": + return "File"; + case "attachment": + return "Attachment"; + case "memory": + return "Memory"; + } +} + +/** The secondary line beneath a resource's name: its kind and, when known, author. */ +export function resourceSubtitle(resource: Resource): string { + const kind = resourceKindLabel(resource.kind); + if (!resource.authorParticipantId) return kind; + return `${kind} · ${resource.authorParticipantId}`; +} diff --git a/apps/web/src/features/sessions/api/sessionsApi.ts b/apps/web/src/features/sessions/api/sessionsApi.ts index 2cd1df4..f52a079 100644 --- a/apps/web/src/features/sessions/api/sessionsApi.ts +++ b/apps/web/src/features/sessions/api/sessionsApi.ts @@ -1,6 +1,8 @@ import type { Attachment, CreateSessionRequest, + ListResourcesResponse, + Resource, Session, UpdateSessionRequest, UploadFilesResponse, @@ -93,6 +95,19 @@ export async function getArtifactText(url: string): Promise { return res.text(); } +/** + * Lists a session's catalogued resources — pinned artifacts, human attachments, + * memory documents, and workspace files — regardless of which mechanism + * produced them. The server scans the workspace before returning, so the list + * reflects what is on disk at request time. + */ +export async function listResources(sessionId: string): Promise { + const data = await parseJson( + await fetch(apiUrl(`/api/sessions/${sessionId}/resources`)), + ); + return data.resources; +} + export async function markSessionViewed(id: string): Promise { const res = await fetch(apiUrl(`/api/sessions/${id}/viewed`), { method: "POST", diff --git a/apps/web/src/features/sessions/model/sessionQueryKeys.ts b/apps/web/src/features/sessions/model/sessionQueryKeys.ts index 6e52938..f01e4e3 100644 --- a/apps/web/src/features/sessions/model/sessionQueryKeys.ts +++ b/apps/web/src/features/sessions/model/sessionQueryKeys.ts @@ -4,4 +4,5 @@ export const SessionQueryKeys = { All: () => ["sessions"] as const, Id: (id: string) => ["sessions", id] as const, + Resources: (id: string) => ["sessions", id, "resources"] as const, } as const; diff --git a/packages/shared/src/contracts.ts b/packages/shared/src/contracts.ts index dd9d349..a339665 100644 --- a/packages/shared/src/contracts.ts +++ b/packages/shared/src/contracts.ts @@ -369,6 +369,39 @@ export interface PinnedArtifact { pinnedAt: string; } +/** What a catalogued resource is: content the session holds, by origin. */ +export type ResourceKind = "file" | "memory" | "attachment" | "artifact"; + +/** + * A catalogued piece of content in a session — a pinned `artifact`, a human + * `attachment`, a `memory` document, or a workspace `file` — regardless of + * which connector or mechanism produced it. The bytes stay where they are; this + * is the catalog entry that points at them by {@link Resource.uri}. + */ +export interface Resource { + id: string; + sessionId: string; + kind: ResourceKind; + /** Display name / title. */ + name: string; + /** + * Where the content lives: a path relative to the session root (e.g. + * `artifacts/report.html`) or a `memory://session` / `memory://global` + * scheme. + */ + uri: string; + /** The participant that produced it, when known. */ + authorParticipantId?: string; + /** Kind-specific facts (e.g. `contentType`, `size`, `scope`). */ + meta?: Record; + createdAt: string; +} + +/** Response from `GET /api/sessions/:id/resources`. */ +export interface ListResourcesResponse { + resources: Resource[]; +} + /** Response from `GET /api/sessions/:id/triggers`. */ export interface ListTriggersResponse { triggers: Trigger[]; From 564ea6e69994002fb561aea8a7d9cbb813d76e5b Mon Sep 17 00:00:00 2001 From: Maksym Yezhov Date: Fri, 14 Aug 2026 17:14:32 -0700 Subject: [PATCH 17/18] - refactor: Multi-party transcript UI --- .../src/routes/sessions/participants.ts | 7 +- .../src/routes/sessions/resources.test.ts | 116 +++++++++++ apps/server/src/routes/sessions/resources.ts | 48 +++-- apps/server/src/routes/sessions/schemas.ts | 11 ++ apps/server/src/sockets/chat.ts | 23 ++- apps/server/src/sockets/mentions.test.ts | 119 +++++++---- .../chat/components/PrimeChatPanel.tsx | 6 + .../features/chat/components/SessionChat.tsx | 54 ++++- .../chat/components/composer/ChatInput.tsx | 5 + .../components/composer/ComposerShell.tsx | 54 ++++- .../components/composer/MentionPicker.tsx | 46 +++++ .../composer/useMentionAutocomplete.ts | 95 +++++++++ .../message/AgentActivityBubble.tsx | 18 +- .../chat/components/message/ChatMessage.tsx | 18 ++ .../components/message/ChatMessageList.tsx | 21 +- .../chat/components/message/ReportMessage.tsx | 87 ++++++++ .../sidebar/participants/ParticipantList.tsx | 185 ++++++++++++++++++ .../sidebar/participants/PresenceDot.tsx | 20 ++ .../chat/components/tabs/AssetTabContent.tsx | 4 + .../chat/components/tabs/SubagentTabView.tsx | 8 + .../components/windows/ParticipantsWindow.tsx | 20 ++ .../windows/ParticipantsWindowHeader.tsx | 23 +++ .../windows/SessionChatWindowsContext.ts | 17 +- .../windows/useSessionChatWindows.tsx | 8 + .../src/features/chat/hooks/useSessionChat.ts | 30 ++- .../chat/hooks/useSessionParticipants.ts | 45 +++++ .../chat/hooks/useSessionResources.ts | 15 +- apps/web/src/features/chat/model/mentions.ts | 90 +++++++++ .../src/features/sessions/api/sessionsApi.ts | 135 ++++++++++++- .../sessions/model/sessionQueryKeys.ts | 15 +- packages/shared/src/contracts.ts | 14 ++ 31 files changed, 1271 insertions(+), 86 deletions(-) create mode 100644 apps/server/src/routes/sessions/resources.test.ts create mode 100644 apps/web/src/features/chat/components/composer/MentionPicker.tsx create mode 100644 apps/web/src/features/chat/components/composer/useMentionAutocomplete.ts create mode 100644 apps/web/src/features/chat/components/message/ReportMessage.tsx create mode 100644 apps/web/src/features/chat/components/sidebar/participants/ParticipantList.tsx create mode 100644 apps/web/src/features/chat/components/sidebar/participants/PresenceDot.tsx create mode 100644 apps/web/src/features/chat/components/windows/ParticipantsWindow.tsx create mode 100644 apps/web/src/features/chat/components/windows/ParticipantsWindowHeader.tsx create mode 100644 apps/web/src/features/chat/hooks/useSessionParticipants.ts create mode 100644 apps/web/src/features/chat/model/mentions.ts diff --git a/apps/server/src/routes/sessions/participants.ts b/apps/server/src/routes/sessions/participants.ts index 60d2761..d18e2bb 100644 --- a/apps/server/src/routes/sessions/participants.ts +++ b/apps/server/src/routes/sessions/participants.ts @@ -1,7 +1,9 @@ import type { + ListParticipantsResponse, MembershipView, ParticipantKind, ParticipantView, + ParticipantWithMemberships, } from "@tangent/shared/contracts.ts"; import { type Request, type Response, Router } from "express"; @@ -77,7 +79,7 @@ async function handleListParticipants( const session = await loadSession(store, res, id); if (!session) return; const rows = await participants.list(session.id); - const views = await Promise.all( + const views: ParticipantWithMemberships[] = await Promise.all( rows.map(async (participant) => ({ ...toParticipantView(participant), memberships: ( @@ -85,7 +87,8 @@ async function handleListParticipants( ).map((membership) => toMembershipView(membership, participant.kind)), })), ); - res.json({ participants: views }); + const body: ListParticipantsResponse = { participants: views }; + res.json(body); } /** `POST /:id/participants` → invite a person by email. */ diff --git a/apps/server/src/routes/sessions/resources.test.ts b/apps/server/src/routes/sessions/resources.test.ts new file mode 100644 index 0000000..5ff0899 --- /dev/null +++ b/apps/server/src/routes/sessions/resources.test.ts @@ -0,0 +1,116 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { after, test } from "node:test"; + +// Point the session root at a throwaway dir before importing modules that read +// config at load time, so `createSession`'s mkdir never touches the repo. +const ROOT = mkdtempSync(path.join(tmpdir(), "resources-rest-")); +process.env.SESSIONS_ROOT = ROOT; + +const express = (await import("express")).default; +const { Router } = await import("express"); +const { registerResourceRoutes } = await import("./resources.ts"); +const { ResourceCatalog } = + await import("../../conversation/resourceCatalog.ts"); +const { InMemoryResourceStore } = + await import("../../store/inMemoryResourceStore.ts"); +const { InMemorySessionStore } = + await import("../../store/inMemorySessionStore.ts"); +const { InMemoryParticipantStore } = + await import("../../store/inMemoryParticipantStore.ts"); + +const cleanups: (() => void)[] = []; +after(() => { + for (const cleanup of cleanups) cleanup(); + rmSync(ROOT, { recursive: true, force: true }); +}); + +/** A running express app mounting the resource route over a seeded catalog. */ +async function serve() { + const sessions = new InMemorySessionStore(new InMemoryParticipantStore()); + const catalog = new ResourceCatalog(new InMemoryResourceStore()); + const session = await sessions.createSession({ name: "S" }); + + // Two artifacts referenced in the same Conversation. + const a = await catalog.catalogIn("prime", { + sessionId: session.id, + kind: "artifact", + name: "A", + uri: "artifacts/a.html", + }); + const b = await catalog.catalogIn("prime", { + sessionId: session.id, + kind: "artifact", + name: "B", + uri: "artifacts/b.html", + }); + + const app = express(); + app.use(express.json()); + const router = Router(); + registerResourceRoutes(router, sessions, catalog); + app.use("/api/sessions", router); + + const server = app.listen(0); + await new Promise((resolve) => server.once("listening", resolve)); + cleanups.push(() => server.close()); + const { port } = server.address() as AddressInfo; + const base = `http://127.0.0.1:${port}/api/sessions`; + + const get = async (pathname: string) => { + const res = await fetch(`${base}${pathname}`); + const text = await res.text(); + return { + status: res.status, + json: (text ? JSON.parse(text) : undefined) as { + resources: { id: string }[]; + }, + }; + }; + + return { get, catalog, sessionId: session.id, a, b }; +} + +test("GET resources returns the whole session catalog unscoped", async () => { + const { get, sessionId, a, b } = await serve(); + const { status, json } = await get(`/${sessionId}/resources`); + assert.equal(status, 200); + assert.deepEqual(json.resources.map((r) => r.id).sort(), [a.id, b.id].sort()); +}); + +test("GET resources scoped to a participant consults surfacedFor", async () => { + const { get, catalog, sessionId, a, b } = await serve(); + + // Default-permissive with no grants: the scoped view is the full reference set. + const before = await get( + `/${sessionId}/resources?conversationId=prime&participantId=ben`, + ); + assert.deepEqual( + before.json.resources.map((r) => r.id).sort(), + [a.id, b.id].sort(), + ); + + // A grant narrows ben's surfaced view to the granted subset only. + await catalog.grant({ + sessionId, + conversationId: "prime", + participantId: "ben", + resourceId: a.id, + }); + const after = await get( + `/${sessionId}/resources?conversationId=prime&participantId=ben`, + ); + assert.deepEqual( + after.json.resources.map((r) => r.id), + [a.id], + ); + + // A different participant with no grants still sees everything. + const other = await get( + `/${sessionId}/resources?conversationId=prime&participantId=ana`, + ); + assert.equal(other.json.resources.length, 2); +}); diff --git a/apps/server/src/routes/sessions/resources.ts b/apps/server/src/routes/sessions/resources.ts index a762ac9..6e22c9e 100644 --- a/apps/server/src/routes/sessions/resources.ts +++ b/apps/server/src/routes/sessions/resources.ts @@ -1,28 +1,51 @@ +import type { Resource } from "@tangent/shared/contracts.ts"; import { type Request, type Response, Router } from "express"; import type { ResourceCatalog } from "../../conversation/resourceCatalog.ts"; import { catalogWorkspaceFiles } from "../../conversation/workspaceFiles.ts"; import { getValidated, validate } from "../../middleware/validate.ts"; import type { SessionStore } from "../../store/sessionStore.ts"; -import type { SessionParams } from "./schemas.ts"; -import { sessionParamsSchema } from "./schemas.ts"; +import type { ListResourcesQuery, SessionParams } from "./schemas.ts"; +import { listResourcesQuerySchema, sessionParamsSchema } from "./schemas.ts"; import { loadSession } from "./utils.ts"; +/** + * The catalog to surface: scoped to a Conversation + Participant's grants when + * both are named (via {@link ResourceCatalog.surfacedFor}, which is + * default-permissive with no grants), else the whole session catalog. This is + * where §4.4's "should this surface for this Participant" is consulted. + */ +async function resolveResources( + resources: ResourceCatalog, + sessionId: string, + query: ListResourcesQuery, +): Promise { + if (query.conversationId && query.participantId) + return resources.surfacedFor( + sessionId, + query.conversationId, + query.participantId, + ); + return resources.listForSession(sessionId); +} + /** * `GET /:id/resources` → the session's catalogued content. Scans the workspace * for `file` resources first (scan-then-list) so the returned catalog reflects - * what is on disk at request time, then returns every catalogued resource. + * what is on disk at request time, then returns the surfaced catalog — scoped to + * a Conversation + Participant when the query names both. */ async function handleListResources( store: SessionStore, resources: ResourceCatalog, id: string, + query: ListResourcesQuery, res: Response, ): Promise { const session = await loadSession(store, res, id); if (!session) return; await catalogWorkspaceFiles(resources, session); - res.json({ resources: await resources.listForSession(session.id) }); + res.json({ resources: await resolveResources(resources, session.id, query) }); } /** Registers the resource catalog read route on a session. */ @@ -33,13 +56,14 @@ export function registerResourceRoutes( ): void { router.get( "/:id/resources", - validate({ params: sessionParamsSchema }), - (req: Request, res: Response) => - handleListResources( - store, - resources, - getValidated(req).params.id, - res, - ), + validate({ params: sessionParamsSchema, query: listResourcesQuerySchema }), + (req: Request, res: Response) => { + const { params, query } = getValidated< + unknown, + SessionParams, + ListResourcesQuery + >(req); + return handleListResources(store, resources, params.id, query, res); + }, ); } diff --git a/apps/server/src/routes/sessions/schemas.ts b/apps/server/src/routes/sessions/schemas.ts index c0d93d6..5e37654 100644 --- a/apps/server/src/routes/sessions/schemas.ts +++ b/apps/server/src/routes/sessions/schemas.ts @@ -20,6 +20,17 @@ export const sessionParamsSchema = z.object({ }); export type SessionParams = z.infer; +/** + * Optional query for `GET /:id/resources`. When both a Conversation and a + * Participant are named, the catalog is filtered by the per-Membership grants + * (default-permissive); absent, the whole session catalog is returned. + */ +export const listResourcesQuerySchema = z.object({ + conversationId: z.string().optional(), + participantId: z.string().optional(), +}); +export type ListResourcesQuery = z.infer; + const triggerScheduleSchema = z.object({ every: z.string().optional(), cron: z.string().optional(), diff --git a/apps/server/src/sockets/chat.ts b/apps/server/src/sockets/chat.ts index 65cf940..f8bc18c 100644 --- a/apps/server/src/sockets/chat.ts +++ b/apps/server/src/sockets/chat.ts @@ -364,19 +364,28 @@ function isSessionOwner(author: ChatAuthor, session: Session): boolean { } /** - * Who a message in this session can address: Prime plus every sub-agent any - * connector holds. Names come from the live roster, so a mention resolves - * against what the sender currently sees in the sidebar. + * Who a message in this session can address: Prime, every sub-agent any + * connector holds, and every invited (non-revoked) human Participant. Agent + * names come from the live roster; human names from the participant roster, so a + * mention of a person resolves to their stable id at write time even though the + * display name is mutable. A revoked person is dropped — an old message that + * addressed them keeps its resolved id, but new ones cannot. */ -function mentionCandidates( +export async function mentionCandidates( connectors: ConnectorRegistry, + participantService: ParticipantService, sessionId: string, -): MentionCandidate[] { +): Promise { + const participants = await participantService.list(sessionId); + const humans = participants + .filter((p) => p.kind === "human" && !p.revokedAt) + .map((p) => ({ id: p.id, name: p.displayName })); return [ { id: PI_AGENT.id, name: PI_AGENT.name }, ...connectors .list(sessionId) .map((subagent) => ({ id: subagent.id, name: subagent.name })), + ...humans, ]; } @@ -451,7 +460,7 @@ async function handleChatMessage( author: ChatAuthor, payload: ChatMessagePayload, ): Promise { - const { store, pi, connectors, conversations } = deps; + const { store, pi, connectors, conversations, participantService } = deps; const session = await store.getSession(payload?.sessionId); if (!session) { socket.emit("error", { message: "Session not found" }); @@ -482,7 +491,7 @@ async function handleChatMessage( content: payload.content, mentions: resolveMentions( payload.content, - mentionCandidates(connectors, session.id), + await mentionCandidates(connectors, participantService, session.id), ), attachments: payload.attachments, delivery: payload.delivery ?? "auto", diff --git a/apps/server/src/sockets/mentions.test.ts b/apps/server/src/sockets/mentions.test.ts index e0cb172..34ade43 100644 --- a/apps/server/src/sockets/mentions.test.ts +++ b/apps/server/src/sockets/mentions.test.ts @@ -1,46 +1,99 @@ import assert from "node:assert/strict"; -import { test } from "node:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { after, test } from "node:test"; -import { type MentionCandidate, resolveMentions } from "./mentions.ts"; +// Point the session root at a throwaway dir before importing modules that read +// config at load time, so `createSession`'s mkdir never touches the repo. +const ROOT = mkdtempSync(path.join(tmpdir(), "mentions-test-")); +process.env.SESSIONS_ROOT = ROOT; -const ROSTER: MentionCandidate[] = [ - { id: "prime", name: "Prime" }, - { id: "sub-1", name: "Worker One" }, - { id: "sub-2", name: "researcher" }, -]; +const { resolveMentions } = await import("./mentions.ts"); +const { mentionCandidates } = await import("./chat.ts"); +const { ParticipantService } = + await import("../conversation/participantService.ts"); +const { ParticipantRegistry } = + await import("../conversation/participantRegistry.ts"); +const { MembershipRegistry } = + await import("../conversation/membershipRegistry.ts"); +const { RunRegistry } = await import("../runs/runRegistry.ts"); +const { InMemorySessionStore } = + await import("../store/inMemorySessionStore.ts"); +const { InMemoryParticipantStore } = + await import("../store/inMemoryParticipantStore.ts"); +const { InMemoryMembershipStore } = + await import("../store/inMemoryMembershipStore.ts"); +const { InMemoryRunStore } = await import("../store/inMemoryRunStore.ts"); -test("resolves a name to its participant id", () => { - assert.deepEqual(resolveMentions("@Prime take a look", ROSTER), ["prime"]); -}); +type ConnectorRegistry = + import("../connectors/connectorRegistry.ts").ConnectorRegistry; -test("matches case-insensitively and ignores spacing in the name", () => { - assert.deepEqual(resolveMentions("@workerone ping", ROSTER), ["sub-1"]); - assert.deepEqual(resolveMentions("@RESEARCHER ping", ROSTER), ["sub-2"]); -}); +after(() => rmSync(ROOT, { recursive: true, force: true })); -test("an id can be mentioned directly", () => { - assert.deepEqual(resolveMentions("@sub-1 status?", ROSTER), ["sub-1"]); -}); +/** A ParticipantService over in-memory stores, plus a connector-less roster. */ +async function setup() { + const participantStore = new InMemoryParticipantStore(); + const sessions = new InMemorySessionStore(participantStore); + const membershipStore = new InMemoryMembershipStore(); + const participantRegistry = new ParticipantRegistry( + sessions, + participantStore, + ); + const memberships = new MembershipRegistry( + sessions, + membershipStore, + () => true, + ); + const runs = new RunRegistry(new InMemoryRunStore()); + const connectors = { + cancelRun: () => ({ cancelled: true }), + } as unknown as ConnectorRegistry; + const service = new ParticipantService( + participantStore, + membershipStore, + participantRegistry, + memberships, + runs, + connectors, + ); + const session = await sessions.createSession({ name: "S" }); + // No sub-agents; the mention roster is Prime plus invited humans only. + const noConnectors = { list: () => [] } as unknown as ConnectorRegistry; + return { service, connectors: noConnectors, sessionId: session.id }; +} -test("trailing punctuation belongs to the sentence, not the name", () => { - assert.deepEqual(resolveMentions("thanks @Prime, and @sub-2!", ROSTER), [ - "prime", - "sub-2", - ]); -}); +test("an invited human is a mention candidate and @name resolves to their id", async () => { + const { service, connectors, sessionId } = await setup(); + await service.invite(sessionId, { + email: "ada@shopify.com", + displayName: "Ada Lovelace", + }); -test("an unknown mention stays prose rather than becoming an id", () => { - assert.deepEqual(resolveMentions("@nobody hello @Prime", ROSTER), ["prime"]); -}); + const candidates = await mentionCandidates(connectors, service, sessionId); + assert.ok( + candidates.some( + (c) => c.id === "ada@shopify.com" && c.name === "Ada Lovelace", + ), + "the invited human should be a candidate", + ); -test("a repeated mention is listed once, in first-mention order", () => { - assert.deepEqual( - resolveMentions("@sub-2 and @Prime and @sub-2 again", ROSTER), - ["sub-2", "prime"], + // Multiword names are reachable when typed without spaces (server normalizes). + const resolved = resolveMentions( + "hey @AdaLovelace can you look?", + candidates, ); + assert.deepEqual(resolved, ["ada@shopify.com"]); }); -test("a message with no mentions resolves to nothing", () => { - assert.deepEqual(resolveMentions("just a message", ROSTER), []); - assert.deepEqual(resolveMentions("an email a@b.com", ROSTER), []); +test("a revoked human is dropped from the mention roster", async () => { + const { service, connectors, sessionId } = await setup(); + await service.invite(sessionId, { + email: "ada@shopify.com", + displayName: "Ada Lovelace", + }); + await service.revoke(sessionId, "ada@shopify.com"); + + const candidates = await mentionCandidates(connectors, service, sessionId); + assert.ok(!candidates.some((c) => c.id === "ada@shopify.com")); }); diff --git a/apps/web/src/features/chat/components/PrimeChatPanel.tsx b/apps/web/src/features/chat/components/PrimeChatPanel.tsx index 94a6bd8..0b987b2 100644 --- a/apps/web/src/features/chat/components/PrimeChatPanel.tsx +++ b/apps/web/src/features/chat/components/PrimeChatPanel.tsx @@ -12,6 +12,7 @@ import { BlockStack, InlineStack } from "@tangent/ui-primitives/layout"; import type { AgentModelSelection } from "@/features/chat/hooks/useSessionChat"; import type { Asset } from "@/features/chat/model/assets"; +import type { MentionCandidate } from "@/features/chat/model/mentions"; import type { ChatMessage } from "@/features/chat/model/types"; import { ActiveTasksIndicator } from "./composer/ActiveTasksIndicator"; @@ -58,6 +59,7 @@ interface PrimeChatPanelProps { openArtifactTab: (url: string, title: string) => void; pinnedPaths: Set; togglePinArtifact: (path: string, title: string) => void; + mentionCandidates: MentionCandidate[]; } export function PrimeChatPanel({ @@ -87,6 +89,7 @@ export function PrimeChatPanel({ openArtifactTab, pinnedPaths, togglePinArtifact, + mentionCandidates, }: PrimeChatPanelProps) { return ( @@ -96,6 +99,8 @@ export function PrimeChatPanel({ currentAuthorId={currentAuthorId} primaryConversationId={primaryConversationId} activity={activity} + activityAuthorName={PI_AGENT.name} + activityAuthorRole="prime" historyLoaded={historyLoaded} bundleId={bundleId} onSendPrompt={send} @@ -140,6 +145,7 @@ export function PrimeChatPanel({ agentId={PI_AGENT.id} disabled={!connected} agentBusy={agentBusy} + mentionCandidates={mentionCandidates} onAbort={() => abort(primaryConversationId)} onSubmit={(content, { delivery, attachments }) => send(content, { diff --git a/apps/web/src/features/chat/components/SessionChat.tsx b/apps/web/src/features/chat/components/SessionChat.tsx index 6f7bbfa..c431f57 100644 --- a/apps/web/src/features/chat/components/SessionChat.tsx +++ b/apps/web/src/features/chat/components/SessionChat.tsx @@ -20,9 +20,14 @@ import { useAssetTabs, } from "@/features/chat/hooks/useAssetTabs"; import { useSessionChat } from "@/features/chat/hooks/useSessionChat"; +import { + useMuteMembership, + useSessionParticipants, +} from "@/features/chat/hooks/useSessionParticipants"; import { useSessionResources } from "@/features/chat/hooks/useSessionResources"; import { type Agent, buildAgents } from "@/features/chat/model/agents"; import { buildAssets } from "@/features/chat/model/assets"; +import { buildMentionCandidates } from "@/features/chat/model/mentions"; import { useSession } from "@/features/sessions/hooks/useSession"; import { apiUrl } from "@/shared/lib/basePath"; import { isViewableArtifact, resolveUrl } from "@/shared/lib/markdown/artifact"; @@ -82,14 +87,49 @@ export function SessionChat({ sessionId }: SessionChatProps) { // The session's pages, files, and triggers as one uniform list of cards. const assets = buildAssets({ sessionId, artifacts, triggers }); + // The Chat tab stands in for Prime's card, so map it back to Prime's id when + // deciding which agent card reads as selected. + const selectedAgentId = + activeTab === CHAT_TAB_VALUE ? PI_AGENT.id : activeTab; + + // The Conversation currently in view — the roster's mute toggle acts on a + // participant's Membership there, and the Resources panel surfaces for it. + // The Chat tab resolves to Prime's thread. + const activeConversationId = conversationForAgent(selectedAgentId); + + // The session's roster (people, agents, automations) with live presence, + // fetched over REST and refreshed on each `participant:presence` signal. + const { data: participants = [] } = useSessionParticipants(sessionId); + const muteMembership = useMuteMembership(sessionId); + + // Scope the catalog to the current human when they are an invited Participant, + // so surfacing consults their per-Conversation grants (default-permissive with + // none). The session owner isn't a Participant and keeps the whole catalog. + const currentParticipant = participants.find( + (p) => p.id === currentAuthorId && !p.revokedAt, + ); + const resourceScope = currentParticipant + ? { + conversationId: activeConversationId, + participantId: currentParticipant.id, + } + : undefined; + // The catalogued content (artifacts, attachments, memory, workspace files) // surfaced read-only in the Resources panel, fetched over REST and refreshed // by useSessionChat when a socket signal implies the catalog changed. - const { data: resources = [] } = useSessionResources(sessionId); + const { data: resources = [] } = useSessionResources( + sessionId, + resourceScope, + ); // Prime first, then the live sub-agent roster, surfaced as sidebar cards. const agents = buildAgents(subagents); + // Who a composer can @mention: Prime, the sub-agent roster, and invited + // humans. The server re-resolves names to ids at write time. + const mentionCandidates = buildMentionCandidates(subagents, participants); + // The Chat tab is Prime's main thread; each sub-agent opens its own thread // tab on demand. Prime's card selects the fixed Chat tab; sub-agent cards // open (or focus) a closeable tab. @@ -101,11 +141,6 @@ export function SessionChat({ sessionId }: SessionChatProps) { openAgent({ id: agent.id, name: agent.name }); }; - // The Chat tab stands in for Prime's card, so map it back to Prime's id when - // deciding which agent card reads as selected. - const selectedAgentId = - activeTab === CHAT_TAB_VALUE ? PI_AGENT.id : activeTab; - // The Chat tab is Prime's main thread; each sub-agent has its own thread tab. const primeMessages = messagesFor(primaryConversationId); @@ -175,6 +210,7 @@ export function SessionChat({ sessionId }: SessionChatProps) { send, openArtifactTab, togglePinArtifact, + mentionCandidates, }; return ( @@ -187,6 +223,11 @@ export function SessionChat({ sessionId }: SessionChatProps) { activeTab, assets, resources, + participants, + activeConversationId, + currentUserId: currentAuthorId, + onToggleMuteParticipant: (participantId, conversationId, muted) => + muteMembership.mutate({ participantId, conversationId, muted }), onOpenAgent: openAgentTab, onRemoveAgent: (agent) => { dismissSubagent(agent.id); @@ -256,6 +297,7 @@ export function SessionChat({ sessionId }: SessionChatProps) { openArtifactTab={openArtifactTab} pinnedPaths={pinnedPaths} togglePinArtifact={togglePinArtifact} + mentionCandidates={mentionCandidates} />
diff --git a/apps/web/src/features/chat/components/composer/ChatInput.tsx b/apps/web/src/features/chat/components/composer/ChatInput.tsx index 2cfd3d9..b6931ed 100644 --- a/apps/web/src/features/chat/components/composer/ChatInput.tsx +++ b/apps/web/src/features/chat/components/composer/ChatInput.tsx @@ -10,6 +10,7 @@ import { readDraft, writeDraft, } from "@/features/chat/model/chatDraft"; +import type { MentionCandidate } from "@/features/chat/model/mentions"; import { uploadFiles } from "@/features/sessions/api/sessionsApi"; import { ComposerShell } from "./ComposerShell"; @@ -52,6 +53,8 @@ interface ChatInputProps { onRemove?: () => void; /** Aborts the agent's in-progress run. Required for the Stop control. */ onAbort?: () => void; + /** People/agents the `@mention` picker can address in this composer. */ + mentionCandidates?: MentionCandidate[]; onSubmit: ( content: string, options: { delivery: MessageDelivery; attachments?: Attachment[] }, @@ -66,6 +69,7 @@ export function ChatInput({ agentStatus, onRemove, onAbort, + mentionCandidates, onSubmit, }: ChatInputProps) { // Drafts persist per session+agent so the unsent text survives navigation and @@ -208,6 +212,7 @@ export function ChatInput({ busy={busy} placeholder={agentBusy ? "Nudge the agent..." : "Message the session..."} hideSend={agentBusy} + mentionCandidates={mentionCandidates} > (null); + const textareaRef = useRef(null); + const mention = useMentionAutocomplete({ + candidates: mentionCandidates, + value, + onValueChange, + textareaRef, + }); function handleFilesPicked(e: ChangeEvent) { onAttach(e.target.files ? Array.from(e.target.files) : []); @@ -67,7 +81,13 @@ export function ComposerShell({ onAttach(images); } + function handleChange(e: ChangeEvent) { + onValueChange(e.target.value); + mention.sync(e.target); + } + function handleKeyDown(e: KeyboardEvent) { + if (mention.handleKeyDown(e)) return; if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); onSubmit(); @@ -95,16 +115,30 @@ export function ComposerShell({ disabled={busy} aria-label="Attach files" /> -