feat(subagent): continuable background subagents

Implement the continuable background subagents RFC: a durable child
session with a series of Task-backed activations, each disposing its
run before the Task settles.

- dsh-subagent: rename SubagentRun.sendMessage to strict steer, drop
  run-level resume, add SubagentProvider.resume dispatch via
  SubagentService.resume, the continuation start field, and the
  versioned model-hidden subagent/descriptor session event.
- dsh-subagent-inprocess/-spawn/-fork: publish the control-allocated
  child id, append the descriptor inside the initial turn, implement
  cold resume from the child's own transcript under the live parent
  scope, and strict running-only steer.
- dsh-subagent-control (new): SubagentControlService owning stable
  child ids, descriptor snapshot/fold/authorization, Task-backed
  activation with settle-then-dispose ordering, the process-local
  active-run association, and steer-or-resume sendMessage routing.
- dsh-tool-subagent: background route branches on the provider's
  resume capability (continuable via the control service; one-shot
  task for ACP), returning both child and task ids.
- dsh-tool-subagent-control (new): the globally named send_message
  tool rendering steered/started routes.

Keyless coverage spans Task ownership and disposal ordering, running
delivery, cold follow-up, descriptor rejection and rollback, known-id
reconstruction, kill during lookup, admission races, and a new
subagent-continuable ACP snapshot scenario.
This commit is contained in:
Dudu-0223
2026-07-23 17:07:38 +08:00
committed by imccyu
parent 76ff841279
commit 99a778d63f
83 changed files with 3167 additions and 627 deletions

View File

@@ -880,6 +880,20 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
],
},
{
key: 'subagentControl',
summary: 'The continuable-subagent orchestration service.',
methods: [
{
signature: 'startContinuable(spec: ContinuableStartSpec): ContinuableStart',
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 */',
},
],
},
{
key: 'subagents',
summary: 'Named provider registry and capability-checked start surface.',
@@ -900,6 +914,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
signature: 'async start(name: string, request: SubagentStartRequest): Promise<SubagentRun>',
jsDoc: '/**\n * Establish a ready child on the named provider. Capability and semantic\n * checks run before delegation. Provider ownership lasts until its promise\n * fulfills; a rejection therefore has no run for the caller to dispose and\n * emits no run lifecycle events.\n * @param name - the provider to use.\n * @param request - child prompt, parent, signal, and optional capabilities.\n * @returns the ready holder-owned run.\n */',
},
{
signature: 'async resume(name: string, request: SubagentResumeRequest): Promise<SubagentRun>',
jsDoc: '/**\n * Resume a persisted continuable child through the named provider\'s\n * `resume` capability, with the same run lifecycle observation as\n * {@link start}. The caller (the control service) has already loaded the\n * child, folded its descriptor, and authorized the parent; this method owns\n * only capability-checked dispatch.\n * @param name - the provider recorded in the child\'s descriptor.\n * @param request - the fully resolved resume request.\n * @returns the fresh holder-owned run for the resumed activation.\n */',
},
],
},
{
@@ -1783,6 +1801,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'ContentBlockType',
declaration: 'export type ContentBlockType = keyof ContentBlockMap;',
},
{
name: 'ContinuableStart',
declaration: 'export interface ContinuableStart {\n readonly childId: SessionId;\n readonly taskId: TaskId;\n}',
},
{
name: 'ContinuableStartSpec',
declaration: 'export interface ContinuableStartSpec {\n readonly provider: string;\n readonly label: string;\n readonly request: Omit<SubagentStartRequest, \'signal\' | \'continuation\'>;\n}',
},
{
name: 'CreateAgentOptions',
declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise<void> | void;\n}',
@@ -2331,6 +2357,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SearchResultView',
declaration: 'export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;',
},
{
name: 'SendMessageResult',
declaration: 'export type SendMessageResult = {\n readonly route: \'steered\';\n readonly taskId: TaskId;\n} | {\n readonly route: \'started\';\n readonly taskId: TaskId;\n};',
},
{
name: 'SendOptions',
declaration: 'export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n}',
@@ -2651,21 +2681,33 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SubagentCapabilities',
declaration: 'export interface SubagentCapabilities {\n readonly outputSchema: boolean;\n readonly depthLimit: boolean;\n readonly toolFilter: boolean;\n readonly persona: boolean;\n}',
},
{
name: 'SubagentContinuation',
declaration: 'export interface SubagentContinuation {\n readonly sessionId: SessionId;\n readonly descriptor: SubagentDescriptorData;\n}',
},
{
name: 'SubagentDescriptorData',
declaration: 'export interface SubagentDescriptorData {\n readonly version: number;\n readonly provider: string;\n readonly agentProvider?: string;\n readonly agentModel?: string;\n readonly persona?: string;\n readonly toolFilter?: ToolRestriction;\n}',
},
{
name: 'SubagentProvider',
declaration: 'export interface SubagentProvider {\n readonly name: string;\n readonly capabilities: SubagentCapabilities;\n readonly inheritsParentContext: boolean;\n start(request: SubagentStartRequest): Promise<SubagentRun>;\n}',
declaration: 'export interface SubagentProvider {\n readonly name: string;\n readonly capabilities: SubagentCapabilities;\n readonly inheritsParentContext: boolean;\n start(request: SubagentStartRequest): Promise<SubagentRun>;\n resume?(request: SubagentResumeRequest): Promise<SubagentRun>;\n}',
},
{
name: 'SubagentResult',
declaration: 'export interface SubagentResult {\n readonly output: ContentBlock[];\n readonly structured?: unknown;\n readonly stopReason: SubagentStopReason;\n}',
},
{
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}',
},
{
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 sendMessage?(content: ContentBlock[]): void;\n resume?(content: ContentBlock[]): Promise<SubagentRun>;\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[]): void;\n}',
},
{
name: 'SubagentStartRequest',
declaration: 'export interface SubagentStartRequest {\n readonly prompt: ContentBlock[];\n readonly parent: Agent;\n readonly signal: AbortSignal;\n readonly agentOptions?: AgentOptions;\n readonly outputSchema?: ObjectJsonSchema;\n readonly maxDepth?: number;\n readonly toolFilter?: ToolRestriction;\n readonly persona?: string;\n}',
declaration: 'export interface SubagentStartRequest {\n readonly prompt: ContentBlock[];\n readonly parent: Agent;\n readonly signal: AbortSignal;\n readonly agentOptions?: AgentOptions;\n readonly outputSchema?: ObjectJsonSchema;\n readonly maxDepth?: number;\n readonly toolFilter?: ToolRestriction;\n readonly persona?: string;\n readonly continuation?: SubagentContinuation;\n}',
},
{
name: 'SubagentStopReason',

View File

@@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
it('boots every shipped tool package and harvests its model-facing schemas', async () => {
const catalog = await collectToolCatalog()
const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'lsp', 'ralph', 'read', 'run_code', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write'])
expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'lsp', 'ralph', 'read', 'run_code', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write'])
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
for (const entry of catalog) {
for (const schema of entry.schemas) {

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/subagent/README.md
README.md: fed0c3d6b252f5eeb8355c3b544066765999120a
README.zh.md: 9bc187aa972bc92385d32fe787e2fd65b6ce8361
README.md: 438907ea7de41842f900b050385f15feac7cc272
README.zh.md: 87911216bc4e6b5f75e17ca2c58818725f66e7ec

View File

@@ -6,14 +6,16 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](..
| Package | Role | ctx key |
|---|---|---|
| `subagent/` | Abstract subagent seam: named-provider registry + vocabulary | `ctx.subagents` |
| `subagent/` | Abstract subagent seam: named-provider registry + vocabulary + the durable child descriptor | `ctx.subagents` |
| `subagent-inprocess/` | Shared in-process run driver (no provider; one cleanup effect per run) | — |
| `subagent-spawn/` | In-process backend: a fresh child agent | (registers on `ctx.subagents`) |
| `subagent-fork/` | In-process backend: a child seeded with the parent's completed-turn prefix | (registers on `ctx.subagents`) |
| `subagent-acp/` | Out-of-process backend: a child agent in a spawned subprocess, driven over ACP | (registers on `ctx.subagents`) |
| `subagent-spawn/` | In-process backend: a fresh child agent, with cold resume | (registers on `ctx.subagents`) |
| `subagent-fork/` | In-process backend: a child seeded with the parent's completed-turn prefix, with cold resume | (registers on `ctx.subagents`) |
| `subagent-acp/` | Out-of-process backend: a child agent in a spawned subprocess, driven over ACP (one-shot) | (registers on `ctx.subagents`) |
| `subagent-dsh-sdk/` | Out-of-process backend: a child harness runtime in a spawned subprocess, driven over stdio JSON-RPC through the TypeScript SDK client | (registers on `ctx.subagents`) |
| `subagent-control/` | Continuable-child orchestration: stable ids, descriptor lookup, Task-backed activation, steer-or-resume routing | `ctx.subagentControl` |
| `tool-subagent/` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) |
| `tool-subagent-control/` | The one globally named `send_message` follow-up tool over `ctx.subagentControl` | (registers on `ctx.tools`) |
The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` / `subagent-dsh-sdk` backends spawn their children through the [`subprocess/`](../subprocess/README.md) seam (the shared credential scrub, tree-scoped teardown, and dispose ladder). Tests replace only the child boundary with package-local fixtures.
The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` / `subagent-dsh-sdk` backends spawn their children through the [`subprocess/`](../subprocess/README.md) seam (the shared credential scrub, tree-scoped teardown, and dispose ladder). `subagent-control` sits above the seam: it binds one durable child session to a series of disposable Task-backed activations, and both model tools and human-facing adapters route through its one contract. Tests replace only the child boundary with package-local fixtures.
The proposal and design rationale: [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md).
The proposals and design rationale: [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md) and [.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md).

View File

@@ -6,14 +6,16 @@ subagent子 agentseam 允许 agent智能体把工作委派给子 age
| 包package | 角色 | ctx 键 |
|---|---|---|
| `subagent/` | 抽象 subagent seam具名提供方注册表词汇 | `ctx.subagents` |
| `subagent/` | 抽象 subagent seam具名提供方注册表词汇与持久化子 agent 描述符 | `ctx.subagents` |
| `subagent-inprocess/` | 共享进程内运行驱动器(不含提供方;每次运行使用一个清理 effect | 无 |
| `subagent-spawn/` | 进程内后端:全新子 agent | (注册到 `ctx.subagents` |
| `subagent-fork/` | 进程内后端:以父 agent 已完成轮次的前缀作为初始内容的子 agent | (注册到 `ctx.subagents` |
| `subagent-acp/` | 进程外后端:在 spawn 的子进程中运行并通过 ACPAgent Client Protocol驱动的子 agent | (注册到 `ctx.subagents` |
| `subagent-spawn/` | 进程内后端:支持冷恢复的全新子 agent | (注册到 `ctx.subagents` |
| `subagent-fork/` | 进程内后端:以父 agent 已完成轮次的前缀作为初始内容、支持冷恢复的子 agent | (注册到 `ctx.subagents` |
| `subagent-acp/` | 进程外后端:在 spawn 的子进程中运行并通过 ACPAgent Client Protocol驱动的一次性子 agent | (注册到 `ctx.subagents` |
| `subagent-dsh-sdk/` | 进程外后端:在 spawn 的子进程中运行的子 harness 运行时,经 TypeScript SDK 客户端走 stdio JSON-RPC 驱动 | (注册到 `ctx.subagents` |
| `subagent-control/` | 可继续子 agent 编排:稳定 ID、描述符查找、由 Task 支撑的 activation以及 steer 或恢复路由 | `ctx.subagentControl` |
| `tool-subagent/` | 面向模型的 `subagent` 委派工具,基于 `ctx.subagents` | (注册到 `ctx.tools` |
| `tool-subagent-control/` | 基于 `ctx.subagentControl`、全局名称唯一的 `send_message` 后续消息工具 | (注册到 `ctx.tools` |
接口位于 `subagent/subagent/`。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不含提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` / `subagent-dsh-sdk` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程共享的凭据清除、以进程树为范围的拆卸、dispose资源释放阶梯。测试只用包内 fixture测试前置数据替换子 agent 边界。
接口位于 `subagent/subagent/`。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不含提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` / `subagent-dsh-sdk` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程共享的凭据清除、以进程树为范围的拆卸、dispose资源释放阶梯`subagent-control` 位于该 seam 之上:它把一个持久化子会话绑定到一系列可 dispose、由 Task 支撑的 activation模型工具和面向人的适配器都通过这份统一契约进行路由。测试只用包内 fixture测试前置数据替换子 agent 边界。
提案与设计理由见 [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)。
提案与设计理由见 [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md) 和 [.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)

View File

@@ -0,0 +1,37 @@
# @deepseek-ai/dsh-subagent-control
The continuable-subagent control service (`ctx.subagentControl`): the one orchestration path that binds a durable child session to a series of disposable Task-backed activations. Model tools and human-facing adapters call the same contract; the low-level `ctx.subagents` seam stays collection-, Task-, and persistence-agnostic.
## Activation lifecycle
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 child result, disposes the run, and only then records the `TaskOutcome`: a terminal Task leaves the durable child session but no live child Agent.
`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.
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).
The activation association is process-local routing state, installed before any persistence or provider await and removed after run disposal and Task terminal publication. It is not a durable catalog: restart recovers the child session, not in-flight Tasks or their notifications.
## Model Experience
### Task completion and output
#### What the model sees
None directly, as this package registers no tool and no prompt text; the model observes continuable children through `@deepseek-ai/dsh-tool-subagent`'s background acknowledgement, `@deepseek-ai/dsh-tool-subagent-control`'s `send_message` results, and the generic task surface, whose outputs this service produces.
#### Token effect
None beyond the consuming tools' own results.
#### KV Cache effect
None; this service appends nothing to any model-visible sequence.
## Known Limitations and Deferred Work
- **Concurrent stopped-child admission is not atomic across awaits** — the synchronous association install admits one activation per child in this process, but a caller bypassing the control service can still race it; the Agent registry's same-id collision is the final backstop, and the losing Task fails with its message not delivered.
- **The association coordinates only one runtime** — concurrent resume from multiple processes needs a persistence-level lease or compare-and-set, which no backend offers yet.
- **Task records are process-local** — restart recovers the durable child session, not an interrupted Task, its result, or its completion notice; durable Task recovery is a separate concern.
- **Human interaction requires the exact live parent Agent** — Task access is fenced by the owner session and owner disposal cancels its Tasks; standalone child conversations belong to the interactive-side-sessions proposal, not this Task-owned lifecycle.
- **ACP children remain one-shot** — `AcpProvider.resume` and per-child continuation advertisement are deferred until the remote-session descriptor contract is resolved.

View File

@@ -0,0 +1,55 @@
{
"name": "@deepseek-ai/dsh-subagent-control",
"description": "Continuable-subagent control service: Task-backed activation, durable child descriptors, and steer-or-resume message routing",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"@deepseek-ai/dsh-subagent": "^0.0.1",
"@deepseek-ai/dsh-tasks": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-subagent-fork": "workspace:^",
"@deepseek-ai/dsh-subagent-spawn": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-tasks-local": "workspace:^",
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,442 @@
/**
* Continuable-subagent control service (`ctx.subagentControl`): stable child
* ids, descriptor persistence and lookup by known child id, Task-backed
* activation, and steer-or-resume message routing. The low-level
* `ctx.subagents` seam stays collection-, Task-, and persistence-agnostic;
* this service owns the policy that binds one durable child session to a
* series of disposable Task-backed activations.
*
* Every continuable activation — initial or resumed, parent- or human-started
* — has exactly one Task and one result. Task settlement awaits the child
* result, disposes the run, and only then records the outcome, so a terminal
* Task leaves the durable child session but no live child Agent. Cancellation
* targets the whole activation: parent and human messages that joined one
* turn share its result and its `killed` outcome.
*
* @module @deepseek-ai/dsh-subagent-control
*/
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 { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import { foldSubagentDescriptor, snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
import type { SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
import type { TaskHooks, TaskId, TaskOutcome } from '@deepseek-ai/dsh-tasks'
declare module 'cordis' {
interface Context {
subagentControl: SubagentControlService
}
}
/** Typed error for control-service routing, authorization, and delivery failures. */
export class SubagentControlError extends HarnessError {
constructor(message: string, code: string, options?: ErrorOptions) {
super(message, code, options)
this.name = 'SubagentControlError'
}
}
/** What a caller asks for when starting a continuable background child. */
export interface ContinuableStartSpec {
/** The `ctx.subagents` provider to establish the child on. */
readonly provider: string
/** One-line model-facing Task label (the delegation description). */
readonly label: string
/**
* The delegation request. The service resolves the stable child id and the
* durable descriptor, then supplies the Task-owned cancellation signal and
* `continuation` itself.
*/
readonly request: Omit<SubagentStartRequest, 'signal' | 'continuation'>
}
/** Identities returned by {@link SubagentControlService.startContinuable}. */
export interface ContinuableStart {
/** The durable child session id, stable across activations. */
readonly childId: SessionId
/** The initial activation's Task id. */
readonly taskId: TaskId
}
/**
* How {@link SubagentControlService.sendMessage} delivered a message:
* `steered` joined the running activation's existing Task without creating a
* Task of its own; `started` created a fresh Task that cold-resumes the
* durable child with the message. Failure is an exception, never a result —
* an undelivered message throws.
*/
export type SendMessageResult =
| { readonly route: 'steered'; readonly taskId: TaskId }
| { readonly route: 'started'; readonly taskId: TaskId }
/**
* One child's current process-local activation: its Task and, after provider
* publication, its run. Installed before any provider or persistence await
* and removed only after run disposal and Task terminal publication. This
* exists solely so parent and human senders can find the same activation — it
* is not a durable catalog, admission reservation, or run-state machine.
*/
interface ActiveActivation {
/** Assigned in the same synchronous frame as the install, when the Task registers. */
taskId: TaskId | undefined
/** Filled when the provider publishes; `undefined` while starting or resuming. */
run: SubagentRun | undefined
/** Resolved by the completion listener when the Task's terminal snapshot is recorded. */
readonly terminal: PromiseWithResolvers<void>
}
/**
* Map a child result to the task outcome: completed carries final text,
* aborted is killed, and every other reason is failed without partial output.
* @param result - child terminal result.
* @returns outcome for the `ctx.tasks` registration.
*/
export function runOutcome(result: SubagentResult): TaskOutcome {
switch (result.stopReason) {
case 'completed':
return { status: 'completed', output: finalText(result.output) }
case 'aborted':
return { status: 'killed' }
case 'error':
case 'max-tokens':
case 'refusal':
return { status: 'failed', detail: result.stopReason }
// Merge-extensible reasons remain failures with their raw detail.
default:
return { status: 'failed', detail: String(result.stopReason) }
}
}
/**
* Await the child result, dispose the run, then return its task outcome. Result
* and disposal failures become `failed`; when both fail, both details survive.
* @param run - live run to settle and release.
* @returns outcome after child resources are released.
*/
export async function settleRun(run: SubagentRun): Promise<TaskOutcome> {
let outcome: TaskOutcome
try {
outcome = runOutcome(await run.result)
} catch (error: unknown) {
outcome = { status: 'failed', detail: String(error) }
}
try {
await run.dispose()
} catch (error: unknown) {
const prefix = outcome.detail === undefined ? '' : `${outcome.detail}; `
return { status: 'failed', detail: `${prefix}dispose failed: ${String(error)}` }
}
return outcome
}
/** Flatten a child's final output blocks to the task's final text. */
function finalText(blocks: ContentBlock[]): string {
return blocks
.filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text')
.map(block => block.text)
.join('')
}
/**
* The continuable-subagent orchestration service. Tool schema and UI adapters
* are consumers of this one contract: parent and human messages route through
* {@link sendMessage} and share one activation result and cancellation
* boundary, while foreground one-shot delegation keeps calling
* `ctx.subagents.start()` directly.
*/
export class SubagentControlService extends Service {
static inject = ['subagents', 'tasks', 'agents']
/** Child session id → its current activation. Process-local, never durable. */
private activations = new Map<SessionId, ActiveActivation>()
constructor(ctx: Context) {
super(ctx, 'subagentControl')
// Terminal publication is one of the two removal conditions. The exact
// Task id pins the resolution to this activation, never a later same-child one.
ctx.tasks.onTaskDone((snapshot) => {
for (const activation of this.activations.values()) {
if (activation.taskId === snapshot.id) activation.terminal.resolve()
}
})
ctx.effect(() => () => { this.activations.clear() }, 'subagentControl.activations()')
}
/**
* Start a continuable background child: allocate its stable session id,
* snapshot its durable descriptor, and register the initial activation's
* Task. A synchronous validation failure (a non-JSON descriptor input,
* missing persistence, Task preflight) throws without creating a Task; the
* method otherwise returns both identities immediately, without waiting for
* child publication or descriptor durability. Asynchronous startup failure
* settles the returned Task as `failed` (or `killed` when cancelled) after
* any published run is disposed, which can leave an unmaterialized child id
* that later by-id operations report as unavailable.
* @param spec - provider, Task label, and the delegation request.
* @returns the stable child id and the initial activation's Task id.
*/
startContinuable(spec: ContinuableStartSpec): ContinuableStart {
this.requirePersistence()
const childId = SessionId(randomUUID())
const request = spec.request
// Snapshot before Task creation: invalid descriptor JSON rejects the call
// with no Task, and the detached value is what reaches the child log.
const agentProvider = request.agentOptions?.provider ?? request.parent.options.provider
const agentModel = request.agentOptions?.model ?? request.parent.options.model
const descriptor = snapshotSubagentDescriptor({
provider: spec.provider,
...agentProvider !== undefined ? { agentProvider } : {},
...agentModel !== undefined ? { agentModel } : {},
...request.persona !== undefined ? { persona: request.persona } : {},
...request.toolFilter !== undefined ? { toolFilter: request.toolFilter } : {},
})
const taskId = this.startActivation(childId, spec.label, request.parent, signal =>
this.ctx.subagents.start(spec.provider, {
...request,
signal,
continuation: { sessionId: childId, descriptor },
}))
return { childId, taskId }
}
/**
* 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
* 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
* direct-parent authorization (its AbortSignal exists before that lookup),
* so an unknown, foreign, or descriptor-less child settles the started Task
* as `failed` with a detail reporting the id as unavailable.
* @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.
* @returns whether the message `steered` the existing Task or `started` a new one.
*/
sendMessage(parent: Agent, childId: SessionId, message: ContentBlock[]): 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: 'started', taskId: this.resumeActivation(parent, childId, message) }
}
/**
* Synchronous ownership compare before any by-id routing: a live registry
* Agent outside the association — or different from the associated run's
* agent — was started by something else. Fail instead of adopting an idle
* Agent or attaching an untracked turn.
*/
private assertOwnership(childId: SessionId): void {
const live = this.ctx.agents.get(childId)
if (live === undefined) return
const activation = this.activations.get(childId)
if (activation === undefined) {
throw new SubagentControlError(
`subagent "${childId}" has a live agent outside control-service ownership; the message was not delivered`,
'OWNERSHIP_CONFLICT',
)
}
if (activation.run !== undefined && activation.run.localAgent !== live) {
throw new SubagentControlError(
`subagent "${childId}" registry agent is not the associated activation's agent; the message was not delivered`,
'OWNERSHIP_CONFLICT',
)
}
}
/** Deliver to the running activation's Task through strict live steering. */
private steerActivation(
activation: ActiveActivation,
parent: Agent,
childId: SessionId,
message: ContentBlock[],
): 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.
const snapshot = this.ctx.tasks.get(taskId, parent)
if (snapshot.status !== 'running') {
throw new SubagentControlError(
`subagent "${childId}" task ${taskId} is ${snapshot.status}; the message was not delivered `
+ '— retry after it settles to start the next activation',
'NOT_DELIVERED',
)
}
const run = activation.run
if (run === undefined) {
throw new SubagentControlError(`subagent "${childId}" activation is starting; the message was not delivered`, 'NOT_DELIVERED')
}
if (run.steer === undefined) {
throw new SubagentControlError(
`subagent "${childId}" provider does not accept live delivery; the message was not delivered`,
'NOT_DELIVERED',
)
}
try {
run.steer(message)
} 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
// caller did not observe.
throw new SubagentControlError(
`subagent "${childId}" stopped before delivery; the message was not delivered`,
'NOT_DELIVERED',
{ cause: error },
)
}
return taskId
}
/**
* Cold-resume a persisted child into a fresh Task-backed activation. The
* Task owns its `AbortController` before descriptor lookup: the load,
* direct-parent authorization, and descriptor fold run inside the
* 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 {
const persistence = this.requirePersistence()
return this.startActivation(childId, resumeLabel(message), parent, async (signal) => {
let loaded: Awaited<ReturnType<typeof persistence.load>>
try {
loaded = await persistence.load(childId)
} catch (error: unknown) {
throw new SubagentControlError(
`subagent "${childId}" is unavailable`,
'NOT_RESUMABLE',
{ cause: error },
)
}
// The persistence seam takes no signal; recheck before any child work.
if (signal.aborted) throw new SubagentControlError('subagent resume was cancelled during lookup', 'CANCELLED')
// Authorize the persisted header before folding: only the direct parent
// recorded at creation may continue this child.
if (loaded.meta.parentSession !== parent.id) {
throw new SubagentControlError(
`subagent "${childId}" belongs to another parent session`,
'UNAUTHORIZED',
)
}
// Fold only the child's own suffix: a fork seed replays the parent's
// log, which may carry an ANCESTOR's descriptor when the parent is
// itself a continuable child.
const descriptor = foldSubagentDescriptor(loaded.events.slice(loaded.meta.seedLength ?? 0))
if (descriptor === undefined) {
throw new SubagentControlError(
`subagent "${childId}" has no supported continuation descriptor`,
'NOT_RESUMABLE',
)
}
return this.ctx.subagents.resume(descriptor.provider, {
sessionId: childId,
prompt: message,
parent,
signal,
descriptor,
})
})
}
/**
* Install the activation association, register its Task, and bind the two
* removal conditions. The association is installed before any persistence
* or provider await — the producer body runs synchronously up to its first
* await — and removed only after run disposal (the producer settled) and
* Task terminal publication. This synchronous install admits one activation
* per child in this process; a competing untracked publication still loses
* at the Agent registry collision boundary inside the provider.
*/
private startActivation(
childId: SessionId,
label: string,
owner: Agent,
begin: (signal: AbortSignal) => Promise<SubagentRun>,
): TaskId {
const activation: ActiveActivation = {
taskId: undefined,
run: undefined,
terminal: Promise.withResolvers<void>(),
}
this.activations.set(childId, activation)
let taskId: TaskId
try {
taskId = this.ctx.tasks.start({
kind: 'subagent',
label,
owner,
run: (): TaskHooks => {
const controller = new AbortController()
const done = (async (): Promise<TaskOutcome> => {
try {
const run = await begin(controller.signal)
activation.run = run
return await settleRun(run)
} catch (error: unknown) {
// A pre-publication abort rejects only after the provider's
// creation transaction rolled back to quiescence, so recording
// `killed` here honors the settlement-after-rollback contract.
return controller.signal.aborted
? { status: 'killed' }
: { status: 'failed', detail: String(error) }
}
})()
void Promise.allSettled([done, activation.terminal.promise]).then(() => {
/* v8 ignore else -- service teardown clears the map while a producer is still settling. */
if (this.activations.get(childId) === activation) this.activations.delete(childId)
})
return {
cancel: (reason?: string) => {
// Cancellation targets the whole activation: every message that
// joined this turn shares the `killed` outcome.
controller.abort(reason ?? 'subagent activation killed')
},
done,
// No readOutput: the child session owns intermediate detail.
}
},
})
} catch (error: unknown) {
// Task preflight failed; nothing started, so the install rolls back.
this.activations.delete(childId)
throw error
}
// Same synchronous frame as the install: an observer that can run at all
// runs after this assignment.
activation.taskId = taskId
return taskId
}
/** Resolve the persistence service continuable children require, or fail loud. */
private requirePersistence(): SessionPersistence {
const persistence = this.ctx.get('sessionPersistence')
if (persistence === undefined) {
throw new SubagentControlError(
'continuable subagents require session persistence (load a dsh-session-persistence backend)',
'PERSISTENCE_UNAVAILABLE',
)
}
return persistence
}
}
/** Derive a resumed activation's Task label from its message. */
function resumeLabel(message: ContentBlock[]): string {
const text = finalText(message).trim().replace(/\s+/g, ' ')
if (text.length === 0) return 'subagent follow-up'
return text.length > 80 ? `${text.slice(0, 79)}` : text
}
export default SubagentControlService

View File

@@ -0,0 +1,32 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-subagent-control`.
* @module @deepseek-ai/dsh-subagent-control/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-control'
/** Cordis companion plugin name. */
export const name = 'subagent-control-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: the activation association is deliberately private
* process-local routing state with no event stream of its own; the run
* lifecycle pair it participates in is checked by `@deepseek-ai/dsh-subagent`,
* and Task lifecycle relations belong to `@deepseek-ai/dsh-tasks`.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,539 @@
import { afterEach, describe, expect, it } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import SubagentService, { SUBAGENT_DESCRIPTOR_VERSION } from '@deepseek-ai/dsh-subagent'
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork'
import { TaskId } from '@deepseek-ai/dsh-tasks'
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, LlmAdapter } from '@deepseek-ai/dsh-llm'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import SubagentControlService, { runOutcome, settleRun, SubagentControlError } from '../src/index.ts'
type Script = ConstructorParameters<typeof MockAdapter>[0]
/** One scripted response that may wait on a caller-released gate before streaming. */
interface GatedEntry {
chunks: StreamChunk[]
gate?: Promise<void>
}
/** Adapter whose entries can hold a model call open until the test releases it. */
class GatedAdapter extends LlmAdapter {
constructor(private script: GatedEntry[]) {
super()
}
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
const entry = this.script.shift()
if (!entry) throw new Error('GatedAdapter: script exhausted')
if (entry.gate) await entry.gate
for (const chunk of entry.chunks) {
if (options.signal?.aborted) throw new Error('aborted')
yield chunk
}
}
}
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
/** Boot the full continuable stack: loop, persistence, providers, tasks, control. */
async function setupWith(adapter: LlmAdapter, options: { persistence?: boolean } = {}) {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
if (options.persistence !== false) {
const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-control-'))
roots.push(root)
await ctx.plugin(JsonlSessionPersistence, { root })
}
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentService)
await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
await ctx.plugin(SubagentFork, { providerName: 'fork' })
await ctx.plugin(LocalTaskService)
await ctx.plugin(ToolTasks, {})
await ctx.plugin(SubagentControlService)
ctx.llm.registerAdapter(['mock'], adapter)
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
return { ctx, parent }
}
async function setup(script: Script, options: { persistence?: boolean } = {}) {
const adapter = new MockAdapter(script)
const { ctx, parent } = await setupWith(adapter, options)
return { ctx, parent, adapter }
}
function startSpec(parent: Agent, provider = 'spawn') {
return {
provider,
label: 'delegated work',
request: { prompt: [{ type: 'text' as const, text: 'child task' }], parent },
}
}
async function waitTerminal(ctx: Context, taskId: TaskId, parent: Agent) {
return ctx.tasks.wait(taskId, 5_000, parent)
}
function message(text: string) {
return [{ type: 'text' as const, text }]
}
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')])
const started = ctx.subagentControl.startContinuable(startSpec(parent))
expect(started.childId).toMatch(/[0-9a-f-]{36}/)
expect(started.taskId).toBe('subagent-1')
const snapshot = await waitTerminal(ctx, started.taskId, parent)
expect(snapshot.status).toBe('completed')
expect(ctx.tasks.read(started.taskId, parent).text).toBe('first answer')
// Disposal ordering: the terminal Task leaves no live child Agent.
expect(ctx.agents.get(started.childId)).toBeUndefined()
})
it('publishes the control-allocated child id and appends the turn-enclosed descriptor', async () => {
const { ctx, parent } = await setup([textResponse('answer')])
const seen: SessionEvent[] = []
ctx.on('session/event', (session, event) => {
if (session.id !== SessionId('parent')) seen.push(event)
})
const started = ctx.subagentControl.startContinuable(startSpec(parent))
await waitTerminal(ctx, started.taskId, parent)
const descriptorIndex = seen.findIndex(event => event.type === 'subagent/descriptor')
const turnStartIndex = seen.findIndex(event => event.type === 'turn/start')
const firstAssistant = seen.findIndex(event => event.type === 'assistant/message')
expect(descriptorIndex).toBeGreaterThan(turnStartIndex)
expect(descriptorIndex).toBeLessThan(firstAssistant)
const descriptor = seen[descriptorIndex] as SessionEvent<'subagent/descriptor'>
expect(descriptor.data).toEqual({
version: SUBAGENT_DESCRIPTOR_VERSION,
provider: 'spawn',
agentProvider: 'mock',
agentModel: 'mock',
})
// Model-hidden: the descriptor never carries surface metadata.
expect('surfaceOp' in descriptor).toBe(false)
// The durable log kept the exact control-allocated id.
const loaded = await ctx.sessionPersistence.load(started.childId)
expect(loaded.meta.id).toBe(started.childId)
expect(loaded.meta.parentSession).toBe(SessionId('parent'))
expect(loaded.events.some(event => event.type === 'subagent/descriptor')).toBe(true)
})
it('rejects synchronously with no Task when persistence is not configured', async () => {
const { ctx, parent } = await setup([textResponse('unused')], { persistence: false })
expect(() => ctx.subagentControl.startContinuable(startSpec(parent)))
.toThrow(/require session persistence/)
expect(ctx.tasks.list(parent)).toEqual([])
})
it('rejects a non-JSON descriptor input synchronously with no Task', async () => {
const { ctx, parent } = await setup([textResponse('unused')])
const spec = startSpec(parent)
expect(() => ctx.subagentControl.startContinuable({
...spec,
// A symbol survives the static ToolRestriction type only through this
// cast — exactly the durable-boundary input the snapshot rejects.
request: { ...spec.request, toolFilter: { deny: [Symbol('boom') as unknown as string] } },
})).toThrow(/not losslessly JSON-serializable/)
expect(ctx.tasks.list(parent)).toEqual([])
})
it('settles the Task as failed when provider startup fails after the ids were returned', async () => {
const { ctx, parent } = await setup([textResponse('unused')])
const spec = {
provider: 'spawn',
label: 'broken delegation',
request: {
prompt: [{ type: 'text' as const, text: 'child task' }],
parent,
// The spawn provider enforces depth: parent depth 0 → child depth 1 > 0.
maxDepth: 0,
},
}
const started = ctx.subagentControl.startContinuable(spec)
const snapshot = await waitTerminal(ctx, started.taskId, parent)
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?'))
expect(followUp.route).toBe('started')
const failed = await waitTerminal(ctx, followUp.taskId, parent)
expect(failed.status).toBe('failed')
expect(failed.detail).toContain('unavailable')
})
it('task_kill during the run aborts, disposes, and settles killed after quiescence', async () => {
const { ctx, parent } = await setup(['hang'])
const started = ctx.subagentControl.startContinuable(startSpec(parent))
// Let the child publish and begin its turn.
await new Promise(resolve => setTimeout(resolve, 30))
expect(ctx.agents.get(started.childId)).toBeDefined()
expect(ctx.tasks.kill(started.taskId, parent, 'no longer needed')).toBe('requested')
const snapshot = await waitTerminal(ctx, started.taskId, parent)
expect(snapshot.status).toBe('killed')
expect(ctx.agents.get(started.childId)).toBeUndefined()
})
})
describe('SubagentControlService.sendMessage', () => {
it('steers a running activation into the existing Task without creating a second Task', async () => {
// Hold the child's first model call open so the child is observably
// running when the message arrives; the steered content then drives a
// second step in the SAME turn.
let releaseFirst!: () => void
const gate = new Promise<void>((resolve) => { releaseFirst = resolve })
const { ctx, parent } = await setupWith(new GatedAdapter([
{ chunks: textResponse('first step answer'), gate },
{ chunks: textResponse('steered turn answer') },
]))
const started = ctx.subagentControl.startContinuable(startSpec(parent))
// Wait for the child agent to publish and enter running.
await new Promise<void>((resolve) => {
const timer = setInterval(() => {
if (ctx.agents.get(started.childId)?.status === 'running') {
clearInterval(timer)
resolve()
}
}, 5)
})
const delivered = ctx.subagentControl.sendMessage(parent, started.childId, message('also consider Y'))
expect(delivered).toEqual({ route: 'steered', taskId: started.taskId })
releaseFirst()
const snapshot = await waitTerminal(ctx, started.taskId, parent)
expect(snapshot.status).toBe('completed')
// Exactly one Task exists: steering created none.
expect(ctx.tasks.list(parent).map(task => task.id)).toEqual([started.taskId])
// 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')
})
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(parent, started.childId, message('and then?'))
expect(followUp.route).toBe('started')
expect(followUp.taskId).not.toBe(started.taskId)
const snapshot = await waitTerminal(ctx, followUp.taskId, parent)
expect(snapshot.status).toBe('completed')
expect(ctx.tasks.read(followUp.taskId, parent).text).toBe('second answer')
// Fresh activation disposed again: durable child, no live Agent.
expect(ctx.agents.get(started.childId)).toBeUndefined()
// The durable transcript accumulated BOTH activations' turns.
const loaded = await ctx.sessionPersistence.load(started.childId)
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?'])
})
it('reconstructs the declared composition on cold resume', async () => {
const { ctx, parent } = await setup([textResponse('first'), textResponse('second')])
const spec = {
provider: 'spawn',
label: 'scoped delegation',
request: {
prompt: [{ type: 'text' as const, text: 'child task' }],
parent,
persona: 'You are the resumable child.',
toolFilter: { deny: [] as string[] },
},
}
const started = ctx.subagentControl.startContinuable(spec)
await waitTerminal(ctx, started.taskId, parent)
const loaded = await ctx.sessionPersistence.load(started.childId)
const descriptor = loaded.events.find((event): event is SessionEvent<'subagent/descriptor'> => event.type === 'subagent/descriptor')
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 snapshot = await waitTerminal(ctx, followUp.taskId, parent)
expect(snapshot.status).toBe('completed')
// The resumed child's system prompt carried the persona back.
const resumed = await ctx.sessionPersistence.load(started.childId)
const headers = resumed.events.filter((event): event is SessionEvent<'request/header'> => event.type === 'request/header')
expect(headers.at(-1)?.data.header.system).toContain('You are the resumable child.')
})
it('fork children resume from their own transcript without re-forking parent history', async () => {
const { ctx, parent } = await setup([
textResponse('parent turn one'),
textResponse('fork first answer'),
textResponse('parent turn two'),
textResponse('fork second answer'),
])
parent.followup(createUserMessage({ content: message('parent question one'), source: { kind: 'user' } }))
await parent.whenIdle()
const started = ctx.subagentControl.startContinuable(startSpec(parent, 'fork'))
await waitTerminal(ctx, started.taskId, parent)
const firstLoad = await ctx.sessionPersistence.load(started.childId)
const seedLength = firstLoad.meta.seedLength ?? 0
expect(seedLength).toBeGreaterThan(0)
// The parent gains NEW history the resume must not re-fork.
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'))
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.
expect(resumed.meta.seedLength).toBe(seedLength)
const texts = resumed.events
.filter((event): event is SessionEvent<'user/message'> => event.type === 'user/message')
.map(event => (event.data.content[0] as { text: string }).text)
expect(texts).toContain('parent question one')
expect(texts).not.toContain('parent question two')
})
it('a resumed child cannot regain a top-level delegation budget (header floor)', async () => {
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 childAgents: Agent[] = []
const stop = ctx.on('agent/created', (agent: Agent) => {
if (agent.id === started.childId) childAgents.push(agent)
})
await waitTerminal(ctx, followUp.taskId, parent)
stop()
// The resumed runtime options carry no depth, so the header keeps the floor.
const resumedChild = childAgents.at(-1)
expect(resumedChild).toBeDefined()
expect(resumedChild!.session.header.delegationDepth).toBe(1)
})
it('rejects a foreign child id: the started Task fails with UNAUTHORIZED and delivers nothing', async () => {
const { ctx, parent } = await setup([textResponse('other parent answer'), textResponse('unused')])
const otherParent = ctx.agentLoop.create(SessionId('other-parent'), { provider: 'mock', model: 'mock' })
const started = ctx.subagentControl.startContinuable(startSpec(otherParent))
await waitTerminal(ctx, started.taskId, otherParent)
const attempt = ctx.subagentControl.sendMessage(parent, started.childId, message('mine now'))
expect(attempt.route).toBe('started')
const snapshot = await waitTerminal(ctx, attempt.taskId, parent)
expect(snapshot.status).toBe('failed')
expect(snapshot.detail).toContain('another parent session')
})
it('rejects a persisted child with no descriptor as not resumable', async () => {
const { ctx, parent } = await setup([textResponse('plain child')])
// A plain (non-continuable) child session persisted under this parent.
const handle = await ctx.agents.create({
sessionId: SessionId('plain-child'),
meta: { parentSession: parent.id, delegationDepth: 1 },
agentOptions: { provider: 'mock', model: 'mock' },
})
handle.agent.followup(createUserMessage({ content: message('do something'), source: { kind: 'user' } }))
await handle.agent.whenIdle()
await handle.dispose()
const attempt = ctx.subagentControl.sendMessage(parent, SessionId('plain-child'), message('continue?'))
const snapshot = await waitTerminal(ctx, attempt.taskId, parent)
expect(snapshot.status).toBe('failed')
expect(snapshot.detail).toContain('continuation descriptor')
})
it('rejects delivery to a live agent outside control-service ownership', async () => {
const { ctx, parent } = await setup([textResponse('unused')])
// A live child created around the control service.
const handle = await ctx.agents.create({
sessionId: SessionId('rogue-child'),
meta: { parentSession: parent.id },
agentOptions: { provider: 'mock', model: 'mock' },
})
expect(() => ctx.subagentControl.sendMessage(parent, SessionId('rogue-child'), message('hello')))
.toThrow(SubagentControlError)
expect(() => ctx.subagentControl.sendMessage(parent, SessionId('rogue-child'), message('hello')))
.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 () => {
// 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')])
let releaseDispose!: () => void
const disposeGate = new Promise<void>((resolve) => { releaseDispose = resolve })
const realStart = ctx.subagents.start.bind(ctx.subagents)
ctx.subagents.start = async (name, request) => {
const run = await realStart(name, request)
const realDispose = run.dispose.bind(run)
return {
...run,
...run.steer !== undefined ? { steer: run.steer.bind(run) } : {},
dispose: async () => {
await disposeGate
return realDispose()
},
}
}
const started = ctx.subagentControl.startContinuable(startSpec(parent))
// Wait for the child to finish its turn while the run remains undisposed
// and the association therefore still holds.
await new Promise<void>((resolve) => {
const timer = setInterval(() => {
const child = ctx.agents.get(started.childId)
if (child !== undefined && child.status === 'idle'
&& child.session.events.some(event => event.type === 'turn/end')) {
clearInterval(timer)
resolve()
}
}, 5)
})
// 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?')))
.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'))
expect(retry.route).toBe('started')
await waitTerminal(ctx, retry.taskId, parent)
})
it('each follow-up Task result is fenced to the parent session', async () => {
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 other = ctx.agentLoop.create(SessionId('intruder'), { provider: 'mock', model: 'mock' })
expect(() => ctx.tasks.get(followUp.taskId, other)).toThrow(/belongs to another session/)
})
it('kills a cold-resume activation during descriptor lookup without starting child work', async () => {
const { ctx, parent } = await setup([textResponse('first'), textResponse('never used')])
const started = ctx.subagentControl.startContinuable(startSpec(parent))
await waitTerminal(ctx, started.taskId, parent)
// Make the persistence load hang until the kill lands.
const realLoad = ctx.sessionPersistence.load.bind(ctx.sessionPersistence)
let releaseLoad!: () => void
const gate = new Promise<void>((resolve) => { releaseLoad = resolve })
ctx.sessionPersistence.load = async (id) => {
await gate
return realLoad(id)
}
const followUp = ctx.subagentControl.sendMessage(parent, started.childId, message('follow up'))
expect(ctx.tasks.kill(followUp.taskId, parent)).toBe('requested')
releaseLoad()
const snapshot = await waitTerminal(ctx, followUp.taskId, parent)
expect(snapshot.status).toBe('killed')
// Cancellation during lookup prevented any child publication.
expect(ctx.agents.get(started.childId)).toBeUndefined()
})
it('admits one process-local activation per child: a second send during resume load steers or fails, never duplicates', async () => {
const { ctx, parent } = await setup([textResponse('first'), textResponse('resumed answer')])
const started = ctx.subagentControl.startContinuable(startSpec(parent))
await waitTerminal(ctx, started.taskId, parent)
const realLoad = ctx.sessionPersistence.load.bind(ctx.sessionPersistence)
let releaseLoad!: () => void
const gate = new Promise<void>((resolve) => { releaseLoad = resolve })
ctx.sessionPersistence.load = async (id) => {
await gate
return realLoad(id)
}
const first = ctx.subagentControl.sendMessage(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')))
.toThrow(/not delivered/)
releaseLoad()
const snapshot = await waitTerminal(ctx, first.taskId, parent)
expect(snapshot.status).toBe('completed')
// Exactly one follow-up Task was created.
expect(ctx.tasks.list(parent).map(task => task.id)).toEqual([started.taskId, first.taskId])
})
})
describe('outcome mapping helpers', () => {
it('runOutcome maps the stop-reason vocabulary onto task outcomes', () => {
const output = [{ type: 'text' as const, text: 'partial' }]
expect(runOutcome({ output, stopReason: 'completed' })).toEqual({ status: 'completed', output: 'partial' })
expect(runOutcome({ output, stopReason: 'aborted' })).toEqual({ status: 'killed' })
expect(runOutcome({ output, stopReason: 'error' })).toEqual({ status: 'failed', detail: 'error' })
expect(runOutcome({ output, stopReason: 'max-tokens' })).toEqual({ status: 'failed', detail: 'max-tokens' })
expect(runOutcome({ output, stopReason: 'refusal' })).toEqual({ status: 'failed', detail: 'refusal' })
// Merge-extensible: an unknown reason is failed-with-detail, never success.
expect(runOutcome({ output, stopReason: 'paused' as never })).toEqual({ status: 'failed', detail: 'paused' })
})
it('settleRun disposes the run before reporting, on both result paths', async () => {
const order: string[] = []
const completed = await settleRun({
id: SessionId('child-1'),
localAgent: undefined,
result: Promise.resolve({ output: [{ type: 'text' as const, text: 'ok' }], stopReason: 'completed' as const }),
dispose() { order.push('dispose'); return Promise.resolve() },
})
order.push('reported')
expect(completed).toEqual({ status: 'completed', output: 'ok' })
expect(order).toEqual(['dispose', 'reported'])
// An infrastructure rejection still disposes and reports failed.
let disposed = false
const failed = await settleRun({
id: SessionId('child-2'),
localAgent: undefined,
result: Promise.reject(new Error('transport gone')),
dispose() { disposed = true; return Promise.resolve() },
})
expect(failed).toEqual({ status: 'failed', detail: 'Error: transport gone' })
expect(disposed).toBe(true)
const disposeFailed = await settleRun({
id: SessionId('child-3'),
localAgent: undefined,
result: Promise.resolve({ output: [], stopReason: 'completed' }),
dispose: () => Promise.reject(new Error('reap failed')),
})
expect(disposeFailed).toEqual({ status: 'failed', detail: 'dispose failed: Error: reap failed' })
const bothFailed = await settleRun({
id: SessionId('child-4'),
localAgent: undefined,
result: Promise.reject(new Error('result failed')),
dispose: () => Promise.reject(new Error('reap failed')),
})
expect(bothFailed).toEqual({
status: 'failed',
detail: 'Error: result failed; dispose failed: Error: reap failed',
})
})
})

View File

@@ -0,0 +1,39 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../core/agent"
},
{
"path": "../../llm/llm"
},
{
"path": "../../core/session"
},
{
"path": "../../session-persistence/session-persistence"
},
{
"path": "../subagent"
},
{
"path": "../../tasks/tasks"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -57,5 +57,4 @@ Append-only; newly visible content follows the reusable request prefix and does
## Known Limitations and Deferred Work
- **Runs expose no `sendMessage`/`resume`** — the optional runtime capabilities are absent on in-process runs.
- **The seed is a one-time snapshot** — the child sees the parent's completed turns as of the fork and nothing the parent logs afterwards; there is no live context sharing.

View File

@@ -11,8 +11,8 @@ import type { Context } from 'cordis'
import z from 'schemastery'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
import { startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess'
import type { SubagentCapabilities, SubagentProvider, SubagentResumeRequest, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
import { resumeInProcessRun, startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess'
export const name = 'subagent-fork'
// `tools` is deliberately NOT injected — same rationale as subagent-spawn: the
@@ -67,6 +67,13 @@ class ForkProvider implements SubagentProvider {
...seed.length > 0 ? { seed } : {},
})
}
resume(request: SubagentResumeRequest) {
// Cold resume loads the child's OWN persisted transcript, which already
// contains the completed-turn prefix captured at initial creation; it
// never forks the parent's newer history again.
return resumeInProcessRun(request)
}
}
export function apply(ctx: Context, config: Config): void {

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/subagent/subagent-inprocess/README.md
README.md: 980bc18de088c41dfe2f57a5ff0882a60892fc9f
README.zh.md: 1ceb628371c3ae9cee6d8afa6bc1d95ba4cda8ae
README.md: 7587b6dfc44bef90756c9f2aba96d54872935fee
README.zh.md: 751e745c6c7a64831debd2df58ed8c3d7861f84d

View File

@@ -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, optional child customization, result reading, cancellation, 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, strict steering, and disposal—has one implementation here.
## Start contract
@@ -11,21 +11,27 @@ This package is the shared run driver for the two in-process providers. Spawn pa
The driver follows this sequence:
1. Validate the parent depth and optional absolute `maxDepth`, then derive child depth as parent depth plus one and persist it in the child session header.
2. Call `parent.ctx.agents.create` directly, passing the required request signal into the factory's creation transaction.
3. During that transaction's unpublished setup window, install the requested persona, tool restriction, and structured-output runtime.
2. Call `parent.ctx.agents.create` directly, passing the required request signal into the factory's creation transaction. A continuable request publishes exactly `request.continuation.sessionId` instead of an internally minted id.
3. During that transaction's unpublished setup window, install the requested persona, tool restriction, structured-output runtime, and — for a continuable request — the one-shot `agent/pre-step` contribution that appends the `subagent/descriptor` event after the initial `turn/start` and before the first request, so the descriptor reaches persistence with that turn's flush.
4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.followup(prompt)` followed by `child.whenIdle()`.
5. Read the child's own last assistant message and latest message-triggered turn reason, excluding any fork seed and later between-turn records.
5. Read the child's own last assistant message and latest message-triggered turn reason, excluding any fork seed and later plugin-owned zero-step turns.
The child gets the parent's working-directory/session lineage and inherits the parent provider, model, and output-token cap unless `request.agentOptions` overrides them. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset.
When the optional sandbox-policy or approval service is composed, the driver snapshots the parent's explicit session override before child creation and appends a source-tagged event during unpublished setup, after any fork history and before session publication. It never copies deployment defaults or one-shot grants; later child switches still win. See the [policy-inheritance decision](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md).
## Cold resume
`resumeInProcessRun(request): Promise<SubagentRun>` reconstructs a persisted continuable child under the live parent's scope: `parent.ctx.agents.resume` loads the child's own transcript through persistence (a fork child's log already contains its seed prefix, so resume never re-forks current parent history), the descriptor's persona and tool filter are reapplied in the unpublished setup window, and the descriptor's `agentProvider`/`agentModel` become the runtime options. The persisted header stays authoritative for lineage and the delegation-depth floor. The activation's result boundary is the resumed log length: only this follow-up turn's output becomes the run result. Publication, abort handoff, and disposal follow the same contract as start.
## Cancellation and ownership
The required request signal covers both startup and the live run. Before publication, `AgentCreationTransaction` observes it, rolls back, and rejects. The factory detaches that creation-only listener before returning; the driver immediately checks the signal once more before installing a minimal live-run listener, closing the handoff race. After publication, abort cancels the child.
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: a synchronous `AgentStatus.running` check and `Agent.steer()` call share one frame, so delivery joins the observed turn or throws. 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.
## Spawn and fork inputs
`InProcessRunOptions` is `{ seed?: SessionEvent[] }`. Spawn omits it. Fork supplies a balanced completed-turn prefix and records its length so the result reader never mistakes a seeded parent message for child output.
@@ -110,5 +116,4 @@ Append-only; newly visible content follows the reusable request prefix and does
## Known Limitations and Deferred Work
- **Runs expose no `sendMessage`/`resume`** — the optional runtime capabilities are absent on in-process runs.
- **Structured capture accepts the `defineTool` schema subset only** — unsupported JSON Schema constructs fail before the child is created; a provider needing a broader schema vocabulary requires a different runtime.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
本包是两个进程内提供方共用的运行驱动器。spawn 不传入会话初始内容fork 传入父 agent智能体已完成轮次的前缀。其余机制包括深度、子 agent 创建、可选的子 agent 定制、结果读取、取消和 dispose资源释放都在此共用同一套实现。
本包是两个进程内提供方共用的运行驱动器。spawn 不传入会话初始内容fork 传入父 agent智能体已完成轮次的前缀。其余机制包括深度、子 agent 创建与冷恢复、可选的子 agent 定制、结果读取、取消、严格 steering中途引导和 dispose资源释放都在此共用同一套实现。
## 启动契约
@@ -11,21 +11,27 @@
驱动器按以下顺序运行:
1. 校验父 agent 深度和可选的绝对 `maxDepth`,然后把子 agent 深度推导为父 agent 深度加一,并将其持久化到子 agent 会话 header。
2. 直接调用 `parent.ctx.agents.create`,把必需的请求信号传入工厂的创建事务。
3. 在该事务未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时。
2. 直接调用 `parent.ctx.agents.create`,把必需的请求信号传入工厂的创建事务。可继续请求会精确发布 `request.continuation.sessionId`,而不是内部生成的 ID。
3. 在该事务未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时;对于可继续请求,还会安装一次性的 `agent/pre-step` 贡献,在初始 `turn/start` 之后、首次请求之前追加 `subagent/descriptor` 事件,使描述符随该轮次的 flush 到达持久化层
4. 发布子 agent保留返回的 `AgentHandle`,并通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。
5. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除任何 fork 初始内容和后续轮次间记录
5. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除任何 fork 初始内容和后续由插件拥有的零步骤轮次。
子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型和输出 token 上限。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。
当组合中挂载了可选的沙箱策略或审批服务时,驱动器会在创建子 agent 前对父级的显式会话覆盖项获取快照,并在未发布的设置阶段追加一条带来源标记的事件,使其位于所有 fork 历史之后、会话发布之前。它绝不复制部署默认值或一次性授权;子 agent 后续的切换仍然优先。参见[策略继承决策](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)。
## 冷恢复
`resumeInProcessRun(request): Promise<SubagentRun>` 会在当前父级作用域下重建持久化的可继续子 agent`parent.ctx.agents.resume` 通过持久化层加载子 agent 自身的 transcript文本记录fork 子 agent 的日志已经包含初始前缀,因此恢复绝不会再次 fork 当前父级历史),在未发布的设置窗口中重新应用描述符中的 persona 和工具过滤器,并把描述符中的 `agentProvider` / `agentModel` 作为运行时选项。持久化 header 对谱系和委派深度下限保持权威性。activation 的结果边界是恢复后日志的长度:只有此次后续轮次的输出会成为运行结果。发布、中止交接和 dispose 遵循与启动相同的契约。
## 取消与所有权
必需的请求信号同时覆盖启动阶段和实时运行。发布前,`AgentCreationTransaction` 会观察该信号、回滚并拒绝。工厂返回前会移除仅用于创建阶段的监听器;驱动器随即再次检查信号,然后安装最小化的实时运行监听器,从而消除交接竞态。发布后,中止会取消子 agent。
兑现后,调用方拥有该运行。提供方插件卸载不会撤销它。`dispose()` 会移除实时中止监听器、记录取消,并委托给返回的 `AgentHandle.dispose()`;后者通过可复用的完全停稳事务停止循环、移除 agent 和会话,并展开有作用域的注册。取消决定所有尚未完成的进行中结果,并将其报告为 `aborted`;已经完成的轮次仍保持完成状态。
运行公开严格的 `steer` 功能:同步的 `AgentStatus.running` 检查与 `Agent.steer()` 调用位于同一个调用栈帧中,因此消息要么加入观察到的轮次,要么抛错。运行不会触达 Agent 层在空闲时排队并启动新轮次的 fallback否则会在运行结果读取后启动一个未被跟踪的轮次。
## Spawn 与 fork 输入
`InProcessRunOptions` 的形态为 `{ seed?: SessionEvent[] }`。spawn 省略该值。fork 提供平衡的已完成轮次前缀,并记录其长度,确保结果读取器不会把作为初始内容的父 agent 消息误认为子 agent 输出。
@@ -110,5 +116,4 @@ When you have your final answer, you MUST report it by calling the `structured_o
## 已知限制与延期工作
- **运行不公开 `sendMessage`/`resume`**:进程内运行不具备这些可选运行时能力。
- **结构化捕获只接受 `defineTool` schema 子集**:不支持的 JSON Schema 构造会在子 agent 创建前失败;需要更广 schema 词汇的提供方必须采用不同的运行时。

View File

@@ -9,11 +9,18 @@
import { randomUUID } from 'node:crypto'
import type { Context } from 'cordis'
import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentHandle, AgentOptions } from '@deepseek-ai/dsh-agent'
import { findLastMessageTurnEnd, SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
import { createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm'
import { assertSubagentMaxDepth, delegationDepthOf } from '@deepseek-ai/dsh-subagent'
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
import type {
SubagentDescriptorData,
SubagentResult,
SubagentResumeRequest,
SubagentRun,
SubagentStartRequest,
SubagentStopReason,
} from '@deepseek-ai/dsh-subagent'
// Type-only: make `ctx.get('sandboxPolicy')` / `ctx.get('approval')` resolve
// to the policy services when composed — the driver consumes both
// opportunistically (the documented `ctx.get` pattern), never as a hard dep.
@@ -65,10 +72,27 @@ function prePublicationAbort(): Error {
return new Error('subagent request was aborted before child publication')
}
/**
* Register the one-shot child-scoped contribution that appends the durable
* `subagent/descriptor` event. `agent/step` is the first serial seam
* inside the child's initial turn, so the append lands after `turn/start` and
* before the first request, and reaches persistence with that turn's flush.
*/
function attachDescriptorAppend(childCtx: Context, descriptor: SubagentDescriptorData): void {
let appended = false
childCtx.on('agent/step', (agent) => {
if (appended) return
appended = true
agent.session.append('subagent/descriptor', descriptor)
})
}
/**
* Establish and drive one in-process child. Fulfillment means the agent is
* already published in the registry; rejection means the agent factory's
* creation transaction and any partially-created child have reached quiescence.
* A `request.continuation` publishes exactly its stable child id and appends
* its descriptor inside the child's initial turn.
* @param request - the trusted typed start request, including its required signal.
* @param options - the optional fork seed.
* @returns a ready holder-owned run.
@@ -88,7 +112,9 @@ export async function startInProcessRun(
throw new SubagentDepthError(childDepth, request.maxDepth)
}
const childId = SessionId(randomUUID())
// A continuable delegation names the durable conversation up front; the
// provider publishes exactly that id instead of allocating one internally.
const childId = request.continuation?.sessionId ?? SessionId(randomUUID())
const seedLength = options.seed?.length ?? 0
const parentHeader = parent.session.header
const parentProvider = parent.options.provider
@@ -123,9 +149,11 @@ export async function startInProcessRun(
if (request.outputSchema !== undefined) {
structured = attachStructuredRuntime(childCtx, request.outputSchema)
}
if (request.continuation !== undefined) {
attachDescriptorAppend(childCtx, request.continuation.descriptor)
}
}
const flags = { cancelled: false }
const handle = await parent.ctx.agents.create({
sessionId: childId,
meta: {
@@ -140,36 +168,84 @@ export async function startInProcessRun(
signal: request.signal,
setup,
})
return driveTurn(handle, request.signal, request.prompt, childId, seedLength, structured)
}
/**
* Reconstruct a persisted continuable child under the live parent's scope and
* drive one follow-up turn. The resumed session's own transcript is the seed
* (loaded through the parent's persistence-backed registry `resume`), so a
* fork child never re-forks current parent history; the persisted header
* remains authoritative for lineage and the delegation-depth floor.
* @param request - the fully resolved resume request from the low-level service.
* @returns a fresh ready holder-owned run for this activation.
*/
export async function resumeInProcessRun(request: SubagentResumeRequest): Promise<SubagentRun> {
if (request.signal.aborted) throw prePublicationAbort()
const descriptor = request.descriptor
const agentOptions: AgentOptions = {
...descriptor.agentProvider !== undefined ? { provider: descriptor.agentProvider } : {},
...descriptor.agentModel !== undefined ? { model: descriptor.agentModel } : {},
}
const setup = (childCtx: Context): void => {
if (descriptor.persona !== undefined) {
childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: descriptor.persona })
}
if (descriptor.toolFilter !== undefined) childCtx.tools.restrict(descriptor.toolFilter)
}
const handle = await request.parent.ctx.agents.resume({
resumeSessionId: request.sessionId,
agentOptions,
signal: request.signal,
setup,
})
// 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)
}
/**
* 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`, strict steering, and disposal.
*/
function driveTurn(
handle: AgentHandle,
signal: AbortSignal,
prompt: ContentBlock[],
childId: SessionId,
boundary: number,
structured?: StructuredAttachment,
): SubagentRun | Promise<never> {
const child = handle.agent
// Agent creation detaches its creation-only abort listener before returning.
// Close the narrow handoff race before installing the live-run listener.
// Static analysis does not model the abort that may land between the
// factory's listener detachment and this continuation.
// oxlint-disable-next-line typescript/no-unnecessary-condition
if (request.signal.aborted) {
flags.cancelled = true
await handle.dispose()
throw prePublicationAbort()
if (signal.aborted) {
return handle.dispose().then(() => { throw prePublicationAbort() })
}
const flags = { cancelled: false }
const onAbort = (): void => {
flags.cancelled = true
child.cancel({ kind: 'parent' })
}
request.signal.addEventListener('abort', onAbort, { once: true })
signal.addEventListener('abort', onAbort, { once: true })
const result: Promise<SubagentResult> = (async () => {
try {
child.followup(createUserMessage({ content: request.prompt, source: { kind: 'user' } }))
child.followup(createUserMessage({ content: prompt, source: { kind: 'user' } }))
await child.whenIdle()
return readResult(
child,
seedLength,
boundary,
flags.cancelled,
structured ? { captured: structured.captured() } : undefined,
)
} finally {
request.signal.removeEventListener('abort', onAbort)
signal.removeEventListener('abort', onAbort)
}
})()
@@ -178,21 +254,31 @@ export async function startInProcessRun(
localAgent: child,
result,
dispose(): Promise<void> {
request.signal.removeEventListener('abort', onAbort)
signal.removeEventListener('abort', onAbort)
flags.cancelled = true
return handle.dispose()
},
steer(content: ContentBlock[]): void {
// Strict live delivery: the synchronous running check and 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
// 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`)
}
child.steer(createUserMessage({ content, source: { kind: 'user' } }))
},
}
}
/** Read one settled child's result from events after its optional fork seed. */
/** Read one settled child's result from events after its activation boundary. */
function readResult(
child: Agent,
seedLength: number,
boundary: number,
cancelled: boolean,
structured?: { captured?: { value: unknown } | undefined },
): SubagentResult {
const own = child.session.events.slice(seedLength)
const own = child.session.events.slice(boundary)
const lastMessage = own.findLast((event): event is SessionEvent<'assistant/message'> => event.type === 'assistant/message')
const lastEnd = findLastMessageTurnEnd(own)
const output: ContentBlock[] = lastMessage?.data.message.content ?? []

View File

@@ -52,5 +52,4 @@ Append-only; newly visible content follows the reusable request prefix and does
## Known Limitations and Deferred Work
- **Runs expose no `sendMessage`/`resume`** — the optional runtime capabilities are absent on in-process runs.
- **Fresh means no parent transcript** — the child inherits cwd, lineage, model, and explicitly configured persona/tool restrictions, but none of the parent's conversation; use the fork provider when completed-turn context is required.

View File

@@ -8,8 +8,8 @@
import type { Context } from 'cordis'
import z from 'schemastery'
import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
import { startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess'
import type { SubagentCapabilities, SubagentProvider, SubagentResumeRequest, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
import { resumeInProcessRun, startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess'
export const name = 'subagent-spawn'
// `tools` is deliberately not injected: the child factory already provides it during setup,
@@ -46,6 +46,12 @@ class SpawnProvider implements SubagentProvider {
// request carries an outputSchema), and maps the result.
return startInProcessRun(request, {})
}
resume(request: SubagentResumeRequest) {
// Cold resume reconstructs the persisted child from its own transcript
// under the live parent scope; the shared driver drives the follow-up turn.
return resumeInProcessRun(request)
}
}
export function apply(ctx: Context, config: Config): void {

View File

@@ -235,12 +235,19 @@ describe('dsh-subagent-spawn', () => {
expect(result.stopReason).toBe('aborted')
})
it('does not expose the optional runtime methods (sendMessage/resume) in this cut', async () => {
it('exposes strict steer (no run-level resume): a settled child throws instead of queueing', async () => {
const { ctx, parent } = await setup([textResponse('x')])
const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
expect('sendMessage' in run).toBe(false)
// A run represents one disposable activation: cold resume is a provider
// method, never a run method.
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
// rather than falling back to Agent.steer()'s idle queue (which would
// start an untracked turn).
expect(() => { run.steer!([{ type: 'text', text: 'late' }]) })
.toThrow(/not running; the message was not delivered/)
await run.dispose()
})

View File

@@ -10,17 +10,19 @@ The family separates the stable interface from implementations and model-facing
| Package | Role |
|---|---|
| `@deepseek-ai/dsh-subagent` | Provider registry, request/result types, and lifecycle events. |
| `@deepseek-ai/dsh-subagent-spawn` | Fresh in-process child. |
| `@deepseek-ai/dsh-subagent-fork` | In-process child seeded with completed parent turns. |
| `@deepseek-ai/dsh-subagent-acp` | Fresh out-of-process ACP child. |
| `@deepseek-ai/dsh-tool-subagent` | Model-facing tool over one configured provider. |
| `@deepseek-ai/dsh-subagent` | Provider registry, request/result/descriptor types, and lifecycle events. |
| `@deepseek-ai/dsh-subagent-spawn` | Fresh in-process child, with cold resume. |
| `@deepseek-ai/dsh-subagent-fork` | In-process child seeded with completed parent turns, with cold resume. |
| `@deepseek-ai/dsh-subagent-acp` | Fresh out-of-process ACP child (one-shot). |
| `@deepseek-ai/dsh-subagent-control` | Continuable-child orchestration: durable ids, descriptors, Task-backed activation. |
| `@deepseek-ai/dsh-tool-subagent` | Model-facing delegation tool over one configured provider. |
| `@deepseek-ai/dsh-tool-subagent-control` | The globally named `send_message` follow-up tool. |
Multiple providers may coexist under different names. This lets a deployment expose, for example, a cheap in-process child and an isolated ACP child without changing the service contract.
## Service API
`SubagentService` has four main operations:
`SubagentService` has five main operations:
| Member | Meaning |
|---|---|
@@ -28,8 +30,9 @@ Multiple providers may coexist under different names. This lets a deployment exp
| `getProvider(name)` | Return the provider, or `undefined` when absent. |
| `list()` | Return provider names in insertion order. |
| `start(name, request)` | Validate requested capabilities and semantic values, then await the provider until a real child is ready. Fulfillment returns a holder-owned `SubagentRun`; rejection means the provider has already cleaned every partial startup resource. |
| `resume(name, request)` | Capability-checked dispatch to `provider.resume?()` with the same run lifecycle observation as `start`. The caller (the control service) has already loaded the child, folded its descriptor, and authorized the parent; this seam stays collection-, Task-, and persistence-agnostic. |
`SubagentStartRequest.signal` is required and is the canonical cancellation channel. An abort before publication makes `start()` reject after rollback; an abort after publication cancels the live child. The request may also select a model, require structured output, cap delegation depth, restrict child tools, or set a child persona.
`SubagentStartRequest.signal` is required and is the canonical cancellation channel. An abort before publication makes `start()` reject after rollback; an abort after publication cancels the live child. The request may also select a model, require structured output, cap delegation depth, restrict child tools, set a child persona, or carry a resolved `continuation` (the control-allocated stable child id plus its durable descriptor), which requires the provider's `resume` capability.
Same-process requests, descriptors, results, and event payloads are trusted typed values borrowed as immutable. The service does not clone or freeze them; serialization and hostile-input validation belong at actual process, worker, persistence, and model boundaries.
@@ -42,23 +45,27 @@ 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.
## The durable descriptor
The seam owns the versioned `subagent/descriptor` session event vocabulary (`src/descriptor.ts`): `snapshotSubagentDescriptor()` validates and detaches the declared composition before any Task exists, and `foldSubagentDescriptor()` recovers it from a loaded child log. The payload records the provider name, resolved child `agentOptions.provider`/`model`, and optional `persona`/`toolFilter` — explicit fields, never the merge-extensible `AgentOptions` object, so an unrelated extension value cannot break continuation. It omits `subagentDepth` (the persisted header's `delegationDepth` is the monotone floor) and `outputSchema` (an activation's result contract). The event is log-only: no `surfaceOp`, absent from model history, and retained by the append-only log across compaction.
## Delegation depth
The seam owns the depth vocabulary shared by implementations and consumers: the `AgentOptions.subagentDepth` declaration, `assertSubagentMaxDepth`, and `delegationDepthOf(agent)`. The persisted `SessionHeader.delegationDepth` is authoritative and monotone — runtime options may deepen the count but never lower it, so a resumed child cannot be re-counted as top-level.
Runtime features are optional methods on `SubagentRun`: `sendMessage?` steers a live child, while `resume?` asynchronously creates a continuation run. Method presence is the capability check.
`inheritsParentContext` is descriptive rather than enforceable. It says only whether the child sees completed parent conversation history (`fork` does; `spawn` and ACP do not), not whether it inherits tools, services, or authority.
## Ownership and lifecycle
`provider.start(request): Promise<SubagentRun>` is the ownership-transfer boundary. Before fulfillment, the provider owns setup and must cancel, roll back, and quiesce partial resources on every failure. After fulfillment, the caller owns the run and must call `dispose()` on every path.
`provider.start(request): Promise<SubagentRun>` is the ownership-transfer boundary. Before fulfillment, the provider owns setup and must cancel, roll back, and quiesce partial resources on every failure. After fulfillment, the caller owns the run and must call `dispose()` on every path. `provider.resume?(request)` shares the same contract for a resumed activation.
`SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. `dispose()` is idempotent, cancels remaining work, and waits for the child resources to quiesce.
A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, exposes the exact child as `SubagentRun.localAgent`, and records `request.parent.session.id` in the child's `parentSession` header. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`.
A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, exposes the exact child as `SubagentRun.localAgent`, and records `request.parent.session.id` in the child's `parentSession` header. A continuable start publishes exactly the control-allocated `continuation.sessionId`. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`.
The service emits `subagent/start` only after `start()` has fulfilled. It attaches the result observer before that synchronous notification, so even an already-settled child still produces `subagent/start` before `subagent/end`. The pair shares a service-minted `runId`; its `local` flag is snapshotted from the provider's exact `localAgent`, so observers never infer run identity or locality from reusable provider/session names.
The service emits `subagent/start` only after `start()` or `resume()` has fulfilled. It attaches the result observer before that synchronous notification, so even an already-settled child still produces `subagent/start` before `subagent/end`. The pair shares a service-minted `runId`; its `local` flag is snapshotted from the provider's exact `localAgent`, so observers never infer run identity or locality from reusable provider/session names.
Run events are scoped to the delegating parent. Every listener is independently contained: a synchronous throw or rejected returned promise is logged without starving peer listeners or changing the run.
@@ -66,17 +73,17 @@ Provider additions and removals also emit `subagent/provider-added` and `subagen
## Collection model
The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. Background delegation does not change this seam; the consumer registers startup and the eventual run with the generic `ctx.tasks` runtime, then collection and cancellation use the shared task tools. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts.
The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. Background delegation does not change this seam; `@deepseek-ai/dsh-subagent-control` registers each activation with the generic `ctx.tasks` runtime, then collection and cancellation use the shared task tools. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts.
## Model Experience
Indirectly, through `dsh-tool-subagent`, which renders provider-specific schemas and foreground or generic-background results while child working context remains child-only.
Indirectly, through `dsh-tool-subagent` and `dsh-tool-subagent-control`, which render provider-specific schemas and foreground, background, or follow-up results while child working context remains child-only.
#### KV Cache effect
No direct invalidation; the named consumer owns any request-prefix changes.
No direct invalidation; the named consumers own any request-prefix changes.
## Known Limitations and Deferred Work
- **Runtime steering and continuation are seam-only capabilities** — `sendMessage` and `resume` have no model-facing consumer in the current tool.
- **ACP children remain one-shot** — `AcpProvider.resume` requires persisting the remote session id in provider-specific descriptor data and a per-child continuation advertisement, since ACP `loadSession` support is negotiated per child rather than established by the provider method's presence.
- **Lifecycle events are observe-only** — a run-affecting `subagent/end` continuation or decision surface waits for a concrete consumer.

View File

@@ -0,0 +1,116 @@
/**
* The durable continuable-child descriptor: the versioned, model-hidden
* `subagent/descriptor` session event that records a child's declared
* composition so a known child id can be cold-resumed after its run — and its
* process — are gone. Providers append it turn-enclosed in the child's initial
* turn; the control service folds it back on resume.
*
* The descriptor deliberately snapshots explicit fields rather than the
* merge-extensible `AgentOptions` object: an unrelated extension value cannot
* make continuation fail merely because it is not JSON, and later composition
* inputs require a deliberate {@link SUBAGENT_DESCRIPTOR_VERSION} change. It
* omits `subagentDepth` — cold resume trusts the persisted header's
* `delegationDepth` as the monotone floor — and `outputSchema`, which belongs
* to one activation's result contract rather than durable child composition.
*
* @module @deepseek-ai/dsh-subagent/descriptor
*/
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { ToolRestriction } from '@deepseek-ai/dsh-tools'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/**
* Durable declared composition of a continuable subagent child, appended
* once by the establishing provider inside the child's initial turn,
* before its first request. Log-only: it carries no `surfaceOp`, never
* enters model history, and the append-only log retains it when
* compaction replaces surface history.
*/
'subagent/descriptor': SubagentDescriptorData
}
}
/**
* The current descriptor format version, stamped into every appended
* `subagent/descriptor` event and required verbatim by {@link foldSubagentDescriptor}.
* Supporting another composition input is a deliberate version change, never
* an implicit extra field.
*/
export const SUBAGENT_DESCRIPTOR_VERSION = 1
/** The `subagent/descriptor` event payload — a continuable child's declared composition. */
export interface SubagentDescriptorData {
/** Descriptor format version ({@link SUBAGENT_DESCRIPTOR_VERSION}). */
readonly version: number
/** The `ctx.subagents` provider name that established the child. */
readonly provider: string
/** Resolved child `agentOptions.provider`, when one was declared. */
readonly agentProvider?: string
/** Resolved child `agentOptions.model`, when one was declared. */
readonly agentModel?: string
/** Per-child persona that shadows the deployment persona on resume. */
readonly persona?: string
/** Child tool scoping reapplied on resume. */
readonly toolFilter?: ToolRestriction
}
/** Inputs {@link snapshotSubagentDescriptor} validates and detaches. */
export interface SubagentDescriptorInput {
/** The `ctx.subagents` provider name that will establish the child. */
readonly provider: string
/** Requested child `agentOptions.provider`. */
readonly agentProvider?: string
/** Requested child `agentOptions.model`. */
readonly agentModel?: string
/** Requested per-child persona. */
readonly persona?: string
/** Requested child tool scoping. */
readonly toolFilter?: ToolRestriction
}
/**
* Validate and detach descriptor inputs into the durable payload, before any
* Task or provider work begins — the same detached lossless-JSON boundary the
* session log itself enforces, applied early so a synchronous validation
* failure rejects the tool call without creating a Task.
* @param input - the caller-collected composition fields.
* @returns the versioned, detached descriptor payload.
* @throws when a field is not losslessly JSON-serializable.
*/
export function snapshotSubagentDescriptor(input: SubagentDescriptorInput): SubagentDescriptorData {
const candidate: SubagentDescriptorData = {
version: SUBAGENT_DESCRIPTOR_VERSION,
provider: input.provider,
...input.agentProvider !== undefined ? { agentProvider: input.agentProvider } : {},
...input.agentModel !== undefined ? { agentModel: input.agentModel } : {},
...input.persona !== undefined ? { persona: input.persona } : {},
...input.toolFilter !== undefined ? { toolFilter: input.toolFilter } : {},
}
const snapshot = snapshotJsonValue(candidate)
if (snapshot === undefined) {
throw new Error('subagent descriptor is not losslessly JSON-serializable')
}
return snapshot
}
/**
* Fold a persisted child log to its supported descriptor. The first
* `subagent/descriptor` event is authoritative — the establishing provider
* appends exactly one, so a later same-type event cannot rewrite the declared
* composition.
* @param events - the loaded child session events.
* @returns the descriptor, or `undefined` when the log has none or its
* version is not {@link SUBAGENT_DESCRIPTOR_VERSION} (the child is not
* resumable by this runtime).
*/
export function foldSubagentDescriptor(events: readonly SessionEvent[]): SubagentDescriptorData | undefined {
const event = events.find(
(candidate): candidate is SessionEvent<'subagent/descriptor'> => candidate.type === 'subagent/descriptor',
)
if (event === undefined) return undefined
if (event.data.version !== SUBAGENT_DESCRIPTOR_VERSION) return undefined
return event.data
}

View File

@@ -13,12 +13,13 @@
* (`@deepseek-ai/dsh-subagent-spawn`, `-fork`, `-acp`) and the model-facing
* consumer (`@deepseek-ai/dsh-tool-subagent`) are separate packages.
*
* Scope: the seam stays collection-agnostic — a run is started and its
* `result` awaited, whether the consumer blocks on it (foreground) or
* registers it as a `ctx.tasks` background task (the generic runtime owns
* ids/polling/stop; this seam gains nothing task-shaped). Steering
* ({@link SubagentRun.sendMessage}) is part of the contract but intentionally
* unused.
* Scope: the seam stays collection-, Task-, and persistence-agnostic — a run
* is started or resumed and its `result` awaited, whether the consumer blocks
* on it (foreground) or registers it as a `ctx.tasks` background task (the
* generic runtime owns ids/polling/stop; this seam gains nothing task-shaped).
* Durable continuable-child ids, descriptor lookup, and Task association
* belong to `@deepseek-ai/dsh-subagent-control`; this service only validates
* and dispatches `start`/`resume` and observes run lifecycle.
*
* Same-process providers are trusted typed collaborators. Requests, provider
* descriptors, results, and lifecycle payloads are borrowed immutable values;
@@ -41,6 +42,7 @@ import type {
SubagentCapabilities,
SubagentProvider,
SubagentResult,
SubagentResumeRequest,
SubagentRun,
SubagentStartRequest,
} from './types.ts'
@@ -50,13 +52,21 @@ export * from './out-of-process.ts'
export { SubagentRunId } from './types.ts'
export type {
SubagentCapabilities,
SubagentContinuation,
SubagentProvider,
SubagentResult,
SubagentResumeRequest,
SubagentRun,
SubagentStartRequest,
SubagentStopReason,
SubagentStopReasonMap,
} from './types.ts'
export {
foldSubagentDescriptor,
snapshotSubagentDescriptor,
SUBAGENT_DESCRIPTOR_VERSION,
} from './descriptor.ts'
export type { SubagentDescriptorData, SubagentDescriptorInput } from './descriptor.ts'
declare module '@deepseek-ai/dsh-agent' {
interface AgentOptions {
@@ -237,16 +247,52 @@ export class SubagentService extends Service {
* @returns the ready holder-owned run.
*/
async start(name: string, request: SubagentStartRequest): Promise<SubagentRun> {
const provider = this.expectProvider(name)
this.assertCapabilities(provider, request)
assertSubagentMaxDepth(request.maxDepth)
if (request.outputSchema !== undefined) assertObjectJsonSchema(request.outputSchema)
if (request.continuation !== undefined && provider.resume === undefined) {
throw new SubagentError(
`subagent provider "${provider.name}" does not support continuable children (no resume capability)`,
'UNSUPPORTED_CAPABILITY',
)
}
return this.observeRun(name, request.parent, await provider.start(request))
}
/**
* Resume a persisted continuable child through the named provider's
* `resume` capability, with the same run lifecycle observation as
* {@link start}. The caller (the control service) has already loaded the
* child, folded its descriptor, and authorized the parent; this method owns
* only capability-checked dispatch.
* @param name - the provider recorded in the child's descriptor.
* @param request - the fully resolved resume request.
* @returns the fresh holder-owned run for the resumed activation.
*/
async resume(name: string, request: SubagentResumeRequest): Promise<SubagentRun> {
const provider = this.expectProvider(name)
if (provider.resume === undefined) {
throw new SubagentError(
`subagent provider "${provider.name}" does not support resuming persisted children (no resume capability)`,
'UNSUPPORTED_CAPABILITY',
)
}
return this.observeRun(name, request.parent, await provider.resume(request))
}
/** Look up a provider for dispatch or fail loud. */
private expectProvider(name: string): SubagentProvider {
const provider = this.providers.get(name)
if (provider === undefined) {
throw new SubagentError(`no subagent provider registered for "${name}"`, 'NO_PROVIDER')
}
this.assertCapabilities(provider, request)
assertSubagentMaxDepth(request.maxDepth)
if (request.outputSchema !== undefined) assertObjectJsonSchema(request.outputSchema)
return provider
}
const parent = request.parent
const run = await provider.start(request)
/** Emit the start/end lifecycle pair for one accepted run and return it. */
private observeRun(name: string, parent: Agent, run: SubagentRun): SubagentRun {
const runId = SubagentRunId(randomUUID())
const lifecycleIdentity = {
runId,

View File

@@ -9,6 +9,7 @@ import type { Branded } from '@deepseek-ai/dsh-brand'
import type { ContentBlock } 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'
/** Identifies one accepted subagent run across its lifecycle event pair. */
export type SubagentRunId = Branded<'SubagentRunId'>
@@ -27,9 +28,10 @@ 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 such as steering and resume are optional {@link SubagentRun} methods whose presence
* is the capability. Each flag corresponds one-to-one to a {@link SubagentStartRequest} option:
* `depthLimit` to `maxDepth`; the other names match.
* capabilities are optional methods whose presence is the capability — strict 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.
*/
export interface SubagentCapabilities {
readonly outputSchema: boolean
@@ -91,6 +93,56 @@ export interface SubagentStartRequest {
* persona (strict `{{…}}` interpolation against the registered variables).
*/
readonly persona?: string
/**
* Continuable-child intent, resolved by the control service before start.
* The provider MUST publish exactly `sessionId` as the child identity
* instead of allocating one internally, and MUST append the snapshotted
* `descriptor` as the child's turn-enclosed `subagent/descriptor` event
* before its first request. Requires {@link SubagentProvider.resume} (the
* continuation capability); the service rejects the request otherwise.
*/
readonly continuation?: SubagentContinuation
}
/**
* The resolved continuable-child identity and durable composition record a
* control-service caller attaches to a start request.
*/
export interface SubagentContinuation {
/** Control-allocated stable child session id, published verbatim. */
readonly sessionId: SessionId
/** Snapshotted descriptor persisted in the child log for cold resume. */
readonly descriptor: SubagentDescriptorData
}
/**
* What a caller asks for when resuming a persisted continuable child. The
* control service loads the child log, folds and authorizes its descriptor,
* and passes this fully resolved request to
* {@link SubagentService.resume}, which dispatches to
* {@link SubagentProvider.resume}. The provider reconstructs the declared
* composition under the live parent's scope and drives one turn with `prompt`.
*/
export interface SubagentResumeRequest {
/** The persisted child session id to resume. */
readonly sessionId: SessionId
/** The follow-up message that starts the resumed activation's turn. */
readonly prompt: ContentBlock[]
/**
* The live parent agent — the direct parent recorded in the persisted child
* header. In-process backends reconstruct the child under this agent's
* currently loaded scope.
*/
readonly parent: Agent
/**
* Activation-owned cancellation signal, created before descriptor lookup.
* Same pre/post-publication contract as {@link SubagentStartRequest.signal}:
* an abort before publication rejects after rollback quiescence, and an
* abort afterward cancels the published child turn.
*/
readonly signal: AbortSignal
/** The folded durable descriptor whose composition the provider reconstructs. */
readonly descriptor: SubagentDescriptorData
}
/**
@@ -165,15 +217,16 @@ export interface SubagentRun {
*/
dispose(): Promise<void>
/**
* OPTIONAL (steering capability): send additional content to the running
* child between steps. Present only on providers that support live steering.
* 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 require the child to
* be running with no asynchronous boundary before delivery, and must not
* fall back to a queue path that could start a new, untracked turn after
* this run has settled. Throws when the child is not running. A run
* represents one disposable activation, so it has no cold-resume operation;
* resuming a settled child goes through {@link SubagentProvider.resume}.
*/
sendMessage?(content: ContentBlock[]): void
/**
* OPTIONAL (resume capability): send a follow-up task to a settled child,
* continuing its session, and return a fresh run for the continuation.
*/
resume?(content: ContentBlock[]): Promise<SubagentRun>
steer?(content: ContentBlock[]): void
}
/**
@@ -201,4 +254,15 @@ export interface SubagentProvider {
* promise rejects. Ownership transfers to the caller only on fulfillment.
*/
start(request: SubagentStartRequest): Promise<SubagentRun>
/**
* OPTIONAL (continuation capability): reconstruct a persisted continuable
* child from its own transcript and declared descriptor, drive one
* follow-up turn, and return a fresh run. Method presence is the capability
* — the service rejects `resume` dispatch and continuable starts on
* providers without it. Same publication contract as {@link start}: if
* reconstruction fails or `request.signal` aborts before fulfillment, the
* provider rolls its creation transaction back to quiescence before
* rejecting; after fulfillment the same signal cancels the published run.
*/
resume?(request: SubagentResumeRequest): Promise<SubagentRun>
}

View File

@@ -0,0 +1,40 @@
# @deepseek-ai/dsh-tool-subagent-control
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.
## Model Experience
### Tool schema
#### What the model sees
The generated [`send_message` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-control): `subagent_id` and `message`, with delivery-or-continue semantics and the `task_output` collection path described.
#### Token effect
Fixed schema cost per parent request.
#### KV Cache effect
Prefix-stable; the schema does not change at runtime.
### Delivery result
#### What the model sees
`message delivered to running task <taskId>` when the message joined the running activation, or `message started task <taskId> continuing subagent <subagent_id>` when it cold-resumed the child. Failures are errored results whose message states the message was not delivered (unknown or foreign child, ownership conflict, settlement race, no live-delivery capability).
#### Token effect
One short acknowledgement per call; the child's response enters parent history only when collected through `task_output` or injected by the task completion notice.
#### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
## Known Limitations and Deferred Work
- **A delivered message has no independent result** — its effect is reflected in the current Task's eventual result; only a started follow-up owns a fresh Task result.
- **Delivery can lose timing races** — a message racing task settlement, cancellation, or cleanup fails explicitly rather than falling through to cold resume; the model retries after the task settles.

View File

@@ -0,0 +1,55 @@
{
"name": "@deepseek-ai/dsh-tool-subagent-control",
"description": "Globally named send_message tool over the continuable-subagent control service",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-subagent-control": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-subagent-control": "workspace:^",
"@deepseek-ai/dsh-subagent-spawn": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-tasks-local": "workspace:^",
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,74 @@
/**
* The globally named `send_message` tool: a thin model-facing adapter over
* `ctx.subagentControl.sendMessage()`. It performs no lifecycle routing of its
* own — steer-or-resume orchestration belongs to the control service — and it
* lives apart from the provider-bound `@deepseek-ai/dsh-tool-subagent`
* instances so multiple delegation tools share one control tool.
* @module @deepseek-ai/dsh-tool-subagent-control
*/
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-subagent-control'
export const name = 'tool-subagent-control'
export const inject = ['tools', 'subagentControl']
/**
* Register the `send_message` tool.
* @param ctx - context carrying the tool registry and the control service.
*/
export function apply(ctx: Context): void {
ctx.tools.register(defineTool({
name: 'send_message',
description:
'Send a follow-up message to a background subagent by its subagent id. If it is still working, the '
+ 'message joins its current task; if it has finished, this starts a new task that continues the same '
+ 'subagent conversation. Either way the response arrives through the returned task id — collect it '
+ 'with `task_output`. A failure means the message was NOT delivered.',
parameters: {
subagent_id: {
type: 'string',
required: true,
description: 'The subagent id returned when the background subagent was started.',
},
message: {
type: 'string',
required: true,
description: 'The message to deliver to the subagent.',
},
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
route: {
type: 'string',
required: true,
enum: ['steered', 'started'],
},
taskId: { type: 'string', required: true },
},
},
render: (args, value) => [{
type: 'text',
text: value.route === 'steered'
? `message delivered to running task ${value.taskId}`
: `message started task ${value.taskId} continuing subagent ${args.subagent_id}`,
}],
},
execute(args, exec) {
const parent = exec.agent
if (!parent) {
// Non-agent callers have no session to authorize Task access with.
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)
return Promise.resolve(result)
},
}))
}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-tool-subagent-control`.
* @module @deepseek-ai/dsh-tool-subagent-control/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-subagent-control'
/** Cordis companion plugin name. */
export const name = 'tool-subagent-control-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this model-facing adapter has no independent lifecycle stream; delivery
* and activation relations are owned by the control service it calls.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,153 @@
import { afterEach, describe, expect, it } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { SessionId } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import SubagentService from '@deepseek-ai/dsh-subagent'
import SubagentControlService from '@deepseek-ai/dsh-subagent-control'
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import * as tool from '../src/index.ts'
const testToolSignal = new AbortController().signal
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
async function setup(script: ConstructorParameters<typeof MockAdapter>[0]) {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
const root = mkdtempSync(join(tmpdir(), 'dsh-tool-subagent-control-'))
roots.push(root)
await ctx.plugin(JsonlSessionPersistence, { root })
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentService)
await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
await ctx.plugin(LocalTaskService)
await ctx.plugin(ToolTasks, {})
await ctx.plugin(SubagentControlService)
await ctx.plugin(tool)
ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
return { ctx, parent }
}
function text(result: { content: { type: string; text?: string }[] }): string {
return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
}
let calls = 0
function callTool(ctx: Context, name: string, args: unknown, agent?: unknown) {
return ctx.tools.execute({
signal: testToolSignal,
callId: CallId(`call-${++calls}`),
name,
arguments: args,
...agent !== undefined ? { agent: agent as never } : {},
})
}
describe('dsh-tool-subagent-control', () => {
it('registers send_message once, globally, with the two required parameters', async () => {
const { ctx } = await setup([])
const schemas = ctx.tools.schemas().filter(schema => schema.name === 'send_message')
expect(schemas).toHaveLength(1)
const props = (schemas[0]!.parameters as { properties?: Record<string, unknown> }).properties ?? {}
expect(Object.keys(props).sort()).toEqual(['message', 'subagent_id'])
expect(schemas[0]!.description).toContain('task_output')
})
it('cold-resumes a settled child and renders the started route with its task id', async () => {
const { ctx, parent } = await setup([textResponse('first answer'), textResponse('second answer')])
const started = ctx.subagentControl.startContinuable({
provider: 'spawn',
label: 'work',
request: { prompt: [{ type: 'text', text: 'child task' }], parent },
})
await ctx.tasks.wait(started.taskId, 5_000, parent)
const result = await callTool(ctx, 'send_message', {
subagent_id: started.childId,
message: 'and then?',
}, parent)
expect(result.isError).toBe(false)
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]')
})
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
// 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) => {
steered = (message[0] as { text: string }).text
return { route: 'steered', taskId: ctx.tasks.list(agent)[0]?.id ?? ('subagent-9' as never) }
}
const result = await callTool(ctx, 'send_message', {
subagent_id: 'some-child',
message: 'also consider Y',
}, parent)
expect(result.isError).toBe(false)
expect(steered).toBe('also consider Y')
expect(text(result)).toBe('message delivered to running task subagent-9')
})
it('reports a control-service failure as an errored, not-delivered result', async () => {
const { ctx, parent } = await setup([])
const result = await callTool(ctx, 'send_message', {
subagent_id: 'no-such-child',
message: 'hello?',
}, parent)
// Unknown ids start a Task whose failure carries the unavailable detail;
// synchronous rejections (ownership conflicts) become isError results.
if (result.isError) {
expect(text(result)).toContain('not delivered')
} else {
const taskId = text(result).match(/task (\S+) /)?.[1]
expect(taskId).toBeDefined()
const snapshot = await ctx.tasks.wait(taskId as never, 5_000, parent)
expect(snapshot.status).toBe('failed')
expect(snapshot.detail).toContain('unavailable')
}
})
it('fails loud when invoked without a calling agent', async () => {
const { ctx } = await setup([])
const result = await callTool(ctx, 'send_message', { subagent_id: 'x', message: 'y' })
expect(result.isError).toBe(true)
expect(text(result)).toContain('requires a calling agent')
})
it('unregisters with its plugin fiber (HMR safety)', async () => {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentService)
await ctx.plugin(LocalTaskService)
await ctx.plugin(SubagentControlService)
const fiber = await ctx.plugin(tool)
expect(ctx.tools.schemas().some(schema => schema.name === 'send_message')).toBe(true)
await fiber.dispose()
expect(ctx.tools.schemas().some(schema => schema.name === 'send_message')).toBe(false)
})
it('has the namespace-plugin export shape (no stray default)', () => {
expect('default' in tool).toBe(false)
expect(tool.name).toBe('tool-subagent-control')
expect(tool.inject).toEqual(['tools', 'subagentControl'])
expect(typeof tool.apply).toBe('function')
})
})

View File

@@ -0,0 +1,33 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../llm/llm"
},
{
"path": "../../core/session"
},
{
"path": "../../core/tools"
},
{
"path": "../subagent-control"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/subagent/tool-subagent/README.md
README.md: 20bb6b9c59a13f23301368faefe18849ccc0b1e9
README.zh.md: 8da57896359f5ac47d0ec076c3395d2e7fb1e02a
README.md: 7d32da3c974361eb5e58cdb2ee5be756383ad3d1
README.zh.md: eadc168fd07701b3e3d9600b3fe69bd8b22e235a

View File

@@ -10,7 +10,7 @@ Each plugin instance binds one `provider` to one `toolName`; the model receives
A foreground call passes the execution signal through startup and execution, awaits `run.result`, and always awaits `run.dispose()` before returning. Only `completed` returns the canonical `{ kind: 'foreground', runId, output: JsonValue[] }`, rendered as the same final text; abort, refusal, token limit, and other failures become errored tool results without partial output.
With `run_in_background: true`, the tool registers the parent-owned task before starting the provider and returns canonical `{ kind: 'background', taskId }`, rendered as `started background subagent task <id>`. A task-owned signal covers pending startup and the child after the starting call returns. `task_kill` and owner disposal abort it. Settlement awaits startup rollback or child disposal, then maps completed final text, abort to `killed`, and other failures to `failed`. The task has no incremental read; generic task tools own later status, collection, cancellation, and notices. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md).
With `run_in_background: true`, the route follows the provider's continuation capability and returns canonical `{ kind: 'background', taskId, subagentId? }`. A resumable provider (spawn, fork) delegates to `ctx.subagentControl.startContinuable()`, which owns the durable child id, descriptor snapshot, Task registration, and settle-then-dispose ordering; the result includes `subagentId`, renders as `started subagent <childId> as task <taskId>`, and accepts follow-up messages through the global `send_message` tool. A one-shot provider (ACP) keeps the plain parent-owned task, omits `subagentId`, and renders as `started background subagent task <id>`. Either way a task-owned signal covers pending startup and the child after the starting call returns; `task_kill` and owner disposal abort it, settlement awaits startup rollback or child disposal, and completed final text, abort to `killed`, and other failures to `failed` map identically. The task has no incremental read; generic task tools own later status, collection, cancellation, and notices. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md) and the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md).
`toolFilter` changes the child's global tool layer but is not a parent-derived authority ceiling. See the [agent-scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals).
@@ -64,7 +64,7 @@ Append-only; newly visible content follows the reusable request prefix and does
#### What the model sees
Start returns exactly `started background subagent task <id>`. The generic task surface provides later status, final output, cancellation responses, and notices.
Start returns exactly `started subagent <childId> as task <taskId>` on a resumable provider, or `started background subagent task <id>` on a one-shot provider. The generic task surface provides later status, final output, cancellation responses, and notices; `send_message` (from `dsh-tool-subagent-control`) delivers follow-ups to a continuable child.
#### Token effect

View File

@@ -10,7 +10,7 @@
前台调用会让执行信号贯穿启动和执行,等待 `run.result`,并且在返回前总会等待 `run.dispose()`。只有 `completed` 会返回规范值 `{ kind: 'foreground', runId, output: JsonValue[] }`并渲染为相同的最终文本中止、拒绝、token 上限和其他失败都会变成出错的工具结果,不包含局部输出。
设置 `run_in_background: true` 后,工具会在启动提供方前注册父级拥有的任务,并返回规范值 `{ kind: 'background', taskId }`渲染为 `started background subagent task <id>`。任务拥有的信号覆盖待处理的启动阶段,以及启动调用返回后的子 agent`task_kill` 和所有者 dispose(资源释放)会中止它结算会等待启动回滚或子 agent dispose然后把完成的最终文本映射为完成、中止映射为 `killed`、其他失败映射为 `failed`。任务不提供增量读取;通用任务工具负责后续状态、收集、取消和通知。见[后台 subagent Agent Noteagent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)。
设置 `run_in_background: true` 后,路由遵循提供方的继续功能,并返回规范值 `{ kind: 'background', taskId, subagentId? }`。可恢复提供方spawn、fork会委派给 `ctx.subagentControl.startContinuable()`,由它拥有持久化子 agent ID、描述符快照、Task 注册和先结算后 dispose资源释放的顺序结果包含 `subagentId`,渲染为 `started subagent <childId> as task <taskId>`,并通过全局 `send_message` 工具接收后续消息。一次性提供方 ACPAgent Client Protocol保留普通的父级所有任务省略 `subagentId`,并渲染为 `started background subagent task <id>`两条路径中,任务拥有的信号都会覆盖待处理的启动阶段启动调用返回后的子 agent`task_kill` 和所有者 dispose 会中止它结算会等待启动回滚或子 agent dispose然后把完成的最终文本映射为完成、中止映射为 `killed`、其他失败映射为 `failed`。任务不提供增量读取;通用任务工具负责后续状态、收集、取消和通知。见[后台 subagent Agent Noteagent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)和[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)
`toolFilter` 会改变子 agent 的全局工具层,但不是从父级派生的权限上限。见 [agent 作用域的安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。
@@ -64,7 +64,7 @@
#### 模型看到的内容
启动时原样返回 `started background subagent task <id>`。通用任务接口提供后续状态、最终输出、取消响应和通知
对于可恢复提供方,启动时精确返回 `started subagent <childId> as task <taskId>`;对于一次性提供方,则返回 `started background subagent task <id>`。通用任务接口提供后续状态、最终输出、取消响应和通知;`send_message`(来自 `dsh-tool-subagent-control`)会把后续消息交付给可继续子 agent
#### Token 影响

View File

@@ -31,6 +31,7 @@
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-subagent": "^0.0.1",
"@deepseek-ai/dsh-subagent-control": "^0.0.1",
"@deepseek-ai/dsh-tasks": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.7"
@@ -43,7 +44,12 @@
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-subagent-control": "workspace:^",
"@deepseek-ai/dsh-subagent-spawn": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-tasks-local": "workspace:^",

View File

@@ -1,20 +1,23 @@
/**
* Model-facing delegation through one configured `ctx.subagents` provider.
* Provider lifecycle controls tool registration and context-sensitive schema
* wording. Foreground calls always dispose the run after collection; background
* calls use an independent cancellation signal and settle a final-output task
* only after child disposal.
* wording. Foreground calls always dispose the run after collection. A
* background call's route follows the provider's continuation capability:
* a provider with `resume` delegates to `ctx.subagentControl`, which owns the
* durable child id, its descriptor, and the Task-backed activation lifecycle;
* a provider without it (ACP) keeps the one-shot background task.
* @module @deepseek-ai/dsh-tool-subagent
*/
import type { Context } from 'cordis'
import z from 'schemastery'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent'
import type { AgentOptions } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { JsonValue } from '@deepseek-ai/dsh-session'
import { assertSubagentMaxDepth } from '@deepseek-ai/dsh-subagent'
import type { SubagentProvider, SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
import type { SubagentProvider, SubagentResult, SubagentRun } from '@deepseek-ai/dsh-subagent'
import { settleRun } from '@deepseek-ai/dsh-subagent-control'
import type { TaskOutcome } from '@deepseek-ai/dsh-tasks'
export const name = 'tool-subagent'
@@ -85,18 +88,6 @@ export const Config: z<Config> = z.object({
maxDepth: z.union([z.natural().max(Number.MAX_SAFE_INTEGER), z.const('provider-managed' as const)]).default(3),
})
/**
* Flatten a child's final output blocks to text for the tool result. The child
* may return non-text blocks; this path returns only text. Structured results
* use `outputSchema`.
*/
function outputText(blocks: ContentBlock[]): string {
return blocks
.filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text')
.map(b => b.text)
.join('')
}
/** Render text blocks from the canonical JSON block array without trusting arbitrary values. */
function outputValueText(values: JsonValue[]): string {
return values
@@ -107,6 +98,17 @@ function outputValueText(values: JsonValue[]): string {
.join('')
}
/** Settle pending startup without rejecting the task producer contract. */
async function settleStart(start: Promise<SubagentRun>, signal: AbortSignal): Promise<TaskOutcome> {
try {
return await settleRun(await start)
} catch (error: unknown) {
return signal.aborted
? { status: 'killed' }
: { status: 'failed', detail: String(error) }
}
}
/** A non-`completed` stop reason means the child did not finish cleanly. */
function stopReasonError(result: SubagentResult): string | undefined {
switch (result.stopReason) {
@@ -127,50 +129,6 @@ function stopReasonError(result: SubagentResult): string | undefined {
}
}
/**
* Map a child result to the task outcome: completed carries final text,
* aborted is killed, and every other reason is failed without partial output.
* @param result - child terminal result.
* @returns outcome for the `ctx.tasks` registration.
*/
export function runOutcome(result: SubagentResult): TaskOutcome {
switch (result.stopReason) {
case 'completed':
return { status: 'completed', output: outputText(result.output) }
case 'aborted':
return { status: 'killed' }
case 'error':
case 'max-tokens':
case 'refusal':
return { status: 'failed', detail: result.stopReason }
// Merge-extensible reasons remain failures with their raw detail.
default:
return { status: 'failed', detail: String(result.stopReason) }
}
}
/**
* Await the child result, dispose the run, then return its task outcome. Result
* and disposal failures become `failed`; when both fail, both details survive.
* @param run - live run to settle and release.
* @returns outcome after child resources are released.
*/
export async function settleRun(run: SubagentRun): Promise<TaskOutcome> {
let outcome: TaskOutcome
try {
outcome = runOutcome(await run.result)
} catch (error: unknown) {
outcome = { status: 'failed', detail: String(error) }
}
try {
await run.dispose()
} catch (error: unknown) {
const prefix = outcome.detail === undefined ? '' : `${outcome.detail}; `
return { status: 'failed', detail: `${prefix}dispose failed: ${String(error)}` }
}
return outcome
}
/**
* Model-facing wording from the provider's conversation-history descriptor
* ({@link SubagentProvider.inheritsParentContext}).
@@ -210,30 +168,6 @@ function providerWording(inheritsConversation: boolean): { description: string;
}
}
function startRequest(config: Config, prompt: string, parent: Agent, signal: AbortSignal): SubagentStartRequest {
const maxDepth = typeof config.maxDepth === 'number' ? config.maxDepth : undefined
return {
prompt: [{ type: 'text', text: prompt }],
parent,
signal,
...config.agentOptions !== undefined ? { agentOptions: config.agentOptions } : {},
...config.persona !== undefined ? { persona: config.persona } : {},
...config.toolFilter !== undefined ? { toolFilter: config.toolFilter } : {},
...maxDepth !== undefined ? { maxDepth } : {},
}
}
/** Settle pending startup without rejecting the task producer contract. */
async function settleStart(start: Promise<SubagentRun>, signal: AbortSignal): Promise<TaskOutcome> {
try {
return await settleRun(await start)
} catch (error: unknown) {
return signal.aborted
? { status: 'killed' }
: { status: 'failed', detail: String(error) }
}
}
export function apply(ctx: Context, config: Config): void {
// Direct apply() bypasses Schemastery's numeric constraints. A direct-apply
// omission stays capless (the schema default only runs through the loader).
@@ -257,10 +191,18 @@ export function apply(ctx: Context, config: Config): void {
}
const wording = providerWording(provider.inheritsParentContext)
const backgroundEnabled = config.enableRunInBackground !== false
// The provider's continuation capability decides the background route: a
// resumable provider starts durable, follow-up-able children through the
// control service, while a one-shot provider (ACP) keeps the plain task.
const continuable = provider.resume !== undefined
disposeTool = ctx.tools.register(defineTool({
name: config.toolName ?? 'subagent',
description: wording.description + (backgroundEnabled
? ' Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.'
? continuable
? ' Set `run_in_background: true` to start a continuable background subagent: you receive its'
+ ' subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`,'
+ ' and send follow-up messages with `send_message`.'
: ' Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.'
: ''),
parameters: {
description: {
@@ -276,7 +218,10 @@ export function apply(ctx: Context, config: Config): void {
...backgroundEnabled ? {
run_in_background: {
type: 'boolean' as const,
description: 'Run as a background task and return its id; collect with task_output or stop with task_kill.',
description: continuable
? 'Run as a continuable background subagent and return its subagent and task ids; '
+ 'collect with task_output, stop with task_kill, follow up with send_message.'
: 'Run as a background task and return its id; collect with task_output or stop with task_kill.',
},
} : {},
},
@@ -289,6 +234,7 @@ export function apply(ctx: Context, config: Config): void {
properties: {
kind: { type: 'string', required: true, const: 'background' },
taskId: { type: 'string', required: true },
subagentId: { type: 'string' },
},
},
{
@@ -305,7 +251,9 @@ export function apply(ctx: Context, config: Config): void {
render: (_args, value) => [{
type: 'text',
text: value.kind === 'background'
? `started background subagent task ${value.taskId}`
? value.subagentId === undefined
? `started background subagent task ${value.taskId}`
: `started subagent ${value.subagentId} as task ${value.taskId}`
: outputValueText(value.output),
}],
},
@@ -316,27 +264,54 @@ export function apply(ctx: Context, config: Config): void {
throw new Error('subagent tool requires a calling agent (exec.agent was undefined)')
}
const maxDepth = typeof config.maxDepth === 'number' ? config.maxDepth : undefined
const request = {
prompt: [{ type: 'text', text: args.prompt }] as ContentBlock[],
parent,
...config.agentOptions !== undefined ? { agentOptions: config.agentOptions } : {},
...config.persona !== undefined ? { persona: config.persona } : {},
...config.toolFilter !== undefined ? { toolFilter: config.toolFilter } : {},
...maxDepth !== undefined ? { maxDepth } : {},
}
if (args.run_in_background === true) {
// The validator permits undeclared keys, so schema omission also needs
// execution-time enforcement.
if (!backgroundEnabled) {
throw new Error('run_in_background is disabled for this tool instance (enableRunInBackground: false)')
}
if (continuable) {
const control = ctx.get('subagentControl')
if (control === undefined) {
throw new Error('continuable background subagents unavailable: load @deepseek-ai/dsh-subagent-control and @deepseek-ai/dsh-tool-tasks')
}
// The control service owns the durable child id, descriptor
// snapshot, Task registration, and settle-then-dispose ordering; a
// synchronous validation failure rejects the call with no Task.
const started = control.startContinuable({
provider: config.provider,
label: args.description,
request,
})
return {
kind: 'background' as const,
taskId: started.taskId,
subagentId: started.childId,
}
}
const tasks = ctx.get('tasks')
if (tasks === undefined) {
throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
}
// Task preflight finishes before the starter can spawn a child.
// One-shot background child: task preflight finishes before the
// starter can spawn, and the task-owned signal covers startup.
const id = tasks.start({
kind: 'subagent',
label: args.description,
owner: parent,
run: () => {
const controller = new AbortController()
const start = ctx.subagents.start(
config.provider,
startRequest(config, args.prompt, parent, controller.signal),
)
const start = ctx.subagents.start(config.provider, { ...request, signal: controller.signal })
return {
cancel: (reason?: string) => {
controller.abort(reason ?? 'background subagent task killed')
@@ -349,14 +324,10 @@ export function apply(ctx: Context, config: Config): void {
return { kind: 'background' as const, taskId: id }
}
const request = startRequest(
config,
args.prompt,
parent,
exec.signal,
)
const run: SubagentRun = await ctx.subagents.start(config.provider, request)
const run: SubagentRun = await ctx.subagents.start(config.provider, {
...request,
signal: exec.signal,
})
try {
const result = await run.result

View File

@@ -1,4 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { CallId } from '@deepseek-ai/dsh-llm'
@@ -6,13 +9,18 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
import { type Agent } from '@deepseek-ai/dsh-agent'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import SubagentService from '@deepseek-ai/dsh-subagent'
import type { SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
import SubagentControlService from '@deepseek-ai/dsh-subagent-control'
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import * as mock from './scripted-provider.ts'
import * as tool from '../src/index.ts'
import { runOutcome, settleRun } from '../src/index.ts'
import { SessionId } from '@deepseek-ai/dsh-session'
const testToolSignal = new AbortController().signal
@@ -808,58 +816,75 @@ describe('dsh-tool-subagent background mode', () => {
expect(text(killed)).toBe('(no new output)\n[status: killed]')
})
it('runOutcome maps the stop-reason vocabulary onto task outcomes', () => {
const output = [{ type: 'text' as const, text: 'partial' }]
expect(runOutcome({ output, stopReason: 'completed' })).toEqual({ status: 'completed', output: 'partial' })
expect(runOutcome({ output, stopReason: 'aborted' })).toEqual({ status: 'killed' })
expect(runOutcome({ output, stopReason: 'error' })).toEqual({ status: 'failed', detail: 'error' })
expect(runOutcome({ output, stopReason: 'max-tokens' })).toEqual({ status: 'failed', detail: 'max-tokens' })
expect(runOutcome({ output, stopReason: 'refusal' })).toEqual({ status: 'failed', detail: 'refusal' })
// Merge-extensible: an unknown reason is failed-with-detail, never success.
expect(runOutcome({ output, stopReason: 'paused' as never })).toEqual({ status: 'failed', detail: 'paused' })
})
describe('dsh-tool-subagent continuable background mode', () => {
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
it('settleRun disposes the run before reporting, on both result paths', async () => {
const order: string[] = []
const completed = await settleRun({
id: SessionId('child-1'),
localAgent: undefined,
result: Promise.resolve({ output: [{ type: 'text' as const, text: 'ok' }], stopReason: 'completed' as const }),
dispose() { order.push('dispose'); return Promise.resolve() },
})
order.push('reported')
expect(completed).toEqual({ status: 'completed', output: 'ok' })
expect(order).toEqual(['dispose', 'reported'])
/** Boot the real continuable stack: loop, persistence, spawn, tasks, control. */
async function continuableSetup() {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
const root = mkdtempSync(path.join(tmpdir(), 'dsh-tool-subagent-continuable-'))
roots.push(root)
await ctx.plugin(JsonlSessionPersistence, { root })
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentService)
await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
await ctx.plugin(LocalTaskService)
await ctx.plugin(ToolTasks, {})
await ctx.plugin(SubagentControlService)
await ctx.plugin(tool, { provider: 'spawn' })
ctx.llm.registerAdapter(['mock'], new MockAdapter([
textResponse('continuable answer'),
]))
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
return { ctx, parent }
}
// An infrastructure rejection still disposes and reports failed.
let disposed = false
const failed = await settleRun({
id: SessionId('child-2'),
localAgent: undefined,
result: Promise.reject(new Error('transport gone')),
dispose() { disposed = true; return Promise.resolve() },
})
expect(failed).toEqual({ status: 'failed', detail: 'Error: transport gone' })
expect(disposed).toBe(true)
it('a resumable provider advertises send_message and returns both ids', async () => {
const { ctx, parent } = await continuableSetup()
const schema = ctx.tools.schemas().find(s => s.name === 'subagent')!
expect(schema.description).toContain('send_message')
const disposeFailed = await settleRun({
id: SessionId('child-3'),
localAgent: undefined,
result: Promise.resolve({ output: [], stopReason: 'completed' }),
dispose: () => Promise.reject(new Error('reap failed')),
})
expect(disposeFailed).toEqual({ status: 'failed', detail: 'dispose failed: Error: reap failed' })
const started = await callSubagent(
ctx,
{ description: 'continuable work', prompt: 'dig in', run_in_background: true },
{ agent: parent },
)
expect(started.isError).toBe(false)
const match = /^started subagent (\S+) as task (\S+)$/.exec(text(started))
expect(match).not.toBeNull()
const [, childId, taskId] = match!
const snapshot = await ctx.tasks.wait(taskId as never, 5_000, parent)
expect(snapshot.status).toBe('completed')
expect(ctx.tasks.read(taskId as never, parent).text).toBe('continuable answer')
// The child id names a durable session that outlives the settled Task.
const loaded = await ctx.sessionPersistence.load(SessionId(childId!))
expect(loaded.events.some(event => event.type === 'subagent/descriptor')).toBe(true)
})
const bothFailed = await settleRun({
id: SessionId('child-4'),
localAgent: undefined,
result: Promise.reject(new Error('result failed')),
dispose: () => Promise.reject(new Error('reap failed')),
})
expect(bothFailed).toEqual({
status: 'failed',
detail: 'Error: result failed; dispose failed: Error: reap failed',
it('fails loud when the provider is resumable but the control service is not loaded', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(SubagentService)
// A resumable provider without ctx.subagentControl.
ctx.subagents.registerProvider({
name: 'resumable',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: () => { throw new Error('unreachable') },
resume: () => { throw new Error('unreachable') },
})
await ctx.plugin(tool, { provider: 'resumable', maxDepth: 'provider-managed' })
const result = await callSubagent(ctx, { description: 'd', prompt: 'p', run_in_background: true })
expect(result.isError).toBe(true)
expect(text(result)).toContain('load @deepseek-ai/dsh-subagent-control')
})
})

View File

@@ -29,6 +29,9 @@
{
"path": "../subagent"
},
{
"path": "../subagent-control"
},
{
"path": "../../tasks/tasks"
},