fix(subagent): confirm steering request admission
This commit is contained in:
+2
-2
@@ -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: 6552db82dc5cf1fabac8f18dd347cc8735f73587
|
||||
2026-07-21-continuable-background-subagents.zh.md: ed07abd2af34397d056cc022fc451e6397964acb
|
||||
2026-07-21-continuable-background-subagents.md: b5683f7e4a81a65b176ff4b4306c1ad0b761cc58
|
||||
2026-07-21-continuable-background-subagents.zh.md: 0b0f22d0945bf270267df1698b9145f0ab4b04f1
|
||||
@@ -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 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.
|
||||
Routing follows the Task association. A running Task accepts live delivery through the run's optional confirmed `SubagentRun.steer` capability. An absent Task starts a fresh Task and cold-resumes the child. In-process spawn and fork first synchronously require the child to be `running` and reject an already committed structured capture, then call `Agent.steer()` and await that exact message's admission receipt. The default loop gives every steering item a message-owned receipt and resolves it `admitted` only after a successful pre-step has appended the message, captured the immutable request history, and committed `step/start`; terminal turn policy, cancellation, and disposal resolve pending receipts `rejected`. A non-terminal turn close may carry pending steering into a later queued turn without acknowledging it. Providers must check the live status before `Agent.steer()` so its idle path cannot start a turn outside the observed run. If Task settlement or terminal policy wins after association lookup but before request admission, `steer()` rejects, `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.
|
||||
|
||||
@@ -59,7 +59,7 @@ The model receives one `send_message(subagent_id, message)` tool backed by `Suba
|
||||
|
||||
- If the child has a running Task and live-steering capability, the service calls `run.steer(message, source)` and returns the existing Task id; it creates no Task of its own.
|
||||
- If the child has no running Task, `send_message` creates a fresh Task, cold-resumes the durable session with the message, and returns the new Task id.
|
||||
- If the active provider cannot accept live delivery, strict steering loses a race with Task settlement, or a live child exists outside the Task association, `send_message` fails rather than silently starting, resuming, or adopting an untracked turn.
|
||||
- If the active provider cannot accept live delivery, confirmed steering loses its admission race, or a live child exists outside the Task association, `send_message` fails rather than silently starting, resuming, or adopting an untracked turn.
|
||||
|
||||
The service result identifies the route as `steered` with the existing Task id or `started` with the new Task id. Failure is explicit and says that the message was not delivered. The model-facing tool renders these distinctions so timing-dependent routing is observable to the caller.
|
||||
|
||||
@@ -73,7 +73,7 @@ The control service snapshots every descriptor input with the seam's `snapshotSu
|
||||
|
||||
The versioned descriptor (`SUBAGENT_DESCRIPTOR_VERSION` in [descriptor.ts](../../../../packages/subagent/subagent/src/descriptor.ts)) contains the subagent provider name, resolved child `agentOptions.provider` and `agentOptions.model`, and optional `persona` and `toolFilter`. It does not snapshot the merge-extensible `AgentOptions` object: unrelated extension values cannot make continuation fail merely because they are not JSON. It deliberately omits `subagentDepth`; cold resume relies on the persisted header's `delegationDepth` rather than reconstructing depth from the descriptor. `outputSchema` belongs to one activation's result contract rather than durable child composition. The child header remains authoritative for the child id, `cwd`, `parentSession`, `seedLength`, and `delegationDepth`, while the persisted child transcript owns the fork seed and subsequent history. [`delegationDepthOf()`](../../../../packages/subagent/subagent/src/index.ts) takes the maximum of header and runtime values, so reconstructed runtime options may deepen the persisted value but never lower it and a resumed child cannot regain a top-level delegation budget.
|
||||
|
||||
Cold resume cannot depend on an optional method of the old `SubagentRun`, because that run has been disposed and is not retained across process restart. `SubagentRun` has no `resume` operation: a run represents one disposable activation and exposes only activation-scoped operations. The former `SubagentRun.sendMessage?()` capability is named `SubagentRun.steer?()` so its strict live-only contract cannot be confused with service orchestration or the model-facing tool.
|
||||
Cold resume cannot depend on an optional method of the old `SubagentRun`, because that run has been disposed and is not retained across process restart. `SubagentRun` has no `resume` operation: a run represents one disposable activation and exposes only activation-scoped operations. The former `SubagentRun.sendMessage?()` capability is named `SubagentRun.steer?()` so its confirmed live-only contract cannot be confused with service orchestration or the model-facing tool.
|
||||
|
||||
`SubagentControlService`'s resume path loads the known child session, folds its descriptor, authorizes the persisted `parentSession`, and runs inside the Task it creates. It passes a fully resolved request, including the Task-owned cancellation signal, to the low-level `SubagentService.resume(provider, request)`, whose only responsibility is capability-checked provider dispatch and the ordinary run lifecycle observation used by `start`. The selected `SubagentProvider.resume?()` owns transport-specific reconstruction (in-process: `parent.ctx.agents.resume` under the currently loaded parent scope) and returns a fresh run. Presence of the provider method is the continuation capability, so no redundant capability flag exists. `SubagentControlService.sendMessage()` chooses between the associated run's `steer?()` operation and this cold-resume path. Neither the low-level service nor a provider enumerates durable children or associates Tasks.
|
||||
|
||||
@@ -97,7 +97,7 @@ Task records and active-run associations are process-local. Persistence makes th
|
||||
|
||||
**Create a Task for every message.** Steering joins an existing turn and has no independent final result, so a Task created for steering would duplicate the active Task or report a result it does not own. Only a message that starts an activation creates a Task.
|
||||
|
||||
**Split `send_message` and `follow_up`.** Separate strict operations expose an implementation-state distinction to the model without removing stopped-child races. One operation follows the Claude Code model: deliver to running work or resume a new Task-backed lifecycle.
|
||||
**Split `send_message` and `follow_up`.** Separate delivery operations expose an implementation-state distinction to the model without removing stopped-child races. One operation follows the Claude Code model: deliver to running work or resume a new Task-backed lifecycle.
|
||||
|
||||
**Keep `resume?()` on the disposed run.** Retaining a disposed `SubagentRun` only to call `resume()` makes the old run double as a durable child handle and cannot reconstruct that object after restart. Service dispatch plus provider reconstruction makes the persistence boundary explicit.
|
||||
|
||||
|
||||
@@ -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 循环所提供的可选原子操作 `trySteer()`,以实现该功能:child 必须处于 `running` 状态,其轮次和步骤在日志中必须仍然打开,该步骤最后一次排空 steering(中途引导)必须尚未开始,且不得已有结构化捕获提交。循环会在排空 steering 并进入 `agent/post-step` 前关闭 `trySteer()` 准入,使终止性 stop 无法丢弃在这个窗口中已确认接收的消息。不提供 `trySteer()` 的循环无法支撑严格的进程内消息投递。提供方不得将 Agent 层的 idle fallback 暴露为严格 steering,因为观察到的 run 结束后,该 fallback 可能启动一个未受 Task 跟踪的轮次。如果 Task 在查找关联与执行这项严格操作之间进入结算,`steer()` 会失败,`send_message` 会报告消息未送达,而且该次调用不会改用从持久化存储恢复路径;在 Task 终态发布后重试,才可能启动下一次激活。
|
||||
系统依据 Task 关联进行路由。运行中的 Task 通过 run 可选且提供确认语义的 `SubagentRun.steer` 功能接收在线消息。Task 不存在时,系统创建新 Task,并从持久化存储恢复 child。进程内 spawn 和 fork 会先同步要求 child 处于 `running` 状态,并拒绝已经提交结构化捕获的 child;随后调用 `Agent.steer()`,等待该消息专属的准入回执。默认循环会为每个 steering 项目提供一份归属于该消息的回执;只有在 `agent/pre-step` 成功后追加该消息、捕获不可变的请求历史并提交 `step/start`,回执才会解析为 `admitted`。终止型轮次策略、取消和 dispose(资源释放)会将待处理回执解析为 `rejected`。非终止型轮次关闭可以把待处理 steering 带入后续排队轮次,但不会确认其准入。提供方必须在调用 `Agent.steer()` 前检查存活状态,避免其 idle 路径在观察到的 run 之外启动轮次。如果查找关联之后、请求获准之前,Task 结算或终止策略率先完成,`steer()` 会拒绝,`send_message` 会报告消息未送达,而且该次调用不会改用从持久化存储恢复路径;在 Task 终态发布后重试,才可能启动下一次激活。
|
||||
|
||||
控制服务不会串行化两个通过其外部路径同时争抢已停止 child 的调用方,也不会为结果产生与 dispose 之间的阶段单独建立 settling 状态。在 producer 首次 await 之前同步安装的关联,使本进程内每个 child 只准入一次激活——resume 加载期间竞争的 `sendMessage` 会观察到待处理的激活并显式失败——而绕开该关联的发布仍会在 Agent 注册表相同会话的冲突边界上失败。发送也可能因与启动、取消、完成或清理发生竞态而失败。这些限制是明确的,而非隐藏在更大的生命周期抽象之后。
|
||||
|
||||
@@ -59,7 +59,7 @@ durable child Session
|
||||
|
||||
- 如果 child 存在运行中的 Task 并支持在线消息,服务会调用 `run.steer(message, source)` 并返回现有 task id;它不会创建新 Task。
|
||||
- 如果 child 没有运行中的 Task,`send_message` 会创建新 Task,使用该消息从持久化存储恢复会话,并返回新的 task id。
|
||||
- 如果活跃提供方无法接收在线消息、严格 steering 在与 Task 结算的竞态中失败,或 Task 关联之外存在存活 child,`send_message` 会失败,而不会静默启动、恢复或接管未受跟踪的轮次。
|
||||
- 如果活跃提供方无法接收在线消息、带确认语义的 steering 在准入竞态中失败,或 Task 关联之外存在存活 child,`send_message` 会失败,而不会静默启动、恢复或接管未受跟踪的轮次。
|
||||
|
||||
服务结果将路由标识为 `steered` 并携带现有 task id,或标识为 `started` 并携带新的 task id。失败结果会明确说明消息未送达。面向模型的工具会呈现这些差异,让调用方能够观察由时序决定的实际路由。
|
||||
|
||||
@@ -73,7 +73,7 @@ durable child Session
|
||||
|
||||
版本化描述符([descriptor.ts](../../../../packages/subagent/subagent/src/descriptor.ts) 中的 `SUBAGENT_DESCRIPTOR_VERSION`)包含 subagent 提供方名称、已解析的 child `agentOptions.provider` 和 `agentOptions.model`,以及可选的 `persona` 与 `toolFilter`。它不会对可通过声明合并扩展的 `AgentOptions` 对象建立快照:与此无关的扩展值不会仅因无法表示为 JSON 而导致继续执行失败。描述符会特意省略 `subagentDepth`;从持久化存储恢复时,系统依赖持久化 header 中的 `delegationDepth`,而不根据描述符重建深度。`outputSchema` 属于单次激活的结果契约,不属于持久化 child 组合配置。child header 仍是 child id、`cwd`、`parentSession`、`seedLength` 和 `delegationDepth` 的权威信息,持久化 child transcript 则负责保存 fork seed 和后续历史。[`delegationDepthOf()`](../../../../packages/subagent/subagent/src/index.ts) 会在 header 值和运行时值中取最大值,因此重建后的运行时选项可以加深持久化值,但绝不能降低它,恢复后的 child 无法重新获得顶层委派预算。
|
||||
|
||||
从持久化存储恢复不能依赖旧 `SubagentRun` 的可选方法,因为该 run 已被 dispose,并且进程重启后不会保留。`SubagentRun` 不含 `resume` 操作:run 表示一次可 dispose 的激活,只暴露作用于当前激活的操作。原有的 `SubagentRun.sendMessage?()` 功能改名为 `SubagentRun.steer?()`,以免其严格的仅在线契约与服务编排或面向模型的工具混淆。
|
||||
从持久化存储恢复不能依赖旧 `SubagentRun` 的可选方法,因为该 run 已被 dispose,并且进程重启后不会保留。`SubagentRun` 不含 `resume` 操作:run 表示一次可 dispose 的激活,只暴露作用于当前激活的操作。原有的 `SubagentRun.sendMessage?()` 功能改名为 `SubagentRun.steer?()`,以免其提供确认语义且仅适用于在线消息的契约与服务编排或面向模型的工具混淆。
|
||||
|
||||
`SubagentControlService` 的恢复路径会加载已知 child 会话、归并其描述符、根据持久化的 `parentSession` 鉴权,并在其创建的 Task 内部运行。它向底层 `SubagentService.resume(provider, request)` 传递完全解析的请求,其中包含由 Task 持有的取消信号;后者只负责检查提供方功能后进行分发,并执行 `start` 所使用的普通 run 生命周期观察。选中的 `SubagentProvider.resume?()` 负责传输相关的重建(进程内:在当前加载的 parent 作用域下执行 `parent.ctx.agents.resume`),并返回一个新 run。提供方是否存在该方法本身就是继续执行功能,无需额外功能标志。`SubagentControlService.sendMessage()` 在关联 run 的 `steer?()` 操作与该持久化恢复路径之间做出选择。底层服务和提供方都不会枚举持久化 child 或关联 Task。
|
||||
|
||||
@@ -97,7 +97,7 @@ Task 记录和活跃 run 关联都位于进程内。持久化使 child 会话可
|
||||
|
||||
**为每条消息创建 Task。** 发送到现有 run 的消息会加入已有轮次,不产生独立的最终结果;为这类消息创建 Task,会重复当前 Task,或报告一个它并不拥有的结果。只有启动新激活的消息才会创建 Task。
|
||||
|
||||
**拆分 `send_message` 与 `follow_up`。** 两个严格操作会向模型暴露实现状态差异,却无法消除 child 已停止时的竞态。单一操作采用 Claude Code 模型:向运行中的工作发送消息,或恢复一个由新 Task 支撑的生命周期。
|
||||
**拆分 `send_message` 与 `follow_up`。** 两个独立的投递操作会向模型暴露实现状态差异,却无法消除 child 已停止时的竞态。单一操作采用 Claude Code 模型:向运行中的工作发送消息,或恢复一个由新 Task 支撑的生命周期。
|
||||
|
||||
**在已 dispose 的 run 上保留 `resume?()`。** 如果仅为调用 `resume()` 而保留已 dispose 的 `SubagentRun`,旧 run 会同时充当持久化 child handle,而且进程重启后无法重建该对象。由服务分发、提供方重建,可明确表达持久化边界。
|
||||
|
||||
|
||||
@@ -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: c5788ad33dc87e104dbdf0f420ac937af9ff2662
|
||||
architecture.zh.md: db98ead01d5bcb689a2cfd199eaae059763ad19e
|
||||
architecture.md: d7beb60baac3c550eb008d414158d9a05181337a
|
||||
architecture.zh.md: 200f82df3d936b45f4aeef0cb080c55483af602a
|
||||
@@ -93,12 +93,12 @@ forever:
|
||||
append prompt + additional contexts as separate 'user/message' events
|
||||
STEP loop:
|
||||
agent/step
|
||||
drain injected context and steering (steering bypasses prompt-submit)
|
||||
assemble system prompt and tools
|
||||
materialize changed runtime context as sourced 'user/message'
|
||||
drain injected context and provisional steering (steering bypasses prompt-submit)
|
||||
snapshot the derived messages (the reconstruction boundary)
|
||||
'step/start'
|
||||
open strict-steering acceptance
|
||||
admit the drained steering receipts
|
||||
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'
|
||||
@@ -107,10 +107,10 @@ 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'
|
||||
close strict-steering acceptance, then drain accepted tool context and steering
|
||||
drain accepted tool context after all results; keep steering provisional
|
||||
'step/end'
|
||||
continue for tools or steering unless a result concluded the turn
|
||||
otherwise agent/turn-stopping -> drain -> continue only for steering
|
||||
continue for tools or steering unless a result concluded the turn and rejects pending steering
|
||||
otherwise agent/turn-stopping -> drain context -> continue only for steering
|
||||
close the next-step acceptance window
|
||||
'turn/end' -> agent/settled
|
||||
start the next waking queued message, or emit agent/status(idle)
|
||||
@@ -122,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. 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.
|
||||
Admission-time and active-turn `inject()` stage for the next step; tool-time injection and post-tool `additionalContexts` settle after results. Steering shares the outbox but remains provisional until a request admits it. `steer()` returns a message-owned receipt: after `agent/step` and asynchronous prompt assembly succeed, the loop commits the stable batch, snapshots request history, opens `step/start`, then resolves its receipts as admitted with the turn and step; later arrivals wait. A turn-concluding tool result, broad cancellation, disposal, or a claimed idle-steering turn that never opens a step rejects affected receipts, while `cancel(..., { keepInbox: true })` and non-terminal routing preserve pending delivery. 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)).
|
||||
|
||||
@@ -136,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()`, 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.
|
||||
`ctx.agents` owns agents, returning `AgentHandle { agent, dispose() }`. Plugins use `send()` or `followup()`, receipt-bearing `steer()`, and `inject()` presets; [`reserveTurnAdmission()`](../packages/core/agent/README.md#agent-interface-typests) synchronously reserves idle for durable work without changing queued prompt identity. Await a steering receipt when request admission matters; best-effort UI steering may ignore it. `cancel()` and `whenIdle()` control lifecycle. Caller, factory, and consumer co-own teardown through one awaited disposer.
|
||||
|
||||
### Agent Scope
|
||||
|
||||
|
||||
@@ -93,12 +93,12 @@ forever:
|
||||
append prompt + additional contexts as separate 'user/message' events
|
||||
STEP loop:
|
||||
agent/step
|
||||
drain injected context and steering (steering bypasses prompt-submit)
|
||||
assemble system prompt and tools
|
||||
materialize changed runtime context as sourced 'user/message'
|
||||
drain injected context and provisional steering (steering bypasses prompt-submit)
|
||||
snapshot the derived messages (the reconstruction boundary)
|
||||
'step/start'
|
||||
open strict-steering acceptance
|
||||
admit the drained steering receipts
|
||||
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'
|
||||
@@ -107,10 +107,10 @@ 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'
|
||||
close strict-steering acceptance, then drain accepted tool context and steering
|
||||
drain accepted tool context after all results; keep steering provisional
|
||||
'step/end'
|
||||
continue for tools or steering unless a result concluded the turn
|
||||
otherwise agent/turn-stopping -> drain -> continue only for steering
|
||||
continue for tools or steering unless a result concluded the turn and rejects pending steering
|
||||
otherwise agent/turn-stopping -> drain context -> continue only for steering
|
||||
close the next-step acceptance window
|
||||
'turn/end' -> agent/settled
|
||||
start the next waking queued message, or emit agent/status(idle)
|
||||
@@ -122,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 与其共用这一暂存边界,并请求再执行一个步骤。默认循环会在最后一次排空 steering 前立即关闭其可选 `trySteer()` 的准入;普通 `steer()` 保留尽力路由语义。空闲状态下的 `inject()` 会立即追加,且不改变轮次编号;持久化层会尽快排空。
|
||||
接纳期间和活跃轮次内的 `inject()` 会为下一步骤暂存;工具执行期间的注入和工具执行后的 `additionalContexts` 会在结果记录完毕后落定。steering 与其共用 outbox,但在请求接纳前始终处于待准入状态。`steer()` 会返回归属于该消息的回执:`agent/step` 和异步提示词组装成功后,循环提交稳定批次、捕获请求历史并开启 `step/start`,再将其回执解析为已准入并附带轮次与步骤;后续消息继续等待。结束轮次的工具结果、广义取消、dispose(资源释放),以及已领取 idle-steering 消息却从未开启步骤的轮次,都会拒绝受影响的回执;`cancel(..., { keepInbox: true })` 和非终止型路由则保留待处理投递。空闲状态下的 `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))。
|
||||
|
||||
@@ -136,7 +136,7 @@ idle inject:
|
||||
|
||||
### Agent 句柄
|
||||
|
||||
`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()` 控制生命周期。需等待完成的资源释放负责拆卸。
|
||||
`ctx.agents` 拥有 agent,返回 `AgentHandle { agent, dispose() }`。插件使用 `send()` 或 `followup()`、带回执的 `steer()` 和 `inject()` 预设;[`reserveTurnAdmission()`](../packages/core/agent/README.md#agent-interface-typests) 为持久工作同步预留空闲状态,同时不改变排队提示词身份。需要确认请求准入时应等待 steering 回执;尽力执行的 UI steering 可以忽略它。`cancel()` 与 `whenIdle()` 控制生命周期。调用方、工厂和消费方通过同一个需等待完成的 disposer 共同拥有拆卸过程。
|
||||
|
||||
### Agent 作用域
|
||||
|
||||
|
||||
@@ -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:343`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:349`](../../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:274`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:280`](../../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:283`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:289`](../../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:457`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:463`](../../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:321`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:327`](../../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:333`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:339`](../../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:302`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:308`](../../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:311`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:317`](../../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:370`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:376`](../../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:396`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:402`](../../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:415`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:421`](../../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:356`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:362`](../../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:444`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:450`](../../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:292`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:298`](../../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:383`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:389`](../../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:430`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:436`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
## `agent-loop/*`
|
||||
|
||||
|
||||
@@ -1970,7 +1970,7 @@ startContinuable(spec: ContinuableStartSpec): ContinuableStart
|
||||
* Deliver one message to a known continuable child: steer its running
|
||||
* activation, or cold-resume the durable session into a fresh Task-backed
|
||||
* activation. The two routes are reported distinctly so timing-dependent
|
||||
* routing is observable. A throw means the message was NOT delivered — in
|
||||
* routing is observable. Rejection means the message was NOT delivered — in
|
||||
* particular, losing a race with Task settlement does not fall through to
|
||||
* cold resume within the same call; a later retry after Task terminal may
|
||||
* start the next activation. The started Task owns descriptor lookup and
|
||||
@@ -1984,7 +1984,7 @@ startContinuable(spec: ContinuableStartSpec): ContinuableStart
|
||||
* @param source - caller-supplied attribution retained across either route.
|
||||
* @returns whether the message `steered` the existing Task or `started` a new one.
|
||||
*/
|
||||
sendMessage(parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource): SendMessageResult
|
||||
async sendMessage( parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource, ): Promise<SendMessageResult>
|
||||
```
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [ContinuableStart](../core-data-structures/subagent.md) · [ContinuableStartSpec](../core-data-structures/subagent.md) · [MessageSource](../core-data-structures/core.md) · [SendMessageResult](../core-data-structures/subagent.md) · [SessionId](../core-data-structures/core.md)
|
||||
|
||||
@@ -560,6 +560,8 @@ interface CancelOptions {
|
||||
}
|
||||
```
|
||||
|
||||
`SteeringReceipt.outcome` always resolves. `admitted` identifies the turn and step whose immutable request history contains that exact message; `rejected` means lifecycle or terminal policy discarded it first. Synchronous input validation still throws from `steer()`.
|
||||
|
||||
```ts type-equiv
|
||||
/** Stable runtime cause accepted by {@link Agent.cancel}. */
|
||||
type AgentCancelCause =
|
||||
@@ -661,26 +663,18 @@ interface Agent {
|
||||
followup(message: UserMessage): void
|
||||
|
||||
/**
|
||||
* Submit steering during prompt admission or an open turn — the
|
||||
* `next-step`/wakeup preset of {@link send}. It stages for the next steering
|
||||
* checkpoint before a request or stop decision. If the activity fails before
|
||||
* that boundary, the remainder stays staged without waking the agent; retry
|
||||
* or a later prompt takes it. Outside that window steering falls back to a
|
||||
* woken follow-up turn, while cancellation or disposal may discard pending
|
||||
* steering.
|
||||
* Submit steering with a message-owned admission receipt — the
|
||||
* `next-step`/wakeup preset of {@link send}. During prompt admission or an
|
||||
* open turn, the message waits in the steering FIFO until a committed step
|
||||
* snapshots it; outside that window it enters the ordinary queued FIFO. The
|
||||
* receipt resolves `admitted` only after the message joins that step's
|
||||
* immutable request history, or `rejected` when terminal policy,
|
||||
* cancellation, or disposal discards it first. A non-terminal turn close may
|
||||
* leave it staged for a later admitted prompt without settling the receipt.
|
||||
* @param message - identified steering content and its producer provenance.
|
||||
* @returns the receipt for this exact message's eventual admission outcome.
|
||||
*/
|
||||
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
|
||||
steer(message: UserMessage): SteeringReceipt
|
||||
|
||||
/**
|
||||
* Append model-facing context without running the model — the
|
||||
|
||||
@@ -10,7 +10,7 @@ Sources: [`packages/subagent/subagent/src/types.ts`](../../packages/subagent/sub
|
||||
|
||||
## Two kinds of capability, discovered two ways
|
||||
|
||||
A provider advertises its **start-time** features on a static descriptor the service checks BEFORE a run exists; a request that needs one the provider lacks is rejected loud (`SubagentError('UNSUPPORTED_CAPABILITY')`), never accepted-then-ignored. **Runtime** features are instead optional methods whose presence IS the capability, with TS narrowing as the discovery mechanism: strict live steering is [`SubagentRun.steer`](#a-live-run-subagentrun) and persisted cold resume is [`SubagentProvider.resume`](#the-provider-seam-subagentprovider).
|
||||
A provider advertises its **start-time** features on a static descriptor the service checks BEFORE a run exists; a request that needs one the provider lacks is rejected loud (`SubagentError('UNSUPPORTED_CAPABILITY')`), never accepted-then-ignored. **Runtime** features are instead optional methods whose presence IS the capability, with TS narrowing as the discovery mechanism: confirmed live steering is [`SubagentRun.steer`](#a-live-run-subagentrun) and persisted cold resume is [`SubagentProvider.resume`](#the-provider-seam-subagentprovider).
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
@@ -18,7 +18,7 @@ A provider advertises its **start-time** features on a static descriptor the ser
|
||||
* {@link SubagentProvider.start}: a request that needs a capability the chosen provider lacks
|
||||
* is rejected with a typed error rather than accepted-then-ignored (the "fail loud, no silent
|
||||
* degradation" rule). These static flags cover features needed before a run exists; runtime
|
||||
* capabilities are optional methods whose presence is the capability — strict live steering
|
||||
* capabilities are optional methods whose presence is the capability — confirmed live steering
|
||||
* is {@link SubagentRun.steer} and persisted cold resume is {@link SubagentProvider.resume}. Each
|
||||
* flag corresponds one-to-one to a {@link SubagentStartRequest} option: `depthLimit` to
|
||||
* `maxDepth`; the other names match.
|
||||
@@ -214,7 +214,7 @@ interface SubagentStopReasonMap {
|
||||
|
||||
## A live run: `SubagentRun`
|
||||
|
||||
`SubagentRun` is the consumer-owned handle for a ready child — one disposable activation, never a durable child handle. Consumers await `result` and always dispose the run to reach quiescence. Child failures resolve with a non-completed stop reason; only unrepresentable infrastructure faults reject. A completed continuable result additionally means the provider confirmed the activation's final state durable; a failed required checkpoint rejects. The optional strict `steer` method advertises live delivery by presence. Cold resume is a provider-level operation: `SubagentProvider.resume` reconstructs a fresh run from the child's persisted session because the process-local run ceases to exist after disposal or process restart.
|
||||
`SubagentRun` is the consumer-owned handle for a ready child — one disposable activation, never a durable child handle. Consumers await `result` and always dispose the run to reach quiescence. Child failures resolve with a non-completed stop reason; only unrepresentable infrastructure faults reject. A completed continuable result additionally means the provider confirmed the activation's final state durable; a failed required checkpoint rejects. The optional confirmed `steer` method advertises live delivery by presence and fulfills only after a request snapshot admits the message. Cold resume is a provider-level operation: `SubagentProvider.resume` reconstructs a fresh run from the child's persisted session because the process-local run ceases to exist after disposal or process restart.
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
@@ -251,19 +251,16 @@ interface SubagentRun {
|
||||
*/
|
||||
dispose(): Promise<void>
|
||||
/**
|
||||
* OPTIONAL (strict live-steering capability): deliver additional content to
|
||||
* the actively running child turn. STRICT means delivery joins the observed
|
||||
* turn or fails — the implementation must synchronously verify, with no
|
||||
* asynchronous boundary before delivery, that the child is running and its
|
||||
* turn can still record the message, and must not fall back to a queue path
|
||||
* that could start a new, untracked turn or silently drop the message after
|
||||
* this run has settled. Throws when delivery cannot join the turn. A run
|
||||
* represents one disposable activation, so it has no cold-resume operation;
|
||||
* resuming a settled child goes through {@link SubagentProvider.resume}.
|
||||
* `source` is retained on the child's logged steering message without
|
||||
* changing its user role in model history.
|
||||
* OPTIONAL (confirmed live-steering capability): submit additional content
|
||||
* to the active child and fulfill only after a committed request snapshot
|
||||
* admits it. Rejects when terminal policy, cancellation, disposal, or a lost
|
||||
* settlement race prevents admission; it never falls through to a queued
|
||||
* untracked turn or cold resume. A run represents one disposable activation,
|
||||
* so resuming a settled child goes through {@link SubagentProvider.resume}.
|
||||
* `source` is retained on the admitted steering message without changing its
|
||||
* user role in model history.
|
||||
*/
|
||||
steer?(content: ContentBlock[], source: MessageSource): void
|
||||
steer?(content: ContentBlock[], source: MessageSource): Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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: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) |
|
||||
| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:349`](../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:280`](../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:289`](../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:463`](../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:327`](../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:339`](../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:308`](../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:317`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy` |
|
||||
| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:376`](../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:402`](../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:421`](../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:362`](../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:450`](../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:298`](../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:389`](../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:436`](../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
@@ -44,7 +44,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent {
|
||||
acceptsNextStep: true,
|
||||
ctx: new Context(),
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
|
||||
inject(input) {
|
||||
session.append('user/message', input, { surfaceOp: 'append' })
|
||||
},
|
||||
|
||||
@@ -100,7 +100,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent {
|
||||
acceptsNextStep: true,
|
||||
ctx: new Context(),
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
|
||||
updateInbox: () => 'not-found',
|
||||
inject(input) {
|
||||
session.append('user/message', input, { surfaceOp: 'append' })
|
||||
|
||||
@@ -179,7 +179,7 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent {
|
||||
status: 'idle',
|
||||
acceptsNextStep: false,
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
|
||||
inject(input) {
|
||||
session.append('user/message', input, { surfaceOp: 'append' })
|
||||
},
|
||||
|
||||
@@ -889,8 +889,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
jsDoc: '/**\n * Start a continuable background child: allocate its stable session id,\n * snapshot its durable descriptor, and register the initial activation\'s\n * Task. A synchronous validation failure (a non-JSON descriptor input,\n * missing persistence, Task preflight) throws without creating a Task; the\n * method otherwise returns both identities immediately, without waiting for\n * child publication or descriptor durability. Asynchronous startup failure\n * settles the returned Task as `failed` (or `killed` when cancelled) after\n * any published run is disposed, which can leave an unmaterialized child id\n * that later by-id operations report as unavailable.\n * @param spec - provider, Task label, and the delegation request.\n * @returns the stable child id and the initial activation\'s Task id.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'sendMessage(parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource): SendMessageResult',
|
||||
jsDoc: '/**\n * Deliver one message to a known continuable child: steer its running\n * activation, or cold-resume the durable session into a fresh Task-backed\n * activation. The two routes are reported distinctly so timing-dependent\n * routing is observable. A throw means the message was NOT delivered — in\n * particular, losing a race with Task settlement does not fall through to\n * cold resume within the same call; a later retry after Task terminal may\n * start the next activation. The started Task owns descriptor lookup and\n * direct-parent authorization (its AbortSignal exists before that lookup),\n * so an unknown, foreign, or descriptor-less child settles the started Task\n * as `failed` with a detail reporting the id as unavailable.\n * @param parent - the live parent agent sending the message (model tool or\n * human adapter); Task access is authorized by its session id.\n * @param childId - the stable child session id.\n * @param message - the user-role content to deliver.\n * @param source - caller-supplied attribution retained across either route.\n * @returns whether the message `steered` the existing Task or `started` a new one.\n */',
|
||||
signature: 'async sendMessage( parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource, ): Promise<SendMessageResult>',
|
||||
jsDoc: '/**\n * Deliver one message to a known continuable child: steer its running\n * activation, or cold-resume the durable session into a fresh Task-backed\n * activation. The two routes are reported distinctly so timing-dependent\n * routing is observable. Rejection means the message was NOT delivered — in\n * particular, losing a race with Task settlement does not fall through to\n * cold resume within the same call; a later retry after Task terminal may\n * start the next activation. The started Task owns descriptor lookup and\n * direct-parent authorization (its AbortSignal exists before that lookup),\n * so an unknown, foreign, or descriptor-less child settles the started Task\n * as `failed` with a detail reporting the id as unavailable.\n * @param parent - the live parent agent sending the message (model tool or\n * human adapter); Task access is authorized by its session id.\n * @param childId - the stable child session id.\n * @param message - the user-role content to deliver.\n * @param source - caller-supplied attribution retained across either route.\n * @returns whether the message `steered` the existing Task or `started` a new one.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -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 trySteer?(message: UserMessage): boolean;\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): SteeringReceipt;\n inject(message: UserMessage): void;\n}',
|
||||
},
|
||||
{
|
||||
name: 'AgentCancelCause',
|
||||
@@ -2669,6 +2669,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'SpillSource',
|
||||
declaration: 'export interface SpillSource {\n toolName: string;\n callId: CallId;\n label: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SteeringOutcome',
|
||||
declaration: 'export type SteeringOutcome = {\n readonly status: \'admitted\';\n readonly turn: number;\n readonly step: number;\n} | {\n readonly status: \'rejected\';\n};',
|
||||
},
|
||||
{
|
||||
name: 'SteeringReceipt',
|
||||
declaration: 'export interface SteeringReceipt {\n readonly outcome: Promise<SteeringOutcome>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'StorageForms',
|
||||
declaration: 'export interface StorageForms {\n}',
|
||||
@@ -2703,7 +2711,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'SubagentRun',
|
||||
declaration: 'export interface SubagentRun {\n readonly id: SessionId;\n readonly localAgent: Agent | undefined;\n readonly result: Promise<SubagentResult>;\n dispose(): Promise<void>;\n steer?(content: ContentBlock[], source: MessageSource): void;\n}',
|
||||
declaration: 'export interface SubagentRun {\n readonly id: SessionId;\n readonly localAgent: Agent | undefined;\n readonly result: Promise<SubagentResult>;\n dispose(): Promise<void>;\n steer?(content: ContentBlock[], source: MessageSource): Promise<void>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SubagentStartRequest',
|
||||
|
||||
@@ -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 packages/core/agent-loop/README.md
|
||||
README.md: 79d2865073c89bd88a4d39fafacb5cf60f1fc10c
|
||||
README.zh.md: 48c4f4900d25f524942abf53c1bc887e7d125cb3
|
||||
README.md: 1662b1076cc116888d048cb6af1be1c7ab8196f6
|
||||
README.zh.md: 2fca32a02fdd73961c912c988933e1cd1a1a5817
|
||||
@@ -57,6 +57,8 @@ The concrete `ReactLoopAgent`, its queued input, outbox, and run controls are pa
|
||||
|
||||
The unified `send()` primitive routes content and source by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. A `next-turn` item joins the queued FIFO, waking the driver unless `wakeup: false`; admission happens before any turn opens. `reserveTurnAdmission()` can synchronously hold that idle boundary for a standalone durable operation: accepted waking work has right of way, later sends keep their ordinary queue identity and FIFO position, release re-arms the same driver path, and `whenIdle()` waits for the reservation without making teardown await it. The loop opens a private next-step acceptance window before `agent/prompt-submit` and closes it before `turn/end`. During that window, `steer()` and `inject()` stage in one outbox; an allowed admission opens the turn, records the prompt and returned `additionalContexts`, then drains the staged input before the first request. A blocked or failed admission writes no prompt or hook-produced context. A caller-staged context-only batch then takes idle injection's immediate append, while steering and context staged beside it remain pending for retry or a later admitted prompt. Outside the window, steering becomes a waking queued prompt and injection immediately appends `user/message` without opening a turn or running the model.
|
||||
|
||||
`steer()` attaches a one-shot admission receipt to its exact accepted message. After `agent/step` and asynchronous prompt assembly succeed, the loop commits a stable pending batch as `steering/message`, snapshots derived history, and opens `step/start`; only then does each receipt resolve `admitted` with that turn and step. Later arrivals remain pending. Idle steering enters the ordinary FIFO and uses the first request of its eventual turn as the same admission boundary. A turn-concluding tool result, broad cancellation, disposal, or a claimed idle-steering turn that never reaches a request resolves affected receipts `rejected`; `cancel(..., { keepInbox: true })` and non-terminal routing preserve pending delivery. Open-turn `inject()` still commits after all tool results, including accepted context finalized during an interrupted batch, while steering remains provisional until a request admits it.
|
||||
|
||||
Every FIFO acceptance mints an `InboxItemId` and publishes `agent/inbox/enqueue` with the complete occurrence. `updateInbox()` owns the synchronous queued-item boundary: edit freezes replacement content without changing message identity or position, while remove publishes discard. Edit publishes `agent/inbox/update`; steering and claimed occurrences return `not-found`. Claim publishes `agent/inbox/dequeue` and irrevocably removes the live address before prompt admission, so a racing update cannot rewrite durable history; `cancel()` without `keepInbox` publishes `agent/inbox/discard`.
|
||||
|
||||
### Loop lifecycle (`agent.ts`)
|
||||
|
||||
@@ -57,6 +57,8 @@ interface Config {
|
||||
|
||||
统一的 `send()` 原语按(`target` × `wakeup`)路由内容与来源;`followup`/`steer`/`inject` 是它的固定预设别名。`next-turn` 项加入排队 FIFO,除非 `wakeup: false`,否则会唤醒驱动器;接纳发生在任何轮次开启之前。`reserveTurnAdmission()` 可以为独立持久操作同步保留该空闲边界:已获接纳的唤醒工作拥有优先权,之后发送的项保留普通队列身份与 FIFO 位置,释放会重新启用同一驱动器路径,`whenIdle()` 会等待预留结束,但 teardown 不会等待它。循环在 `agent/prompt-submit` 之前打开一个私有的 next-step 接收窗口,并在 `turn/end` 之前关闭它。在该窗口内,`steer()` 与 `inject()` 会暂存到同一个 outbox;接纳获准后会开启轮次,记录提示词及其返回的 `additionalContexts`,再于首次请求前排空暂存输入。接纳被阻止或失败时,不会写入提示词或钩子生成的上下文。之后,仅含调用方暂存上下文的批次会采用空闲注入的立即追加行为,而 steering(中途引导)及与其一同暂存的上下文则继续待处理,以供重试或之后获准的提示词使用。窗口之外,steering 会成为唤醒驱动器的排队提示词,而注入会立即追加 `user/message`,不开启轮次也不运行模型。
|
||||
|
||||
`steer()` 会把一次性准入回执附着到其准确的已接收消息。`agent/step` 和异步提示词组装成功后,循环把稳定的待处理批次提交为 `steering/message`、捕获派生历史并开启 `step/start`;只有此时,每个回执才会解析为 `admitted`,并附带轮次与步骤。之后到达的消息继续待处理。空闲 steering 会进入普通 FIFO,并以其最终轮次的首次请求作为相同准入边界。结束轮次的工具结果、广义取消、dispose(资源释放),或已领取 idle-steering 消息却从未到达请求的轮次,会把受影响回执解析为 `rejected`;`cancel(..., { keepInbox: true })` 和非终止型路由会保留待处理投递。活跃轮次内的 `inject()` 仍会在所有工具结果后提交,包括被中断批次中已最终确认的上下文;steering 则保持待准入,直到请求接纳它。
|
||||
|
||||
每次 FIFO 接受项时都会铸造一个 `InboxItemId`,并通过 `agent/inbox/enqueue` 发布完整的单次入队项。`updateInbox()` 持有同步 queued 项边界:编辑会冻结替换内容,但不改变消息标识或位置;移除会发布 discard。编辑会发布 `agent/inbox/update`;steering 项和已被认领的项会返回 `not-found`。认领操作会发布 `agent/inbox/dequeue`,并在提示词接纳前不可逆地移除实时寻址标识,因此竞态中的更新无法改写持久历史;`cancel()` 在不带 `keepInbox` 时会发布 `agent/inbox/discard`。
|
||||
|
||||
### 循环生命周期(`agent.ts`)
|
||||
|
||||
@@ -29,6 +29,8 @@ import type {
|
||||
RequestError,
|
||||
RequestErrorAction,
|
||||
SendOptions,
|
||||
SteeringOutcome,
|
||||
SteeringReceipt,
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import {
|
||||
BlockAssembler,
|
||||
@@ -56,6 +58,26 @@ type StepOutcome =
|
||||
| { kind: 'completed'; continueTurn: boolean; concluded: boolean; maxTokens: boolean }
|
||||
| { kind: 'request-failed'; error: RequestError; failure: LlmFailure; retryPolicy: ResolvedRetryPolicy | undefined }
|
||||
|
||||
/** Internal one-shot controller paired with a public steering receipt. */
|
||||
interface SteeringDelivery {
|
||||
readonly receipt: SteeringReceipt
|
||||
settle(outcome: SteeringOutcome): void
|
||||
}
|
||||
|
||||
/** Create one idempotent steering-admission controller. */
|
||||
function createSteeringDelivery(): SteeringDelivery {
|
||||
const { promise, resolve } = Promise.withResolvers<SteeringOutcome>()
|
||||
let settled = false
|
||||
return {
|
||||
receipt: { outcome: promise },
|
||||
settle(outcome): void {
|
||||
if (settled) return
|
||||
settled = true
|
||||
resolve(outcome)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const RUNTIME_CONTEXT_SOURCE = '@deepseek-ai/dsh-system-prompt'
|
||||
/** Clearing marker kept distinct from every prefixed {@link renderContextSnapshot} result. */
|
||||
const CLEARED_RUNTIME_CONTEXT = 'Current runtime context: none. Earlier runtime-context snapshots no longer apply.'
|
||||
@@ -112,9 +134,13 @@ function requestProposal(header: EpochHeader): LlmCallConfig {
|
||||
*/
|
||||
export class ReactLoopAgent implements Agent {
|
||||
/** Prompts awaiting individual turns. */
|
||||
private queued: { item: InboxItem; wakeup: boolean }[] = []
|
||||
private queued: { item: InboxItem; wakeup: boolean; delivery?: SteeringDelivery }[] = []
|
||||
/** Input taken into the session log at step boundaries. */
|
||||
private outbox: { message: UserMessage; steering: boolean; item?: InboxItem }[] = []
|
||||
private outbox: { message: UserMessage; steering: boolean; item?: InboxItem; delivery?: SteeringDelivery }[] = []
|
||||
/** Steering already committed to the log but not yet captured by a request. */
|
||||
private pendingAdmissions: SteeringDelivery[] = []
|
||||
/** Whether the active cancellation preserves already committed pending delivery. */
|
||||
private preservePendingAdmissionsOnAbort = false
|
||||
|
||||
/** Whether observers see a running interval; consecutive turns share it. */
|
||||
private busy = false
|
||||
@@ -142,8 +168,6 @@ 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
|
||||
|
||||
@@ -167,6 +191,15 @@ export class ReactLoopAgent implements Agent {
|
||||
send(
|
||||
message: UserMessage,
|
||||
options: SendOptions,
|
||||
): void {
|
||||
this.route(message, options)
|
||||
}
|
||||
|
||||
/** Route one accepted message, optionally tracking steering admission. */
|
||||
private route(
|
||||
message: UserMessage,
|
||||
options: SendOptions,
|
||||
delivery?: SteeringDelivery,
|
||||
): void {
|
||||
const { target, wakeup } = options
|
||||
if (target === 'next-step' && !wakeup) {
|
||||
@@ -185,9 +218,9 @@ export class ReactLoopAgent implements Agent {
|
||||
placement,
|
||||
})
|
||||
if (placement === 'steering') {
|
||||
this.outbox.push({ message, steering: true, item })
|
||||
this.outbox.push({ message, steering: true, item, ...delivery === undefined ? {} : { delivery } })
|
||||
} else {
|
||||
this.queued.push({ item, wakeup })
|
||||
this.queued.push({ item, wakeup, ...delivery === undefined ? {} : { delivery } })
|
||||
}
|
||||
// Preserve the routing decision for every send in this synchronous caller
|
||||
// stack, while installing quiescence ownership before enqueue observers
|
||||
@@ -218,6 +251,7 @@ export class ReactLoopAgent implements Agent {
|
||||
}
|
||||
case 'remove': {
|
||||
this.queued.splice(queuedIndex, 1)
|
||||
pending.delivery?.settle({ status: 'rejected' })
|
||||
emitAgentEvent(this.loopCtx, this, 'agent/inbox/discard', [pending.item])
|
||||
return 'applied'
|
||||
}
|
||||
@@ -235,22 +269,14 @@ export class ReactLoopAgent implements Agent {
|
||||
})
|
||||
}
|
||||
|
||||
/** Steer the open turn, falling back to a waking prompt while idle. */
|
||||
steer(input: UserMessage): void {
|
||||
this.send(input, {
|
||||
/** Steer the open turn, falling back to a tracked waking prompt while idle. */
|
||||
steer(input: UserMessage): SteeringReceipt {
|
||||
const delivery = createSteeringDelivery()
|
||||
this.route(input, {
|
||||
target: 'next-step',
|
||||
wakeup: true,
|
||||
})
|
||||
}
|
||||
|
||||
/** 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
|
||||
}, delivery)
|
||||
return delivery.receipt
|
||||
}
|
||||
|
||||
/** Append model-facing context without waking the driver. */
|
||||
@@ -304,11 +330,17 @@ export class ReactLoopAgent implements Agent {
|
||||
// inboxes clear; listener failures are contained by the dispatcher.
|
||||
if (cause.kind !== 'disposed') emitAgentEvent(this.loopCtx, this, 'agent/cancel-requested', cause)
|
||||
}
|
||||
if (options.keepInbox && this.abort !== undefined) this.preservePendingAdmissionsOnAbort = true
|
||||
if (!options.keepInbox) {
|
||||
const discarded = this.queued.map(item => item.item)
|
||||
for (const item of this.queued) item.delivery?.settle({ status: 'rejected' })
|
||||
for (const item of this.outbox) {
|
||||
if (item.steering && item.item !== undefined) discarded.push(item.item)
|
||||
if (item.steering && item.item !== undefined) {
|
||||
item.delivery?.settle({ status: 'rejected' })
|
||||
discarded.push(item.item)
|
||||
}
|
||||
}
|
||||
this.rejectPendingAdmissions()
|
||||
// Clear before abort observers run: replacement work belongs to the next turn.
|
||||
this.queued.length = 0
|
||||
this.outbox.length = 0
|
||||
@@ -373,7 +405,8 @@ export class ReactLoopAgent implements Agent {
|
||||
// The some() guard above proves the queue is non-empty; the non-null
|
||||
// assertion expresses that invariant.
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion
|
||||
const { item } = this.queued.shift()!
|
||||
const pending = this.queued.shift()!
|
||||
const { item, delivery } = pending
|
||||
const { message } = item
|
||||
const inheritedOutboxLength = this.outbox.length
|
||||
|
||||
@@ -423,6 +456,7 @@ export class ReactLoopAgent implements Agent {
|
||||
// still owns the slot here and releasing it unconditionally is exact.
|
||||
this.abort = undefined
|
||||
if (admitted === undefined) {
|
||||
delivery?.settle({ status: 'rejected' })
|
||||
this.acceptsNextStep = false
|
||||
try {
|
||||
this.flushRejectedAdmissionContexts()
|
||||
@@ -440,7 +474,7 @@ export class ReactLoopAgent implements Agent {
|
||||
this.continueOrIdle()
|
||||
return
|
||||
}
|
||||
await this.run(trigger, admitted, inheritedOutboxLength)
|
||||
await this.run(trigger, admitted, inheritedOutboxLength, Object.freeze([]), delivery)
|
||||
})
|
||||
// Published only after the abort owner and pending done are installed: a
|
||||
// dequeue listener that cancels or disposes must find live cancellation
|
||||
@@ -457,6 +491,7 @@ export class ReactLoopAgent implements Agent {
|
||||
admitted: UserMessage[] = [],
|
||||
inheritedOutboxLength = 0,
|
||||
priorFailures: readonly LlmFailure[] = Object.freeze([]),
|
||||
promptDelivery?: SteeringDelivery,
|
||||
): Promise<void> {
|
||||
// Both entries hold the invariant: kick() clears the admission slot before
|
||||
// awaiting run(), and a retry is entered only after the prior run clears it.
|
||||
@@ -464,6 +499,7 @@ export class ReactLoopAgent implements Agent {
|
||||
if (this.abort !== undefined) throw new Error(`agent "${this.id}" is already running`)
|
||||
const controller = new AbortController()
|
||||
this.abort = controller
|
||||
this.preservePendingAdmissionsOnAbort = false
|
||||
this.acceptsNextStep = true
|
||||
const signal = controller.signal
|
||||
const turn = this.lastTurn + 1
|
||||
@@ -487,13 +523,12 @@ export class ReactLoopAgent implements Agent {
|
||||
// Context or steering retained by an earlier rejected admission happened
|
||||
// before this prompt and must occupy the same order in durable history.
|
||||
this.drainOutbox(turn, inheritedOutboxLength)
|
||||
if (promptDelivery !== undefined) this.pendingAdmissions.push(promptDelivery)
|
||||
for (const input of admitted) {
|
||||
this.session.append('user/message', input, { surfaceOp: 'append' })
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
|
||||
this.drainOutbox(turn)
|
||||
|
||||
steps: while (true) {
|
||||
step += 1
|
||||
const outcome = await this.step(turn, step, signal)
|
||||
@@ -501,17 +536,19 @@ export class ReactLoopAgent implements Agent {
|
||||
case 'completed':
|
||||
requestFailureHistory = Object.freeze([])
|
||||
if (outcome.maxTokens) reason = { kind: 'max-tokens' }
|
||||
// A concluding tool result is terminal: steering already in the
|
||||
// log waits for the next turn's request instead of reopening this
|
||||
// one, and the agent/turn-stopping drain below is skipped for the same
|
||||
// reason.
|
||||
if (outcome.concluded) break steps
|
||||
// A concluding tool result is terminal: reject steering that did
|
||||
// not enter a request, while retaining same-boundary context in
|
||||
// durable history before the turn closes.
|
||||
if (outcome.concluded) {
|
||||
this.discardOutboxSteering()
|
||||
this.drainOutbox(turn)
|
||||
break steps
|
||||
}
|
||||
if (outcome.continueTurn || this.outbox.some(item => item.steering)) continue
|
||||
break
|
||||
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) {
|
||||
@@ -542,12 +579,14 @@ export class ReactLoopAgent implements Agent {
|
||||
}
|
||||
await this.loopCtx.serial(agentCarrier(this), 'agent/turn-stopping', this, turn, signal)
|
||||
signal.throwIfAborted()
|
||||
if (!this.drainOutbox(turn)) break
|
||||
this.drainOutboxContexts()
|
||||
if (!this.outbox.some(item => item.steering)) {
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (caught: unknown) {
|
||||
try {
|
||||
if (this.stepOpen) {
|
||||
this.strictSteeringOpen = false
|
||||
this.stepOpen = false
|
||||
this.session.append('step/end', { turn, step })
|
||||
}
|
||||
@@ -565,7 +604,6 @@ 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.
|
||||
@@ -582,6 +620,10 @@ export class ReactLoopAgent implements Agent {
|
||||
// is still this run's controller here.
|
||||
this.abort = undefined
|
||||
signal.removeEventListener('abort', cancelRetry)
|
||||
const preservePending = signal.aborted && this.preservePendingAdmissionsOnAbort
|
||||
this.preservePendingAdmissionsOnAbort = false
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-condition -- keepInbox cancellation can set this while turn work is awaited.
|
||||
if (!preservePending) this.rejectPendingAdmissions()
|
||||
}
|
||||
|
||||
if (opened) {
|
||||
@@ -620,10 +662,6 @@ export class ReactLoopAgent implements Agent {
|
||||
await this.loopCtx.serial(agentCarrier(this), 'agent/step', this, turn, step, signal)
|
||||
signal.throwIfAborted()
|
||||
|
||||
// Take the outbox whole — same-boundary steering and context leave in
|
||||
// this request together.
|
||||
this.drainOutbox(turn)
|
||||
|
||||
// Assemble request-owned prompt inputs fresh each step. Dynamic context is
|
||||
// committed at the tail before deriving history once, preserving the stable
|
||||
// system/history cache prefix while keeping every model-visible byte logged.
|
||||
@@ -632,13 +670,18 @@ export class ReactLoopAgent implements Agent {
|
||||
const system = renderPrompt(assembly)
|
||||
materializeRuntimeContext(session, renderContextSnapshot(assembly))
|
||||
|
||||
// Commit the exact pending batch only after every asynchronous
|
||||
// pre-request contribution succeeded. Input accepted after this splice
|
||||
// remains pending for a later request.
|
||||
this.drainOutbox(turn)
|
||||
|
||||
// Snapshot the exact log prefix: the reconstruction boundary. Appends
|
||||
// after this synchronous snapshot join the next request.
|
||||
const boundaryMessages = session.deriveMessages()
|
||||
|
||||
session.append('step/start', { turn, step })
|
||||
this.stepOpen = true
|
||||
this.strictSteeringOpen = true
|
||||
this.admitPendingAdmissions(turn, step)
|
||||
signal.throwIfAborted()
|
||||
|
||||
const { request, preparedCall } = await this.buildRequest(
|
||||
@@ -705,15 +748,14 @@ 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)
|
||||
// Ordinary context keeps the base loop's result-adjacent commit point.
|
||||
// Steering remains provisional until the next request snapshot admits it.
|
||||
this.drainOutboxContexts()
|
||||
session.append('step/end', { turn, step })
|
||||
this.stepOpen = false
|
||||
return {
|
||||
kind: 'completed',
|
||||
continueTurn: (toolCalls.length > 0 && !concluded) || steered,
|
||||
continueTurn: (toolCalls.length > 0 && !concluded) || this.outbox.some(item => item.steering),
|
||||
concluded,
|
||||
maxTokens: finish.kind === 'max-tokens',
|
||||
}
|
||||
@@ -818,25 +860,83 @@ export class ReactLoopAgent implements Agent {
|
||||
return { request, ...preparedCall === undefined ? {} : { preparedCall } }
|
||||
}
|
||||
|
||||
/** Commit the outbox and report whether it contained steering. */
|
||||
private drainOutbox(turn: number, limit = this.outbox.length): boolean {
|
||||
let steered = false
|
||||
for (const item of this.outbox.splice(0, limit)) {
|
||||
if (item.steering) {
|
||||
steered = true
|
||||
/* v8 ignore next -- only inbox-backed steer entries carry steering:true. */
|
||||
if (item.item === undefined) throw new Error(`agent "${this.id}" steering outbox item has no inbox identity`)
|
||||
emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', item.item)
|
||||
this.session.append(
|
||||
'steering/message',
|
||||
{ turn, message: item.message },
|
||||
{ surfaceOp: 'append' },
|
||||
)
|
||||
} else {
|
||||
this.session.append('user/message', item.message, { surfaceOp: 'append' })
|
||||
/** Commit one stable outbox prefix and retain tracked delivery until snapshot admission. */
|
||||
private drainOutbox(turn: number, limit = this.outbox.length): void {
|
||||
const batch = this.outbox.splice(0, limit)
|
||||
for (let index = 0; index < batch.length; index += 1) {
|
||||
const item = batch[index]
|
||||
/* v8 ignore next -- the index walks the exact array length. */
|
||||
if (item === undefined) throw new Error(`agent "${this.id}" outbox item disappeared during drain`)
|
||||
try {
|
||||
if (item.steering) {
|
||||
/* v8 ignore next -- only inbox-backed steer entries carry steering:true. */
|
||||
if (item.item === undefined) throw new Error(`agent "${this.id}" steering outbox item has no inbox identity`)
|
||||
emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', item.item)
|
||||
this.session.append(
|
||||
'steering/message',
|
||||
{ turn, message: item.message },
|
||||
{ surfaceOp: 'append' },
|
||||
)
|
||||
if (item.delivery !== undefined) this.pendingAdmissions.push(item.delivery)
|
||||
} else {
|
||||
this.session.append('user/message', item.message, { surfaceOp: 'append' })
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
item.delivery?.settle({ status: 'rejected' })
|
||||
this.outbox.unshift(...batch.slice(item.steering ? index + 1 : index))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return steered
|
||||
}
|
||||
|
||||
/** Commit ordinary context while retaining provisional steering in order. */
|
||||
private drainOutboxContexts(): void {
|
||||
const pending = this.outbox
|
||||
this.outbox = []
|
||||
for (let index = 0; index < pending.length; index += 1) {
|
||||
const item = pending[index]
|
||||
/* v8 ignore next -- the index walks the exact array length. */
|
||||
if (item === undefined) throw new Error(`agent "${this.id}" outbox item disappeared during context drain`)
|
||||
if (item.steering) {
|
||||
this.outbox.push(item)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
this.session.append('user/message', item.message, { surfaceOp: 'append' })
|
||||
} catch (error: unknown) {
|
||||
this.outbox.push(...pending.slice(index))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Settle every committed steering item captured by this immutable request. */
|
||||
private admitPendingAdmissions(turn: number, step: number): void {
|
||||
const outcome: SteeringOutcome = { status: 'admitted', turn, step }
|
||||
for (const delivery of this.pendingAdmissions.splice(0)) delivery.settle(outcome)
|
||||
}
|
||||
|
||||
/** Reject committed steering that left the inbox without reaching a request. */
|
||||
private rejectPendingAdmissions(): void {
|
||||
for (const delivery of this.pendingAdmissions.splice(0)) delivery.settle({ status: 'rejected' })
|
||||
}
|
||||
|
||||
/** Discard uncommitted steering while retaining same-boundary injected context. */
|
||||
private discardOutboxSteering(): void {
|
||||
const contexts: typeof this.outbox = []
|
||||
const discarded: InboxItem[] = []
|
||||
for (const item of this.outbox) {
|
||||
if (!item.steering) {
|
||||
contexts.push(item)
|
||||
continue
|
||||
}
|
||||
item.delivery?.settle({ status: 'rejected' })
|
||||
/* v8 ignore next -- only inbox-backed steer entries carry steering:true. */
|
||||
if (item.item === undefined) throw new Error(`agent "${this.id}" steering outbox item has no inbox identity`)
|
||||
discarded.push(item.item)
|
||||
}
|
||||
this.outbox = contexts
|
||||
if (discarded.length > 0) emitAgentEvent(this.loopCtx, this, 'agent/inbox/discard', discarded)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -50,10 +50,11 @@ describe('Agent', () => {
|
||||
}])).toBeUndefined()
|
||||
expect(call('inject', [message('context')])).toBeUndefined()
|
||||
expect(call('followup', [message('followup')])).toBeUndefined()
|
||||
expect(call('steer', [message('steering')])).toBeUndefined()
|
||||
const receipt = agent.steer(message('steering'))
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(adapter.requests).toHaveLength(3)
|
||||
expect(await receipt.outcome).toEqual({ status: 'admitted', turn: 3, step: 1 })
|
||||
})
|
||||
|
||||
it('idle inject() appends context without opening a turn or requesting a flush', async () => {
|
||||
|
||||
@@ -721,13 +721,14 @@ describe('agent loop', () => {
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
let receipt: ReturnType<Agent['steer']> | undefined
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'finalize',
|
||||
description: '',
|
||||
parameters: {},
|
||||
async execute(_args, exec) {
|
||||
// Steering lands while the concluding tool is still executing.
|
||||
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'late steering' }], source: { kind: 'user' } }))
|
||||
receipt = agent.steer(createUserMessage({ content: [{ type: 'text', text: 'late steering' }], source: { kind: 'user' } }))
|
||||
exec.concludeTurn()
|
||||
return [{ type: 'text', text: 'final' }]
|
||||
},
|
||||
@@ -740,9 +741,9 @@ describe('agent loop', () => {
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
const events = agent.session.events.map(event => event.type)
|
||||
expect(events.filter(type => type === 'turn/end')).toHaveLength(1)
|
||||
// The steering is durable inside the concluded turn and feeds the NEXT
|
||||
// turn's request instead of being dropped or re-queued.
|
||||
expect(events).toContain('steering/message')
|
||||
if (receipt === undefined) throw new Error('concluding tool did not submit steering')
|
||||
expect(await receipt.outcome).toEqual({ status: 'rejected' })
|
||||
expect(events).not.toContain('steering/message')
|
||||
|
||||
send(agent, 'follow up')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -751,7 +752,7 @@ describe('agent loop', () => {
|
||||
.flatMap(message => message.content)
|
||||
.filter(block => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
expect(texts).toContain('late steering')
|
||||
expect(texts).not.toContain('late steering')
|
||||
})
|
||||
|
||||
it('agent/request waterfall switches models by returning a replacement config; the switch is logged', async () => {
|
||||
|
||||
@@ -64,8 +64,7 @@ The handle every plugin programs against:
|
||||
- `agent.reserveTurnAdmission()` — synchronously reserve the idle boundary before any queued waking prompt can claim its turn. An accepted prompt, including a same-tick pending wake, has right of way and makes reservation return `undefined`. Later sends keep their ordinary IDs, FIFO placement, and wakeup facts while held; `acceptsNextStep` remains false, `inject()` is not withheld, `whenIdle()` counts the reservation as activity, and the returned release is idempotent. This narrow coordination capability lets standalone durable operations such as manual compaction finish and flush before queued prompts derive from the session.
|
||||
- `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.steer(input)` — the `next-step`/wakeup preset: submit one identified message and receive its `SteeringReceipt`. During prompt admission or an open turn, the message stages for the next safe request boundary without dispatching `agent/prompt-submit`; outside that acceptance window, it becomes a woken queued prompt. `receipt.outcome` resolves `admitted` with the turn and step only after the loop logs the message, captures it in immutable request history, and commits `step/start`. A turn-concluding tool result, broad cancellation, disposal, or pre-admission failure resolves it `rejected`; `cancel(..., { keepInbox: true })` and non-terminal routing preserve pending delivery. Reliable callers await the receipt, while best-effort UI steering may ignore it.
|
||||
- `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`.
|
||||
|
||||
@@ -64,7 +64,7 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供,
|
||||
- `agent.reserveTurnAdmission()`:在任何已排队唤醒提示词认领其轮次之前,同步预留空闲边界。已获接纳的提示词拥有优先权,包括同一 tick 内仍在等待唤醒的项,此时预留返回 `undefined`。预留期间,之后发送的项保留其普通 ID、FIFO 位置与唤醒信息;`acceptsNextStep` 保持 false,`inject()` 不受阻塞,`whenIdle()` 将该预留计为活动,返回的释放函数可幂等调用。这项范围有限的协调能力使手动压缩(compaction)等独立持久操作能够在排队提示词从会话派生内容前完成并 flush。
|
||||
- `agent.updateInbox(itemId, action)`:同步编辑或移除一个仍处于待处理状态的 queued 入队项。编辑会替换已冻结的内容,同时保留其 `MessageId`、`InboxItemId`、来源与 FIFO 位置;移除会发出该项的终态 discard。steering 项和已被认领的项会返回 `not-found`。
|
||||
- `agent.followup(input)`:`send()` 的 `next-turn`/wakeup 预设:排队一个普通后续轮次并唤醒驱动器。
|
||||
- `agent.steer(input)`:`next-step`/wakeup 预设:提示词接纳期间或轮次打开时,为下一个安全边界暂存 steering,且不分发 `agent/prompt-submit`;该接收窗口之外则委托给会唤醒的后续轮次。接纳失败会保留暂存的 steering,以供重试或之后获准的提示词使用,而取消或 dispose 可能丢弃它。
|
||||
- `agent.steer(input)`:`next-step`/wakeup 预设:提交一条已有标识的消息,并取得其 `SteeringReceipt`。提示词接纳期间或轮次打开时,消息会为下一个安全请求边界暂存,且不分发 `agent/prompt-submit`;该接收窗口之外则成为会唤醒驱动器的排队提示词。只有循环记录消息、将其捕获到不可变请求历史并提交 `step/start` 后,`receipt.outcome` 才会解析为 `admitted`,并附带轮次与步骤。结束轮次的工具结果、广义取消、dispose(资源释放)或准入前故障会使其解析为 `rejected`;`cancel(..., { keepInbox: true })` 和非终止型路由会保留待处理投递。需要可靠投递的调用方应等待回执;尽力执行的 UI steering 可以忽略它。
|
||||
- `agent.inject(input)`:`next-step`/不唤醒预设:追加面向模型的上下文而不运行模型;下一次请求会看到一条逐字的 user role 消息,其来源由必填的 `input.source` 携带。提示词接纳期间或轮次打开时,注入会在 outbox 中等待下一个安全边界。该接收窗口之外,它会立即追加而不开启轮次;如果接纳结束却未开启轮次,仅含上下文的接纳批次会采用这一回退,而与 steering 一同暂存的上下文则会随其继续待处理。持久化独立地响应 `session/event`。注入不发出 `agent/inbox/*` 事件。
|
||||
- `agent.acceptsNextStep`:当前发送 `next-step` 时,是否会加入提示词接纳或已打开的轮次。当调用方必须在 steering 与新接纳的提示词之间选择时,应使用这一更窄的路由判定;`status === 'running'` 还涵盖接纳收尾与轮次结算阶段。
|
||||
- `agent.cancel(cause, options?)`:取消活动轮次,并在未设置 `options.keepInbox` 时取消全部待处理工作。调用方必须显式选择 `user | parent` 原因;活动持有者会在中止前把其判别字段复制为已分离、冻结的信号原因。有效调用会在清除排队与 steering 工作前,随原因发出 `agent/cancel-requested`;丢弃项在 `agent/inbox/discard` 上报告,观察方可以同步状态,但不能 veto 取消。`keepInbox: true` 会中止轮次,但保留排队与 steering 项(不丢弃,且不删除尚未开始的工作)。同进程类型化 seam 不会为无类型调用方添加运行时校验或兼容回退。重复取消活动轮次时,首个信号生效;空闲取消是安全空操作,不发通知。ACP 映射到 `user`,进程内父传播映射到 `parent`。原因只存在于运行时;持久 `turn/end` 保持粗粒度的 `aborted`。
|
||||
|
||||
@@ -58,6 +58,20 @@ export type InboxAction =
|
||||
/** Result of applying an inbox action at the synchronous ownership boundary. */
|
||||
export type InboxActionResult = 'applied' | 'not-found'
|
||||
|
||||
/** Final admission outcome for one call to {@link Agent.steer}. */
|
||||
export type SteeringOutcome =
|
||||
| { readonly status: 'admitted'; readonly turn: number; readonly step: number }
|
||||
| { readonly status: 'rejected' }
|
||||
|
||||
/**
|
||||
* Message-owned steering admission receipt. The outcome promise always
|
||||
* resolves: synchronous input validation still throws from {@link Agent.steer},
|
||||
* while lifecycle policy reports non-admission as `rejected`.
|
||||
*/
|
||||
export interface SteeringReceipt {
|
||||
readonly outcome: Promise<SteeringOutcome>
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for the unified {@link Agent.send} primitive over the
|
||||
* (`target` × `wakeup`) matrix. Named presets: {@link Agent.followup}
|
||||
@@ -225,26 +239,18 @@ export interface Agent {
|
||||
followup(message: UserMessage): void
|
||||
|
||||
/**
|
||||
* Submit steering during prompt admission or an open turn — the
|
||||
* `next-step`/wakeup preset of {@link send}. It stages for the next steering
|
||||
* checkpoint before a request or stop decision. If the activity fails before
|
||||
* that boundary, the remainder stays staged without waking the agent; retry
|
||||
* or a later prompt takes it. Outside that window steering falls back to a
|
||||
* woken follow-up turn, while cancellation or disposal may discard pending
|
||||
* steering.
|
||||
* Submit steering with a message-owned admission receipt — the
|
||||
* `next-step`/wakeup preset of {@link send}. During prompt admission or an
|
||||
* open turn, the message waits in the steering FIFO until a committed step
|
||||
* snapshots it; outside that window it enters the ordinary queued FIFO. The
|
||||
* receipt resolves `admitted` only after the message joins that step's
|
||||
* immutable request history, or `rejected` when terminal policy,
|
||||
* cancellation, or disposal discards it first. A non-terminal turn close may
|
||||
* leave it staged for a later admitted prompt without settling the receipt.
|
||||
* @param message - identified steering content and its producer provenance.
|
||||
* @returns the receipt for this exact message's eventual admission outcome.
|
||||
*/
|
||||
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
|
||||
steer(message: UserMessage): SteeringReceipt
|
||||
|
||||
/**
|
||||
* Append model-facing context without running the model — the
|
||||
|
||||
@@ -26,7 +26,7 @@ function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent {
|
||||
send: () => {},
|
||||
updateInbox: () => 'not-found',
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
|
||||
inject: () => {},
|
||||
reserveTurnAdmission: () => undefined,
|
||||
cancel() {},
|
||||
|
||||
@@ -36,7 +36,7 @@ function agent(ctx: Context, cwd: string): Agent {
|
||||
acceptsNextStep: false,
|
||||
ctx: scope.ctx,
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
|
||||
inject: () => {},
|
||||
send: () => {},
|
||||
updateInbox: () => 'not-found',
|
||||
|
||||
@@ -40,7 +40,7 @@ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session }
|
||||
send: () => {},
|
||||
updateInbox: () => 'not-found',
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
|
||||
inject(input) { appendInjection(session, input) },
|
||||
reserveTurnAdmission: () => undefined,
|
||||
cancel() { status = 'idle' },
|
||||
|
||||
@@ -50,7 +50,7 @@ function stubAgentForSession(session: Session): StubAgent {
|
||||
send: () => {},
|
||||
updateInbox: () => 'not-found',
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
|
||||
inject(input) {
|
||||
if (shouldDefer) deferred.push(input)
|
||||
else appendInjection(session, input)
|
||||
|
||||
@@ -41,7 +41,7 @@ function liveAgent(ctx: Context, session: Session): Agent {
|
||||
send: () => {},
|
||||
updateInbox: () => 'not-found',
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
|
||||
inject(input: UserMessage) {
|
||||
session.append('user/message', input, { surfaceOp: 'append' })
|
||||
},
|
||||
|
||||
@@ -35,7 +35,7 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent {
|
||||
send: () => {},
|
||||
updateInbox: () => 'not-found',
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
|
||||
inject(input) {
|
||||
session.append('user/message', input, { surfaceOp: 'append' })
|
||||
},
|
||||
|
||||
@@ -48,7 +48,7 @@ function stubAgent(session: Session): Agent {
|
||||
acceptsNextStep: false,
|
||||
ctx: new Context(),
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
|
||||
inject: () => {},
|
||||
send: () => {},
|
||||
updateInbox: () => 'not-found',
|
||||
|
||||
@@ -45,7 +45,7 @@ function agent(ctx: Context, cwd?: string): Agent {
|
||||
options: {},
|
||||
session: new Session(id, undefined, { version: 0, id, createdAt: 0, ...cwd === undefined ? {} : { cwd } }),
|
||||
status: 'idle', acceptsNextStep: false, ctx,
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,7 +258,7 @@ describe('pty-local plugin shape', () => {
|
||||
const ownerFiber = await ctx.plugin(() => {})
|
||||
const owner: Agent = {
|
||||
id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx: ownerFiber.ctx,
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
ctx.agents.register(owner)
|
||||
const providerFiber = await registerStubLocalBackend(ctx, () => stubLocalSession())
|
||||
@@ -301,7 +301,7 @@ describe('pty-local plugin shape', () => {
|
||||
const ownerFiber = await ctx.plugin(() => {})
|
||||
const owner: Agent = {
|
||||
id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx: ownerFiber.ctx,
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
ctx.agents.register(owner)
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
|
||||
@@ -35,7 +35,7 @@ function stubAgent(ctx: Context, rawId: string): Agent {
|
||||
const scope = ctx.plugin(() => {})
|
||||
return {
|
||||
id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx,
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ function stubAgent(ctx: Context, rawId: string): Agent {
|
||||
acceptsNextStep: false,
|
||||
ctx: scopeFiber.ctx,
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
|
||||
inject: () => {},
|
||||
send: () => {},
|
||||
updateInbox: () => 'not-found',
|
||||
|
||||
@@ -46,7 +46,7 @@ function agent(ctx: Context, cwd: string): Agent {
|
||||
acceptsNextStep: false,
|
||||
ctx: scope.ctx,
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
|
||||
inject: () => {},
|
||||
send: () => {},
|
||||
updateInbox: () => 'not-found',
|
||||
|
||||
@@ -42,7 +42,7 @@ function agent(ctx: Context, cwd: string | undefined): Agent {
|
||||
acceptsNextStep: false,
|
||||
ctx: scope.ctx,
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
|
||||
inject: () => {},
|
||||
send: () => {},
|
||||
updateInbox: () => 'not-found',
|
||||
|
||||
@@ -40,7 +40,7 @@ function agent(ctx: Context): Agent {
|
||||
const id = SessionId('pty-loader-agent')
|
||||
const value: Agent = {
|
||||
id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx,
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
ctx.agents.register(value)
|
||||
return value
|
||||
|
||||
@@ -18,7 +18,7 @@ function fakeAgent(ctx: Context, rawId: string): Agent {
|
||||
const id = SessionId(rawId)
|
||||
const agent: Agent = {
|
||||
id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx,
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
ctx.agents.register(agent)
|
||||
return agent
|
||||
|
||||
@@ -49,7 +49,7 @@ function agentForCwd(cwd: string): Agent {
|
||||
send: () => {},
|
||||
updateInbox: () => 'not-found',
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
|
||||
inject(input) {
|
||||
session.append('user/message', input, { surfaceOp: 'append' })
|
||||
},
|
||||
@@ -70,7 +70,7 @@ function sessionAgent(session: Session, id = 'tool-skill-agent'): Agent {
|
||||
send: () => {},
|
||||
updateInbox: () => 'not-found',
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
|
||||
inject(input) {
|
||||
session.append('user/message', input, { surfaceOp: 'append' })
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@ The continuable-subagent control service (`ctx.subagentControl`): the one orches
|
||||
|
||||
A continuable background subagent is a durable child session with a series of Task-backed activations. `startContinuable()` allocates the stable child session id before Task creation, snapshots the descriptor inputs (a non-JSON input throws with no Task), and registers the initial activation's Task; the provider publishes exactly that child id and appends the versioned `subagent/descriptor` event inside the child's first turn. Every activation — initial or resumed — creates a fresh Task whose settlement awaits the provider's durability-confirmed child result, disposes the run, and only then records the `TaskOutcome`: a terminal Task leaves the durable child session but no live child Agent. A provider rejection with `DURABILITY_FAILED` settles the Task as `failed` and copies the error message into `detail`, so `task_output` reports the failed checkpoint and resumability risk without exposing unconfirmed output.
|
||||
|
||||
`sendMessage(parent, childId, message, source)` owns steer-or-resume routing and requires the caller's `MessageSource`. A running activation preserves it through the run's strict `steer` capability and returns the existing Task id (`steered`); an absent activation starts a fresh Task that loads the persisted child, authorizes the recorded `parentSession` as the direct parent, folds the descriptor, and dispatches `SubagentService.resume()` with the same source (`started`). Either route projects the content to the model as a user-role message while retaining its source in the child log. Failure throws and means the message was not delivered: losing a strict-steering race with Task settlement never falls through to cold resume within the same call, and a live registry Agent outside the activation association is an ownership conflict rather than an adoption target.
|
||||
`sendMessage(parent, childId, message, source)` owns steer-or-resume routing and requires the caller's `MessageSource`. A running activation preserves it through the run's confirmed `steer` capability and returns the existing Task id (`steered`) only after a committed request snapshot admits the message; an absent activation starts a fresh Task that loads the persisted child, authorizes the recorded `parentSession` as the direct parent, folds the descriptor, and dispatches `SubagentService.resume()` with the same source (`started`). Either route projects the content to the model as a user-role message while retaining its source in the child log. Rejection means the message was not delivered: terminal policy or Task settlement winning the admission race never falls through to cold resume within the same call, and a live registry Agent outside the activation association is an ownership conflict rather than an adoption target.
|
||||
|
||||
Cancellation targets the whole activation. `task_kill` or owner disposal aborts the Task-owned signal; before publication the provider rejects only after its creation transaction rolled back to quiescence, afterwards the signal cancels the published run, and settlement records `killed` only once the activation is quiescent. Human input shares this path: an adapter submits child input through `sendMessage()` under the loaded parent, so parent and human messages that joined one turn share its result and cancellation outcome, and `TaskService.start()`'s control-surface requirement applies (load `@deepseek-ai/dsh-tool-tasks` or attach a surface).
|
||||
|
||||
|
||||
@@ -251,7 +251,7 @@ export class SubagentControlService extends Service {
|
||||
* Deliver one message to a known continuable child: steer its running
|
||||
* activation, or cold-resume the durable session into a fresh Task-backed
|
||||
* activation. The two routes are reported distinctly so timing-dependent
|
||||
* routing is observable. A throw means the message was NOT delivered — in
|
||||
* routing is observable. Rejection means the message was NOT delivered — in
|
||||
* particular, losing a race with Task settlement does not fall through to
|
||||
* cold resume within the same call; a later retry after Task terminal may
|
||||
* start the next activation. The started Task owns descriptor lookup and
|
||||
@@ -265,13 +265,18 @@ export class SubagentControlService extends Service {
|
||||
* @param source - caller-supplied attribution retained across either route.
|
||||
* @returns whether the message `steered` the existing Task or `started` a new one.
|
||||
*/
|
||||
sendMessage(parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource): SendMessageResult {
|
||||
async sendMessage(
|
||||
parent: Agent,
|
||||
childId: SessionId,
|
||||
message: ContentBlock[],
|
||||
source: MessageSource,
|
||||
): Promise<SendMessageResult> {
|
||||
this.assertOwnership(childId)
|
||||
const activation = this.activations.get(childId)
|
||||
if (activation !== undefined) {
|
||||
return {
|
||||
route: 'steered',
|
||||
taskId: this.steerActivation(activation, parent, childId, message, source),
|
||||
taskId: await this.steerActivation(activation, parent, childId, message, source),
|
||||
}
|
||||
}
|
||||
return { route: 'started', taskId: this.resumeActivation(parent, childId, message, source) }
|
||||
@@ -301,20 +306,20 @@ export class SubagentControlService extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
/** Deliver to the running activation's Task through strict live steering. */
|
||||
private steerActivation(
|
||||
/** Deliver to the running activation's Task through confirmed live steering. */
|
||||
private async steerActivation(
|
||||
activation: ActiveActivation,
|
||||
parent: Agent,
|
||||
childId: SessionId,
|
||||
message: ContentBlock[],
|
||||
source: MessageSource,
|
||||
): TaskId {
|
||||
): Promise<TaskId> {
|
||||
const taskId = activation.taskId
|
||||
/* v8 ignore next 3 -- the install and Task registration share one synchronous frame, so an observed activation carries its Task id. */
|
||||
if (taskId === undefined) {
|
||||
throw new SubagentControlError(`subagent "${childId}" activation is starting; the message was not delivered`, 'NOT_DELIVERED')
|
||||
}
|
||||
// Owner-session authorization plus the live status for the strict check.
|
||||
// Owner-session authorization plus the live status for admission.
|
||||
const snapshot = this.ctx.tasks.get(taskId, parent)
|
||||
if (snapshot.status !== 'running') {
|
||||
throw new SubagentControlError(
|
||||
@@ -334,9 +339,9 @@ export class SubagentControlService extends Service {
|
||||
)
|
||||
}
|
||||
try {
|
||||
run.steer(message, source)
|
||||
await run.steer(message, source)
|
||||
} catch (error: unknown) {
|
||||
// Strict steering lost the race with turn settlement. Deliberately no
|
||||
// Confirmed steering lost the race with request admission. Deliberately no
|
||||
// cold-resume fallback here: that would attach the message to a turn the
|
||||
// caller did not observe.
|
||||
throw new SubagentControlError(
|
||||
|
||||
@@ -17,7 +17,7 @@ import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
|
||||
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { createUserMessage, HarnessError, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import SubagentControlService, { runOutcome, settleRun, SubagentControlError } from '../src/index.ts'
|
||||
|
||||
type Script = ConstructorParameters<typeof MockAdapter>[0]
|
||||
@@ -30,11 +30,14 @@ interface GatedEntry {
|
||||
|
||||
/** Adapter whose entries can hold a model call open until the test releases it. */
|
||||
class GatedAdapter extends LlmAdapter {
|
||||
readonly requests: GenerateOptions[] = []
|
||||
|
||||
constructor(private script: GatedEntry[]) {
|
||||
super()
|
||||
}
|
||||
|
||||
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
this.requests.push(options)
|
||||
const entry = this.script.shift()
|
||||
if (!entry) throw new Error('GatedAdapter: script exhausted')
|
||||
if (entry.gate) await entry.gate
|
||||
@@ -216,7 +219,7 @@ describe('SubagentControlService.startContinuable', () => {
|
||||
expect(snapshot.status).toBe('failed')
|
||||
expect(snapshot.detail).toContain('maxDepth')
|
||||
// The unmaterialized child id is reported unavailable on later use.
|
||||
const followUp = sendMessage(ctx, parent, started.childId, message('hello?'))
|
||||
const followUp = await sendMessage(ctx, parent, started.childId, message('hello?'))
|
||||
expect(followUp.route).toBe('started')
|
||||
const failed = await waitTerminal(ctx, followUp.taskId, parent)
|
||||
expect(failed.status).toBe('failed')
|
||||
@@ -264,20 +267,23 @@ describe('SubagentControlService.sendMessage', () => {
|
||||
await waitPublishedRun(ctx, started.childId)
|
||||
|
||||
expect(descriptor).toEqual({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'no-steer' })
|
||||
expect(() => sendMessage(ctx, parent, started.childId, message('join')))
|
||||
.toThrow(/provider does not accept live delivery/)
|
||||
await expect(sendMessage(ctx, parent, started.childId, message('join')))
|
||||
.rejects.toThrow(/provider does not accept live delivery/)
|
||||
|
||||
let terminalDeliveryError: unknown
|
||||
let terminalDelivery: Promise<void> | undefined
|
||||
ctx.tasks.onTaskDone((snapshot) => {
|
||||
if (snapshot.id !== started.taskId) return
|
||||
try {
|
||||
sendMessage(ctx, parent, started.childId, message('after terminal'))
|
||||
} catch (error: unknown) {
|
||||
terminalDeliveryError = error
|
||||
}
|
||||
terminalDelivery = sendMessage(ctx, parent, started.childId, message('after terminal')).then(
|
||||
() => undefined,
|
||||
(error: unknown) => {
|
||||
terminalDeliveryError = error
|
||||
},
|
||||
)
|
||||
})
|
||||
result.resolve({ output: [{ type: 'text', text: 'done' }], stopReason: 'completed' })
|
||||
await waitTerminal(ctx, started.taskId, parent)
|
||||
await terminalDelivery
|
||||
expect(String(terminalDeliveryError)).toContain('is completed')
|
||||
})
|
||||
|
||||
@@ -310,8 +316,8 @@ describe('SubagentControlService.sendMessage', () => {
|
||||
const started = ctx.subagentControl.startContinuable(startSpec(parent, 'mismatched-local'))
|
||||
await waitPublishedRun(ctx, started.childId)
|
||||
|
||||
expect(() => sendMessage(ctx, parent, started.childId, message('join')))
|
||||
.toThrow(/registry agent is not the associated activation's agent/)
|
||||
await expect(sendMessage(ctx, parent, started.childId, message('join')))
|
||||
.rejects.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)
|
||||
})
|
||||
@@ -322,30 +328,32 @@ describe('SubagentControlService.sendMessage', () => {
|
||||
// second step in the SAME turn.
|
||||
let releaseFirst!: () => void
|
||||
const gate = new Promise<void>((resolve) => { releaseFirst = resolve })
|
||||
const { ctx, parent } = await setupWith(new GatedAdapter([
|
||||
const adapter = new GatedAdapter([
|
||||
{ chunks: textResponse('first step answer'), gate },
|
||||
{ chunks: textResponse('steered turn answer') },
|
||||
]))
|
||||
])
|
||||
const { ctx, parent } = await setupWith(adapter)
|
||||
|
||||
const started = ctx.subagentControl.startContinuable(startSpec(parent))
|
||||
// Wait for the child agent to publish and enter running.
|
||||
// Wait until the first immutable request has crossed the adapter boundary.
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setInterval(() => {
|
||||
if (ctx.agents.get(started.childId)?.status === 'running') {
|
||||
if (adapter.requests.length === 1) {
|
||||
clearInterval(timer)
|
||||
resolve()
|
||||
}
|
||||
}, 5)
|
||||
})
|
||||
|
||||
const delivered = ctx.subagentControl.sendMessage(
|
||||
const delivery = ctx.subagentControl.sendMessage(
|
||||
parent,
|
||||
started.childId,
|
||||
message('also consider Y'),
|
||||
coordinatorSource,
|
||||
)
|
||||
expect(delivered).toEqual({ route: 'steered', taskId: started.taskId })
|
||||
releaseFirst()
|
||||
const delivered = await delivery
|
||||
expect(delivered).toEqual({ route: 'steered', taskId: started.taskId })
|
||||
const snapshot = await waitTerminal(ctx, started.taskId, parent)
|
||||
expect(snapshot.status).toBe('completed')
|
||||
// Exactly one Task exists: steering created none.
|
||||
@@ -360,13 +368,57 @@ describe('SubagentControlService.sendMessage', () => {
|
||||
expect(steering?.data.message.source).toEqual(coordinatorSource)
|
||||
})
|
||||
|
||||
it('rejects before acknowledgement when terminal policy prevents steering admission', async () => {
|
||||
const { ctx, parent, adapter } = await setup([
|
||||
toolCallResponse('c1', 'structured_output', { answer: 7 }),
|
||||
])
|
||||
const startedTool = Promise.withResolvers<undefined>()
|
||||
const releaseTool = Promise.withResolvers<undefined>()
|
||||
ctx.on('tools/pre-execute', async (exec, next) => {
|
||||
if (exec.name === 'structured_output') {
|
||||
startedTool.resolve(undefined)
|
||||
await releaseTool.promise
|
||||
}
|
||||
return next()
|
||||
})
|
||||
|
||||
const base = startSpec(parent)
|
||||
const started = ctx.subagentControl.startContinuable({
|
||||
...base,
|
||||
request: {
|
||||
...base.request,
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: { answer: { type: 'number' } },
|
||||
required: ['answer'],
|
||||
},
|
||||
},
|
||||
})
|
||||
await startedTool.promise
|
||||
|
||||
const delivery = ctx.subagentControl.sendMessage(
|
||||
parent,
|
||||
started.childId,
|
||||
message('follow-up that terminal policy rejects'),
|
||||
coordinatorSource,
|
||||
)
|
||||
releaseTool.resolve(undefined)
|
||||
await expect(delivery).rejects.toThrow(/message was not delivered/)
|
||||
|
||||
const snapshot = await waitTerminal(ctx, started.taskId, parent)
|
||||
expect(snapshot.status).toBe('completed')
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
const loaded = await ctx.sessionPersistence.load(started.childId)
|
||||
expect(loaded.events.some(event => event.type === 'steering/message')).toBe(false)
|
||||
})
|
||||
|
||||
it('cold-resumes a settled child into a fresh Task and reports `started`', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('first answer'), textResponse('second answer')])
|
||||
const started = ctx.subagentControl.startContinuable(startSpec(parent))
|
||||
await waitTerminal(ctx, started.taskId, parent)
|
||||
expect(ctx.agents.get(started.childId)).toBeUndefined()
|
||||
|
||||
const followUp = ctx.subagentControl.sendMessage(
|
||||
const followUp = await ctx.subagentControl.sendMessage(
|
||||
parent,
|
||||
started.childId,
|
||||
message('and then?'),
|
||||
@@ -409,7 +461,7 @@ describe('SubagentControlService.sendMessage', () => {
|
||||
expect(descriptor?.data.persona).toBe('You are the resumable child.')
|
||||
expect(descriptor?.data.toolFilter).toEqual({ deny: [] })
|
||||
|
||||
const followUp = sendMessage(ctx, parent, started.childId, message('continue'))
|
||||
const followUp = await sendMessage(ctx, parent, started.childId, message('continue'))
|
||||
const snapshot = await waitTerminal(ctx, followUp.taskId, parent)
|
||||
expect(snapshot.status).toBe('completed')
|
||||
// The resumed child's system prompt carried the persona back.
|
||||
@@ -438,7 +490,7 @@ describe('SubagentControlService.sendMessage', () => {
|
||||
parent.followup(createUserMessage({ content: message('parent question two'), source: { kind: 'user' } }))
|
||||
await parent.whenIdle()
|
||||
|
||||
const followUp = sendMessage(ctx, parent, started.childId, message('follow up'))
|
||||
const followUp = await sendMessage(ctx, parent, started.childId, message('follow up'))
|
||||
await waitTerminal(ctx, followUp.taskId, parent)
|
||||
const resumed = await ctx.sessionPersistence.load(started.childId)
|
||||
// The persisted seed boundary is unchanged and parent turn two is absent.
|
||||
@@ -454,7 +506,7 @@ describe('SubagentControlService.sendMessage', () => {
|
||||
const { ctx, parent } = await setup([textResponse('first'), textResponse('second')])
|
||||
const started = ctx.subagentControl.startContinuable(startSpec(parent))
|
||||
await waitTerminal(ctx, started.taskId, parent)
|
||||
const followUp = sendMessage(ctx, parent, started.childId, message('go on'))
|
||||
const followUp = await sendMessage(ctx, parent, started.childId, message('go on'))
|
||||
|
||||
const childAgents: Agent[] = []
|
||||
const stop = ctx.on('agent/created', (agent: Agent) => {
|
||||
@@ -474,7 +526,7 @@ describe('SubagentControlService.sendMessage', () => {
|
||||
const started = ctx.subagentControl.startContinuable(startSpec(otherParent))
|
||||
await waitTerminal(ctx, started.taskId, otherParent)
|
||||
|
||||
const attempt = sendMessage(ctx, parent, started.childId, message('mine now'))
|
||||
const attempt = await sendMessage(ctx, parent, started.childId, message('mine now'))
|
||||
expect(attempt.route).toBe('started')
|
||||
const snapshot = await waitTerminal(ctx, attempt.taskId, parent)
|
||||
expect(snapshot.status).toBe('failed')
|
||||
@@ -493,7 +545,7 @@ describe('SubagentControlService.sendMessage', () => {
|
||||
await handle.agent.whenIdle()
|
||||
await handle.dispose()
|
||||
|
||||
const attempt = sendMessage(ctx, parent, SessionId('plain-child'), message('continue?'))
|
||||
const attempt = await sendMessage(ctx, parent, SessionId('plain-child'), message('continue?'))
|
||||
const snapshot = await waitTerminal(ctx, attempt.taskId, parent)
|
||||
expect(snapshot.status).toBe('failed')
|
||||
expect(snapshot.detail).toContain(
|
||||
@@ -503,9 +555,9 @@ describe('SubagentControlService.sendMessage', () => {
|
||||
|
||||
it('derives fallback and bounded labels for resumed activations', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const blank = sendMessage(ctx, parent, SessionId('blank-child'), message(' '))
|
||||
const blank = await sendMessage(ctx, parent, SessionId('blank-child'), message(' '))
|
||||
const longText = 'x'.repeat(100)
|
||||
const long = sendMessage(ctx, parent, SessionId('long-child'), message(longText))
|
||||
const long = await sendMessage(ctx, 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)}…`)
|
||||
@@ -523,14 +575,14 @@ describe('SubagentControlService.sendMessage', () => {
|
||||
meta: { parentSession: parent.id },
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
expect(() => sendMessage(ctx, parent, SessionId('rogue-child'), message('hello')))
|
||||
.toThrow(SubagentControlError)
|
||||
expect(() => sendMessage(ctx, parent, SessionId('rogue-child'), message('hello')))
|
||||
.toThrow(/outside control-service ownership.*not delivered/)
|
||||
await expect(sendMessage(ctx, parent, SessionId('rogue-child'), message('hello')))
|
||||
.rejects.toThrow(SubagentControlError)
|
||||
await expect(sendMessage(ctx, parent, SessionId('rogue-child'), message('hello')))
|
||||
.rejects.toThrow(/outside control-service ownership.*not delivered/)
|
||||
await handle.dispose()
|
||||
})
|
||||
|
||||
it('does not fall through to cold resume when strict steering loses the settlement race', async () => {
|
||||
it('does not fall through to cold resume when steering loses the admission race', async () => {
|
||||
// Deterministic race: hold run disposal open so the association still
|
||||
// names a run whose child turn has already ended.
|
||||
const { ctx, parent } = await setup([textResponse('quick answer'), textResponse('unused')])
|
||||
@@ -564,15 +616,15 @@ describe('SubagentControlService.sendMessage', () => {
|
||||
}, 5)
|
||||
})
|
||||
|
||||
// Strict steering finds the settled child, fails loud, and does NOT start
|
||||
// Confirmed steering finds the settled child, fails loud, and does NOT start
|
||||
// a cold resume within this call.
|
||||
expect(() => sendMessage(ctx, parent, started.childId, message('too late?')))
|
||||
.toThrow(/not delivered/)
|
||||
await expect(sendMessage(ctx, parent, started.childId, message('too late?')))
|
||||
.rejects.toThrow(/not delivered/)
|
||||
expect(ctx.tasks.list(parent).map(task => task.id)).toEqual([started.taskId])
|
||||
releaseDispose()
|
||||
await waitTerminal(ctx, started.taskId, parent)
|
||||
// AFTER the Task settles, retry legitimately starts the next activation.
|
||||
const retry = sendMessage(ctx, parent, started.childId, message('retry'))
|
||||
const retry = await sendMessage(ctx, parent, started.childId, message('retry'))
|
||||
expect(retry.route).toBe('started')
|
||||
await waitTerminal(ctx, retry.taskId, parent)
|
||||
})
|
||||
@@ -581,7 +633,7 @@ describe('SubagentControlService.sendMessage', () => {
|
||||
const { ctx, parent } = await setup([textResponse('first'), textResponse('second')])
|
||||
const started = ctx.subagentControl.startContinuable(startSpec(parent))
|
||||
await waitTerminal(ctx, started.taskId, parent)
|
||||
const followUp = sendMessage(ctx, parent, started.childId, message('more'))
|
||||
const followUp = await sendMessage(ctx, parent, started.childId, message('more'))
|
||||
const other = ctx.agentLoop.create(SessionId('intruder'), { provider: 'mock', model: 'mock' })
|
||||
expect(() => ctx.tasks.get(followUp.taskId, other)).toThrow(/belongs to another session/)
|
||||
})
|
||||
@@ -600,7 +652,7 @@ describe('SubagentControlService.sendMessage', () => {
|
||||
return realLoad(id)
|
||||
}
|
||||
|
||||
const followUp = sendMessage(ctx, parent, started.childId, message('follow up'))
|
||||
const followUp = await sendMessage(ctx, parent, started.childId, message('follow up'))
|
||||
expect(ctx.tasks.kill(followUp.taskId, parent)).toBe('requested')
|
||||
releaseLoad()
|
||||
const snapshot = await waitTerminal(ctx, followUp.taskId, parent)
|
||||
@@ -622,12 +674,12 @@ describe('SubagentControlService.sendMessage', () => {
|
||||
return realLoad(id)
|
||||
}
|
||||
|
||||
const first = sendMessage(ctx, parent, started.childId, message('first follow-up'))
|
||||
const first = await sendMessage(ctx, parent, started.childId, message('first follow-up'))
|
||||
expect(first.route).toBe('started')
|
||||
// The association is installed synchronously, so the competing caller
|
||||
// observes the pending activation instead of starting a duplicate resume.
|
||||
expect(() => sendMessage(ctx, parent, started.childId, message('second follow-up')))
|
||||
.toThrow(/not delivered/)
|
||||
await expect(sendMessage(ctx, parent, started.childId, message('second follow-up')))
|
||||
.rejects.toThrow(/not delivered/)
|
||||
releaseLoad()
|
||||
const snapshot = await waitTerminal(ctx, first.taskId, parent)
|
||||
expect(snapshot.status).toBe('completed')
|
||||
|
||||
@@ -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 packages/subagent/subagent-inprocess/README.md
|
||||
README.md: eb5d973566f01c05b43f4f56eff746b7af93f60b
|
||||
README.zh.md: 5be640f9b6da6402ece0e1d15997d9e2970a7d1c
|
||||
README.md: 6225b84f1274b61cae1d4ca567155dcc6e6a0888
|
||||
README.zh.md: 5c3ab3baa3ab86f33fe34026ddbdf97449cb4f92
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
This package is the shared run driver for the two in-process providers. Spawn passes no session seed; fork passes the parent's completed-turn prefix. Everything else—depth, child creation and cold resume, optional child customization, result reading, cancellation, strict steering, and disposal—has one implementation here.
|
||||
This package is the shared run driver for the two in-process providers. Spawn passes no session seed; fork passes the parent's completed-turn prefix. Everything else—depth, child creation and cold resume, optional child customization, result reading, cancellation, confirmed steering, and disposal—has one implementation here.
|
||||
|
||||
## Start contract
|
||||
|
||||
@@ -31,7 +31,7 @@ The required request signal covers both startup and the live run. Before publica
|
||||
|
||||
After fulfillment, the caller owns the run. Provider-plugin unload does not revoke it. `dispose()` removes the live abort listener, records cancellation, and delegates to the returned `AgentHandle.dispose()`, whose memoized quiescence transaction stops the loop, removes the agent and session, and unwinds scoped registrations. Cancellation owns every non-completed in-flight outcome and reports `aborted`; an already-completed turn remains completed.
|
||||
|
||||
Runs expose the strict `steer` capability: the synchronous checks and the `Agent.trySteer()` call share one frame, so delivery joins the observed step or throws. Delivery requires `AgentStatus.running`, an open turn and step in the child log, no committed structured capture, and acceptance before that step's final drain begins. Admission, between-step processing such as `agent/turn-stopping`, and a closed turn's durability flush all reject delivery. The Agent-level idle fallback (queue and start a new turn) is deliberately not reachable through the run — that would start an untracked turn after the run's result was read.
|
||||
Runs expose confirmed `steer`: a synchronous status check prevents the Agent-level idle fallback from starting an untracked turn, then the run submits through `Agent.steer()` and awaits that exact message's receipt. Fulfillment means a committed child request snapshot admitted the message; terminal turn policy, cancellation, disposal, or a settlement race rejects instead. A synchronously visible structured capture is rejected before submission because its terminal outcome is already authoritative. The run never falls through from rejected live delivery to a later queued turn or cold resume.
|
||||
|
||||
## Spawn and fork inputs
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
本包是两个进程内提供方共用的运行驱动器。spawn 不传入会话初始内容;fork 传入父 agent(智能体)已完成轮次的前缀。其余机制,包括深度、子 agent 创建与冷恢复、可选的子 agent 定制、结果读取、取消、严格 steering(中途引导)和 dispose(资源释放),都在此共用同一套实现。
|
||||
本包是两个进程内提供方共用的运行驱动器。spawn 不传入会话初始内容;fork 传入父 agent(智能体)已完成轮次的前缀。其余机制,包括深度、子 agent 创建与冷恢复、可选的子 agent 定制、结果读取、取消、确认式 steering(中途引导)和 dispose(资源释放),都在此共用同一套实现。
|
||||
|
||||
## 启动契约
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
兑现后,调用方拥有该运行。提供方插件卸载不会撤销它。`dispose()` 会移除实时中止监听器、记录取消,并委托给返回的 `AgentHandle.dispose()`;后者通过可复用的完全停稳事务停止循环、移除 agent 和会话,并展开有作用域的注册。取消决定所有尚未完成的进行中结果,并将其报告为 `aborted`;已经完成的轮次仍保持完成状态。
|
||||
|
||||
运行公开严格的 `steer` 功能:同步检查与 `Agent.trySteer()` 调用位于同一个调用栈帧中,因此消息要么加入观察到的步骤,要么抛错。交付要求 `AgentStatus.running`、子 agent 日志中有开放的轮次和步骤、没有已提交的结构化捕获,并且在该步骤的最终 drain 开始前获接纳。提示词接纳、`agent/turn-stopping` 等步骤间处理,以及已关闭轮次的持久性 flush 都会拒绝交付。运行不会触达 Agent 层在空闲时排队并启动新轮次的 fallback;否则会在运行结果读取后启动一个未被跟踪的轮次。
|
||||
运行公开确认式 `steer`:同步状态检查会阻止 Agent 层的空闲 fallback 启动未跟踪轮次,随后运行通过 `Agent.steer()` 提交消息,并等待该准确消息的回执。兑现表示某个已提交的子 agent 请求 snapshot 接纳了消息;结束轮次的策略、取消、dispose(资源释放)或结算竞态会改为拒绝。已同步可见的结构化捕获会在提交前被拒绝,因为其终态结果已经具有权威性。实时投递被拒绝后,运行绝不会转而进入之后的排队轮次或冷恢复。
|
||||
|
||||
## Spawn 与 fork 输入
|
||||
|
||||
|
||||
@@ -238,8 +238,8 @@ export async function resumeInProcessRun(request: SubagentResumeRequest): Promis
|
||||
* Drive one activation turn on a published child and wrap it as a run. The
|
||||
* caller has already created or resumed the agent; this owns the
|
||||
* signal-handoff race, the live abort listener, result collection past
|
||||
* `boundary`, the continuable-run durability confirmation, strict steering,
|
||||
* and disposal.
|
||||
* `boundary`, the continuable-run durability confirmation, confirmed
|
||||
* steering, and disposal.
|
||||
*/
|
||||
function driveTurn(
|
||||
handle: AgentHandle,
|
||||
@@ -299,46 +299,21 @@ function driveTurn(
|
||||
flags.cancelled = true
|
||||
return handle.dispose()
|
||||
},
|
||||
steer(content: ContentBlock[], steeringSource: MessageSource): void {
|
||||
// Strict live delivery: the synchronous checks and Agent.trySteer() share
|
||||
// one frame, so delivery joins the observed step or throws. The ordinary
|
||||
// Agent.steer() idle fallback would instead queue the message and
|
||||
// start a new, untracked turn after this run's result was read.
|
||||
async steer(content: ContentBlock[], steeringSource: MessageSource): Promise<void> {
|
||||
// The status check and submission share one synchronous frame. An idle
|
||||
// Agent.steer() would queue an untracked turn after this run's result.
|
||||
if (child.status !== 'running') {
|
||||
throw new Error(`subagent child "${childId}" is not running; the message was not delivered`)
|
||||
}
|
||||
// Status stays `running` through the closed turn's durability flush, when
|
||||
// ordinary steering would queue a later turn. Requiring an open turn
|
||||
// keeps this activation's acknowledged delivery honest.
|
||||
const lastBoundary = child.session.events.findLast(
|
||||
event => event.type === 'turn/start' || event.type === 'turn/end',
|
||||
)
|
||||
if (lastBoundary?.type !== 'turn/start') {
|
||||
throw new Error(`subagent child "${childId}" turn has already closed; the message was not delivered`)
|
||||
}
|
||||
// Between steps there is no current step whose final drain can own strict
|
||||
// delivery. A message accepted during an open step is recorded at that
|
||||
// step's settlement checkpoint before the continuation decision
|
||||
// (cancellation remains the documented shared-outcome race).
|
||||
const lastStep = child.session.events.findLast(
|
||||
event => event.type === 'step/start' || event.type === 'step/end',
|
||||
)
|
||||
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 step conclusion
|
||||
// terminal. The capture is synchronously observable, so reject rather
|
||||
// than acknowledge a message the run is about to drop.
|
||||
// Avoid waiting for the structured terminal checkpoint when its outcome
|
||||
// is already authoritative and synchronously visible.
|
||||
if (structured?.captured() !== undefined) {
|
||||
throw new Error(`subagent child "${childId}" already reported its structured result; the message was not delivered`)
|
||||
}
|
||||
// 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: steeringSource }))) {
|
||||
throw new Error(`subagent child "${childId}" passed its steering checkpoint; the message was not delivered`)
|
||||
const receipt = child.steer(createUserMessage({ content, source: steeringSource }))
|
||||
const outcome = await receipt.outcome
|
||||
if (outcome.status === 'rejected') {
|
||||
throw new Error(`subagent child "${childId}" stopped before steering admission; the message was not delivered`)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -120,26 +120,24 @@ describe('in-process structured output', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('strict steer rejects delivery once the structured result is captured', async () => {
|
||||
it('confirmed steering rejects delivery once the structured result is captured', async () => {
|
||||
const { ctx, parent } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
|
||||
])
|
||||
// oxlint-disable-next-line prefer-const -- single assignment follows listener registration so pre-fulfillment events remain guardable.
|
||||
let run: Awaited<ReturnType<typeof ctx.subagents.start>> | undefined
|
||||
let rejected: unknown
|
||||
let delivery: Promise<void> | undefined
|
||||
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' }], { kind: 'user' })
|
||||
} catch (error: unknown) {
|
||||
rejected = error
|
||||
}
|
||||
|| event.type !== 'tool/result' || delivery !== undefined) return
|
||||
delivery = run.steer?.([{ type: 'text', text: 'one more thing' }], { kind: 'user' })
|
||||
void delivery?.catch(() => undefined)
|
||||
})
|
||||
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/)
|
||||
if (delivery === undefined) throw new Error('structured result did not submit steering')
|
||||
await expect(delivery)
|
||||
.rejects.toThrow(/already reported its structured result; the message was not delivered/)
|
||||
expect(result.structured).toEqual({ answer: 7 })
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
@@ -10,7 +10,8 @@ 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, { SUBAGENT_DESCRIPTOR_VERSION, SubagentError } from '@deepseek-ai/dsh-subagent'
|
||||
import { maxTokensResponse, MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
import { maxTokensResponse, MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import { resumeInProcessRun, startInProcessRun } from '../src/index.ts'
|
||||
|
||||
type Script = ConstructorParameters<typeof MockAdapter>[0]
|
||||
@@ -290,7 +291,7 @@ describe('startInProcessRun', () => {
|
||||
reserveTurnAdmission: () => undefined,
|
||||
updateInbox: () => 'not-found',
|
||||
followup(): void {},
|
||||
steer(): void {},
|
||||
steer() { return { outcome: Promise.resolve({ status: 'rejected' as const }) } },
|
||||
inject(): void {},
|
||||
cancel(): void {},
|
||||
whenIdle: () => Promise.resolve(),
|
||||
@@ -381,156 +382,103 @@ describe('startInProcessRun', () => {
|
||||
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
|
||||
})
|
||||
|
||||
it('strict steer rejects a settled child instead of queueing an untracked turn', async () => {
|
||||
it('confirmed steering rejects a settled child instead of queueing an untracked turn', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
const run = await startInProcessRun(request(parent), {})
|
||||
await run.result
|
||||
// The child is idle after its turn: Agent.steer() would silently QUEUE.
|
||||
expect(() => { run.steer!([{ type: 'text', text: 'late' }], { kind: 'user' }) })
|
||||
.toThrow(/not running; the message was not delivered/)
|
||||
await expect(run.steer!([{ type: 'text', text: 'late' }], { kind: 'user' }))
|
||||
.rejects.toThrow(/not running; the message was not delivered/)
|
||||
const child = ctx.agents.get(run.id)!
|
||||
expect(child.session.events.some(event => event.type === 'steering/message')).toBe(false)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
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-stopping', (agent) => {
|
||||
if (agent.session.header.parentSession === undefined || releaseStop !== undefined) return undefined
|
||||
return new Promise((resolve) => {
|
||||
releaseStop = () => { resolve(undefined) }
|
||||
})
|
||||
})
|
||||
const run = await startInProcessRun(request(parent), {})
|
||||
const child = ctx.agents.get(run.id)!
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setInterval(() => {
|
||||
if (releaseStop !== undefined) { clearInterval(timer); resolve() }
|
||||
}, 5)
|
||||
})
|
||||
expect(child.status).toBe('running')
|
||||
expect(() => {
|
||||
run.steer!([{ type: 'text', text: 'too late for this turn' }], { kind: 'user' })
|
||||
})
|
||||
.toThrow(/between steps; the message was not delivered/)
|
||||
releaseStop!()
|
||||
await run.result
|
||||
expect(child.session.events.some(event => event.type === 'steering/message')).toBe(false)
|
||||
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' }], { kind: 'user' })
|
||||
} else if (event.type === 'steering/message' && rejected === undefined) {
|
||||
try {
|
||||
run.steer?.([{ type: 'text', text: 'after the drain began' }], { kind: 'user' })
|
||||
} 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()
|
||||
},
|
||||
}),
|
||||
},
|
||||
it('confirmed steering rejects when a concluding tool prevents request admission', async () => {
|
||||
const { ctx, parent } = await setup([toolCallResponse('c1', 'finalize', {})])
|
||||
const enteredTool = Promise.withResolvers<undefined>()
|
||||
const releaseTool = Promise.withResolvers<undefined>()
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'finalize',
|
||||
description: 'Finish the child run.',
|
||||
parameters: {},
|
||||
async execute(_args, exec) {
|
||||
enteredTool.resolve(undefined)
|
||||
await releaseTool.promise
|
||||
exec.concludeTurn()
|
||||
return [{ type: 'text', text: 'final' }]
|
||||
},
|
||||
} as unknown as Agent
|
||||
|
||||
const run = await startInProcessRun(request(parent), {})
|
||||
expect(() => {
|
||||
run.steer!([{ type: 'text', text: 'unsupported strict delivery' }], { kind: 'user' })
|
||||
})
|
||||
.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
|
||||
// discard a drained steering message instead of recording it.
|
||||
const { ctx, parent } = await setup([textResponse('quick')])
|
||||
let releaseFlush: (() => void) | undefined
|
||||
ctx.on('session/flush', (session) => {
|
||||
if (session.header.parentSession === undefined || releaseFlush !== undefined) return
|
||||
const lastEnd = session.events.findLast(event => event.type === 'turn/end')
|
||||
if (lastEnd === undefined) return
|
||||
return new Promise<void>((resolve) => { releaseFlush = resolve })
|
||||
})
|
||||
}))
|
||||
const run = await startInProcessRun(request(parent), {})
|
||||
const child = ctx.agents.get(run.id)!
|
||||
// Wait until the child's turn has closed while the flush keeps it running.
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setInterval(() => {
|
||||
if (releaseFlush !== undefined) { clearInterval(timer); resolve() }
|
||||
}, 5)
|
||||
})
|
||||
expect(child.status).toBe('running')
|
||||
expect(() => { run.steer!([{ type: 'text', text: 'into the void' }], { kind: 'user' }) })
|
||||
.toThrow(/turn has already closed; the message was not delivered/)
|
||||
releaseFlush!()
|
||||
await enteredTool.promise
|
||||
|
||||
const delivery = run.steer!([{ type: 'text', text: 'terminal race' }], { kind: 'user' })
|
||||
releaseTool.resolve(undefined)
|
||||
await expect(delivery).rejects.toThrow(/stopped before steering admission; the message was not delivered/)
|
||||
await run.result
|
||||
expect(child.session.events.some(event => event.type === 'steering/message')).toBe(false)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('confirmed steering fulfills only after the next request snapshot admits it', async () => {
|
||||
const { ctx, parent, adapter } = await setup([textResponse('first'), textResponse('second')])
|
||||
const enteredStopping = Promise.withResolvers<undefined>()
|
||||
const releaseStopping = Promise.withResolvers<undefined>()
|
||||
let held = false
|
||||
ctx.on('agent/turn-stopping', (agent) => {
|
||||
if (agent.session.header.parentSession === undefined || held) return
|
||||
held = true
|
||||
enteredStopping.resolve(undefined)
|
||||
return releaseStopping.promise
|
||||
})
|
||||
|
||||
const run = await startInProcessRun(request(parent), {})
|
||||
const child = ctx.agents.get(run.id)!
|
||||
await enteredStopping.promise
|
||||
|
||||
let settled = false
|
||||
const delivery = run.steer!([{ type: 'text', text: 'after the first step' }], { kind: 'user' })
|
||||
.then(() => { settled = true })
|
||||
await Promise.resolve()
|
||||
expect(settled).toBe(false)
|
||||
releaseStopping.resolve(undefined)
|
||||
await delivery
|
||||
|
||||
const result = await run.result
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(JSON.stringify(adapter.requests[1]?.messages)).toContain('after the first step')
|
||||
expect((result.output[0] as { text?: string }).text).toBe('second')
|
||||
const steering = child.session.events.find(event => event.type === 'steering/message')
|
||||
expect(steering?.type === 'steering/message' && steering.data.message.source).toEqual({ kind: 'user' })
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('carries steering from a non-terminal flush window into a tracked next turn', async () => {
|
||||
const { ctx, parent, adapter } = await setup([textResponse('first'), textResponse('second')])
|
||||
const enteredFlush = Promise.withResolvers<undefined>()
|
||||
const releaseFlush = Promise.withResolvers<undefined>()
|
||||
let held = false
|
||||
ctx.on('session/flush', (session) => {
|
||||
if (session.header.parentSession === undefined || held) return
|
||||
if (!session.events.some(event => event.type === 'turn/end')) return
|
||||
held = true
|
||||
enteredFlush.resolve(undefined)
|
||||
return releaseFlush.promise
|
||||
})
|
||||
|
||||
const run = await startInProcessRun(request(parent), {})
|
||||
const child = ctx.agents.get(run.id)!
|
||||
await enteredFlush.promise
|
||||
expect(child.status).toBe('running')
|
||||
|
||||
const delivery = run.steer!([{ type: 'text', text: 'next tracked turn' }], { kind: 'user' })
|
||||
releaseFlush.resolve(undefined)
|
||||
await delivery
|
||||
const result = await run.result
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(child.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
|
||||
expect(child.session.events.some(event => event.type === 'steering/message')).toBe(false)
|
||||
expect((result.output[0] as { text?: string }).text).toBe('second')
|
||||
await run.dispose()
|
||||
})
|
||||
})
|
||||
@@ -235,7 +235,7 @@ describe('dsh-subagent-spawn', () => {
|
||||
expect(result.stopReason).toBe('aborted')
|
||||
})
|
||||
|
||||
it('exposes strict steer (no run-level resume): a settled child throws instead of queueing', async () => {
|
||||
it('exposes confirmed steer (no run-level resume): a settled child rejects instead of queueing', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('x')])
|
||||
const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
|
||||
// A run represents one disposable activation: cold resume is a provider
|
||||
@@ -243,11 +243,11 @@ describe('dsh-subagent-spawn', () => {
|
||||
expect('resume' in run).toBe(false)
|
||||
expect(typeof run.steer).toBe('function')
|
||||
await run.result
|
||||
// Strict live-only contract: after the child settles, delivery fails loud
|
||||
// Confirmed live-only contract: after the child settles, delivery fails loud
|
||||
// rather than falling back to Agent.steer()'s idle queue (which would
|
||||
// start an untracked turn).
|
||||
expect(() => { run.steer!([{ type: 'text', text: 'late' }], { kind: 'user' }) })
|
||||
.toThrow(/not running; the message was not delivered/)
|
||||
await expect(run.steer!([{ type: 'text', text: 'late' }], { kind: 'user' }))
|
||||
.rejects.toThrow(/not running; the message was not delivered/)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ Start-time features are advertised in `provider.capabilities` because the servic
|
||||
- `toolFilter` — apply the requested child tool restriction.
|
||||
- `persona` — apply a per-child persona.
|
||||
|
||||
Runtime features are optional methods whose presence is the capability check: `SubagentRun.steer?` delivers strictly to the actively running child turn (it throws rather than queueing when the child is not running), and `SubagentProvider.resume?` reconstructs a persisted continuable child. A run represents one disposable activation, so it deliberately has no cold-resume operation — a disposed run cannot be reconstructed after restart.
|
||||
Runtime features are optional methods whose presence is the capability check: `SubagentRun.steer?` fulfills only after a request snapshot in the active child admits the message and rejects rather than queueing an untracked turn, while `SubagentProvider.resume?` reconstructs a persisted continuable child. A run represents one disposable activation, so it deliberately has no cold-resume operation — a disposed run cannot be reconstructed after restart.
|
||||
|
||||
## The durable descriptor
|
||||
|
||||
|
||||
@@ -42,12 +42,12 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
- `toolFilter`:应用请求的子 agent 工具限制;
|
||||
- `persona`:应用每个子 agent 独立的 persona。
|
||||
|
||||
运行时功能通过可选方法是否存在来检查能力:`SubagentRun.steer?` 只有在活跃子 agent 的请求 snapshot 接纳消息后才会兑现,并会拒绝而非排队一个未跟踪轮次;`SubagentProvider.resume?` 则重建已持久化且可继续的子 agent。一次运行表示一个可 dispose(资源释放)的 activation,因此刻意不提供冷恢复操作;已释放的运行无法在重启后重建。
|
||||
|
||||
## 委派深度
|
||||
|
||||
该 seam 拥有实现和消费方共享的深度词汇:`AgentOptions.subagentDepth` 声明、`assertSubagentMaxDepth` 和 `delegationDepthOf(agent)`。持久化的 `SessionHeader.delegationDepth` 具有权威性且单调:运行时选项可以加深计数,但绝不能降低它,因此恢复后的子 agent 不会被重新计为顶层。
|
||||
|
||||
运行时功能是 `SubagentRun` 上的可选方法:`sendMessage?` 可对正在运行的子 agent 进行 steering(中途引导),`resume?` 则异步创建延续运行。方法是否存在就是能力检查。
|
||||
|
||||
`inheritsParentContext` 只用于描述,不能强制执行。它仅说明子 agent 是否能看到父级已完成的对话历史(`fork` 可以;`spawn` 和 ACP 不可以),不表示是否继承工具、服务或权限。
|
||||
|
||||
## 所有权与生命周期
|
||||
|
||||
@@ -28,7 +28,7 @@ export function SubagentRunId(id: string): SubagentRunId {
|
||||
* {@link SubagentProvider.start}: a request that needs a capability the chosen provider lacks
|
||||
* is rejected with a typed error rather than accepted-then-ignored (the "fail loud, no silent
|
||||
* degradation" rule). These static flags cover features needed before a run exists; runtime
|
||||
* capabilities are optional methods whose presence is the capability — strict live steering
|
||||
* capabilities are optional methods whose presence is the capability — confirmed live steering
|
||||
* is {@link SubagentRun.steer} and persisted cold resume is {@link SubagentProvider.resume}. Each
|
||||
* flag corresponds one-to-one to a {@link SubagentStartRequest} option: `depthLimit` to
|
||||
* `maxDepth`; the other names match.
|
||||
@@ -221,19 +221,16 @@ export interface SubagentRun {
|
||||
*/
|
||||
dispose(): Promise<void>
|
||||
/**
|
||||
* OPTIONAL (strict live-steering capability): deliver additional content to
|
||||
* the actively running child turn. STRICT means delivery joins the observed
|
||||
* turn or fails — the implementation must synchronously verify, with no
|
||||
* asynchronous boundary before delivery, that the child is running and its
|
||||
* turn can still record the message, and must not fall back to a queue path
|
||||
* that could start a new, untracked turn or silently drop the message after
|
||||
* this run has settled. Throws when delivery cannot join the turn. A run
|
||||
* represents one disposable activation, so it has no cold-resume operation;
|
||||
* resuming a settled child goes through {@link SubagentProvider.resume}.
|
||||
* `source` is retained on the child's logged steering message without
|
||||
* changing its user role in model history.
|
||||
* OPTIONAL (confirmed live-steering capability): submit additional content
|
||||
* to the active child and fulfill only after a committed request snapshot
|
||||
* admits it. Rejects when terminal policy, cancellation, disposal, or a lost
|
||||
* settlement race prevents admission; it never falls through to a queued
|
||||
* untracked turn or cold resume. A run represents one disposable activation,
|
||||
* so resuming a settled child goes through {@link SubagentProvider.resume}.
|
||||
* `source` is retained on the admitted steering message without changing its
|
||||
* user role in model history.
|
||||
*/
|
||||
steer?(content: ContentBlock[], source: MessageSource): void
|
||||
steer?(content: ContentBlock[], source: MessageSource): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -101,7 +101,7 @@ describe('dsh-tool-subagent-control', () => {
|
||||
// Reach past the tool into the control service to fake a running route
|
||||
// deterministically: the tool is a thin adapter, so its steered wording is
|
||||
// what this test pins.
|
||||
ctx.subagentControl.sendMessage = (agent, _childId, message, messageSource) => {
|
||||
ctx.subagentControl.sendMessage = async (agent, _childId, message, messageSource) => {
|
||||
steered = (message[0] as { text: string }).text
|
||||
source = messageSource
|
||||
return { route: 'steered', taskId: ctx.tasks.list(agent)[0]?.id ?? ('subagent-9' as never) }
|
||||
|
||||
@@ -26,7 +26,7 @@ function stubAgent(ctx: Context, rawId: string): Agent {
|
||||
acceptsNextStep: false,
|
||||
ctx: scopeFiber.ctx,
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
|
||||
inject: () => {},
|
||||
send: () => {},
|
||||
updateInbox: (): 'not-found' => 'not-found',
|
||||
|
||||
@@ -228,7 +228,7 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
|
||||
steeredOptions.push(input)
|
||||
const id = input.id
|
||||
steeredIds.push(id)
|
||||
return id
|
||||
return { outcome: Promise.resolve({ status: 'admitted' as const, turn: 1, step: 1 }) }
|
||||
},
|
||||
inject(input) {
|
||||
injected.push(input.content)
|
||||
|
||||
@@ -5528,7 +5528,7 @@ describe('terminal mounting', () => {
|
||||
const session = ctx.sessions.create(SessionId('main'))
|
||||
ctx.agents.register({
|
||||
id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx,
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
})
|
||||
const terminal = new FakeTerminal()
|
||||
mountTui(ctx, { theme: { color: false } }, { terminal, exit: vi.fn() })
|
||||
@@ -5553,7 +5553,7 @@ describe('terminal mounting', () => {
|
||||
const session = ctx.sessions.create(SessionId('main'))
|
||||
ctx.agents.register({
|
||||
id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx,
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
})
|
||||
const terminal = new FakeTerminal()
|
||||
// Mirror dsh-tui's own inject (minus loader, the absence under test).
|
||||
@@ -5588,14 +5588,14 @@ describe('terminal mounting', () => {
|
||||
const otherSession = ctx.sessions.create(SessionId('other-session'))
|
||||
ctx.agents.register({
|
||||
id: otherSession.id, options: {}, session: otherSession, status: 'idle', acceptsNextStep: false, ctx,
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
})
|
||||
expect(terminal.started).toBe(0)
|
||||
|
||||
const session = ctx.sessions.create(SessionId('late-session'))
|
||||
const agent = {
|
||||
id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx,
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
} as Agent
|
||||
ctx.agents.register(agent)
|
||||
await tick()
|
||||
@@ -5626,7 +5626,7 @@ describe('terminal mounting', () => {
|
||||
const session = ctx.sessions.create(SessionId('main-session'))
|
||||
ctx.agents.register({
|
||||
id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx,
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
})
|
||||
await tick()
|
||||
expect(terminal.started).toBe(0)
|
||||
@@ -5670,7 +5670,7 @@ describe('terminal mounting', () => {
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
ctx.agents.register({
|
||||
id: session.id, options: {}, session, status: 'running', acceptsNextStep: true, ctx,
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
})
|
||||
const terminal = new FakeTerminal()
|
||||
terminal.start = () => { throw new Error('terminal startup failed') }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"AGENTS.md": 1775,
|
||||
"docs/AGENTS.md": 1150,
|
||||
"docs/architecture.md": 2040,
|
||||
"docs/architecture.md": 2160,
|
||||
"docs/cordis-primer.md": 600,
|
||||
"docs/defensive-patterns.md": 550,
|
||||
"docs/testing.md": 1100,
|
||||
|
||||
Reference in New Issue
Block a user