diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml index a4bc2722c2..10165b1d7a 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml @@ -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: af7ef5c18c2af925e64b309d76e31ee079360b81 -2026-07-21-continuable-background-subagents.zh.md: 0c9e2e4d87e50ebb02cafe8f2333dca81ef8c5da +2026-07-21-continuable-background-subagents.md: 6552db82dc5cf1fabac8f18dd347cc8735f73587 +2026-07-21-continuable-background-subagents.zh.md: ed07abd2af34397d056cc022fc451e6397964acb diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md index af7ef5c18c..6552db82dc 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md @@ -55,9 +55,9 @@ The control service does not serialize two callers that race a stopped child thr ### Model-facing `send_message` -The model receives one `send_message(subagent_id, message)` tool backed by `SubagentControlService.sendMessage()`. The control operation owns steer-or-resume orchestration and is distinct from the run's `SubagentRun.steer?()`, which only delivers to an already active run. The tool performs no lifecycle routing of its own. It lives in the separately loaded `@deepseek-ai/dsh-tool-subagent-control` package so provider-bound `@deepseek-ai/dsh-tool-subagent` instances can continue registering distinct delegation tools for spawn, fork, or ACP without registering duplicate global control tools. +The model receives one `send_message(subagent_id, message)` tool backed by `SubagentControlService.sendMessage()`. The control operation owns steer-or-resume orchestration and is distinct from the run's `SubagentRun.steer?()`, which only delivers to an already active run. The tool performs no lifecycle routing of its own. It attributes the follow-up as `{ kind: 'coordinator', senderSessionId: parent.id }`; the control service requires a caller-supplied `MessageSource` and carries it through both live steering and cold resume. The child model still receives ordinary user-role content, while the durable source prevents model-generated follow-ups from being classified as direct human input. A human adapter instead supplies `{ kind: 'user' }`. The tool lives in the separately loaded `@deepseek-ai/dsh-tool-subagent-control` package so provider-bound `@deepseek-ai/dsh-tool-subagent` instances can continue registering distinct delegation tools for spawn, fork, or ACP without registering duplicate global control tools. -- If the child has a running Task and live-steering capability, the service calls `run.steer(message)` and returns the existing Task id; it creates no Task of its own. +- 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. @@ -107,8 +107,8 @@ Task records and active-run associations are process-local. Persistence makes th ## Testing -- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` pins the continuable durability boundary: a permanent flush failure rejects with `DURABILITY_FAILED` and its cause, a transient loop-checkpoint failure can succeed on the final confirmation, resume also confirms durability, and foreground runs remain best-effort. `packages/subagent/subagent-control/tests/subagent-control.spec.ts` drives the real stack (agent loop, JSONL persistence, spawn/fork providers, Task service and surface, control service) keylessly: initial and resumed activations create fresh Tasks and dispose their runs before terminal; the descriptor event is turn-enclosed, model-hidden, versioned, and durable under the control-allocated child id; `task_kill` during a run or during cold-resume lookup settles `killed` after quiescence with no child work; steering joins the running Task without a second Task; cold follow-ups accumulate turns in one durable transcript with the declared composition reconstructed; fork resume keeps the persisted seed boundary and never re-forks newer parent history; resumed depth uses the persisted header floor; foreign-parent, descriptor-less, and unmaterialized ids fail their started Task with the id unavailable; ownership conflicts and steering-settlement races report not-delivered without cold-resume fallthrough; competing sends during resume load are admitted once. -- `packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts` pins the `send_message` schema, both route renderings, the not-delivered failure, the no-agent rejection, and HMR disposal. +- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` pins the continuable durability boundary: a permanent flush failure rejects with `DURABILITY_FAILED` and its cause, a transient loop-checkpoint failure can succeed on the final confirmation, resume also confirms durability, and foreground runs remain best-effort. `packages/subagent/subagent-control/tests/subagent-control.spec.ts` drives the real stack (agent loop, JSONL persistence, spawn/fork providers, Task service and surface, control service) keylessly: initial and resumed activations create fresh Tasks and dispose their runs before terminal; the descriptor event is turn-enclosed, model-hidden, versioned, and durable under the control-allocated child id; `task_kill` during a run or during cold-resume lookup settles `killed` after quiescence with no child work; steering joins the running Task without a second Task and retains the caller source; cold follow-ups accumulate turns in one durable transcript with their source and declared composition reconstructed; fork resume keeps the persisted seed boundary and never re-forks newer parent history; resumed depth uses the persisted header floor; foreign-parent, descriptor-less, and unmaterialized ids fail their started Task with the id unavailable; ownership conflicts and steering-settlement races report not-delivered without cold-resume fallthrough; competing sends during resume load are admitted once. +- `packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts` pins the `send_message` schema, coordinator attribution, both route renderings, the not-delivered failure, the no-agent rejection, and HMR disposal. - `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` covers the capability-branched background route: a resumable provider returns both ids through the control service and advertises `send_message`, a one-shot provider keeps the plain task acknowledgement, and a resumable provider without the control service fails loud. - The keyless ACP snapshot scenario `subagent-continuable` (examples/acp-agent) pins the model-visible transcript: the two-id acknowledgement, a final durability-confirmation failure rendered through `task_output` without unconfirmed child output, and a `send_message` follow-up whose started Task fails with the id unavailable. diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md index 0c9e2e4d87..ed07abd2af 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md @@ -55,9 +55,9 @@ durable child Session ### 面向模型的 `send_message` -模型获得一个由 `SubagentControlService.sendMessage()` 支撑的 `send_message(subagent_id, message)` 工具。控制操作负责在 steering 与恢复之间编排;它不同于 run 的 `SubagentRun.steer?()`,后者只能向已活跃的 run 发送消息。工具本身不执行生命周期路由。该工具位于单独加载的 `@deepseek-ai/dsh-tool-subagent-control` 包中,因此按提供方绑定的 `@deepseek-ai/dsh-tool-subagent` 实例可以继续为 spawn、fork 或 ACP 注册不同的委派工具,而不会重复注册全局控制工具。 +模型获得一个由 `SubagentControlService.sendMessage()` 支撑的 `send_message(subagent_id, message)` 工具。控制操作负责在 steering 与恢复之间编排;它不同于 run 的 `SubagentRun.steer?()`,后者只能向已活跃的 run 发送消息。工具本身不执行生命周期路由。该工具将后续消息的来源标记为 `{ kind: 'coordinator', senderSessionId: parent.id }`;控制服务要求调用方提供 `MessageSource`,并在在线 steering 与 cold resume 两条路径中传递该来源。child 模型收到的仍是普通的 user role 内容,而持久化的来源信息可防止模型生成的后续消息被归类为直接用户输入。用户适配器则提供 `{ kind: 'user' }`。该工具位于单独加载的 `@deepseek-ai/dsh-tool-subagent-control` 包中,因此按提供方绑定的 `@deepseek-ai/dsh-tool-subagent` 实例可以继续为 spawn、fork 或 ACP 注册不同的委派工具,而不会重复注册全局控制工具。 -- 如果 child 存在运行中的 Task 并支持在线消息,服务会调用 `run.steer(message)` 并返回现有 task id;它不会创建新 Task。 +- 如果 child 存在运行中的 Task 并支持在线消息,服务会调用 `run.steer(message, source)` 并返回现有 task id;它不会创建新 Task。 - 如果 child 没有运行中的 Task,`send_message` 会创建新 Task,使用该消息从持久化存储恢复会话,并返回新的 task id。 - 如果活跃提供方无法接收在线消息、严格 steering 在与 Task 结算的竞态中失败,或 Task 关联之外存在存活 child,`send_message` 会失败,而不会静默启动、恢复或接管未受跟踪的轮次。 @@ -107,8 +107,8 @@ Task 记录和活跃 run 关联都位于进程内。持久化使 child 会话可 ## 测试 -- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` 固定可继续执行的持久性边界:flush 持续失败时会以 `DURABILITY_FAILED` 拒绝并保留失败原因,循环检查点的瞬时失败可在最终确认成功后继续完成,resume 同样会确认持久性,而前台运行仍采用尽力而为策略。`packages/subagent/subagent-control/tests/subagent-control.spec.ts` 以无密钥方式驱动真实栈(agent loop、JSONL 持久化、spawn/fork 提供方、Task 服务与控制面、控制服务):初始及恢复后的激活都会创建新 Task,并在进入终态前 dispose 各自的 run;描述符事件位于轮次内、对模型隐藏、带版本,并在控制服务分配的 child id 下持久化;在 run 运行期间或 cold resume 查找期间执行 `task_kill`,会在完全停稳后结算为 `killed`,且不产生任何 child 工作;steering 会加入运行中的 Task,而不创建第二个 Task;cold follow-up 会在一份持久化 transcript 中累积轮次,并重建声明的组合配置;恢复 fork 会保持持久化 seed 边界,绝不重新 fork parent 更新后的历史;恢复后的深度以持久化 header 为下界;外来 parent、无描述符及 unmaterialized 的 id 会带着「id 不可用」使其已启动的 Task 失败;所有权冲突和 steering 与结算的竞态会报告未送达,且不改用从持久化存储恢复路径;resume 加载期间竞争的发送只准入一次。 -- `packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts` 固定 `send_message` 的 schema、两种路由渲染、未送达失败、无 agent 时的拒绝,以及 HMR(热模块替换)dispose。 +- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` 固定可继续执行的持久性边界:flush 持续失败时会以 `DURABILITY_FAILED` 拒绝并保留失败原因,循环检查点的瞬时失败可在最终确认成功后继续完成,resume 同样会确认持久性,而前台运行仍采用尽力而为策略。`packages/subagent/subagent-control/tests/subagent-control.spec.ts` 以无密钥方式驱动真实栈(agent loop、JSONL 持久化、spawn/fork 提供方、Task 服务与控制面、控制服务):初始及恢复后的激活都会创建新 Task,并在进入终态前 dispose 各自的 run;描述符事件位于轮次内、对模型隐藏、带版本,并在控制服务分配的 child id 下持久化;在 run 运行期间或 cold resume 查找期间执行 `task_kill`,会在完全停稳后结算为 `killed`,且不产生任何 child 工作;steering 会加入运行中的 Task,不创建第二个 Task,并保留调用方来源;cold follow-up 会在一份持久化 transcript 中累积轮次,并重建其来源和声明的组合配置;恢复 fork 会保持持久化 seed 边界,绝不重新 fork parent 更新后的历史;恢复后的深度以持久化 header 为下界;外来 parent、无描述符及 unmaterialized 的 id 会带着「id 不可用」使其已启动的 Task 失败;所有权冲突和 steering 与结算的竞态会报告未送达,且不改用从持久化存储恢复路径;resume 加载期间竞争的发送只准入一次。 +- `packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts` 固定 `send_message` 的 schema、coordinator 来源标记、两种路由渲染、未送达失败、无 agent 时的拒绝,以及 HMR(热模块替换)dispose。 - `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` 覆盖按功能分支的后台路由:可恢复的提供方会通过控制服务返回两个 id 并公开 `send_message`,一次性提供方保持普通的 task 确认消息,而缺少控制服务的可恢复提供方会明确失败。 - 无密钥 ACP 快照场景 `subagent-continuable`(examples/acp-agent)固定模型可见的 transcript:双 id 确认消息、最终持久性确认失败(该失败通过 `task_output` 呈现,且不包含未经确认的 child 输出),以及一次 `send_message` 后续操作——其已启动的 Task 会带着「id 不可用」失败。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index e73ce42c82..76ee56bfb3 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1980,15 +1980,16 @@ startContinuable(spec: ContinuableStartSpec): ContinuableStart * @param parent - the live parent agent sending the message (model tool or * human adapter); Task access is authorized by its session id. * @param childId - the stable child session id. - * @param message - the content to deliver. + * @param message - the user-role content to deliver. + * @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[]): SendMessageResult +sendMessage(parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource): 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) · [SendMessageResult](../core-data-structures/subagent.md) · [SessionId](../core-data-structures/core.md) +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) -Source: [`packages/subagent/subagent-control/src/index.ts:163`](../../packages/subagent/subagent-control/src/index.ts) +Source: [`packages/subagent/subagent-control/src/index.ts:176`](../../packages/subagent/subagent-control/src/index.ts) ## `ctx.subagents` — `SubagentService` diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index b547305e8d..b746073424 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -6,7 +6,7 @@ The subagent seam — an agent delegating work to a child agent. Like [bash](bas Interface: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Implementations are sibling packages (`dsh-subagent-spawn`, `-fork`, `-acp`); the model-facing consumers are [dsh-tool-subagent](../../packages/subagent/tool-subagent) (per-provider delegation) and [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) (the global `send_message`). Continuable-child orchestration lives on `ctx.subagentControl` in [dsh-subagent-control](../../packages/subagent/subagent-control). The proposals and rationale: [the subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md) and [the continuable background subagents Agent Note](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md). -Source: [`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts) +Sources: [`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts) and [`packages/subagent/subagent-control/src/index.ts`](../../packages/subagent/subagent-control/src/index.ts) ## Two kinds of capability, discovered two ways @@ -105,7 +105,16 @@ interface SubagentStartRequest { ## Continuable children: `SubagentContinuation` and `SubagentResumeRequest` -A **continuable background subagent** is a durable child session with a series of Task-backed activations. `ctx.subagentControl` (`SubagentControlService` in [dsh-subagent-control](../../packages/subagent/subagent-control)) allocates the stable child id, snapshots the versioned `subagent/descriptor` payload, and passes both through the resolved start request; the provider publishes exactly that id and appends the descriptor inside the child's first turn. On follow-up, the control service loads the persisted child, authorizes the recorded `parentSession` as the direct parent, folds the descriptor, and dispatches a fully resolved resume request through `SubagentService.resume()` to `SubagentProvider.resume()`. The seam stays Task- and persistence-agnostic — descriptor lookup and Task association live only in the control service. `startContinuable()` returns a `ContinuableStart` (both identities), and `sendMessage()` returns a `SendMessageResult` reporting whether the message `steered` the running activation's existing Task or `started` a fresh one. +A **continuable background subagent** is a durable child session with a series of Task-backed activations. `ctx.subagentControl` (`SubagentControlService` in [dsh-subagent-control](../../packages/subagent/subagent-control)) allocates the stable child id, snapshots the versioned `subagent/descriptor` payload, and passes both through the resolved start request; the provider publishes exactly that id and appends the descriptor inside the child's first turn. On follow-up, the control service loads the persisted child, authorizes the recorded `parentSession` as the direct parent, folds the descriptor, and dispatches a fully resolved resume request through `SubagentService.resume()` to `SubagentProvider.resume()`. The seam stays Task- and persistence-agnostic — descriptor lookup and Task association live only in the control service. `startContinuable()` returns a `ContinuableStart` (both identities), and `sendMessage()` returns a `SendMessageResult` reporting whether the message `steered` the running activation's existing Task or `started` a fresh one. Every sender supplies a `MessageSource`; the model-facing tool uses `CoordinatorMessageSource`, while a human adapter uses `{ kind: 'user' }`. Both project to a user-role model message, but the durable source remains distinct for policy and title consumers. + +```ts type-equiv +/** Attribution for a model coordinator's follow-up to one of its children. */ +interface CoordinatorMessageSource { + readonly kind: 'coordinator' + /** Session id of the agent whose tool call produced the follow-up. */ + readonly senderSessionId: SessionId +} +``` ```ts type-equiv /** @@ -134,6 +143,8 @@ interface SubagentResumeRequest { readonly sessionId: SessionId /** The follow-up message that starts the resumed activation's turn. */ readonly prompt: ContentBlock[] + /** Attribution retained when the follow-up becomes the resumed turn's user-role message. */ + readonly source: MessageSource /** * The live parent agent — the direct parent recorded in the persisted child * header. In-process backends reconstruct the child under this agent's @@ -203,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 deliberately does NOT live here (a disposed run cannot be reconstructed after restart) — it is `SubagentProvider.resume`. +`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. ```ts type-equiv /** @@ -249,8 +260,10 @@ interface SubagentRun { * 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. */ - steer?(content: ContentBlock[]): void + steer?(content: ContentBlock[], source: MessageSource): void } ``` diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index b090b438e6..749ba48359 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -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[]): 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 content to deliver.\n * @returns whether the message `steered` the existing Task or `started` a new one.\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 */', }, ], }, @@ -2699,11 +2699,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentResumeRequest', - declaration: 'export interface SubagentResumeRequest {\n readonly sessionId: SessionId;\n readonly prompt: ContentBlock[];\n readonly parent: Agent;\n readonly signal: AbortSignal;\n readonly descriptor: SubagentDescriptorData;\n}', + declaration: 'export interface SubagentResumeRequest {\n readonly sessionId: SessionId;\n readonly prompt: ContentBlock[];\n readonly source: MessageSource;\n readonly parent: Agent;\n readonly signal: AbortSignal;\n readonly descriptor: SubagentDescriptorData;\n}', }, { name: 'SubagentRun', - declaration: 'export interface SubagentRun {\n readonly id: SessionId;\n readonly localAgent: Agent | undefined;\n readonly result: Promise;\n dispose(): Promise;\n steer?(content: ContentBlock[]): void;\n}', + declaration: 'export interface SubagentRun {\n readonly id: SessionId;\n readonly localAgent: Agent | undefined;\n readonly result: Promise;\n dispose(): Promise;\n steer?(content: ContentBlock[], source: MessageSource): void;\n}', }, { name: 'SubagentStartRequest', diff --git a/packages/subagent/subagent-control/README.md b/packages/subagent/subagent-control/README.md index e65c6dcf1c..141333f719 100644 --- a/packages/subagent/subagent-control/README.md +++ b/packages/subagent/subagent-control/README.md @@ -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)` owns steer-or-resume routing. A running activation receives live delivery 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()` (`started`). 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 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. 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). diff --git a/packages/subagent/subagent-control/src/index.ts b/packages/subagent/subagent-control/src/index.ts index ab0fab6d9d..9de5459451 100644 --- a/packages/subagent/subagent-control/src/index.ts +++ b/packages/subagent/subagent-control/src/index.ts @@ -20,7 +20,7 @@ import { randomUUID } from 'node:crypto' import { Context, Service } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { HarnessError } from '@deepseek-ai/dsh-llm' -import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import { foldSubagentDescriptor, snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' @@ -33,6 +33,19 @@ declare module 'cordis' { } } +/** Attribution for a model coordinator's follow-up to one of its children. */ +export interface CoordinatorMessageSource { + readonly kind: 'coordinator' + /** Session id of the agent whose tool call produced the follow-up. */ + readonly senderSessionId: SessionId +} + +declare module '@deepseek-ai/dsh-llm' { + interface MessageSourceMap { + coordinator: CoordinatorMessageSource + } +} + /** Typed error for control-service routing, authorization, and delivery failures. */ export class SubagentControlError extends HarnessError { constructor(message: string, code: string, options?: ErrorOptions) { @@ -248,16 +261,20 @@ export class SubagentControlService extends Service { * @param parent - the live parent agent sending the message (model tool or * human adapter); Task access is authorized by its session id. * @param childId - the stable child session id. - * @param message - the content to deliver. + * @param message - the user-role content to deliver. + * @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[]): SendMessageResult { + sendMessage(parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource): SendMessageResult { this.assertOwnership(childId) const activation = this.activations.get(childId) if (activation !== undefined) { - return { route: 'steered', taskId: this.steerActivation(activation, parent, childId, message) } + return { + route: 'steered', + taskId: this.steerActivation(activation, parent, childId, message, source), + } } - return { route: 'started', taskId: this.resumeActivation(parent, childId, message) } + return { route: 'started', taskId: this.resumeActivation(parent, childId, message, source) } } /** @@ -290,6 +307,7 @@ export class SubagentControlService extends Service { parent: Agent, childId: SessionId, message: ContentBlock[], + source: MessageSource, ): 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. */ @@ -316,7 +334,7 @@ export class SubagentControlService extends Service { ) } try { - run.steer(message) + run.steer(message, source) } catch (error: unknown) { // Strict steering lost the race with turn settlement. Deliberately no // cold-resume fallback here: that would attach the message to a turn the @@ -337,7 +355,12 @@ export class SubagentControlService extends Service { * activation, with cancellation rechecked after the un-signalled * persistence await so an early `task_kill` prevents any later child work. */ - private resumeActivation(parent: Agent, childId: SessionId, message: ContentBlock[]): TaskId { + private resumeActivation( + parent: Agent, + childId: SessionId, + message: ContentBlock[], + source: MessageSource, + ): TaskId { const persistence = this.requirePersistence() return this.startActivation(childId, resumeLabel(message), parent, async (signal) => { let loaded: Awaited> @@ -374,6 +397,7 @@ export class SubagentControlService extends Service { return this.ctx.subagents.resume(descriptor.provider, { sessionId: childId, prompt: message, + source, parent, signal, descriptor, diff --git a/packages/subagent/subagent-control/tests/subagent-control.spec.ts b/packages/subagent/subagent-control/tests/subagent-control.spec.ts index 458e6a6960..c81b88b756 100644 --- a/packages/subagent/subagent-control/tests/subagent-control.spec.ts +++ b/packages/subagent/subagent-control/tests/subagent-control.spec.ts @@ -107,6 +107,20 @@ function message(text: string) { return [{ type: 'text' as const, text }] } +const coordinatorSource = { + kind: 'coordinator', + senderSessionId: SessionId('parent'), +} as const + +function sendMessage( + ctx: Context, + parent: Agent, + childId: SessionId, + content: ReturnType, +) { + return ctx.subagentControl.sendMessage(parent, childId, content, { kind: 'user' }) +} + describe('SubagentControlService.startContinuable', () => { it('returns both identities immediately; the Task settles with the child result after disposal', async () => { const { ctx, parent } = await setup([textResponse('first answer')]) @@ -202,7 +216,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 = ctx.subagentControl.sendMessage(parent, started.childId, message('hello?')) + const followUp = 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') @@ -250,14 +264,14 @@ describe('SubagentControlService.sendMessage', () => { await waitPublishedRun(ctx, started.childId) expect(descriptor).toEqual({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'no-steer' }) - expect(() => ctx.subagentControl.sendMessage(parent, started.childId, message('join'))) + expect(() => sendMessage(ctx, parent, started.childId, message('join'))) .toThrow(/provider does not accept live delivery/) let terminalDeliveryError: unknown ctx.tasks.onTaskDone((snapshot) => { if (snapshot.id !== started.taskId) return try { - ctx.subagentControl.sendMessage(parent, started.childId, message('after terminal')) + sendMessage(ctx, parent, started.childId, message('after terminal')) } catch (error: unknown) { terminalDeliveryError = error } @@ -296,7 +310,7 @@ describe('SubagentControlService.sendMessage', () => { const started = ctx.subagentControl.startContinuable(startSpec(parent, 'mismatched-local')) await waitPublishedRun(ctx, started.childId) - expect(() => ctx.subagentControl.sendMessage(parent, started.childId, message('join'))) + expect(() => sendMessage(ctx, parent, started.childId, message('join'))) .toThrow(/registry agent is not the associated activation's agent/) result.resolve({ output: [{ type: 'text', text: 'done' }], stopReason: 'completed' }) await waitTerminal(ctx, started.taskId, parent) @@ -324,7 +338,12 @@ describe('SubagentControlService.sendMessage', () => { }, 5) }) - const delivered = ctx.subagentControl.sendMessage(parent, started.childId, message('also consider Y')) + const delivered = ctx.subagentControl.sendMessage( + parent, + started.childId, + message('also consider Y'), + coordinatorSource, + ) expect(delivered).toEqual({ route: 'steered', taskId: started.taskId }) releaseFirst() const snapshot = await waitTerminal(ctx, started.taskId, parent) @@ -334,6 +353,11 @@ describe('SubagentControlService.sendMessage', () => { // The steered content joined the SAME child turn and drove another step. const output = ctx.tasks.read(started.taskId, parent) expect(output.text).toBe('steered turn answer') + const loaded = await ctx.sessionPersistence.load(started.childId) + const steering = loaded.events.find( + (event): event is SessionEvent<'steering/message'> => event.type === 'steering/message', + ) + expect(steering?.data.message.source).toEqual(coordinatorSource) }) it('cold-resumes a settled child into a fresh Task and reports `started`', async () => { @@ -342,7 +366,12 @@ describe('SubagentControlService.sendMessage', () => { await waitTerminal(ctx, started.taskId, parent) expect(ctx.agents.get(started.childId)).toBeUndefined() - const followUp = ctx.subagentControl.sendMessage(parent, started.childId, message('and then?')) + const followUp = ctx.subagentControl.sendMessage( + parent, + started.childId, + message('and then?'), + coordinatorSource, + ) expect(followUp.route).toBe('started') expect(followUp.taskId).not.toBe(started.taskId) const snapshot = await waitTerminal(ctx, followUp.taskId, parent) @@ -356,6 +385,8 @@ describe('SubagentControlService.sendMessage', () => { const userMessages = loaded.events.filter((event): event is SessionEvent<'user/message'> => event.type === 'user/message') expect(userMessages.map(event => (event.data.content[0] as { text: string }).text)) .toEqual(['child task', 'and then?']) + expect(userMessages.map(event => event.data.source)) + .toEqual([{ kind: 'user' }, coordinatorSource]) }) it('reconstructs the declared composition on cold resume', async () => { @@ -378,7 +409,7 @@ describe('SubagentControlService.sendMessage', () => { expect(descriptor?.data.persona).toBe('You are the resumable child.') expect(descriptor?.data.toolFilter).toEqual({ deny: [] }) - const followUp = ctx.subagentControl.sendMessage(parent, started.childId, message('continue')) + const followUp = 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. @@ -407,7 +438,7 @@ describe('SubagentControlService.sendMessage', () => { parent.followup(createUserMessage({ content: message('parent question two'), source: { kind: 'user' } })) await parent.whenIdle() - const followUp = ctx.subagentControl.sendMessage(parent, started.childId, message('follow up')) + const followUp = 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. @@ -423,7 +454,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 = ctx.subagentControl.sendMessage(parent, started.childId, message('go on')) + const followUp = sendMessage(ctx, parent, started.childId, message('go on')) const childAgents: Agent[] = [] const stop = ctx.on('agent/created', (agent: Agent) => { @@ -443,7 +474,7 @@ describe('SubagentControlService.sendMessage', () => { const started = ctx.subagentControl.startContinuable(startSpec(otherParent)) await waitTerminal(ctx, started.taskId, otherParent) - const attempt = ctx.subagentControl.sendMessage(parent, started.childId, message('mine now')) + const attempt = 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') @@ -462,7 +493,7 @@ describe('SubagentControlService.sendMessage', () => { await handle.agent.whenIdle() await handle.dispose() - const attempt = ctx.subagentControl.sendMessage(parent, SessionId('plain-child'), message('continue?')) + const attempt = 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( @@ -472,9 +503,9 @@ describe('SubagentControlService.sendMessage', () => { it('derives fallback and bounded labels for resumed activations', async () => { const { ctx, parent } = await setup([]) - const blank = ctx.subagentControl.sendMessage(parent, SessionId('blank-child'), message(' ')) + const blank = sendMessage(ctx, parent, SessionId('blank-child'), message(' ')) const longText = 'x'.repeat(100) - const long = ctx.subagentControl.sendMessage(parent, SessionId('long-child'), message(longText)) + const long = 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)}…`) @@ -492,9 +523,9 @@ describe('SubagentControlService.sendMessage', () => { meta: { parentSession: parent.id }, agentOptions: { provider: 'mock', model: 'mock' }, }) - expect(() => ctx.subagentControl.sendMessage(parent, SessionId('rogue-child'), message('hello'))) + expect(() => sendMessage(ctx, parent, SessionId('rogue-child'), message('hello'))) .toThrow(SubagentControlError) - expect(() => ctx.subagentControl.sendMessage(parent, SessionId('rogue-child'), message('hello'))) + expect(() => sendMessage(ctx, parent, SessionId('rogue-child'), message('hello'))) .toThrow(/outside control-service ownership.*not delivered/) await handle.dispose() }) @@ -535,13 +566,13 @@ describe('SubagentControlService.sendMessage', () => { // Strict steering finds the settled child, fails loud, and does NOT start // a cold resume within this call. - expect(() => ctx.subagentControl.sendMessage(parent, started.childId, message('too late?'))) + expect(() => sendMessage(ctx, parent, started.childId, message('too late?'))) .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 = ctx.subagentControl.sendMessage(parent, started.childId, message('retry')) + const retry = sendMessage(ctx, parent, started.childId, message('retry')) expect(retry.route).toBe('started') await waitTerminal(ctx, retry.taskId, parent) }) @@ -550,7 +581,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 = ctx.subagentControl.sendMessage(parent, started.childId, message('more')) + const followUp = 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/) }) @@ -569,7 +600,7 @@ describe('SubagentControlService.sendMessage', () => { return realLoad(id) } - const followUp = ctx.subagentControl.sendMessage(parent, started.childId, message('follow up')) + const followUp = 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) @@ -591,11 +622,11 @@ describe('SubagentControlService.sendMessage', () => { return realLoad(id) } - const first = ctx.subagentControl.sendMessage(parent, started.childId, message('first follow-up')) + const first = 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(() => ctx.subagentControl.sendMessage(parent, started.childId, message('second follow-up'))) + expect(() => sendMessage(ctx, parent, started.childId, message('second follow-up'))) .toThrow(/not delivered/) releaseLoad() const snapshot = await waitTerminal(ctx, first.taskId, parent) diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index aaad34ccf8..f48257ad02 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -11,7 +11,7 @@ import { randomUUID } from 'node:crypto' import type { Context } from 'cordis' import type { Agent, AgentHandle, AgentOptions } from '@deepseek-ai/dsh-agent' import { findLastMessageTurnEnd, SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' -import { createUserMessage, errorChain, type ContentBlock } from '@deepseek-ai/dsh-llm' +import { createUserMessage, errorChain, type ContentBlock, type MessageSource } from '@deepseek-ai/dsh-llm' import { assertSubagentMaxDepth, delegationDepthOf, SubagentError } from '@deepseek-ai/dsh-subagent' import type { SubagentDescriptorData, @@ -70,6 +70,14 @@ export interface InProcessRunOptions { /** Whether one activation must prove its final state durable before success. */ type Durability = 'best-effort' | 'required' +/** Activation-specific inputs to the shared in-process driver. */ +interface DriveTurnOptions { + readonly durability: Durability + /** Attribution for a resumed activation's follow-up prompt. */ + readonly source?: MessageSource + readonly structured?: StructuredAttachment +} + /** Error used when cancellation wins before the child publication boundary. */ function prePublicationAbort(): Error { return new Error('subagent request was aborted before child publication') @@ -177,8 +185,10 @@ export async function startInProcessRun( request.prompt, childId, seedLength, - request.continuation === undefined ? 'best-effort' : 'required', - structured, + { + durability: request.continuation === undefined ? 'best-effort' : 'required', + ...structured === undefined ? {} : { structured }, + }, ) } @@ -214,7 +224,14 @@ export async function resumeInProcessRun(request: SubagentResumeRequest): Promis // The result boundary is this activation's own work: everything already in // the resumed transcript belongs to earlier turns. const resumePoint = handle.agent.session.events.length - return driveTurn(handle, request.signal, request.prompt, request.sessionId, resumePoint, 'required') + return driveTurn( + handle, + request.signal, + request.prompt, + request.sessionId, + resumePoint, + { durability: 'required', source: request.source }, + ) } /** @@ -230,10 +247,10 @@ function driveTurn( prompt: ContentBlock[], childId: SessionId, boundary: number, - durability: Durability, - structured?: StructuredAttachment, + options: DriveTurnOptions, ): SubagentRun | Promise { const child = handle.agent + const { durability, source, structured } = options // Agent creation detaches its creation-only abort listener before returning. // Close the narrow handoff race before installing the live-run listener. if (signal.aborted) { @@ -249,7 +266,7 @@ function driveTurn( const result: Promise = (async () => { try { - child.followup(createUserMessage({ content: prompt, source: { kind: 'user' } })) + child.followup(createUserMessage({ content: prompt, source: source ?? { kind: 'user' } })) await child.whenIdle() if (durability === 'required') { try { @@ -282,31 +299,27 @@ function driveTurn( flags.cancelled = true return handle.dispose() }, - steer(content: ContentBlock[]): void { - // Strict live delivery: the synchronous checks and the Agent.steer() - // call share one frame, so delivery joins the observed turn or throws. - // Agent.steer()'s own idle fallback would instead QUEUE the message and + 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. if (child.status !== 'running') { throw new Error(`subagent child "${childId}" is not running; the message was not delivered`) } - // The status stays `running` through the closed turn's durability flush, - // and the loop DISCARDS terminal-stopped steering drained after turn - // close instead of recording it. Requiring an open turn keeps - // acknowledged delivery honest. + // 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`) } - // Turn settlement only runs between steps: with no step open, the loop - // may be awaiting its continuation/turn-stopping checkpoint, where - // pending steering was already folded and a later arrival would miss - // this turn. A message accepted during an OPEN step is instead - // drained and recorded at that step's settlement checkpoint before any - // terminal decision (cancellation remains the documented shared-outcome - // race). + // 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', ) @@ -324,7 +337,7 @@ function driveTurn( if (child.trySteer === undefined) { throw new Error(`subagent child "${childId}" agent does not support strict steering; the message was not delivered`) } - if (!child.trySteer(createUserMessage({ content, source: { kind: 'user' } }))) { + if (!child.trySteer(createUserMessage({ content, source: steeringSource }))) { throw new Error(`subagent child "${childId}" passed its steering checkpoint; the message was not delivered`) } }, diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index 3ed95d159d..a9ef98a892 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -130,7 +130,7 @@ describe('in-process structured output', () => { if (session.header.parentSession === undefined || run === undefined || event.type !== 'tool/result' || rejected !== undefined) return try { - run.steer?.([{ type: 'text', text: 'one more thing' }]) + run.steer?.([{ type: 'text', text: 'one more thing' }], { kind: 'user' }) } catch (error: unknown) { rejected = error } diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index bcec292d93..b26ca6b84d 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -262,6 +262,7 @@ describe('startInProcessRun', () => { await expect(resumeInProcessRun({ sessionId: SessionId('resumed-child'), prompt: [{ type: 'text', text: 'continue' }], + source: { kind: 'user' }, parent, signal: controller.signal, descriptor: { version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn' }, @@ -309,6 +310,7 @@ describe('startInProcessRun', () => { const run = await resumeInProcessRun({ sessionId: childId, prompt: [{ type: 'text', text: 'continue' }], + source: { kind: 'plugin', plugin: 'test-coordinator' }, parent, signal: new AbortController().signal, descriptor: { version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn' }, @@ -384,7 +386,7 @@ describe('startInProcessRun', () => { 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' }]) }) + expect(() => { run.steer!([{ type: 'text', text: 'late' }], { kind: 'user' }) }) .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) @@ -410,7 +412,9 @@ describe('startInProcessRun', () => { }, 5) }) expect(child.status).toBe('running') - expect(() => { run.steer!([{ type: 'text', text: 'too late for this turn' }]) }) + 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 @@ -427,10 +431,10 @@ describe('startInProcessRun', () => { 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' }]) + 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' }]) + run.steer?.([{ type: 'text', text: 'after the drain began' }], { kind: 'user' }) } catch (error: unknown) { rejected = error } @@ -493,7 +497,9 @@ describe('startInProcessRun', () => { } as unknown as Agent const run = await startInProcessRun(request(parent), {}) - expect(() => { run.steer!([{ type: 'text', text: 'unsupported strict delivery' }]) }) + 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 @@ -520,7 +526,7 @@ describe('startInProcessRun', () => { }, 5) }) expect(child.status).toBe('running') - expect(() => { run.steer!([{ type: 'text', text: 'into the void' }]) }) + expect(() => { run.steer!([{ type: 'text', text: 'into the void' }], { kind: 'user' }) }) .toThrow(/turn has already closed; the message was not delivered/) releaseFlush!() await run.result diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index 1e43b074b4..8b55dd25f9 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -246,7 +246,7 @@ describe('dsh-subagent-spawn', () => { // Strict 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' }]) }) + expect(() => { run.steer!([{ type: 'text', text: 'late' }], { kind: 'user' }) }) .toThrow(/not running; the message was not delivered/) await run.dispose() }) diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 0b76723e26..0806a01554 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -6,7 +6,7 @@ import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent' import type { Branded } from '@deepseek-ai/dsh-brand' -import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { SessionId } from '@deepseek-ai/dsh-session' import type { ObjectJsonSchema, ToolRestriction } from '@deepseek-ai/dsh-tools' import type { SubagentDescriptorData } from './descriptor.ts' @@ -128,6 +128,8 @@ export interface SubagentResumeRequest { readonly sessionId: SessionId /** The follow-up message that starts the resumed activation's turn. */ readonly prompt: ContentBlock[] + /** Attribution retained when the follow-up becomes the resumed turn's user-role message. */ + readonly source: MessageSource /** * The live parent agent — the direct parent recorded in the persisted child * header. In-process backends reconstruct the child under this agent's @@ -228,8 +230,10 @@ export interface SubagentRun { * 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. */ - steer?(content: ContentBlock[]): void + steer?(content: ContentBlock[], source: MessageSource): void } /** diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index d04599a4d2..69dacf70f6 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -118,6 +118,7 @@ describe('SubagentService', () => { await expect(subagents.resume('one-shot', { sessionId, prompt: [{ type: 'text', text: 'continue' }], + source: { kind: 'user' }, parent, signal, descriptor, diff --git a/packages/subagent/tool-subagent-control/README.md b/packages/subagent/tool-subagent-control/README.md index 7654012145..6a6026e3fd 100644 --- a/packages/subagent/tool-subagent-control/README.md +++ b/packages/subagent/tool-subagent-control/README.md @@ -2,7 +2,7 @@ The globally named `send_message` tool: a thin adapter over `ctx.subagentControl.sendMessage()`. Provider-bound `@deepseek-ai/dsh-tool-subagent` instances register distinct delegation tools per transport; this separately loaded package registers the one shared control tool, so multiple delegation tools never register duplicate global controls. -The tool performs no lifecycle routing. The control service decides between live delivery to the running activation's existing Task and a fresh Task that cold-resumes the durable child; the tool renders which route was taken and the relevant Task id. A control-service throw becomes an errored tool result stating the message was not delivered. +The tool performs no lifecycle routing. It attributes every follow-up as `{ kind: 'coordinator', senderSessionId: parent.id }`; the control service preserves that source while deciding between live delivery to the running activation's existing Task and a fresh Task that cold-resumes the durable child. The tool renders which route was taken and the relevant Task id. A control-service throw becomes an errored tool result stating the message was not delivered. ## Model Experience diff --git a/packages/subagent/tool-subagent-control/src/index.ts b/packages/subagent/tool-subagent-control/src/index.ts index 3c537f471c..959ff8eb49 100644 --- a/packages/subagent/tool-subagent-control/src/index.ts +++ b/packages/subagent/tool-subagent-control/src/index.ts @@ -67,7 +67,12 @@ export function apply(ctx: Context): void { throw new Error('send_message requires a calling agent (exec.agent was undefined)') } const message: ContentBlock[] = [{ type: 'text', text: args.message }] - const result = ctx.subagentControl.sendMessage(parent, SessionId(args.subagent_id), message) + const result = ctx.subagentControl.sendMessage( + parent, + SessionId(args.subagent_id), + message, + { kind: 'coordinator', senderSessionId: parent.id }, + ) return Promise.resolve(result) }, })) diff --git a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts index b54eb6508a..927162d21d 100644 --- a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts +++ b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts @@ -83,17 +83,27 @@ describe('dsh-tool-subagent-control', () => { expect(text(result)).toBe(`message started task subagent-2 continuing subagent ${started.childId}`) const collected = await callTool(ctx, 'task_output', { task_id: 'subagent-2', wait: true }, parent) expect(text(collected)).toBe('second answer\n[status: completed]') + const loaded = await ctx.sessionPersistence.load(started.childId) + const followUp = loaded.events.findLast(event => + event.type === 'user/message', + ) + expect(followUp?.type === 'user/message' && followUp.data.source).toEqual({ + kind: 'coordinator', + senderSessionId: parent.id, + }) }) it('renders the steered route when the child is still running', async () => { // Script the child's single turn as two steps: the steer joins mid-turn. const { ctx, parent } = await setup([]) let steered: string | undefined + let source: unknown // 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) => { + ctx.subagentControl.sendMessage = (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) } } const result = await callTool(ctx, 'send_message', { @@ -102,6 +112,7 @@ describe('dsh-tool-subagent-control', () => { }, parent) expect(result.isError).toBe(false) expect(steered).toBe('also consider Y') + expect(source).toEqual({ kind: 'coordinator', senderSessionId: parent.id }) expect(text(result)).toBe('message delivered to running task subagent-9') }) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index d8c7ee2715..c8aeb86a39 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -162,6 +162,7 @@ export const LINK_MAP: Readonly> = { SpillRef: 'spill.md', ContinuableStart: 'subagent.md', ContinuableStartSpec: 'subagent.md', + CoordinatorMessageSource: 'subagent.md', SendMessageResult: 'subagent.md', SubagentProvider: 'subagent.md', SubagentResumeRequest: 'subagent.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 7dfd3195a8..9e8f68177d 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1099,6 +1099,11 @@ "symbol": "SubagentContinuation", "source": "packages/subagent/subagent/src/types.ts" }, + { + "doc": "docs/core-data-structures/subagent.md", + "symbol": "CoordinatorMessageSource", + "source": "packages/subagent/subagent-control/src/index.ts" + }, { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentResumeRequest",