diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 5721bd97d9..ff27f51067 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -69,10 +69,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ 'async create(options: CreateAgentOptions): Promise', 'async resume(options: ResumeAgentOptions): Promise', 'register(agent: Agent): () => void', - 'enter(agent: Agent): () => void', + 'enter(agent: Agent, owner: Agent | undefined): () => void', 'announce(agent: Agent): void', 'get(id: SessionId): Agent | undefined', 'list(): Agent[]', + 'roots(): Agent[]', ], }, { diff --git a/packages/ui/jsonrpc/README.md b/packages/ui/jsonrpc/README.md index 2324227ff7..735cfead0a 100644 --- a/packages/ui/jsonrpc/README.md +++ b/packages/ui/jsonrpc/README.md @@ -4,7 +4,7 @@ The **SDK server plugin** (`jsonrpc`): mounting it serves a stdio JSON-RPC serve ## Wiring -`inject: ['agents']` — the server creates one agent per SDK `sessionId` (get-or-create on `session/prompt`). A local subagent's shared agent/session id supplies `subagent.finished.childSessionId` directly; the server caches only parent lineage because the child may be disposed before `subagent/end`. Runs from remote providers are not reported through this local-session notification pair because they create no local `session/created`/`subagent.started` edge. The LLM seam is read opportunistically via `ctx.get('llm')` (not injected): when `initialize.model` has no registered adapter, the plugin mounts `dsh-llm-deepseek` for it (credentials from `$DEEPSEEK_API_KEY` / `$DEEPSEEK_BASE_URL`); a config-registered adapter for the model wins. Everything else — persistence, the tool stacks, the adapter set — comes from the surrounding `cordis.yml`. +`inject: ['agents']` — the server creates one agent per SDK `sessionId` (get-or-create on `session/prompt`). A local subagent's shared agent/session id supplies `subagent.finished.childSessionId` directly; the server caches runtime-local identity plus optional parent lineage because the child may be disposed before `subagent/end`, and the provider contract does not require lineage. Runs from remote providers are not reported because they create no local agent. The LLM seam is read opportunistically via `ctx.get('llm')` (not injected): when `initialize.model` has no registered adapter, the plugin mounts `dsh-llm-deepseek` for it (credentials from `$DEEPSEEK_API_KEY` / `$DEEPSEEK_BASE_URL`) — a config-registered adapter for the model wins. Everything else — persistence, the tool stacks, the adapter set — comes from the surrounding `cordis.yml`. ## Config diff --git a/packages/ui/jsonrpc/src/server.ts b/packages/ui/jsonrpc/src/server.ts index 3c803a6c8c..bf21f7fc2d 100644 --- a/packages/ui/jsonrpc/src/server.ts +++ b/packages/ui/jsonrpc/src/server.ts @@ -58,6 +58,11 @@ interface SessionRecord { activePrompt: boolean } +/** Runtime-local agent identity plus optional durable fork lineage. */ +interface LocalAgentRecord { + parentSessionId?: SessionId +} + /** * The SDK server over a booted harness context. Constructing it subscribes to * the context's `session/event`, `session/created`, `agent/created`, and @@ -71,7 +76,7 @@ export class HarnessSdkServer { private llmFiber: { dispose(): Promise } | undefined private readonly sessions = new Map() private readonly sessionCreations = new Map>() - private readonly subagentParents = new Map() + private readonly localAgents = new Map() private readonly disposers: (() => void)[] = [] private shutdownTask: Promise> | undefined private shuttingDown = false @@ -95,23 +100,24 @@ export class HarnessSdkServer { childSessionId: String(session.id), }) })) - // Cache parent lineage on creation: by the time `subagent/end` fires the - // child agent may already be disposed and gone from the registry. The child - // session id needs no cache because it is the shared agent/session id. + // Cache runtime-local identity and optional lineage on creation: by the + // time `subagent/end` fires the child agent may already be disposed and + // gone from the registry. Parent lineage is not required by the provider + // contract, so an empty record remains a load-bearing locality marker. this.disposers.push(ctx.on('agent/created', (agent) => { const parentSessionId = agent.session.header.parentSession - if (parentSessionId !== undefined) this.subagentParents.set(agent.id, parentSessionId) + this.localAgents.set(agent.id, parentSessionId === undefined ? {} : { parentSessionId }) })) this.disposers.push(ctx.on('subagent/end', (info: SubagentRunEndInfo) => { const agent = this.ctx.agents.get(info.id) - const cachedParentSessionId = this.subagentParents.get(info.id) - this.subagentParents.delete(info.id) - // This protocol reports LOCAL child sessions, paired with the - // session/created-driven subagent.started notification above. A remote - // provider may use a real remote SessionId for its run, but that session - // does not exist in this harness and therefore has no paired start event. - if (cachedParentSessionId === undefined && agent === undefined) return - const parentSessionId = cachedParentSessionId ?? agent?.session.header.parentSession + const cachedLocalAgent = this.localAgents.get(info.id) + this.localAgents.delete(info.id) + // This protocol reports LOCAL child sessions. A lineage-bearing child + // has the session/created-driven start notification above; a parentless + // local provider still gets its terminal notification. A remote provider + // has neither a cached creation nor a live local agent and is ignored. + if (cachedLocalAgent === undefined && agent === undefined) return + const parentSessionId = cachedLocalAgent?.parentSessionId ?? agent?.session.header.parentSession this.transport.notify('subagent.finished', { provider: info.provider, agentId: String(info.id), @@ -189,7 +195,7 @@ export class HarnessSdkServer { this.sessionCreations.clear() const records = [...this.sessions.values()] this.sessions.clear() - this.subagentParents.clear() + this.localAgents.clear() const failures: unknown[] = [] while (this.disposers.length > 0) { try { diff --git a/packages/ui/jsonrpc/tests/server.spec.ts b/packages/ui/jsonrpc/tests/server.spec.ts index e7f591b88f..163aee4c0c 100644 --- a/packages/ui/jsonrpc/tests/server.spec.ts +++ b/packages/ui/jsonrpc/tests/server.spec.ts @@ -272,15 +272,26 @@ describe('HarnessSdkServer', () => { meta: { cwd: storageDir, parentSession: SessionId('main') }, agentOptions: { model: 'deepseek' }, }) + const parentlessHandle = await ctx.agents.create({ + sessionId: SessionId('parentless-child-session'), + meta: { cwd: storageDir }, + agentOptions: { model: 'deepseek' }, + }) // The backend may dispose the child before publishing its run outcome; - // only the cached parent lineage should be needed at this point. + // cached locality must survive with or without optional parent lineage. await handle.dispose() + await parentlessHandle.dispose() await settleSubagent(ctx, parentHandle.agent, { provider: 'spawn', id: SessionId('child-session'), stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'child done' }], }) + await settleSubagent(ctx, parentHandle.agent, { + provider: 'spawn', + id: SessionId('parentless-child-session'), + stopReason: 'error', + }) expect(transport.notifications).toContainEqual({ method: 'subagent.finished', @@ -294,6 +305,16 @@ describe('HarnessSdkServer', () => { lastAssistantMessage: [{ type: 'text', text: 'child done' }], }, }) + expect(transport.notifications).toContainEqual({ + method: 'subagent.finished', + params: { + provider: 'spawn', + agentId: 'parentless-child-session', + childSessionId: 'parentless-child-session', + status: 'error', + stopReason: 'error', + }, + }) await parentHandle.dispose() await server.shutdown()