refactor(subagent): merge continuation control service
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/subagent/README.md
|
||||
README.md: 438907ea7de41842f900b050385f15feac7cc272
|
||||
README.zh.md: 87911216bc4e6b5f75e17ca2c58818725f66e7ec
|
||||
README.md: a195ecbaeb24cb63af8cdd4ac872bb6a2fc97d46
|
||||
README.zh.md: b9965030a38b603f7c03d98d6b8021acbeb47fda
|
||||
|
||||
@@ -6,16 +6,15 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](..
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `subagent/` | Abstract subagent seam: named-provider registry + vocabulary + the durable child descriptor | `ctx.subagents` |
|
||||
| `subagent/` | Subagent service: named-provider registry, vocabulary, durable descriptor, and optional Task-backed continuation orchestration | `ctx.subagents` |
|
||||
| `subagent-inprocess/` | Shared in-process run driver (no provider; one cleanup effect per run) | — |
|
||||
| `subagent-spawn/` | In-process backend: a fresh child agent, with cold resume | (registers on `ctx.subagents`) |
|
||||
| `subagent-fork/` | In-process backend: a child seeded with the parent's completed-turn prefix, with cold resume | (registers on `ctx.subagents`) |
|
||||
| `subagent-acp/` | Out-of-process backend: a child agent in a spawned subprocess, driven over ACP (one-shot) | (registers on `ctx.subagents`) |
|
||||
| `subagent-dsh-sdk/` | Out-of-process backend: a child harness runtime in a spawned subprocess, driven over stdio JSON-RPC through the TypeScript SDK client | (registers on `ctx.subagents`) |
|
||||
| `subagent-control/` | Continuable-child orchestration: stable ids, descriptor lookup, Task-backed activation, steer-or-resume routing | `ctx.subagentControl` |
|
||||
| `tool-subagent/` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) |
|
||||
| `tool-subagent-control/` | The one globally named `send_message` follow-up tool over `ctx.subagentControl` | (registers on `ctx.tools`) |
|
||||
| `tool-subagent-control/` | The optional, globally named `send_message` follow-up tool over `ctx.subagents` | (registers on `ctx.tools`) |
|
||||
|
||||
The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` / `subagent-dsh-sdk` backends spawn their children through the [`subprocess/`](../subprocess/README.md) seam (the shared credential scrub, tree-scoped teardown, and dispose ladder). `subagent-control` sits above the seam: it binds one durable child session to a series of disposable Task-backed activations, and both model tools and human-facing adapters route through its one contract. Tests replace only the child boundary with package-local fixtures.
|
||||
The interface and continuation orchestration live at `subagent/subagent/`. Raw `start` / `resume` dispatch stays independent of Tasks and persistence; an internal manager binds durable child sessions to disposable Task-backed activations only while the Task and Agent services are present, and resolves persistence only when a continuation operation runs. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` / `subagent-dsh-sdk` backends spawn their children through the [`subprocess/`](../subprocess/README.md) seam (the shared credential scrub, tree-scoped teardown, and dispose ladder). Tests replace only the child boundary with package-local fixtures.
|
||||
|
||||
The proposals and design rationale: [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md) and [.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md).
|
||||
The design rationale: [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), and [.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md).
|
||||
|
||||
@@ -6,16 +6,15 @@ subagent(子 agent)seam 允许 agent(智能体)把工作委派给子 age
|
||||
|
||||
| 包(package) | 角色 | ctx 键 |
|
||||
|---|---|---|
|
||||
| `subagent/` | 抽象 subagent seam:具名提供方注册表、词汇与持久化子 agent 描述符 | `ctx.subagents` |
|
||||
| `subagent/` | Subagent 服务:具名提供方注册表、词汇、持久化描述符与可选的由 Task 支撑的继续执行编排 | `ctx.subagents` |
|
||||
| `subagent-inprocess/` | 共享进程内运行驱动器(不含提供方;每次运行使用一个清理 effect) | 无 |
|
||||
| `subagent-spawn/` | 进程内后端:支持冷恢复的全新子 agent | (注册到 `ctx.subagents`) |
|
||||
| `subagent-fork/` | 进程内后端:以父 agent 已完成轮次的前缀作为初始内容、支持冷恢复的子 agent | (注册到 `ctx.subagents`) |
|
||||
| `subagent-acp/` | 进程外后端:在 spawn 的子进程中运行并通过 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`) |
|
||||
| `tool-subagent-control/` | 基于 `ctx.subagents`、可选且全局名称唯一的 `send_message` 后续消息工具 | (注册到 `ctx.tools`) |
|
||||
|
||||
接口位于 `subagent/subagent/`。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不含提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` / `subagent-dsh-sdk` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程(共享的凭据清除、以进程树为范围的拆卸、dispose(资源释放)阶梯)。`subagent-control` 位于该 seam 之上:它把一个持久化子会话绑定到一系列可 dispose、由 Task 支撑的 activation,模型工具和面向人的适配器都通过这份统一契约进行路由。测试只用包内 fixture(测试前置数据)替换子 agent 边界。
|
||||
接口和继续执行编排位于 `subagent/subagent/`。原始 `start` / `resume` 分发仍与 Task 和持久化无关;只有在 Task 与 Agent 服务存在时,内部管理器才会把持久化子会话绑定到可 dispose、由 Task 支撑的 activation,并且只在继续执行操作运行时解析持久化服务。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不含提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` / `subagent-dsh-sdk` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程(共享的凭据清除、以进程树为范围的拆卸、dispose(资源释放)阶梯)。测试只用包内 fixture(测试前置数据)替换子 agent 边界。
|
||||
|
||||
提案与设计理由见 [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md) 和 [.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)。
|
||||
设计理由见 [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md) 和 [.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
# @deepseek-ai/dsh-subagent-control
|
||||
|
||||
The continuable-subagent control service (`ctx.subagentControl`): the one orchestration path that binds a durable child session to a series of disposable Task-backed activations. Model tools and human-facing adapters call the same contract; the low-level `ctx.subagents` seam stays collection-, Task-, and persistence-agnostic.
|
||||
|
||||
## Activation lifecycle
|
||||
|
||||
A continuable background subagent is a durable child session with a series of Task-backed activations. `startContinuable()` allocates the stable child session id before Task creation, snapshots the descriptor inputs (a non-JSON input throws with no Task), and registers the initial activation's Task; the provider publishes exactly that child id and appends the versioned `subagent/descriptor` event inside the child's first turn. Every activation — initial or resumed — creates a fresh Task whose settlement awaits the provider's durability-confirmed child result, disposes the run, and only then records the `TaskOutcome`: a terminal Task leaves the durable child session but no live child Agent. A provider rejection with `DURABILITY_FAILED` settles the Task as `failed` and copies the error message into `detail`, so `task_output` reports the failed checkpoint and resumability risk without exposing unconfirmed output.
|
||||
|
||||
`sendMessage(parent, childId, message, source)` owns steer-or-resume routing and requires the caller's `MessageSource`. A running activation preserves it through the run's confirmed `steer` capability and returns the existing Task id (`steered`) only after a committed request snapshot admits the message; an absent activation starts a fresh Task that loads the persisted child, authorizes the recorded `parentSession` as the direct parent, folds the descriptor, and dispatches `SubagentService.resume()` with the same source (`started`). Either route projects the content to the model as a user-role message while retaining its source in the child log. Rejection means the message was not delivered: terminal policy or Task settlement winning the admission race never falls through to cold resume within the same call, and a live registry Agent outside the activation association is an ownership conflict rather than an adoption target.
|
||||
|
||||
Cancellation targets the whole activation. `task_kill` or owner disposal aborts the Task-owned signal; before publication the provider rejects only after its creation transaction rolled back to quiescence, afterwards the signal cancels the published run, and settlement records `killed` only once the activation is quiescent. Human input shares this path: an adapter submits child input through `sendMessage()` under the loaded parent, so parent and human messages that joined one turn share its result and cancellation outcome, and `TaskService.start()`'s control-surface requirement applies (load `@deepseek-ai/dsh-tool-tasks` or attach a surface).
|
||||
|
||||
The activation association is process-local routing state, installed before any persistence or provider await and removed after run disposal and Task terminal publication. It is not a durable catalog: restart recovers the child session, not in-flight Tasks or their notifications.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Task completion and output
|
||||
|
||||
#### What the model sees
|
||||
|
||||
None directly, as this package registers no tool and no prompt text; the model observes continuable children through `@deepseek-ai/dsh-tool-subagent`'s background acknowledgement, `@deepseek-ai/dsh-tool-subagent-control`'s `send_message` results, and the generic task surface, whose outputs this service produces.
|
||||
|
||||
#### Token effect
|
||||
|
||||
None beyond the consuming tools' own results.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this service appends nothing to any model-visible sequence.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Concurrent stopped-child admission is not atomic across awaits** — the synchronous association install admits one activation per child in this process, but a caller bypassing the control service can still race it; the Agent registry's same-id collision is the final backstop, and the losing Task fails with its message not delivered.
|
||||
- **The association coordinates only one runtime** — concurrent resume from multiple processes needs a persistence-level lease or compare-and-set, which no backend offers yet.
|
||||
- **Task records are process-local** — restart recovers the durable child session, not an interrupted Task, its result, or its completion notice; durable Task recovery is a separate concern.
|
||||
- **Human interaction requires the exact live parent Agent** — Task access is fenced by the owner session and owner disposal cancels its Tasks; standalone child conversations belong to the interactive-side-sessions proposal, not this Task-owned lifecycle.
|
||||
- **ACP children remain one-shot** — `AcpProvider.resume` and per-child continuation advertisement are deferred until the remote-session descriptor contract is resolved.
|
||||
@@ -1,55 +0,0 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-subagent-control",
|
||||
"description": "Continuable-subagent control service: Task-backed activation, durable child descriptors, and steer-or-resume message routing",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tasks": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-fork": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-spawn": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-subagent-control`.
|
||||
* @module @deepseek-ai/dsh-subagent-control/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-control'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'subagent-control-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the activation association is deliberately private
|
||||
* process-local routing state with no event stream of its own; the run
|
||||
* lifecycle pair it participates in is checked by `@deepseek-ai/dsh-subagent`,
|
||||
* and Task lifecycle relations belong to `@deepseek-ai/dsh-tasks`.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../subagent"
|
||||
},
|
||||
{
|
||||
"path": "../../tasks/tasks"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -10,11 +10,10 @@ The family separates the stable interface from implementations and model-facing
|
||||
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
| `@deepseek-ai/dsh-subagent` | Provider registry, request/result/descriptor types, and lifecycle events. |
|
||||
| `@deepseek-ai/dsh-subagent` | Provider registry, request/result/descriptor types, lifecycle events, and continuable-child orchestration. |
|
||||
| `@deepseek-ai/dsh-subagent-spawn` | Fresh in-process child, with cold resume. |
|
||||
| `@deepseek-ai/dsh-subagent-fork` | In-process child seeded with completed parent turns, with cold resume. |
|
||||
| `@deepseek-ai/dsh-subagent-acp` | Fresh out-of-process ACP child (one-shot). |
|
||||
| `@deepseek-ai/dsh-subagent-control` | Continuable-child orchestration: durable ids, descriptors, Task-backed activation. |
|
||||
| `@deepseek-ai/dsh-tool-subagent` | Model-facing delegation tool over one configured provider. |
|
||||
| `@deepseek-ai/dsh-tool-subagent-control` | The globally named `send_message` follow-up tool. |
|
||||
|
||||
@@ -22,7 +21,7 @@ Multiple providers may coexist under different names. This lets a deployment exp
|
||||
|
||||
## Service API
|
||||
|
||||
`SubagentService` has five main operations:
|
||||
`SubagentService` has seven main operations:
|
||||
|
||||
| Member | Meaning |
|
||||
|---|---|
|
||||
@@ -30,7 +29,9 @@ Multiple providers may coexist under different names. This lets a deployment exp
|
||||
| `getProvider(name)` | Return the provider, or `undefined` when absent. |
|
||||
| `list()` | Return provider names in insertion order. |
|
||||
| `start(name, request)` | Validate requested capabilities and semantic values, then await the provider until a real child is ready. Fulfillment returns a holder-owned `SubagentRun`; rejection means the provider has already cleaned every partial startup resource. |
|
||||
| `resume(name, request)` | Capability-checked dispatch to `provider.resume?()` with the same run lifecycle observation as `start`. The caller (the control service) has already loaded the child, folded its descriptor, and authorized the parent; this seam stays collection-, Task-, and persistence-agnostic. |
|
||||
| `resume(name, request)` | Capability-checked raw dispatch to `provider.resume?()` with the same run lifecycle observation as `start`; the caller owns descriptor lookup, authorization, and collection. |
|
||||
| `startContinuable(spec)` | Allocate a durable child id and register its initial Task-backed activation. Requires `ctx.tasks`, `ctx.agents`, session persistence, and a resumable provider. |
|
||||
| `sendMessage(parent, childId, message, source)` | Steer the current activation or start a new Task that cold-resumes the durable child. Requires `ctx.tasks` and `ctx.agents`; cold resume also requires session persistence. |
|
||||
|
||||
`SubagentStartRequest.signal` is required and is the canonical cancellation channel. An abort before publication makes `start()` reject after rollback; an abort after publication cancels the live child. The request may also select a model, require structured output, cap delegation depth, restrict child tools, set a child persona, or carry a resolved `continuation` (the control-allocated stable child id plus its durable descriptor), which requires the provider's `resume` capability.
|
||||
|
||||
@@ -63,7 +64,7 @@ The seam owns the depth vocabulary shared by implementations and consumers: the
|
||||
|
||||
`SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. For a continuable activation, a completed result also confirms that the provider made its final state durable; a failed required checkpoint rejects as infrastructure rather than publishing unconfirmed output. `dispose()` is idempotent, cancels remaining work, and waits for the child resources to quiesce.
|
||||
|
||||
A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, exposes the exact child as `SubagentRun.localAgent`, and records `request.parent.session.id` in the child's `parentSession` header. A continuable start publishes exactly the control-allocated `continuation.sessionId`. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`.
|
||||
A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, exposes the exact child as `SubagentRun.localAgent`, and records `request.parent.session.id` in the child's `parentSession` header. A continuable start publishes exactly the service-allocated `continuation.sessionId`. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`.
|
||||
|
||||
The service emits `subagent/start` only after `start()` or `resume()` has fulfilled. It attaches the result observer before that synchronous notification, so even an already-settled child still produces `subagent/start` before `subagent/end`. The pair shares a service-minted `runId`; its `local` flag is snapshotted from the provider's exact `localAgent`, so observers never infer run identity or locality from reusable provider/session names.
|
||||
|
||||
@@ -73,7 +74,7 @@ Provider additions and removals also emit `subagent/provider-added` and `subagen
|
||||
|
||||
## Collection model
|
||||
|
||||
The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. Background delegation does not change this seam; `@deepseek-ai/dsh-subagent-control` registers each activation with the generic `ctx.tasks` runtime, then collection and cancellation use the shared task tools. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts.
|
||||
The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool. Continuable background delegation calls `ctx.subagents.startContinuable()`, whose internal manager exists only while `ctx.tasks` and `ctx.agents` are available; session persistence is resolved per continuation operation. Collection and cancellation use the shared task tools. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -33,9 +33,19 @@
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tasks": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@deepseek-ai/dsh-session-persistence": {
|
||||
"optional": true
|
||||
},
|
||||
"@deepseek-ai/dsh-tasks": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
@@ -43,6 +53,8 @@
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
/**
|
||||
* Continuable-subagent control service (`ctx.subagentControl`): stable child
|
||||
* ids, descriptor persistence and lookup by known child id, Task-backed
|
||||
* activation, and steer-or-resume message routing. The low-level
|
||||
* `ctx.subagents` seam stays collection-, Task-, and persistence-agnostic;
|
||||
* this service owns the policy that binds one durable child session to a
|
||||
* series of disposable Task-backed activations.
|
||||
* Internal continuable-subagent manager: stable child ids, descriptor
|
||||
* persistence and lookup by known child id, Task-backed activation, and
|
||||
* steer-or-resume message routing behind `ctx.subagents`.
|
||||
*
|
||||
* Every continuable activation — initial or resumed, parent- or human-started
|
||||
* — has exactly one Task and one result. Task settlement awaits the child
|
||||
@@ -13,26 +10,21 @@
|
||||
* targets the whole activation: parent and human messages that joined one
|
||||
* turn share its result and its `killed` outcome.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent-control
|
||||
* @module @deepseek-ai/dsh-subagent
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { Context } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||
import { foldSubagentDescriptor, snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
||||
import { foldSubagentDescriptor, snapshotSubagentDescriptor } from './descriptor.ts'
|
||||
import type { SubagentResult, SubagentRun, SubagentStartRequest } from './types.ts'
|
||||
import type { SubagentService } from './index.ts'
|
||||
import type { TaskHooks, TaskId, TaskOutcome } from '@deepseek-ai/dsh-tasks'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
subagentControl: SubagentControlService
|
||||
}
|
||||
}
|
||||
|
||||
/** Attribution for a model coordinator's follow-up to one of its children. */
|
||||
export interface CoordinatorMessageSource {
|
||||
readonly kind: 'coordinator'
|
||||
@@ -46,7 +38,7 @@ declare module '@deepseek-ai/dsh-llm' {
|
||||
}
|
||||
}
|
||||
|
||||
/** Typed error for control-service routing, authorization, and delivery failures. */
|
||||
/** Typed error for continuation routing, authorization, and delivery failures. */
|
||||
export class SubagentControlError extends HarnessError {
|
||||
constructor(message: string, code: string, options?: ErrorOptions) {
|
||||
super(message, code, options)
|
||||
@@ -68,7 +60,7 @@ export interface ContinuableStartSpec {
|
||||
readonly request: Omit<SubagentStartRequest, 'signal' | 'continuation'>
|
||||
}
|
||||
|
||||
/** Identities returned by {@link SubagentControlService.startContinuable}. */
|
||||
/** Identities returned by {@link SubagentContinuationManager.startContinuable}. */
|
||||
export interface ContinuableStart {
|
||||
/** The durable child session id, stable across activations. */
|
||||
readonly childId: SessionId
|
||||
@@ -77,7 +69,7 @@ export interface ContinuableStart {
|
||||
}
|
||||
|
||||
/**
|
||||
* How {@link SubagentControlService.sendMessage} delivered a message:
|
||||
* How {@link SubagentContinuationManager.sendMessage} delivered a message:
|
||||
* `steered` joined the running activation's existing Task without creating a
|
||||
* Task of its own; `started` created a fresh Task that cold-resumes the
|
||||
* durable child with the message. Failure is an exception, never a result —
|
||||
@@ -173,14 +165,14 @@ function finalText(blocks: ContentBlock[]): string {
|
||||
* boundary, while foreground one-shot delegation keeps calling
|
||||
* `ctx.subagents.start()` directly.
|
||||
*/
|
||||
export class SubagentControlService extends Service {
|
||||
static inject = ['subagents', 'tasks', 'agents']
|
||||
|
||||
export class SubagentContinuationManager {
|
||||
/** Child session id → its current activation. Process-local, never durable. */
|
||||
private activations = new Map<SessionId, ActiveActivation>()
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'subagentControl')
|
||||
constructor(
|
||||
private readonly ctx: Context,
|
||||
private readonly subagents: SubagentService,
|
||||
) {
|
||||
// Terminal publication is one of the two removal conditions. The exact
|
||||
// Task id pins the resolution to this activation, never a later same-child one.
|
||||
ctx.tasks.onTaskDone((snapshot) => {
|
||||
@@ -189,7 +181,7 @@ export class SubagentControlService extends Service {
|
||||
}
|
||||
})
|
||||
// TaskService deliberately keeps producer Tasks alive across a
|
||||
// control-surface or producer reload, so this service's disposal must not
|
||||
// follow-up-tool or producer reload, so this manager's disposal must not
|
||||
// strand the activations it can no longer route to: cancel each one and
|
||||
// await producer settlement (run disposal) before releasing the map. The
|
||||
// effect-scoped onTaskDone listener above is already gone by then, so
|
||||
@@ -198,7 +190,7 @@ export class SubagentControlService extends Service {
|
||||
const active = [...this.activations.values()]
|
||||
this.activations.clear()
|
||||
for (const activation of active) {
|
||||
activation.controller.abort('subagent control service disposed')
|
||||
activation.controller.abort('subagent continuation manager disposed')
|
||||
activation.terminal.resolve()
|
||||
}
|
||||
await Promise.allSettled(active.map((activation) => {
|
||||
@@ -207,7 +199,7 @@ export class SubagentControlService extends Service {
|
||||
if (activation.done === undefined) return Promise.resolve()
|
||||
return activation.done
|
||||
}))
|
||||
}, 'subagentControl.activations()')
|
||||
}, 'subagents.continuations()')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -239,7 +231,7 @@ export class SubagentControlService extends Service {
|
||||
...request.toolFilter !== undefined ? { toolFilter: request.toolFilter } : {},
|
||||
})
|
||||
const taskId = this.startActivation(childId, spec.label, request.parent, signal =>
|
||||
this.ctx.subagents.start(spec.provider, {
|
||||
this.subagents.start(spec.provider, {
|
||||
...request,
|
||||
signal,
|
||||
continuation: { sessionId: childId, descriptor },
|
||||
@@ -294,7 +286,7 @@ export class SubagentControlService extends Service {
|
||||
const activation = this.activations.get(childId)
|
||||
if (activation === undefined) {
|
||||
throw new SubagentControlError(
|
||||
`subagent "${childId}" has a live agent outside control-service ownership; the message was not delivered`,
|
||||
`subagent "${childId}" has a live agent outside continuation ownership; the message was not delivered`,
|
||||
'OWNERSHIP_CONFLICT',
|
||||
)
|
||||
}
|
||||
@@ -399,7 +391,7 @@ export class SubagentControlService extends Service {
|
||||
'NOT_RESUMABLE',
|
||||
)
|
||||
}
|
||||
return this.ctx.subagents.resume(descriptor.provider, {
|
||||
return this.subagents.resume(descriptor.provider, {
|
||||
sessionId: childId,
|
||||
prompt: message,
|
||||
source,
|
||||
@@ -501,4 +493,4 @@ function resumeLabel(message: ContentBlock[]): string {
|
||||
return text.length > 80 ? `${text.slice(0, 79)}…` : text
|
||||
}
|
||||
|
||||
export default SubagentControlService
|
||||
export default SubagentContinuationManager
|
||||
@@ -3,7 +3,7 @@
|
||||
* `subagent/descriptor` session event that records a child's declared
|
||||
* composition so a known child id can be cold-resumed after its run — and its
|
||||
* process — are gone. Providers append it turn-enclosed in the child's initial
|
||||
* turn; the control service folds it back on resume.
|
||||
* turn; the continuation manager folds it back on resume.
|
||||
*
|
||||
* The descriptor deliberately snapshots explicit fields rather than the
|
||||
* merge-extensible `AgentOptions` object: an unrelated extension value cannot
|
||||
|
||||
@@ -13,13 +13,11 @@
|
||||
* (`@deepseek-ai/dsh-subagent-spawn`, `-fork`, `-acp`) and the model-facing
|
||||
* consumer (`@deepseek-ai/dsh-tool-subagent`) are separate packages.
|
||||
*
|
||||
* Scope: the seam stays collection-, Task-, and persistence-agnostic — a run
|
||||
* is started or resumed and its `result` awaited, whether the consumer blocks
|
||||
* on it (foreground) or registers it as a `ctx.tasks` background task (the
|
||||
* generic runtime owns ids/polling/stop; this seam gains nothing task-shaped).
|
||||
* Durable continuable-child ids, descriptor lookup, and Task association
|
||||
* belong to `@deepseek-ai/dsh-subagent-control`; this service only validates
|
||||
* and dispatches `start`/`resume` and observes run lifecycle.
|
||||
* Raw `start` and `resume` remain collection-agnostic provider dispatch.
|
||||
* When `ctx.tasks` and `ctx.agents` are available, the same service also binds
|
||||
* an internal continuation manager for durable child ids, descriptor lookup,
|
||||
* Task-backed activations, and steer-or-resume delivery. Persistence remains
|
||||
* optional and is required only when a continuation operation is called.
|
||||
*
|
||||
* Same-process providers are trusted typed collaborators. Requests, provider
|
||||
* descriptors, results, and lifecycle payloads are borrowed immutable values;
|
||||
@@ -35,7 +33,7 @@ import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import { assertObjectJsonSchema } from '@deepseek-ai/dsh-tools'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {
|
||||
@@ -47,6 +45,12 @@ import type {
|
||||
SubagentStartRequest,
|
||||
} from './types.ts'
|
||||
import { SubagentRunId } from './types.ts'
|
||||
import SubagentContinuationManager from './continuation.ts'
|
||||
import type {
|
||||
ContinuableStart,
|
||||
ContinuableStartSpec,
|
||||
SendMessageResult,
|
||||
} from './continuation.ts'
|
||||
|
||||
export * from './out-of-process.ts'
|
||||
export { SubagentRunId } from './types.ts'
|
||||
@@ -67,6 +71,17 @@ export {
|
||||
SUBAGENT_DESCRIPTOR_VERSION,
|
||||
} from './descriptor.ts'
|
||||
export type { SubagentDescriptorData, SubagentDescriptorInput } from './descriptor.ts'
|
||||
export {
|
||||
runOutcome,
|
||||
settleRun,
|
||||
SubagentControlError,
|
||||
} from './continuation.ts'
|
||||
export type {
|
||||
ContinuableStart,
|
||||
ContinuableStartSpec,
|
||||
CoordinatorMessageSource,
|
||||
SendMessageResult,
|
||||
} from './continuation.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-agent' {
|
||||
interface AgentOptions {
|
||||
@@ -187,12 +202,49 @@ export class SubagentError extends HarnessError {
|
||||
}
|
||||
}
|
||||
|
||||
/** Named provider registry and capability-checked start surface. */
|
||||
/** Named provider registry with raw and Task-backed continuation operations. */
|
||||
export class SubagentService extends Service {
|
||||
private providers = new Map<string, SubagentProvider>()
|
||||
private continuations: SubagentContinuationManager | undefined
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'subagents')
|
||||
ctx.inject(['tasks', 'agents'], (childCtx: Context) => {
|
||||
const manager = new SubagentContinuationManager(childCtx, this)
|
||||
this.continuations = manager
|
||||
childCtx.effect(() => () => {
|
||||
/* v8 ignore else -- one injected binding owns the slot until its fiber disposes. */
|
||||
if (this.continuations === manager) this.continuations = undefined
|
||||
}, 'subagents.continuationBinding()')
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Start one durable continuable child through a Task-backed initial
|
||||
* activation.
|
||||
* @param spec - provider, Task label, and delegation request.
|
||||
* @returns the stable child id and initial activation Task id.
|
||||
*/
|
||||
startContinuable(spec: ContinuableStartSpec): ContinuableStart {
|
||||
return this.requireContinuations().startContinuable(spec)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver a message to a continuable child by steering its live activation
|
||||
* or cold-resuming a fresh Task-backed activation.
|
||||
* @param parent - live direct parent authorizing the operation.
|
||||
* @param childId - durable child session id.
|
||||
* @param message - user-role content to deliver.
|
||||
* @param source - durable caller attribution.
|
||||
* @returns the existing steered Task or newly started Task.
|
||||
*/
|
||||
sendMessage(
|
||||
parent: Agent,
|
||||
childId: SessionId,
|
||||
message: ContentBlock[],
|
||||
source: MessageSource,
|
||||
): Promise<SendMessageResult> {
|
||||
return this.requireContinuations().sendMessage(parent, childId, message, source)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -264,7 +316,7 @@ export class SubagentService extends Service {
|
||||
/**
|
||||
* Resume a persisted continuable child through the named provider's
|
||||
* `resume` capability, with the same run lifecycle observation as
|
||||
* {@link start}. The caller (the control service) has already loaded the
|
||||
* {@link start}. The internal continuation manager has already loaded the
|
||||
* child, folded its descriptor, and authorized the parent; this method owns
|
||||
* only capability-checked dispatch.
|
||||
* @param name - the provider recorded in the child's descriptor.
|
||||
@@ -291,6 +343,17 @@ export class SubagentService extends Service {
|
||||
return provider
|
||||
}
|
||||
|
||||
/** Resolve the optional Task-backed continuation runtime or fail loud. */
|
||||
private requireContinuations(): SubagentContinuationManager {
|
||||
if (this.continuations === undefined) {
|
||||
throw new SubagentError(
|
||||
'continuable subagents require the tasks and agents services',
|
||||
'CONTINUATION_UNAVAILABLE',
|
||||
)
|
||||
}
|
||||
return this.continuations
|
||||
}
|
||||
|
||||
/** Emit the start/end lifecycle pair for one accepted run and return it. */
|
||||
private observeRun(name: string, parent: Agent, run: SubagentRun): SubagentRun {
|
||||
const runId = SubagentRunId(randomUUID())
|
||||
|
||||
@@ -94,7 +94,7 @@ export interface SubagentStartRequest {
|
||||
*/
|
||||
readonly persona?: string
|
||||
/**
|
||||
* Continuable-child intent, resolved by the control service before start.
|
||||
* Continuable-child intent, resolved by `ctx.subagents` before start.
|
||||
* The provider MUST publish exactly `sessionId` as the child identity
|
||||
* instead of allocating one internally, and MUST append the snapshotted
|
||||
* `descriptor` as the child's turn-enclosed `subagent/descriptor` event
|
||||
@@ -106,10 +106,10 @@ export interface SubagentStartRequest {
|
||||
|
||||
/**
|
||||
* The resolved continuable-child identity and durable composition record a
|
||||
* control-service caller attaches to a start request.
|
||||
* continuation caller attaches to a start request.
|
||||
*/
|
||||
export interface SubagentContinuation {
|
||||
/** Control-allocated stable child session id, published verbatim. */
|
||||
/** Service-allocated stable child session id, published verbatim. */
|
||||
readonly sessionId: SessionId
|
||||
/** Snapshotted descriptor persisted in the child log for cold resume. */
|
||||
readonly descriptor: SubagentDescriptorData
|
||||
@@ -117,7 +117,7 @@ export interface SubagentContinuation {
|
||||
|
||||
/**
|
||||
* What a caller asks for when resuming a persisted continuable child. The
|
||||
* control service loads the child log, folds and authorizes its descriptor,
|
||||
* continuation manager loads the child log, folds and authorizes its descriptor,
|
||||
* and passes this fully resolved request to
|
||||
* {@link SubagentService.resume}, which dispatches to
|
||||
* {@link SubagentProvider.resume}. The provider reconstructs the declared
|
||||
|
||||
@@ -9,7 +9,6 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import SubagentService, { SUBAGENT_DESCRIPTOR_VERSION } from '@deepseek-ai/dsh-subagent'
|
||||
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
|
||||
import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork'
|
||||
import { TaskId } from '@deepseek-ai/dsh-tasks'
|
||||
@@ -18,7 +17,12 @@ import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { createUserMessage, HarnessError, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import SubagentControlService, { runOutcome, settleRun, SubagentControlError } from '../src/index.ts'
|
||||
import SubagentService, {
|
||||
runOutcome,
|
||||
settleRun,
|
||||
SubagentControlError,
|
||||
SUBAGENT_DESCRIPTOR_VERSION,
|
||||
} from '../src/index.ts'
|
||||
|
||||
type Script = ConstructorParameters<typeof MockAdapter>[0]
|
||||
|
||||
@@ -53,12 +57,12 @@ afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** Boot the full continuable stack: loop, persistence, providers, tasks, control. */
|
||||
/** Boot the full continuable stack: loop, persistence, providers, tasks, and subagents. */
|
||||
async function setupWith(adapter: LlmAdapter, options: { persistence?: boolean } = {}) {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
if (options.persistence !== false) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-control-'))
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-continuation-'))
|
||||
roots.push(root)
|
||||
await ctx.plugin(JsonlSessionPersistence, { root })
|
||||
}
|
||||
@@ -68,7 +72,6 @@ async function setupWith(adapter: LlmAdapter, options: { persistence?: boolean }
|
||||
await ctx.plugin(SubagentFork, { providerName: 'fork' })
|
||||
await ctx.plugin(LocalTaskService)
|
||||
await ctx.plugin(ToolTasks, {})
|
||||
await ctx.plugin(SubagentControlService)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
|
||||
return { ctx, parent }
|
||||
@@ -93,12 +96,12 @@ async function waitTerminal(ctx: Context, taskId: TaskId, parent: Agent) {
|
||||
}
|
||||
|
||||
async function waitPublishedRun(ctx: Context, childId: SessionId): Promise<void> {
|
||||
const control = ctx.subagentControl as unknown as {
|
||||
activations: Map<SessionId, { run: unknown }>
|
||||
const continuations = ctx.subagents as unknown as {
|
||||
continuations: { activations: Map<SessionId, { run: unknown }> }
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setInterval(() => {
|
||||
if (control.activations.get(childId)?.run !== undefined) {
|
||||
if (continuations.continuations.activations.get(childId)?.run !== undefined) {
|
||||
clearInterval(timer)
|
||||
resolve()
|
||||
}
|
||||
@@ -121,13 +124,13 @@ function sendMessage(
|
||||
childId: SessionId,
|
||||
content: ReturnType<typeof message>,
|
||||
) {
|
||||
return ctx.subagentControl.sendMessage(parent, childId, content, { kind: 'user' })
|
||||
return ctx.subagents.sendMessage(parent, childId, content, { kind: 'user' })
|
||||
}
|
||||
|
||||
describe('SubagentControlService.startContinuable', () => {
|
||||
describe('SubagentService.startContinuable', () => {
|
||||
it('returns both identities immediately; the Task settles with the child result after disposal', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('first answer')])
|
||||
const started = ctx.subagentControl.startContinuable(startSpec(parent))
|
||||
const started = ctx.subagents.startContinuable(startSpec(parent))
|
||||
expect(started.childId).toMatch(/[0-9a-f-]{36}/)
|
||||
expect(started.taskId).toBe('subagent-1')
|
||||
|
||||
@@ -138,13 +141,13 @@ describe('SubagentControlService.startContinuable', () => {
|
||||
expect(ctx.agents.get(started.childId)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('publishes the control-allocated child id and appends the turn-enclosed descriptor', async () => {
|
||||
it('publishes the service-allocated child id and appends the turn-enclosed descriptor', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('answer')])
|
||||
const seen: SessionEvent[] = []
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (session.id !== SessionId('parent')) seen.push(event)
|
||||
})
|
||||
const started = ctx.subagentControl.startContinuable(startSpec(parent))
|
||||
const started = ctx.subagents.startContinuable(startSpec(parent))
|
||||
await waitTerminal(ctx, started.taskId, parent)
|
||||
|
||||
const descriptorIndex = seen.findIndex(event => event.type === 'subagent/descriptor')
|
||||
@@ -162,7 +165,7 @@ describe('SubagentControlService.startContinuable', () => {
|
||||
// Model-hidden: the descriptor never carries surface metadata.
|
||||
expect('surfaceOp' in descriptor).toBe(false)
|
||||
|
||||
// The durable log kept the exact control-allocated id.
|
||||
// The durable log kept the exact service-allocated id.
|
||||
const loaded = await ctx.sessionPersistence.load(started.childId)
|
||||
expect(loaded.meta.id).toBe(started.childId)
|
||||
expect(loaded.meta.parentSession).toBe(SessionId('parent'))
|
||||
@@ -171,7 +174,7 @@ describe('SubagentControlService.startContinuable', () => {
|
||||
|
||||
it('rejects synchronously with no Task when persistence is not configured', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('unused')], { persistence: false })
|
||||
expect(() => ctx.subagentControl.startContinuable(startSpec(parent)))
|
||||
expect(() => ctx.subagents.startContinuable(startSpec(parent)))
|
||||
.toThrow(/require session persistence/)
|
||||
expect(ctx.tasks.list(parent)).toEqual([])
|
||||
})
|
||||
@@ -181,19 +184,21 @@ describe('SubagentControlService.startContinuable', () => {
|
||||
const realStart = ctx.tasks.start.bind(ctx.tasks)
|
||||
ctx.tasks.start = () => { throw new Error('task preflight failed') }
|
||||
try {
|
||||
expect(() => ctx.subagentControl.startContinuable(startSpec(parent)))
|
||||
expect(() => ctx.subagents.startContinuable(startSpec(parent)))
|
||||
.toThrow('task preflight failed')
|
||||
} finally {
|
||||
ctx.tasks.start = realStart
|
||||
}
|
||||
const control = ctx.subagentControl as unknown as { activations: Map<SessionId, unknown> }
|
||||
expect(control.activations.size).toBe(0)
|
||||
const continuations = ctx.subagents as unknown as {
|
||||
continuations: { activations: Map<SessionId, unknown> }
|
||||
}
|
||||
expect(continuations.continuations.activations.size).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects a non-JSON descriptor input synchronously with no Task', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('unused')])
|
||||
const spec = startSpec(parent)
|
||||
expect(() => ctx.subagentControl.startContinuable({
|
||||
expect(() => ctx.subagents.startContinuable({
|
||||
...spec,
|
||||
// A symbol survives the static ToolRestriction type only through this
|
||||
// cast — exactly the durable-boundary input the snapshot rejects.
|
||||
@@ -214,7 +219,7 @@ describe('SubagentControlService.startContinuable', () => {
|
||||
maxDepth: 0,
|
||||
},
|
||||
}
|
||||
const started = ctx.subagentControl.startContinuable(spec)
|
||||
const started = ctx.subagents.startContinuable(spec)
|
||||
const snapshot = await waitTerminal(ctx, started.taskId, parent)
|
||||
expect(snapshot.status).toBe('failed')
|
||||
expect(snapshot.detail).toContain('maxDepth')
|
||||
@@ -228,7 +233,7 @@ describe('SubagentControlService.startContinuable', () => {
|
||||
|
||||
it('task_kill during the run aborts, disposes, and settles killed after quiescence', async () => {
|
||||
const { ctx, parent } = await setup(['hang'])
|
||||
const started = ctx.subagentControl.startContinuable(startSpec(parent))
|
||||
const started = ctx.subagents.startContinuable(startSpec(parent))
|
||||
// Let the child publish and begin its turn.
|
||||
await new Promise(resolve => setTimeout(resolve, 30))
|
||||
expect(ctx.agents.get(started.childId)).toBeDefined()
|
||||
@@ -250,7 +255,7 @@ describe('SubagentControlService.startContinuable', () => {
|
||||
checkpointStarted.resolve(undefined)
|
||||
await releaseCheckpoint.promise
|
||||
})
|
||||
const started = ctx.subagentControl.startContinuable(startSpec(parent))
|
||||
const started = ctx.subagents.startContinuable(startSpec(parent))
|
||||
|
||||
await checkpointStarted.promise
|
||||
expect(ctx.tasks.kill(started.taskId, parent, 'no longer needed')).toBe('requested')
|
||||
@@ -262,7 +267,7 @@ describe('SubagentControlService.startContinuable', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubagentControlService.sendMessage', () => {
|
||||
describe('SubagentService.sendMessage', () => {
|
||||
it('omits undeclared model selectors and rejects a provider without live delivery', async () => {
|
||||
const { ctx } = await setup([])
|
||||
const result = Promise.withResolvers<{
|
||||
@@ -286,7 +291,7 @@ describe('SubagentControlService.sendMessage', () => {
|
||||
resume: async () => { throw new Error('not used') },
|
||||
})
|
||||
const parent = ctx.agentLoop.create(SessionId('bare-parent'), {})
|
||||
const started = ctx.subagentControl.startContinuable(startSpec(parent, 'no-steer'))
|
||||
const started = ctx.subagents.startContinuable(startSpec(parent, 'no-steer'))
|
||||
await waitPublishedRun(ctx, started.childId)
|
||||
|
||||
expect(descriptor).toEqual({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'no-steer' })
|
||||
@@ -336,7 +341,7 @@ describe('SubagentControlService.sendMessage', () => {
|
||||
},
|
||||
resume: async () => { throw new Error('not used') },
|
||||
})
|
||||
const started = ctx.subagentControl.startContinuable(startSpec(parent, 'mismatched-local'))
|
||||
const started = ctx.subagents.startContinuable(startSpec(parent, 'mismatched-local'))
|
||||
await waitPublishedRun(ctx, started.childId)
|
||||
|
||||
await expect(sendMessage(ctx, parent, started.childId, message('join')))
|
||||
@@ -357,7 +362,7 @@ describe('SubagentControlService.sendMessage', () => {
|
||||
])
|
||||
const { ctx, parent } = await setupWith(adapter)
|
||||
|
||||
const started = ctx.subagentControl.startContinuable(startSpec(parent))
|
||||
const started = ctx.subagents.startContinuable(startSpec(parent))
|
||||
// Wait until the first immutable request has crossed the adapter boundary.
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setInterval(() => {
|
||||
@@ -368,7 +373,7 @@ describe('SubagentControlService.sendMessage', () => {
|
||||
}, 5)
|
||||
})
|
||||
|
||||
const delivery = ctx.subagentControl.sendMessage(
|
||||
const delivery = ctx.subagents.sendMessage(
|
||||
parent,
|
||||
started.childId,
|
||||
message('also consider Y'),
|
||||
@@ -406,7 +411,7 @@ describe('SubagentControlService.sendMessage', () => {
|
||||
})
|
||||
|
||||
const base = startSpec(parent)
|
||||
const started = ctx.subagentControl.startContinuable({
|
||||
const started = ctx.subagents.startContinuable({
|
||||
...base,
|
||||
request: {
|
||||
...base.request,
|
||||
@@ -419,7 +424,7 @@ describe('SubagentControlService.sendMessage', () => {
|
||||
})
|
||||
await startedTool.promise
|
||||
|
||||
const delivery = ctx.subagentControl.sendMessage(
|
||||
const delivery = ctx.subagents.sendMessage(
|
||||
parent,
|
||||
started.childId,
|
||||
message('follow-up that terminal policy rejects'),
|
||||
@@ -437,11 +442,11 @@ describe('SubagentControlService.sendMessage', () => {
|
||||
|
||||
it('cold-resumes a settled child into a fresh Task and reports `started`', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('first answer'), textResponse('second answer')])
|
||||
const started = ctx.subagentControl.startContinuable(startSpec(parent))
|
||||
const started = ctx.subagents.startContinuable(startSpec(parent))
|
||||
await waitTerminal(ctx, started.taskId, parent)
|
||||
expect(ctx.agents.get(started.childId)).toBeUndefined()
|
||||
|
||||
const followUp = await ctx.subagentControl.sendMessage(
|
||||
const followUp = await ctx.subagents.sendMessage(
|
||||
parent,
|
||||
started.childId,
|
||||
message('and then?'),
|
||||
@@ -476,7 +481,7 @@ describe('SubagentControlService.sendMessage', () => {
|
||||
toolFilter: { deny: [] as string[] },
|
||||
},
|
||||
}
|
||||
const started = ctx.subagentControl.startContinuable(spec)
|
||||
const started = ctx.subagents.startContinuable(spec)
|
||||
await waitTerminal(ctx, started.taskId, parent)
|
||||
|
||||
const loaded = await ctx.sessionPersistence.load(started.childId)
|
||||
@@ -503,7 +508,7 @@ describe('SubagentControlService.sendMessage', () => {
|
||||
parent.followup(createUserMessage({ content: message('parent question one'), source: { kind: 'user' } }))
|
||||
await parent.whenIdle()
|
||||
|
||||
const started = ctx.subagentControl.startContinuable(startSpec(parent, 'fork'))
|
||||
const started = ctx.subagents.startContinuable(startSpec(parent, 'fork'))
|
||||
await waitTerminal(ctx, started.taskId, parent)
|
||||
const firstLoad = await ctx.sessionPersistence.load(started.childId)
|
||||
const seedLength = firstLoad.meta.seedLength ?? 0
|
||||
@@ -527,7 +532,7 @@ describe('SubagentControlService.sendMessage', () => {
|
||||
|
||||
it('a resumed child cannot regain a top-level delegation budget (header floor)', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('first'), textResponse('second')])
|
||||
const started = ctx.subagentControl.startContinuable(startSpec(parent))
|
||||
const started = ctx.subagents.startContinuable(startSpec(parent))
|
||||
await waitTerminal(ctx, started.taskId, parent)
|
||||
const followUp = await sendMessage(ctx, parent, started.childId, message('go on'))
|
||||
|
||||
@@ -546,7 +551,7 @@ describe('SubagentControlService.sendMessage', () => {
|
||||
it('rejects a foreign child id: the started Task fails with UNAUTHORIZED and delivers nothing', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('other parent answer'), textResponse('unused')])
|
||||
const otherParent = ctx.agentLoop.create(SessionId('other-parent'), { provider: 'mock', model: 'mock' })
|
||||
const started = ctx.subagentControl.startContinuable(startSpec(otherParent))
|
||||
const started = ctx.subagents.startContinuable(startSpec(otherParent))
|
||||
await waitTerminal(ctx, started.taskId, otherParent)
|
||||
|
||||
const attempt = await sendMessage(ctx, parent, started.childId, message('mine now'))
|
||||
@@ -590,9 +595,9 @@ describe('SubagentControlService.sendMessage', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects delivery to a live agent outside control-service ownership', async () => {
|
||||
it('rejects delivery to a live agent outside continuation ownership', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('unused')])
|
||||
// A live child created around the control service.
|
||||
// A live child created outside continuation orchestration.
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('rogue-child'),
|
||||
meta: { parentSession: parent.id },
|
||||
@@ -601,7 +606,7 @@ describe('SubagentControlService.sendMessage', () => {
|
||||
await expect(sendMessage(ctx, parent, SessionId('rogue-child'), message('hello')))
|
||||
.rejects.toThrow(SubagentControlError)
|
||||
await expect(sendMessage(ctx, parent, SessionId('rogue-child'), message('hello')))
|
||||
.rejects.toThrow(/outside control-service ownership.*not delivered/)
|
||||
.rejects.toThrow(/outside continuation ownership.*not delivered/)
|
||||
await handle.dispose()
|
||||
})
|
||||
|
||||
@@ -625,7 +630,7 @@ describe('SubagentControlService.sendMessage', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const started = ctx.subagentControl.startContinuable(startSpec(parent))
|
||||
const started = ctx.subagents.startContinuable(startSpec(parent))
|
||||
// Wait for the child to finish its turn while the run remains undisposed
|
||||
// and the association therefore still holds.
|
||||
await new Promise<void>((resolve) => {
|
||||
@@ -654,7 +659,7 @@ describe('SubagentControlService.sendMessage', () => {
|
||||
|
||||
it('each follow-up Task result is fenced to the parent session', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('first'), textResponse('second')])
|
||||
const started = ctx.subagentControl.startContinuable(startSpec(parent))
|
||||
const started = ctx.subagents.startContinuable(startSpec(parent))
|
||||
await waitTerminal(ctx, started.taskId, parent)
|
||||
const followUp = await sendMessage(ctx, parent, started.childId, message('more'))
|
||||
const other = ctx.agentLoop.create(SessionId('intruder'), { provider: 'mock', model: 'mock' })
|
||||
@@ -663,7 +668,7 @@ describe('SubagentControlService.sendMessage', () => {
|
||||
|
||||
it('kills a cold-resume activation during descriptor lookup without starting child work', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('first'), textResponse('never used')])
|
||||
const started = ctx.subagentControl.startContinuable(startSpec(parent))
|
||||
const started = ctx.subagents.startContinuable(startSpec(parent))
|
||||
await waitTerminal(ctx, started.taskId, parent)
|
||||
|
||||
// Make the persistence load hang until the kill lands.
|
||||
@@ -686,7 +691,7 @@ describe('SubagentControlService.sendMessage', () => {
|
||||
|
||||
it('admits one process-local activation per child: a second send during resume load steers or fails, never duplicates', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('first'), textResponse('resumed answer')])
|
||||
const started = ctx.subagentControl.startContinuable(startSpec(parent))
|
||||
const started = ctx.subagents.startContinuable(startSpec(parent))
|
||||
await waitTerminal(ctx, started.taskId, parent)
|
||||
|
||||
const realLoad = ctx.sessionPersistence.load.bind(ctx.sessionPersistence)
|
||||
@@ -715,15 +720,15 @@ describe('service disposal with live activations', () => {
|
||||
it('cancels and settles a starting activation on service disposal instead of stranding it', async () => {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-control-hmr-'))
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-continuation-hmr-'))
|
||||
roots.push(root)
|
||||
await ctx.plugin(JsonlSessionPersistence, { root })
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SubagentService)
|
||||
const subagentsFiber = await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(LocalTaskService)
|
||||
await ctx.plugin(ToolTasks, {})
|
||||
// A provider that stays pending until its signal aborts, so the activation
|
||||
// is observably mid-start when the control service is disposed.
|
||||
// is observably mid-start when the subagent service is disposed.
|
||||
let sawAbort = false
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'pending',
|
||||
@@ -737,19 +742,17 @@ describe('service disposal with live activations', () => {
|
||||
}),
|
||||
resume: () => Promise.reject(new Error('unreachable')),
|
||||
})
|
||||
const controlFiber = await ctx.plugin(SubagentControlService)
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter([]))
|
||||
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const control = ctx.get('subagentControl')!
|
||||
const started = control.startContinuable({
|
||||
const started = ctx.subagents.startContinuable({
|
||||
provider: 'pending',
|
||||
label: 'will be interrupted',
|
||||
request: { prompt: message('go'), parent },
|
||||
})
|
||||
// LocalTaskService keeps the producer Task; the disposing control service must
|
||||
// LocalTaskService keeps the producer Task; the disposing subagent service must
|
||||
// cancel its activation and await settlement rather than strand it.
|
||||
await controlFiber.dispose()
|
||||
await subagentsFiber.dispose()
|
||||
expect(sawAbort).toBe(true)
|
||||
const snapshot = await waitTerminal(ctx, started.taskId, parent)
|
||||
expect(snapshot.status).toBe('killed')
|
||||
@@ -125,6 +125,17 @@ describe('SubagentService', () => {
|
||||
})).rejects.toMatchObject({ code: 'UNSUPPORTED_CAPABILITY' })
|
||||
})
|
||||
|
||||
it('rejects Task-backed continuation operations when their runtime services are absent', async () => {
|
||||
const { subagents } = await service()
|
||||
expect(() => {
|
||||
subagents.startContinuable({
|
||||
provider: 'unused',
|
||||
label: 'work',
|
||||
request: baseRequest(),
|
||||
})
|
||||
}).toThrow(expect.objectContaining({ code: 'CONTINUATION_UNAVAILABLE' }))
|
||||
})
|
||||
|
||||
it.each([
|
||||
['outputSchema', { outputSchema: { type: 'object', properties: {} } }],
|
||||
['depthLimit', { maxDepth: 1 }],
|
||||
|
||||
@@ -26,6 +26,12 @@
|
||||
{
|
||||
"path": "../../core/scope"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../tasks/tasks"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# @deepseek-ai/dsh-tool-subagent-control
|
||||
|
||||
The globally named `send_message` tool: a thin adapter over `ctx.subagentControl.sendMessage()`. Provider-bound `@deepseek-ai/dsh-tool-subagent` instances register distinct delegation tools per transport; this separately loaded package registers the one shared control tool, so multiple delegation tools never register duplicate global controls.
|
||||
The optional, globally named `send_message` tool: a thin adapter over `ctx.subagents.sendMessage()`. Provider-bound `@deepseek-ai/dsh-tool-subagent` instances register distinct delegation tools per transport; this separately loaded package registers one shared follow-up tool, so multiple delegation tools never register duplicate global controls. Its presence does not determine whether a delegation tool starts continuable work.
|
||||
|
||||
The tool performs no lifecycle routing. It attributes every follow-up as `{ kind: 'coordinator', senderSessionId: parent.id }`; the control service preserves that source while deciding between live delivery to the running activation's existing Task and a fresh Task that cold-resumes the durable child. The tool renders which route was taken and the relevant Task id. A control-service throw becomes an errored tool result stating the message was not delivered.
|
||||
The tool performs no lifecycle routing. It attributes every follow-up as `{ kind: 'coordinator', senderSessionId: parent.id }`; the subagent service preserves that source while deciding between live delivery to the running activation's existing Task and a fresh Task that cold-resumes the durable child. The tool renders which route was taken and the relevant Task id. A delivery failure becomes an errored tool result stating the message was not delivered.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tool-subagent-control",
|
||||
"description": "Globally named send_message tool over the continuable-subagent control service",
|
||||
"description": "Globally named send_message tool over ctx.subagents continuations",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -30,7 +30,7 @@
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent-control": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
@@ -44,7 +44,6 @@
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-control": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-spawn": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks-local": "workspace:^",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* The globally named `send_message` tool: a thin model-facing adapter over
|
||||
* `ctx.subagentControl.sendMessage()`. It performs no lifecycle routing of its
|
||||
* own — steer-or-resume orchestration belongs to the control service — and it
|
||||
* `ctx.subagents.sendMessage()`. It performs no lifecycle routing of its
|
||||
* own — steer-or-resume orchestration belongs to the subagent service — and it
|
||||
* lives apart from the provider-bound `@deepseek-ai/dsh-tool-subagent`
|
||||
* instances so multiple delegation tools share one control tool.
|
||||
* @module @deepseek-ai/dsh-tool-subagent-control
|
||||
@@ -11,14 +11,14 @@ import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-subagent-control'
|
||||
import type {} from '@deepseek-ai/dsh-subagent'
|
||||
|
||||
export const name = 'tool-subagent-control'
|
||||
export const inject = ['tools', 'subagentControl']
|
||||
export const inject = ['tools', 'subagents']
|
||||
|
||||
/**
|
||||
* Register the `send_message` tool.
|
||||
* @param ctx - context carrying the tool registry and the control service.
|
||||
* @param ctx - context carrying the tool registry and subagent service.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.tools.register(defineTool({
|
||||
@@ -67,7 +67,7 @@ export function apply(ctx: Context): void {
|
||||
throw new Error('send_message requires a calling agent (exec.agent was undefined)')
|
||||
}
|
||||
const message: ContentBlock[] = [{ type: 'text', text: args.message }]
|
||||
const result = ctx.subagentControl.sendMessage(
|
||||
const result = ctx.subagents.sendMessage(
|
||||
parent,
|
||||
SessionId(args.subagent_id),
|
||||
message,
|
||||
|
||||
@@ -16,7 +16,7 @@ export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this model-facing adapter has no independent lifecycle stream; delivery
|
||||
* and activation relations are owned by the control service it calls.
|
||||
* and activation relations are owned by the subagent service it calls.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import SubagentControlService from '@deepseek-ai/dsh-subagent-control'
|
||||
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
|
||||
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
|
||||
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
|
||||
@@ -34,7 +33,6 @@ async function setup(script: ConstructorParameters<typeof MockAdapter>[0]) {
|
||||
await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
|
||||
await ctx.plugin(LocalTaskService)
|
||||
await ctx.plugin(ToolTasks, {})
|
||||
await ctx.plugin(SubagentControlService)
|
||||
await ctx.plugin(tool)
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
|
||||
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
|
||||
@@ -68,7 +66,7 @@ describe('dsh-tool-subagent-control', () => {
|
||||
|
||||
it('cold-resumes a settled child and renders the started route with its task id', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('first answer'), textResponse('second answer')])
|
||||
const started = ctx.subagentControl.startContinuable({
|
||||
const started = ctx.subagents.startContinuable({
|
||||
provider: 'spawn',
|
||||
label: 'work',
|
||||
request: { prompt: [{ type: 'text', text: 'child task' }], parent },
|
||||
@@ -98,10 +96,10 @@ describe('dsh-tool-subagent-control', () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
let steered: string | undefined
|
||||
let source: unknown
|
||||
// Reach past the tool into the control service to fake a running route
|
||||
// Reach past the tool into the subagent service to fake a running route
|
||||
// deterministically: the tool is a thin adapter, so its steered wording is
|
||||
// what this test pins.
|
||||
ctx.subagentControl.sendMessage = async (agent, _childId, message, messageSource) => {
|
||||
ctx.subagents.sendMessage = async (agent, _childId, message, messageSource) => {
|
||||
steered = (message[0] as { text: string }).text
|
||||
source = messageSource
|
||||
return { route: 'steered', taskId: ctx.tasks.list(agent)[0]?.id ?? ('subagent-9' as never) }
|
||||
@@ -116,7 +114,7 @@ describe('dsh-tool-subagent-control', () => {
|
||||
expect(text(result)).toBe('message delivered to running task subagent-9')
|
||||
})
|
||||
|
||||
it('reports a control-service failure as an errored, not-delivered result', async () => {
|
||||
it('reports a delivery failure as an errored, not-delivered result', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const result = await callTool(ctx, 'send_message', {
|
||||
subagent_id: 'no-such-child',
|
||||
@@ -148,7 +146,6 @@ describe('dsh-tool-subagent-control', () => {
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(LocalTaskService)
|
||||
await ctx.plugin(SubagentControlService)
|
||||
const fiber = await ctx.plugin(tool)
|
||||
expect(ctx.tools.schemas().some(schema => schema.name === 'send_message')).toBe(true)
|
||||
await fiber.dispose()
|
||||
@@ -158,7 +155,7 @@ describe('dsh-tool-subagent-control', () => {
|
||||
it('has the namespace-plugin export shape (no stray default)', () => {
|
||||
expect('default' in tool).toBe(false)
|
||||
expect(tool.name).toBe('tool-subagent-control')
|
||||
expect(tool.inject).toEqual(['tools', 'subagentControl'])
|
||||
expect(tool.inject).toEqual(['tools', 'subagents'])
|
||||
expect(typeof tool.apply).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../subagent-control"
|
||||
"path": "../subagent"
|
||||
},
|
||||
{
|
||||
"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: 7d32da3c974361eb5e58cdb2ee5be756383ad3d1
|
||||
README.zh.md: eadc168fd07701b3e3d9600b3fe69bd8b22e235a
|
||||
README.md: 9d60363602a9825730984700a7fe987d911e1cac
|
||||
README.zh.md: 5964c38bd847c1c14cac9decdd913ca65c39e8f3
|
||||
|
||||
@@ -10,7 +10,7 @@ Each plugin instance binds one `provider` to one `toolName`; the model receives
|
||||
|
||||
A foreground call passes the execution signal through startup and execution, awaits `run.result`, and always awaits `run.dispose()` before returning. Only `completed` returns the canonical `{ kind: 'foreground', runId, output: JsonValue[] }`, rendered as the same final text; abort, refusal, token limit, and other failures become errored tool results without partial output.
|
||||
|
||||
With `run_in_background: true`, the route follows the provider's continuation capability and returns canonical `{ kind: 'background', taskId, subagentId? }`. A resumable provider (spawn, fork) delegates to `ctx.subagentControl.startContinuable()`, which owns the durable child id, descriptor snapshot, Task registration, and settle-then-dispose ordering; the result includes `subagentId`, renders as `started subagent <childId> as task <taskId>`, and accepts follow-up messages through the global `send_message` tool. A one-shot provider (ACP) keeps the plain parent-owned task, omits `subagentId`, and renders as `started background subagent task <id>`. Either way a task-owned signal covers pending startup and the child after the starting call returns; `task_kill` and owner disposal abort it, settlement awaits startup rollback or child disposal, and completed final text, abort to `killed`, and other failures to `failed` map identically. The task has no incremental read; generic task tools own later status, collection, cancellation, and notices. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md) and the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md).
|
||||
With `run_in_background: true`, `backgroundMode` selects the route. `one-shot` registers a plain parent-owned Task and returns canonical `{ kind: 'background', taskId }`, rendered as `started background subagent task <id>`, even when the provider supports resume. `continuable` requires `provider.resume`, calls `ctx.subagents.startContinuable()`, and returns `{ kind: 'background', taskId, subagentId }`, rendered as `started subagent <childId> as task <taskId>`. The optional global `send_message` tool is not required to start continuable work. Either route uses a Task-owned signal, settles only after startup rollback or run disposal, and maps completed final text, abort → `killed`, and other failures → `failed`. Generic task tools own later status, collection, cancellation, and notices. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), and the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md).
|
||||
|
||||
`toolFilter` changes the child's global tool layer but is not a parent-derived authority ceiling. See the [agent-scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals).
|
||||
|
||||
@@ -21,6 +21,7 @@ With `run_in_background: true`, the route follows the provider's continuation ca
|
||||
| `provider` (required) | Provider name (`spawn`, `fork`, `acp`, ...). |
|
||||
| `toolName` | Model-facing name, default `subagent`; distinct for every loaded instance. |
|
||||
| `enableRunInBackground` | Exposes background mode, default `true`; disabling also rejects forced background calls. |
|
||||
| `backgroundMode` | Background lifecycle policy, default `one-shot`. `continuable` requires provider resume support and returns a durable child id; it does not require the follow-up tool. |
|
||||
| `agentOptions` | Provider-specific child `provider`, `model`, and positive `maxTokens`; the in-process provider treats explicit values as overrides of inherited parent options. |
|
||||
| `persona` | Per-child persona; requires provider `persona` capability. |
|
||||
| `toolFilter` | Per-child global-tool restriction; requires `toolFilter` capability. |
|
||||
@@ -64,7 +65,7 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Start returns exactly `started subagent <childId> as task <taskId>` on a resumable provider, or `started background subagent task <id>` on a one-shot provider. The generic task surface provides later status, final output, cancellation responses, and notices; `send_message` (from `dsh-tool-subagent-control`) delivers follow-ups to a continuable child.
|
||||
Start returns exactly `started subagent <childId> as task <taskId>` in configured continuable mode, or `started background subagent task <id>` in configured one-shot mode. The generic task surface provides later status, final output, cancellation responses, and notices; an independently loaded `send_message` tool delivers follow-ups to a continuable child.
|
||||
|
||||
#### Token effect
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
前台调用会让执行信号贯穿启动和执行,等待 `run.result`,并且在返回前总会等待 `run.dispose()`。只有 `completed` 会返回规范值 `{ kind: 'foreground', runId, output: JsonValue[] }`,并渲染为相同的最终文本;中止、拒绝、token 上限和其他失败都会变成出错的工具结果,不包含局部输出。
|
||||
|
||||
设置 `run_in_background: true` 后,路由遵循提供方的继续功能,并返回规范值 `{ kind: 'background', taskId, subagentId? }`。可恢复提供方(spawn、fork)会委派给 `ctx.subagentControl.startContinuable()`,由它拥有持久化子 agent ID、描述符快照、Task 注册和先结算后 dispose(资源释放)的顺序;结果包含 `subagentId`,渲染为 `started subagent <childId> as task <taskId>`,并通过全局 `send_message` 工具接收后续消息。一次性提供方 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)。
|
||||
设置 `run_in_background: true` 后,由 `backgroundMode` 选择路由。`one-shot` 会注册普通的父级所有 Task,并返回规范值 `{ kind: 'background', taskId }`;即使提供方支持恢复,也会渲染为 `started background subagent task <id>`。`continuable` 要求 `provider.resume`,调用 `ctx.subagents.startContinuable()`,并返回 `{ kind: 'background', taskId, subagentId }`,渲染为 `started subagent <childId> as task <taskId>`。启动可继续工作不要求加载可选的全局 `send_message` 工具。两条路由都使用 Task 所有的信号,只在启动回滚或 run dispose(资源释放)之后结算,并把完成的最终文本映射为完成、中止映射为 `killed`、其他失败映射为 `failed`。通用任务工具负责后续状态、收集、取消和通知。见[后台 subagent Agent 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)和[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。
|
||||
|
||||
`toolFilter` 会改变子 agent 的全局工具层,但不是从父级派生的权限上限。见 [agent 作用域的安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
| `provider`(必填) | 提供方名称(`spawn`、`fork`、`acp` 等)。 |
|
||||
| `toolName` | 面向模型的名称,默认 `subagent`;每个已加载实例必须不同。 |
|
||||
| `enableRunInBackground` | 公开后台模式,默认 `true`;禁用时也会拒绝强制后台调用。 |
|
||||
| `backgroundMode` | 后台生命周期策略,默认 `one-shot`。`continuable` 要求提供方支持恢复并返回持久化子 agent ID;它不要求加载后续消息工具。 |
|
||||
| `agentOptions` | 传给具体提供方的子 agent `provider`、`model` 和正整数 `maxTokens`;进程内提供方会用显式值覆盖继承的父级选项。 |
|
||||
| `persona` | 每个子 agent 独立的 persona;要求提供方具备 `persona` 能力。 |
|
||||
| `toolFilter` | 每个子 agent 独立的全局工具限制;要求提供方具备 `toolFilter` 能力。 |
|
||||
@@ -64,7 +65,7 @@
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
对于可恢复提供方,启动时精确返回 `started subagent <childId> as task <taskId>`;对于一次性提供方,则返回 `started background subagent task <id>`。通用任务接口提供后续状态、最终输出、取消响应和通知;`send_message`(来自 `dsh-tool-subagent-control`)会把后续消息交付给可继续子 agent。
|
||||
在已配置的 continuable 模式下,启动时精确返回 `started subagent <childId> as task <taskId>`;在已配置的 one-shot 模式下,则返回 `started background subagent task <id>`。通用任务接口提供后续状态、最终输出、取消响应和通知;独立加载的 `send_message` 工具会把后续消息交付给可继续子 agent。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent-control": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tasks": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
@@ -48,7 +47,6 @@
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-control": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-spawn": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks": "workspace:^",
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
/**
|
||||
* Model-facing delegation through one configured `ctx.subagents` provider.
|
||||
* Provider lifecycle controls tool registration and context-sensitive schema
|
||||
* wording. Foreground calls always dispose the run after collection. A
|
||||
* background call's route follows the provider's continuation capability:
|
||||
* a provider with `resume` delegates to `ctx.subagentControl`, which owns the
|
||||
* durable child id, its descriptor, and the Task-backed activation lifecycle;
|
||||
* a provider without it (ACP) keeps the one-shot background task.
|
||||
* wording. Foreground calls always dispose the run after collection.
|
||||
* Background policy is selected by this plugin's configuration: one-shot
|
||||
* calls own a plain Task, while continuable calls use
|
||||
* `ctx.subagents.startContinuable()`.
|
||||
* @module @deepseek-ai/dsh-tool-subagent
|
||||
*/
|
||||
|
||||
@@ -15,9 +14,8 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { AgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
||||
import { assertSubagentMaxDepth } from '@deepseek-ai/dsh-subagent'
|
||||
import { assertSubagentMaxDepth, settleRun } from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubagentProvider, SubagentResult, SubagentRun } from '@deepseek-ai/dsh-subagent'
|
||||
import { settleRun } from '@deepseek-ai/dsh-subagent-control'
|
||||
import type { TaskOutcome } from '@deepseek-ai/dsh-tasks'
|
||||
|
||||
export const name = 'tool-subagent'
|
||||
@@ -37,6 +35,12 @@ export interface Config {
|
||||
* parameter and reject forced background calls.
|
||||
*/
|
||||
enableRunInBackground?: boolean
|
||||
/**
|
||||
* Background execution policy (default `one-shot`). `continuable` requires
|
||||
* a provider with persisted resume support and returns both child and Task
|
||||
* ids; follow-up adapters remain independently optional.
|
||||
*/
|
||||
backgroundMode?: 'one-shot' | 'continuable'
|
||||
/**
|
||||
* Agent options applied to every child; omitted fields use child-loop defaults.
|
||||
*/
|
||||
@@ -73,6 +77,7 @@ export const Config: z<Config> = z.object({
|
||||
provider: z.string().required(),
|
||||
toolName: z.string().default('subagent'),
|
||||
enableRunInBackground: z.boolean().default(true),
|
||||
backgroundMode: z.union(['one-shot', 'continuable'] as const).default('one-shot'),
|
||||
// Prevent Schemastery from materializing omitted agentOptions as `{}`.
|
||||
agentOptions: z.object({
|
||||
provider: z.string(),
|
||||
@@ -191,17 +196,19 @@ export function apply(ctx: Context, config: Config): void {
|
||||
}
|
||||
const wording = providerWording(provider.inheritsParentContext)
|
||||
const backgroundEnabled = config.enableRunInBackground !== false
|
||||
// The provider's continuation capability decides the background route: a
|
||||
// resumable provider starts durable, follow-up-able children through the
|
||||
// control service, while a one-shot provider (ACP) keeps the plain task.
|
||||
const continuable = provider.resume !== undefined
|
||||
const continuable = (config.backgroundMode ?? 'one-shot') === 'continuable'
|
||||
if (continuable && provider.resume === undefined) {
|
||||
throw new Error(
|
||||
`tool-subagent: provider "${provider.name}" does not support \`backgroundMode: continuable\``,
|
||||
)
|
||||
}
|
||||
disposeTool = ctx.tools.register(defineTool({
|
||||
name: config.toolName ?? 'subagent',
|
||||
description: wording.description + (backgroundEnabled
|
||||
? continuable
|
||||
? ' Set `run_in_background: true` to start a continuable background subagent: you receive its'
|
||||
+ ' subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`,'
|
||||
+ ' and send follow-up messages with `send_message`.'
|
||||
+ ' stable subagent id and current task id; collect the result with `task_output` and stop it with'
|
||||
+ ' `task_kill`.'
|
||||
: ' Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.'
|
||||
: ''),
|
||||
parameters: {
|
||||
@@ -220,7 +227,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
type: 'boolean' as const,
|
||||
description: continuable
|
||||
? 'Run as a continuable background subagent and return its subagent and task ids; '
|
||||
+ 'collect with task_output, stop with task_kill, follow up with send_message.'
|
||||
+ 'collect with task_output or stop with task_kill.'
|
||||
: 'Run as a background task and return its id; collect with task_output or stop with task_kill.',
|
||||
},
|
||||
} : {},
|
||||
@@ -281,23 +288,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
throw new Error('run_in_background is disabled for this tool instance (enableRunInBackground: false)')
|
||||
}
|
||||
if (continuable) {
|
||||
const control = ctx.get('subagentControl')
|
||||
if (control === undefined) {
|
||||
throw new Error('continuable background subagents unavailable: load @deepseek-ai/dsh-subagent-control and @deepseek-ai/dsh-tool-tasks')
|
||||
}
|
||||
// The schema above tells the model to follow up with
|
||||
// `send_message`; starting a durable child the model cannot
|
||||
// continue would make that advertisement false. Sibling load order
|
||||
// is undetermined at mount, so the check lives at the operation,
|
||||
// and it resolves in the CALLER's scope so a restriction that
|
||||
// removes send_message from this agent also blocks the start.
|
||||
if (ctx.tools.get('send_message', parent) === undefined) {
|
||||
throw new Error('continuable background subagents unavailable: load @deepseek-ai/dsh-tool-subagent-control (the advertised send_message tool is not registered)')
|
||||
}
|
||||
// The control service owns the durable child id, descriptor
|
||||
// snapshot, Task registration, and settle-then-dispose ordering; a
|
||||
// synchronous validation failure rejects the call with no Task.
|
||||
const started = control.startContinuable({
|
||||
const started = ctx.subagents.startContinuable({
|
||||
provider: config.provider,
|
||||
label: args.description,
|
||||
request,
|
||||
|
||||
@@ -15,9 +15,7 @@ import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
||||
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
|
||||
import SubagentControlService from '@deepseek-ai/dsh-subagent-control'
|
||||
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
|
||||
import * as ToolSubagentControl from '@deepseek-ai/dsh-tool-subagent-control'
|
||||
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
|
||||
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import * as mock from './scripted-provider.ts'
|
||||
@@ -70,6 +68,21 @@ function text(result: { content: { type: string; text?: string }[] }): string {
|
||||
}
|
||||
|
||||
describe('dsh-tool-subagent', () => {
|
||||
it('rejects continuable background policy when the configured provider cannot resume', async () => {
|
||||
let failure: unknown
|
||||
try {
|
||||
await setup({
|
||||
provider: 'mock',
|
||||
backgroundMode: 'continuable',
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
failure = error
|
||||
}
|
||||
expect(String(failure)).toContain(
|
||||
'provider "mock" does not support `backgroundMode: continuable`',
|
||||
)
|
||||
})
|
||||
|
||||
it('registers a `subagent` tool that delegates to the configured provider and returns its output', async () => {
|
||||
const ctx = await setup({ provider: 'mock' }, { reply: 'child says hi' })
|
||||
const result = await callSubagent(ctx, { description: 'do a thing', prompt: 'go research X' })
|
||||
@@ -655,6 +668,47 @@ describe('dsh-tool-subagent background mode', () => {
|
||||
return ctx
|
||||
}
|
||||
|
||||
it('keeps a resumable provider one-shot when backgroundMode selects one-shot', async () => {
|
||||
const ctx = await backgroundSetup({ provider: 'mock' })
|
||||
const parent = ownerAgent(ctx, 'sess-parent')
|
||||
let resumeCalls = 0
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'resumable',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: async request => ({
|
||||
id: SessionId('one-shot-child'),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve({
|
||||
output: [{ type: 'text', text: 'one-shot answer' }],
|
||||
stopReason: request.signal.aborted ? 'aborted' : 'completed',
|
||||
}),
|
||||
dispose: () => Promise.resolve(),
|
||||
}),
|
||||
resume: async () => {
|
||||
resumeCalls += 1
|
||||
throw new Error('one-shot policy must not resume')
|
||||
},
|
||||
})
|
||||
tool.apply(ctx, {
|
||||
provider: 'resumable',
|
||||
toolName: 'subagent_resumable',
|
||||
backgroundMode: 'one-shot',
|
||||
maxDepth: 'provider-managed',
|
||||
})
|
||||
|
||||
const started = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('resumable-one-shot'),
|
||||
name: 'subagent_resumable',
|
||||
arguments: { description: 'work', prompt: 'go', run_in_background: true },
|
||||
agent: parent,
|
||||
})
|
||||
|
||||
expect(text(started)).toBe('started background subagent task subagent-1')
|
||||
expect(resumeCalls).toBe(0)
|
||||
})
|
||||
|
||||
it('returns a task id immediately and the answer is collected through task_output', async () => {
|
||||
const ctx = await backgroundSetup({ provider: 'mock', agentOptions: { model: 'child-model' } }, { reply: 'background answer' })
|
||||
const parent = ownerAgent(ctx, 'sess-parent')
|
||||
@@ -825,8 +879,8 @@ describe('dsh-tool-subagent continuable background mode', () => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** Boot the real continuable stack: loop, persistence, spawn, tasks, control. */
|
||||
async function continuableSetup(options: { controlTool?: boolean } = {}) {
|
||||
/** Boot the real continuable stack without any model-facing follow-up adapter. */
|
||||
async function continuableSetup() {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'dsh-tool-subagent-continuable-'))
|
||||
@@ -837,9 +891,7 @@ describe('dsh-tool-subagent continuable background mode', () => {
|
||||
await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
|
||||
await ctx.plugin(LocalTaskService)
|
||||
await ctx.plugin(ToolTasks, {})
|
||||
await ctx.plugin(SubagentControlService)
|
||||
if (options.controlTool !== false) await ctx.plugin(ToolSubagentControl)
|
||||
await ctx.plugin(tool, { provider: 'spawn' })
|
||||
await ctx.plugin(tool, { provider: 'spawn', backgroundMode: 'continuable' })
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter([
|
||||
textResponse('continuable answer'),
|
||||
]))
|
||||
@@ -847,10 +899,10 @@ describe('dsh-tool-subagent continuable background mode', () => {
|
||||
return { ctx, parent }
|
||||
}
|
||||
|
||||
it('a resumable provider advertises send_message and returns both ids', async () => {
|
||||
it('starts a continuable child and returns both ids without send_message', async () => {
|
||||
const { ctx, parent } = await continuableSetup()
|
||||
const schema = ctx.tools.schemas().find(s => s.name === 'subagent')!
|
||||
expect(schema.description).toContain('send_message')
|
||||
expect(schema.description).not.toContain('send_message')
|
||||
|
||||
const started = await callSubagent(
|
||||
ctx,
|
||||
@@ -869,56 +921,6 @@ describe('dsh-tool-subagent continuable background mode', () => {
|
||||
expect(loaded.events.some(event => event.type === 'subagent/descriptor')).toBe(true)
|
||||
})
|
||||
|
||||
it('fails loud when the provider is resumable but the control service is not loaded', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(SubagentService)
|
||||
// A resumable provider without ctx.subagentControl.
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'resumable',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: () => { throw new Error('unreachable') },
|
||||
resume: () => { throw new Error('unreachable') },
|
||||
})
|
||||
await ctx.plugin(tool, { provider: 'resumable', maxDepth: 'provider-managed' })
|
||||
|
||||
const result = await callSubagent(ctx, { description: 'd', prompt: 'p', run_in_background: true })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('load @deepseek-ai/dsh-subagent-control')
|
||||
})
|
||||
|
||||
it('fails loud when the advertised send_message tool is not registered', async () => {
|
||||
// The schema tells the model to follow up with send_message; starting a
|
||||
// durable child the model cannot continue would make that false.
|
||||
const { ctx, parent } = await continuableSetup({ controlTool: false })
|
||||
const result = await callSubagent(
|
||||
ctx,
|
||||
{ description: 'd', prompt: 'p', run_in_background: true },
|
||||
{ agent: parent },
|
||||
)
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('load @deepseek-ai/dsh-tool-subagent-control')
|
||||
// Nothing was started: no Task exists for the parent.
|
||||
expect(ctx.tasks.list(parent)).toEqual([])
|
||||
})
|
||||
|
||||
it('resolves send_message availability in the CALLER scope, not the global registry', async () => {
|
||||
// A scoped restriction that keeps this delegation tool but removes
|
||||
// send_message means this agent cannot execute the promised follow-up;
|
||||
// the availability check must see the caller's surface.
|
||||
const { ctx, parent } = await continuableSetup()
|
||||
parent.ctx.tools.restrict({ deny: ['send_message'] })
|
||||
const result = await callSubagent(
|
||||
ctx,
|
||||
{ description: 'd', prompt: 'p', run_in_background: true },
|
||||
{ agent: parent },
|
||||
)
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('load @deepseek-ai/dsh-tool-subagent-control')
|
||||
expect(ctx.tasks.list(parent)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('background preflight failure (no orphaned child, by construction)', () => {
|
||||
|
||||
@@ -29,9 +29,6 @@
|
||||
{
|
||||
"path": "../subagent"
|
||||
},
|
||||
{
|
||||
"path": "../subagent-control"
|
||||
},
|
||||
{
|
||||
"path": "../../tasks/tasks"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user