diff --git a/docs/architecture.md b/docs/architecture.md index 2b05e613b1..612e73ddce 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -116,7 +116,7 @@ Tool schemas are deliberately **part of the assembly**: "what the model is told - `whenIdle()` — resolves once the agent reaches quiescence after settling out of `running` (resolves immediately when already idle; awaits the loop exit when disposed). A non-owner's quiescence-observation hook: it lets a consumer await the current work settling **without** disposing the agent. It is NOT teardown — it does not stop queued work, unregister the agent, or detach the session; a lifecycle owner tears an agent down with `await AgentHandle.dispose()` (which stops the loop, awaits its exit, and unregisters). - `session`, `status`, `options` -**TODO(sub-agents)**: `spawn`/`fork` land on `AgentLoop.create()` — fork seeds the child Session with the parent's event log, spawn starts fresh; children are ordinary `Agent` handles so `steer()` and event subscription work uniformly. Inter-agent channels beyond these primitives are deliberately deferred. +**Subagents**: `spawn`/`fork` are realized by the [`@deepseek-ai/dsh-subagent`](../packages/subagent/subagent) seam (a named-provider registry on `ctx.subagents`), not a method on `Agent`. The in-process backends create the child via `ctx.agents.create` — fork seeds the child Session with a balanced completed-turn prefix of the parent's log (`CreateAgentOptions.seed`), spawn starts fresh; children are ordinary `Agent` handles so `steer()` and event subscription work uniformly. Out-of-process transports (ACP, and later A2A / Codex app-server / Claude Code SDK) register as sibling providers. See [docs/core-data-structures/subagent.md](core-data-structures/subagent.md) and [the subagent RFC](rfc/proposed/feature/2026-06-21-subagent-capability-seam.md). Inter-agent channels beyond delegation remain deferred. ### Loop lifecycle (session / turn / step) diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 36edfae505..e0fe1cd6c2 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -25,7 +25,7 @@ An agent was registered in the AgentRegistry and is ready to receive messages. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:136`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:137`](../../packages/core/agent/src/types.ts) #### `agent/disposed` — emit @@ -37,7 +37,7 @@ An agent was disposed and removed from the registry; its fiber and any in-flight Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:142`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:143`](../../packages/core/agent/src/types.ts) #### `agent/error` — emit @@ -49,7 +49,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:219`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:220`](../../packages/core/agent/src/types.ts) #### `agent/queued` — emit @@ -61,7 +61,7 @@ A message entered the agent's inbox (queued or steering). `source` is the resolv Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:155`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:156`](../../packages/core/agent/src/types.ts) #### `agent/request` — waterfall @@ -73,7 +73,7 @@ Waterfall: mutate the fully-assembled GenerateOptions before the model call (hoo Types: [Agent](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:188`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:189`](../../packages/core/agent/src/types.ts) #### `agent/status` — emit @@ -85,7 +85,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:149`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:150`](../../packages/core/agent/src/types.ts) #### `agent/steering` — emit @@ -97,7 +97,7 @@ Steering content was injected into a running turn. Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:213`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:214`](../../packages/core/agent/src/types.ts) #### `agent/step-end` — emit @@ -109,7 +109,7 @@ A step ended. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:179`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:180`](../../packages/core/agent/src/types.ts) #### `agent/step-result` — waterfall @@ -121,7 +121,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:194`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:195`](../../packages/core/agent/src/types.ts) #### `agent/step-start` — emit @@ -133,7 +133,7 @@ A step (one model call plus its tool dispatch) began. `step` is 1-based within t Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:174`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:175`](../../packages/core/agent/src/types.ts) #### `agent/stream-chunk` — emit @@ -145,7 +145,7 @@ A raw StreamChunk arrived from the model (token-level UI/log feed). Types: [Agent](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/core/agent/src/types.ts:208`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:209`](../../packages/core/agent/src/types.ts) #### `agent/turn-continuation` — waterfall @@ -157,7 +157,7 @@ Waterfall: override the turn-continuation decision. The default (computed by the Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:201`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:202`](../../packages/core/agent/src/types.ts) #### `agent/turn-end` — emit @@ -169,7 +169,7 @@ A turn ended. `reason` distinguishes a clean stop from a truncated or aborted on Types: [Agent](../core-data-structures/core.md) · [TurnEndReason](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:168`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:169`](../../packages/core/agent/src/types.ts) #### `agent/turn-start` — emit @@ -181,7 +181,7 @@ A turn began. `turn` is the 1-based turn number within the session. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:162`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:163`](../../packages/core/agent/src/types.ts) ### `llm/*` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 117d1e3606..49c1559c40 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -273,11 +273,12 @@ interface Agent { */ whenIdle(): Promise - // TODO(sub-agents): spawn/fork seams — semantics deliberately deferred. - // The intended shape: a creation option referencing a parent agent - // (fork = seed the child Session with the parent's event log; spawn = - // fresh Session), with the child returned as an Agent handle so steer() - // and event subscription work uniformly. See docs/architecture.md. + // Subagent delegation is realized on top of this interface by the + // `@deepseek-ai/dsh-subagent` seam, not by a method here: a backend creates + // the child through `ctx.agents.create` (fork seeds the child Session with a + // balanced prefix of the parent's log via `CreateAgentOptions.seed`; spawn + // starts fresh) and drives it as an ordinary Agent handle, so steer() and + // event subscription work uniformly. See docs/core-data-structures/subagent.md. } ``` diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml index c8371d3cba..136031062a 100644 --- a/examples/coding-agent/cordis.yml +++ b/examples/coding-agent/cordis.yml @@ -44,7 +44,7 @@ # under ./.sessions); unset starts a fresh session each run. resumeSessionId: !!js process.env.RESUME_SESSION_ID persistenceRoot: './.sessions' - welcome: 'coding-agent ready. Give it a coding task (bash is its only tool).' + welcome: 'coding-agent ready. Give it a coding task (its tools are bash and subagent).' systemPrompt: | You are coding-agent, a CLI coding assistant. diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 3179674366..e5393ed0aa 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -121,10 +121,6 @@ export class AgentLoop extends Service implements AgentFactory { * deliberate resume-or-create policy (resume the prior session if one exists, * else start fresh) or an explicit caller-chosen session id — revisit when the * UI/ACP path owns session selection. - * - * TODO(sub-agents): spawn/fork land here — accept a parent agent reference; - * fork seeds the new Session with the parent's event log, spawn starts - * fresh; the child is returned as a regular Agent handle. */ create(id: AgentId, options: AgentOptions = {}): ReactLoopAgent { this.assertAgentIdFree(id) diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 6d27bf7256..efe392155c 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -118,11 +118,12 @@ export interface Agent { */ whenIdle(): Promise - // TODO(sub-agents): spawn/fork seams — semantics deliberately deferred. - // The intended shape: a creation option referencing a parent agent - // (fork = seed the child Session with the parent's event log; spawn = - // fresh Session), with the child returned as an Agent handle so steer() - // and event subscription work uniformly. See docs/architecture.md. + // Subagent delegation is realized on top of this interface by the + // `@deepseek-ai/dsh-subagent` seam, not by a method here: a backend creates + // the child through `ctx.agents.create` (fork seeds the child Session with a + // balanced prefix of the parent's log via `CreateAgentOptions.seed`; spawn + // starts fresh) and drives it as an ordinary Agent handle, so steer() and + // event subscription work uniformly. See docs/core-data-structures/subagent.md. } declare module 'cordis' { diff --git a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts index d550101cb9..96cdd55141 100644 --- a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts +++ b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts @@ -10,11 +10,16 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as Invariants from '@deepseek-ai/dsh-invariants' import SubagentService from '@deepseek-ai/dsh-subagent' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import type { StreamChunk } from '@deepseek-ai/dsh-llm' import * as fork from '../src/index.ts' import { completedTurnPrefix } from '../src/index.ts' type Script = ConstructorParameters[0] +/** A bare `stop` finish that streams no content → the turn ends `completed` + * with NO `assistant/message` of its own. */ +const emptyStop: StreamChunk[] = [{ type: 'finish', reason: { kind: 'stop' } }] + /** * Drives the REAL fork backend with a real loop + scripted mock MODEL + the * real dsh-invariants plugin. The invariants plugin re-replays a seeded child @@ -132,6 +137,26 @@ describe('dsh-subagent-fork', () => { await run.dispose() }) + it('does NOT return the seeded parent output when the child produces no message of its own', async () => { + // Regression: readResult must scope to the child's OWN events (after the + // seed). The parent completes a turn with a distinctive assistant message, + // then the fork child's own turn finishes with a bare `stop` and NO + // assistant/message. Scanning the whole (seeded) log would return the + // parent's "parent stale" message with stopReason 'completed'; scoped to the + // child's own events the output is empty. + const { ctx, parent } = await setup([textResponse('parent stale'), emptyStop]) + parent.send([{ type: 'text', text: 'parent question' }]) + await parent.whenIdle() + + const run = ctx.subagents.start('fork', { prompt: [{ type: 'text', text: 'child question' }], parent }) + const result = await run.result + // The child completed its own (empty) turn — completed, but with NO output + // borrowed from the seeded parent prefix. + expect(result.stopReason).toBe('completed') + expect(result.output).toEqual([]) + await run.dispose() + }) + it('advertises depthLimit but not outputSchema/toolFilter', async () => { const { ctx } = await setup([]) expect(ctx.subagents.getProvider('fork')!.capabilities).toEqual({ outputSchema: false, depthLimit: true, toolFilter: false }) diff --git a/packages/subagent/subagent-spawn/src/in-process.ts b/packages/subagent/subagent-spawn/src/in-process.ts index 4b40bb667d..3b04b55c86 100644 --- a/packages/subagent/subagent-spawn/src/in-process.ts +++ b/packages/subagent/subagent-spawn/src/in-process.ts @@ -99,6 +99,11 @@ export function startInProcessRun( } const childId = AgentId(randomUUID()) + // The child's OWN events begin after the seed (fork seeds the parent's + // completed-turn prefix; spawn seeds nothing). `readResult` scopes to this + // boundary so a child that produces no message of its own never returns the + // SEEDED parent's last assistant message as its result. + const seedLength = options.seed?.length ?? 0 const parentHeader = request.parent.session.header // Inherit the parent's model by default (a child with no model cannot run); // an explicit `request.agentOptions.model` overrides it. The parent's @@ -124,14 +129,23 @@ export function startInProcessRun( // Bridge the request's abort signal to the child (the consumer also bridges // its own exec.signal, but a backend-level bridge keeps the contract local). - const onAbort = (): void => { child.cancel('subagent cancelled') } + // `cancelled` records that a cancel was requested at all, so the pre-turn + // cancel window — where the child clears the queued prompt before any + // `turn/end` is logged — settles as `aborted` (honoring the cancel contract) + // rather than falling through to the no-turn `error` mapping. + let cancelled = false + const requestCancel = (reason: string): void => { + cancelled = true + child.cancel(reason) + } + const onAbort = (): void => { requestCancel('subagent cancelled') } request.signal?.addEventListener('abort', onAbort, { once: true }) const result: Promise = (async () => { try { child.send(request.prompt) await child.whenIdle() - return readResult(child) + return readResult(child, seedLength, cancelled) } finally { request.signal?.removeEventListener('abort', onAbort) } @@ -141,7 +155,7 @@ export function startInProcessRun( id: childId, result, cancel(reason?: string): void { - child.cancel(reason ?? 'subagent cancelled') + requestCancel(reason ?? 'subagent cancelled') }, async dispose(): Promise { request.signal?.removeEventListener('abort', onAbort) @@ -151,14 +165,22 @@ export function startInProcessRun( } /** - * Read a settled child's terminal result from its session log: the last - * `assistant/message` content (deep-cloned — the log is frozen) and the last - * `turn/end` reason mapped to a {@link SubagentStopReason}. + * Read a settled child's terminal result from its session log, scoped to the + * child's OWN events (everything at or after `seedLength` — fork seeds the + * parent's completed-turn prefix, so a child that produced no message of its + * own must NOT return the seeded parent's last assistant message). The output + * is the child's last `assistant/message` content (deep-cloned — the log is + * frozen); the stop reason is the child's last `turn/end` reason mapped to a + * {@link SubagentStopReason}. When `cancelled` is set but no `turn/end` was + * logged (a cancel landed in the pre-turn window, before any turn ran), the + * run settles `aborted` per the {@link SubagentRun.cancel} contract rather than + * the generic no-turn `error`. */ -function readResult(child: Agent): SubagentResult { - const events = child.session.events - const lastMessage = events.findLast((e): e is SessionEvent<'assistant/message'> => e.type === 'assistant/message') - const lastEnd = events.findLast((e): e is SessionEvent<'turn/end'> => e.type === 'turn/end') +function readResult(child: Agent, seedLength: number, cancelled: boolean): SubagentResult { + const own = child.session.events.slice(seedLength) + const lastMessage = own.findLast((e): e is SessionEvent<'assistant/message'> => e.type === 'assistant/message') + const lastEnd = own.findLast((e): e is SessionEvent<'turn/end'> => e.type === 'turn/end') const output: ContentBlock[] = lastMessage ? structuredClone(lastMessage.data.content) : [] + if (lastEnd === undefined && cancelled) return { output, stopReason: 'aborted' } return { output, stopReason: toStopReason(lastEnd?.data.reason) } } diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index 830aa81ec8..4cbf1ca9d3 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -128,6 +128,22 @@ describe('dsh-subagent-spawn', () => { await run.dispose() }) + it('cancelling BEFORE the child turn starts settles aborted, not error', async () => { + // Regression: a cancel landing in the pre-turn window clears the queued + // prompt before any `turn/end` is logged. Deriving the stop reason from + // `turn/end` alone then mis-maps the no-turn case to `error`; the run must + // honor the cancel contract and settle `aborted`. The cancel is synchronous + // (same tick as start, before the loop's queued-wait continuation runs), so + // the turn is dropped and the empty script is never consumed. + const { ctx, parent } = await setup([]) + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) + run.cancel('early') + const result = await run.result + expect(result.stopReason).toBe('aborted') + expect(result.output).toEqual([]) + await run.dispose() + }) + it('cancelling a running child settles the run as aborted (the abort bridge + cancel())', async () => { // 'hang' makes the child's model stream one chunk then wait until aborted. const controller = new AbortController() diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index c7d954f09c..356ad60a00 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -70,7 +70,7 @@ declare module 'cordis' { export interface SubagentRunInfo { /** The provider that started the run. */ provider: string - /** The child agent/session id. */ + /** The child agent's id. */ id: AgentId } @@ -78,7 +78,7 @@ export interface SubagentRunInfo { export interface SubagentRunEndInfo { /** The provider that ran it. */ provider: string - /** The child agent/session id. */ + /** The child agent's id. */ id: AgentId /** The terminal stop reason. */ stopReason: SubagentResult['stopReason'] diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 0e04acb317..fb60d5667c 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -122,7 +122,7 @@ export interface SubagentResult { * presence of the method IS the capability — narrow before calling. */ export interface SubagentRun { - /** The child agent's id (also its session id token, for correlation). */ + /** The child agent's id (use `ctx.agents.get(id)` to reach the live child). */ readonly id: AgentId /** * Resolves with the child's terminal {@link SubagentResult} when the run