refactor(subagent): merge continuation control service

This commit is contained in:
Dudu
2026-07-27 00:00:14 +08:00
committed by imccyu
parent 0a95ad8cc0
commit 88f913a9ae
71 changed files with 543 additions and 673 deletions

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: 438907ea7de41842f900b050385f15feac7cc272
README.zh.md: 87911216bc4e6b5f75e17ca2c58818725f66e7ec
README.md: a195ecbaeb24cb63af8cdd4ac872bb6a2fc97d46
README.zh.md: b9965030a38b603f7c03d98d6b8021acbeb47fda

View File

@@ -6,16 +6,15 @@ 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 + the durable child descriptor | `ctx.subagents` |
| `subagent/` | Subagent service: named-provider registry, vocabulary, durable descriptor, and optional Task-backed continuation orchestration | `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, 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`) |
| `tool-subagent-control/` | The optional, globally named `send_message` follow-up tool over `ctx.subagents` | (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). `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 interface and continuation orchestration live at `subagent/subagent/`. Raw `start` / `resume` dispatch stays independent of Tasks and persistence; an internal manager binds durable child sessions to disposable Task-backed activations only while the Task and Agent services are present, and resolves persistence only when a continuation operation runs. 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 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).
The design rationale: [.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), and [.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md).

View File

@@ -6,16 +6,15 @@ subagent子 agentseam 允许 agent智能体把工作委派给子 age
| 包package | 角色 | ctx 键 |
|---|---|---|
| `subagent/` | 抽象 subagent seam:具名提供方注册表、词汇持久化子 agent 描述符 | `ctx.subagents` |
| `subagent/` | Subagent 服务:具名提供方注册表、词汇持久化描述符与可选的由 Task 支撑的继续执行编排 | `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-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` |
| `tool-subagent-control/` | 基于 `ctx.subagents`、可选且全局名称唯一的 `send_message` 后续消息工具 | (注册到 `ctx.tools` |
接口位于 `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 边界。
接口和继续执行编排位于 `subagent/subagent/`原始 `start` / `resume` 分发仍与 Task 和持久化无关;只有在 Task 与 Agent 服务存在时,内部管理器才会把持久化子会话绑定到可 dispose、由 Task 支撑的 activation并且只在继续执行操作运行时解析持久化服务。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不含提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` / `subagent-dsh-sdk` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程共享的凭据清除、以进程树为范围的拆卸、dispose资源释放阶梯。测试只用包内 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-07-21-continuable-background-subagents.md](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.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) 和 [.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)

View File

@@ -1,37 +0,0 @@
# @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 provider's durability-confirmed child result, disposes the run, and only then records the `TaskOutcome`: a terminal Task leaves the durable child session but no live child Agent. A provider rejection with `DURABILITY_FAILED` settles the Task as `failed` and copies the error message into `detail`, so `task_output` reports the failed checkpoint and resumability risk without exposing unconfirmed output.
`sendMessage(parent, childId, message, source)` owns steer-or-resume routing and requires the caller's `MessageSource`. A running activation preserves it through the run's confirmed `steer` capability and returns the existing Task id (`steered`) only after a committed request snapshot admits the message; an absent activation starts a fresh Task that loads the persisted child, authorizes the recorded `parentSession` as the direct parent, folds the descriptor, and dispatches `SubagentService.resume()` with the same source (`started`). Either route projects the content to the model as a user-role message while retaining its source in the child log. Rejection means the message was not delivered: terminal policy or Task settlement winning the admission race never falls through to cold resume within the same call, and a live registry Agent outside the activation association is an ownership conflict rather than an adoption target.
Cancellation targets the whole activation. `task_kill` or owner disposal aborts the Task-owned signal; before publication the provider rejects only after its creation transaction rolled back to quiescence, afterwards the signal cancels the published run, and settlement records `killed` only once the activation is quiescent. Human input shares this path: an adapter submits child input through `sendMessage()` under the loaded parent, so parent and human messages that joined one turn share its result and cancellation outcome, and `TaskService.start()`'s control-surface requirement applies (load `@deepseek-ai/dsh-tool-tasks` or attach a surface).
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

@@ -1,55 +0,0 @@
{
"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

@@ -1,32 +0,0 @@
/**
* 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

@@ -1,39 +0,0 @@
{
"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

@@ -10,11 +10,10 @@ The family separates the stable interface from implementations and model-facing
| Package | Role |
|---|---|
| `@deepseek-ai/dsh-subagent` | Provider registry, request/result/descriptor types, and lifecycle events. |
| `@deepseek-ai/dsh-subagent` | Provider registry, request/result/descriptor types, lifecycle events, and continuable-child orchestration. |
| `@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. |
@@ -22,7 +21,7 @@ Multiple providers may coexist under different names. This lets a deployment exp
## Service API
`SubagentService` has five main operations:
`SubagentService` has seven main operations:
| Member | Meaning |
|---|---|
@@ -30,7 +29,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. |
| `resume(name, request)` | Capability-checked raw dispatch to `provider.resume?()` with the same run lifecycle observation as `start`; the caller owns descriptor lookup, authorization, and collection. |
| `startContinuable(spec)` | Allocate a durable child id and register its initial Task-backed activation. Requires `ctx.tasks`, `ctx.agents`, session persistence, and a resumable provider. |
| `sendMessage(parent, childId, message, source)` | Steer the current activation or start a new Task that cold-resumes the durable child. Requires `ctx.tasks` and `ctx.agents`; cold resume also requires session persistence. |
`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.
@@ -63,7 +64,7 @@ The seam owns the depth vocabulary shared by implementations and consumers: the
`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. For a continuable activation, a completed result also confirms that the provider made its final state durable; a failed required checkpoint rejects as infrastructure rather than publishing unconfirmed output. `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. A continuable start publishes exactly the control-allocated `continuation.sessionId`. 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 service-allocated `continuation.sessionId`. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`.
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.
@@ -73,7 +74,7 @@ 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; `@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.
The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool. Continuable background delegation calls `ctx.subagents.startContinuable()`, whose internal manager exists only while `ctx.tasks` and `ctx.agents` are available; session persistence is resolved per continuation operation. 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 [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.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

View File

@@ -33,9 +33,19 @@
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"@deepseek-ai/dsh-tasks": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"peerDependenciesMeta": {
"@deepseek-ai/dsh-session-persistence": {
"optional": true
},
"@deepseek-ai/dsh-tasks": {
"optional": true
}
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
@@ -43,6 +53,8 @@
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.7"
}

View File

@@ -1,10 +1,7 @@
/**
* 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.
* Internal continuable-subagent manager: stable child ids, descriptor
* persistence and lookup by known child id, Task-backed activation, and
* steer-or-resume message routing behind `ctx.subagents`.
*
* Every continuable activation initial or resumed, parent- or human-started
* has exactly one Task and one result. Task settlement awaits the child
@@ -13,26 +10,21 @@
* 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
* @module @deepseek-ai/dsh-subagent
*/
import { randomUUID } from 'node:crypto'
import { Context, Service } from 'cordis'
import type { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import { foldSubagentDescriptor, snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
import type { SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
import { foldSubagentDescriptor, snapshotSubagentDescriptor } from './descriptor.ts'
import type { SubagentResult, SubagentRun, SubagentStartRequest } from './types.ts'
import type { SubagentService } from './index.ts'
import type { TaskHooks, TaskId, TaskOutcome } from '@deepseek-ai/dsh-tasks'
declare module 'cordis' {
interface Context {
subagentControl: SubagentControlService
}
}
/** Attribution for a model coordinator's follow-up to one of its children. */
export interface CoordinatorMessageSource {
readonly kind: 'coordinator'
@@ -46,7 +38,7 @@ declare module '@deepseek-ai/dsh-llm' {
}
}
/** Typed error for control-service routing, authorization, and delivery failures. */
/** Typed error for continuation routing, authorization, and delivery failures. */
export class SubagentControlError extends HarnessError {
constructor(message: string, code: string, options?: ErrorOptions) {
super(message, code, options)
@@ -68,7 +60,7 @@ export interface ContinuableStartSpec {
readonly request: Omit<SubagentStartRequest, 'signal' | 'continuation'>
}
/** Identities returned by {@link SubagentControlService.startContinuable}. */
/** Identities returned by {@link SubagentContinuationManager.startContinuable}. */
export interface ContinuableStart {
/** The durable child session id, stable across activations. */
readonly childId: SessionId
@@ -77,7 +69,7 @@ export interface ContinuableStart {
}
/**
* How {@link SubagentControlService.sendMessage} delivered a message:
* How {@link SubagentContinuationManager.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
@@ -173,14 +165,14 @@ function finalText(blocks: ContentBlock[]): string {
* boundary, while foreground one-shot delegation keeps calling
* `ctx.subagents.start()` directly.
*/
export class SubagentControlService extends Service {
static inject = ['subagents', 'tasks', 'agents']
export class SubagentContinuationManager {
/** Child session id → its current activation. Process-local, never durable. */
private activations = new Map<SessionId, ActiveActivation>()
constructor(ctx: Context) {
super(ctx, 'subagentControl')
constructor(
private readonly ctx: Context,
private readonly subagents: SubagentService,
) {
// 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) => {
@@ -189,7 +181,7 @@ export class SubagentControlService extends Service {
}
})
// TaskService deliberately keeps producer Tasks alive across a
// control-surface or producer reload, so this service's disposal must not
// follow-up-tool or producer reload, so this manager's disposal must not
// strand the activations it can no longer route to: cancel each one and
// await producer settlement (run disposal) before releasing the map. The
// effect-scoped onTaskDone listener above is already gone by then, so
@@ -198,7 +190,7 @@ export class SubagentControlService extends Service {
const active = [...this.activations.values()]
this.activations.clear()
for (const activation of active) {
activation.controller.abort('subagent control service disposed')
activation.controller.abort('subagent continuation manager disposed')
activation.terminal.resolve()
}
await Promise.allSettled(active.map((activation) => {
@@ -207,7 +199,7 @@ export class SubagentControlService extends Service {
if (activation.done === undefined) return Promise.resolve()
return activation.done
}))
}, 'subagentControl.activations()')
}, 'subagents.continuations()')
}
/**
@@ -239,7 +231,7 @@ export class SubagentControlService extends Service {
...request.toolFilter !== undefined ? { toolFilter: request.toolFilter } : {},
})
const taskId = this.startActivation(childId, spec.label, request.parent, signal =>
this.ctx.subagents.start(spec.provider, {
this.subagents.start(spec.provider, {
...request,
signal,
continuation: { sessionId: childId, descriptor },
@@ -294,7 +286,7 @@ export class SubagentControlService extends Service {
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`,
`subagent "${childId}" has a live agent outside continuation ownership; the message was not delivered`,
'OWNERSHIP_CONFLICT',
)
}
@@ -399,7 +391,7 @@ export class SubagentControlService extends Service {
'NOT_RESUMABLE',
)
}
return this.ctx.subagents.resume(descriptor.provider, {
return this.subagents.resume(descriptor.provider, {
sessionId: childId,
prompt: message,
source,
@@ -501,4 +493,4 @@ function resumeLabel(message: ContentBlock[]): string {
return text.length > 80 ? `${text.slice(0, 79)}` : text
}
export default SubagentControlService
export default SubagentContinuationManager

View File

@@ -3,7 +3,7 @@
* `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.
* turn; the continuation manager folds it back on resume.
*
* The descriptor deliberately snapshots explicit fields rather than the
* merge-extensible `AgentOptions` object: an unrelated extension value cannot

View File

@@ -13,13 +13,11 @@
* (`@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-, 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.
* Raw `start` and `resume` remain collection-agnostic provider dispatch.
* When `ctx.tasks` and `ctx.agents` are available, the same service also binds
* an internal continuation manager for durable child ids, descriptor lookup,
* Task-backed activations, and steer-or-resume delivery. Persistence remains
* optional and is required only when a continuation operation is called.
*
* Same-process providers are trusted typed collaborators. Requests, provider
* descriptors, results, and lifecycle payloads are borrowed immutable values;
@@ -35,7 +33,7 @@ import { scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import { assertObjectJsonSchema } from '@deepseek-ai/dsh-tools'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type {
@@ -47,6 +45,12 @@ import type {
SubagentStartRequest,
} from './types.ts'
import { SubagentRunId } from './types.ts'
import SubagentContinuationManager from './continuation.ts'
import type {
ContinuableStart,
ContinuableStartSpec,
SendMessageResult,
} from './continuation.ts'
export * from './out-of-process.ts'
export { SubagentRunId } from './types.ts'
@@ -67,6 +71,17 @@ export {
SUBAGENT_DESCRIPTOR_VERSION,
} from './descriptor.ts'
export type { SubagentDescriptorData, SubagentDescriptorInput } from './descriptor.ts'
export {
runOutcome,
settleRun,
SubagentControlError,
} from './continuation.ts'
export type {
ContinuableStart,
ContinuableStartSpec,
CoordinatorMessageSource,
SendMessageResult,
} from './continuation.ts'
declare module '@deepseek-ai/dsh-agent' {
interface AgentOptions {
@@ -187,12 +202,49 @@ export class SubagentError extends HarnessError {
}
}
/** Named provider registry and capability-checked start surface. */
/** Named provider registry with raw and Task-backed continuation operations. */
export class SubagentService extends Service {
private providers = new Map<string, SubagentProvider>()
private continuations: SubagentContinuationManager | undefined
constructor(ctx: Context) {
super(ctx, 'subagents')
ctx.inject(['tasks', 'agents'], (childCtx: Context) => {
const manager = new SubagentContinuationManager(childCtx, this)
this.continuations = manager
childCtx.effect(() => () => {
/* v8 ignore else -- one injected binding owns the slot until its fiber disposes. */
if (this.continuations === manager) this.continuations = undefined
}, 'subagents.continuationBinding()')
})
}
/**
* Start one durable continuable child through a Task-backed initial
* activation.
* @param spec - provider, Task label, and delegation request.
* @returns the stable child id and initial activation Task id.
*/
startContinuable(spec: ContinuableStartSpec): ContinuableStart {
return this.requireContinuations().startContinuable(spec)
}
/**
* Deliver a message to a continuable child by steering its live activation
* or cold-resuming a fresh Task-backed activation.
* @param parent - live direct parent authorizing the operation.
* @param childId - durable child session id.
* @param message - user-role content to deliver.
* @param source - durable caller attribution.
* @returns the existing steered Task or newly started Task.
*/
sendMessage(
parent: Agent,
childId: SessionId,
message: ContentBlock[],
source: MessageSource,
): Promise<SendMessageResult> {
return this.requireContinuations().sendMessage(parent, childId, message, source)
}
/**
@@ -264,7 +316,7 @@ export class SubagentService extends Service {
/**
* 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
* {@link start}. The internal continuation manager 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.
@@ -291,6 +343,17 @@ export class SubagentService extends Service {
return provider
}
/** Resolve the optional Task-backed continuation runtime or fail loud. */
private requireContinuations(): SubagentContinuationManager {
if (this.continuations === undefined) {
throw new SubagentError(
'continuable subagents require the tasks and agents services',
'CONTINUATION_UNAVAILABLE',
)
}
return this.continuations
}
/** 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())

View File

@@ -94,7 +94,7 @@ export interface SubagentStartRequest {
*/
readonly persona?: string
/**
* Continuable-child intent, resolved by the control service before start.
* Continuable-child intent, resolved by `ctx.subagents` 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
@@ -106,10 +106,10 @@ export interface SubagentStartRequest {
/**
* The resolved continuable-child identity and durable composition record a
* control-service caller attaches to a start request.
* continuation caller attaches to a start request.
*/
export interface SubagentContinuation {
/** Control-allocated stable child session id, published verbatim. */
/** Service-allocated stable child session id, published verbatim. */
readonly sessionId: SessionId
/** Snapshotted descriptor persisted in the child log for cold resume. */
readonly descriptor: SubagentDescriptorData
@@ -117,7 +117,7 @@ export interface SubagentContinuation {
/**
* What a caller asks for when resuming a persisted continuable child. The
* control service loads the child log, folds and authorizes its descriptor,
* continuation manager 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

View File

@@ -9,7 +9,6 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test
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'
@@ -18,7 +17,12 @@ import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { createUserMessage, HarnessError, LlmAdapter } from '@deepseek-ai/dsh-llm'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import SubagentControlService, { runOutcome, settleRun, SubagentControlError } from '../src/index.ts'
import SubagentService, {
runOutcome,
settleRun,
SubagentControlError,
SUBAGENT_DESCRIPTOR_VERSION,
} from '../src/index.ts'
type Script = ConstructorParameters<typeof MockAdapter>[0]
@@ -53,12 +57,12 @@ afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
/** Boot the full continuable stack: loop, persistence, providers, tasks, control. */
/** Boot the full continuable stack: loop, persistence, providers, tasks, and subagents. */
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-'))
const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-continuation-'))
roots.push(root)
await ctx.plugin(JsonlSessionPersistence, { root })
}
@@ -68,7 +72,6 @@ async function setupWith(adapter: LlmAdapter, options: { persistence?: boolean }
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 }
@@ -93,12 +96,12 @@ async function waitTerminal(ctx: Context, taskId: TaskId, parent: Agent) {
}
async function waitPublishedRun(ctx: Context, childId: SessionId): Promise<void> {
const control = ctx.subagentControl as unknown as {
activations: Map<SessionId, { run: unknown }>
const continuations = ctx.subagents as unknown as {
continuations: { activations: Map<SessionId, { run: unknown }> }
}
await new Promise<void>((resolve) => {
const timer = setInterval(() => {
if (control.activations.get(childId)?.run !== undefined) {
if (continuations.continuations.activations.get(childId)?.run !== undefined) {
clearInterval(timer)
resolve()
}
@@ -121,13 +124,13 @@ function sendMessage(
childId: SessionId,
content: ReturnType<typeof message>,
) {
return ctx.subagentControl.sendMessage(parent, childId, content, { kind: 'user' })
return ctx.subagents.sendMessage(parent, childId, content, { kind: 'user' })
}
describe('SubagentControlService.startContinuable', () => {
describe('SubagentService.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))
const started = ctx.subagents.startContinuable(startSpec(parent))
expect(started.childId).toMatch(/[0-9a-f-]{36}/)
expect(started.taskId).toBe('subagent-1')
@@ -138,13 +141,13 @@ describe('SubagentControlService.startContinuable', () => {
expect(ctx.agents.get(started.childId)).toBeUndefined()
})
it('publishes the control-allocated child id and appends the turn-enclosed descriptor', async () => {
it('publishes the service-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))
const started = ctx.subagents.startContinuable(startSpec(parent))
await waitTerminal(ctx, started.taskId, parent)
const descriptorIndex = seen.findIndex(event => event.type === 'subagent/descriptor')
@@ -162,7 +165,7 @@ describe('SubagentControlService.startContinuable', () => {
// Model-hidden: the descriptor never carries surface metadata.
expect('surfaceOp' in descriptor).toBe(false)
// The durable log kept the exact control-allocated id.
// The durable log kept the exact service-allocated id.
const loaded = await ctx.sessionPersistence.load(started.childId)
expect(loaded.meta.id).toBe(started.childId)
expect(loaded.meta.parentSession).toBe(SessionId('parent'))
@@ -171,7 +174,7 @@ describe('SubagentControlService.startContinuable', () => {
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)))
expect(() => ctx.subagents.startContinuable(startSpec(parent)))
.toThrow(/require session persistence/)
expect(ctx.tasks.list(parent)).toEqual([])
})
@@ -181,19 +184,21 @@ describe('SubagentControlService.startContinuable', () => {
const realStart = ctx.tasks.start.bind(ctx.tasks)
ctx.tasks.start = () => { throw new Error('task preflight failed') }
try {
expect(() => ctx.subagentControl.startContinuable(startSpec(parent)))
expect(() => ctx.subagents.startContinuable(startSpec(parent)))
.toThrow('task preflight failed')
} finally {
ctx.tasks.start = realStart
}
const control = ctx.subagentControl as unknown as { activations: Map<SessionId, unknown> }
expect(control.activations.size).toBe(0)
const continuations = ctx.subagents as unknown as {
continuations: { activations: Map<SessionId, unknown> }
}
expect(continuations.continuations.activations.size).toBe(0)
})
it('rejects a non-JSON descriptor input synchronously with no Task', async () => {
const { ctx, parent } = await setup([textResponse('unused')])
const spec = startSpec(parent)
expect(() => ctx.subagentControl.startContinuable({
expect(() => ctx.subagents.startContinuable({
...spec,
// A symbol survives the static ToolRestriction type only through this
// cast — exactly the durable-boundary input the snapshot rejects.
@@ -214,7 +219,7 @@ describe('SubagentControlService.startContinuable', () => {
maxDepth: 0,
},
}
const started = ctx.subagentControl.startContinuable(spec)
const started = ctx.subagents.startContinuable(spec)
const snapshot = await waitTerminal(ctx, started.taskId, parent)
expect(snapshot.status).toBe('failed')
expect(snapshot.detail).toContain('maxDepth')
@@ -228,7 +233,7 @@ describe('SubagentControlService.startContinuable', () => {
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))
const started = ctx.subagents.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()
@@ -250,7 +255,7 @@ describe('SubagentControlService.startContinuable', () => {
checkpointStarted.resolve(undefined)
await releaseCheckpoint.promise
})
const started = ctx.subagentControl.startContinuable(startSpec(parent))
const started = ctx.subagents.startContinuable(startSpec(parent))
await checkpointStarted.promise
expect(ctx.tasks.kill(started.taskId, parent, 'no longer needed')).toBe('requested')
@@ -262,7 +267,7 @@ describe('SubagentControlService.startContinuable', () => {
})
})
describe('SubagentControlService.sendMessage', () => {
describe('SubagentService.sendMessage', () => {
it('omits undeclared model selectors and rejects a provider without live delivery', async () => {
const { ctx } = await setup([])
const result = Promise.withResolvers<{
@@ -286,7 +291,7 @@ describe('SubagentControlService.sendMessage', () => {
resume: async () => { throw new Error('not used') },
})
const parent = ctx.agentLoop.create(SessionId('bare-parent'), {})
const started = ctx.subagentControl.startContinuable(startSpec(parent, 'no-steer'))
const started = ctx.subagents.startContinuable(startSpec(parent, 'no-steer'))
await waitPublishedRun(ctx, started.childId)
expect(descriptor).toEqual({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'no-steer' })
@@ -336,7 +341,7 @@ describe('SubagentControlService.sendMessage', () => {
},
resume: async () => { throw new Error('not used') },
})
const started = ctx.subagentControl.startContinuable(startSpec(parent, 'mismatched-local'))
const started = ctx.subagents.startContinuable(startSpec(parent, 'mismatched-local'))
await waitPublishedRun(ctx, started.childId)
await expect(sendMessage(ctx, parent, started.childId, message('join')))
@@ -357,7 +362,7 @@ describe('SubagentControlService.sendMessage', () => {
])
const { ctx, parent } = await setupWith(adapter)
const started = ctx.subagentControl.startContinuable(startSpec(parent))
const started = ctx.subagents.startContinuable(startSpec(parent))
// Wait until the first immutable request has crossed the adapter boundary.
await new Promise<void>((resolve) => {
const timer = setInterval(() => {
@@ -368,7 +373,7 @@ describe('SubagentControlService.sendMessage', () => {
}, 5)
})
const delivery = ctx.subagentControl.sendMessage(
const delivery = ctx.subagents.sendMessage(
parent,
started.childId,
message('also consider Y'),
@@ -406,7 +411,7 @@ describe('SubagentControlService.sendMessage', () => {
})
const base = startSpec(parent)
const started = ctx.subagentControl.startContinuable({
const started = ctx.subagents.startContinuable({
...base,
request: {
...base.request,
@@ -419,7 +424,7 @@ describe('SubagentControlService.sendMessage', () => {
})
await startedTool.promise
const delivery = ctx.subagentControl.sendMessage(
const delivery = ctx.subagents.sendMessage(
parent,
started.childId,
message('follow-up that terminal policy rejects'),
@@ -437,11 +442,11 @@ describe('SubagentControlService.sendMessage', () => {
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))
const started = ctx.subagents.startContinuable(startSpec(parent))
await waitTerminal(ctx, started.taskId, parent)
expect(ctx.agents.get(started.childId)).toBeUndefined()
const followUp = await ctx.subagentControl.sendMessage(
const followUp = await ctx.subagents.sendMessage(
parent,
started.childId,
message('and then?'),
@@ -476,7 +481,7 @@ describe('SubagentControlService.sendMessage', () => {
toolFilter: { deny: [] as string[] },
},
}
const started = ctx.subagentControl.startContinuable(spec)
const started = ctx.subagents.startContinuable(spec)
await waitTerminal(ctx, started.taskId, parent)
const loaded = await ctx.sessionPersistence.load(started.childId)
@@ -503,7 +508,7 @@ describe('SubagentControlService.sendMessage', () => {
parent.followup(createUserMessage({ content: message('parent question one'), source: { kind: 'user' } }))
await parent.whenIdle()
const started = ctx.subagentControl.startContinuable(startSpec(parent, 'fork'))
const started = ctx.subagents.startContinuable(startSpec(parent, 'fork'))
await waitTerminal(ctx, started.taskId, parent)
const firstLoad = await ctx.sessionPersistence.load(started.childId)
const seedLength = firstLoad.meta.seedLength ?? 0
@@ -527,7 +532,7 @@ describe('SubagentControlService.sendMessage', () => {
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))
const started = ctx.subagents.startContinuable(startSpec(parent))
await waitTerminal(ctx, started.taskId, parent)
const followUp = await sendMessage(ctx, parent, started.childId, message('go on'))
@@ -546,7 +551,7 @@ describe('SubagentControlService.sendMessage', () => {
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))
const started = ctx.subagents.startContinuable(startSpec(otherParent))
await waitTerminal(ctx, started.taskId, otherParent)
const attempt = await sendMessage(ctx, parent, started.childId, message('mine now'))
@@ -590,9 +595,9 @@ describe('SubagentControlService.sendMessage', () => {
])
})
it('rejects delivery to a live agent outside control-service ownership', async () => {
it('rejects delivery to a live agent outside continuation ownership', async () => {
const { ctx, parent } = await setup([textResponse('unused')])
// A live child created around the control service.
// A live child created outside continuation orchestration.
const handle = await ctx.agents.create({
sessionId: SessionId('rogue-child'),
meta: { parentSession: parent.id },
@@ -601,7 +606,7 @@ describe('SubagentControlService.sendMessage', () => {
await expect(sendMessage(ctx, parent, SessionId('rogue-child'), message('hello')))
.rejects.toThrow(SubagentControlError)
await expect(sendMessage(ctx, parent, SessionId('rogue-child'), message('hello')))
.rejects.toThrow(/outside control-service ownership.*not delivered/)
.rejects.toThrow(/outside continuation ownership.*not delivered/)
await handle.dispose()
})
@@ -625,7 +630,7 @@ describe('SubagentControlService.sendMessage', () => {
}
}
const started = ctx.subagentControl.startContinuable(startSpec(parent))
const started = ctx.subagents.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) => {
@@ -654,7 +659,7 @@ describe('SubagentControlService.sendMessage', () => {
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))
const started = ctx.subagents.startContinuable(startSpec(parent))
await waitTerminal(ctx, started.taskId, parent)
const followUp = await sendMessage(ctx, parent, started.childId, message('more'))
const other = ctx.agentLoop.create(SessionId('intruder'), { provider: 'mock', model: 'mock' })
@@ -663,7 +668,7 @@ describe('SubagentControlService.sendMessage', () => {
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))
const started = ctx.subagents.startContinuable(startSpec(parent))
await waitTerminal(ctx, started.taskId, parent)
// Make the persistence load hang until the kill lands.
@@ -686,7 +691,7 @@ describe('SubagentControlService.sendMessage', () => {
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))
const started = ctx.subagents.startContinuable(startSpec(parent))
await waitTerminal(ctx, started.taskId, parent)
const realLoad = ctx.sessionPersistence.load.bind(ctx.sessionPersistence)
@@ -715,15 +720,15 @@ describe('service disposal with live activations', () => {
it('cancels and settles a starting activation on service disposal instead of stranding it', async () => {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-control-hmr-'))
const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-continuation-hmr-'))
roots.push(root)
await ctx.plugin(JsonlSessionPersistence, { root })
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentService)
const subagentsFiber = await ctx.plugin(SubagentService)
await ctx.plugin(LocalTaskService)
await ctx.plugin(ToolTasks, {})
// A provider that stays pending until its signal aborts, so the activation
// is observably mid-start when the control service is disposed.
// is observably mid-start when the subagent service is disposed.
let sawAbort = false
ctx.subagents.registerProvider({
name: 'pending',
@@ -737,19 +742,17 @@ describe('service disposal with live activations', () => {
}),
resume: () => Promise.reject(new Error('unreachable')),
})
const controlFiber = await ctx.plugin(SubagentControlService)
ctx.llm.registerAdapter(['mock'], new MockAdapter([]))
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
const control = ctx.get('subagentControl')!
const started = control.startContinuable({
const started = ctx.subagents.startContinuable({
provider: 'pending',
label: 'will be interrupted',
request: { prompt: message('go'), parent },
})
// LocalTaskService keeps the producer Task; the disposing control service must
// LocalTaskService keeps the producer Task; the disposing subagent service must
// cancel its activation and await settlement rather than strand it.
await controlFiber.dispose()
await subagentsFiber.dispose()
expect(sawAbort).toBe(true)
const snapshot = await waitTerminal(ctx, started.taskId, parent)
expect(snapshot.status).toBe('killed')

View File

@@ -125,6 +125,17 @@ describe('SubagentService', () => {
})).rejects.toMatchObject({ code: 'UNSUPPORTED_CAPABILITY' })
})
it('rejects Task-backed continuation operations when their runtime services are absent', async () => {
const { subagents } = await service()
expect(() => {
subagents.startContinuable({
provider: 'unused',
label: 'work',
request: baseRequest(),
})
}).toThrow(expect.objectContaining({ code: 'CONTINUATION_UNAVAILABLE' }))
})
it.each([
['outputSchema', { outputSchema: { type: 'object', properties: {} } }],
['depthLimit', { maxDepth: 1 }],

View File

@@ -26,6 +26,12 @@
{
"path": "../../core/scope"
},
{
"path": "../../session-persistence/session-persistence"
},
{
"path": "../../tasks/tasks"
},
{
"path": "../../support/invariants"
}

View File

@@ -1,8 +1,8 @@
# @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 optional, globally named `send_message` tool: a thin adapter over `ctx.subagents.sendMessage()`. Provider-bound `@deepseek-ai/dsh-tool-subagent` instances register distinct delegation tools per transport; this separately loaded package registers one shared follow-up tool, so multiple delegation tools never register duplicate global controls. Its presence does not determine whether a delegation tool starts continuable work.
The tool performs no lifecycle routing. It attributes every follow-up as `{ kind: 'coordinator', senderSessionId: parent.id }`; the control service preserves that source while deciding between live delivery to the running activation's existing Task and a fresh Task that cold-resumes the durable child. The tool renders which route was taken and the relevant Task id. A control-service throw becomes an errored tool result stating the message was not delivered.
The tool performs no lifecycle routing. It attributes every follow-up as `{ kind: 'coordinator', senderSessionId: parent.id }`; the subagent service preserves that source while deciding between live delivery to the running activation's existing Task and a fresh Task that cold-resumes the durable child. The tool renders which route was taken and the relevant Task id. A delivery failure becomes an errored tool result stating the message was not delivered.
## Model Experience

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-tool-subagent-control",
"description": "Globally named send_message tool over the continuable-subagent control service",
"description": "Globally named send_message tool over ctx.subagents continuations",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -30,7 +30,7 @@
"@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-subagent": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
@@ -44,7 +44,6 @@
"@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:^",

View File

@@ -1,7 +1,7 @@
/**
* 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
* `ctx.subagents.sendMessage()`. It performs no lifecycle routing of its
* own — steer-or-resume orchestration belongs to the subagent 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
@@ -11,14 +11,14 @@ 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'
import type {} from '@deepseek-ai/dsh-subagent'
export const name = 'tool-subagent-control'
export const inject = ['tools', 'subagentControl']
export const inject = ['tools', 'subagents']
/**
* Register the `send_message` tool.
* @param ctx - context carrying the tool registry and the control service.
* @param ctx - context carrying the tool registry and subagent service.
*/
export function apply(ctx: Context): void {
ctx.tools.register(defineTool({
@@ -67,7 +67,7 @@ export function apply(ctx: Context): void {
throw new Error('send_message requires a calling agent (exec.agent was undefined)')
}
const message: ContentBlock[] = [{ type: 'text', text: args.message }]
const result = ctx.subagentControl.sendMessage(
const result = ctx.subagents.sendMessage(
parent,
SessionId(args.subagent_id),
message,

View File

@@ -16,7 +16,7 @@ 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.
* and activation relations are owned by the subagent service it calls.
*/
const install: InvariantInstaller = () => {}

View File

@@ -9,7 +9,6 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test
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'
@@ -34,7 +33,6 @@ async function setup(script: ConstructorParameters<typeof MockAdapter>[0]) {
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' })
@@ -68,7 +66,7 @@ describe('dsh-tool-subagent-control', () => {
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({
const started = ctx.subagents.startContinuable({
provider: 'spawn',
label: 'work',
request: { prompt: [{ type: 'text', text: 'child task' }], parent },
@@ -98,10 +96,10 @@ describe('dsh-tool-subagent-control', () => {
const { ctx, parent } = await setup([])
let steered: string | undefined
let source: unknown
// Reach past the tool into the control service to fake a running route
// Reach past the tool into the subagent 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 = async (agent, _childId, message, messageSource) => {
ctx.subagents.sendMessage = async (agent, _childId, message, messageSource) => {
steered = (message[0] as { text: string }).text
source = messageSource
return { route: 'steered', taskId: ctx.tasks.list(agent)[0]?.id ?? ('subagent-9' as never) }
@@ -116,7 +114,7 @@ describe('dsh-tool-subagent-control', () => {
expect(text(result)).toBe('message delivered to running task subagent-9')
})
it('reports a control-service failure as an errored, not-delivered result', async () => {
it('reports a delivery 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',
@@ -148,7 +146,6 @@ describe('dsh-tool-subagent-control', () => {
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()
@@ -158,7 +155,7 @@ describe('dsh-tool-subagent-control', () => {
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(tool.inject).toEqual(['tools', 'subagents'])
expect(typeof tool.apply).toBe('function')
})
})

View File

@@ -24,7 +24,7 @@
"path": "../../core/tools"
},
{
"path": "../subagent-control"
"path": "../subagent"
},
{
"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: 7d32da3c974361eb5e58cdb2ee5be756383ad3d1
README.zh.md: eadc168fd07701b3e3d9600b3fe69bd8b22e235a
README.md: 9d60363602a9825730984700a7fe987d911e1cac
README.zh.md: 5964c38bd847c1c14cac9decdd913ca65c39e8f3

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 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).
With `run_in_background: true`, `backgroundMode` selects the route. `one-shot` registers a plain parent-owned Task and returns canonical `{ kind: 'background', taskId }`, rendered as `started background subagent task <id>`, even when the provider supports resume. `continuable` requires `provider.resume`, calls `ctx.subagents.startContinuable()`, and returns `{ kind: 'background', taskId, subagentId }`, rendered as `started subagent <childId> as task <taskId>`. The optional global `send_message` tool is not required to start continuable work. Either route uses a Task-owned signal, settles only after startup rollback or run disposal, and maps completed final text, abort `killed`, and other failures `failed`. 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), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), and the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.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).
@@ -21,6 +21,7 @@ With `run_in_background: true`, the route follows the provider's continuation ca
| `provider` (required) | Provider name (`spawn`, `fork`, `acp`, ...). |
| `toolName` | Model-facing name, default `subagent`; distinct for every loaded instance. |
| `enableRunInBackground` | Exposes background mode, default `true`; disabling also rejects forced background calls. |
| `backgroundMode` | Background lifecycle policy, default `one-shot`. `continuable` requires provider resume support and returns a durable child id; it does not require the follow-up tool. |
| `agentOptions` | Provider-specific child `provider`, `model`, and positive `maxTokens`; the in-process provider treats explicit values as overrides of inherited parent options. |
| `persona` | Per-child persona; requires provider `persona` capability. |
| `toolFilter` | Per-child global-tool restriction; requires `toolFilter` capability. |
@@ -64,7 +65,7 @@ Append-only; newly visible content follows the reusable request prefix and does
#### What the model sees
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.
Start returns exactly `started subagent <childId> as task <taskId>` in configured continuable mode, or `started background subagent task <id>` in configured one-shot mode. The generic task surface provides later status, final output, cancellation responses, and notices; an independently loaded `send_message` tool 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, 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)。
设置 `run_in_background: true` 后,`backgroundMode` 选择路由。`one-shot` 会注册普通的父级所有 Task并返回规范值 `{ kind: 'background', taskId }`;即使提供方支持恢复,也会渲染为 `started background subagent task <id>``continuable` 要求 `provider.resume`,调用 `ctx.subagents.startContinuable()`并返回 `{ kind: 'background', taskId, subagentId }`,渲染为 `started subagent <childId> as task <taskId>`。启动可继续工作不要求加载可选的全局 `send_message` 工具。两条路由都使用 Task 所有的信号,只在启动回滚或 run 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)和[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)
`toolFilter` 会改变子 agent 的全局工具层,但不是从父级派生的权限上限。见 [agent 作用域的安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。
@@ -21,6 +21,7 @@
| `provider`(必填) | 提供方名称(`spawn``fork``acp` 等)。 |
| `toolName` | 面向模型的名称,默认 `subagent`;每个已加载实例必须不同。 |
| `enableRunInBackground` | 公开后台模式,默认 `true`;禁用时也会拒绝强制后台调用。 |
| `backgroundMode` | 后台生命周期策略,默认 `one-shot``continuable` 要求提供方支持恢复并返回持久化子 agent ID它不要求加载后续消息工具。 |
| `agentOptions` | 传给具体提供方的子 agent `provider``model` 和正整数 `maxTokens`;进程内提供方会用显式值覆盖继承的父级选项。 |
| `persona` | 每个子 agent 独立的 persona要求提供方具备 `persona` 能力。 |
| `toolFilter` | 每个子 agent 独立的全局工具限制;要求提供方具备 `toolFilter` 能力。 |
@@ -64,7 +65,7 @@
#### 模型看到的内容
对于可恢复提供方,启动时精确返回 `started subagent <childId> as task <taskId>`对于一次性提供方,则返回 `started background subagent task <id>`。通用任务接口提供后续状态、最终输出、取消响应和通知;`send_message`(来自 `dsh-tool-subagent-control`会把后续消息交付给可继续子 agent。
在已配置的 continuable 模式下,启动时精确返回 `started subagent <childId> as task <taskId>`在已配置的 one-shot 模式下,则返回 `started background subagent task <id>`。通用任务接口提供后续状态、最终输出、取消响应和通知;独立加载的 `send_message` 工具会把后续消息交付给可继续子 agent。
#### Token 影响

View File

@@ -31,7 +31,6 @@
"@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"
@@ -48,7 +47,6 @@
"@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:^",

View File

@@ -1,11 +1,10 @@
/**
* 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. 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.
* wording. Foreground calls always dispose the run after collection.
* Background policy is selected by this plugin's configuration: one-shot
* calls own a plain Task, while continuable calls use
* `ctx.subagents.startContinuable()`.
* @module @deepseek-ai/dsh-tool-subagent
*/
@@ -15,9 +14,8 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
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 { assertSubagentMaxDepth, settleRun } 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'
@@ -37,6 +35,12 @@ export interface Config {
* parameter and reject forced background calls.
*/
enableRunInBackground?: boolean
/**
* Background execution policy (default `one-shot`). `continuable` requires
* a provider with persisted resume support and returns both child and Task
* ids; follow-up adapters remain independently optional.
*/
backgroundMode?: 'one-shot' | 'continuable'
/**
* Agent options applied to every child; omitted fields use child-loop defaults.
*/
@@ -73,6 +77,7 @@ export const Config: z<Config> = z.object({
provider: z.string().required(),
toolName: z.string().default('subagent'),
enableRunInBackground: z.boolean().default(true),
backgroundMode: z.union(['one-shot', 'continuable'] as const).default('one-shot'),
// Prevent Schemastery from materializing omitted agentOptions as `{}`.
agentOptions: z.object({
provider: z.string(),
@@ -191,17 +196,19 @@ 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
const continuable = (config.backgroundMode ?? 'one-shot') === 'continuable'
if (continuable && provider.resume === undefined) {
throw new Error(
`tool-subagent: provider "${provider.name}" does not support \`backgroundMode: continuable\``,
)
}
disposeTool = ctx.tools.register(defineTool({
name: config.toolName ?? 'subagent',
description: wording.description + (backgroundEnabled
? 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`.'
+ ' stable subagent id and current task id; collect the result with `task_output` and stop it with'
+ ' `task_kill`.'
: ' Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.'
: ''),
parameters: {
@@ -220,7 +227,7 @@ export function apply(ctx: Context, config: Config): void {
type: 'boolean' as const,
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.'
+ 'collect with task_output or stop with task_kill.'
: 'Run as a background task and return its id; collect with task_output or stop with task_kill.',
},
} : {},
@@ -281,23 +288,7 @@ export function apply(ctx: Context, config: Config): void {
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 schema above tells the model to follow up with
// `send_message`; starting a durable child the model cannot
// continue would make that advertisement false. Sibling load order
// is undetermined at mount, so the check lives at the operation,
// and it resolves in the CALLER's scope so a restriction that
// removes send_message from this agent also blocks the start.
if (ctx.tools.get('send_message', parent) === undefined) {
throw new Error('continuable background subagents unavailable: load @deepseek-ai/dsh-tool-subagent-control (the advertised send_message tool is not registered)')
}
// 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({
const started = ctx.subagents.startContinuable({
provider: config.provider,
label: args.description,
request,

View File

@@ -15,9 +15,7 @@ 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 ToolSubagentControl from '@deepseek-ai/dsh-tool-subagent-control'
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'
@@ -70,6 +68,21 @@ function text(result: { content: { type: string; text?: string }[] }): string {
}
describe('dsh-tool-subagent', () => {
it('rejects continuable background policy when the configured provider cannot resume', async () => {
let failure: unknown
try {
await setup({
provider: 'mock',
backgroundMode: 'continuable',
})
} catch (error: unknown) {
failure = error
}
expect(String(failure)).toContain(
'provider "mock" does not support `backgroundMode: continuable`',
)
})
it('registers a `subagent` tool that delegates to the configured provider and returns its output', async () => {
const ctx = await setup({ provider: 'mock' }, { reply: 'child says hi' })
const result = await callSubagent(ctx, { description: 'do a thing', prompt: 'go research X' })
@@ -655,6 +668,47 @@ describe('dsh-tool-subagent background mode', () => {
return ctx
}
it('keeps a resumable provider one-shot when backgroundMode selects one-shot', async () => {
const ctx = await backgroundSetup({ provider: 'mock' })
const parent = ownerAgent(ctx, 'sess-parent')
let resumeCalls = 0
ctx.subagents.registerProvider({
name: 'resumable',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: async request => ({
id: SessionId('one-shot-child'),
localAgent: undefined,
result: Promise.resolve({
output: [{ type: 'text', text: 'one-shot answer' }],
stopReason: request.signal.aborted ? 'aborted' : 'completed',
}),
dispose: () => Promise.resolve(),
}),
resume: async () => {
resumeCalls += 1
throw new Error('one-shot policy must not resume')
},
})
tool.apply(ctx, {
provider: 'resumable',
toolName: 'subagent_resumable',
backgroundMode: 'one-shot',
maxDepth: 'provider-managed',
})
const started = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('resumable-one-shot'),
name: 'subagent_resumable',
arguments: { description: 'work', prompt: 'go', run_in_background: true },
agent: parent,
})
expect(text(started)).toBe('started background subagent task subagent-1')
expect(resumeCalls).toBe(0)
})
it('returns a task id immediately and the answer is collected through task_output', async () => {
const ctx = await backgroundSetup({ provider: 'mock', agentOptions: { model: 'child-model' } }, { reply: 'background answer' })
const parent = ownerAgent(ctx, 'sess-parent')
@@ -825,8 +879,8 @@ describe('dsh-tool-subagent continuable background mode', () => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
/** Boot the real continuable stack: loop, persistence, spawn, tasks, control. */
async function continuableSetup(options: { controlTool?: boolean } = {}) {
/** Boot the real continuable stack without any model-facing follow-up adapter. */
async function continuableSetup() {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
const root = mkdtempSync(path.join(tmpdir(), 'dsh-tool-subagent-continuable-'))
@@ -837,9 +891,7 @@ describe('dsh-tool-subagent continuable background mode', () => {
await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
await ctx.plugin(LocalTaskService)
await ctx.plugin(ToolTasks, {})
await ctx.plugin(SubagentControlService)
if (options.controlTool !== false) await ctx.plugin(ToolSubagentControl)
await ctx.plugin(tool, { provider: 'spawn' })
await ctx.plugin(tool, { provider: 'spawn', backgroundMode: 'continuable' })
ctx.llm.registerAdapter(['mock'], new MockAdapter([
textResponse('continuable answer'),
]))
@@ -847,10 +899,10 @@ describe('dsh-tool-subagent continuable background mode', () => {
return { ctx, parent }
}
it('a resumable provider advertises send_message and returns both ids', async () => {
it('starts a continuable child and returns both ids without send_message', async () => {
const { ctx, parent } = await continuableSetup()
const schema = ctx.tools.schemas().find(s => s.name === 'subagent')!
expect(schema.description).toContain('send_message')
expect(schema.description).not.toContain('send_message')
const started = await callSubagent(
ctx,
@@ -869,56 +921,6 @@ describe('dsh-tool-subagent continuable background mode', () => {
expect(loaded.events.some(event => event.type === 'subagent/descriptor')).toBe(true)
})
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')
})
it('fails loud when the advertised send_message tool is not registered', async () => {
// The schema tells the model to follow up with send_message; starting a
// durable child the model cannot continue would make that false.
const { ctx, parent } = await continuableSetup({ controlTool: false })
const result = await callSubagent(
ctx,
{ description: 'd', prompt: 'p', run_in_background: true },
{ agent: parent },
)
expect(result.isError).toBe(true)
expect(text(result)).toContain('load @deepseek-ai/dsh-tool-subagent-control')
// Nothing was started: no Task exists for the parent.
expect(ctx.tasks.list(parent)).toEqual([])
})
it('resolves send_message availability in the CALLER scope, not the global registry', async () => {
// A scoped restriction that keeps this delegation tool but removes
// send_message means this agent cannot execute the promised follow-up;
// the availability check must see the caller's surface.
const { ctx, parent } = await continuableSetup()
parent.ctx.tools.restrict({ deny: ['send_message'] })
const result = await callSubagent(
ctx,
{ description: 'd', prompt: 'p', run_in_background: true },
{ agent: parent },
)
expect(result.isError).toBe(true)
expect(text(result)).toContain('load @deepseek-ai/dsh-tool-subagent-control')
expect(ctx.tasks.list(parent)).toEqual([])
})
})
describe('background preflight failure (no orphaned child, by construction)', () => {

View File

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