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:
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -6,14 +6,16 @@ subagent(子 agent)seam 允许 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 的子进程中运行并通过 ACP(Agent Client Protocol)驱动的子 agent | (注册到 `ctx.subagents`) |
|
||||
| `subagent-spawn/` | 进程内后端:支持冷恢复的全新子 agent | (注册到 `ctx.subagents`) |
|
||||
| `subagent-fork/` | 进程内后端:以父 agent 已完成轮次的前缀作为初始内容、支持冷恢复的子 agent | (注册到 `ctx.subagents`) |
|
||||
| `subagent-acp/` | 进程外后端:在 spawn 的子进程中运行并通过 ACP(Agent 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)。
|
||||
|
||||
37
packages/subagent/subagent-control/README.md
Normal file
37
packages/subagent/subagent-control/README.md
Normal 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.
|
||||
55
packages/subagent/subagent-control/package.json
Normal file
55
packages/subagent/subagent-control/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
442
packages/subagent/subagent-control/src/index.ts
Normal file
442
packages/subagent/subagent-control/src/index.ts
Normal 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
|
||||
32
packages/subagent/subagent-control/src/invariant.ts
Normal file
32
packages/subagent/subagent-control/src/invariant.ts
Normal 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 */
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
39
packages/subagent/subagent-control/tsconfig.json
Normal file
39
packages/subagent/subagent-control/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 词汇的提供方必须采用不同的运行时。
|
||||
|
||||
@@ -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 ?? []
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
116
packages/subagent/subagent/src/descriptor.ts
Normal file
116
packages/subagent/subagent/src/descriptor.ts
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
40
packages/subagent/tool-subagent-control/README.md
Normal file
40
packages/subagent/tool-subagent-control/README.md
Normal 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.
|
||||
55
packages/subagent/tool-subagent-control/package.json
Normal file
55
packages/subagent/tool-subagent-control/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
74
packages/subagent/tool-subagent-control/src/index.ts
Normal file
74
packages/subagent/tool-subagent-control/src/index.ts
Normal 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)
|
||||
},
|
||||
}))
|
||||
}
|
||||
30
packages/subagent/tool-subagent-control/src/invariant.ts
Normal file
30
packages/subagent/tool-subagent-control/src/invariant.ts
Normal 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 */
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
33
packages/subagent/tool-subagent-control/tsconfig.json
Normal file
33
packages/subagent/tool-subagent-control/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 Note(agent 决策记录)](../../../.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` 工具接收后续消息。一次性提供方 ACP(Agent Client Protocol)保留普通的父级所有任务,省略 `subagentId`,并渲染为 `started background subagent task <id>`。两条路径中,任务拥有的信号都会覆盖待处理的启动阶段和启动调用返回后的子 agent;`task_kill` 和所有者 dispose 会中止它,结算会等待启动回滚或子 agent dispose,然后把完成的最终文本映射为完成、中止映射为 `killed`、其他失败映射为 `failed`。任务不提供增量读取;通用任务工具负责后续状态、收集、取消和通知。见[后台 subagent Agent Note(agent 决策记录)](../../../.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 影响
|
||||
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -29,6 +29,9 @@
|
||||
{
|
||||
"path": "../subagent"
|
||||
},
|
||||
{
|
||||
"path": "../subagent-control"
|
||||
},
|
||||
{
|
||||
"path": "../../tasks/tasks"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user