diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6e8bf080ac..18ef659ee0 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -745,7 +745,7 @@ export interface Config { Depends on: [`SubagentCapabilities`](../packages/subagent/subagent/src/index.ts) · [`SubagentStopReason`](../packages/subagent/subagent/src/index.ts) -Source: [`packages/support/subagent-mock/src/index.ts:84`](../packages/support/subagent-mock/src/index.ts) +Source: [`packages/support/subagent-mock/src/index.ts:87`](../packages/support/subagent-mock/src/index.ts) ## `@deepseek-ai/dsh-subagent-spawn` @@ -1019,7 +1019,7 @@ export interface Config { export type ApprovalPolicy = 'ask' | 'never' ``` -Source: [`packages/ui/user-approval/src/index.ts:263`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:268`](../packages/ui/user-approval/src/index.ts) ## `@deepseek-ai/dsh-web` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index cd1327a1d1..f7bbecb20f 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -23,7 +23,7 @@ An agent's fully composed scoped world was published in the AgentRegistry. Its s Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:298`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:300`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -35,7 +35,7 @@ An agent was removed from the registry after its driver and any in-flight turn r Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:312`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:314`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -47,7 +47,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:585`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:587`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -61,7 +61,7 @@ Serial (awaited in registration order), not a waterfall: a listener mutates the Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:417`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:419`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -73,7 +73,7 @@ Waterfall: decide what happens to ONE drained queued message before it becomes a 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:435`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:437`](../../packages/core/agent/src/types.ts) ### `agent/queued` — emit @@ -85,7 +85,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:340`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:342`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -97,7 +97,7 @@ Waterfall: shape the step's call configuration — model switching, sampling ove Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:464`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:466`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -113,7 +113,7 @@ The seed is a frozen empty list; a contributing listener returns a NEW array — Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:516`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:518`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -125,7 +125,7 @@ The agent's session lifecycle began, fired once before its first turn. `source` Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:360`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:362`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -137,7 +137,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:326`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:328`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -149,7 +149,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:531`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:533`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -161,7 +161,7 @@ Waterfall: override the turn-continuation decision via a typed ContinuationDecis Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:549`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:551`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -173,13 +173,13 @@ Serial terminal-stop checkpoint after the ordinary `agent/turn-continuation` wat Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:568`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:570`](../../packages/core/agent/src/types.ts) ## `approval/*` ### `approval/request` — waterfall -Waterfall asking the composed answerers to decide one approval request. Dispatched only from ApprovalService.request — callers go through the service (which owns cancellation and the audit events), never through `ctx.waterfall` directly. A listener that can answer for this request's agent returns an outcome WITHOUT calling `next()` (the decision slot is single-occupancy, first listener to answer wins); a listener that does not recognize the agent MUST call `next()` so another answerer — or the fail-closed default `'unavailable'` — gets the question. Throwing is contained by the service and yields `'unavailable'`. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) keys the carrier by `req.agent`: a listener registered through `agent.ctx` receives only that agent's questions, while a plain-context listener receives every agent's. +Waterfall asking the composed answerers to decide one approval request. Dispatched only from ApprovalService.request — callers go through the service (which owns cancellation and the audit events), never through `ctx.waterfall` directly. A listener that can answer for this request's agent returns an outcome WITHOUT calling `next()` (the decision slot is single-occupancy, first listener to answer wins); a listener that does not recognize the agent MUST call `next()` so another answerer — or the fail-closed default `'unavailable'` — gets the question. Throwing is contained by the service and yields `'unavailable'`. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) keys the carrier by `req.agent`: a listener registered through `agent.ctx` receives only that agent's questions, while a plain-context listener receives every agent's. `req` is the service's shallow-frozen acceptance snapshot: later caller mutation cannot redirect the question, while the `agent` and `signal` identity capabilities remain exact. ```ts cordis-catalog 'approval/request'(this: Scoped, req: ApprovalRequest, next: () => Promise): Promise @@ -187,7 +187,7 @@ Waterfall asking the composed answerers to decide one approval request. Dispatch Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalRequest](../core-data-structures/approval.md) -Source: [`packages/ui/user-approval/src/index.ts:69`](../../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:72`](../../packages/ui/user-approval/src/index.ts) ## `fs/*` @@ -301,13 +301,13 @@ Source: [`packages/skill/skill/src/index.ts:136`](../../packages/skill/skill/src ### `subagent/end` — emit -A subagent run settled — emitted when SubagentRun.result resolves (any stop reason). Paired with Events['subagent/start']. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by the DELEGATING PARENT — a listener registered through the parent's `agent.ctx` observes only its own delegations; a plain plugin listener observes every run. +A started subagent run settled — emitted when SubagentRun.result resolves (any stop reason) or rejects (reported as `error`). Paired with Events['subagent/start']; a run whose readiness rejected emits neither event. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by the DELEGATING PARENT — a listener registered through the parent's `agent.ctx` observes only its own delegations; a plain plugin listener observes every run. ```ts cordis-catalog 'subagent/end'(this: Scoped, info: SubagentRunEndInfo): void ``` -Source: [`packages/subagent/subagent/src/index.ts:109`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:114`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-added` — emit @@ -331,13 +331,13 @@ Source: [`packages/subagent/subagent/src/index.ts:86`](../../packages/subagent/s ### `subagent/start` — emit -A subagent run started — emitted after the provider is resolved and its capabilities validated, as the child run begins. Paired with Events['subagent/end']. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by the DELEGATING PARENT — a listener registered through the parent's `agent.ctx` observes only its own delegations; a plain plugin listener observes every run. +A subagent run started — emitted only after SubagentRun.started fulfills, when the provider has established a live child. For an in-process provider, `ctx.agents.get(info.id)` is therefore guaranteed to resolve during this notification. A readiness rejection emits neither lifecycle event; every emitted start is paired with Events['subagent/end']. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by the DELEGATING PARENT — a listener registered through the parent's `agent.ctx` observes only its own delegations; a plain plugin listener observes every run. ```ts cordis-catalog 'subagent/start'(this: Scoped, info: SubagentRunInfo): void ``` -Source: [`packages/subagent/subagent/src/index.ts:98`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:101`](../../packages/subagent/subagent/src/index.ts) ## `system-prompt/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index b331e53fe3..f3cf92f425 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -40,7 +40,7 @@ list(): Agent[] Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/index.ts:168`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:169`](../../packages/core/agent/src/index.ts) ## `ctx.approval` — `ApprovalService` @@ -54,7 +54,7 @@ async request(req: ApprovalRequest): Promise Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalRequest](../core-data-structures/approval.md) -Source: [`packages/ui/user-approval/src/index.ts:287`](../../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:292`](../../packages/ui/user-approval/src/index.ts) ## `ctx.bash` — `BashExecutor` (abstract seam) @@ -246,7 +246,7 @@ list(): string[] start(name: string, request: SubagentStartRequest): SubagentRun ``` -Source: [`packages/subagent/subagent/src/index.ts:155`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:160`](../../packages/subagent/subagent/src/index.ts) ## `ctx.systemPrompt` — `SystemPrompt` diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 66525592cf..3de6b5861e 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -62,11 +62,12 @@ interface SubagentStopReasonMap { ## A live run: `SubagentRun` -The handle the consumer holds while a child executes. The consumer awaits `result`, may `cancel` mid-flight, and MUST `dispose` on every path to reach child quiescence (no leaked idle child / session). `result` does NOT reject on a child-level failure — a model/transport failure resolves with `stopReason: 'error'` — so the consumer maps a non-`completed` reason to an `isError` result; it rejects only on an infrastructure fault the seam cannot represent. `sendMessage` and `resume` are OPTIONAL: a provider that supports the runtime capability defines the method; one that doesn't omits it. +The handle the consumer holds while a child executes. `started` is the provider's publication boundary: it resolves only after an in-process agent is live in `ctx.agents` or a remote transport has created its child session, and rejects when the attempt fails or is cancelled before that point. The consumer normally awaits `result`, may `cancel` mid-flight, and MUST `dispose` on every path to reach child quiescence (no leaked idle child / session). `result` does NOT reject on a child-level failure — a model/transport failure resolves with `stopReason: 'error'` — so the consumer maps a non-`completed` reason to an `isError` result; it rejects only on an infrastructure fault the seam cannot represent. `sendMessage` and `resume` are OPTIONAL: a provider that supports the runtime capability defines the method; one that doesn't omits it. ```ts type-equiv interface SubagentRun { readonly id: AgentId + readonly started: Promise readonly result: Promise cancel(reason?: string): void dispose(): Promise @@ -88,11 +89,11 @@ interface SubagentProvider { } ``` -The service (`ctx.subagents`) emits `subagent/start` when a run begins and `subagent/end` when it settles (see the [events catalog](../cordis-catalog/events.md)). `subagent/end` carries `lastAssistantMessage` (the child's final `output`) on the settle path, so an observer sees WHAT the subagent produced without holding the run (absent when the run rejected at the infrastructure level — no result was produced). These are **observe-only** events: both are plain `emit`s (the `subagent/end` fires from a detached `.then` after the result settles and awaits no listener), so a subscriber observes but cannot change the run. Both emits contain a thrown listener **per listener** (logged, never propagated): one bad subscriber can neither strand a live run, surface as an unhandled rejection on the detached settle hook, nor starve the listeners registered after it. +The service (`ctx.subagents`) emits `subagent/start` only after `run.started` fulfills and emits the paired `subagent/end` when that started run settles (see the [events catalog](../cordis-catalog/events.md)); a pre-publication readiness rejection emits neither event. For an in-process provider, a start listener can therefore resolve the live child with `ctx.agents.get(info.id)`; a remote provider need not publish into the local registry. `subagent/end` carries `lastAssistantMessage` (the child's final `output`) on the settle path, so an observer sees WHAT the subagent produced without holding the run (absent when the run rejected at the infrastructure level — no result was produced). These are **observe-only** events: both are plain `emit`s, so a subscriber observes but cannot change the run. Result settlement is observed immediately even while readiness is pending, then its cloned end payload is buffered until start has been announced; this prevents an early rejection from becoming unhandled while preserving start-before-end order and protecting the caller's result from listener mutation. Both emits contain a thrown listener **per listener** (logged, never propagated): one bad subscriber can neither strand a live run, surface as an unhandled rejection on the detached settle hook, nor starve the listeners registered after it. ## In-process backends: depth and seed -The two in-process backends ([dsh-subagent-spawn](../../packages/subagent/subagent-spawn) fresh, [dsh-subagent-fork](../../packages/subagent/subagent-fork) seeded) run the child as a child `Agent` on the same application. They synchronously snapshot caller-owned data, install provider ownership before attaching the abort listener, create one run-owner fiber under `parent.ctx`, and invoke the factory through that fiber: parent teardown, provider teardown, and manual run disposal share the same pre-publication ownership and quiescence boundary, while the child still receives a flat new scope rather than inheriting the parent's capabilities. Two pieces of vocabulary ride on the existing agent/session types rather than new core types: +The two in-process backends ([dsh-subagent-spawn](../../packages/subagent/subagent-spawn) fresh, [dsh-subagent-fork](../../packages/subagent/subagent-fork) seeded) run the child as a child `Agent` on the same application. They synchronously snapshot caller-owned data, install provider ownership before attaching the abort listener, create one run-owner fiber under `parent.ctx`, and invoke the factory through that fiber: parent teardown, provider teardown, and manual run disposal share the same pre-publication ownership and quiescence boundary, while the child still receives a flat new scope rather than inheriting the parent's capabilities. Their `started` promise projects the factory's successful publication and the result driver awaits that same promise before sending the prompt. Two pieces of vocabulary ride on the existing agent/session types rather than new core types: - **Delegation depth** is a merge-extensible `AgentOptions.subagentDepth` field (`0` for a top-level agent, parent + 1 for a child). The seam owns it — the loop neither sets nor reads it — so a nested spawn reads its parent's depth from `parent.options.subagentDepth` and the `depthLimit` capability caps the tree by refusing a child whose depth would exceed `request.maxDepth`. - **Fork seeding** uses `CreateAgentOptions.seed` (a `SessionEvent[]` prefix threaded through `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })`, the same primitive `resume` uses). The fork backend passes a *balanced completed-turn prefix* of the parent's log — the parent's events up to and including its last `turn/end` — so the seed is contiguous-from-0 and the [invariants](../../packages/support/invariants) replay accepts it (the in-flight, unbalanced turn is excluded). diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index e62e11922f..ad741a7522 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -175,7 +175,7 @@ type PostToolDecision = | { kind: 'block'; feedback: ContentBlock[]; additionalContext?: HookContext } ``` -Call `next()` to delegate to the default (allow / dispatch / accept-unchanged), or return a decision/result to short-circuit. A `pre-execute` `deny` (or `ask`, which degrades to deny until the permission system lands) skips dispatch and yields an `isError` result; a registered `ToolGuard` runs after that waterfall and can impose a final denial. Input rewrite is deliberately NOT offered on `PreToolDecision` because it would desync the pre-execution audit/history/UI from what ran. A `post-execute` `accept` may replace the model-facing `content`; a `block` turns the call into an `isError` whose content is the corrective `feedback`. The awaited `tools/result` notification then receives the frozen execution identity and a deep-frozen result snapshot after every wrapper, post decision, and outer error catch; observers cannot transform the outcome or race each other through payload mutation, and one observer failure neither changes the result nor starves peers. An unregistered tool routes through the same catch as a tool-thrown error, so both failure classes get a structured `{ name, code }` (`ToolNotFoundError` → `UNKNOWN_TOOL`) — the loop records a failed tool call instead of failing the whole turn. +Call `next()` to delegate to the default (allow / dispatch / accept-unchanged), or return a decision/result to short-circuit. A `pre-execute` `deny` skips dispatch and yields an `isError` result. An `ask` resolves through the optional approval seam: only `allowed-once` proceeds, while every non-grant, missing channel/service, or agent-less request becomes a normalized denial. A registered `ToolGuard` then runs and can still impose a final denial. Input rewrite is deliberately NOT offered on `PreToolDecision` because it would desync the pre-execution audit/history/UI from what ran. A `post-execute` `accept` may replace the model-facing `content`; a `block` turns the call into an `isError` whose content is the corrective `feedback`. The awaited `tools/result` notification then receives the frozen execution identity and a deep-frozen result snapshot after every wrapper, post decision, and outer error catch; observers cannot transform the outcome or race each other through payload mutation, and one observer failure neither changes the result nor starves peers. An unregistered tool routes through the same catch as a tool-thrown error, so both failure classes get a structured `{ name, code }` (`ToolNotFoundError` → `UNKNOWN_TOOL`) — the loop records a failed tool call instead of failing the whole turn. ## The structured-output schema subset diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 36cad3e3ad..879c0ac2e4 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,20 +7,20 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:298`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:312`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:585`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:417`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:435`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:340`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:464`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:516`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:360`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`invariants`](../packages/support/invariants) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio-agent`](../packages/ui/stdio-agent) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:531`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:549`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:568`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`strictSerial (serial)`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | -| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:69`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:300`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:314`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:587`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:419`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:437`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:342`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:466`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:518`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:362`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`invariants`](../packages/support/invariants) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:328`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:533`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:551`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:570`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`strictSerial (serial)`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:72`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:123`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:138`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:109`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | @@ -30,10 +30,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `skill/provider-added` | `emit` | [`packages/skill/skill/src/index.ts:130`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - | | `skill/provider-removed` | `emit` | [`packages/skill/skill/src/index.ts:136`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:109`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:114`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:75`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:86`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:98`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:101`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:45`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | - | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:55`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:176`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 7139ce261a..4bbe894338 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -23,7 +23,7 @@ An approval question was put to the answerer chain — log-only audit (like `hoo Types: [CallId](core-data-structures/core.md) -Source: [`packages/ui/user-approval/src/index.ts:83`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:86`](../packages/ui/user-approval/src/index.ts) #### `approval/decided` — log-only @@ -33,7 +33,7 @@ The outcome of a prior `approval/asked` (same `id`) — log-only audit. Exactly 'approval/decided': { id: ApprovalRequestId; outcome: ApprovalOutcome } ``` -Source: [`packages/ui/user-approval/src/index.ts:94`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:97`](../packages/ui/user-approval/src/index.ts) #### `approval/policy` — log-only @@ -43,7 +43,7 @@ The session's approval policy was switched — log-only, durable, replayable, ne 'approval/policy': { policy: ApprovalPolicy } ``` -Source: [`packages/ui/user-approval/src/index.ts:106`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:109`](../packages/ui/user-approval/src/index.ts) ### `assistant/*` diff --git a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md index b92896c2b8..d0cdefaee8 100644 --- a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md +++ b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md @@ -206,6 +206,8 @@ The operation being described determines the key; callers cannot attach an unrel | `session/created`, `session/event`, `session/flush` | The owner scope captured when the session enters the store | | `subagent/start`, `subagent/end` | The delegating parent agent | +Approval requests cross an asynchronous answer boundary, so the service snapshots the accepted record synchronously. It preserves the exact agent and abort-signal identities but copies the scalar fields, captures the agent's session once, and uses that one snapshot for `approval/asked`, scoped dispatch, cancellation, policy, and `approval/decided`. Mutating the caller-owned record after `request()` returns therefore cannot split the audit pair or redirect the question to another agent's listeners. + The dispatch rule can be read independently of Cordis internals: ```text @@ -334,12 +336,11 @@ The sequence is not described as atomic because observers run between its steps. ### Teardown stops work before revoking its world -Every owner path uses the same reverse order: stop the loop and await its actual exit, remove the agent from the registry, detach the session, then unwind the scope. The final session events and durability flush therefore happen while the session and scoped listeners are still live. +Every owner path uses the same reverse order: stop the loop and await its actual exit plus every agent-started durability checkpoint, remove the agent from the registry, detach the session, then unwind the scope. Final turn events, the turn-ending flush, and any outstanding idle-injection flush therefore settle while the session and scoped listeners are still live. ```text disposeOwnedAgent(world): - world.stopDriver() - await world.agent.done # waits for any in-flight turn close and flush + await world.stopDriver() # waits for loop exit and all agent-started flushes world.detachAgent() # emits agent/disposed when announced world.detachSession() await world.scope.dispose() @@ -519,10 +520,14 @@ In-process subagents demonstrate how the scope, lifecycle, and final-policy piec ### Inputs and ownership are fixed before asynchronous creation +Provider registration first freezes an acceptance snapshot of the provider name, capability flags, parent-context descriptor, and `start` callback; the callback is bound to the original provider receiver so its intentional internal state stays live. Lookup, validation, model-facing wording, dispatch, lifecycle notifications, and HMR cleanup all use that snapshot. Mutating or reusing the caller's provider object later therefore cannot rename a live entry, change its advertised powers, replace its callback, or make its disposer delete the wrong key. + Starting a run snapshots every accepted field before asynchronous owner setup. The parent and abort signal are retained as identity capabilities but never reread from the mutable request record; tool filters, seed events, agent options, output schema, and prompt are detached. The schema is validated before cloning, while the prompt must pass the same lossless-JSON check before and after cloning that the session log requires. Later caller mutation therefore cannot change lifecycle scope, configuration, the schema enforced by the capture tool, or the prompt eventually logged and sent. The driver first installs provider ownership. Only after that succeeds does it attach the request's abort listener and create one run-owner Cordis fiber under `parent.ctx`; an already-unloading provider therefore leaves neither a child nor an orphaned listener. The child factory runs through the owner fiber. Parent teardown, provider teardown, and manual run disposal all dispose this same node; moving it out of the active state synchronously prevents an unpublished setup from publishing afterward, while all three paths follow one quiescence promise. This structured ownership does not change the child's flat capability view. +The returned run separates acceptance from publication with `started: Promise`. For spawn and fork, it fulfills only after the child factory returns a published handle, so the service can emit `subagent/start` with `ctx.agents.get(run.id)` already live; it rejects when rollback prevents publication. The service observes `result` immediately but buffers its cloned end payload until readiness, preserving start-before-end order without leaving an early rejection unhandled. A readiness rejection emits neither lifecycle event. The result driver awaits the same boundary before sending the child prompt. + ```text startInProcessRun(providerContext, acceptedRequest): snapshot all request data, including parent identity @@ -535,11 +540,21 @@ startInProcessRun(providerContext, acceptedRequest): dispose providerLink await disposeRunOwner() - childHandle = await runOwner.ctx.agents.create({ + creation = runOwner.ctx.agents.create({ fresh ids and lineage, cloned options and optional seed, setup(childCtx) => install persona, tool restriction, structured runtime }) + + returnedRun.started = creation.then(childHandle => publication complete) + returnedRun.result = async: + await returnedRun.started + send the child prompt, await idle, derive the terminal result + +SubagentService.start(...): + attach result settlement handlers immediately + await returnedRun.started + emit subagent/start; later emit the buffered or eventual subagent/end ``` Parent teardown reaches `runOwner` by nesting; the provider and returned run handle reach the same node through their explicit disposers. @@ -687,7 +702,7 @@ The main benefit is one composition model across data, behavior, and lifetime: r - Plugin authors use the same registration APIs globally and per agent; only the context changes. - Registry-owned prompt schemas, executable lookup, Code Mode bindings, policy listeners, and UI presentation resolve from the same agent view. - Create and resume expose no partially configured registry entry during awaited setup. -- Agent disposal revokes scoped contributions after the driver and final session flush have settled. +- Agent disposal revokes scoped contributions after the driver and all final or idle-injection session flushes have settled. - Structured output composes per child without global mutation or listener-order assumptions. - Existing unscoped plugins remain deployment-wide contributors and observers. diff --git a/docs/rfc/implemented/feature/2026-06-15-code-mode.md b/docs/rfc/implemented/feature/2026-06-15-code-mode.md index f97e7abf50..be18e94ae0 100644 --- a/docs/rfc/implemented/feature/2026-06-15-code-mode.md +++ b/docs/rfc/implemented/feature/2026-06-15-code-mode.md @@ -4,7 +4,7 @@ Status: implemented ## Problem -Today the agent loop advertises every registered tool to the model as a native JSON-schema function definition. `ToolRegistry` contributes its schemas to the system-prompt assembly, the assembly's `tools` land on the wire (and in the logged request header), the model invokes one `tool-call` block per step, and the loop dispatches each call through `ctx.tools.execute()` **sequentially** (parallel tool execution is an explicit open TODO in `dsh-tools` and [docs/architecture.md](../../../architecture.md)), with **every** intermediate `tool-result` re-entering the model's context on the next request. +In the registry's native presentation, the agent loop advertises every visible capability as a JSON-schema function definition. `ToolRegistry` contributes its schemas to the system-prompt assembly, the assembly's `tools` land on the wire (and in the logged request header), the model invokes one `tool-call` block per step, and the loop dispatches each call through `ctx.tools.execute()` **sequentially** (parallel tool execution is an explicit open TODO in `dsh-tools` and [docs/architecture.md](../../../architecture.md)), with **every** intermediate `tool-result` re-entering the model's context on the next request. For multi-step tool work this is token-heavy and serial. The model cannot compose tools — loop over a result set, branch on an intermediate value, fan out, post-process — without a full model round-trip per call, and each round-trip drags the entire intermediate result back into context whether the model needs it or not. @@ -37,14 +37,14 @@ Three decisions, each elaborated in its own section below: Under `'code'` and `'both'` the registry owns `run_code` as a reserved presentation transport with one required parameter, `{ code: string }`. It is represented by a normal `ToolDefinition` for dispatch but stays outside the filterable capability layers, so restrictions cannot accidentally remove Code Mode's only entry point. Calls traverse the complete tool pipeline — `tools/pre-execute` → monotonic guards → `tools/execute` around dispatch → `tools/post-execute` → immutable `tools/result` notification — exactly like native calls; a permission plugin can inspect the program text before it runs, and final-result observers see the normalized outer outcome. Its `execute(args, exec)`: 1. **Builds the bindings**: the bridge owns a **run-scoped `AbortController`** whose signal follows `exec.signal` (an outer cancel propagates in) and which the bridge itself fires the moment the run settles for any reason — completion, program exception, `computeMs`/`maxWallMs` expiry, worker exit. For every visible capability tool, the binding is an async function that (a) checks the run signal before and after, (b) **JSON-normalizes the argument** — a `JSON.parse(JSON.stringify(args))` round-trip, rejecting that one call with a descriptive `Error` when the value does not survive (`BigInt`, circular structures) — because the seam's structured-clone boundary is wider than JSON while the session log accepts only JSON, (c) awaits its turn on the **per-run serialization queue** (below), (d) calls `this.execute({ callId, name, arguments, agent: exec.agent, parent: exec.token, signal: runSignal })` with a deterministic sub-id `` CallId(`${exec.callId}:code:${n}`) ``, (e) appends a `tool/code-dispatch` session event, and (f) maps the result: success → the text-block contents joined as a `string` (non-text blocks become placeholders), `isError` → **the binding rejects** with an `Error` carrying the result text. The child's readonly `parent` is only the outer execution's frozen, property-free token, so commit-style observers can correlate outcomes without receiving a mutation path into the live `run_code` wrapper. Every sub-call still traverses the full pipeline under its own immutable identity and registry-assigned token. The run signal, rather than the bare outer one, lets budget expiry abort an in-flight sub-tool instead of orphaning it. Rejection gives programs ordinary `try/catch` and `Promise.all` failure semantics rather than a bespoke result envelope. -2. **Runs the program**: `ctx.codeRuntime.run({ program: args.code, bindings: [{ global: 'tools', functions }], signal: exec.signal })`. -3. **Surfaces the outcome — after reaching quiescence.** When `ctx.codeRuntime.run()` resolves, the bridge fires the run-scoped abort (cancelling any in-flight sub-dispatch and abandoning queued-unstarted ones), then **awaits the dispatch queue's drain before returning**, per the dispose-to-quiescence rule in [defensive patterns](../../../defensive-patterns.md): an aborted in-flight sub-call still settles and logs its `isError` `tool/code-dispatch` event *inside* the open turn, and nothing can append after `run_code` returns. A successful run then returns one text block — the captured console/stdout output followed by the rendered return value (if any) — plus a `meta` payload (capped logs, dispatch count) for presentation. A run with `result.error` throws a `CodeRunFailedError extends HarnessError` (`code: 'CODE_RUN_FAILED'`, message = the error kind and text plus captured logs so the model can self-correct); the registry's existing catch turns it into a structured `isError` result. +2. **Runs the program**: `ctx.codeRuntime.run({ program: args.code, bindings: [{ global: 'tools', functions }], signal: runController.signal })`. The runtime receives the run-scoped signal, not only the caller's outer signal, so any way the outer run settles also aborts work inside the runtime. +3. **Surfaces the outcome — after reaching quiescence.** When `ctx.codeRuntime.run()` settles, whether by fulfillment or rejection, the bridge fires the run-scoped abort (cancelling any in-flight sub-dispatch and abandoning queued-unstarted ones), then **awaits the dispatch queue's drain before returning or propagating**, per the dispose-to-quiescence rule in [defensive patterns](../../../defensive-patterns.md): an aborted in-flight sub-call still settles and logs its `isError` `tool/code-dispatch` event *inside* the open turn, and nothing can append after `run_code` settles. A successful result then returns one text block — the captured console/stdout output followed by the rendered return value (if any) — plus a `meta` payload (capped logs, dispatch count) for presentation. A fulfilled run with `result.error` throws a `CodeRunFailedError extends HarnessError` (`code: 'CODE_RUN_FAILED'`, message = the error kind and text plus captured logs so the model can self-correct); a backend rejection propagates through the same registry error boundary. Both become structured `isError` tool results. **Sub-call `additionalContext` is suppressed, deliberately.** A `tools/post-execute` hook may attach `additionalContext` to a call; for loop-dispatched calls the loop buffers those and appends each as a `context/message` only after the step's `tool/result`s, preserving call/result adjacency. A sub-dispatch result's `additionalContext` has no such safe outlet from inside a running `run_code`: injecting immediately would land a `context/message` between the parent's `tool/call` and its `tool/result` (breaking the adjacency the buffering exists to protect), and `PostToolDecision.additionalContext` is singular where a program may produce many. The MVP therefore drops sub-call `additionalContext`, pinned by a test and stated in the hooks bridge's docs; the follow-up (a plural context channel or loop-level sub-dispatch buffering) is deferred until a real hook needs it through Code Mode. **Concurrency: serialized, enforced by the binding.** The bindings are async, so a model writing `Promise.all([tools.a(…), tools.b(…)])` starts both immediately — concurrent dispatch would be the default, while the tool contract carries no concurrency-safety metadata (the open parallel-execution TODO). Each `run_code` invocation therefore owns a dispatch queue and every binding call chains onto it, so even `Promise.all` executes the underlying `ctx.tools.execute()` calls one at a time in submission order; when the run settles, queued-but-unstarted dispatches are abandoned. Lifting this per tool remains tied to tools declaring themselves concurrency-safe. -**Presentation.** `run_code`'s render intent is decided here per the [render-intent RFC](../../implemented/architecture/2026-07-02-tool-render-intent-union.md): `presentCall` → a `generic` card, `kind: 'execute'`, title `Run code`, `rawInput` = the program text; `presentResult` → a `generic` card whose content is the captured output (from `meta`). Not a `terminal` card: that card's semantics are "a shell command in a working directory", which a program is not. +**Presentation.** `run_code`'s render intent is decided here per the [render-intent RFC](../../implemented/architecture/2026-07-02-tool-render-intent-union.md): `presentCall` → a `generic` card, `kind: 'execute'`, title = the program text, `rawInput` = the same program text; `presentResult` → a `generic` card whose content is the captured output (from `meta`). The program is the title because ACP execute cards reliably render that field while some clients omit body and raw-input content. This is not a `terminal` card: that card's semantics are "a shell command in a working directory", which a program is not. ### Observability: `tool/code-dispatch` @@ -56,7 +56,7 @@ Each sub-dispatch appends one session event, declared by `dsh-tools` via `Sessio - `CodeRunRequest = { program: string; bindings: CodeBindingNamespace[]; signal?: AbortSignal }` - `CodeBindingNamespace = { global: string; functions: Record Promise> }` — the runtime exposes each namespace as a global object of async functions inside the program; binding arguments and resolutions must be structured-cloneable (a runtime may cross a serialization boundary; ours does). -- `CodeRunResult = { value?: unknown; logs: CodeLogEntry[]; error?: CodeRunFailure }` — an error is a field on a resolved result, never a rejection of `run()`. +- `CodeRunResult = { value?: unknown; logs: CodeLogEntry[]; error?: CodeRunFailure }` — program execution outcomes, including exception, timeout, abort, and worker exit, resolve as the `error` field. `run()` may reject only for caller/seam misuse (for example a duplicate binding namespace); consumers still contain a non-conforming backend rejection at their own error boundary. - `CodeLogEntry = { source: 'console' | 'stdout' | 'stderr'; level?: 'log' | 'info' | 'warn' | 'error' | 'debug'; text: string }` - `CodeRunFailure = { kind: 'exception' | 'timeout' | 'abort' | 'worker-exit'; message: string }` — orthogonal outcomes reported independently per [defensive patterns](../../../defensive-patterns.md); a timed-out run is not an exception, an abort is not a timeout. - Two readonly backend descriptors, informational not gating: `language` (what the program must be written in — `'typescript'` for the shipped backend; a Python backend would say so, and pair with its own SDK generator on the presentation side) and `isolation` (`'worker-thread'` for the shipped backend; `'process'`, `'container'`, … for future ones). `dsh-tools` requires `language === 'typescript'` in the MVP — its codegen emits TS — and fails the assembly loudly otherwise, the same misconfiguration idiom as `toolOrder` violations (as when `mode` is non-native with no `ctx.codeRuntime` loaded at all). diff --git a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md index 28daff14ff..474abd2c67 100644 --- a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md @@ -37,7 +37,7 @@ A new package group `packages/subagent/`: ### The primitive: `start → SubagentRun` -A provider exposes `start(request) → SubagentRun`. The run carries a `result` promise (the terminal `SubagentResult`), `cancel()`, and `dispose()`. The transport-neutral verb is **`start`**; "spawn" is reserved for the in-process `dsh-subagent-spawn` backend's identity, not the service verb. The service's `start(name, request)` resolves the named provider, validates capabilities, delegates, and emits `subagent/start` / `subagent/end` around the run. +A provider exposes `start(request) → SubagentRun`. The run carries `started` (the provider's publication/readiness promise), `result` (the terminal `SubagentResult`), `cancel()`, and `dispose()`. The transport-neutral verb is **`start`**; "spawn" is reserved for the in-process `dsh-subagent-spawn` backend's identity, not the service verb. The service's `start(name, request)` resolves the named provider, validates capabilities, delegates, and waits for `started` before emitting the paired `subagent/start` / `subagent/end`; an attempt that never establishes a child emits neither lifecycle event. For an in-process backend, readiness means the child is published in `ctx.agents`; for ACP it means the remote session exists. ### Two kinds of optional capability, discovered two ways diff --git a/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md b/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md index fc3c8a9a93..4c9b89b8d0 100644 --- a/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md +++ b/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md @@ -26,9 +26,11 @@ Each bridge maps the neutral `MergedHookOutcome` from the shared lib onto the se | `tools/pre-execute` | `deny`→`deny`; `ask`→`ask` | `block`→`deny` (no allow/ask) | | `tools/post-execute` | `deny`→`block`+feedback; context-only→delegate+fold | same | | `agent/turn-continuation` | blocking Stop → `continue` (reason = next-step steering) | same | -| `subagent/start` (emit) | additionalContext → inject into the live child | — (not a Codex event) | +| `subagent/start` (emit) | additionalContext → inject into a live in-process child; a remote child has no local injection target | — (not a Codex event) | | `subagent/end` (emit) | observe-only | — | +The CC bridge's `ask` result is a real permission path, not a terminal bridge decision: `dsh-tools` resolves it through the optional [approval seam](2026-07-06-approval-seam.md). A composed ACP answerer prompts the owning editor session and `allowed-once` proceeds; without an ApprovalService or answerer, the call fails closed to `deny`. + ### Context source is always the plugin (the mislabel guard) `agent.inject()` defaults a missing `MessageSource` to `{ kind: 'user' }` — which would record plugin-injected context as if the user had typed it. So every bridge `inject()` and every `HookContext` passes an explicit `{ kind: 'plugin', plugin: 'hooks-claude' | 'hooks-codex' }` source. A test asserts the resulting `context/message.source` is the plugin, never `user`. @@ -52,11 +54,10 @@ Two different cwds, kept distinct on purpose. The hooks **themselves** run in th ## Deferred (faithful-but-degraded) - **Tool-input rewrite.** A CC/Codex `updatedInput` is logged + warned, not honored — input rewrite is a deferred consistency-design problem ([the pre-tool-input-rewrite RFC](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)), because the pre-execution args are read by `tool/call` audit + `assistant/message` history + ACP/tool-bash presentation, so an honest rewrite is a design unit, not a field. -- **Stop loop-guard** (`TODO(stop-loop-guard)`). CC/Codex break an infinite force-continue with `stop_hook_active` (true once a Stop hook fired this run) plus a max-consecutive cap; both are deferred. Today `stop_hook_active` is always `false`, so a Stop hook that unconditionally blocks would force-continue every step — a hook author must self-limit until the guard lands. -- **Permission `ask`** — deferred at landing, since serviced: the [approval seam](2026-07-06-approval-seam.md) resolves `ask` through `ctx.approval` (ACP prompts over `session/request_permission`), degrading to `deny` only where no approval service is composed. +- **Stop loop-guard** (`TODO(stop-loop-guard)`). CC/Codex break an infinite force-continue with `stop_hook_active` (true once a Stop hook fired this run) plus a max-consecutive cap; both are deferred. `stop_hook_active` is always `false`, so a Stop hook that unconditionally blocks would force-continue every step — a hook author must self-limit until the guard lands. - **Hook `continue:false` (hard halt).** A hook can ask to halt the whole run (CC/Codex `continue:false`); the shared merge folds it into `MergedHookOutcome.stop`/`stopReason`, but no bridge acts on it (`TODO(hook-continue-false)`) — the interception seams have no "hard-halt the agent" primitive yet (a Decision blocks/steers a single point, not the run). Deferred with the loop-guard work; the halt request is recorded in the `hook/result` log, and the hook keeps its per-point effect (decision/context) meanwhile. - **Config discovery.** The path is explicit in `cordis.yml` and process-level (see above); the full multi-layer CC/Codex precedence walk, per-session project-local discovery, and the trust/hash model are not reimplemented (`TODO(per-session-hook-config)`). -- **Session-start / subagent-start context is best-effort, not gated (`TODO(session-start-gating)`).** `agent/session-start` is a synchronous emit and the bridge runs its hook on a detached `.then`, so the injected `additionalContext` is not guaranteed to land before the first turn reaches the model — a slow hook can miss the first request (the context then arrives as a later injection). `subagent/start` is sharper: an in-process provider may have already queued the child's prompt before the listener runs, and a short-lived child can finish before the detached inject fires. Making startup context a gated/awaited primitive is a loop-level change deferred to the interception seams; today the contract is "injected as soon as the hook resolves", not "before the first request". The bridge tests do NOT wait on the injection where they assert the guaranteed-timing behavior, so they document the real (best-effort) timing rather than masking it. +- **Session-start / subagent-start context is best-effort, not gated (`TODO(session-start-gating)`).** `agent/session-start` is a synchronous emit and the bridge runs its hook on a detached `.then`, so the injected `additionalContext` is not guaranteed to land before the first turn reaches the model — a slow hook can miss the first request (the context then arrives as a later injection). `subagent/start` is emitted only after child publication, so the bridge can capture the live in-process child synchronously, but the result driver may queue the prompt as that same readiness boundary resolves and a short-lived child can finish before the detached hook injects. Making startup context a gated/awaited primitive is a loop-level change deferred to the interception seams; the contract is "injected as soon as the hook resolves", not "before the first request". The bridge tests do NOT wait on the injection where they assert the guaranteed-timing behavior, so they document the real (best-effort) timing rather than masking it. ## Alternatives considered diff --git a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md index 111b5d833f..ba372b98b2 100644 --- a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md +++ b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md @@ -22,7 +22,7 @@ The canonical surface separates transformable policy, around-dispatch control, a Every call follows one ordered pipeline: `tools/pre-execute` → monotonic guards → `tools/execute` → core dispatch → `tools/post-execute` → `tools/result`. The registry requires caller-owned `arguments` to survive lossless-JSON validation before and after cloning, then snapshots `ToolExecutionInput` into a pipeline execution with its own opaque token: identity fields and deeply frozen detached arguments are immutable for the whole pipeline, and a nested call's `parent` contains only the enclosing execution's token rather than its live object. Optional `signal` is the only operational field an around-dispatch wrapper may add, replace, or remove, and the complete object freezes before final observers run. This identity contract prevents a policy listener from silently changing what the log, UI, and tool body believe ran. -- **`tools/pre-execute`** is the extensible waterfall gate. Its `PreToolDecision` allows, denies, or asks; deny/ask skips `tools/execute` and core dispatch but still produces a normalized result for post-policy and final observers. +- **`tools/pre-execute`** is the extensible waterfall gate. Its `PreToolDecision` allows, denies, or asks. Deny skips `tools/execute` and core dispatch. Ask resolves through the optional approval seam: only `allowed-once` continues through guards and dispatch; rejection, cancellation, an unavailable channel, a missing approval service, or an agent-less call becomes a normalized denial. Every outcome still reaches post-policy and final observers. - **`ctx.tools.guard()`** installs synchronous scope-aware policy after the whole pre-execute waterfall. A guard may deny or abstain, never force-allow, so listener ordering cannot resurrect an operation that a final invariant forbids. - **`tools/execute`** is the around-dispatch waterfall for timeout, retry, and metrics plugins. A wrapper delegates to core dispatch with `next()`, may add, replace, or remove only `exec.signal` before doing so, and receives the already-normalized result of a thrown or unknown tool; returning its own valid result short-circuits dispatch. - **`tools/post-execute`** is the inspect/transform waterfall. Its `PostToolDecision` accepts, blocks with feedback, optionally replaces content, or attaches `additionalContext`; in-place mutation of the result is not a transform channel, because the registry rebuilds the outcome from a protected snapshot plus the returned decision. @@ -46,7 +46,7 @@ Core dispatch and the tool body sit inside normalization boundaries, so tool, li ### Boundaries -The seam package does **not** declare `hook/*` session events (the durable hook-invocation log); those belong to `dsh-hook-protocol`, because a native plugin uses typed decisions without an external hook log. The native-plugin integration test (`packages/core/agent-loop/tests/interception.spec.ts`) composes the seams through the real loop with no `hook/*` protocol. Compaction (`PreCompact`/`PostCompact`), Notification, and Codex `PermissionRequest` remain outside this decision. The permission/`ask` system has since landed as the [approval seam](2026-07-06-approval-seam.md), whose `ctx.approval` services the `ask` this RFC originally shipped degraded to deny; terminal monotonic stopping is now provided separately by `agent/turn-stop`. +The seam package does **not** declare `hook/*` session events (the durable hook-invocation log); those belong to `dsh-hook-protocol`, because a native plugin uses typed decisions without an external hook log. The native-plugin integration test (`packages/core/agent-loop/tests/interception.spec.ts`) composes the seams through the real loop with no `hook/*` protocol. Compaction (`PreCompact`/`PostCompact`), Notification, and Codex `PermissionRequest` remain outside this decision. The [approval seam](2026-07-06-approval-seam.md) resolves `ask` decisions through `ctx.approval`, while terminal monotonic stopping is owned separately by `agent/turn-stop`. ## Alternatives considered diff --git a/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.md b/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.md index f3b1c9bfb6..17730458de 100644 --- a/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.md +++ b/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.md @@ -12,7 +12,7 @@ This RFC enriches the end payload. It is deliberately **observe-only**: no contr **Add `lastAssistantMessage` — the child's final output — to `SubagentRunEndInfo`.** On the settle path it is a DEEP CLONE of `SubagentResult.output` (so an observer sees WHAT the subagent produced without holding the run). On the REJECT path (an infrastructure fault where no `SubagentResult` was produced — the seam only knows `stopReason: 'error'`) it is absent. The clone is load-bearing for observe-only: the `subagent/end` emit fires from a detached `.then` registered *before* `start()` returns, i.e. before the caller's own `await run.result` continuation — handing listeners the same array reference would let a mutating listener corrupt the caller's `SubagentResult.output`. `structuredClone` makes the event a read-only view (a regression test mutates the event's array and asserts the caller's result is untouched); a clone failure is contained (logged, the event still fires without `lastAssistantMessage`) rather than becoming an unhandled rejection on the detached `.then`. -Both events stay plain **`emit`s**. `subagent/end` fires from a detached `.then` on `run.result` and awaits no listener, so it is genuinely observe-only by construction — a `subagent/start` listener can still reach the live child via `ctx.agents.get(info.id)` and `inject()` into it; a `subagent/end` listener can only observe (the run has settled). Per-listener containment (already in place) keeps one bad subscriber from stranding a live run or surfacing as an unhandled rejection on the detached settle hook. +Both events stay plain **`emit`s**. The service waits for `run.started` before firing `subagent/start`; an in-process listener can therefore reach the published child via `ctx.agents.get(info.id)` and `inject()` into it, while a remote provider need not have a local registry entry. It observes `run.result` immediately, snapshots the end payload before the caller can mutate it, and emits `subagent/end` only after start; readiness rejection emits neither event. The callbacks remain observe-only and per-listener containment keeps one bad subscriber from stranding a live run, surfacing as an unhandled rejection, or starving later listeners. ## Alternatives considered diff --git a/docs/rfc/implemented/feature/2026-07-06-approval-seam.md b/docs/rfc/implemented/feature/2026-07-06-approval-seam.md index eddb3ba733..1257554d30 100644 --- a/docs/rfc/implemented/feature/2026-07-06-approval-seam.md +++ b/docs/rfc/implemented/feature/2026-07-06-approval-seam.md @@ -4,7 +4,7 @@ Status: implemented ## Problem -Two callers need to put one question — "may this specific action proceed?" — to a human, and neither has a channel. `tools/pre-execute`'s `ask` decision (produced today by the Claude-Code hook bridge's `permissionDecision: ask`) degrades to deny because nothing services it. The [sandbox RFC](2026-07-06-sandbox.md)'s escalation phase needs the same channel for its post-denial one-shot retry. Without a shared seam, each would invent its own outcome vocabulary, UI routing, cancellation, and audit trail — and a deployment with no UI at all needs a guarantee that an unanswerable question can never grant anything. +Two callers need to put one question — "may this specific action proceed?" — to a human: `tools/pre-execute`'s `ask` decision (including the Claude-Code hook bridge's `permissionDecision: ask`) and the [sandbox RFC](2026-07-06-sandbox.md)'s post-denial one-shot escalation retry. A shared seam keeps them from inventing separate outcome vocabularies, UI routing, cancellation, and audit trails, while guaranteeing that a deployment with no UI can never grant an unanswerable request. The routing problem is ownership: an approval prompt must reach the editor session that owns the asking agent (the ACP bridge multiplexes N sessions over one connection), fail closed for agents nobody owns (in-process subagents, tests), and stay out of deployments that compose no UI (headless, CI). @@ -14,7 +14,7 @@ One package, `dsh-user-approval` (`packages/ui/user-approval`), owning the vocab ### How a deployment uses it -One `cordis.yml` entry mounts the seam; not loading it is the opt-out — consumers degrade to their historical fail-closed behavior with zero approval code registered: +One `cordis.yml` entry mounts the seam. Not loading it is the fail-closed opt-out: consumers deny unanswerable requests with zero approval code registered. ```yaml - id: approval @@ -49,29 +49,29 @@ The `escalation-rejected` twin ends in `{"outcome": "rejected"}` instead: nothin #### The seam: mechanism and policy split -`ApprovalService.request(req)` always resolves to a closed `ApprovalOutcome` — `allowed-once` / `rejected` / `cancelled` / `unavailable` — and never rejects. The service is the mechanism: it dispatches the `approval/request` waterfall, races the request's `AbortSignal` (abort settles `cancelled`; a late answer is discarded, never double-audited), contains a throwing answerer as `unavailable`, normalizes a rogue non-vocabulary return to `unavailable`, and lands the log-only audit pair `approval/asked`/`approval/decided` (paired by the branded `ApprovalRequestId`) on the requesting agent's session log. Grants are one-shot by definition: `allowed-once` authorizes the single asked-about action, never a class of future ones, and the service stores nothing between requests. The one precondition: `request()` throws (before appending anything) when the agent's session has no open turn — the audit pair must be turn-enclosed, the turn being the durable log's commit/replay boundary (a bare event between turns is dropped as crash tail on reload); every shipped ask path runs mid-turn already, and idle asks are a deferred design. +`ApprovalService.request(req)` always resolves to a closed `ApprovalOutcome` — `allowed-once` / `rejected` / `cancelled` / `unavailable` — and never rejects. The service synchronously snapshots and shallow-freezes the accepted request before its first asynchronous boundary: scalar fields are copied while the agent and `AbortSignal` remain exact identity capabilities, so later caller mutation cannot redirect scope, payload, cancellation, or either audit event. The service dispatches the `approval/request` waterfall, races the captured signal (abort settles `cancelled`; a late answer is discarded, never double-audited), contains a throwing answerer as `unavailable`, normalizes a rogue non-vocabulary return to `unavailable`, and lands the log-only audit pair `approval/asked`/`approval/decided` (paired by the branded `ApprovalRequestId`) on the captured agent's captured session log. A session observer runs after an event enters the append-only log; if one throws, the service recognizes the recorded event, contains the callback failure, and completes the pair. Grants are one-shot by definition: `allowed-once` authorizes the single asked-about action, never a class of future ones, and the service stores nothing between requests. The one precondition: `request()` throws (before appending anything) when the agent's session has no open turn — the audit pair must be turn-enclosed, the turn being the durable log's commit/replay boundary (a bare event between turns is dropped as crash tail on reload); every ask path runs mid-turn already, and idle asks are a deferred design. Answerers are the policy, and they are `approval/request` waterfall listeners. The waterfall buys exactly what the seam needs: with zero listeners the dispatch falls through to the caller-supplied default — `unavailable`, so fail-closed needs no configuration and no code in any deployment; a listener that recognizes the request's agent answers by returning an outcome without calling `next()` (the decision slot is single-occupancy, first answer wins — the same documented semantics as the `fs/write-intent` gate); a listener that does not recognize the agent MUST delegate via `next()` so another answerer or the default gets the question; and listeners dispose with their owning fiber, so an unloaded UI plugin degrades the next ask to `unavailable` instead of leaving a dangling channel. Registration order across sibling plugins is not load-order deterministic (the loader starts siblings concurrently), so a deployment composes ONE terminal answerer and reserves `prepend` listeners for decide-or-delegate gates. -`ApprovalRequest` carries the asking `agent` (routes the question; receives the audit events), the `toolName`, the optional exact `callId`, the asker's human-readable `reason`, and the optional `signal`. The vocabulary is deliberately self-contained — it names the tool-call by the `CallId` brand from `dsh-llm` and never imports `dsh-tools` — because `dsh-tools` depends on `dsh-user-approval` (the ask routing) and a `ToolCallView` import would close a package cycle. It deliberately does NOT carry tool arguments: a UI answerer attaches the prompt to the already-streamed tool call via `callId` instead of re-rendering the call. +`ApprovalRequest` carries the asking `agent` (routes the question; receives the audit events), the `toolName`, the optional exact `callId`, the asker's human-readable `reason`, and the optional `signal`. The caller owns this input record; `request()` owns its frozen acceptance snapshot. The vocabulary is deliberately self-contained — it names the tool-call by the `CallId` brand from `dsh-llm` and never imports `dsh-tools` — because `dsh-tools` depends on `dsh-user-approval` (the ask routing) and a `ToolCallView` import would close a package cycle. It deliberately does NOT carry tool arguments: a UI answerer attaches the prompt to the already-streamed tool call via `callId` instead of re-rendering the call. #### Ask routing in dsh-tools -`ToolRegistry.execute()` resolves an `ask` decision through the seam before the shared deny path: `allowed-once` proceeds to dispatch, and the three non-grants deny with distinct reasons — "the user rejected…", "…was cancelled", "…no approval channel is available" — so the model can tell a human "no" from an absent channel. The seam is consumed opportunistically (`ctx.get('approval')`, the `tool-bash`/`agent-loop` pattern), not statically injected: a deployment that composes no ApprovalService keeps the historical ask→deny degrade verbatim, an unmount mid-session degrades the same way on the next ask, and the registry's fiber never gates on the seam's presence. An agent-less execution also degrades — without an agent there is no session to audit to and no UI to route to. +`ToolRegistry.execute()` resolves an `ask` decision through the seam before the shared deny path: `allowed-once` proceeds to guards and dispatch, and the three non-grants deny with distinct reasons — "the user rejected…", "…was cancelled", "…no approval channel is available" — so the model can tell a human "no" from an absent channel. The seam is consumed opportunistically (`ctx.get('approval')`, the `tool-bash`/`agent-loop` pattern), not statically injected: with no ApprovalService, or after one unmounts, the next ask fails closed without gating the registry's fiber. An agent-less execution also fails closed — without an agent there is no session to audit to and no UI to route to. #### The per-session policy tier -The seam also owns the session-scoped approval policy — the approval knob of the two-knob per-session switching design ([the sandbox RFC](2026-07-06-sandbox.md) § Per-session modes is the pattern's home: one log-only event per knob, a pure fold, THE write path, ACP config-option advertisement, and turn-anchoring). `ApprovalPolicy` is `'ask' | 'never'`, and `effectiveApprovalPolicy(events) ?? Config.policy` (default `'ask'`) decides every request BEFORE any interactive answerer: the service resolves a `'never'` session to `'rejected'` INSIDE `request()`, before dispatching the waterfall at all — no listener registration, including a later `prepend`, can sit ahead of it — while `'ask'` dispatches unchanged (fail-closed `'unavailable'` with nobody composed, exactly the prior behavior). Visibility follows the switching design's two layers with one asymmetry: the prompt section states ONLY `'never'` (deterministic, availability-independent — "you will be prompted" would overclaim in a composition with no answerer, and absence under a logged header is exactly how the narrator reads `'ask'` back), the narrator injects at most one coalesced notice per switch, and the audit pair still lands on every ask, including the policy's auto-rejections. +The seam also owns the session-scoped approval policy — the approval knob of the two-knob per-session switching design ([the sandbox RFC](2026-07-06-sandbox.md) § Per-session modes is the pattern's home: one log-only event per knob, a pure fold, THE write path, ACP config-option advertisement, and turn-anchoring). `ApprovalPolicy` is `'ask' | 'never'`, and `effectiveApprovalPolicy(events) ?? Config.policy` (default `'ask'`) decides every request BEFORE any interactive answerer: the service resolves a `'never'` session to `'rejected'` INSIDE `request()`, before dispatching the waterfall at all — no listener registration, including a later `prepend`, can sit ahead of it — while `'ask'` dispatches unchanged and falls through to fail-closed `'unavailable'` when nobody answers. Visibility follows the switching design's two layers with one asymmetry: the prompt section states ONLY `'never'` (deterministic, availability-independent — "you will be prompted" would overclaim in a composition with no answerer, and absence under a logged header is exactly how the narrator reads `'ask'` back), the narrator injects at most one coalesced notice per switch, and the audit pair still lands on every ask, including the policy's auto-rejections. #### The ACP answerer The bridge registers the first real answerer: it resolves the owning session through its existing `WeakMap` reverse map, issues `session/request_permission` with the request's `callId` as the `toolCall` reference and the one-shot options `allow_once`/`reject_once`, and maps the response — selected `allow-once` → `allowed-once`, any other selection → `rejected` (an unknown optionId from a non-conforming client never grants), client `cancelled` → `cancelled`. A request for a foreign agent — or one without a `callId`, since the protocol prompt must attach to a tool call — delegates via `next()`. A rejected RPC (client gone mid-prompt) propagates to the service, which contains it as `unavailable`. Whether a call ASKS at all is policy — a hook or `tools/pre-execute` plugin returning `ask` — never the bridge's own judgment. -The reverse-map ownership seam [the ACP support RFC](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md) laid down is exactly what the answerer routes through, and per-session permission ownership (the blocker recorded in [the multi-session RFC](../../implemented/feature/2026-06-14-acp-multi-session.md)) is what it implements. +The answerer routes through the bridge's reverse-map ownership seam described by [the ACP support RFC](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md), implementing the per-session permission ownership required by [the multi-session RFC](../../implemented/feature/2026-06-14-acp-multi-session.md). #### Audit, and what the model sees -`approval/asked` / `approval/decided` are log-only session events (the `hook/invoked`/`hook/result` precedent): durable, replayable, never in the model transcript. The model's entire view of an approval is the tool result the asker derives from the outcome — reconstructability holds because that result is an ordinary logged `tool/result`. One `decided` per `asked`, whatever the outcome, including an already-aborted signal (settled `cancelled` without dispatching) and a contained answerer failure. +`approval/asked` / `approval/decided` are log-only session events (the `hook/invoked`/`hook/result` precedent): durable, replayable, never in the model transcript. The model's entire view of an approval is the tool result the asker derives from the outcome — reconstructability holds because that result is an ordinary logged `tool/result`. One `decided` lands per `asked`, whatever the outcome, including an already-aborted signal (settled `cancelled` without dispatching), a contained answerer failure, or a session observer that throws after either event is already appended. #### Entities and dependencies @@ -79,35 +79,35 @@ One package, no cycles: `dsh-user-approval` peers on `cordis`, `dsh-session` (ev ### Testing -Unit tier: the service's outcome branches (fail-closed default, first-wins slot, delegation, containment, rogue-value normalization, abort-before and abort-during with late-answer discard, fresh ids, fiber-disposal degradation) and the policy tier (both values × dispatch/decide, a `'never'` decision unbypassable even by an answerer prepended AFTER the service, audit pair intact) in `dsh-user-approval`; the ask routing matrix (grant dispatches; three non-grant reasons pinned verbatim; unmounted and agent-less degrades; the registry's own exhaustiveness backstop against a non-conforming stand-in) in `dsh-tools`; the answerer (wire shape of the prompt, outcome mapping, unknown-option conservatism, foreign-agent and call-less delegation) driven through a real bridge + scripted client in `dsh-acp`. +Unit tier: the service's outcome branches (fail-closed default, first-wins slot, delegation, containment, rogue-value normalization, abort-before and abort-during with late-answer discard, fresh ids, fiber-disposal degradation), accepted-request mutation across agent scopes, post-append observer throws on both audit events, and the policy tier (both values × dispatch/decide, a `'never'` decision unbypassable even by an answerer prepended AFTER the service, audit pair intact) in `dsh-user-approval`; the ask routing matrix (grant dispatches; three non-grant reasons pinned verbatim; unmounted and agent-less degrades; the registry's own exhaustiveness backstop against a non-conforming stand-in) in `dsh-tools`; the answerer (wire shape of the prompt, outcome mapping, unknown-option conservatism, foreign-agent and call-less delegation) driven through a real bridge + scripted client in `dsh-acp`. Snapshot tier: the harness accepts scripted permission answers (`permissionAnswers` in a scenario's `input.json`, consumed FIFO; an unscripted prompt answers `cancelled`, fail closed). The seam's wire is recorded end to end in the sandbox example's suite: both escalation branches drive `session/request_permission` through this seam over scripted answers (grant and rejection), and the recorded `mode-switching` scenario pins the `'never'` prompt sentence and the policy-switch notice ([the sandbox RFC](2026-07-06-sandbox.md) § Testing). ## Deferred - **`allow_always` grant storage** — honoring a persistent grant means designing storage, scope identity (call? path? prefix? session? time window?), and revocation; until designed, only the one-shot options are advertised ([the sandbox RFC](2026-07-06-sandbox.md) § Escalation records the open scope question). -- **A recorded hook-driven `ask` scenario** — the wire is recorded via the sandbox example's escalation branches; the hook-producer variant stays on the unit tier and the hook matrix's `hook-cc-pretool-ask`, with its deny texts pinned verbatim there. -- **Routing a child agent's approvals to the parent session** — `subagent-acp`'s child today auto-answers its own `permission` requests; surfacing them to the parent's editor is its own design. +- **A recorded hook-driven `ask` through a composed answerer** — the human-prompt wire is recorded through the sandbox example's escalation branches. The hook matrix's `hook-cc-pretool-ask` pins the no-ApprovalService fallback denial, while the hook-producer-plus-answerer composition remains on the unit tier. +- **Routing a child agent's approvals to the parent session** — `subagent-acp`'s child auto-answers its own `permission` requests; surfacing them to the parent's editor is its own design. ## Alternatives considered - **A single registered provider instead of waterfall listeners** — rejected: a `registerProvider()` surface forces every composition question — allowlist pre-filters, external hook deciders, scripted test answers, a policy gate in front of a human — inside one provider implementation. The waterfall gets composition, fail-closed absence, and HMR disposal from machinery the runtime already has; the seam's JSDoc pins the single-decision-slot convention instead of inventing a provider registry. -- **[The ACP support RFC](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md)'s inline `tools/pre-execute` permission gate** — rejected, and superseded by this seam: prompting for every bridge-owned call hardwires the asking POLICY into the UI plugin, cannot serve a second asker (sandbox escalation happens after execution starts, with no pre-execute moment), and leaves hooks' `ask` — the vocabulary the interception seams already ship — unserviced. -- **A generic user-interaction seam (`ctx.userInteraction`) instead** — rejected: the two share a skeleton (route by agent, block for a human, handle absence), but approval's contract is narrower in every dimension that matters: a closed outcome vocabulary instead of free text, a protocol-native prompt attached to a tool call instead of a generic form, mandatory fail-closed absence, and audit events. The generic seam has since shipped (`packages/ui/user-interaction`, the `ask_user_question` tool over ACP elicitation) and approval deliberately still does not ride it — an elicitation form is not a permission prompt, and a free-text answer is not a closed outcome; sharing provider plumbing stays open if the two ever converge. +- **An inline `tools/pre-execute` permission gate in the ACP bridge** — rejected: prompting for every bridge-owned call hardwires the asking POLICY into the UI plugin, cannot serve a second asker (sandbox escalation happens after execution starts, with no pre-execute moment), and leaves hook-produced `ask` decisions without a shared mechanism. +- **The generic user-interaction seam (`ctx.userInteraction`)** — rejected as the approval mechanism: the two share a skeleton (route by agent, block for a human, handle absence), but approval's contract is narrower in every dimension that matters: a closed outcome vocabulary instead of free text, a protocol-native prompt attached to a tool call instead of a generic form, mandatory fail-closed absence, and audit events. Approval therefore does not ride the shipped `packages/ui/user-interaction` / `ask_user_question` elicitation path — an elicitation form is not a permission prompt, and a free-text answer is not a closed outcome; sharing provider plumbing stays open if the two ever converge. - **Static optional injection in `dsh-tools`** — rejected: the vendored cordis `Inject` type has no optional flag — the object form maps service names to intercept config, and a declared inject gates the fiber. `ctx.get('approval')` is the documented opportunistic-consumption pattern (the `tool-bash` owner-token lookup, the loop's persistence probe), reads presence per call, and degrades correctly across HMR without extra machinery. - **The capability-seam three-package split** — rejected: interface/implementation/consumer fits a seam whose implementation is swappable (bash-local vs bash-sandbox). Here the service body is fixed mechanism and the variable part is listeners that live with their owners — splitting would manufacture an implementation package with nothing in it ("don't split preemptively"). - **Offering `allow_always` now** — rejected: the protocol can express it, but honoring it means designing grant storage, scope identity, and revocation (§ Deferred). Advertising an option the harness cannot honor manufactures doomed grants. ## Consequences -What shipped pins — the suites in Testing hold each: +The implemented contract is pinned by the suites in Testing: - With an ApprovalService and an answerer composed, a hook's `ask` reaches a human and `allowed-once` dispatches the tool; every other outcome denies with its distinct reason. - A `'never'` session auto-rejects every ask without prompting anyone, states the policy in its prompt, and narrates switches (the shared switching mechanics are pinned in [the sandbox RFC](2026-07-06-sandbox.md)). -- Every unanswerable path fails closed to `unavailable`: no service (degrade, verbatim historical text), no listener, a foreign or agent-less request, a throwing answerer, a rogue return value, a dead client connection. -- Every `request()` lands exactly one `approval/asked`/`approval/decided` pair on the asking agent's log, replayable, invisible to the model transcript. +- Every unanswerable path fails closed to `unavailable`: no service, no listener, a foreign or agent-less request, a throwing answerer, a rogue return value, or a dead client connection. +- Every `request()` snapshots its routing identity and lands exactly one `approval/asked`/`approval/decided` pair on that agent's captured log, replayable and invisible to the model transcript; post-append observer failures cannot split the pair. - Prompts route per-session through the bridge's ownership map; one session's prompt can never reach another session's editor. -- A deployment that composes nothing new behaves byte-identically (the snapshot suite's goldens are unchanged). +- A deployment with no ApprovalService emits no approval prompt or approval audit events and denies every `ask` request. Costs and accepted limits: @@ -125,7 +125,7 @@ Behavioral and usage questions only — every "why not X?" design question lives - **Who decides whether a call asks in the first place?** Policy producers: a hook returning `permissionDecision: ask`, any `tools/pre-execute` listener, or the sandbox escalation gate. The seam and the bridge only route and answer; neither injects its own judgment about what deserves a prompt. - **What happens when the user dismisses the prompt, or the turn aborts mid-ask?** Dismissal maps to `cancelled` with its own deny text. An already-aborted signal settles `cancelled` without dispatching; an abort during the ask discards the late answer — one audit pair either way, never two. - **What if the client answers with an option the harness never offered?** Any selection other than the offered `allow_once` maps to `rejected` — an unknown optionId from a non-conforming client can never grant. -- **How do subagents' approvals route?** An agent no answerer owns delegates through the whole waterfall and fails closed — in-process subagents are unanswerable today by design. `subagent-acp`'s child-side auto-answer is untouched; routing a child's asks to the parent's editor is deferred (§ Deferred). +- **How do subagents' approvals route?** An agent no answerer owns delegates through the whole waterfall and fails closed — in-process subagents are deliberately unanswerable. `subagent-acp`'s child-side auto-answer is separate; routing a child's asks to the parent's editor is deferred (§ Deferred). - **What does `policy: 'never'` actually change at runtime?** The service resolves every ask for that session to `rejected` before dispatching any answerer (in-service, so no registration order can bypass it); the system prompt states the policy; switches are narrated at boundaries; the audit pair still lands for every auto-rejection. - **What happens across a hot reload, or when the UI plugin unloads mid-session?** Answerers dispose with their owning fiber, so the next ask degrades to `unavailable` instead of hanging on a dead channel; remounting re-registers the answerer with no catch-up state. - **Where does the user see what they are approving?** On the tool call itself: the prompt attaches to the already-streamed call via `callId` — arguments included — and adds the asker's human-readable `reason`; the request carries no argument copy of its own. diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 57390710f8..e56f675bb2 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -381,7 +381,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'subagent/end', mode: 'emit', signature: '\'subagent/end\'(this: Scoped, info: SubagentRunEndInfo): void', - summary: 'A subagent run settled — emitted when SubagentRun.result resolves (any stop reason).', + summary: 'A started subagent run settled — emitted when SubagentRun.result resolves (any stop reason) or rejects (reported as `error`).', }, { name: 'subagent/provider-added', @@ -399,7 +399,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'subagent/start', mode: 'emit', signature: '\'subagent/start\'(this: Scoped, info: SubagentRunInfo): void', - summary: 'A subagent run started — emitted after the provider is resolved and its capabilities validated, as the child run begins.', + summary: 'A subagent run started — emitted only after SubagentRun.started fulfills, when the provider has established a live child.', }, { name: 'system-prompt/assemble', @@ -869,7 +869,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentRun', - declaration: 'export interface SubagentRun {\n readonly id: AgentId;\n readonly result: Promise;\n cancel(reason?: string): void;\n dispose(): Promise;\n sendMessage?(content: ContentBlock[]): void;\n resume?(content: ContentBlock[]): SubagentRun;\n}', + declaration: 'export interface SubagentRun {\n readonly id: AgentId;\n readonly started: Promise;\n readonly result: Promise;\n cancel(reason?: string): void;\n dispose(): Promise;\n sendMessage?(content: ContentBlock[]): void;\n resume?(content: ContentBlock[]): SubagentRun;\n}', }, { name: 'SubagentStartRequest', diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 2909ba6c37..217ba0a643 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -8,7 +8,7 @@ This is the only package in the harness that contains concrete loop logic. Every ### Public API -Lifecycle (scoped): programmatic creation and resume snapshot caller-owned identity/configuration data, reserve both IDs, mint `agent.ctx`, and install the ordered teardown skeleton before awaiting optional `setup`. Resume installs an owner-liveness sentinel before persistence load, then hands ownership directly to the full lifecycle. After setup resolves, the factory checks its lifecycle flag, owner-fiber state, and owning agent status around one microtask checkpoint so a same-turn Cordis unload wins before publication. Successful setup inserts both session and agent before announcing either, enables driving immediately before `agent/session-start`, then starts the loop. Setup calls to `send`/`steer`/`inject`/`cancel` reject structurally; load/setup rejection or owner unload publishes nothing. Teardown runs stop/drain → unregister → detach session → unwind scope. All `agent/*` dispatches go through `agentEvents(ctx, agent)`; per-step assembly through `assembleContextFor(agent)`; the turn-end durability checkpoint through `ctx.sessions.flush(session)`. +Lifecycle (scoped): programmatic creation and resume snapshot caller-owned identity/configuration data, reserve both IDs, mint `agent.ctx`, and install the ordered teardown skeleton before awaiting optional `setup`. Resume installs an owner-liveness sentinel before persistence load, then hands ownership directly to the full lifecycle. After setup resolves, the factory checks its lifecycle flag, owner-fiber state, and owning agent status around one microtask checkpoint so a same-turn Cordis unload wins before publication. Successful setup inserts both session and agent before announcing either, enables driving immediately before `agent/session-start`, then starts the loop. Setup calls to `send`/`steer`/`inject`/`cancel` reject structurally; load/setup rejection or owner unload publishes nothing. Teardown runs stop/drain (including outstanding idle-injection flushes) → unregister → detach session → unwind scope. All `agent/*` dispatches go through `agentEvents(ctx, agent)`; per-step assembly through `assembleContextFor(agent)`; the turn-end durability checkpoint through `ctx.sessions.flush(session)`. - `ctx.agentLoop.create(id: string, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent` — config-driven create: an agent on a fresh per-run session id `${id}-session-` with optional session metadata. Used for `cordis.yml`-configured agents. The per-run uuid avoids colliding with the on-disk log a prior run materialized once a durable persistence backend is loaded; each run is a new session (a deliberate demo simplification — a real resume-or-create policy is a TODO). Disposed with the calling fiber. diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index fb00f4ad76..c4c35f34db 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -31,8 +31,12 @@ export interface PreparedReactLoopAgent { agent: ReactLoopAgent /** Open its driving verbs at the rollback-covered publication boundary. */ enableDrive(): void - /** Start its driver after publication and session-start notification. */ - startDriver(): () => void + /** + * Start its driver after publication and session-start notification. + * The returned disposer reaches quiescence for both the loop and every + * fire-and-forget idle-injection flush the agent started. + */ + startDriver(): () => Promise } /** @@ -126,6 +130,12 @@ export class ReactLoopAgent implements Agent { * the `disposed` transition fires and leave the promise hanging. */ private idleWaiters: (() => void)[] = [] + /** + * Durability checkpoints started by idle {@link inject} calls. `inject()` is + * synchronous, so it cannot await them itself; the driver disposer drains + * this set before the lifecycle unregisters the agent or detaches its session. + */ + private pendingIdleFlushes = new Set>() constructor( private loopCtx: Context, @@ -249,14 +259,16 @@ export class ReactLoopAgent implements Agent { // will flush this turn. Fire-and-forget with error containment: inject() // is synchronous, and a persistence backend failing must not throw into // the caller (e.g. a tool-bash task-done callback). Disposal still drains - // independently, so a slow flush is safe. A flush failure is reported via - // agent/error (step 0 — the idle-injection convention, there is no real - // step) AND the logger, mirroring the loop's post-turn/end flush path so - // plugins monitoring agent/error see idle-injection persistence failures - // too. A throwing agent/error listener is contained. + // independently, so a slow flush is safe. The task is tracked until it + // settles: driver disposal awaits every pending idle-injection checkpoint + // before unregistering the agent or detaching the session. A flush failure + // is reported via agent/error (step 0 — the idle-injection convention, + // there is no real step) AND the logger, mirroring the loop's post-turn/end + // flush path so plugins monitoring agent/error see idle-injection + // persistence failures too. A throwing agent/error listener is contained. if (turnRecorded) { // Through the store's flush (the carrier owner), never a raw parallel. - void this.loopCtx.sessions.flush(this.session).catch((error: unknown) => { + const flush = this.loopCtx.sessions.flush(this.session).catch((error: unknown) => { const err = error instanceof Error ? error : new Error(String(error)) this.loopCtx.logger.warn(`agent "${this.id}": flush after idle injection failed: ${err.message}`) try { @@ -266,6 +278,13 @@ export class ReactLoopAgent implements Agent { // listener must not escape this fire-and-forget catch. } }) + this.pendingIdleFlushes.add(flush) + // Attach the same retirement callback to both settlement arms so even a + // logger failure in the catch above cannot become an unhandled rejection. + // Teardown uses allSettled for the same reason: a reporting failure must + // not strand ownership. + const retire = (): void => { this.pendingIdleFlushes.delete(flush) } + void flush.then(retire, retire) } } } @@ -310,8 +329,8 @@ export class ReactLoopAgent implements Agent { * fully ended) or chaining {@link done} on `disposed` (wait for the loop to * actually exit). Implements the {@link Agent.whenIdle} contract: a non-owner * quiescence-observation hook, distinct from teardown (a lifecycle owner stops - * and unregisters via `AgentHandle.dispose()`, which awaits {@link done} - * directly, not through this). + * and unregisters via `AgentHandle.dispose()`, whose driver boundary awaits + * both {@link done} and outstanding idle-injection flushes, not through this). */ whenIdle(): Promise { if (this._status === 'disposed') return this.done @@ -334,12 +353,14 @@ export class ReactLoopAgent implements Agent { * Start the driver loop. Returns a disposer: calling it sets status to * `disposed`, emits `agent/status('disposed')`, resolves the disposed * promise (unblocking the idle wait), releases any `whenIdle` waiters, and - * aborts the current request if any. The returned `agent.done` promise - * resolves once the loop exits. - * @returns the disposer — idempotent and infallible (it runs inside the - * fiber's LIFO disposal chain, where a throw would skip later disposers). + * aborts the current request if any. Its returned promise resolves only after + * the loop exits and every idle-injection flush started by this agent settles. + * @returns the disposer — idempotent, synchronously marks the agent disposed, + * and asynchronously reaches loop + flush quiescence without rejecting (it + * runs inside the fiber's LIFO disposal chain, where a rejection would skip + * later disposers). */ - [startDriver](): () => void { + [startDriver](): () => Promise { this.done = runLoop(this.loopCtx, this, { inbox: this.#inbox, setStatus: (status) => { this.setStatus(status) }, @@ -360,22 +381,35 @@ export class ReactLoopAgent implements Agent { // The disposer must be infallible: it runs inside the fiber's LIFO // disposal chain, where a throw would skip later disposers (e.g. the // registry unregistration) and leave `done` pending forever. - return () => { - if (this._status === 'disposed') return - this._status = 'disposed' - this.resolveDisposed() - // Release whenIdle waiters BEFORE the (guarded) event emit — they are - // internal state that must settle even if a listener throws below. Each - // waiter chains `done`, so it resolves only once the loop actually exits. - this.settleIdleWaiters() - this.currentAbort?.abort('disposed') - // setStatus refuses transitions out of 'disposed', so emit directly — - // 'disposed' is part of the agent/status contract. Guarded: a throwing - // listener must not break the disposal chain. - try { - this.loopCtx.emit(this.carrier, 'agent/status', this, 'disposed') - } catch { - // listener error during disposal — nothing safe left to do with it + return async () => { + if (this._status !== 'disposed') { + this._status = 'disposed' + this.resolveDisposed() + // Release whenIdle waiters BEFORE the (guarded) event emit — they are + // internal state that must settle even if a listener throws below. Each + // waiter chains `done`, so it resolves only once the loop actually exits. + this.settleIdleWaiters() + this.currentAbort?.abort('disposed') + // setStatus refuses transitions out of 'disposed', so emit directly — + // 'disposed' is part of the agent/status contract. Guarded: a throwing + // listener must not break the disposal chain. + try { + this.loopCtx.emit(this.carrier, 'agent/status', this, 'disposed') + } catch { + // listener error during disposal — nothing safe left to do with it + } + } + // An unexpected driver rejection must not skip registry/session/scope + // cleanup. The normal loop contains turn failures itself; allSettled is the + // final lifecycle backstop for anything outside those boundaries. + await Promise.allSettled([this.done]) + // No new inject() can start after the synchronous disposed transition. + // Loop because settled tasks retire themselves in promise reactions that + // may run beside this continuation; either the set is empty or this waits + // the exact remaining quiescence boundary. allSettled keeps a failure in + // error reporting from skipping the registry/session/scope disposers. + while (this.pendingIdleFlushes.size > 0) { + await Promise.allSettled([...this.pendingIdleFlushes]) } } } diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 0b8879c2b7..bd5b7e3188 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -375,7 +375,7 @@ export class AgentLoop extends Service implements AgentFactory { let active = true let detachSession: (() => void) | undefined let detachAgent: (() => void) | undefined - let stop: (() => void) | undefined + let stop: (() => Promise) | undefined const { promise: deactivated, resolve: markDeactivated } = Promise.withResolvers() const { promise: torndown, resolve: markTorndown } = Promise.withResolvers() @@ -401,8 +401,7 @@ export class AgentLoop extends Service implements AgentFactory { active = false markDeactivated() if (stop === undefined) return - stop() - return agent.done + return stop() } }, 'agentLoop.lifecycle()') @@ -461,8 +460,9 @@ export class AgentLoop extends Service implements AgentFactory { /** * Build an {@link AgentHandle} for a PREPARED session + a fresh agent. The * handle's `dispose()` runs the composite effect's disposer (see - * {@link start}) — which stops the loop, awaits its exit (final flush - * captured), unregisters the agent, and detaches the session, in that order. + * {@link start}) — which stops the loop, awaits its exit and outstanding + * idle-injection flushes, unregisters the agent, and detaches the session, in + * that order. * The same composite effect is what a fiber unload disposes, so both teardown * triggers honor the ordering identically. * @@ -470,8 +470,8 @@ export class AgentLoop extends Service implements AgentFactory { * single-shot (a second call returns immediately because the effect's epoch is * already cleared, NOT awaiting the in-flight teardown), so concurrent/repeated * `dispose()` calls would otherwise resolve before the first call's - * `await agent.done` + final flush completed. Memoizing the promise makes every - * caller observe the SAME quiescence boundary, honoring the + * loop + flush quiescence boundary completed. Memoizing the promise makes + * every caller observe that SAME boundary, honoring the * `AgentHandle.dispose(): Promise` contract (mirrors the ACP `quiesce()` * helper). */ diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index b49c38f5da..8c30e42257 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -489,7 +489,7 @@ async function runTurn( // against the system prompt (it counts toward the budget). runStep reuses // this same assembly for the request, so the prompt is assembled once per // step. renderPrompt IS the full prompt — the persona is the order-0 - // section (registered by the AgentLoop plugin) and `{{variable}}` + // section (owned by dsh-system-prompt) and `{{variable}}` // interpolation happens in the render, so there is no separate join. const assembly = await ctx.systemPrompt.assemble(assembleContextFor(agent)) const fullSystemPrompt = renderPrompt(assembly) diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index d4f9d1e3d4..ab3494d991 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -242,11 +242,12 @@ describe('ReactLoopAgent', () => { const dispose = prepared.startDriver() // First dispose - dispose() + const firstDisposal = dispose() expect(agent.status).toBe('disposed') + await firstDisposal // Second dispose — idempotent, no throw - expect(() => { dispose() }).not.toThrow() + await expect(dispose()).resolves.toBeUndefined() expect(agent.status).toBe('disposed') }) @@ -347,10 +348,10 @@ describe('ReactLoopAgent', () => { expect(agent.status).toBe('running') const idle = agent.whenIdle() // queues an internal waiter (running) - dispose() // settles the waiter synchronously; whenIdle chains done + const disposal = dispose() // settles the waiter synchronously; whenIdle chains done await idle expect(agent.status).toBe('disposed') - await agent.done + await disposal }) it('whenIdle() subscribed while running survives a FIBER dispose (no hung promise)', async () => { diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index 085629db64..2bf58be832 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -440,4 +440,35 @@ describe('agent scope lifecycle', () => { expect(ctx.sessions.get(SessionId('h1-s'))).toBeUndefined() await unload }) + + it('handle.dispose() awaits an idle-injection flush before unregistering or detaching', async () => { + const ctx = await harness() + const handle = await ctx.agents.create({ + agentId: AgentId('idle-flush'), + sessionId: SessionId('idle-flush-s'), + agentOptions: { model: 'mock' }, + }) + const gate = Promise.withResolvers() + let flushStarted = false + ctx.on('session/flush', (session) => { + if (session !== handle.agent.session) return + flushStarted = true + return gate.promise + }) + + handle.agent.inject(text('durable idle context'), { source: { kind: 'plugin', plugin: 'test' } }) + expect(flushStarted).toBe(true) + + let disposed = false + const disposal = handle.dispose().then(() => { disposed = true }) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(disposed).toBe(false) + expect(ctx.agents.get(AgentId('idle-flush'))).toBe(handle.agent) + expect(ctx.sessions.get(SessionId('idle-flush-s'))).toBe(handle.agent.session) + + gate.resolve(undefined) + await disposal + expect(ctx.agents.get(AgentId('idle-flush'))).toBeUndefined() + expect(ctx.sessions.get(SessionId('idle-flush-s'))).toBeUndefined() + }) }) diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 035332704e..71e2be279d 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -23,7 +23,7 @@ Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-age - `ctx.agents.create(options: CreateAgentOptions): Promise` — snapshot caller-owned IDs/options/metadata/seed, construct and await optional setup while unpublished, insert and announce both session and agent, open the `agent/session-start` driving boundary, then start a new loop on the caller-supplied `sessionId`. Agent/session IDs are reserved across setup; setup rejection or owner unload publishes nothing. Publication is rollback-covered: if a creation listener throws, entries and scope unwind but effects of already-delivered notifications remain observable; an agent whose announcement began emits `agent/disposed` during that rollback. Rejects if no factory is registered. - `ctx.agents.resume(options: ResumeAgentOptions): Promise` — snapshot caller-owned IDs/options, load a persisted session ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), mint a fresh agent scope, await optional setup while unpublished, then follow the same insert → announce → session-start → loop-start boundary. The IDs are reserved across persistence load and setup; load/setup rejection or owner unload publishes nothing. Rejects if no factory is registered or session persistence is unconfigured. -`AgentHandle = { agent: Agent; dispose(): Promise }`. The disposer is a **capability** — only the holder can tear this agent down. `dispose()` stops the loop, `await`s its exit (quiescence — NOT just the `disposed` status flip), unregisters the agent, removes its session from the store, and finally unwinds its scoped world. This order captures the loop's final `session/flush` before the session is detached and keeps scoped listeners alive through that flush. `ctx.agents.get(id)` still returns a bare `Agent` — the handle is only for the OWNER that created it. The ACP bridge and in-process subagent backends are production consumers; config-created agents are owned by the loop fiber and never need a handle. +`AgentHandle = { agent: Agent; dispose(): Promise }`. The disposer is a **capability** — only the holder can tear this agent down. `dispose()` stops the loop, `await`s its exit plus every outstanding idle-injection flush (quiescence — NOT just the `disposed` status flip), unregisters the agent, removes its session from the store, and finally unwinds its scoped world. This order captures every agent-started `session/flush` before the session is detached and keeps scoped listeners alive through those checkpoints. `ctx.agents.get(id)` still returns a bare `Agent` — the handle is only for the OWNER that created it. The ACP bridge and in-process subagent backends are production consumers; config-created agents are owned by the loop fiber and never need a handle. ### Live events diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 5bd3dd80d7..c73de89e6d 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -107,11 +107,12 @@ export interface ResumeAgentOptions { /** * An owned agent plus its disposer, returned by {@link AgentRegistry.create} / * {@link AgentRegistry.resume}. The disposer is a CAPABILITY: only the holder - * can tear this agent down. `dispose()` stops the loop, awaits its exit - * (quiescence — NOT just the `disposed` status flip), unregisters the agent, - * removes its session from the store, and finally unwinds its scoped world. - * This order captures the loop's final `session/flush` before the session is - * detached and keeps scoped listeners alive through that flush. + * can tear this agent down. `dispose()` stops the loop, awaits its exit and + * every outstanding idle-injection flush (quiescence — NOT just the `disposed` + * status flip), unregisters the agent, removes its session from the store, and + * finally unwinds its scoped world. This order captures every agent-started + * `session/flush` before the session is detached and keeps scoped listeners + * alive through those checkpoints. * * `ctx.agents.get(id)` still returns a bare {@link Agent} — the handle is only * for the OWNER that created it. Config-created agents (the loop's own startup) diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 1017a2df46..4196eb43d6 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -222,8 +222,10 @@ export interface Agent { * turn (`turn/start` → `context/message` → `turn/end`) and checkpoints it for * durability, so every event stays inside a turn and a persistence backend * never loses a between-turn notice. The idle checkpoint is fire-and-forget - * (inject is synchronous): a failing flush is reported via `agent/error` - * (step `0`) and the logger, never thrown into the caller. + * from this synchronous method, but lifecycle disposal awaits it before + * unregistering the agent or detaching its session. A failing flush is + * reported via `agent/error` (step `0`) and the logger, never thrown into the + * caller. * * Live-adapter review has validated the tagged-envelope rendering against * current DeepSeek behavior; provider-specific mismatches belong in that diff --git a/packages/hooks/hooks-claude/README.md b/packages/hooks/hooks-claude/README.md index 306bfdbfeb..f97cdb5cfc 100644 --- a/packages/hooks/hooks-claude/README.md +++ b/packages/hooks/hooks-claude/README.md @@ -39,7 +39,7 @@ The hooks **themselves** run in the agent's session workspace: for the agent-sco | `PreToolUse` | `tools/pre-execute` (waterfall) | `deny` → `PreToolDecision.deny`; `ask` → `PreToolDecision.ask` | | `PostToolUse` | `tools/post-execute` (waterfall) | `deny` → `block` with feedback; additionalContext-only → delegate via `next()` then fold context onto the downstream decision (a Code Mode sub-call’s context is dropped by the run_code bridge — see [the pipeline doc](../../../docs/tool-execution-pipeline.md)) | | `Stop` | `agent/turn-continuation` (waterfall) | a blocking Stop hook forces `continue`, feeding its reason as next-step steering | -| `SubagentStart` | `subagent/start` (emit) | additionalContext → `agent.inject()` into the live child | +| `SubagentStart` | `subagent/start` (emit) | additionalContext → `agent.inject()` into a live in-process child; a remote child has no local injection target | | `SubagentStop` | `subagent/end` (emit) | observe-only | The three emit points run detached — no seam awaits a `SessionStart`/`SubagentStart`/`SubagentStop` hook. Each run chain is tracked, and disposing the bridge aborts still-running hook processes, then drains the continuations before the dispose resolves (`createDetachedRuns` in `dsh-hook-protocol`). diff --git a/packages/subagent/subagent-acp/README.md b/packages/subagent/subagent-acp/README.md index 7fb087dcdc..cb51986915 100644 --- a/packages/subagent/subagent-acp/README.md +++ b/packages/subagent/subagent-acp/README.md @@ -6,7 +6,7 @@ It is the direction-inverted twin of the server-side bridge in [`@deepseek-ai/ds ## What it does -`start(request)` spawns the configured command, wraps its stdio in an ACP `ClientSideConnection`, and drives one session: `initialize` → `newSession` → `prompt`. The child's streamed `agent_message_chunk` text becomes the `SubagentResult.output`; the prompt's terminal `StopReason` maps to the stop reason. `dispose()` kills the subprocess and awaits its exit. +`start(request)` spawns the configured command, wraps its stdio in an ACP `ClientSideConnection`, and drives one session: `initialize` → `newSession` → `prompt`. `run.started` resolves after `newSession` publishes the remote session and rejects when initialization fails or cancellation wins first; the service emits no start/end pair for a child that never became live. The child's streamed `agent_message_chunk` text becomes the `SubagentResult.output`; the prompt's terminal `StopReason` maps to the stop reason. `dispose()` kills the subprocess and awaits its exit. **Fresh process per run.** Each `start` spawns a new child, runs exactly one ACP session, and disposes it. Persistent-process pooling is a future optimization (see the RFC). @@ -22,7 +22,7 @@ Unlike the in-process backends, the child does NOT share this cordis context — | `providerName` | string | `acp` | Registry name on `ctx.subagents`. | | `command` | string | — (required) | The executable to spawn for each run (the child ACP agent). | | `args` | string[] | `[]` | Arguments passed to `command`. | -| `cwd` | string | parent cwd | Working directory for the child process and its ACP session. | +| `cwd` | string | process cwd | Working directory for the child process and its ACP session. | | `permission` | `'allow' \| 'reject'` | `reject` | How to auto-answer the child's `session/request_permission` prompts. `reject` declines every prompt (answer `cancelled`); `allow` approves via the first allow-shaped option. The first cut surfaces no prompt to a human. | | `env` | Record | `{}` | Extra env vars for the child (e.g. its own `DEEPSEEK_API_KEY`). Forwarded on top of a credential-scrubbed copy of the parent env, so an explicit key reaches the child while ambient secrets do not leak implicitly. | | `disposeEofGraceMs` | number | `6000` | Dispose ladder tier 1: how long the child gets to quiesce on its own after stdin EOF (flush persistence, tear down its nested subprocesses) before SIGTERM. | diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 9d5b17cb9a..410f7ad7bf 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -196,9 +196,15 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su // return an inert run that settled `aborted`, rather than launching the // configured binary just to tear it down. `dispose`/`cancel` are no-ops. if (request.signal?.aborted) { + const started = Promise.reject(new Error('subagent request was aborted before the ACP child started')) + // The result is derived from the same boundary so the readiness rejection + // is observed even when this provider is driven directly rather than + // through SubagentService. + const result: Promise = started.catch(() => ({ output: [], stopReason: 'aborted' })) return { id, - result: Promise.resolve({ output: [], stopReason: 'aborted' }), + started, + result, cancel(_reason?: string): void { /* nothing was started */ }, dispose(): Promise { return Promise.resolve() }, } @@ -289,54 +295,70 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su const onAbort = (): void => { requestCancel() } request.signal?.addEventListener('abort', onAbort, { once: true }) + // The accumulated child text as harness ContentBlocks (empty array when the + // child streamed nothing). Read at every return so a partial answer survives + // a later cancel/error. + const collectOutput = (): ContentBlock[] => { + const text = output.join('') + return text.length > 0 ? [{ type: 'text', text }] : [] + } + + // A provider is "started" only once the remote child has completed ACP + // initialization and published a session. SubagentService gates its + // `subagent/start` notification on this boundary, just as the in-process + // provider gates it on local Agent publication. Failure or cancellation + // before this point rejects readiness and therefore produces no paired + // lifecycle events for a child that never became live. + const started: Promise = Promise.race([ + (async (): Promise => { + await conn.initialize({ + protocolVersion: PROTOCOL_VERSION, + // Advertise NO optional client capabilities (no fs, no terminal): the + // child self-serves in its own process. + clientCapabilities: {}, + }) + const session = await conn.newSession({ cwd: spec.cwd, mcpServers: [] }) + sessionId = session.sessionId + if (flags.cancelled) throw new Error('subagent cancelled before the ACP session started') + })(), + spawnFailed.then((err): never => { throw err }), + cancelSettled.then((): never => { throw new Error('subagent cancelled before the ACP session started') }), + ]) + const result: Promise = (async (): Promise => { - // The accumulated child text as harness ContentBlocks (empty array when the - // child streamed nothing). Read at every return so a partial answer survives - // a later cancel/error. - const collectOutput = (): ContentBlock[] => { - const text = output.join('') - return text.length > 0 ? [{ type: 'text', text }] : [] - } try { - // Race three outcomes, first to settle wins: - // - driveAcp: the normal initialize → newSession → prompt path; - // - spawnFailed: a bad command never speaks ACP, so `initialize` would - // hang forever — the spawn `error` event is the only signal, and a - // rejected race settles the run `error` via the catch; + // Readiness is the initialize → newSession phase above. Awaiting the SAME + // promise immediately observes its rejection even without the service, + // and guarantees the prompt phase never starts before the provider can + // truthfully announce a live child. + await started + + // Race two post-start outcomes, first to settle wins: + // - prompt: the normal remote turn; // - cancelSettled: a cancel was requested — settle `aborted` immediately // rather than waiting on a child that may ignore `session/cancel` or // wedge the prompt (the `cancel()` contract: `result` settles `aborted`). - const driveAcp = async (): Promise => { - await conn.initialize({ - protocolVersion: PROTOCOL_VERSION, - // Advertise NO optional client capabilities (no fs, no terminal): the - // child self-serves in its own process. - clientCapabilities: {}, - }) - const session = await conn.newSession({ cwd: spec.cwd, mcpServers: [] }) - sessionId = session.sessionId - // A cancel that raced ahead of `newSession` set `cancelled` but could not - // send `session/cancel` (no session id yet). Honor it here: settle - // `aborted` without ever issuing the prompt, rather than running the child - // to completion and ignoring the cancel. - if (flags.cancelled) return { output: collectOutput(), stopReason: 'aborted' } - const promptResult = await conn.prompt({ sessionId, prompt: toAcpPrompt(request.prompt) }) + // A spawn error can only precede readiness and is already one arm of + // `started`; after `newSession` succeeds, transport/process failure rejects + // the in-flight prompt RPC through the connection. + const prompt = async (): Promise => { + // `started` cannot fulfill without assigning the session id; the cast + // records that local invariant without an unreachable defensive arm. + const promptResult = await conn.prompt({ sessionId: sessionId as string, prompt: toAcpPrompt(request.prompt) }) return { output: collectOutput(), stopReason: acpStopReason(promptResult.stopReason) } } return await Promise.race([ - driveAcp(), - spawnFailed.then((err): SubagentResult => { throw err }), + prompt(), cancelSettled.then((): SubagentResult => ({ output: collectOutput(), stopReason: 'aborted' })), ]) } catch (error: unknown) { + if (flags.cancelled) return { output: collectOutput(), stopReason: 'aborted' } // The seam contract: result resolves (never rejects) on a child-level - // failure. Cancellation is handled by the `cancelSettled` race arm above - // (it settles `aborted` the instant cancel is requested, beating any - // rejection), so a rejection that reaches HERE is always a genuine - // child-level error — the awaited ACP RPCs or the spawn-failure race - // (initialize/newSession/prompt transport/RPC errors, or ENOENT), not a - // local bug. Flatten to `error` and surface the original via onError so a - // real fault is preserved rather than silently lost. + // failure. A cancellation is recognized by the flag above even when it + // wins during readiness; every other rejection is a genuine child-level + // error — initialize/newSession/prompt transport/RPC failure or ENOENT. + // Flatten to `error` and surface the original via onError so a real fault + // is preserved rather than silently lost. try { spec.onError?.(toError(error), 'error') } catch { @@ -350,6 +372,7 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su return { id, + started, result, cancel(_reason?: string): void { requestCancel() diff --git a/packages/subagent/subagent-fork/README.md b/packages/subagent/subagent-fork/README.md index c626775945..1434601c8e 100644 --- a/packages/subagent/subagent-fork/README.md +++ b/packages/subagent/subagent-fork/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-subagent-fork -The in-process **fork** subagent backend: a [`SubagentProvider`](../subagent/README.md) that runs each child as a child [`Agent`](../../core/agent) **seeded with a prefix of the parent's session log** — so the child inherits the parent's conversation context instead of starting fresh. Shares the run driver (`startInProcessRun`) with [`dsh-subagent-spawn`](../subagent-spawn/README.md); the only difference is the seed. +The in-process **fork** subagent backend: a [`SubagentProvider`](../subagent/README.md) that runs each child as a child [`Agent`](../../core/agent) **seeded with a prefix of the parent's session log** — so the child inherits the parent's conversation context instead of starting fresh. Shares the run driver (`startInProcessRun`) with [`dsh-subagent-spawn`](../subagent-spawn/README.md); the only difference is the seed. The shared `run.started` boundary resolves only after the seeded child is published, so `subagent/start` observers see a live registry entry. ## The seed boundary (the crux) diff --git a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts index 31a4f4c895..f9c7b04a51 100644 --- a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts +++ b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts @@ -71,6 +71,23 @@ describe('completedTurnPrefix', () => { }) describe('dsh-subagent-fork', () => { + it('emits subagent/start only after the seeded child is published', async () => { + const { ctx, parent } = await setup([textResponse('child answer')]) + let childAtStart: ReturnType + ctx.on('subagent/start', (info) => { + if (info.provider === 'fork') childAtStart = ctx.agents.get(info.id) + }) + + const run = ctx.subagents.start('fork', { prompt: [{ type: 'text', text: 'child q' }], parent }) + expect(childAtStart).toBeUndefined() + await run.started + expect(childAtStart).toBe(ctx.agents.get(run.id)) + expect(childAtStart?.id).toBe(run.id) + + await run.result + await run.dispose() + }) + it('forks an UNSEEDED (fresh) child when the parent has no completed turn', async () => { // The parent has never completed a turn → empty prefix → the provider omits // the seed → the child runs fresh. Exercises the `seed.length > 0` false arm. diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 1c5aa13374..caa7b9b161 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -9,11 +9,11 @@ The shared **in-process subagent run driver**. A library with no provider or imp Runs a child as a child [`Agent`](../../core/agent) on the same cordis context (`ctx.agents`): 1. snapshots the accepted request before asynchronous owner setup: the parent and signal remain identity capabilities but are never reread from the caller-owned record; tool filter, seed, agent options, output schema, and prompt are detached. It computes child depth = `depthOf(parent) + 1` and rejects `request.maxDepth` overflow with `SubagentDepthError`; `outputSchema` is asserted before cloning so a hostile value fails as `OutputSchemaError`, while the prompt passes the session log's lossless-JSON check before and after cloning; -2. first installs provider ownership, then attaches the request abort listener and creates one run-owner Cordis fiber under `parent.ctx`; an already-unloading provider therefore leaves no child or orphaned listener. Async child creation goes through that fiber's `ctx.agents` service with fresh IDs, lineage/seed, inherited model, and an unpublished setup transaction for persona, tool restriction, and structured output. Parent teardown, provider teardown, and manual `run.dispose()` all dispose this exact node, preventing publication after it becomes inactive and awaiting the same quiescence boundary. `startInProcessRun` still returns its `SubagentRun` immediately, and cancellation during creation is recorded and applied when a child exists; +2. first installs provider ownership, then attaches the request abort listener and creates one run-owner Cordis fiber under `parent.ctx`; an already-unloading provider therefore leaves no child or orphaned listener. Async child creation goes through that fiber's `ctx.agents` service with fresh IDs, lineage/seed, inherited model, and an unpublished setup transaction for persona, tool restriction, and structured output. Parent teardown, provider teardown, and manual `run.dispose()` all dispose this exact node, preventing publication after it becomes inactive and awaiting the same quiescence boundary. `startInProcessRun` still returns its `SubagentRun` immediately: `run.started` resolves only after `ctx.agents.create()` has published the child (and rejects if publication never happens), while cancellation during creation is recorded and applied when a child exists; 3. drives the one-shot: `child.send(prompt)` then `await child.whenIdle()` (ordering matters — `send` enqueues synchronously, so `whenIdle` observes the queued work and resolves on the child's `running → idle` transition, never before the turn starts); there is deliberately NO re-prompt for a structured child that finished cleanly without calling `structured_output` — the shortfall maps to an `error` result for the parent; 4. reads the result, scoped to the child's OWN events (everything at or after `seedLength`, so a seeded child that produced no message of its own never returns the seeded parent's last message): the last `assistant/message` content (deep-cloned — the log is frozen) and the last `turn/end.reason` mapped to a `SubagentStopReason`. A structured run surfaces the captured value as `result.structured`; a structured child that finished cleanly WITHOUT ever capturing settles `error` (a clean finish without the demanded result is a failure, not a success with a missing field). -`dispose()` awaits creation or rollback and then delegates to `AgentHandle.dispose()` (stop and drain → remove agent → detach session → unwind scope); `cancel()` records its request even before publication and cancels the child immediately once available. A cancel landing before any `turn/end` still settles `aborted`, honoring the cancel contract rather than the generic no-turn `error`. +`SubagentService` waits for `run.started` before emitting `subagent/start`, so a synchronous start observer can resolve the published child with `ctx.agents.get(run.id)`; the result driver awaits the same boundary before sending the prompt. An attempt that never publishes rejects readiness and emits no false start/end pair; its result reports a deliberate cancel/dispose as `aborted` and propagates an infrastructure fault. `dispose()` awaits creation or rollback and then delegates to `AgentHandle.dispose()` (stop and drain → remove agent → detach session → unwind scope); `cancel()` records its request even before publication and cancels the child immediately once available. A cancel landing before any `turn/end` still settles `aborted`, honoring the cancel contract rather than the generic no-turn `error`. ### `InProcessRunOptions` diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 61867e1b39..de3b042fe3 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -289,11 +289,23 @@ export function startInProcessRun( return created.agent })() + // Provider readiness is a distinct lifecycle boundary from accepting the + // request. It resolves only after the factory has published the child and + // returned its handle, so SubagentService can emit `subagent/start` while + // `ctx.agents.get(childId)` is guaranteed to resolve. The result path awaits + // THIS SAME promise immediately, which also observes a readiness rejection + // when the driver is invoked directly rather than through SubagentService. + const started: Promise = creation.then(() => undefined) + const result: Promise = (async () => { try { let liveChild: Agent try { - liveChild = await creation + await started + // `creation` assigns `child` before it fulfills, and `started` is its + // direct fulfillment projection. The cast records that local invariant + // without manufacturing an unreachable runtime branch. + liveChild = child as Agent } catch (error: unknown) { if (isManualDisposeRequested()) return { output: [], stopReason: 'aborted' } throw error instanceof Error ? error : new Error('subagent child creation failed with a non-Error value', { cause: error }) @@ -313,6 +325,7 @@ export function startInProcessRun( let disposing: Promise | undefined return { id: childId, + started, result, cancel(reason?: string): void { requestCancel(reason ?? 'subagent cancelled') diff --git a/packages/subagent/subagent-spawn/README.md b/packages/subagent/subagent-spawn/README.md index e133cb1e5b..e411d44ab0 100644 --- a/packages/subagent/subagent-spawn/README.md +++ b/packages/subagent/subagent-spawn/README.md @@ -6,7 +6,7 @@ The run mechanics live in the shared [`@deepseek-ai/dsh-subagent-inprocess`](../ ## What it does -`start(request)` delegates to `startInProcessRun(ctx, request, {})` with no seed: a fresh child agent with the parent's `cwd`/`parentSession` lineage and (by default) the parent's model. The driver creates one run-owner fiber under `parent.ctx`; parent teardown, this provider's teardown, and manual disposal all converge there before child publication. See the [driver README](../subagent-inprocess/README.md) for the full lifecycle (depth check, one-shot drive, result read, dispose). +`start(request)` delegates to `startInProcessRun(ctx, request, {})` with no seed: a fresh child agent with the parent's `cwd`/`parentSession` lineage and (by default) the parent's model. The driver creates one run-owner fiber under `parent.ctx`; parent teardown, this provider's teardown, and manual disposal all converge there before child publication. Its `run.started` boundary resolves only after the fresh child is published, so `subagent/start` observers see a live registry entry. See the [driver README](../subagent-inprocess/README.md) for the full lifecycle (depth check, one-shot drive, result read, dispose). ## Capabilities diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index a8dde937c0..3d23fca285 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -55,6 +55,25 @@ describe('dsh-subagent-spawn', () => { await run.dispose() }) + it('emits subagent/start only after the fresh child is published', async () => { + const { ctx, parent } = await setup([textResponse('child answer')]) + let childAtStart: ReturnType + ctx.on('subagent/start', (info) => { + if (info.provider === 'spawn') childAtStart = ctx.agents.get(info.id) + }) + + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'do X' }], parent }) + // Creation is asynchronous; no lifecycle claim is made while the child is + // still inside its unpublished setup transaction. + expect(childAtStart).toBeUndefined() + await run.started + expect(childAtStart).toBe(ctx.agents.get(run.id)) + expect(childAtStart?.id).toBe(run.id) + + await run.result + await run.dispose() + }) + it('gives the child its OWN session (not the parent\'s), with parentSession lineage', async () => { const { ctx, parent } = await setup([textResponse('hi')]) const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 695bd8f952..e214ba58c6 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -18,10 +18,10 @@ Unlike the bash seam (one executor per context, second load throws), **multiple | Member | Semantics | |---|---| -| `registerProvider(provider)` | Register under `provider.name`. Throws `SubagentError('DUPLICATE_PROVIDER')` on a name clash. Effect-scoped (HMR-safe); returns the disposer. | -| `getProvider(name)` | Look up a provider (`undefined` if absent). | +| `registerProvider(provider)` | Register a frozen acceptance snapshot under `provider.name`; later caller mutation cannot change registry behavior or HMR cleanup, while `start` stays bound to the original provider receiver. Throws `SubagentError('DUPLICATE_PROVIDER')` on a name clash. Effect-scoped (HMR-safe); returns the disposer. | +| `getProvider(name)` | Look up the frozen registry snapshot (`undefined` if absent). | | `list()` | Registered provider names (insertion order). | -| `start(name, request)` | Resolve the provider (`NO_PROVIDER` if absent), validate every requested START-TIME capability (`UNSUPPORTED_CAPABILITY` for the first unmet one — before any child is created), then delegate to `provider.start` and emit `subagent/start` / `subagent/end` around the run. | +| `start(name, request)` | Resolve the provider (`NO_PROVIDER` if absent), validate every requested START-TIME capability (`UNSUPPORTED_CAPABILITY` for the first unmet one — before any child is created), then delegate to `provider.start`. Emit `subagent/start` only after `run.started` fulfills and the paired `subagent/end` after that started run settles; a pre-publication readiness rejection emits neither. | ## Capabilities: two kinds, discovered two ways @@ -32,9 +32,9 @@ Beside `capabilities` sits one DESCRIPTIVE fact, not validated by the service: ` ## Run lifecycle -`provider.start(request)` returns a `SubagentRun`: a handle with a `result` promise, `cancel()`, `dispose()`, and the optional runtime methods. `result` resolves with a `SubagentResult` (`output`, optional `structured`, `stopReason`) — it does **not** reject on a child-level failure (a model/transport failure resolves with `stopReason: 'error'`), so the consumer maps a non-`completed` reason to an `isError` tool result. The consumer MUST `dispose()` on every path (success, error, abort) to reach child quiescence and avoid leaking an idle child / session. +`provider.start(request)` returns a `SubagentRun`: a handle with `started` (the publication/readiness promise), `result` (the terminal outcome), `cancel()`, `dispose()`, and the optional runtime methods. `started` resolves only after the provider has established a real child and rejects if the attempt fails or is cancelled first. `result` resolves with a `SubagentResult` (`output`, optional `structured`, `stopReason`) — it does **not** reject on a child-level failure (a model/transport failure resolves with `stopReason: 'error'`), so the consumer maps a non-`completed` reason to an `isError` tool result. The consumer MUST `dispose()` on every path (success, error, abort) to reach child quiescence and avoid leaking an idle child / session. -The service also announces provider lifecycle: `subagent/provider-added` (the live provider) fires after a registration and `subagent/provider-removed` (the name) after an unregistration, so a consumer deriving state from a named provider (the model-facing tool wording) mirrors registry membership instead of assuming load order — the cordis Loader starts sibling plugins concurrently, so "listed earlier" does not mean "registered earlier". The service emits `subagent/start` (payload `SubagentRunInfo`) and `subagent/end` (payload `SubagentRunEndInfo`) around the run — both **observe-only** (plain `emit`s; `subagent/end` fires from a detached `.then` and awaits no listener). `subagent/end` carries `lastAssistantMessage` (a deep clone of the child's final `output`) on the settle path, absent when the run rejected at the infrastructure level. The clone keeps the surface observe-only: the end emit fires from a detached `.then` before the caller's `await run.result` resumes, so a shared reference would let a mutating listener corrupt the caller's result. A `subagent/start` listener can still reach the live child via `ctx.agents.get(info.id)`; a `subagent/end` listener can only observe (the run has settled). Any run-affecting decision (continuation, injection that changes the run) is out of scope for this observe-only surface. +The service also announces provider lifecycle: `subagent/provider-added` (the frozen registry snapshot) fires after a registration and `subagent/provider-removed` (the accepted name) after an unregistration, so a consumer deriving state from a named provider (the model-facing tool wording) mirrors registry membership instead of assuming load order — the cordis Loader starts sibling plugins concurrently, so "listed earlier" does not mean "registered earlier". Run lifecycle is gated by provider readiness: `subagent/start` (payload `SubagentRunInfo`) fires only after `run.started` fulfills, and `subagent/end` (payload `SubagentRunEndInfo`) fires only for that announced run; readiness rejection emits neither. For spawn/fork, the start listener can resolve the published child via `ctx.agents.get(info.id)`; a remote provider need not have a local registry entry. Both events are **observe-only** plain emits. The service observes `result` immediately even while readiness is pending, clones its output before the caller can mutate it, and buffers that end payload until start has fired; a rejecting result cannot become an unhandled detached promise, start always precedes end, and a listener cannot corrupt the caller's result. `subagent/end` carries the cloned output as `lastAssistantMessage` on the settle path and omits it on infrastructure rejection. Any run-affecting decision is out of scope for this observe-only surface. ## Scope (first cut) diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 45287271c6..e1f4fac594 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -69,7 +69,7 @@ declare module 'cordis' { * tool wording in `dsh-tool-subagent`) react HERE instead of assuming load * order — the cordis Loader starts sibling plugins concurrently, so * "listed earlier in cordis.yml" does not mean "registered earlier". - * @param provider - the provider that just registered, live in the registry. + * @param provider - the registry's frozen acceptance snapshot of the provider. * @mode emit */ 'subagent/provider-added'(provider: SubagentProvider): void @@ -85,8 +85,11 @@ declare module 'cordis' { */ 'subagent/provider-removed'(name: string): void /** - * A subagent run started — emitted after the provider is resolved and its - * capabilities validated, as the child run begins. Paired with + * A subagent run started — emitted only after {@link SubagentRun.started} + * fulfills, when the provider has established a live child. For an + * in-process provider, `ctx.agents.get(info.id)` is therefore guaranteed to + * resolve during this notification. A readiness rejection emits neither + * lifecycle event; every emitted start is paired with * {@link Events['subagent/end']}. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed * by the DELEGATING PARENT — a listener registered through the parent's @@ -97,8 +100,10 @@ declare module 'cordis' { */ 'subagent/start'(this: Scoped, info: SubagentRunInfo): void /** - * A subagent run settled — emitted when {@link SubagentRun.result} - * resolves (any stop reason). Paired with {@link Events['subagent/start']}. + * A started subagent run settled — emitted when {@link SubagentRun.result} + * resolves (any stop reason) or rejects (reported as `error`). Paired with + * {@link Events['subagent/start']}; a run whose readiness rejected emits + * neither event. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed * by the DELEGATING PARENT — a listener registered through the parent's * `agent.ctx` observes only its own delegations; a plain plugin listener @@ -161,21 +166,43 @@ export class SubagentService extends Service { /** * Register a provider under its `provider.name`. Throws {@link SubagentError} - * (`DUPLICATE_PROVIDER`) if the name is already taken. Effect-scoped: disposed - * with the calling fiber (HMR-safe). Emits `subagent/provider-added` after - * the registration and `subagent/provider-removed` on unregistration, so - * consumers can mirror provider lifecycle instead of assuming load order. + * (`DUPLICATE_PROVIDER`) if the name is already taken. The registry snapshots + * the name, static descriptors, and `start` callback identity at acceptance; + * later caller mutation cannot change lookup, capability validation, consumer + * wording, dispatch, or HMR cleanup. The callback remains bound to the + * original provider object, so provider-owned mutable state stays live. + * Effect-scoped: disposed with the calling fiber (HMR-safe). Emits + * `subagent/provider-added` after the registration and + * `subagent/provider-removed` on unregistration, so consumers can mirror + * provider lifecycle instead of assuming load order. * @param provider - the provider; its `name` is the registry key. * @returns the disposer that unregisters the provider. The exact * Cordis effect disposer (single-shot): composite (generator) effects may * yield it directly — exact identity nests the teardown in order. */ registerProvider(provider: SubagentProvider): () => Promise | void { + // Snapshot the accepted registration contract before entering the effect. + // Cleanup must never re-read caller-owned `provider.name`: an HMR host may + // mutate or reuse the provider object before its old fiber unloads. Binding + // preserves the provider method's receiver while making replacement of the + // public callback field after registration inert. + const capabilities: SubagentCapabilities = Object.freeze({ + outputSchema: provider.capabilities.outputSchema, + depthLimit: provider.capabilities.depthLimit, + toolFilter: provider.capabilities.toolFilter, + persona: provider.capabilities.persona, + }) + const snapshot: SubagentProvider = Object.freeze({ + name: provider.name, + capabilities, + inheritsParentContext: provider.inheritsParentContext, + start: provider.start.bind(provider), + }) const dispose = this.ctx.effect(function* (this: SubagentService) { - if (this.providers.has(provider.name)) { - throw new SubagentError(`a subagent provider named "${provider.name}" is already registered`, 'DUPLICATE_PROVIDER') + if (this.providers.has(snapshot.name)) { + throw new SubagentError(`a subagent provider named "${snapshot.name}" is already registered`, 'DUPLICATE_PROVIDER') } - this.providers.set(provider.name, provider) + this.providers.set(snapshot.name, snapshot) // Yield the rollback BEFORE emitting `subagent/provider-added`: a // throwing added-listener then unregisters the provider (and announces // the removal) instead of leaking it into the registry. The removal @@ -183,10 +210,10 @@ export class SubagentService extends Service { // it runs inside this disposer, where a propagating subscriber would // disrupt the backend fiber's teardown and starve later mirrors. yield () => { - this.providers.delete(provider.name) - this.emitLifecycle('subagent/provider-removed', provider.name) + this.providers.delete(snapshot.name) + this.emitLifecycle('subagent/provider-removed', snapshot.name) } - this.ctx.emit('subagent/provider-added', provider) + this.ctx.emit('subagent/provider-added', snapshot) }.bind(this), 'subagents.registerProvider()') // The EXACT cordis effect disposer, not a wrapper: a composite (generator) // effect that owns a teardown ORDER must be able to yield THIS function — @@ -198,9 +225,10 @@ export class SubagentService extends Service { } /** - * Look up a registered provider by name (`undefined` if absent). - * @param name - the provider name as registered. - * @returns the provider, or undefined when the name is unknown. + * Look up the registry's frozen provider snapshot by its accepted name + * (`undefined` if absent). + * @param name - the provider name accepted at registration. + * @returns the frozen acceptance snapshot, or undefined when the name is unknown. */ getProvider(name: string): SubagentProvider | undefined { return this.providers.get(name) @@ -219,8 +247,9 @@ export class SubagentService extends Service { * `NO_PROVIDER` if absent), validates every requested START-TIME capability * against {@link SubagentProvider.capabilities} (throws `UNSUPPORTED_CAPABILITY` * for the first unmet one — fail loud, before any child is created), then - * delegates to {@link SubagentProvider.start} and emits `subagent/start` / - * `subagent/end` around the run. + * delegates to {@link SubagentProvider.start}, then emits `subagent/start` / + * `subagent/end` only after the run's readiness boundary fulfills. A provider + * that fails before establishing a child emits neither event. * @param name - the provider to run on. * @param request - the child's prompt, capabilities, and options. * @returns the live run (its `result` resolves when the child settles). @@ -252,46 +281,63 @@ export class SubagentService extends Service { ...request.persona !== undefined ? { persona: request.persona } : {}, } const run = provider.start(accepted) - // Emit `subagent/start` with PER-LISTENER containment (see {@link emitLifecycle}): - // the run is already live, so neither a throwing subscriber escaping - // `start()` (the caller would never receive the run to dispose it — a leaked - // child) NOR one bad subscriber starving the listeners after it is - // acceptable. `ctx.emit` halts the dispatch on the first throw, so a single - // surrounding try/catch is not enough — each listener is invoked and - // contained individually. - this.emitLifecycle('subagent/start', { provider: name, id: run.id }, parent) - // Emit `subagent/end` when the run settles. The result promise does not - // reject on a child-level failure (it resolves with stopReason 'error'), - // so a rejection here is an infrastructure fault — surface its stop reason - // as 'error' for the telemetry event without swallowing the rejection - // (the consumer still observes it via `run.result`). On the resolve path the - // child's final output rides on the event (lastAssistantMessage); on the - // reject path there is no SubagentResult, so only the stop reason is known. - // Per-listener containment also keeps a thrown `subagent/end` listener from - // becoming an unhandled rejection on this detached `.then`. + + // Observe result settlement IMMEDIATELY, before waiting on readiness. A + // provider may fail both promises in the same turn; deferring the rejection + // handler until `started` fulfilled would leave `result` transiently + // unhandled. The settled event is buffered until start has been announced, + // preserving start → end order even for an already-settled scripted run. + let readiness: 'pending' | 'started' | 'failed' = 'pending' + let pendingEnd: SubagentRunEndInfo | undefined + const deliverEnd = (info: SubagentRunEndInfo): void => { + if (readiness === 'started') this.emitLifecycle('subagent/end', info, parent) + else if (readiness === 'pending') pendingEnd = info + // A pre-publication readiness failure has no lifecycle pair; result + // remains observable by the run's consumer, but telemetry must not claim + // that a child started. + } void run.result.then( (result) => { - // Deep-clone the output onto the event: this detached `.then` runs BEFORE - // the caller's own `await run.result` continuation, so handing listeners - // the SAME array reference the caller consumes would let a mutating - // `subagent/end` listener corrupt the caller's SubagentResult.output — - // breaking the observe-only contract. A snapshot makes the event a - // read-only view, not a shared handle. The clone is wrapped: it runs - // inside `onFulfilled`, OUTSIDE emitLifecycle's per-listener containment, - // so an uncloneable value (a future non-serializable content-block type, - // or a contract-violating result with no `output`) would otherwise become - // an unhandled rejection on this detached `.then`. On clone failure, log - // and emit the event WITHOUT lastAssistantMessage rather than dropping the - // whole `subagent/end`. + // Snapshot before the caller's own `await run.result` continuation. Even + // when readiness is still pending, buffering the clone rather than the + // caller-owned result keeps the eventual observe-only event immutable + // with respect to consumer mutation. let lastAssistantMessage: SubagentResult['output'] | undefined try { lastAssistantMessage = structuredClone(result.output) } catch (error: unknown) { this.ctx.logger.warn(`subagent: could not clone ${name} output for subagent/end: ${String(error)}`) } - this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: result.stopReason, ...lastAssistantMessage !== undefined ? { lastAssistantMessage } : {} }, parent) + deliverEnd({ + provider: name, + id: run.id, + stopReason: result.stopReason, + ...lastAssistantMessage !== undefined ? { lastAssistantMessage } : {}, + }) + }, + () => { deliverEnd({ provider: name, id: run.id, stopReason: 'error' }) }, + ) + + // Readiness is the publication boundary owned by the provider. For + // in-process runs, fulfillment means the agent registry already contains + // `run.id`; for ACP it means the remote session exists. Emit start with + // per-listener containment, then flush an outcome that settled unusually + // early. A readiness rejection is handled here and deliberately emits no + // false start/end pair; the result path above remains independently handled. + void run.started.then( + () => { + readiness = 'started' + this.emitLifecycle('subagent/start', { provider: name, id: run.id }, parent) + if (pendingEnd !== undefined) { + const info = pendingEnd + pendingEnd = undefined + this.emitLifecycle('subagent/end', info, parent) + } + }, + () => { + readiness = 'failed' + pendingEnd = undefined }, - () => { this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: 'error' }, parent) }, ) return run } diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index a95fbae623..003d6a6a3d 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -142,8 +142,16 @@ export interface SubagentResult { * presence of the method IS the capability — narrow before calling. */ export interface SubagentRun { - /** The child agent's id (use `ctx.agents.get(id)` to reach the live child). */ + /** The child agent's id (local in-process runs publish it in `ctx.agents`; remote transports need not). */ readonly id: AgentId + /** + * The provider's publication/readiness boundary. Resolves only after a real + * child is established: an in-process agent is live in `ctx.agents`, or a + * remote transport has created its child session. Rejects when the attempt + * fails or is cancelled before that boundary. The service emits the paired + * `subagent/start`/`subagent/end` lifecycle only after this fulfills. + */ + readonly started: Promise /** * Resolves with the child's terminal {@link SubagentResult} when the run * settles. Does NOT reject on a child-level failure — a model/transport @@ -176,7 +184,9 @@ export interface SubagentRun { * A subagent backend: one transport for running a child agent (in-process * spawn/fork, ACP to another process, …). Implementations register under a * unique name via {@link SubagentService.registerProvider}; multiple providers - * coexist in one context (unlike the single-implementation bash seam). + * coexist in one context (unlike the single-implementation bash seam). The + * service freezes the public descriptor and callback identity at registration; + * the captured `start` remains bound to the original provider receiver. */ export interface SubagentProvider { /** Unique registry name (e.g. `spawn`, `fork`, `acp`). */ @@ -194,9 +204,12 @@ export interface SubagentProvider { */ readonly inheritsParentContext: boolean /** - * Start a child run. The service has already validated that every requested - * start-time capability is supported, so an implementation may assume e.g. - * `request.maxDepth` is honorable when present. + * Start preparing a child run and return its handle synchronously. The + * service has already validated that every requested start-time capability + * is supported, so an implementation may assume e.g. `request.maxDepth` is + * honorable when present. The returned {@link SubagentRun.started} must mark + * the real publication/readiness boundary; the result path must observe that + * promise immediately so a pre-start rejection cannot become unhandled. */ start(request: SubagentStartRequest): SubagentRun } diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 3a16b19e22..7b9e828c37 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -34,6 +34,7 @@ class StubProvider implements SubagentProvider { this.startCount++ return { id: AgentId(`child:${this.name}:${request.parent.id}`), + started: Promise.resolve(), result: Promise.resolve(this.result), cancel() {}, async dispose() {}, @@ -106,7 +107,7 @@ describe('SubagentService', () => { ctx.subagents.registerProvider(provider) expect(ctx.subagents.list()).toEqual(['alpha']) - expect(ctx.subagents.getProvider('alpha')).toBe(provider) + expect(ctx.subagents.getProvider('alpha')).toMatchObject({ name: 'alpha' }) const run = ctx.subagents.start('alpha', baseRequest()) expect(provider.startCount).toBe(1) @@ -162,6 +163,75 @@ describe('SubagentService', () => { expect(ctx.subagents.list()).toEqual([]) }) + it('snapshots a provider registration so caller mutation cannot corrupt dispatch or HMR cleanup', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const capabilities: SubagentCapabilities = { + outputSchema: true, + depthLimit: true, + toolFilter: true, + persona: true, + } + const provider = new StubProvider('stable', capabilities) + const added: SubagentProvider[] = [] + const removed: string[] = [] + ctx.on('subagent/provider-added', registered => void added.push(registered)) + ctx.on('subagent/provider-removed', name => void removed.push(name)) + const owner = await ctx.plugin({ + name: 'mutable-provider-owner', + inject: ['subagents'], + apply(pluginCtx: Context) { + pluginCtx.subagents.registerProvider(provider) + }, + }) + const accepted = ctx.subagents.getProvider('stable') + + const mutable = provider as unknown as { + name: string + capabilities: SubagentCapabilities + inheritsParentContext: boolean + start: SubagentProvider['start'] + } + mutable.name = 'mutated' + capabilities.outputSchema = false + capabilities.depthLimit = false + capabilities.toolFilter = false + capabilities.persona = false + mutable.capabilities = NO_CAPS + mutable.inheritsParentContext = true + const replacementStart = vi.fn((_request: SubagentStartRequest): SubagentRun => { + throw new Error('replacement start must not run') + }) + mutable.start = replacementStart + + expect(added).toEqual([accepted]) + expect(accepted).not.toBe(provider) + expect(Object.isFrozen(accepted)).toBe(true) + expect(Object.isFrozen(accepted?.capabilities)).toBe(true) + expect(accepted).toMatchObject({ + name: 'stable', + capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: true }, + inheritsParentContext: false, + }) + expect(ctx.subagents.list()).toEqual(['stable']) + expect(ctx.subagents.getProvider('mutated')).toBeUndefined() + + const run = ctx.subagents.start('stable', baseRequest({ + outputSchema: { type: 'object', properties: { answer: { type: 'string' } } }, + maxDepth: 2, + toolFilter: { deny: ['bash'] }, + persona: 'reviewer', + })) + await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' }) + expect(provider.startCount).toBe(1) + expect(replacementStart).not.toHaveBeenCalled() + + await owner.dispose() + expect(removed).toEqual(['stable']) + expect(ctx.subagents.list()).toEqual([]) + expect(() => ctx.subagents.registerProvider(new StubProvider('stable'))).not.toThrow() + }) + it('re-registers a name after its prior registration is disposed (not wedged)', async () => { const ctx = new Context() await ctx.plugin(SubagentService) @@ -220,6 +290,7 @@ describe('SubagentService', () => { ctx.on('subagent/end', ended) const run = ctx.subagents.start('events', baseRequest()) + await run.started expect(started).toHaveBeenCalledWith(expect.objectContaining({ provider: 'events', id: run.id })) await run.result @@ -228,6 +299,67 @@ describe('SubagentService', () => { expect(ended).toHaveBeenCalledWith(expect.objectContaining({ provider: 'events', id: run.id, stopReason: 'completed' })) }) + it('waits for provider readiness and observes an early result rejection without reordering lifecycle', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const readiness = Promise.withResolvers() + ctx.subagents.registerProvider({ + name: 'delayed-start', + capabilities: NO_CAPS, + inheritsParentContext: false, + start: () => ({ + id: AgentId('delayed-child'), + started: readiness.promise, + // Already rejected: SubagentService must attach its result handler in + // the same synchronous start() call, before awaiting readiness. + result: Promise.reject(new Error('early infrastructure fault')), + cancel() {}, + async dispose() {}, + }), + }) + const lifecycle: string[] = [] + ctx.on('subagent/start', () => void lifecycle.push('start')) + ctx.on('subagent/end', info => void lifecycle.push(`end:${info.stopReason}`)) + + const run = ctx.subagents.start('delayed-start', baseRequest()) + await expect(run.result).rejects.toThrow('early infrastructure fault') + expect(lifecycle).toEqual([]) + + readiness.resolve(undefined) + await run.started + expect(lifecycle).toEqual(['start', 'end:error']) + }) + + it('emits no lifecycle pair when readiness rejects before a child exists', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const readiness = Promise.withResolvers() + const result = Promise.withResolvers() + ctx.subagents.registerProvider({ + name: 'never-started', + capabilities: NO_CAPS, + inheritsParentContext: false, + start: () => ({ + id: AgentId('never-started-child'), + started: readiness.promise, + result: result.promise, + cancel() {}, + async dispose() {}, + }), + }) + const lifecycle = vi.fn() + ctx.on('subagent/start', lifecycle) + ctx.on('subagent/end', lifecycle) + + const run = ctx.subagents.start('never-started', baseRequest()) + readiness.reject(new Error('publication rolled back')) + await expect(run.started).rejects.toThrow('publication rolled back') + result.resolve({ output: [], stopReason: 'aborted' }) + await run.result + await Promise.resolve() + expect(lifecycle).not.toHaveBeenCalled() + }) + it('pins start and end to the parent accepted at start despite caller mutation', async () => { const ctx = new Context() await ctx.plugin(SubagentService) @@ -241,6 +373,7 @@ describe('SubagentService', () => { acceptedRequest = accepted return { id: AgentId('deferred-child'), + started: Promise.resolve(), result: gate.promise, cancel() {}, async dispose() {}, @@ -282,6 +415,7 @@ describe('SubagentService', () => { ctx.on('subagent/end', ended) const run = ctx.subagents.start('enriched', baseRequest()) + await run.started expect(started).toHaveBeenCalledWith(expect.objectContaining({ provider: 'enriched', id: run.id })) await run.result @@ -331,6 +465,7 @@ describe('SubagentService', () => { inheritsParentContext: false, start: () => ({ id: AgentId('rej-child'), + started: Promise.resolve(), result: Promise.reject(new Error('infra fault')), cancel() {}, dispose: async () => {}, @@ -365,6 +500,7 @@ describe('SubagentService', () => { inheritsParentContext: false, start: () => ({ id: AgentId('unclone-child'), + started: Promise.resolve(), result: Promise.resolve({ output: uncloneable, stopReason: 'completed' } as SubagentResult), cancel() {}, dispose: async () => {}, @@ -395,6 +531,7 @@ describe('SubagentService', () => { inheritsParentContext: false, start: () => ({ id: AgentId('rej-child'), + started: Promise.resolve(), result: Promise.reject(new Error('infra fault')), cancel() {}, dispose: async () => {}, @@ -424,6 +561,7 @@ describe('SubagentService', () => { const run = ctx.subagents.start('contain', baseRequest()) expect(run.id).toBeDefined() + await run.started expect(second).toHaveBeenCalledWith(expect.objectContaining({ provider: 'contain', id: run.id })) await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' }) }) diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 9d9941fdff..95d26dabdf 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -115,6 +115,7 @@ describe('dsh-tool-subagent', () => { inheritsParentContext: false, start: () => ({ id: AgentId('weird-child'), + started: Promise.resolve(), result: Promise.resolve({ output: [{ type: 'text', text: 'partial' }], stopReason: 'frobnicated' as never }), cancel() {}, dispose: async () => {}, @@ -143,6 +144,7 @@ describe('dsh-tool-subagent', () => { seen = request return { id: AgentId('capture-child'), + started: Promise.resolve(), result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), cancel() {}, dispose: async () => {}, @@ -173,6 +175,7 @@ describe('dsh-tool-subagent', () => { seen = request return { id: AgentId('bare-child'), + started: Promise.resolve(), result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), cancel() {}, dispose: async () => {}, @@ -301,6 +304,7 @@ describe('dsh-tool-subagent', () => { inheritsParentContext: false, start: () => ({ id: AgentId('spy-child'), + started: Promise.resolve(), result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), cancel() {}, dispose: async () => void disposed(), @@ -324,6 +328,7 @@ describe('dsh-tool-subagent', () => { inheritsParentContext: false, start: () => ({ id: AgentId('spy-child'), + started: Promise.resolve(), result: Promise.resolve({ output: [], stopReason: 'error' as const }), cancel() {}, dispose: async () => void disposed(), @@ -351,6 +356,7 @@ describe('dsh-tool-subagent', () => { const result = new Promise<{ output: never[]; stopReason: 'aborted' }>((res) => { resolveResult = res }) return { id: AgentId('spy-child'), + started: Promise.resolve(), result, cancel: () => { cancelled() @@ -398,6 +404,7 @@ describe('dsh-tool-subagent', () => { const result = new Promise<{ output: never[]; stopReason: 'aborted' }>((res) => { resolveResult = res }) return { id: AgentId('spy-child'), + started: Promise.resolve(), result, cancel: () => { cancelled() @@ -466,6 +473,7 @@ describe('dsh-tool-subagent', () => { seen = request return { id: AgentId('capture2-child'), + started: Promise.resolve(), result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), cancel() {}, dispose: async () => {}, @@ -499,6 +507,7 @@ describe('dsh-tool-subagent', () => { seen = request return { id: AgentId('capture3-child'), + started: Promise.resolve(), result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), cancel() {}, dispose: async () => {}, @@ -529,6 +538,7 @@ describe('dsh-tool-subagent', () => { seen = request return { id: AgentId('capture4-child'), + started: Promise.resolve(), result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), cancel() {}, dispose: async () => {}, diff --git a/packages/support/subagent-mock/src/index.ts b/packages/support/subagent-mock/src/index.ts index e72765ba4d..715c7451d7 100644 --- a/packages/support/subagent-mock/src/index.ts +++ b/packages/support/subagent-mock/src/index.ts @@ -66,6 +66,9 @@ class MockSubagentProvider implements SubagentProvider { return { id, + // A scripted run has no asynchronous publication phase; it is ready as + // soon as the provider returns the handle. + started: Promise.resolve(), result: Promise.resolve().then(resultFor), cancel() { cancelled = true diff --git a/packages/ui/user-approval/README.md b/packages/ui/user-approval/README.md index 26ec82bbc9..821e98638f 100644 --- a/packages/ui/user-approval/README.md +++ b/packages/ui/user-approval/README.md @@ -2,7 +2,7 @@ User-approval seam. Owns the `ctx.approval` service ([`ApprovalService`](src/index.ts)) and the one-shot permission vocabulary the harness shares: `ApprovalRequest` (agent + tool identity + reason + abort signal), the closed `ApprovalOutcome` union (`allowed-once` / `rejected` / `cancelled` / `unavailable`), the `ApprovalRequestId` brand pairing the two log-only audit events (`approval/asked` / `approval/decided`), and the `approval/request` waterfall the answerers listen on. It lives in the UI group because its purpose is human permission, while remaining channel-neutral: it depends only on Cordis and core vocabulary packages, never on a concrete UI. -The contract in one line: `ctx.approval.request(req)` puts exactly one question — "may this specific action proceed?" — to whatever answerers the deployment composed, and always resolves to an outcome, never rejects: an aborted signal yields `cancelled`, a throwing or missing answerer yields `unavailable`, and `allowed-once` is a grant for the single asked-about action, never a class of future ones. The one precondition: ask from inside an open turn — the audit pair is turn-enclosed by contract (the turn is the durable log's commit/replay boundary; a bare event between turns is crash-tail garbage on reload), so an idle ask throws before appending anything. +The contract in one line: `ctx.approval.request(req)` puts exactly one question — "may this specific action proceed?" — to whatever answerers the deployment composed, and always resolves to an outcome, never rejects: an aborted signal yields `cancelled`, a throwing or missing answerer yields `unavailable`, and `allowed-once` is a grant for the single asked-about action, never a class of future ones. Acceptance is synchronous: the service shallow-freezes a detached request record before dispatch, preserving the exact `agent` and `AbortSignal` identities while making later caller mutation unable to redirect scope, payload, cancellation, or either audit event. Session observers run after an event enters the append-only log; if one throws, the service recognizes that the audit is already authoritative, contains the observer failure, and completes the pair. The one precondition: ask from inside an open turn — the audit pair is turn-enclosed by contract (the turn is the durable log's commit/replay boundary; a bare event between turns is crash-tail garbage on reload), so an idle ask throws before appending anything. The service is the mechanism, answerers are the policy. Answerers are `approval/request` waterfall listeners occupying a single decision slot: answer for an agent you own by returning an outcome without calling `next()`, or delegate an agent you don't recognize by calling `next()` — the chain's built-in default is `unavailable`, so a deployment with no answerer (headless, CI) fails closed with zero configuration. Dispatch is keyed by `req.agent`: a listener registered through `agent.ctx` receives only that agent's questions, while a plain-context listener receives every agent's. Registration order across sibling plugins is not load-order deterministic; compose one terminal answerer per deployment and use `prepend` listeners only for decide-or-delegate gates. diff --git a/packages/ui/user-approval/src/index.ts b/packages/ui/user-approval/src/index.ts index 656f799d6a..4fb50ba23b 100644 --- a/packages/ui/user-approval/src/index.ts +++ b/packages/ui/user-approval/src/index.ts @@ -63,7 +63,10 @@ declare module 'cordis' { * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) keys the carrier by `req.agent`: a * listener registered through `agent.ctx` receives only that agent's * questions, while a plain-context listener receives every agent's. - * @param req - the pending decision (agent, tool identity, reason, signal). + * `req` is the service's shallow-frozen acceptance snapshot: later caller + * mutation cannot redirect the question, while the `agent` and `signal` + * identity capabilities remain exact. + * @param req - the accepted decision (agent, tool identity, reason, signal). * @mode waterfall */ 'approval/request'(this: Scoped, req: ApprovalRequest, next: () => Promise): Promise @@ -234,7 +237,9 @@ export function setApprovalPolicy(session: Session, policy: ApprovalPolicy): voi * for an answerer to present it and for the audit events to reconstruct what * was asked — it deliberately does NOT carry tool arguments: a UI answerer * attaches the prompt to the already-streamed tool call via `callId` instead - * of re-rendering the call. + * of re-rendering the call. `request()` synchronously copies and shallow-freezes + * this record before crossing an asynchronous boundary. Scalar fields are + * detached; the `agent` and `signal` identity capabilities are preserved. */ export interface ApprovalRequest { /** @@ -364,14 +369,36 @@ export class ApprovalService extends Service { * Within that precondition it always resolves to an outcome, never rejects: * an aborted signal yields `'cancelled'`, a missing or throwing answerer * yields `'unavailable'` (fail closed), and a rogue non-vocabulary return - * value is normalized to `'unavailable'`. Appends the + * value is normalized to `'unavailable'`. The caller-owned request is + * synchronously snapshotted, so later mutation cannot split routing, + * dispatch payload, cancellation, or the audit pair across agents/sessions. + * Appends the * `approval/asked`/`approval/decided` audit pair (log-only) around the - * decision regardless of outcome. + * decision regardless of outcome. A synchronous session observer failure + * after an audit event entered the append-only log is contained; the event + * is already authoritative, so the pair still completes and the request + * still resolves. * @param req - the pending decision (agent, tool identity, reason, signal). * @returns the closed outcome; `'allowed-once'` is the only grant. */ async request(req: ApprovalRequest): Promise { - if (!hasOpenTurn(req.agent.session.events)) { + // Accept one immutable request shape before the first async boundary. The + // caller retains its record and may mutate it as soon as this async method + // returns; identity capabilities stay live, but the record is never reread. + const agent = req.agent + const toolName = req.toolName + const callId = req.callId + const reason = req.reason + const signal = req.signal + const accepted: Readonly = Object.freeze({ + agent, + toolName, + ...callId !== undefined ? { callId } : {}, + ...reason !== undefined ? { reason } : {}, + ...signal !== undefined ? { signal } : {}, + }) + const session = accepted.agent.session + if (!hasOpenTurn(session.events)) { throw new Error( 'approval.request() outside an open turn: the approval/asked + approval/decided audit pair ' + 'must be turn-enclosed (a bare event between turns is crash-tail garbage on reload). ' @@ -379,17 +406,47 @@ export class ApprovalService extends Service { ) } const id = ApprovalRequestId(randomUUID()) - req.agent.session.append('approval/asked', { - id, - toolName: req.toolName, - ...req.callId !== undefined ? { callId: req.callId } : {}, - ...req.reason !== undefined ? { reason: req.reason } : {}, + this.appendAudit(session, 'approval/asked', id, () => { + session.append('approval/asked', { + id, + toolName: accepted.toolName, + ...accepted.callId !== undefined ? { callId: accepted.callId } : {}, + ...accepted.reason !== undefined ? { reason: accepted.reason } : {}, + }) + }) + const outcome = await this.decide(accepted) + this.appendAudit(session, 'approval/decided', id, () => { + session.append('approval/decided', { id, outcome }) }) - const outcome = await this.decide(req) - req.agent.session.append('approval/decided', { id, outcome }) return outcome } + /** + * Append one audit event while distinguishing a post-append observer throw + * from a failure that prevented the event entering the log. `Session.append` + * pushes first and then notifies synchronously, so log growth proves the + * event is already authoritative; that observer failure is reported and + * contained so it cannot reject the approval or suppress its matching event. + * @param session - the captured session receiving both audit events. + * @param type - the audit event currently being appended. + * @param id - the request id, used to identify the contained failure. + * @param append - the single concrete `Session.append` call. + */ + private appendAudit( + session: Session, + type: 'approval/asked' | 'approval/decided', + id: ApprovalRequestId, + append: () => void, + ): void { + const length = session.events.length + try { + append() + } catch (error) { + if (session.events.length === length) throw error + this.ctx.logger.warn(`approval request "${id}": ${type} observer threw after the event was appended`) + } + } + /** * The session's effective policy: its own `approval/policy` fold, else the * configured default (the schema already defaulted an omitted policy to @@ -401,8 +458,8 @@ export class ApprovalService extends Service { return effectiveApprovalPolicy(agent.session.events) ?? this.config.policy ?? 'ask' } - /** Dispatch the waterfall, contained and raced against `req.signal`. */ - private async decide(req: ApprovalRequest): Promise { + /** Dispatch the waterfall, contained and raced against the accepted signal. */ + private async decide(req: Readonly): Promise { if (req.signal?.aborted) return 'cancelled' // The 'never' policy is decided HERE, before any dispatch: a listener // registered with `prepend: true` after this service mounts would sit diff --git a/packages/ui/user-approval/tests/approval.spec.ts b/packages/ui/user-approval/tests/approval.spec.ts index 6b405e10a7..0e69b8dfd7 100644 --- a/packages/ui/user-approval/tests/approval.spec.ts +++ b/packages/ui/user-approval/tests/approval.spec.ts @@ -3,7 +3,7 @@ import { Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { CallId } from '@deepseek-ai/dsh-llm' import { carrierKeyOf, scopeHost } from '@deepseek-ai/dsh-scope' -import { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ApprovalService, { ApprovalOutcome, ApprovalRequest, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' @@ -78,6 +78,138 @@ describe('ApprovalService.request', () => { expect(Object.keys(appended[0]?.data ?? {}).sort()).toEqual(['id', 'toolName']) }) + it('snapshots request identity, scope, payload, and audit before deferred dispatch', async () => { + const ctx = await mounted() + const { agent: acceptedAgent, appended: acceptedAudit } = fakeAgent() + const { agent: replacementAgent, appended: replacementAudit } = fakeAgent() + const host = await scopeHost(ctx, ['approval']) + const acceptedScope = host.mint(acceptedAgent) + const replacementScope = host.mint(replacementAgent) + const dispatchStarted = Promise.withResolvers<'started'>() + const answer = Promise.withResolvers() + const originalSignal = new AbortController().signal + const replacementSignal = new AbortController().signal + let heardBy: 'accepted' | 'replacement' | undefined + let received: ApprovalRequest | undefined + let carrier: unknown + acceptedScope.ctx.on('approval/request', function (req) { + heardBy = 'accepted' + received = req + carrier = carrierKeyOf(this) + dispatchStarted.resolve('started') + return answer.promise + }) + replacementScope.ctx.on('approval/request', function (req) { + heardBy = 'replacement' + received = req + carrier = carrierKeyOf(this) + dispatchStarted.resolve('started') + return answer.promise + }) + const request = requestOf(acceptedAgent, { + toolName: 'original-tool', + callId: CallId('original-call'), + reason: 'original reason', + signal: originalSignal, + }) + + const pending = ctx.approval.request(request) + // request() has returned, but the answerer dispatch is deliberately queued + // in a microtask. Mutating the caller-owned record must not redirect it. + request.agent = replacementAgent + request.toolName = 'mutated-before-dispatch' + request.callId = CallId('mutated-call') + request.reason = 'mutated reason' + request.signal = replacementSignal + await dispatchStarted.promise + // Mutation while the answer is pending must not redirect the final audit. + request.toolName = 'mutated-after-dispatch' + request.reason = 'mutated again' + answer.resolve('allowed-once') + + await expect(pending).resolves.toBe('allowed-once') + expect(heardBy).toBe('accepted') + expect(carrier).toBe(acceptedAgent) + expect(received).not.toBe(request) + expect(Object.isFrozen(received)).toBe(true) + expect(received).toMatchObject({ + agent: acceptedAgent, + toolName: 'original-tool', + callId: 'original-call', + reason: 'original reason', + signal: originalSignal, + }) + expect(acceptedAudit).toHaveLength(2) + expect(acceptedAudit[0]?.data).toMatchObject({ + toolName: 'original-tool', + callId: 'original-call', + reason: 'original reason', + }) + expect(acceptedAudit[1]?.data).toMatchObject({ outcome: 'allowed-once' }) + expect(acceptedAudit[1]?.data['id']).toBe(acceptedAudit[0]?.data['id']) + expect(replacementAudit).toEqual([]) + await host.dispose() + }) + + it('contains an approval/asked observer throw after append and still completes the pair', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(ApprovalService) + const session = ctx.sessions.create(SessionId('asked-observer-throw')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const agent = { session } as unknown as Agent + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + ctx.on('session/event', (_session, event) => { + if (event.type === 'approval/asked') throw new Error('observer failed after asked append') + }) + ctx.on('approval/request', () => Promise.resolve('allowed-once')) + + await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('allowed-once') + + const audit = session.events.filter(event => event.type.startsWith('approval/')) + const asked = session.events.find((event): event is SessionEvent<'approval/asked'> => event.type === 'approval/asked') + const decided = session.events.find((event): event is SessionEvent<'approval/decided'> => event.type === 'approval/decided') + expect(audit.map(event => event.type)).toEqual(['approval/asked', 'approval/decided']) + expect(decided?.data.id).toBe(asked?.data.id) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('approval/asked observer threw')) + }) + + it('contains an approval/decided observer throw after append and still resolves', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(ApprovalService) + const session = ctx.sessions.create(SessionId('decided-observer-throw')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const agent = { session } as unknown as Agent + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + ctx.on('session/event', (_session, event) => { + if (event.type === 'approval/decided') throw new Error('observer failed after decided append') + }) + ctx.on('approval/request', () => Promise.resolve('rejected')) + + await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('rejected') + + const audit = session.events.filter(event => event.type.startsWith('approval/')) + const asked = session.events.find((event): event is SessionEvent<'approval/asked'> => event.type === 'approval/asked') + const decided = session.events.find((event): event is SessionEvent<'approval/decided'> => event.type === 'approval/decided') + expect(audit.map(event => event.type)).toEqual(['approval/asked', 'approval/decided']) + expect(decided?.data).toMatchObject({ id: asked?.data.id, outcome: 'rejected' }) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('approval/decided observer threw')) + }) + + it('does not misclassify a pre-append failure as an observer failure', async () => { + const ctx = await mounted() + const failure = new Error('append failed before log growth') + const agent = { + session: { + events: [{ type: 'turn/start' }], + append: () => { throw failure }, + }, + } as unknown as Agent + + await expect(ctx.approval.request(requestOf(agent))).rejects.toBe(failure) + }) + it('returns the first answering listener outcome (single decision slot)', async () => { const ctx = await mounted() const { agent } = fakeAgent() diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts index 32d605c651..67f3c771cb 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts @@ -83,6 +83,7 @@ class StubProvider implements SubagentProvider { } return { id: AgentId(`stub-child-${index}`), + started: Promise.resolve(), result, cancel: (reason?: string) => { controlled.cancelled = reason ?? 'cancelled' @@ -222,6 +223,7 @@ describe('dsh-workflow-workerthread', () => { inheritsParentContext: false, start: () => ({ id: AgentId('reject-child'), + started: Promise.resolve(), result: Promise.reject(new Error('backend exploded')), cancel: () => { /* nothing in flight */ }, dispose: () => Promise.resolve(), @@ -245,6 +247,7 @@ describe('dsh-workflow-workerthread', () => { inheritsParentContext: false, start: () => ({ id: AgentId('bad-dispose-child'), + started: Promise.resolve(), result: Promise.resolve({ output: [{ type: 'text', text: 'fine' }], stopReason: 'completed' }), cancel: () => { /* settled already */ }, dispose: () => Promise.reject(new Error('dispose exploded')), @@ -266,6 +269,7 @@ describe('dsh-workflow-workerthread', () => { inheritsParentContext: false, start: () => ({ id: AgentId('trap-child'), + started: Promise.resolve(), result: Promise.resolve({ output: [{ type: 'text', text: 'fine' }], stopReason: 'completed' }), cancel: () => { /* settled already */ }, // The rejection VALUE's own coercion throws: a warn built with bare @@ -530,6 +534,7 @@ describe('dsh-workflow-workerthread', () => { }, { once: true }) return { id: AgentId('signal-only-child'), + started: Promise.resolve(), result, // The seam leaves a provider free to honor EITHER cancel channel; // this one deliberately ignores run.cancel() — only the request @@ -572,6 +577,7 @@ describe('dsh-workflow-workerthread', () => { starts += 1 return { id: AgentId('cancel-only-child'), + started: Promise.resolve(), result: new Promise(() => { /* only cancel() ends this child */ }), // Deliberately ignores the request signal — the seam leaves a // provider free to honor ONLY the explicit cancel() channel. @@ -749,6 +755,7 @@ describe('dsh-workflow-workerthread', () => { inheritsParentContext: false, start: () => ({ id: AgentId('doomed-child'), + started: Promise.resolve(), result: new Promise(() => { /* never settles; the reap is the teardown */ }), cancel: (reason?: string) => { cancelled.push(reason ?? 'cancelled') }, dispose: () => Promise.reject(new Error('dispose exploded during reap')),