fix(subagent): make strict steering atomic

This commit is contained in:
Dudu-0223
2026-08-02 04:34:15 +08:00
committed by imccyu
parent 9e5ae0d12e
commit bb8ea2be51
24 changed files with 436 additions and 92 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-21-continuable-background-subagents.md: a23943a0226d2ef4eee27d7294d7a98a84c5f109
2026-07-21-continuable-background-subagents.zh.md: e645cfb0a11c554a30a7ad092b612c5bac7d8dea
2026-07-21-continuable-background-subagents.md: 287239a22c440eb4758a8dab5621406246a7e0b7
2026-07-21-continuable-background-subagents.zh.md: 36b28581e1bf05144e9ffd5de136983eff8fdabc
@@ -49,7 +49,7 @@ For a continuable initial activation, the control service allocates the stable c
Every continuable child turn is admitted through this Task-backed path. A non-terminal Task is the only supported live activation; when no activation exists, its run has already been disposed and the durable child is resumable. Before routing any by-id operation, the control service synchronously compares its association with `ctx.agents.get(childId)`. A registry Agent with no association, or a registry Agent different from the associated `run.localAgent`, is an ownership conflict: the control service fails rather than adopting an idle Agent or attaching an untracked turn. When neither exists, cold resume may proceed; a competing publication after that check still loses at the Agent registry collision boundary.
Routing follows the Task association. A running Task accepts live delivery through the run's optional strict `SubagentRun.steer` capability. An absent Task starts a fresh Task and cold-resumes the child. In-process spawn and fork implement this capability with synchronous checks that share one frame with the `Agent.steer()` call: the child must be `running`, its turn must still be open in the log (status stays `running` through a closed turn's durability flush, where the loop strands drained steering), a step must be open (between steps the loop may sit at its continuation/turn-stop checkpoints, where steering was already folded and a terminal stop discards a later arrival), and no structured capture may have committed (its terminal stop makes the loop discard late steering). Providers must not expose the Agent-level idle fallback as strict steering, because that fallback may start an untracked turn after the observed run has ended. If the Task settles between association lookup and this strict check, `steer()` fails, `send_message` reports the message as not delivered, and that call does not fall through to cold resume; a later retry after Task terminal may start the next activation.
Routing follows the Task association. A running Task accepts live delivery through the run's optional strict `SubagentRun.steer` capability. An absent Task starts a fresh Task and cold-resumes the child. In-process spawn and fork implement this capability with synchronous checks followed by the default Agent loop's optional atomic `trySteer()`: the child must be `running`, its turn and step must still be open in the log, the step's final steering drain must not have begun, and no structured capture may have committed. The loop closes `trySteer()` acceptance before draining and entering `agent/post-step`, so a terminal stop cannot discard an acknowledged message from that window. A loop without `trySteer()` cannot back strict in-process delivery. Providers must not expose the Agent-level idle fallback as strict steering, because that fallback may start an untracked turn after the observed run has ended. If the Task settles between association lookup and this strict operation, `steer()` fails, `send_message` reports the message as not delivered, and that call does not fall through to cold resume; a later retry after Task terminal may start the next activation.
The control service does not serialize two callers that race a stopped child through paths outside it, nor does it model a separate settling phase between result production and disposal. The synchronous association install before the producer's first await admits one activation per child in this process — a competing `sendMessage` during resume load observes the pending activation and fails explicitly — while a bypassing publication still loses at the Agent registry's same-session collision boundary. Delivery racing startup, cancellation, completion, or cleanup may also fail. These limitations are explicit rather than hidden behind a larger lifecycle abstraction.
@@ -49,7 +49,7 @@ durable child Session
每个可继续 child 轮次都通过这条由 Task 支撑的路径准入。非终态 Task 是唯一受支持的存活激活;不存在激活时,其 run 已被 dispose,持久化 child 可以恢复。在路由任何按 id 的操作之前,控制服务会同步将自身关联与 `ctx.agents.get(childId)` 比较。如果注册表中的 Agent 没有关联,或者它与所关联的 `run.localAgent` 不同,就属于所有权冲突:控制服务会失败,而不会接管 idle Agent 或附加未受跟踪的轮次。二者均不存在时,可以从持久化存储恢复;如果检查后又有竞争方发布,仍会在 Agent 注册表的冲突边界上失败。
系统依据 Task 关联进行路由。运行中的 Task 通过 run 可选且严格的 `SubagentRun.steer` 功能接收在线消息。Task 不存在时,系统创建新 Task,并从持久化存储恢复 child。进程内 spawn 和 fork 用与 `Agent.steer()` 调用共享同一同步帧的检查来实现该功能:child 必须处于 `running` 状态,其轮次在日志中必须仍然打开(已关闭轮次的持久化 flush 期间状态仍是 `running`,此时循环会丢弃排空 steering 消息),必须有打开的 step(step 之间循环可能停在其 continuation/turn-stop 检查点上,此时 steering 已被折叠,终止性 stop 会丢弃之后到达的消息),且不得已有结构化捕获提交(其终止性 stop 会让循环丢弃迟到的 steering)。提供方不得将 Agent 层的 idle fallback 暴露为严格 steering(中途引导),因为观察到的 run 结束后,该 fallback 可能启动一个未受 Task 跟踪的轮次。如果 Task 在查找关联与执行这项严格检查之间进入结算,`steer()` 会失败,`send_message` 会报告消息未送达,而且该次调用不会改用从持久化存储恢复路径;在 Task 终态发布后重试,才可能启动下一次激活。
系统依据 Task 关联进行路由。运行中的 Task 通过 run 可选且严格的 `SubagentRun.steer` 功能接收在线消息。Task 不存在时,系统创建新 Task,并从持久化存储恢复 child。进程内 spawn 和 fork 先执行同步检查,再调用默认 Agent 循环所提供的可选原子操作 `trySteer()`,以实现该功能:child 必须处于 `running` 状态,其轮次和步骤在日志中必须仍然打开,该步骤最后一次排空 steering(中途引导)必须尚未开始,且不得已有结构化捕获提交。循环会排空 steering 并进入 `agent/post-step` 前关闭 `trySteer()` 准入,使终止性 stop 无法丢弃在这个窗口中已确认接收的消息。不提供 `trySteer()` 的循环无法支撑严格的进程内消息投递。提供方不得将 Agent 层的 idle fallback 暴露为严格 steering,因为观察到的 run 结束后,该 fallback 可能启动一个未受 Task 跟踪的轮次。如果 Task 在查找关联与执行这项严格操作之间进入结算,`steer()` 会失败,`send_message` 会报告消息未送达,而且该次调用不会改用从持久化存储恢复路径;在 Task 终态发布后重试,才可能启动下一次激活。
控制服务不会串行化两个通过其外部路径同时争抢已停止 child 的调用方,也不会为结果产生与 dispose 之间的阶段单独建立 settling 状态。在 producer 首次 await 之前同步安装的关联,使本进程内每个 child 只准入一次激活——resume 加载期间竞争的 `sendMessage` 会观察到待处理的激活并显式失败——而绕开该关联的发布仍会在 Agent 注册表相同会话的冲突边界上失败。发送也可能因与启动、取消、完成或清理发生竞态而失败。这些限制是明确的,而非隐藏在更大的生命周期抽象之后。
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/architecture.md
architecture.md: 0e78d7f9157e55ab1c5b6f518ef723e61237446e
architecture.zh.md: 27498c0d36ea54e6c952e0c1264b191d1448a554
architecture.md: c5788ad33dc87e104dbdf0f420ac937af9ff2662
architecture.zh.md: db98ead01d5bcb689a2cfd199eaae059763ad19e
+4 -3
View File
@@ -98,6 +98,7 @@ forever:
materialize changed runtime context as sourced 'user/message'
snapshot the derived messages (the reconstruction boundary)
'step/start'
open strict-steering acceptance
agent/request (config only) -> prepare adapter defaults/provenance + context capacity under turn signal -> log request/header (+ request/context on route change) -> llm/stream (frozen, registration-bound)
'assistant/chunk'
'assistant/message'
@@ -106,7 +107,7 @@ forever:
parallel -> rolling pool, <= maxParallelToolCalls; reclassify-at-start; scheduler failure -> stop starts, drain dispatches
start -> 'tool/call' -> ordered tools/pre-execute -> concurrent tools/execute
model-order result -> ordered tools/post-execute -> 'tool/result'
drain accepted tool context and steering
close strict-steering acceptance, then drain accepted tool context and steering
'step/end'
continue for tools or steering unless a result concluded the turn
otherwise agent/turn-stopping -> drain -> continue only for steering
@@ -121,7 +122,7 @@ idle inject:
Each step assembles ordered stable system sections, cache-safe dynamic contexts, tool schemas, and variables; unknown references fail the turn. `dsh-system-prompt` owns identity and persona; the loop supplies `provider`, `model`, and `cwd` ([prompt ownership](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)).
Admission-time and active-turn `inject()` stage for the next step; post-tool `additionalContexts` settles after results. Steering shares that staging boundary and requests another step. Idle `inject()` appends immediately without changing turn numbers; persistence drains eagerly.
Admission-time and active-turn `inject()` stage for the next step; post-tool `additionalContexts` settles after results. Steering shares that staging boundary and requests another step. The default loop closes its optional `trySteer()` acceptance immediately before the final steering drain; ordinary `steer()` keeps its best-effort routing semantics. Idle `inject()` appends immediately without changing turn numbers; persistence drains eagerly.
Pruning precedes summaries; overflow retries require durable progress. `agent/request-error` may authorize one retry turn between failed-step and turn close; cancellation wins. Adapter-owned `retryPolicy` makes normal mode bounded; always mode delegates specialized recovery before retrying until success or cancellation ([compaction](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md), [retry foundation](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md), [provider policy](../.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md)).
@@ -135,7 +136,7 @@ Turn and step events are turn-enclosed. Idle `user/message` and standalone `comp
### Agent Handles
`ctx.agents` owns agents, returning `AgentHandle { agent, dispose() }`. Plugins use `send()` or `followup()`, `steer()`, and `inject()` presets; [`reserveTurnAdmission()`](../packages/core/agent/README.md#agent-interface-typests) synchronously reserves idle for durable work without changing queued prompt identity. `cancel()` and `whenIdle()` control lifecycle. Awaited disposal owns teardown.
`ctx.agents` owns agents, returning `AgentHandle { agent, dispose() }`. Plugins use `send()` or `followup()`, `steer()`, optional `trySteer()`, and `inject()` presets; [`reserveTurnAdmission()`](../packages/core/agent/README.md#agent-interface-typests) synchronously reserves idle for durable work without changing queued prompt identity. The default loop's `trySteer()` atomically rejects after the current step's final steering drain begins, while ordinary `steer()` retains best-effort routing. `cancel()` and `whenIdle()` control lifecycle. Awaited disposal owns teardown.
### Agent Scope
+4 -3
View File
@@ -98,6 +98,7 @@ forever:
materialize changed runtime context as sourced 'user/message'
snapshot the derived messages (the reconstruction boundary)
'step/start'
open strict-steering acceptance
agent/request (config only) -> prepare adapter defaults/provenance + context capacity under turn signal -> log request/header (+ request/context on route change) -> llm/stream (frozen, registration-bound)
'assistant/chunk'
'assistant/message'
@@ -106,7 +107,7 @@ forever:
parallel -> rolling pool, <= maxParallelToolCalls; reclassify-at-start; scheduler failure -> stop starts, drain dispatches
start -> 'tool/call' -> ordered tools/pre-execute -> concurrent tools/execute
model-order result -> ordered tools/post-execute -> 'tool/result'
drain accepted tool context and steering
close strict-steering acceptance, then drain accepted tool context and steering
'step/end'
continue for tools or steering unless a result concluded the turn
otherwise agent/turn-stopping -> drain -> continue only for steering
@@ -121,7 +122,7 @@ idle inject:
每个步骤都会组装有序的稳定系统提示词片段、缓存安全的动态上下文、工具 schema 和变量;未知引用会使该轮次失败。`dsh-system-prompt` 负责身份和角色设定;循环提供 `provider``model``cwd`[提示词归属](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md))。
接纳期间和活跃轮次内的 `inject()` 会为下一步骤暂存;工具执行后的 `additionalContexts` 会在结果记录完毕后落定。steering 与其共用这一暂存边界,并请求再执行一个步骤。空闲状态下的 `inject()` 会立即追加,且不改变轮次编号;持久化层会尽快排空。
接纳期间和活跃轮次内的 `inject()` 会为下一步骤暂存;工具执行后的 `additionalContexts` 会在结果记录完毕后落定。steering 与其共用这一暂存边界,并请求再执行一个步骤。默认循环会在最后一次排空 steering 前立即关闭其可选 `trySteer()` 的准入;普通 `steer()` 保留尽力路由语义。空闲状态下的 `inject()` 会立即追加,且不改变轮次编号;持久化层会尽快排空。
裁剪先于摘要;溢出重试必须取得持久进展。`agent/request-error` 可以在失败步骤与轮次关闭之间授权一个重试轮次;取消优先。适配器拥有的 `retryPolicy` 使 normal mode 保持有界;always mode 先委托专门恢复,再持续重试直至成功或取消([压缩](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)、[重试基础](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md)、[提供方策略](../.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md))。
@@ -135,7 +136,7 @@ idle inject:
### Agent 句柄
`ctx.agents` 拥有 agent,返回 `AgentHandle { agent, dispose() }`。插件使用 `send()`,或使用 `followup()``steer()``inject()` 预设;[`reserveTurnAdmission()`](../packages/core/agent/README.md#agent-interface-typests) 为持久工作同步预留空闲状态,同时不改变排队提示词身份。`cancel()``whenIdle()` 控制生命周期。需等待完成的资源释放负责拆卸。
`ctx.agents` 拥有 agent,返回 `AgentHandle { agent, dispose() }`。插件使用 `send()`,或使用 `followup()``steer()`、可选的 `trySteer()``inject()` 预设;[`reserveTurnAdmission()`](../packages/core/agent/README.md#agent-interface-typests) 为持久工作同步预留空闲状态,同时不改变排队提示词身份。当前步骤开始最后一次排空 steering 后,默认循环的 `trySteer()` 会原子地拒绝调用,而普通 `steer()` 保留尽力路由语义。`cancel()``whenIdle()` 控制生命周期。需等待完成的资源释放负责拆卸。
### Agent 作用域
+16 -16
View File
@@ -32,7 +32,7 @@ Effective broad cancellation was requested, before queued/outbox work is cleared
Types: [Agent](../core-data-structures/core.md) · [AgentCancelCause](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
Source: [`packages/core/agent/src/types.ts:333`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:343`](../../packages/core/agent/src/types.ts)
### `agent/created` — emit
@@ -54,7 +54,7 @@ A fully configured agent and live session were published. Setup is composition-o
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
Source: [`packages/core/agent/src/types.ts:264`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:274`](../../packages/core/agent/src/types.ts)
### `agent/disposed` — emit
@@ -74,7 +74,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence and sco
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
Source: [`packages/core/agent/src/types.ts:273`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:283`](../../packages/core/agent/src/types.ts)
### `agent/error` — emit
@@ -96,7 +96,7 @@ A step or turn errored. The machine reports a failure here (plus the logger) eve
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
Source: [`packages/core/agent/src/types.ts:447`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:457`](../../packages/core/agent/src/types.ts)
### `agent/inbox/dequeue` — emit
@@ -117,7 +117,7 @@ The driver claimed one item out of the inbox: a queued item at a turn boundary,
Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
Source: [`packages/core/agent/src/types.ts:311`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:321`](../../packages/core/agent/src/types.ts)
### `agent/inbox/discard` — emit
@@ -140,7 +140,7 @@ Pending inbox items were dropped without delivering them, so every enqueue occur
Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
Source: [`packages/core/agent/src/types.ts:323`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:333`](../../packages/core/agent/src/types.ts)
### `agent/inbox/enqueue` — emit
@@ -161,7 +161,7 @@ An item entered the queued or steering inbox. `placement` is the acceptance-time
Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
Source: [`packages/core/agent/src/types.ts:292`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:302`](../../packages/core/agent/src/types.ts)
### `agent/inbox/update` — emit
@@ -181,7 +181,7 @@ A still-pending queued item changed content. The item id, placement, and positio
Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
Source: [`packages/core/agent/src/types.ts:301`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:311`](../../packages/core/agent/src/types.ts)
### `agent/prompt-submit` — waterfall
@@ -204,7 +204,7 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message or
Types: [Agent](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md)
Source: [`packages/core/agent/src/types.ts:360`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:370`](../../packages/core/agent/src/types.ts)
### `agent/request` — waterfall
@@ -228,7 +228,7 @@ Replace the frozen call configuration. `await next()` yields the config the mach
Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
Source: [`packages/core/agent/src/types.ts:386`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:396`](../../packages/core/agent/src/types.ts)
### `agent/request-error` — waterfall
@@ -258,7 +258,7 @@ Handle a model-request failure after its failed step has closed but before the f
Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorAction](../core-data-structures/core.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [Scoped](../core-data-structures/scope.md)
Source: [`packages/core/agent/src/types.ts:405`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:415`](../../packages/core/agent/src/types.ts)
### `agent/session-start` — emit
@@ -280,7 +280,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:346`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:356`](../../packages/core/agent/src/types.ts)
### `agent/settled` — emit
@@ -305,7 +305,7 @@ One drain chain reached its terminal turn: that turn's `turn/end` is already com
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SettleReason](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:434`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:444`](../../packages/core/agent/src/types.ts)
### `agent/status` — emit
@@ -325,7 +325,7 @@ Agent status changed (`idle` ⇄ `running`). `send()` does not enter `running` s
Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
Source: [`packages/core/agent/src/types.ts:282`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:292`](../../packages/core/agent/src/types.ts)
### `agent/step` — serial
@@ -349,7 +349,7 @@ Awaited serial checkpoint before EVERY request of a turn is built (the first as
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
Source: [`packages/core/agent/src/types.ts:373`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:383`](../../packages/core/agent/src/types.ts)
### `agent/turn-stopping` — serial
@@ -375,7 +375,7 @@ The turn is about to close: the model owes no response (no live tool calls, no f
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
Source: [`packages/core/agent/src/types.ts:420`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:430`](../../packages/core/agent/src/types.ts)
## `agent-loop/*`
+10
View File
@@ -672,6 +672,16 @@ interface Agent {
*/
steer(message: UserMessage): void
/**
* Atomically submit steering only while the current step still owns its final
* drain. Returns `false` without accepting the message during admission,
* between steps, or after the final per-step drain has begun. Cancellation or
* disposal may still discard previously accepted steering.
* @param message - identified steering content and its producer provenance.
* @returns whether the message entered the current step.
*/
trySteer?(message: UserMessage): boolean
/**
* Append model-facing context without running the model — the
* `next-step`/no-wakeup preset of {@link send}. Admission or an open turn
+16 -16
View File
@@ -8,22 +8,22 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| Event | Mode | Declared in | Dispatchers | Listeners |
| --- | --- | --- | --- | --- |
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:157`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) |
| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:333`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) |
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:264`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:273`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:447`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) |
| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:311`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) |
| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:323`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) |
| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:292`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session) |
| `agent/inbox/update` | `emit` | [`packages/core/agent/src/types.ts:301`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy` |
| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:360`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`tui`](../packages/ui/tui) |
| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:386`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) |
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:405`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:346`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) |
| `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:434`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) |
| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:282`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:373`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:420`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:343`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) |
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:274`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:283`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:457`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) |
| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:321`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) |
| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:333`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) |
| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:302`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session) |
| `agent/inbox/update` | `emit` | [`packages/core/agent/src/types.ts:311`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy` |
| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:370`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`tui`](../packages/ui/tui) |
| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:396`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) |
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:415`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:356`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) |
| `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:444`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) |
| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:292`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:383`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:430`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` |
| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) |
| `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:67`](../packages/credentials/credentials/src/index.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1583,7 +1583,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'Agent',
declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n reserveTurnAdmission(): (() => void) | undefined;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise<void>;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n}',
declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n reserveTurnAdmission(): (() => void) | undefined;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise<void>;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n trySteer?(message: UserMessage): boolean;\n inject(message: UserMessage): void;\n}',
},
{
name: 'AgentCancelCause',
+17 -1
View File
@@ -132,7 +132,6 @@ export class ReactLoopAgent implements Agent {
private abort: AbortController | undefined
/** Resolves when the current admission and turn exit. */
done: Promise<void> = Promise.resolve()
/** The agent-scoped registration boundary; the lifecycle owner unwinds it after {@link done}. */
readonly scope: Scope
/** The agent's scoped composition context ({@link Agent.ctx}). */
@@ -143,6 +142,8 @@ export class ReactLoopAgent implements Agent {
/** Whether the session log is owed a matching turn end event. */
private turnOpen = false
private stepOpen = false
/** Whether {@link trySteer} can still join the current step's final drain. */
private strictSteeringOpen = false
/** Whether this loop instance has appended its initial/resume request anchor. */
private requestHeaderLogged = false
@@ -242,6 +243,16 @@ export class ReactLoopAgent implements Agent {
})
}
/** Atomically steer only while the current step still owns its final drain. */
trySteer(input: UserMessage): boolean {
if (!this.strictSteeringOpen) return false
this.send(input, {
target: 'next-step',
wakeup: true,
})
return true
}
/** Append model-facing context without waking the driver. */
inject(input: UserMessage): void {
this.send(input, {
@@ -500,6 +511,7 @@ export class ReactLoopAgent implements Agent {
case 'request-failed': {
// step() reports request failures only after step/start commits
// and before its own step/end, so the step is always open here.
this.strictSteeringOpen = false
this.stepOpen = false
this.session.append('step/end', { turn, step })
if (!signal.aborted) {
@@ -535,6 +547,7 @@ export class ReactLoopAgent implements Agent {
} catch (caught: unknown) {
try {
if (this.stepOpen) {
this.strictSteeringOpen = false
this.stepOpen = false
this.session.append('step/end', { turn, step })
}
@@ -552,6 +565,7 @@ export class ReactLoopAgent implements Agent {
// failure paths (step(), the request-failed branch, the catch), so the
// finally owes only the turn boundary.
this.acceptsNextStep = false
this.strictSteeringOpen = false
try {
if (this.turnOpen) {
// Re-entrant turn/end listeners must route new input to a later turn.
@@ -624,6 +638,7 @@ export class ReactLoopAgent implements Agent {
session.append('step/start', { turn, step })
this.stepOpen = true
this.strictSteeringOpen = true
signal.throwIfAborted()
const { request, preparedCall } = await this.buildRequest(
@@ -692,6 +707,7 @@ export class ReactLoopAgent implements Agent {
// Tool results stay adjacent to their calls; input accepted during the
// request enters the log only after the complete result batch.
this.strictSteeringOpen = false
const steered = this.drainOutbox(turn)
session.append('step/end', { turn, step })
this.stepOpen = false
+1
View File
@@ -65,6 +65,7 @@ The handle every plugin programs against:
- `agent.updateInbox(itemId, action)` — synchronously edits or removes one still-pending queued occurrence. Edit keeps its `MessageId`, `InboxItemId`, source, and FIFO position while replacing frozen content; remove emits the occurrence's terminal discard. Steering and claimed occurrences return `not-found`.
- `agent.followup(input)` — the `next-turn`/wakeup preset of `send()`: queue an ordinary follow-up turn and wake the driver.
- `agent.steer(input)` — the `next-step`/wakeup preset: during prompt admission or an open turn, stage steering for the next safe boundary without dispatching `agent/prompt-submit`; outside that acceptance window, delegate to a woken follow-up. Admission failure leaves staged steering for retry or a later admitted prompt, while cancellation or disposal may discard it.
- `agent.trySteer?(input)` — an optional strict-steering capability implemented by the default loop. It atomically submits an identified message only while the current step still owns its final drain, returning `false` without accepting input during admission, between steps, or after that drain begins; cancellation and disposal can still discard accepted steering.
- `agent.inject(input)` — the `next-step`/no-wakeup preset: append model-facing context without running the model; the next request sees a verbatim user-role message whose provenance is carried by the required `input.source`. During prompt admission or an open turn, injection waits in the outbox for the next safe boundary. Outside that acceptance window, it appends immediately without opening a turn; a context-only admission batch takes this fallback if admission closes without a turn, while context staged beside steering remains pending with it. Persistence reacts to `session/event` independently. Injection emits no `agent/inbox/*` event.
- `agent.acceptsNextStep` — whether a `next-step` send would currently join prompt admission or the open turn. Use this narrower routing predicate when a caller must choose between steering and a fresh admitted prompt; `status === 'running'` also covers admission exit and turn settlement.
- `agent.cancel(cause, options?)` — cancel the active turn and, unless `options.keepInbox`, ALL pending work. Callers must choose the `user | parent` cause explicitly; an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; dropped items are reported on `agent/inbox/discard`, and observers may synchronize state but cannot veto cancellation. `keepInbox: true` aborts the turn but preserves queued and steering items (no discard, and un-started work is not dropped). The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`.
+10
View File
@@ -236,6 +236,16 @@ export interface Agent {
*/
steer(message: UserMessage): void
/**
* Atomically submit steering only while the current step still owns its final
* drain. Returns `false` without accepting the message during admission,
* between steps, or after the final per-step drain has begun. Cancellation or
* disposal may still discard previously accepted steering.
* @param message - identified steering content and its producer provenance.
* @returns whether the message entered the current step.
*/
trySteer?(message: UserMessage): boolean
/**
* Append model-facing context without running the model — the
* `next-step`/no-wakeup preset of {@link send}. Admission or an open turn
@@ -181,7 +181,12 @@ export class SubagentControlService extends Service {
activation.controller.abort('subagent control service disposed')
activation.terminal.resolve()
}
await Promise.allSettled(active.map(activation => activation.done ?? Promise.resolve()))
await Promise.allSettled(active.map((activation) => {
/* v8 ignore next 2 -- TaskService invokes `run` synchronously before `start` returns;
* every retained activation has `done`, while registration failure removes it. */
if (activation.done === undefined) return Promise.resolve()
return activation.done
}))
}, 'subagentControl.activations()')
}
@@ -354,7 +359,8 @@ export class SubagentControlService extends Service {
const descriptor = foldSubagentDescriptor(loaded.events.slice(loaded.meta.seedLength ?? 0))
if (descriptor === undefined) {
throw new SubagentControlError(
`subagent "${childId}" has no supported continuation descriptor`,
`subagent "${childId}" has no supported continuation state and cannot be resumed; `
+ 'do not retry send_message with this id',
'NOT_RESUMABLE',
)
}
@@ -89,6 +89,20 @@ async function waitTerminal(ctx: Context, taskId: TaskId, parent: Agent) {
return ctx.tasks.wait(taskId, 5_000, parent)
}
async function waitPublishedRun(ctx: Context, childId: SessionId): Promise<void> {
const control = ctx.subagentControl as unknown as {
activations: Map<SessionId, { run: unknown }>
}
await new Promise<void>((resolve) => {
const timer = setInterval(() => {
if (control.activations.get(childId)?.run !== undefined) {
clearInterval(timer)
resolve()
}
}, 5)
})
}
function message(text: string) {
return [{ type: 'text' as const, text }]
}
@@ -145,6 +159,20 @@ describe('SubagentControlService.startContinuable', () => {
expect(ctx.tasks.list(parent)).toEqual([])
})
it('rolls back the activation when Task preflight throws', async () => {
const { ctx, parent } = await setup([textResponse('unused')])
const realStart = ctx.tasks.start.bind(ctx.tasks)
ctx.tasks.start = () => { throw new Error('task preflight failed') }
try {
expect(() => ctx.subagentControl.startContinuable(startSpec(parent)))
.toThrow('task preflight failed')
} finally {
ctx.tasks.start = realStart
}
const control = ctx.subagentControl as unknown as { activations: Map<SessionId, unknown> }
expect(control.activations.size).toBe(0)
})
it('rejects a non-JSON descriptor input synchronously with no Task', async () => {
const { ctx, parent } = await setup([textResponse('unused')])
const spec = startSpec(parent)
@@ -195,6 +223,85 @@ describe('SubagentControlService.startContinuable', () => {
})
describe('SubagentControlService.sendMessage', () => {
it('omits undeclared model selectors and rejects a provider without live delivery', async () => {
const { ctx } = await setup([])
const result = Promise.withResolvers<{
output: { type: 'text'; text: string }[]
stopReason: 'completed'
}>()
let descriptor: SessionEvent<'subagent/descriptor'>['data'] | undefined
ctx.subagents.registerProvider({
name: 'no-steer',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: async (request) => {
descriptor = request.continuation?.descriptor
return {
id: request.continuation!.sessionId,
localAgent: undefined,
result: result.promise,
async dispose() {},
}
},
resume: async () => { throw new Error('not used') },
})
const parent = ctx.agentLoop.create(SessionId('bare-parent'), {})
const started = ctx.subagentControl.startContinuable(startSpec(parent, 'no-steer'))
await waitPublishedRun(ctx, started.childId)
expect(descriptor).toEqual({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'no-steer' })
expect(() => ctx.subagentControl.sendMessage(parent, started.childId, message('join')))
.toThrow(/provider does not accept live delivery/)
let terminalDeliveryError: unknown
ctx.tasks.onTaskDone((snapshot) => {
if (snapshot.id !== started.taskId) return
try {
ctx.subagentControl.sendMessage(parent, started.childId, message('after terminal'))
} catch (error: unknown) {
terminalDeliveryError = error
}
})
result.resolve({ output: [{ type: 'text', text: 'done' }], stopReason: 'completed' })
await waitTerminal(ctx, started.taskId, parent)
expect(String(terminalDeliveryError)).toContain('is completed')
})
it('rejects a registry agent different from the associated run agent', async () => {
const { ctx, parent } = await setup([])
const result = Promise.withResolvers<{
output: { type: 'text'; text: string }[]
stopReason: 'completed'
}>()
ctx.subagents.registerProvider({
name: 'mismatched-local',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: async (request) => {
const childId = request.continuation!.sessionId
const handle = await ctx.agents.create({
sessionId: childId,
meta: { parentSession: request.parent.id },
agentOptions: { provider: 'mock', model: 'mock' },
})
return {
id: childId,
localAgent: {} as Agent,
result: result.promise,
dispose: () => handle.dispose(),
}
},
resume: async () => { throw new Error('not used') },
})
const started = ctx.subagentControl.startContinuable(startSpec(parent, 'mismatched-local'))
await waitPublishedRun(ctx, started.childId)
expect(() => ctx.subagentControl.sendMessage(parent, started.childId, message('join')))
.toThrow(/registry agent is not the associated activation's agent/)
result.resolve({ output: [{ type: 'text', text: 'done' }], stopReason: 'completed' })
await waitTerminal(ctx, started.taskId, parent)
})
it('steers a running activation into the existing Task without creating a second Task', async () => {
// Hold the child's first model call open so the child is observably
// running when the message arrives; the steered content then drives a
@@ -358,7 +465,23 @@ describe('SubagentControlService.sendMessage', () => {
const attempt = ctx.subagentControl.sendMessage(parent, SessionId('plain-child'), message('continue?'))
const snapshot = await waitTerminal(ctx, attempt.taskId, parent)
expect(snapshot.status).toBe('failed')
expect(snapshot.detail).toContain('continuation descriptor')
expect(snapshot.detail).toContain(
'has no supported continuation state and cannot be resumed; do not retry send_message with this id',
)
})
it('derives fallback and bounded labels for resumed activations', async () => {
const { ctx, parent } = await setup([])
const blank = ctx.subagentControl.sendMessage(parent, SessionId('blank-child'), message(' '))
const longText = 'x'.repeat(100)
const long = ctx.subagentControl.sendMessage(parent, SessionId('long-child'), message(longText))
expect(ctx.tasks.get(blank.taskId, parent).label).toBe('subagent follow-up')
expect(ctx.tasks.get(long.taskId, parent).label).toBe(`${'x'.repeat(79)}`)
await Promise.all([
waitTerminal(ctx, blank.taskId, parent),
waitTerminal(ctx, long.taskId, parent),
])
})
it('rejects delivery to a live agent outside control-service ownership', async () => {
@@ -491,7 +614,7 @@ describe('service disposal with live activations', () => {
await ctx.plugin(JsonlSessionPersistence, { root })
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentService)
await ctx.plugin(TaskService)
await ctx.plugin(LocalTaskService)
await ctx.plugin(ToolTasks, {})
// A provider that stays pending until its signal aborts, so the activation
// is observably mid-start when the control service is disposed.
@@ -518,7 +641,7 @@ describe('service disposal with live activations', () => {
label: 'will be interrupted',
request: { prompt: message('go'), parent },
})
// TaskService keeps the producer Task; the disposing control service must
// LocalTaskService keeps the producer Task; the disposing control service must
// cancel its activation and await settlement rather than strand it.
await controlFiber.dispose()
expect(sawAbort).toBe(true)
@@ -276,10 +276,10 @@ function driveTurn(
if (lastBoundary?.type !== 'turn/start') {
throw new Error(`subagent child "${childId}" turn has already closed; the message was not delivered`)
}
// Terminal turn-stops only run between steps: with no step open, the
// loop may be awaiting its continuation/turn-stop checkpoints, where
// pending steering was already folded and a terminal decision discards
// a later arrival. A message accepted during an OPEN step is instead
// Turn settlement only runs between steps: with no step open, the loop
// may be awaiting its continuation/turn-stopping checkpoint, where
// pending steering was already folded and a later arrival would miss
// this turn. A message accepted during an OPEN step is instead
// drained and recorded at that step's settlement checkpoint before any
// terminal decision (cancellation remains the documented shared-outcome
// race).
@@ -289,14 +289,20 @@ function driveTurn(
if (lastStep?.type !== 'step/start') {
throw new Error(`subagent child "${childId}" is between steps; the message was not delivered`)
}
// A committed structured capture makes the pending `agent/turn-stop`
// checkpoint terminal, and the loop then discards late steering. The
// capture is synchronously observable, so reject rather than
// acknowledge a message the run is about to drop.
// A committed structured capture makes the pending step conclusion
// terminal. The capture is synchronously observable, so reject rather
// than acknowledge a message the run is about to drop.
if (structured?.captured() !== undefined) {
throw new Error(`subagent child "${childId}" already reported its structured result; the message was not delivered`)
}
child.steer(createUserMessage({ content, source: { kind: 'user' } }))
// The atomic Agent operation closes before the final drain, so this
// cannot acknowledge content that the current step will not record.
if (child.trySteer === undefined) {
throw new Error(`subagent child "${childId}" agent does not support strict steering; the message was not delivered`)
}
if (!child.trySteer(createUserMessage({ content, source: { kind: 'user' } }))) {
throw new Error(`subagent child "${childId}" passed its steering checkpoint; the message was not delivered`)
}
},
}
}
@@ -121,28 +121,25 @@ describe('in-process structured output', () => {
})
it('strict steer rejects delivery once the structured result is captured', async () => {
// Hold the capture's tool result open so the child is observably running
// with a committed capture: the pending agent/turn-stop checkpoint is
// terminal, and the loop would DISCARD a steering message, so an
// acknowledged delivery here would be a lie.
let releaseResult: (() => void) | undefined
const { ctx, parent } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
])
ctx.on('agent/post-step', (agent) => {
if (agent.session.header.parentSession === undefined || releaseResult !== undefined) return
return new Promise<void>((resolve) => { releaseResult = resolve })
let run: Awaited<ReturnType<typeof ctx.subagents.start>> | undefined
let rejected: unknown
ctx.on('session/event', (session, event) => {
if (session.header.parentSession === undefined || run === undefined
|| event.type !== 'tool/result' || rejected !== undefined) return
try {
run.steer?.([{ type: 'text', text: 'one more thing' }])
} catch (error: unknown) {
rejected = error
}
})
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
await new Promise<void>((resolve) => {
const timer = setInterval(() => {
if (releaseResult !== undefined) { clearInterval(timer); resolve() }
}, 5)
})
expect(() => { run.steer!([{ type: 'text', text: 'one more thing' }]) })
.toThrow(/already reported its structured result; the message was not delivered/)
releaseResult!()
run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(rejected).toBeInstanceOf(Error)
expect((rejected as Error).message)
.toMatch(/already reported its structured result; the message was not delivered/)
expect(result.structured).toEqual({ answer: 7 })
await run.dispose()
})
@@ -2,16 +2,16 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { type Agent, type AgentOptions } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import InvariantService from '@deepseek-ai/dsh-invariants'
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
import SubagentService from '@deepseek-ai/dsh-subagent'
import SubagentService, { SUBAGENT_DESCRIPTOR_VERSION } from '@deepseek-ai/dsh-subagent'
import { maxTokensResponse, MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { startInProcessRun } from '../src/index.ts'
import { resumeInProcessRun, startInProcessRun } from '../src/index.ts'
type Script = ConstructorParameters<typeof MockAdapter>[0]
@@ -186,6 +186,61 @@ describe('startInProcessRun', () => {
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
})
it('rejects an already-aborted resume before publication', async () => {
const { parent } = await setup([])
const controller = new AbortController()
controller.abort('too late')
await expect(resumeInProcessRun({
sessionId: SessionId('resumed-child'),
prompt: [{ type: 'text', text: 'continue' }],
parent,
signal: controller.signal,
descriptor: { version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn' },
})).rejects.toThrow('aborted before child publication')
})
it('resumes without inventing undeclared agent model options', async () => {
const childId = SessionId('resumed-child')
const child = {
id: childId,
options: {},
session: new Session(childId),
status: 'idle',
acceptsNextStep: false,
ctx: new Context(),
send(): void {},
reserveTurnAdmission: () => undefined,
updateInbox: () => 'not-found',
followup(): void {},
steer(): void {},
inject(): void {},
cancel(): void {},
whenIdle: () => Promise.resolve(),
} as Agent
let resumedOptions: unknown
const parent = {
ctx: {
agents: {
resume: (options: { agentOptions: unknown }) => {
resumedOptions = options.agentOptions
return Promise.resolve({ agent: child, dispose: () => Promise.resolve() })
},
},
},
} as unknown as Agent
const run = await resumeInProcessRun({
sessionId: childId,
prompt: [{ type: 'text', text: 'continue' }],
parent,
signal: new AbortController().signal,
descriptor: { version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn' },
})
expect(resumedOptions).toEqual({})
await expect(run.result).resolves.toMatchObject({ stopReason: 'error' })
await run.dispose()
})
it('uses the request signal after publication and dispose as cancellation paths', async () => {
const { parent, adapter } = await setup(['hang', 'hang'])
const controller = new AbortController()
@@ -258,14 +313,12 @@ describe('startInProcessRun', () => {
await run.dispose()
})
it('strict steer rejects the between-steps window where a terminal turn-stop discards steering', async () => {
// Hold `agent/turn-stop` open: the step has closed, pending steering was
// already folded into the continuation decision, and a terminal stop
// would discard a message arriving now — the exact window an
// acknowledged delivery would be a lie.
it('strict steer rejects the between-steps turn-stopping window', async () => {
// Hold `agent/turn-stopping` open after the step closed and pending
// steering was folded into the continuation decision.
const { ctx, parent } = await setup([textResponse('quick')])
let releaseStop: (() => void) | undefined
ctx.on('agent/turn-stop', (agent) => {
ctx.on('agent/turn-stopping', (agent) => {
if (agent.session.header.parentSession === undefined || releaseStop !== undefined) return undefined
return new Promise((resolve) => {
releaseStop = () => { resolve(undefined) }
@@ -287,6 +340,87 @@ describe('startInProcessRun', () => {
await run.dispose()
})
it('strict steer rejects reentrant delivery after the final drain begins', async () => {
const { ctx, parent } = await setup([textResponse('quick')])
let run: Awaited<ReturnType<typeof startInProcessRun>> | undefined
let seeded = false
let rejected: unknown
ctx.on('session/event', (session, event) => {
if (session.header.parentSession === undefined || run === undefined) return
if (event.type === 'assistant/chunk' && !seeded) {
seeded = true
run.steer?.([{ type: 'text', text: 'accepted before the drain' }])
} else if (event.type === 'steering/message' && rejected === undefined) {
try {
run.steer?.([{ type: 'text', text: 'after the drain began' }])
} catch (error: unknown) {
rejected = error
}
}
})
run = await startInProcessRun(request(parent), {})
const child = ctx.agents.get(run.id)!
await run.result
expect(seeded).toBe(true)
expect(rejected).toBeInstanceOf(Error)
expect((rejected as Error).message)
.toMatch(/passed its steering checkpoint; the message was not delivered/)
expect(child.session.events.filter(event => event.type === 'steering/message')).toHaveLength(1)
await run.dispose()
})
it('strict steer rejects an Agent implementation without atomic steering', async () => {
const childId = SessionId('custom-loop-child')
const childSession = new Session(childId)
childSession.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
childSession.append('step/start', { turn: 1, step: 1 })
const idle = Promise.withResolvers<undefined>()
const child = {
id: childId,
options: {},
session: childSession,
status: 'running',
acceptsNextStep: false,
ctx: new Context(),
send(): void {},
reserveTurnAdmission: () => undefined,
updateInbox: () => 'not-found',
followup(): void {},
steer(): void {},
inject(): void {},
cancel(): void {},
whenIdle: () => idle.promise,
} as Agent
const parentId = SessionId('custom-loop-parent')
const parent = {
id: parentId,
options: {},
session: new Session(parentId),
ctx: {
get: () => undefined,
agents: {
create: () => Promise.resolve({
agent: child,
dispose: () => {
idle.resolve(undefined)
return Promise.resolve()
},
}),
},
},
} as unknown as Agent
const run = await startInProcessRun(request(parent), {})
expect(() => { run.steer!([{ type: 'text', text: 'unsupported strict delivery' }]) })
.toThrow(/does not support strict steering; the message was not delivered/)
await run.dispose()
await run.result
})
it('strict steer rejects the closed-turn flush window where the loop discards steering', async () => {
// Hold the turn-end durability flush open: the turn has closed in the log
// and status is still `running`, exactly the window where the loop would
@@ -5,6 +5,9 @@ import { type Agent } from '@deepseek-ai/dsh-agent'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import { carrierKeyOf } from '@deepseek-ai/dsh-scope'
import SubagentService, {
foldSubagentDescriptor,
snapshotSubagentDescriptor,
SUBAGENT_DESCRIPTOR_VERSION,
SubagentError,
assertSubagentMaxDepth,
type SubagentCapabilities,
@@ -13,7 +16,7 @@ import SubagentService, {
type SubagentRun,
type SubagentStartRequest,
} from '@deepseek-ai/dsh-subagent'
import { SessionId } from '@deepseek-ai/dsh-session'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
function fakeParent(id = 'parent-1'): Agent {
return { id: SessionId(id) } as unknown as Agent
@@ -99,6 +102,28 @@ describe('SubagentService', () => {
.rejects.toMatchObject({ code: 'NO_PROVIDER' })
})
it('rejects continuable start and resume when the provider has no resume capability', async () => {
const { subagents } = await service()
subagents.registerProvider(new StubProvider('one-shot'))
const descriptor = snapshotSubagentDescriptor({ provider: 'one-shot' })
const sessionId = SessionId('continuable-child')
const parent = fakeParent()
const signal = new AbortController().signal
await expect(subagents.start('one-shot', baseRequest({
parent,
signal,
continuation: { sessionId, descriptor },
}))).rejects.toMatchObject({ code: 'UNSUPPORTED_CAPABILITY' })
await expect(subagents.resume('one-shot', {
sessionId,
prompt: [{ type: 'text', text: 'continue' }],
parent,
signal,
descriptor,
})).rejects.toMatchObject({ code: 'UNSUPPORTED_CAPABILITY' })
})
it.each([
['outputSchema', { outputSchema: { type: 'object', properties: {} } }],
['depthLimit', { maxDepth: 1 }],
@@ -247,3 +272,17 @@ describe('SubagentService', () => {
expect(error.code).toBe('NO_PROVIDER')
})
})
describe('subagent descriptors', () => {
it('omits absent model selectors and rejects unsupported versions', () => {
expect(snapshotSubagentDescriptor({ provider: 'spawn' })).toEqual({
version: SUBAGENT_DESCRIPTOR_VERSION,
provider: 'spawn',
})
const unsupported = {
type: 'subagent/descriptor',
data: { version: SUBAGENT_DESCRIPTOR_VERSION + 1, provider: 'spawn' },
} as unknown as SessionEvent<'subagent/descriptor'>
expect(foldSubagentDescriptor([unsupported])).toBeUndefined()
})
})