Merge origin/master into codex/todo-goal-queue-layout

This commit is contained in:
kingwl
2026-08-02 14:34:13 +08:00
602 changed files with 24996 additions and 2677 deletions

View File

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

View File

@@ -10,73 +10,110 @@ The family separates the stable interface from implementations and model-facing
| Package | Role |
|---|---|
| `@deepseek-ai/dsh-subagent` | Provider registry, request/result types, and lifecycle events. |
| `@deepseek-ai/dsh-subagent-spawn` | Fresh in-process child. |
| `@deepseek-ai/dsh-subagent-fork` | In-process child seeded with completed parent turns. |
| `@deepseek-ai/dsh-subagent-acp` | Fresh out-of-process ACP child. |
| `@deepseek-ai/dsh-tool-subagent` | Model-facing tool over one configured provider. |
| `@deepseek-ai/dsh-subagent` | Provider registry, request/result/descriptor types, lifecycle events, and continuable-child orchestration. |
| `@deepseek-ai/dsh-subagent-spawn` | Fresh in-process child; supports continuable children. |
| `@deepseek-ai/dsh-subagent-fork` | In-process child seeded with completed parent turns; supports continuable children. |
| `@deepseek-ai/dsh-subagent-acp` | Fresh out-of-process ACP child (one-shot). |
| `@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. |
| `@deepseek-ai/dsh-tool-subagent-report` | Child-scoped return channel to the direct parent. |
Multiple providers may coexist under different names. This lets a deployment expose, for example, a cheap in-process child and an isolated ACP child without changing the service contract.
## Service API
`SubagentService` has four main operations:
`SubagentService` has these operations:
| Member | Meaning |
|---|---|
| `registerProvider(provider)` | Register one trusted same-process implementation by name. Registration is effect-scoped; removing it prevents new starts but does not revoke runs already returned to callers. Duplicate names fail loud. |
| `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. |
| `start(name, request)` | Validate an ordinary caller request, resolve its detached `one-shot` descriptor, then await the provider until a real one-shot child is published. Fulfillment returns a holder-owned `SubagentRun`; rejection means the provider has already cleaned every unpublished startup resource, while post-publication turn or infrastructure faults settle through the run. Continuable children never enter through this operation. |
| `startContinuable(spec)` | Establish one durable continuable child and deliver its initial prompt. Resolves with `{ childId, messageId }` when the child's inbox accepts that prompt, without waiting for the turn to start or for the message to reach the Session log; any earlier failure rejects with no ids and rolls the child back entirely. Requires `ctx.agents`, session persistence, and a provider with the `prepareContinuable` capability. |
| `followup(parent, childId, content, { source, signal })` | Deliver one later message from the exact live direct parent as the child's next FIFO turn, matching `Agent.followup()` terminology, and return the accepted `MessageId`. A resident child's inbox accepts it directly (waking a waiting Activation); an absent one cold-resumes from its persisted Session. Requires `ctx.agents`; cold resume also requires session persistence. |
| `reportFrom(child, content, { delivery, signal })` | Deliver one selected message from the exact live continuable child to its exact live direct parent and return the accepted stable `MessageId`. Quiet delivery injects context; waking delivery submits one later parent turn. |
| `registerContinuableSetup(contribution)` | Compose an optional deployment capability into each continuable child's unpublished scope, with immediate revocation from resident children. |
| `drainContinuableDescendants(parents)` | Close admission below exact live host-owned parent Agents, stop only their visible continuable descendants, await materializations admitted below those roots through publication or rollback, then release the selected forests child-first. The cutoff lasts until each exact parent leaves the registry; unrelated parent forests and manager-wide admission remain live. |
| `listChildren(parentSessionId, signal?)` | List direct session-backed subagents with their `one-shot`/`continuable` mode, `running`/`inactive` activity, origin-classified one-level `hasChildren` hint, and per-child diagnostics, in stable trace order without loading or resuming them. Requires session query; it does not require `ctx.agents` or the continuation manager. |
`SubagentStartRequest.signal` is required and is the canonical cancellation channel. An abort before publication makes `start()` reject after rollback; an abort after publication cancels the live child. The request may also select a model, require structured output, cap delegation depth, restrict child tools, or set a child persona.
`SubagentStartRequest.label` is an optional short durable display label for a session-backed one-shot child. Model-facing delegation supplies its existing `description`; lower-level callers need not invent presentation metadata. Continuable starts always carry their own required label. `signal` is required and is the canonical cancellation channel for a one-shot `start`. An abort before publication makes `start()` reject after rollback; an abort after publication cancels the returned run's remaining turn work without hiding its id. The request may also select a model, require structured output, cap delegation depth, restrict child tools, or set a child persona. For a continuable start or follow-up, the caller signal owns lookup, materialization, and admission only until inbox acceptance; afterward the manager owns the Activation independently, so later caller cancellation neither cancels the accepted turn nor disposes the child.
Follow-up authority comes from the exact live direct parent recorded in the child's durable header. Cold resume checks that authority before reconstruction and again in the final no-await inbox-admission span, so a parent unregistered or replaced during materialization cannot authorize delivery. The `source` on a follow-up is durable provenance retained on the delivered message and grants no authority.
Same-process requests, descriptors, results, and event payloads are trusted typed values borrowed as immutable. The service does not clone or freeze them; serialization and hostile-input validation belong at actual process, worker, persistence, and model boundaries.
## Capabilities
Start-time features are advertised in `provider.capabilities` because the service must reject an unsupported request before child creation:
Start-time features are advertised in `provider.capabilities` because the service must reject an unsupported one-shot request before child creation:
- `outputSchema` — enforce a structured final result.
- `depthLimit` — enforce `maxDepth`.
- `toolFilter` — apply the requested child tool restriction.
- `persona` — apply a per-child persona.
Continuable creation is the optional `SubagentProvider.prepareContinuable?()` method: its presence is the capability check, so the service rejects a configured continuable start on a provider without it, while a provider that has it may still serve ordinary one-shot delegations. The method returns only a detached `ContinuableCreateSpec` (`{ seed? }`) — data, never a capability: it carries no Agent, `AgentHandle`, prompt delivery, result, disposal, or resume operation, because the continuation manager owns identity reservation, composition, Agent creation, prompt delivery, cold resume, ownership, and disposal after preparation. A one-shot `SubagentRun` represents one disposable foreground delegation with one result and no cold-resume operation.
## The durable descriptor
The seam owns the versioned `subagent/descriptor` session event vocabulary (`src/descriptor.ts`): `snapshotSubagentDescriptor()` validates and detaches the record before provider work, and `foldSubagentDescriptor()` validates the complete current-version payload before recovering it from a loaded child log. Every local session-backed start appends one descriptor with the provider name and lifecycle `mode`. A `one-shot` descriptor optionally carries the caller-owned durable display `label`; a `continuable` descriptor requires its durable creation label and additionally records resolved child `agentOptions.provider`/`model` and optional `persona`/`toolFilter` for cold resume. These are explicit fields, never the merge-extensible `AgentOptions` object, so an unrelated extension value cannot break continuation. The descriptor omits `subagentDepth` (the persisted header's `delegationDepth` is the monotone floor) and `outputSchema` (an Activation's result contract). The event is log-only: no `surfaceOp`, absent from model history, and retained by the append-only log across compaction. Malformed current-version payloads are corrupt; unsupported versions cannot be classified by this runtime.
## Delegation depth
The seam owns the depth vocabulary shared by implementations and consumers: the `AgentOptions.subagentDepth` declaration, `assertSubagentMaxDepth`, and `delegationDepthOf(agent)`. The persisted `SessionHeader.delegationDepth` is authoritative and monotone — runtime options may deepen the count but never lower it, so a resumed child cannot be re-counted as top-level.
Runtime features are optional methods on `SubagentRun`: `sendMessage?` steers a live child, while `resume?` asynchronously creates a continuation run. Method presence is the capability check.
`inheritsParentContext` is descriptive rather than enforceable. It says only whether the child sees completed parent conversation history (`fork` does; `spawn` and ACP do not), not whether it inherits tools, services, or authority.
## Ownership and lifecycle
## One-shot ownership and lifecycle
`provider.start(request): Promise<SubagentRun>` is the ownership-transfer boundary. Before fulfillment, the provider owns setup and must cancel, roll back, and quiesce partial resources on every failure. After fulfillment, the caller owns the run and must call `dispose()` on every path.
`provider.start(request): Promise<SubagentRun>` is the ownership-transfer boundary; the delegation tool also uses it inside its one-shot Task-backed background path. Before fulfillment, the provider owns setup and must cancel, roll back, and quiesce unpublished resources on every failure. After fulfillment, the caller owns the run and must call `dispose()` on every path; remaining prompt and turn work belongs to `SubagentRun.result`.
`SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. `dispose()` is idempotent, cancels remaining work, and waits for the child resources to quiesce.
`SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. `dispose()` is idempotent, cancels remaining work, and waits for both result settlement and child-resource quiescence. A result rejection remains on `result`; `dispose()` rejects only for an independent resource-release failure.
A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, exposes the exact child as `SubagentRun.localAgent`, and records `request.parent.session.id` in the child's `parentSession` header. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`.
A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, exposes the exact child as `SubagentRun.localAgent`, records `request.parent.session.id` in the child's `parentSession` header, and appends the resolved descriptor inside its initial turn. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`; without a local child session, their one-shot runs are not part of trace-backed enumeration.
The service emits `subagent/start` only after `start()` has fulfilled. It attaches the result observer before that synchronous notification, so even an already-settled child still produces `subagent/start` before `subagent/end`. The pair shares a service-minted `runId`; its `local` flag is snapshotted from the provider's exact `localAgent`, so observers never infer run identity or locality from reusable provider/session names.
## Continuable children and Activations
A continuable child has one durable Session and at most one process-local **Activation** — one residency epoch for a reconstructed child Agent, not a request, result, cancellation, or Task boundary. The Agent inbox is the only turn queue, so the continuation manager owns residency while the Agent loop owns all turn ordering and execution. No continuable path creates a Task or an intermediate result-bearing wrapper.
The manager derives three internal residency conditions from Agent quiescence and the owned-child set rather than maintaining a second state machine: running (an active admission, open turn, or waking inbox work), waiting (quiescent but still owning at least one undisposed child), and settled (quiescent with every owned child disposed, so the manager disposes the `AgentHandle` and removes the Activation). Every continuation message uses `Agent.followup()` and becomes one FIFO turn with no steering of the current turn. Routing depends only on residency: running enqueues, waiting wakes the same Agent, and an absent Activation cold-resumes a new one.
The manager reserves the child identity, resolves the durable descriptor, calls `ctx.agents.create()` (or `ctx.agents.resume()` for cold resume) through a private activation-owner scope, installs the returned `AgentHandle` in the Activation, establishes any continuable-parent ownership, and then submits the prompt. Cold resume never dispatches through a provider because the persisted Session already holds the initial prefix and the folded descriptor is the whole reconstruction input.
A continuation-managed parent Activation records each child Session id in an `ownedChildren` set before the child can run and disposes only after every owned child Activation completes `AgentHandle` disposal (child-first). Teardown propagates Agent cancellation top-down before awaiting slow descendants, while handle release remains child-first. Top-level and other non-continuation Agents have no Activation and stay outside this waiting graph. Final settlement awaits a best-effort `ctx.sessions.flush(child.session)` before handle disposal. A listener rejection is logged without failing the Activation because listener participation does not identify a persistence backend; the persisted state may therefore be missing or stale on resume.
## Lifecycle events
The service emits a `subagent/start`/`subagent/end` pair for each one-shot run and each resident continuable Activation epoch, so continuable children are observable with the same vocabulary as one-shot runs without exposing whether the manager materialized, woke, or cold-resumed them. For a one-shot start it attaches the result observer before the synchronous `subagent/start`, so even an already-settled child still produces `subagent/start` before `subagent/end`; a continuable epoch that fails before residency emits neither edge. The pair shares a service-minted `runId`; the `local` flag is snapshotted from the provider's exact `localAgent` (always true for a continuable child), so observers never infer run identity or locality from reusable provider/session names. The `provider` field is lifecycle provenance rather than a live-registry claim: an accepted one-shot run may settle after provider removal, and a cold-resumed epoch retains its descriptor's initial provider name without requiring that provider to be registered.
Run events are scoped to the delegating parent. Every listener is independently contained: a synchronous throw or rejected returned promise is logged without starving peer listeners or changing the run.
Provider additions and removals also emit `subagent/provider-added` and `subagent/provider-removed`. Consumers such as the model-facing tool use those events because Cordis may load sibling plugins concurrently; configuration order does not prove registration order.
Continuable children do not create `SubagentRun` or Tasks. The continuation manager directly owns one process-local Activation and retained `AgentHandle` per resident child Session, uses the Agent inbox as the only FIFO, and cold-resumes from the durable descriptor. Exact live direct-parent identity authorizes parent-to-child delivery. Exact live child identity authorizes reports; the manager derives the recipient from durable `parentSession`, and `MessageSource` remains provenance rather than authority.
`registerContinuableSetup()` lets optional packages add child-scoped capabilities without teaching the continuation manager their names. Contributions install synchronously before Activation publication, roll back with failed setup, and are released with the child scope. New grants wait for the next Activation, while contribution removal revokes every resident installation immediately.
## Collection model
The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. Background delegation does not change this seam; the consumer registers startup and the eventual run with the generic `ctx.tasks` runtime, then collection and cancellation use the shared task tools. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts.
The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool, whose generic status, collection, and cancellation tools own later interaction, and persists its model-supplied `description` as the optional display label. Continuable background delegation calls `ctx.subagents.startContinuable()` and returns only the durable child id; the child owns its own turns from inbox acceptance, so there is no Task, no result promise, and no public subagent cancellation — a caller sends later work with the `send_message` follow-up tool, and the durable child Session remains the source of the child's detailed output. The continuation manager exists only while `ctx.agents` is available, and session persistence is resolved per continuation operation. Independently, `listChildren()` resolves session query and dynamically imports its optional runtime only when called, then interprets a read-only live-preferred scan of all descriptor-bearing direct children without consulting the continuation manager, Agent registrations, Activations, or providers. Each healthy row derives its read-time `hasChildren` hint from traced direct-descendant headers carrying durable `origin: 'subagent'`; it does not read descendant event logs, and the descriptor-backed child catalog remains authoritative when expanded. Service consumers such as a UI can retain both modes and choose a fallback for an unlabeled one-shot child; the model-facing `list_agents` tool projects only `continuable` entries and maps service activity to its existing `running`/`complete` vocabulary. The scan forwards the caller's signal to cancellable trace and exact-read operations, checks cancellation around the remaining event-list read, and reports every observed abort as `SubagentError` code `CANCELLED`. 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 [durable catalog Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.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.
Continuable Activations await a best-effort final session flush without treating listener participation as durability confirmation. One-shot runs retain best-effort session checkpointing, so a completed one-shot child is discoverable after disposal only when its session actually reached persistence; the service does not invent a catalog entry from Task history when that checkpoint is absent.
## Model Experience
Indirectly, through `dsh-tool-subagent`, which renders provider-specific schemas and foreground or generic-background results while child working context remains child-only.
Indirectly, through `dsh-tool-subagent`, `dsh-tool-subagent-control`, and `dsh-tool-subagent-report`. The first owns delegation schemas, the second owns parent continuation and discovery, and the third contributes `report` only to continuable child scopes.
#### KV Cache effect
No direct invalidation; the named consumer owns any request-prefix changes.
No direct invalidation; the named consumers own any request-prefix changes.
## Known Limitations and Deferred Work
- **Runtime steering and continuation are seam-only capabilities** — `sendMessage` and `resume` have no model-facing consumer in the current tool.
- **ACP children remain one-shot and are not trace-enumerable** — an ACP run has no local child session in the parent's session corpus. An ACP `prepareContinuable` requires persisting the remote session id in provider-specific descriptor data and a per-child continuation advertisement, since ACP `loadSession` support is negotiated per child rather than established by the method's presence. Remote providers also require a separate Activation ownership contract with equivalent authenticated control and child-first quiescence before they support continuable children.
- **No host-user continuation** — `followup()` requires the exact live direct parent. A future host adapter needs a concrete authenticated interaction before the seam gains a separate user capability.
- **No current-turn steering** — continuable messages and waking reports enqueue later turns; neither redirects an open turn.
- **Process-local residency** — the Activation inbox and ownership graph do not coordinate two harness processes; concurrent access to one persistence store still requires a durable mailbox and cross-process lease protocol.
- **No replay of accepted-but-unlogged messages** — only messages written to the child Session log are reconstructable with their admitted provenance. A crash may lose an accepted initial prompt or follow-up that never reached the log; a later authorized message can cold-resume the child, but the lost message is not replayed automatically.
- **No durable report mailbox** — reports require a live direct parent and provide acceptance identity rather than exactly-once delivery or a read receipt.
- **Lifecycle events are observe-only** — a run-affecting `subagent/end` continuation or decision surface waits for a concrete consumer.

View File

@@ -2,81 +2,118 @@
[English](README.md) | 中文
subagent seam 允许一个 agent智能体通过具名提供方把工作委派给子 agent。调用方使用统一的服务 API`ctx.subagents`);提供方决定子 agent 在当前进程、另一进程中,还是通过未来的传输机制运行。
subagent seam 允许一个 agent智能体通过具名提供方把工作委派给子 agent。调用方使用统一的服务 API`ctx.subagents`);提供方决定子 agent 在当前进程、另一进程还是未来的传输之上运行。
## 包package角色
## 包角色
系列包把稳定接口与实现、面向模型的工具分开:
能力族把稳定接口与实现、面向模型的工具分开:
| 包 | 角色 |
|---|---|
| `@deepseek-ai/dsh-subagent` | 提供方注册表、请求/结果类型生命周期事件。 |
| `@deepseek-ai/dsh-subagent-spawn` | 全新的进程内子 agent。 |
| `@deepseek-ai/dsh-subagent-fork` | 以父 agent 已完成轮次作为初始内容的进程内子 agent。 |
| `@deepseek-ai/dsh-subagent-acp` | 全新的进程外 ACPAgent Client Protocol子 agent。 |
| `@deepseek-ai/dsh-tool-subagent` | 基于一个已配置提供方、面向模型的工具。 |
| `@deepseek-ai/dsh-subagent` | 提供方注册表、请求结果/描述符类型生命周期事件和可继续子 agent 编排。 |
| `@deepseek-ai/dsh-subagent-spawn` | 全新的进程内子 agent支持可继续子 agent。 |
| `@deepseek-ai/dsh-subagent-fork` | 以父 agent 已完成轮次作为初始内容的进程内子 agent支持可继续子 agent。 |
| `@deepseek-ai/dsh-subagent-acp` | 全新的进程外 ACPAgent Client Protocol子 agent(一次性)。 |
| `@deepseek-ai/dsh-tool-subagent` | 基于一个已配置提供方、面向模型的委派工具。 |
| `@deepseek-ai/dsh-tool-subagent-control` | 全局具名 `send_message` 后续操作工具。 |
| `@deepseek-ai/dsh-tool-subagent-report` | 子级作用域的返回通道,指向直接父级。 |
多个提供方可以使用不同名称共存。因此,部署可以同时公开低成本的进程内子 agent 和隔离的 ACP 子 agent而无需改变服务契约。
## 服务 API
`SubagentService` 有四个主要操作:
`SubagentService` 具有以下操作:
| 成员 | 含义 |
|---|---|
| `registerProvider(provider)` | 按名称注册一个可信的同进程实现。注册受 effect 作用域约束;移除注册会阻止新的启动,但不会撤销已返回给调用方的运行。重复名称会明确报错。 |
| `registerProvider(provider)` | 按名称注册一个可信的同进程实现。注册受 effect 作用域约束;移除注册会阻止新的启动,但不会撤销已返回给调用方的运行。重复名称会立即失败。 |
| `getProvider(name)` | 返回提供方;不存在时返回 `undefined`。 |
| `list()` | 按插入顺序返回提供方名称。 |
| `start(name, request)` | 校验请求的能力和语义值,然后等待提供方,直到真实子 agent 就绪。兑现时返回由持有方拥有的 `SubagentRun`;拒绝表示提供方已清理启动过程中取得的全部资源。 |
| `start(name, request)` | 校验普通调用方请求,解析其分离的 `one-shot` 描述符,然后等待提供方,直到真实的一次性子 agent 发布。兑现时返回由持有方拥有的 `SubagentRun`;拒绝表示提供方已清理所有未发布的启动资源,而发布后的轮次或基础设施故障会通过该 run 结算。可继续子 agent 绝不通过此操作进入。 |
| `startContinuable(spec)` | 建立一个持久化可继续子 agent并投递其初始提示词。子 agent 的 inbox 接受该提示词时,兑现为 `{ childId, messageId }`,无需等待轮次开始或消息写入 Session 日志;此前任何失败都会以无 id 拒绝,并完全回滚该子 agent。要求 `ctx.agents`、会话持久化以及具备 `prepareContinuable` 能力的提供方。 |
| `followup(parent, childId, content, { source, signal })` | 将来自确切在线直接父级的一条后续消息作为子 agent 的下一个 FIFO 轮次投递,术语与 `Agent.followup()` 一致,并返回被接受的 `MessageId`。驻留中的子 agent 由其 inbox 直接接受(唤醒处于 waiting 的 Activation不驻留的则从其持久化 Session 冷恢复。要求 `ctx.agents`;冷恢复还要求会话持久化。 |
| `reportFrom(child, content, { delivery, signal })` | 从确切在线可继续 child 向其确切在线直接 parent 投递一条选中消息,并返回已接受的稳定 `MessageId`。静默投递会注入上下文;唤醒投递会提交一个后续 parent 轮次。 |
| `registerContinuableSetup(contribution)` | 把一项可选部署能力组合到每个可继续 child 尚未发布的作用域中,并支持从驻留 child 立即撤销。 |
| `drainContinuableDescendants(parents)` | 在由 host 确切拥有的在线 parent Agent 之下关闭准入,只停止其可见的可继续后代,等待在这些根之下已获准的物化过程完成发布或回滚,再按 child-first 顺序释放所选森林。该截止状态会持续到每个确切 parent 离开注册表;无关的 parent 森林和管理器全局准入保持在线。 |
| `listChildren(parentSessionId, signal?)` | 按稳定的追踪顺序列出由会话支撑的直接 subagent包括其 `one-shot``continuable` 模式、`running``inactive` 活动状态、基于 origin 分类的一层 `hasChildren` 提示与逐 child diagnostic且不会加载或恢复它们。要求会话查询不要求 `ctx.agents` 或继续执行管理器。 |
`SubagentStartRequest.signal` 是必填项,也是规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消正在运行的子 agent。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。
`SubagentStartRequest.label` 是由会话支撑的一次性 child 所使用的可选简短持久化显示标签。面向模型的委派会提供其已有的 `description`;底层调用方无需凭空构造展示元数据。可继续启动始终携带自身的必填标签。`signal` 是必填项,也是一次性 `start`规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消已返回 run 的剩余轮次工作,但不会隐藏其 id。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。对于可继续启动或后续操作,调用方信号只在 inbox 接受之前掌管查找、物化和准入;此后由管理器独立拥有 Activation因此调用方后续取消既不会取消已接受的轮次也不会 dispose 子 agent。
后续操作的权限来自子 agent 持久化 header 中记录的确切在线直接父级。冷恢复会在重建前检查该权限,并在最终无 await 的 inbox 准入区间再次检查,因此在物化期间被注销或替换的 parent 无法授权投递。后续操作上的 `source` 是保留在所投递消息上的持久化来源,不授予任何权限。
同进程请求、描述符、结果和事件 payload 都是以不可变方式借用的可信类型值。服务不会克隆或冻结它们序列化和不可信输入校验属于真实的进程、worker、持久化和模型边界。
## 能力
启动时功能通过 `provider.capabilities` 声明,因为服务必须在创建子 agent 前拒绝不受支持的请求:
启动时功能通过 `provider.capabilities` 声明,因为服务必须在创建子 agent 前拒绝不受支持的一次性请求:
- `outputSchema`:强制执行结构化最终结果;
- `depthLimit`:强制执行 `maxDepth`
- `toolFilter`:应用请求的子 agent 工具限制;
- `persona`:应用每个子 agent 独立的 persona。
可继续创建对应可选的 `SubagentProvider.prepareContinuable?()` 方法:方法是否存在就是能力检查,因此服务会在没有该方法的提供方上拒绝已配置的可继续启动,而具备该方法的提供方仍可服务普通一次性委派。该方法只返回分离的 `ContinuableCreateSpec``{ seed? }`)——这是数据,绝非能力:它不携带任何 Agent、`AgentHandle`、提示词投递、结果、dispose 或恢复操作因为准备之后继续执行管理器拥有身份预留、组合、Agent 创建、提示词投递、冷恢复、所有权和 dispose。一次性 `SubagentRun` 表示一次可 dispose 的前台委派,只有一个结果,且没有冷恢复操作。
## 持久化描述符
该 seam 拥有版本化的 `subagent/descriptor` 会话事件词汇(`src/descriptor.ts``snapshotSubagentDescriptor()` 会在提供方工作之前校验并分离记录,`foldSubagentDescriptor()` 则会在从已加载子 agent 日志中恢复描述符之前,校验当前版本的完整 payload。每次由本地会话支撑的启动都会追加一个带有提供方名称与生命周期 `mode` 的描述符。`one-shot` 描述符可以携带调用方拥有的可选持久化显示 `label``continuable` 描述符要求其持久化创建标签,并另外记录已解析的子 agent `agentOptions.provider``model`,以及用于从持久化存储恢复的可选 `persona``toolFilter`。这些是显式字段,绝不是可通过合并扩展的 `AgentOptions` 对象,因此无关的扩展值不会破坏继续执行。描述符省略 `subagentDepth`(持久化 header 的 `delegationDepth` 是单调下界)和 `outputSchema`(单次 Activation 的结果契约)。该事件只进入日志:不含 `surfaceOp`,不进入模型历史,并由仅追加日志跨压缩保留。格式错误的当前版本 payload 属于损坏;本运行时无法对不受支持的版本进行分类。
## 委派深度
该 seam 拥有实现和消费方共享的深度词汇:`AgentOptions.subagentDepth` 声明、`assertSubagentMaxDepth``delegationDepthOf(agent)`。持久化的 `SessionHeader.delegationDepth` 具有权威性且单调:运行时选项可以加深计数,但绝不能降低它,因此恢复后的子 agent 不会被重新计为顶层。
运行时功能是 `SubagentRun` 上的可选方法:`sendMessage?` 可对正在运行的子 agent 进行 steering中途引导`resume?` 则异步创建延续运行。方法是否存在就是能力检查。
`inheritsParentContext` 只用于描述,不能强制执行。它仅说明子 agent 是否能看到父级已完成的对话历史(`fork` 可以;`spawn` 和 ACP 不可以),不表示是否继承工具、服务或权限。
## 所有权与生命周期
## 一次性所有权与生命周期
`provider.start(request): Promise<SubagentRun>` 是所有权转移边界。兑现前,提供方拥有设置过程,并且每次失败时都必须取消、回滚并使启动过程中已取得的资源完全停稳。兑现后,调用方拥有该运行,并且必须在每条路径上调用 `dispose()`
`provider.start(request): Promise<SubagentRun>` 是所有权转移边界;委派工具也会在其由 Task 支撑的一次性后台路径中使用它。兑现前,提供方拥有设置过程,并且每次失败时都必须取消、回滚并使未发布资源完全停稳。兑现后,调用方拥有该运行,并且必须在每条路径上调用 `dispose()`;剩余提示词和轮次工作属于 `SubagentRun.result`
`SubagentRun.result` 兑现为 `{ output, structured?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。`dispose()` 是幂等的,会取消剩余工作,并等待子 agent 资源完全停稳。
`SubagentRun.result` 兑现为 `{ output, structured?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。`dispose()` 是幂等的,会取消剩余工作,并等待结果结算以及子 agent 资源完全停稳。`result` 的 rejection 仍归 `result` 通道;只有独立的资源释放失败会使 `dispose()` 拒绝。
本地运行会在 `start()` 兑现前发布普通的子 agent/会话,把该共享会话 id 作为 `SubagentRun.id` 返回,以 `SubagentRun.localAgent` 公开子 agent 本身,并`request.parent.session.id` 记录到子 agent 的 `parentSession` header。远程提供方则生成父级作用域的生命周期 id并返回 `localAgent: undefined`
本地运行会在 `start()` 兑现前发布普通的子 agent会话,把该共享会话 id 作为 `SubagentRun.id` 返回,以 `SubagentRun.localAgent` 公开准确的子 agent`request.parent.session.id` 记录到子 agent 的 `parentSession` header,并在其初始轮次内追加已解析的描述符。远程提供方则生成 parent 作用域的生命周期 id并返回 `localAgent: undefined`;由于没有本地 child 会话,其一次性运行不会进入基于追踪的枚举结果
服务只会发出 `subagent/start`,而且是在 `start()` 兑现后。它在同步通知前附加结果观察器,因此即使子 agent 已经结算,也仍会先产生 `subagent/start`,再产生 `subagent/end`。这对事件共享服务生成的 `runId`;其 `local` 标志取自提供方准确 `localAgent` 的快照,因此观察器绝不会从可复用的提供方/会话名称推断运行身份或本地性。
## 可继续子 agent 与 Activation
可继续子 agent 拥有一个持久化 Session 和至多一个进程内 **Activation**——即被重建的子 agent 的一个驻留时段,而不是请求、结果、取消或 Task 边界。Agent inbox 是唯一的轮次队列,因此继续执行管理器负责驻留,而 Agent 循环负责所有轮次排序与执行。任何可继续路径都不会创建 Task 或中间的承载结果的包装器。
管理器根据 Agent 停稳状态和所拥有子集推导三个内部驻留条件而非维护第二个状态机running存在活跃准入、进行中的轮次或唤醒型 inbox 工作、waiting已停稳但仍拥有至少一个未 dispose 的子 agent、settled已停稳且所有拥有的子 agent 都已 dispose因此管理器 dispose `AgentHandle` 并移除 Activation。每条后续消息都使用 `Agent.followup()` 并成为一个 FIFO 轮次,且不会对当前轮次进行 steering中途引导。路由只取决于驻留状态running 入队、waiting 唤醒同一 Agent无 Activation 时则冷恢复一个新的。
管理器预留子 agent 身份、解析持久化描述符,通过私有的 activation-owner 作用域调用 `ctx.agents.create()`(冷恢复时为 `ctx.agents.resume()`),把返回的 `AgentHandle` 安装到 Activation 中,建立任何可继续父级所有权,然后提交提示词。冷恢复绝不通过提供方分发,因为持久化 Session 已持有初始前缀,折叠后的描述符即是全部重建输入。
受继续执行管理的父级 Activation 会在子 agent 能够运行之前,把每个子 agent 的 Session id 记录到 `ownedChildren` 集合中,并且只有在每个所拥有的子 agent Activation 完成 `AgentHandle` dispose 之后才会 dispose子先于父。拆卸会先自顶向下传播 Agent 取消,再等待缓慢的后代,而 handle 释放仍保持 child-first。顶层及其他非继续执行的 Agent 没有 Activation处于该等待图之外。最终结算会在 dispose handle 前等待 best-effort 的 `ctx.sessions.flush(child.session)`。listener rejection 会被记录,但不会使 Activation 失败,因为 listener 是否参与无法标识持久化后端;因此,恢复时持久化状态可能缺失或陈旧。
## 生命周期事件
服务会为每次一次性运行以及每个已驻留的可继续 Activation 时段发出一对 `subagent/start`/`subagent/end`,因此可继续子 agent 可用与一次性运行相同的词汇观察,且不会暴露管理器是物化、唤醒还是冷恢复了它们。对于一次性启动,它会在同步的 `subagent/start` 之前附加结果观察器,因此即使子 agent 已经结算,也仍会先产生 `subagent/start`,再产生 `subagent/end`;在驻留前失败的可继续时段不发出任何事件。这对事件共享服务生成的 `runId``local` 标志取自提供方准确 `localAgent` 的快照(可继续子 agent 恒为 true因此观察器绝不会从可复用的提供方/会话名称推断运行身份或本地性。`provider` 字段是生命周期来源信息,而非提供方仍在注册的声明:已接受的一次性 run 可在提供方移除后才结算,冷恢复时段也会保留描述符中的初始提供方名称,而不要求该提供方仍处于注册状态。
运行事件受执行委派的父级作用域约束。每个监听器都独立隔离:同步抛出或返回的 promise 被拒绝时,只会记录日志,不会阻塞同级监听器或改变运行。
提供方新增和移除还会发出 `subagent/provider-added``subagent/provider-removed`。面向模型的工具等消费方使用这些事件,因为 Cordis 可能并发加载同级插件;配置顺序不能证明注册顺序。
可继续子级不会创建 `SubagentRun` 或 Task。延续管理器为每个驻留子 Session 直接拥有一个仅存在于当前进程的 Activation 和一个留存的 `AgentHandle`,使用 Agent inbox 作为唯一 FIFO并从持久化描述符冷恢复。父到子投递由准确的实时直接父级身份授权。上报则由准确的实时子级身份授权管理器根据持久化的 `parentSession` 推导接收方,`MessageSource` 仍只表示来源,不表示权限。
`registerContinuableSetup()` 允许可选包添加子级作用域功能,而无需让延续管理器知道这些功能的名称。贡献会在 Activation 发布前同步安装,在设置失败时一并回滚,并随子级作用域释放。新授权须等到下一个 Activation移除贡献则会立即撤销每个驻留安装项。
## 收集模型
面向模型的工具默认同步收集:先等待子 agent 结果,再对运行执行 dispose(资源释放),然后才返回。后台委派不会改变该 seam消费方把启动过程和最终运行注册到通用 `ctx.tasks` 运行时,随后使用共享任务工具进行收集和取消。完整契约见[后台 subagent 任务 Agent Noteagent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`
面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task其通用状态、收集和取消工具负责后续交互并将模型提供的 `description` 持久化为可选显示标签。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、没有结果 promise也没有公开的子 agent 取消操作——调用方通过 `send_message` 后续操作工具发送后续工作,而持久化子 agent Session 仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 只在被调用时解析会话查询并动态导入其可选运行时,然后解释对所有带描述符的直接 child 所作的只读、实时优先扫描且不查询继续执行管理器、Agent 注册信息、Activation 或提供方。每个健康条目都会根据追踪结果中携带持久化 `origin: 'subagent'` 的直接后代 header 派生读取时的 `hasChildren` 提示;它不会读取后代事件日志,展开后仍以描述符支撑的 child 目录为权威依据。UI 等服务消费方可以保留两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 工具只投影 `continuable` 条目,并将服务活动状态映射到现有的 `running``complete` 词汇。扫描会把调用方的取消信号转发到可取消的追踪与精确读取操作,在其余事件列表读取的前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`。完整契约见[后台 subagent 任务 Agent Note](../../../.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/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)、[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`
可继续 Activation 会等待 best-effort 的最终会话 flush但不会把 listener 参与视为持久性确认。一次性运行保留尽力执行的会话检查点,因此已完成的一次性 child 只有在其会话确实进入持久化存储时,才可在 dispose 后继续被发现;如果该检查点缺失,服务不会根据 Task 历史虚构目录条目。
## 模型体验
通过 `dsh-tool-subagent` 间接产生影响;它渲染提供方特定的 schema以及前台或通用后台结果同时子 agent 工作上下文只留在子 agent 中
通过 `dsh-tool-subagent``dsh-tool-subagent-control``dsh-tool-subagent-report` 间接产生影响。第一个工具负责委派 schema第二个负责父级延续和发现第三个只向可继续子级作用域贡献 `report`
#### KV Cache 影响
不会直接使缓存失效;具名消费方负责请求前缀的任何变化。
不会直接使缓存失效;具名消费方共同负责请求前缀的任何变化。
## 已知限制与暂缓事项
## 已知限制与延期工作
- **运行时 steering 和延续只是 seam 能力**:当前工具中没有消费 `sendMessage``resume` 的面向模型消费方
- **ACP 子 agent 仍为一次性,且无法通过追踪枚举**ACP 运行在 parent 会话语料中没有本地 child 会话。ACP 的 `prepareContinuable` 需要在提供方专用描述符数据中持久化远端会话 id并按子 agent 声明继续执行功能,因为 ACP 的 `loadSession` 支持按子 agent 协商,而不是通过方法是否存在来确定。远程提供方还需要一份独立的 Activation 所有权契约,具备等效的经认证控制和子先于父的停稳保证,才能支持可继续子 agent
- **无 host-user 继续执行**`followup()` 要求确切在线直接父级。未来 host 适配器需要具体的经认证交互,才能让该 seam 获得单独的用户能力。
- **不对当前轮次进行 steering**:可继续消息和唤醒式 report 会排入后续轮次,均不会重定向正在进行的轮次。
- **驻留仅限进程内**Activation inbox 与所有权图不会在两个 harness 进程之间协调;对单个持久化存储的并发访问仍然需要持久化邮箱和跨进程租约协议。
- **不重放已接受但未记录的消息**:只有写入子 agent Session 日志的消息才能连同其被接受时的来源一起重建。崩溃可能丢失从未写入日志、已被接受的初始提示词或后续消息;此后一条经授权的消息可以冷恢复该子 agent但丢失的消息不会自动重放。
- **没有持久化的上报 mailbox**:上报需要实时直接父级,提供的是接受标识,不保证恰好一次投递,也不提供已读回执。
- **生命周期事件只供观察**:影响运行的 `subagent/end` 延续或决策接口仍需等待具体消费方。

View File

@@ -33,9 +33,23 @@
"@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-session-query": "^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-session-query": {
"optional": true
},
"@deepseek-ai/dsh-tasks": {
"optional": true
}
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
@@ -43,6 +57,9 @@
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-query": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.7"
}

View File

@@ -0,0 +1,196 @@
/**
* Internal registry of deployment capabilities composed into every continuable
* child's unpublished creation context.
*
* A contribution grants a child-scoped capability without teaching the
* continuation manager which capabilities exist. The manager owns residency;
* this registry owns the join between plugin lifetime, unpublished setup, and
* Activation disposal, so no installation outlives either owner and no removed
* contribution can be installed after revocation reports completion.
*
* @module @deepseek-ai/dsh-subagent/activation-setup-registry
*/
import type { Context } from 'cordis'
import { errorChain } from '@deepseek-ai/dsh-llm'
import { SubagentError } from './error.ts'
/**
* One deployment capability installed into a continuable child's unpublished
* creation context. It composes synchronously before publication and returns
* the disposer for exactly that installation.
* @param childCtx - the child's unpublished scoped context.
* @returns the disposer revoking this installation.
*/
export type ContinuableSetupContribution = (childCtx: Context) => () => void
/** One contribution's live registration. */
interface Registration {
readonly contribution: ContinuableSetupContribution
removed: boolean
readonly installations: Set<Installation>
}
/** One contribution installed into one child context. */
interface Installation {
readonly registration: Registration
readonly childCtx: Context
readonly dispose: () => void
released: boolean
/** Present until the child reaches residency. */
transaction: TransactionState | undefined
}
/** One child's provisioning batch. */
interface TransactionState {
readonly installations: Installation[]
invalidated: boolean
}
/** Package-private setup transaction consumed by the continuation manager. */
export interface ActivationSetupTransaction {
/**
* Reject a batch invalidated by revocation before publication.
* @throws {SubagentError} code `ACTIVATION_SETUP_REVOKED` after revocation.
*/
assertIntact(): void
/** Promote this batch to resident installations. */
commit(): void
}
/** Re-read mutable removal state after a contribution may have revoked itself. */
function isRemoved(registration: Registration): boolean {
return registration.removed
}
/**
* Owns continuable-child setup registrations, installations, rollback, child
* cleanup, and immediate live revocation.
*/
export class SubagentActivationSetupRegistry {
/** Live contributions in installation order. */
private readonly registrations = new Set<Registration>()
/** Child context to its live installations. */
private readonly byChild = new Map<Context, Set<Installation>>()
/**
* Register one contribution.
* @param contribution - synchronous child-scope installer.
* @returns an idempotent registration undo.
* @throws after attempting every installation when any disposer fails.
*/
register(contribution: ContinuableSetupContribution): () => void {
const registration: Registration = { contribution, removed: false, installations: new Set() }
this.registrations.add(registration)
return () => {
if (registration.removed) return
// Close before disposal so a snapshotted apply() cannot install after
// revocation reports completion.
registration.removed = true
this.registrations.delete(registration)
this.releaseAll([...registration.installations], 'contribution removal')
}
}
/**
* Install every live contribution into one unpublished child context.
* @param childCtx - the child's unpublished scoped context.
* @returns the provisioning transaction.
*/
apply(childCtx: Context): ActivationSetupTransaction {
const state: TransactionState = { installations: [], invalidated: false }
try {
for (const registration of [...this.registrations]) {
/* v8 ignore next -- only a synchronous re-entrant revocation of an
* already-snapshotted registration reaches this guard. */
if (registration.removed) continue
const installation: Installation = {
registration,
childCtx,
dispose: registration.contribution(childCtx),
released: false,
transaction: state,
}
registration.installations.add(installation)
state.installations.push(installation)
let indexed = this.byChild.get(childCtx)
if (indexed === undefined) {
indexed = new Set()
this.byChild.set(childCtx, indexed)
}
indexed.add(installation)
// An installer may revoke itself before its installation record exists.
// Dispose that escaped record and invalidate the provisioning batch.
if (isRemoved(registration)) this.release(installation)
}
} catch (error: unknown) {
// Keep the installer failure authoritative, but attempt every rollback.
try {
this.releaseAll([...state.installations], 'setup rollback')
} catch (releaseFailure: unknown) {
/* v8 ignore next -- requires independent installer and rollback faults. */
void releaseFailure
}
throw error
}
childCtx.effect(() => () => { this.releaseChild(childCtx) }, 'subagents.activationSetup()')
return {
assertIntact: () => {
if (!state.invalidated) return
throw new SubagentError(
'a continuable-subagent setup contribution was revoked while this child was being built; '
+ 'the child was not established',
'ACTIVATION_SETUP_REVOKED',
)
},
commit: () => {
for (const installation of state.installations) installation.transaction = undefined
},
}
}
/** Release every remaining installation owned by one disposed child scope. */
private releaseChild(childCtx: Context): void {
const indexed = this.byChild.get(childCtx) ?? []
this.releaseAll([...indexed], 'child scope disposal')
}
/**
* Release a batch completely before reporting disposer failures.
* @param installations - records to release.
* @param during - operation name for diagnostics.
*/
private releaseAll(installations: readonly Installation[], during: string): void {
const failures: unknown[] = []
for (const installation of installations) {
try {
this.release(installation)
} catch (error: unknown) {
failures.push(error)
}
}
if (failures.length === 0) return
throw new SubagentError(
`continuable-subagent setup ${during} failed to release ${failures.length} installation(s): `
+ failures.map(failure => errorChain(failure)).join('; '),
'ACTIVATION_SETUP_RELEASE_FAILED',
)
}
/** Drop one installation from both indices and dispose it exactly once. */
private release(installation: Installation): void {
if (installation.released) return
installation.released = true
installation.registration.installations.delete(installation)
const indexed = this.byChild.get(installation.childCtx)
/* v8 ignore next 4 -- every live installation is indexed until this method removes it. */
if (indexed !== undefined) {
indexed.delete(installation)
if (indexed.size === 0) this.byChild.delete(installation.childCtx)
}
if (installation.transaction !== undefined) installation.transaction.invalidated = true
installation.dispose()
}
}
export default SubagentActivationSetupRegistry

View File

@@ -0,0 +1,132 @@
/**
* Shared in-process child composition: the delegation-depth budget, the
* durable session metadata, the resolved child `AgentOptions`, and the scoped
* setup a child agent needs. Both the one-shot provider driver and the
* continuation manager compose children this way, so depth accounting and
* lineage stamping have one home.
*
* @module @deepseek-ai/dsh-subagent/child-agent
*/
import type { Context } from 'cordis'
import type { Agent, AgentOptions, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { ToolRestriction } from '@deepseek-ai/dsh-tools'
import { delegationDepthOf } from './depth.ts'
/** Thrown when starting a child would exceed the requested depth cap. */
export class SubagentDepthError extends Error {
constructor(public readonly attemptedDepth: number, public readonly maxDepth: number) {
super(`subagent depth ${attemptedDepth} exceeds maxDepth ${maxDepth}`)
this.name = 'SubagentDepthError'
}
}
/**
* Resolve the child's delegation depth from its parent and enforce an optional
* cap. The persisted parent header is the monotone floor, so a resumed parent
* cannot delegate as if it were top-level.
* @param parent - the delegating parent agent.
* @param maxDepth - optional absolute cap the resolved depth must not exceed.
* @returns the child's non-negative safe-integer depth.
* @throws {SubagentDepthError} when the resolved depth exceeds `maxDepth`.
* @throws {RangeError} when the resolved depth leaves the safe-integer range.
*/
export function resolveChildDepth(parent: Agent, maxDepth: number | undefined): number {
const childDepth = delegationDepthOf(parent) + 1
if (!Number.isSafeInteger(childDepth)) {
throw new RangeError('subagent child depth exceeds the safe-integer range')
}
if (maxDepth !== undefined && childDepth > maxDepth) {
throw new SubagentDepthError(childDepth, maxDepth)
}
return childDepth
}
/**
* Resolve the child's `AgentOptions`: the parent's provider/model/maxTokens
* route unless the request overrides it, stamped with the child's own
* delegation depth.
* @param parent - the delegating parent whose route the child inherits.
* @param requested - per-child overrides, if any.
* @param childDepth - the resolved delegation depth to stamp.
* @returns the resolved options for `ctx.agents.create()`.
*/
export function resolveChildAgentOptions(
parent: Agent,
requested: AgentOptions | undefined,
childDepth: number,
): AgentOptions {
const parentProvider = parent.options.provider
const parentModel = parent.options.model
const parentMaxTokens = parent.options.maxTokens
return {
...parentProvider !== undefined ? { provider: parentProvider } : {},
...parentModel !== undefined ? { model: parentModel } : {},
...parentMaxTokens !== undefined ? { maxTokens: parentMaxTokens } : {},
...requested,
subagentDepth: childDepth,
}
}
/**
* Build the child session's durable creation metadata: the parent's workspace,
* its direct lineage, coarse product origin, the recursion budget that must
* survive persistence, and the seed boundary that separates inherited parent
* history from child work.
* @param parent - the delegating parent agent.
* @param childDepth - the resolved delegation depth to persist.
* @param lineageSeedLength - how many leading events came from the parent's log.
* @returns the `meta` for `ctx.agents.create()`.
*/
export function childSessionMeta(
parent: Agent,
childDepth: number,
lineageSeedLength: number,
): NonNullable<CreateAgentOptions['meta']> {
const parentHeader = parent.session.header
return {
...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {},
parentSession: parentHeader.id,
// Navigation classification only; the descriptor remains the authority
// for mode and continuation capability.
origin: 'subagent',
// Durable: the recursion budget must survive persistence and resume.
delegationDepth: childDepth,
...lineageSeedLength > 0 ? { seedLength: lineageSeedLength } : {},
}
}
/** The scoped composition a child agent's creation window applies. */
export interface ChildComposition {
/** Per-child persona shadowing the deployment persona. */
readonly persona?: string | undefined
/** Per-child tool scoping. */
readonly toolFilter?: ToolRestriction | undefined
}
/**
* Apply one child's scoped composition inside its creation window: a shadowing
* persona section and a tool restriction, both owned by the child's scope and
* therefore invisible to its parent and siblings.
* @param childCtx - the child agent's scoped creation context.
* @param composition - the persona and tool filter to install.
*/
export function applyChildComposition(childCtx: Context, composition: ChildComposition): void {
if (composition.persona !== undefined) {
childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: composition.persona })
}
if (composition.toolFilter !== undefined) childCtx.tools.restrict(composition.toolFilter)
}
/** Identity and lineage inputs shared by every in-process child creation. */
export interface ChildCreateInputs {
/** The child's reserved session id. */
readonly sessionId: SessionId
/** The delegating parent agent. */
readonly parent: Agent
/** The resolved delegation depth. */
readonly childDepth: number
/** How many leading seed events came from the parent's log. */
readonly lineageSeedLength: number
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,51 @@
/**
* Delegation-depth accounting: the recursion budget a parent passes to its
* children. Kept apart from the service so composition helpers can read it
* without importing the registry.
*
* @module @deepseek-ai/dsh-subagent/depth
*/
import type { Agent } from '@deepseek-ai/dsh-agent'
declare module '@deepseek-ai/dsh-agent' {
interface AgentOptions {
/** Delegation depth: zero for a top-level agent and parent depth + 1 for a child. */
subagentDepth?: number
}
}
/**
* Read an agent's delegation depth, treating absence as top-level depth zero.
* The persisted session header is authoritative and monotone: runtime
* `AgentOptions.subagentDepth` may DEEPEN the count but can never lower it —
* a resumed child arrives with fresh options, and counting it from zero would
* let it delegate as if it were top-level.
* @param agent - the agent whose header and options carry the depth.
* @returns its non-negative safe-integer depth.
* @throws if the runtime `AgentOptions.subagentDepth` is not a non-negative safe integer.
*/
export function delegationDepthOf(agent: Agent): number {
const runtime = agent.options.subagentDepth
if (runtime !== undefined && (!Number.isSafeInteger(runtime) || runtime < 0 || Object.is(runtime, -0))) {
throw new TypeError('agent subagentDepth must be a non-negative safe integer')
}
// The header value was validated at the session boundary (creation and
// persistence load both construct through the store).
return Math.max(agent.session.header.delegationDepth ?? 0, runtime ?? 0)
}
/**
* Reject a recursion cap that cannot represent an exact delegation depth.
* @param maxDepth - the optional runtime value to validate.
*/
export function assertSubagentMaxDepth(maxDepth: unknown): void {
if (maxDepth !== undefined && (
typeof maxDepth !== 'number'
|| !Number.isSafeInteger(maxDepth)
|| maxDepth < 0
|| Object.is(maxDepth, -0)
)) {
throw new TypeError('subagent maxDepth must be a non-negative safe integer')
}
}

View File

@@ -0,0 +1,31 @@
/**
* Seeding of a continuable child's durable descriptor event: the model-hidden
* record of the child's declared composition before its first request, so a
* later cold resume can reconstruct it from its own log.
*
* @module @deepseek-ai/dsh-subagent/descriptor-seed
*/
import { Session } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import type { SubagentDescriptorData } from './descriptor.ts'
/**
* Build the child's creation seed: any inherited parent-history prefix followed
* by one model-hidden, between-turn `descriptor` event. Staging through a
* `Session` assigns the sequence number and enforces the same lossless-JSON
* rules the durable log does.
* @param childId - the reserved child session id the staged log belongs to.
* @param seed - the inherited completed-turn prefix, or `undefined` for a fresh child.
* @param descriptor - the snapshotted composition record to persist.
* @returns the complete seed events, contiguous from sequence zero.
*/
export function seedDescriptorTurn(
childId: SessionId,
seed: readonly SessionEvent[] | undefined,
descriptor: SubagentDescriptorData,
): SessionEvent[] {
const staged = new Session(childId, seed)
staged.append('subagent/descriptor', descriptor)
return [...staged.events]
}

View File

@@ -0,0 +1,309 @@
/**
* The durable subagent-child descriptor: the versioned, model-hidden
* `subagent/descriptor` session event that identifies every session-backed
* subagent and records whether it is one-shot or continuable. Continuable
* descriptors additionally preserve the declared composition required for
* cold resume. Providers append it turn-enclosed in the child's initial turn.
*
* The descriptor deliberately snapshots explicit fields rather than the
* merge-extensible `AgentOptions` object: an unrelated extension value cannot
* make continuation fail merely because it is not JSON, and later composition
* inputs require a deliberate {@link SUBAGENT_DESCRIPTOR_VERSION} change. It
* omits `subagentDepth` — cold resume trusts the persisted header's
* `delegationDepth` as the monotone floor — and `outputSchema`, which belongs
* to one activation's result contract rather than durable child composition.
*
* @module @deepseek-ai/dsh-subagent/descriptor
*/
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { ToolRestriction } from '@deepseek-ai/dsh-tools'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/**
* Durable identity and lifecycle mode of a session-backed subagent child,
* appended once by the establishing provider inside the child's initial
* turn, before its first request. Continuable records also carry their
* resumable composition. Log-only: it carries no `surfaceOp`, never enters
* model history, and survives compaction.
*/
'subagent/descriptor': SubagentDescriptorData
}
}
/**
* The current descriptor format version, stamped into every appended
* `subagent/descriptor` event and required verbatim by {@link foldSubagentDescriptor}.
* Supporting another composition input is a deliberate version change, never
* an implicit extra field.
*/
export const SUBAGENT_DESCRIPTOR_VERSION = 2
/** Fields shared by every supported `subagent/descriptor` payload. */
interface SubagentDescriptorBase {
/** Descriptor format version ({@link SUBAGENT_DESCRIPTOR_VERSION}). */
readonly version: number
/** Whether the child is a terminal one-shot run or a resumable conversation. */
readonly mode: 'one-shot' | 'continuable'
/** The `ctx.subagents` provider name that established the child. */
readonly provider: string
}
/** A session-backed subagent that cannot be cold-resumed after its run. */
export interface OneShotSubagentDescriptorData extends SubagentDescriptorBase {
readonly mode: 'one-shot'
/**
* The initial delegation's short `description`, kept as the child's durable
* creation label so enumeration can identify the conversation without
* replaying parent tool results or exposing the child prompt.
*/
readonly label?: string
}
/** A session-backed subagent whose declared composition supports cold resume. */
export interface ContinuableSubagentDescriptorData extends SubagentDescriptorBase {
readonly mode: 'continuable'
/** The initial delegation's short `description`, used for durable enumeration. */
readonly label: string
/** Resolved child `agentOptions.provider`, when one was declared. */
readonly agentProvider?: string
/** Resolved child `agentOptions.model`, when one was declared. */
readonly agentModel?: string
/** Per-child persona that shadows the deployment persona on resume. */
readonly persona?: string
/** Child tool scoping reapplied on resume. */
readonly toolFilter?: ToolRestriction
}
/** The supported durable subagent identity and optional continuation composition. */
export type SubagentDescriptorData =
| OneShotSubagentDescriptorData
| ContinuableSubagentDescriptorData
/** Fields shared by descriptor snapshot inputs. */
interface SubagentDescriptorInputBase {
/** Whether the child is a terminal one-shot run or a resumable conversation. */
readonly mode: 'one-shot' | 'continuable'
/** The `ctx.subagents` provider name that will establish the child. */
readonly provider: string
}
/** Input for a one-shot child's durable identity. */
export interface OneShotSubagentDescriptorInput extends SubagentDescriptorInputBase {
readonly mode: 'one-shot'
/** Optional initial delegation `description` used as the durable creation label. */
readonly label?: string
}
/** Input for a continuable child's durable identity and resumable composition. */
export interface ContinuableSubagentDescriptorInput extends SubagentDescriptorInputBase {
readonly mode: 'continuable'
/** Initial delegation `description` used for durable enumeration. */
readonly label: string
/** Requested child `agentOptions.provider`. */
readonly agentProvider?: string
/** Requested child `agentOptions.model`. */
readonly agentModel?: string
/** Requested per-child persona. */
readonly persona?: string
/** Requested child tool scoping. */
readonly toolFilter?: ToolRestriction
}
/** Inputs {@link snapshotSubagentDescriptor} validates and detaches. */
export type SubagentDescriptorInput =
| OneShotSubagentDescriptorInput
| ContinuableSubagentDescriptorInput
const DESCRIPTOR_BASE_KEYS = [
'version',
'mode',
'provider',
'label',
] as const
const ONE_SHOT_DESCRIPTOR_KEYS = new Set(DESCRIPTOR_BASE_KEYS)
const CONTINUABLE_DESCRIPTOR_KEYS = new Set([
...DESCRIPTOR_BASE_KEYS,
'agentProvider',
'agentModel',
'persona',
'toolFilter',
])
const TOOL_FILTER_KEYS = new Set(['allow', 'deny'])
/** Whether a persisted JSON value is an object record. */
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/** Reject fields outside one versioned record's declared schema. */
function assertKnownKeys(value: Record<string, unknown>, keys: ReadonlySet<string>, path: string): void {
const unknown = Object.keys(value).find(key => !keys.has(key))
if (unknown !== undefined) {
throw new Error(`persisted subagent descriptor ${path} has unknown field "${unknown}"`)
}
}
/** Read one optional string field from a persisted descriptor record. */
function optionalString(value: Record<string, unknown>, key: string): string | undefined {
if (!Object.hasOwn(value, key)) return undefined
const field = value[key]
if (typeof field !== 'string') {
throw new Error(`persisted subagent descriptor ${key} must be a string`)
}
return field
}
/** Read one optional string-array field from a persisted tool restriction. */
function optionalStringArray(value: Record<string, unknown>, key: string): string[] | undefined {
if (!Object.hasOwn(value, key)) return undefined
const field = value[key]
if (!Array.isArray(field)) {
throw new Error(`persisted subagent descriptor toolFilter.${key} must be an array of strings`)
}
const items: unknown[] = field
if (items.some(item => typeof item !== 'string')) {
throw new Error(`persisted subagent descriptor toolFilter.${key} must be an array of strings`)
}
return items as string[]
}
/** Validate and reconstruct a persisted tool restriction. */
function parseToolFilter(value: unknown): ToolRestriction {
if (!isRecord(value)) {
throw new Error('persisted subagent descriptor toolFilter must be an object')
}
assertKnownKeys(value, TOOL_FILTER_KEYS, 'toolFilter')
const allow = optionalStringArray(value, 'allow')
const deny = optionalStringArray(value, 'deny')
if (allow === undefined && deny === undefined) {
throw new Error('persisted subagent descriptor toolFilter must declare allow and/or deny')
}
return {
...allow !== undefined ? { allow } : {},
...deny !== undefined ? { deny } : {},
}
}
/** Validate one persisted descriptor payload for the current runtime. */
function parseSubagentDescriptor(value: unknown): SubagentDescriptorData | undefined {
if (!isRecord(value)) {
throw new Error('persisted subagent descriptor payload must be an object')
}
const version = value['version']
if (typeof version !== 'number') {
throw new Error('persisted subagent descriptor version must be a number')
}
if (version !== SUBAGENT_DESCRIPTOR_VERSION) return undefined
const mode = value['mode']
if (mode !== 'one-shot' && mode !== 'continuable') {
throw new Error('persisted subagent descriptor mode must be "one-shot" or "continuable"')
}
assertKnownKeys(
value,
mode === 'one-shot' ? ONE_SHOT_DESCRIPTOR_KEYS : CONTINUABLE_DESCRIPTOR_KEYS,
'payload',
)
const provider = value['provider']
if (typeof provider !== 'string') {
throw new Error('persisted subagent descriptor provider must be a string')
}
if (mode === 'one-shot') {
const label = optionalString(value, 'label')
return {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode,
provider,
...label !== undefined ? { label } : {},
}
}
const label = value['label']
if (typeof label !== 'string') {
throw new Error('persisted subagent descriptor label must be a string')
}
const agentProvider = optionalString(value, 'agentProvider')
const agentModel = optionalString(value, 'agentModel')
const persona = optionalString(value, 'persona')
const toolFilter = Object.hasOwn(value, 'toolFilter')
? parseToolFilter(value['toolFilter'])
: undefined
return {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode,
provider,
label,
...agentProvider !== undefined ? { agentProvider } : {},
...agentModel !== undefined ? { agentModel } : {},
...persona !== undefined ? { persona } : {},
...toolFilter !== undefined ? { toolFilter } : {},
}
}
/**
* Validate and detach descriptor inputs into the durable payload, before any
* Task or provider work begins — the same detached lossless-JSON boundary the
* session log itself enforces, applied early so a synchronous validation
* failure rejects the tool call without creating a Task.
* @param input - the caller-collected composition fields.
* @returns the versioned, detached descriptor payload.
* @throws when a field is not losslessly JSON-serializable.
*/
export function snapshotSubagentDescriptor(
input: OneShotSubagentDescriptorInput,
): OneShotSubagentDescriptorData
/**
* Validate and detach a continuable descriptor input.
* @param input - the caller-collected continuable composition fields.
* @returns the versioned, detached continuable descriptor payload.
* @throws when a field is not losslessly JSON-serializable.
*/
export function snapshotSubagentDescriptor(
input: ContinuableSubagentDescriptorInput,
): ContinuableSubagentDescriptorData
export function snapshotSubagentDescriptor(input: SubagentDescriptorInput): SubagentDescriptorData {
const candidate: SubagentDescriptorData = input.mode === 'one-shot'
? {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: input.mode,
provider: input.provider,
...input.label !== undefined ? { label: input.label } : {},
}
: {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: input.mode,
provider: input.provider,
label: input.label,
...input.agentProvider !== undefined ? { agentProvider: input.agentProvider } : {},
...input.agentModel !== undefined ? { agentModel: input.agentModel } : {},
...input.persona !== undefined ? { persona: input.persona } : {},
...input.toolFilter !== undefined ? { toolFilter: input.toolFilter } : {},
}
const snapshot = snapshotJsonValue(candidate)
if (snapshot === undefined) {
throw new Error('subagent descriptor is not losslessly JSON-serializable')
}
return snapshot
}
/**
* Fold a persisted child log to its supported descriptor. The first
* `subagent/descriptor` event is authoritative — the establishing provider
* appends exactly one, so a later same-type event cannot rewrite the declared
* composition.
* @param events - the loaded child session events.
* @returns the descriptor, or `undefined` when the log has none or its
* version is not {@link SUBAGENT_DESCRIPTOR_VERSION} (the child cannot be
* classified by this runtime).
* @throws when a current-version persisted payload does not match its complete
* declared schema.
*/
export function foldSubagentDescriptor(events: readonly SessionEvent[]): SubagentDescriptorData | undefined {
const event = events.find(
(candidate): candidate is SessionEvent<'subagent/descriptor'> => candidate.type === 'subagent/descriptor',
)
if (event === undefined) return undefined
return parseSubagentDescriptor(event.data)
}

View File

@@ -0,0 +1,15 @@
/**
* Typed failures shared by subagent service and provider operations.
*
* @module @deepseek-ai/dsh-subagent
*/
import { HarnessError } from '@deepseek-ai/dsh-llm'
/** Typed failure for the subagent seam. */
export class SubagentError extends HarnessError {
constructor(message: string, code: string, options?: ErrorOptions) {
super(message, code, options)
this.name = 'SubagentError'
}
}

View File

@@ -13,12 +13,15 @@
* (`@deepseek-ai/dsh-subagent-spawn`, `-fork`, `-acp`) and the model-facing
* consumer (`@deepseek-ai/dsh-tool-subagent`) are separate packages.
*
* Scope: the seam stays collection-agnostic — a run is started and its
* `result` awaited, whether the consumer blocks on it (foreground) or
* registers it as a `ctx.tasks` background task (the generic runtime owns
* ids/polling/stop; this seam gains nothing task-shaped). Steering
* ({@link SubagentRun.sendMessage}) is part of the contract but intentionally
* unused.
* Public operations express caller intent: `start` returns one published owned
* one-shot run, `startContinuable` establishes a durable continuable child, and
* `followup` delivers later content without exposing whether the child is
* resident. Continuable children never become a {@link SubagentRun}: the
* continuation manager holds their `AgentHandle` directly and orders every turn
* through the child's own inbox, so providers contribute only the detached
* creation spec and see no handle, turn, or teardown. Direct-child discovery
* independently interprets the optional session-query corpus and does not
* require that continuation runtime.
*
* Same-process providers are trusted typed collaborators. Requests, provider
* descriptors, results, and lifecycle payloads are borrowed immutable values;
@@ -28,27 +31,47 @@
* @module @deepseek-ai/dsh-subagent
*/
import { randomUUID } from 'node:crypto'
import { Context, Service } from 'cordis'
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, MessageId } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type {
ContinuableCreateRequest,
ContinuableCreateSpec,
ResolvedSubagentStartRequest,
SubagentCapabilities,
SubagentProvider,
SubagentResult,
SubagentRun,
SubagentRunEndInfo,
SubagentRunInfo,
SubagentStartRequest,
} from './types.ts'
import { SubagentRunId } from './types.ts'
import { SubagentError } from './error.ts'
import { assertSubagentMaxDepth } from './depth.ts'
import { createActivationObserver, createLifecycleEmitter, observeRun } from './lifecycle.ts'
import type { ActivationObserver, LifecycleEmitter } from './lifecycle.ts'
import SubagentContinuationManager from './continuation.ts'
import type {
ContinuableStart,
ContinuableStartSpec,
SubagentFollowupOptions,
SubagentReportOptions,
} from './continuation.ts'
import SubagentActivationSetupRegistry from './activation-setup-registry.ts'
import type { ContinuableSetupContribution } from './activation-setup-registry.ts'
import { listChildren as listSubagentChildren } from './list-children.ts'
import type { SubagentListEntry } from './list-children.ts'
import { snapshotSubagentDescriptor } from './descriptor.ts'
export * from './out-of-process.ts'
export { SubagentRunId } from './types.ts'
export type {
ContinuableCreateRequest,
ContinuableCreateSpec,
ResolvedSubagentStartRequest,
SubagentCapabilities,
SubagentProvider,
SubagentResult,
@@ -57,48 +80,43 @@ export type {
SubagentStopReason,
SubagentStopReasonMap,
} from './types.ts'
declare module '@deepseek-ai/dsh-agent' {
interface AgentOptions {
/** Delegation depth: zero for a top-level agent and parent depth + 1 for a child. */
subagentDepth?: number
}
}
/**
* Read an agent's delegation depth, treating absence as top-level depth zero.
* The persisted session header is authoritative and monotone: runtime
* `AgentOptions.subagentDepth` may DEEPEN the count but can never lower it —
* a resumed child arrives with fresh options, and counting it from zero would
* let it delegate as if it were top-level.
* @param agent - the agent whose header and options carry the depth.
* @returns its non-negative safe-integer depth.
* @throws if the runtime `AgentOptions.subagentDepth` is not a non-negative safe integer.
*/
export function delegationDepthOf(agent: Agent): number {
const runtime = agent.options.subagentDepth
if (runtime !== undefined && (!Number.isSafeInteger(runtime) || runtime < 0 || Object.is(runtime, -0))) {
throw new TypeError('agent subagentDepth must be a non-negative safe integer')
}
// The header value was validated at the session boundary (creation and
// persistence load both construct through the store).
return Math.max(agent.session.header.delegationDepth ?? 0, runtime ?? 0)
}
/**
* Reject a recursion cap that cannot represent an exact delegation depth.
* @param maxDepth - the optional runtime value to validate.
*/
export function assertSubagentMaxDepth(maxDepth: unknown): void {
if (maxDepth !== undefined && (
typeof maxDepth !== 'number'
|| !Number.isSafeInteger(maxDepth)
|| maxDepth < 0
|| Object.is(maxDepth, -0)
)) {
throw new TypeError('subagent maxDepth must be a non-negative safe integer')
}
}
export {
foldSubagentDescriptor,
snapshotSubagentDescriptor,
SUBAGENT_DESCRIPTOR_VERSION,
} from './descriptor.ts'
export type {
ContinuableSubagentDescriptorData,
ContinuableSubagentDescriptorInput,
OneShotSubagentDescriptorData,
OneShotSubagentDescriptorInput,
SubagentDescriptorData,
SubagentDescriptorInput,
} from './descriptor.ts'
export { seedDescriptorTurn } from './descriptor-seed.ts'
export { SubagentError } from './error.ts'
export { settleRun } from './run-settlement.ts'
export { assertSubagentMaxDepth, delegationDepthOf } from './depth.ts'
export {
applyChildComposition,
childSessionMeta,
resolveChildAgentOptions,
resolveChildDepth,
SubagentDepthError,
} from './child-agent.ts'
export type { ChildComposition } from './child-agent.ts'
export type {
ContinuableStart,
ContinuableStartSpec,
CoordinatorMessageSource,
SubagentFollowupOptions,
SubagentReportDelivery,
SubagentReportMessageSource,
SubagentReportOptions,
} from './continuation.ts'
export type { ContinuableSetupContribution } from './activation-setup-registry.ts'
export type { SubagentListEntry } from './list-children.ts'
export type { SubagentRunEndInfo, SubagentRunInfo } from './types.ts'
declare module 'cordis' {
interface Context {
@@ -119,18 +137,18 @@ declare module 'cordis' {
*/
'subagent/provider-removed'(name: string): void
/**
* A provider established a ready child. For in-process providers,
* A provider established a published child. For in-process providers,
* `ctx.agents.get(info.id)` resolves during this notification.
* Scope-filtered dispatch keys the carrier by the delegating parent, so a
* parent-scoped listener observes only its own delegations. Paired with
* `subagent/end`.
* @param info - the provider and ready child identity.
* @param info - the provider and published child identity.
* @dshScopeScan unsupported
* @mode emit
*/
'subagent/start'(this: Scoped<SubagentService>, info: SubagentRunInfo): void
/**
* A ready child settled. Scope-filtered dispatch uses the same delegating
* A published child settled. Scope-filtered dispatch uses the same delegating
* parent carrier as `subagent/start`, so the lifecycle pair reaches the
* same scoped audience.
* @param info - the run identity and terminal outcome.
@@ -141,48 +159,144 @@ declare module 'cordis' {
}
}
/** Observe-only identifying detail for a ready subagent run. */
export interface SubagentRunInfo {
/** Unique identity shared with the paired terminal event. */
readonly runId: SubagentRunId
/** The provider that established the run. */
readonly provider: string
/** The child agent's id. */
readonly id: SessionId
/** Snapshot of whether `SubagentRun.localAgent` was present when start fulfilled. */
readonly local: boolean
}
/** Observe-only outcome detail for a settled subagent run. */
export interface SubagentRunEndInfo {
/** Unique identity shared with the paired start event. */
readonly runId: SubagentRunId
/** The provider that ran it. */
readonly provider: string
/** The child agent's id. */
readonly id: SessionId
/** Snapshot of whether `SubagentRun.localAgent` was present when start fulfilled. */
readonly local: boolean
/** The terminal stop reason. */
readonly stopReason: SubagentResult['stopReason']
/** The child's final assistant output, absent on infrastructure rejection. */
readonly lastAssistantMessage?: ContentBlock[]
}
/** Typed error for provider lookup, registration, and capability failures. */
export class SubagentError extends HarnessError {
constructor(message: string, code: string, options?: ErrorOptions) {
super(message, code, options)
this.name = 'SubagentError'
}
}
/** Named provider registry and capability-checked start surface. */
/** Named provider registry with one-shot runs, durable discovery, and continuable-child operations. */
export class SubagentService extends Service {
private providers = new Map<string, SubagentProvider>()
private continuations: SubagentContinuationManager | undefined
/** Deployment contributions composed into unpublished continuable children. */
private readonly setupRegistry = new SubagentActivationSetupRegistry()
/**
* The contained lifecycle-edge publisher. Built here because scoped dispatch
* keys its carrier by this exact service instance, whose own context filter
* composes into the carrier.
*/
private readonly emitLifecycle: LifecycleEmitter
constructor(ctx: Context) {
super(ctx, 'subagents')
this.emitLifecycle = createLifecycleEmitter(this.ctx, parent => scopeTarget(this, parent))
ctx.inject(['agents'], (childCtx: Context) => {
const manager = new SubagentContinuationManager(childCtx, {
prepareContinuable: (name, request) => this.prepareContinuable(name, request),
observeActivation: (provider, childId, parent) => this.observeActivation(provider, childId, parent),
}, this.setupRegistry)
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()')
})
}
/**
* Establish one durable continuable child and deliver its initial prompt.
* Resolves when the child's inbox accepts that prompt, without waiting for the
* turn to start or for the message to reach the Session log; any earlier
* failure rejects with no ids and rolls back the child entirely.
* @param spec - provider, delegation request, and caller cancellation.
* @returns the durable child id and the accepted prompt's message id.
* @throws when continuation services are unavailable or materialization fails.
*/
async startContinuable(spec: ContinuableStartSpec): Promise<ContinuableStart> {
return this.requireContinuations().startContinuable(spec)
}
/**
* Deliver one later message to a continuable child as its next FIFO turn. A
* resident child's Agent inbox accepts it directly (waking a `waiting`
* Activation), while an absent one is cold-resumed from its persisted
* Session. The Agent inbox is the only queue, so every accepted message has
* one observable order.
* @param parent - the exact live direct parent authorizing this delivery.
* @param childId - durable child session id.
* @param content - user-role content to deliver.
* @param options - durable provenance and caller cancellation, which stops the
* operation only before inbox acceptance.
* @returns the accepted message's inbox id.
* @throws when continuation services are unavailable, parent authority is
* rejected, or the message was not admitted.
*/
async followup(
parent: Agent,
childId: SessionId,
content: ContentBlock[],
options: SubagentFollowupOptions,
): Promise<MessageId> {
return this.requireContinuations().followup(parent, childId, content, options)
}
/**
* Deliver selected content from one live continuable child to its durable
* direct parent. The child is the authority credential; callers cannot name a
* recipient. Reporting does not conclude the child's turn or Activation.
* @param child - exact live reporting child.
* @param content - selected model-facing content.
* @param options - parent scheduling and pre-acceptance cancellation.
* @returns the stable identity of the parent-accepted message.
* @throws when continuation services are unavailable, sender authorization
* fails, or the direct parent is not live.
*/
async reportFrom(
child: Agent,
content: ContentBlock[],
options: SubagentReportOptions,
): Promise<MessageId> {
return this.requireContinuations().reportFrom(child, content, options)
}
/**
* Compose one deployment capability into every continuable child's
* unpublished creation context on fresh creation and cold resume. Grants wait
* for the next Activation; removing the contribution revokes every resident
* installation immediately.
* @param contribution - synchronous child-scope installer.
* @returns the exact Cordis effect disposer.
*/
registerContinuableSetup(contribution: ContinuableSetupContribution): () => void {
// oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return this.ctx.effect(
() => this.setupRegistry.register(contribution),
'subagents.registerContinuableSetup()',
)
}
/**
* Close continuable admission below exact live parent Agents, stop only their
* visible descendant Activations synchronously, then await admitted scoped
* materializations and release those forests child-first. The scoped cutoff
* lasts until each exact parent leaves the registry; unrelated parent trees
* remain live.
* @param parents - exact host-owned parent Agents entering teardown.
* @returns once every retained descendant Activation released its `AgentHandle`.
* @throws an aggregate error after all branches settle when any failed.
*/
async drainContinuableDescendants(parents: readonly Agent[]): Promise<void> {
const manager = this.continuations
// Absent continuation services means nothing was ever materialized.
if (manager === undefined) return
await manager.drainDescendants(parents)
}
/**
* Enumerate the parent's direct session-backed subagents from the
* live-preferred session corpus without loading or resuming an Agent. Session
* query supplies lineage, candidate order, event reads, and live state; this
* service interprets descriptor mode, activity, and per-child diagnostics
* without consulting Agent registrations, Activations, or providers.
*
* The trace and exact descriptor read receive `signal`; the full event-list
* read has no signal parameter, so the scan rechecks cancellation around
* every await and between candidates. Query rejections that settle after an
* abort become a stable `SubagentError` with code `CANCELLED`.
* @param parentSessionId - parent session whose direct children are listed.
* @param signal - caller-owned cancellation forwarded where supported and
* observed around every query await.
* @returns children and per-child diagnostics in stable trace order.
* @throws {@link SubagentError} when session query is unavailable or the
* caller cancels the scan.
*/
listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentListEntry[]> {
return listSubagentChildren(this.ctx, parentSessionId, signal)
}
/**
@@ -228,75 +342,79 @@ export class SubagentService extends Service {
}
/**
* Establish a ready child on the named provider. Capability and semantic
* Establish a published child on the named provider. Capability and semantic
* checks run before delegation. Provider ownership lasts until its promise
* fulfills; a rejection therefore has no run for the caller to dispose and
* emits no run lifecycle events.
* emits no run lifecycle events. Post-publication turn and infrastructure
* failures settle through the returned run.
* @param name - the provider to use.
* @param request - child prompt, parent, signal, and optional capabilities.
* @returns the ready holder-owned run.
* @param request - child label, prompt, parent, signal, and optional capabilities.
* @returns the published holder-owned run.
*/
async start(name: string, request: SubagentStartRequest): Promise<SubagentRun> {
const provider = this.expectProvider(name)
this.assertCapabilities(provider, request)
assertSubagentMaxDepth(request.maxDepth)
if (request.outputSchema !== undefined) assertObjectJsonSchema(request.outputSchema)
const descriptor = snapshotSubagentDescriptor({
mode: 'one-shot',
provider: name,
...request.label !== undefined ? { label: request.label } : {},
})
const resolved: ResolvedSubagentStartRequest = { ...request, descriptor }
return observeRun(this.emitLifecycle, name, request.parent, await provider.start(resolved))
}
/**
* Resolve one provider's detached continuable-creation contribution. Method
* presence on the provider IS the capability, so a provider without it is
* rejected before the manager reserves any child resources.
*/
private async prepareContinuable(
name: string,
request: ContinuableCreateRequest,
): Promise<ContinuableCreateSpec> {
const provider = this.expectProvider(name)
if (provider.prepareContinuable === undefined) {
throw new SubagentError(
`subagent provider "${provider.name}" does not support continuable children `
+ '(no prepareContinuable capability)',
'UNSUPPORTED_CAPABILITY',
)
}
return provider.prepareContinuable(request)
}
/** Look up a provider for dispatch or fail loud. */
private expectProvider(name: string): SubagentProvider {
const provider = this.providers.get(name)
if (provider === undefined) {
throw new SubagentError(`no subagent provider registered for "${name}"`, 'NO_PROVIDER')
}
this.assertCapabilities(provider, request)
assertSubagentMaxDepth(request.maxDepth)
if (request.outputSchema !== undefined) assertObjectJsonSchema(request.outputSchema)
return provider
}
const parent = request.parent
const run = await provider.start(request)
const runId = SubagentRunId(randomUUID())
const lifecycleIdentity = {
runId,
provider: name,
id: run.id,
local: run.localAgent !== undefined,
/** Resolve the optional continuable-subagent manager or fail loud. */
private requireContinuations(): SubagentContinuationManager {
if (this.continuations === undefined) {
throw new SubagentError(
'continuable subagents require the agents service',
'CONTINUATION_UNAVAILABLE',
)
}
// Attach the terminal observer before dispatching start. Promise reactions
// still run after this synchronous start emission, preserving start → end.
void run.result.then(
(result) => {
this.emitLifecycle('subagent/end', {
...lifecycleIdentity,
stopReason: result.stopReason,
lastAssistantMessage: result.output,
}, parent)
},
() => {
this.emitLifecycle('subagent/end', { ...lifecycleIdentity, stopReason: 'error' }, parent)
},
)
this.emitLifecycle('subagent/start', lifecycleIdentity, parent)
return run
return this.continuations
}
/**
* Emit lifecycle events with per-listener synchronous and asynchronous
* exception containment. Payloads are borrowed immutable values.
* Build the lifecycle observer for one continuable Activation's residency
* epoch, so the manager publishes its edges without owning event dispatch.
*/
private emitLifecycle(name: 'subagent/start', info: SubagentRunInfo, parent: Agent): void
private emitLifecycle(name: 'subagent/end', info: SubagentRunEndInfo, parent: Agent): void
private emitLifecycle(name: 'subagent/provider-removed', info: string): void
private emitLifecycle(
name: 'subagent/start' | 'subagent/end' | 'subagent/provider-removed',
info: SubagentRunInfo | SubagentRunEndInfo | string,
parent?: Agent,
): void {
const dispatchArgs: unknown[] = parent === undefined
? [name, info]
: [scopeTarget(this, parent), name, info]
for (const callback of this.ctx.events.dispatch('emit', dispatchArgs)) {
try {
const returned: unknown = callback(info)
void Promise.resolve(returned).catch((error: unknown) => {
this.ctx.logger.warn(`subagent: ${name} listener rejected: ${renderThrown(error)}`)
})
} catch (error: unknown) {
this.ctx.logger.warn(`subagent: ${name} listener threw: ${renderThrown(error)}`)
}
}
private observeActivation(
provider: string,
childId: SessionId,
parent: Agent,
): ActivationObserver {
return createActivationObserver(this.emitLifecycle, provider, childId, parent)
}
/** Reject the first requested capability that the provider lacks. */
@@ -318,13 +436,4 @@ export class SubagentService extends Service {
}
}
/** Render any listener-thrown value without letting coercion escape containment. */
function renderThrown(value: unknown): string {
try {
return value instanceof Error ? `${value.name}: ${value.message}` : String(value)
} catch {
return '<unrenderable thrown value>'
}
}
export default SubagentService

View File

@@ -2,8 +2,7 @@
import type { Context } from 'cordis'
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import type { SubagentProvider } from './types.ts'
import type { SubagentRunEndInfo, SubagentRunInfo } from './index.ts'
import type { SubagentProvider, SubagentRunEndInfo, SubagentRunInfo } from './types.ts'
const PACKAGE_NAME = '@deepseek-ai/dsh-subagent'
@@ -44,9 +43,11 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
}
if (eventName === 'subagent/start') {
const info = args[0] as SubagentRunInfo
if (!providers.has(info.provider)) fail(`subagent/start names inactive provider ${JSON.stringify(info.provider)}`)
if (String(info.runId).length === 0 || String(info.id).length === 0) {
fail('subagent/start runId and child id must be non-empty')
// Provider availability is an admission-time relationship. A published
// one-shot run may outlive provider removal, and a cold-resumed Activation
// carries durable provider provenance without dispatching through it.
if (info.provider.length === 0 || String(info.runId).length === 0 || String(info.id).length === 0) {
fail('subagent/start provider, runId, and child id must be non-empty')
}
if (runs.has(info.runId)) fail(`subagent/start repeated run id ${JSON.stringify(info.runId)}`)
stagedStarts.add(info)

View File

@@ -0,0 +1,244 @@
/**
* Lifecycle-edge publication for both subagent shapes: the contained emitter,
* the one-shot run observer, and the continuable Activation observer.
*
* The public payload contracts ({@link SubagentRunInfo},
* {@link SubagentRunEndInfo}) live in `./types.ts` with the rest of the seam's
* consumer-facing types; this module owns only the implementation and the
* package-private {@link ActivationObserver} the continuation manager consumes.
* Keeping the internal control interface out of the published surface is
* deliberate: the observer's `start`/`capture`/`settle` ordering is a contract
* between this module and one in-package caller, not something a plugin may
* depend on.
*
* @module @deepseek-ai/dsh-subagent/lifecycle
*/
import { randomUUID } from 'node:crypto'
import type { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { findLastMessageTurnEnd } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import { SubagentRunId } from './types.ts'
import type { SubagentResult, SubagentRun, SubagentRunEndInfo, SubagentRunInfo } from './types.ts'
/**
* Lifecycle observer for one Activation's residency epoch, so continuable
* children emit the same start/end pair as one-shot runs. Package-private: the
* continuation manager is the only consumer, and its call ordering is an
* in-package contract rather than a published extension seam.
*/
export interface ActivationObserver {
/**
* Publish the start edge once the epoch is resident.
* @param child - the resident child agent, whose log suffix bounds this epoch.
*/
start(child: Agent): void
/**
* Snapshot the child-dependent terminal facts while the child is still
* registered, because handle disposal unregisters it and consumers resolve it
* to read the child's own log and scope.
* @param child - the quiescent child agent about to be released.
*/
capture(child: Agent): void
/**
* Publish the terminal edge exactly once, pairing this epoch's {@link start},
* after the disposal outcome is known. Called only for a resident epoch: a
* failure before residency publishes no edge, because inventing one would
* report a lifecycle the child never had.
* @param failure - the teardown or durability failure, or `undefined` on success.
*/
settle(failure: unknown): void
}
/**
* Publish one lifecycle edge with per-listener exception containment. Run edges
* carry the delegating parent that keys scoped dispatch; provider removal has no
* parent carrier and reaches listeners unscoped.
*
* The service owns this closure because scoped dispatch keys its carrier by the
* exact service instance, whose own context filter composes into the carrier;
* a narrowed stand-in would silently change scope filtering.
*/
export type LifecycleEmitter = {
(name: 'subagent/start', info: SubagentRunInfo, parent: Agent): void
(name: 'subagent/end', info: SubagentRunEndInfo, parent: Agent): void
(name: 'subagent/provider-removed', info: string): void
}
/**
* Build the contained lifecycle emitter this seam publishes every edge through.
* Every listener is independently contained: a synchronous throw or a rejected
* returned promise is logged without starving peer listeners, changing the run,
* or — for provider removal, which fires from a disposer — breaking teardown.
* @param ctx - the service's own context, owning dispatch and the logger.
* @param carrier - resolve the scoped dispatch carrier for one delegating parent.
* @returns the emitter both observers and the provider registry publish through.
*/
export function createLifecycleEmitter(
ctx: Context,
carrier: (parent: Agent) => object,
): LifecycleEmitter {
return (
name: 'subagent/start' | 'subagent/end' | 'subagent/provider-removed',
info: SubagentRunInfo | SubagentRunEndInfo | string,
parent?: Agent,
): void => {
const dispatchArgs: unknown[] = parent === undefined
? [name, info]
: [carrier(parent), name, info]
for (const callback of ctx.events.dispatch('emit', dispatchArgs)) {
try {
const returned: unknown = callback(info)
void Promise.resolve(returned).catch((error: unknown) => {
ctx.logger.warn(`subagent: ${name} listener rejected: ${renderThrown(error)}`)
})
} catch (error: unknown) {
ctx.logger.warn(`subagent: ${name} listener threw: ${renderThrown(error)}`)
}
}
}
}
/**
* Emit the start/end lifecycle pair for one accepted one-shot run.
* @param emit - the contained lifecycle emitter.
* @param provider - the provider that established the run.
* @param parent - the delegating parent keying scoped dispatch.
* @param run - the published run whose settlement closes the pair.
* @returns the same run, unchanged.
*/
export function observeRun(
emit: LifecycleEmitter,
provider: string,
parent: Agent,
run: SubagentRun,
): SubagentRun {
const identity = {
runId: SubagentRunId(randomUUID()),
provider,
id: run.id,
local: run.localAgent !== undefined,
}
// Attach the terminal observer before dispatching start. Promise reactions
// still run after this synchronous start emission, preserving start → end.
void run.result.then(
(result) => {
emit('subagent/end', {
...identity,
stopReason: result.stopReason,
lastAssistantMessage: result.output,
}, parent)
},
() => {
emit('subagent/end', { ...identity, stopReason: 'error' }, parent)
},
)
emit('subagent/start', identity, parent)
return run
}
/**
* Build the observer for one continuable Activation's residency epoch. Observers
* see the same vocabulary as a one-shot run, so a child's start and settlement
* remain observable without exposing whether the manager materialized, woke, or
* cold-resumed it. Creation failure before residency emits no lifecycle edge.
* @param emit - the contained lifecycle emitter.
* @param provider - the provider name recorded in the durable descriptor.
* @param childId - the durable child session id.
* @param parent - the exact live direct parent keying scoped dispatch.
* @returns the observer whose edges this epoch publishes.
*/
export function createActivationObserver(
emit: LifecycleEmitter,
provider: string,
childId: SessionId,
parent: Agent,
): ActivationObserver {
const identity = { runId: SubagentRunId(randomUUID()), provider, id: childId, local: true }
// A cold resume replays earlier turns, so this epoch's telemetry must come
// from the suffix it actually produced — never the whole session, which
// would report a previous epoch's answer when this one opened no turn.
let boundary = 0
// Assigned by `capture()`, which the disposal path always runs before
// `settle()`; a resident epoch therefore always has its facts by then.
let captured: { stopReason: SubagentResult['stopReason']; output?: ContentBlock[] } = {
stopReason: 'completed',
}
return {
start: (child: Agent): void => {
boundary = child.session.events.length
emit('subagent/start', identity, parent)
},
capture: (child: Agent): void => {
const own = child.session.events.slice(boundary)
const output = lastAssistantOutput(own)
captured = {
stopReason: epochStopReason(own),
...output === undefined ? {} : { output },
}
},
settle: (failure: unknown): void => {
const output = failure === undefined ? captured.output : undefined
emit('subagent/end', {
...identity,
stopReason: failure === undefined ? captured.stopReason : 'error',
...output === undefined ? {} : { lastAssistantMessage: output },
}, parent)
},
}
}
/**
* Why this child's last ordinary turn ended, for the terminal lifecycle edge.
* The child's own `turn/end` is authoritative: teardown succeeding says nothing
* about whether the model errored, hit its token ceiling, or was cancelled, so
* deriving the reason from disposal would report failed work as completed.
* @param events - this epoch's own event suffix.
* @returns its terminal stop reason; `completed` when no ordinary turn closed.
*/
function epochStopReason(events: readonly SessionEvent[]): SubagentResult['stopReason'] {
const reason = findLastMessageTurnEnd(events)?.data.reason
// No ordinary turn closed, so nothing failed either.
if (reason === undefined) return 'completed'
switch (reason.kind) {
case 'max-tokens':
return 'max-tokens'
case 'aborted':
case 'interrupted':
case 'disposed':
return 'aborted'
case 'error':
return 'error'
case 'completed':
return 'completed'
/* v8 ignore next 3 -- `TurnEndReason` is merge-extensible, so this arm needs a
* backend that adds a variant; treating an unnameable reason as success would
* report failed work as completed. */
default:
return 'error'
}
}
/**
* The child's last assistant message content, for one Activation's terminal
* lifecycle edge. Absent when no assistant message reached the log.
* @param events - this epoch's own event suffix.
* @returns its final assistant content, or `undefined` when it produced none.
*/
function lastAssistantOutput(events: readonly SessionEvent[]): ContentBlock[] | undefined {
const message = events.findLast(
(event): event is SessionEvent<'assistant/message'> => event.type === 'assistant/message',
)
return message?.data.message.content
}
/** Render any listener-thrown value without letting coercion escape containment. */
function renderThrown(value: unknown): string {
try {
return value instanceof Error ? `${value.name}: ${value.message}` : String(value)
} catch {
return '<unrenderable thrown value>'
}
}

View File

@@ -0,0 +1,231 @@
/**
* Read-only interpretation of session-query lineage as durable subagent
* children. The module owns no catalog state and does not consult Activation,
* Agent-registry, continuation-manager, or provider state. A child's
* descriptor distinguishes one-shot work from a continuable conversation.
*
* @module @deepseek-ai/dsh-subagent
*/
import type { Context } from 'cordis'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionQueryService, SessionRecord } from '@deepseek-ai/dsh-session-query'
import type SubagentService from './index.ts'
import { SubagentError } from './error.ts'
import { foldSubagentDescriptor } from './descriptor.ts'
type SessionQueryRuntime = Pick<
typeof import('@deepseek-ai/dsh-session-query'),
'assertSessionHeadersCompatible' | 'SessionQueryError'
>
/**
* One entry of a {@link listChildren} result in trace candidate order. A valid
* descriptor produces a `child`, a per-child inspection failure produces a
* `diagnostic`, and a descriptor-less ordinary child is omitted. Healthy rows
* include a one-level, origin-classified descendant hint. Diagnostics are
* transient query results, never session events or catalog state, and never
* expose model-hidden descriptor content.
*/
export type SubagentListEntry =
| {
readonly kind: 'child'
/** The durable child session id, stable across Activations. */
readonly id: SessionId
/**
* Corpus snapshot activity: `running` means the logical record is live in
* `ctx.sessions`; `inactive` means it exists only in persistence. Neither
* encodes a durable outcome, and a continuable child may still reject
* delivery as an ownership conflict.
*/
readonly activity: 'running' | 'inactive'
/** Whether a direct descendant has durable `origin: 'subagent'`. */
readonly hasChildren: boolean
} & (
| {
/** A terminal one-shot child. */
readonly mode: 'one-shot'
/** Optional durable creation label from the child's descriptor. */
readonly label?: string
}
| {
/** A resumable conversation. */
readonly mode: 'continuable'
/** Durable creation label from the child's descriptor. */
readonly label: string
}
)
| {
readonly kind: 'diagnostic'
/** The traced candidate's session id. */
readonly id: SessionId
/**
* Why the candidate was omitted: `corrupt` for invalid surfaces, header
* conflicts, or malformed/duplicated descriptors; `unsupported` for an
* unknown descriptor version; `unavailable` when the child disappeared or
* its per-child read hit a persistence failure.
*/
readonly reason: 'corrupt' | 'unsupported' | 'unavailable'
}
/**
* Interpret one parent's direct session descendants as session-backed subagents
* without loading or resuming an Agent.
* @see {@link SubagentService.listChildren} for the public cancellation and
* failure contract.
* @param ctx - context carrying the optional session-query service.
* @param parentSessionId - parent session whose direct children are listed.
* @param signal - caller-owned cancellation.
* @returns children and per-child diagnostics in stable trace order.
* @throws {@link SubagentError} when session query is unavailable or
* the caller cancels the scan.
*/
export async function listChildren(
ctx: Context,
parentSessionId: SessionId,
signal?: AbortSignal,
): ReturnType<SubagentService['listChildren']> {
const query = ctx.get('sessionQuery')
if (query === undefined) {
throw new SubagentError(
'listing subagents requires session query (load a dsh-session-query backend)',
'SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE',
)
}
assertListingNotCancelled(signal)
// Keep runtime values behind the listing-only boundary so ordinary
// subagent imports and control operations do not evaluate the optional peer.
const queryRuntime: SessionQueryRuntime = await import('@deepseek-ai/dsh-session-query')
assertListingNotCancelled(signal)
const trace = await runListingQuery(
() => query.traceSession(parentSessionId, signal),
signal,
)
const entries: SubagentListEntry[] = []
for (const node of trace.descendants) {
const hasChildren = node.descendants.some(
descendant => descendant.session.header.origin === 'subagent',
)
const entry = await inspectChild(
query, queryRuntime, parentSessionId, node.session, hasChildren, signal,
)
// Cancellation can race the inspection's last checkpoint or diagnostic
// mapping; do not return success or begin another candidate afterward.
assertListingNotCancelled(signal)
if (entry !== undefined) entries.push(entry)
}
return entries
}
/** Interpret one traced direct-child record as a child, diagnostic, or exclusion. */
async function inspectChild(
query: SessionQueryService,
queryRuntime: SessionQueryRuntime,
parentSessionId: SessionId,
candidate: SessionRecord,
hasChildren: boolean,
signal?: AbortSignal,
): Promise<SubagentListEntry | undefined> {
const childId = candidate.header.id
try {
const records = await runListingQuery(() => query.listEvents(childId), signal)
// Only the child's own suffix: a fork seed may replay an ancestor's
// descriptor without making the fork itself a subagent.
const seedLength = candidate.header.seedLength ?? 0
const descriptorSeqs = records
.filter(record => record.seq >= seedLength && record.type === 'subagent/descriptor')
.map(record => record.seq)
if (descriptorSeqs.length === 0) return undefined
if (descriptorSeqs.length > 1) {
return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
}
// The length-one branch proves this exact-read sequence exists.
// oxlint-disable-next-line typescript/no-non-null-assertion
const seq = descriptorSeqs[0]!
const window = await runListingQuery(
() => query.readEvent({ sessionId: childId, seq }, signal),
signal,
)
queryRuntime.assertSessionHeadersCompatible(window.session, candidate.header)
if (window.session.parentSession !== parentSessionId || window.target.type !== 'subagent/descriptor') {
return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
}
let descriptor: ReturnType<typeof foldSubagentDescriptor>
try {
descriptor = foldSubagentDescriptor([window.target])
} catch {
return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
}
if (descriptor === undefined) {
return { kind: 'diagnostic', id: childId, reason: 'unsupported' }
}
const activity = candidate.live ? 'running' : 'inactive'
if (descriptor.mode === 'one-shot') {
return {
kind: 'child',
id: childId,
mode: descriptor.mode,
...descriptor.label !== undefined ? { label: descriptor.label } : {},
activity,
hasChildren,
}
}
return {
kind: 'child', id: childId, mode: descriptor.mode, label: descriptor.label,
activity, hasChildren,
}
} catch (error: unknown) {
const reason = perChildDiagnosticReason(error, queryRuntime.SessionQueryError)
if (reason === undefined) throw error
return { kind: 'diagnostic', id: childId, reason }
}
}
/** Stop a listing scan at its next cancellation checkpoint. */
function assertListingNotCancelled(signal: AbortSignal | undefined): void {
if (signal?.aborted) {
throw new SubagentError('subagent listing was cancelled', 'CANCELLED')
}
}
/**
* Run one session-query operation between cancellation checkpoints. Query
* implementations may reject with their own abort error after observing the
* forwarded signal; cancellation remains a stable subagent failure.
*/
async function runListingQuery<T>(
operation: () => Promise<T>,
signal: AbortSignal | undefined,
): Promise<T> {
assertListingNotCancelled(signal)
try {
const result = await operation()
assertListingNotCancelled(signal)
return result
} catch (error: unknown) {
assertListingNotCancelled(signal)
throw error
}
}
/**
* Map a per-child query failure to a fixed diagnostic. Configuration errors
* and unrecognized failures remain operation failures.
*/
function perChildDiagnosticReason(
error: unknown,
SessionQueryError: SessionQueryRuntime['SessionQueryError'],
): 'corrupt' | 'unavailable' | undefined {
if (!(error instanceof SessionQueryError)) return undefined
switch (error.code) {
case 'SESSION_QUERY_SESSION_NOT_FOUND':
case 'SESSION_QUERY_EVENT_NOT_FOUND':
case 'SESSION_QUERY_PERSISTENCE_FAILED':
return 'unavailable'
case 'SESSION_QUERY_INVALID_SURFACE':
case 'SESSION_QUERY_SOURCE_CONFLICT':
return 'corrupt'
default:
return undefined
}
}

View File

@@ -0,0 +1,63 @@
/**
* Settlement of one ONE-SHOT subagent run into a background-Task outcome. Only
* the one-shot background path uses Tasks; continuable children have no Task,
* no per-message result, and no Task cancellation.
*
* @module @deepseek-ai/dsh-subagent/run-settlement
*/
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { TaskOutcome } from '@deepseek-ai/dsh-tasks'
import type { SubagentResult, SubagentRun } from './types.ts'
/** Flatten a child's final output blocks to the task's final text. */
function finalText(blocks: ContentBlock[]): string {
return blocks
.filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text')
.map(block => block.text)
.join('')
}
/**
* Map a child result to the task outcome: completed carries final text,
* aborted is killed, and every other reason is failed without partial output.
* @param result - child terminal result.
* @returns outcome for the `ctx.tasks` registration.
*/
function runOutcome(result: SubagentResult): TaskOutcome {
switch (result.stopReason) {
case 'completed':
return { status: 'completed', output: finalText(result.output) }
case 'aborted':
return { status: 'killed' }
case 'error':
case 'max-tokens':
case 'refusal':
return { status: 'failed', detail: result.stopReason }
// Merge-extensible reasons remain failures with their raw detail.
default:
return { status: 'failed', detail: String(result.stopReason) }
}
}
/**
* Await the child result, dispose the run, then return its task outcome. Result
* and disposal failures become `failed`; when both fail, both details survive.
* @param run - live run to settle and release.
* @returns outcome after child resources are released.
*/
export async function settleRun(run: SubagentRun): Promise<TaskOutcome> {
let outcome: TaskOutcome
try {
outcome = runOutcome(await run.result)
} catch (error: unknown) {
outcome = { status: 'failed', detail: String(error) }
}
try {
await run.dispose()
} catch (error: unknown) {
const prefix = outcome.detail === undefined ? '' : `${outcome.detail}; `
return { status: 'failed', detail: `${prefix}dispose failed: ${String(error)}` }
}
return outcome
}

View File

@@ -1,5 +1,10 @@
/**
* Request, result, and capability contracts for {@link SubagentProvider}.
* The seam's consumer-facing contracts: request, result, and capability types
* for {@link SubagentProvider}, plus the `subagent/start` and `subagent/end`
* payloads that plugins and hosts observe. Internal control interfaces belong
* with their implementation — the lifecycle observer in `./lifecycle.ts`, the
* continuation host in `./continuation.ts` — so this module stays the published
* surface rather than a bag of everything type-shaped.
*
* @module @deepseek-ai/dsh-subagent/types
*/
@@ -7,8 +12,9 @@
import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent'
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import type { ObjectJsonSchema, ToolRestriction } from '@deepseek-ai/dsh-tools'
import type { SubagentDescriptorData } from './descriptor.ts'
/** Identifies one accepted subagent run across its lifecycle event pair. */
export type SubagentRunId = Branded<'SubagentRunId'>
@@ -22,14 +28,55 @@ export function SubagentRunId(id: string): SubagentRunId {
return id as SubagentRunId
}
/**
* Observe-only identifying detail for a published subagent run, carried by
* `subagent/start`. One-shot runs and continuable Activation epochs share this
* payload, so an observer sees the same vocabulary for both.
*/
export interface SubagentRunInfo {
/** Unique identity shared with the paired terminal event. */
readonly runId: SubagentRunId
/**
* Provider provenance for this run or Activation epoch. The named provider
* may be absent when an accepted run becomes ready or a persisted Activation
* cold-resumes, because neither lifecycle depends on continued registration.
*/
readonly provider: string
/** The child agent's id. */
readonly id: SessionId
/** Snapshot of whether `SubagentRun.localAgent` was present when start fulfilled. */
readonly local: boolean
}
/**
* Observe-only outcome detail for a settled subagent run, carried by
* `subagent/end` and paired with one {@link SubagentRunInfo} by `runId`.
*/
export interface SubagentRunEndInfo {
/** Unique identity shared with the paired start event. */
readonly runId: SubagentRunId
/** The same provider provenance carried by the paired start event. */
readonly provider: string
/** The child agent's id. */
readonly id: SessionId
/** Snapshot of whether `SubagentRun.localAgent` was present when start fulfilled. */
readonly local: boolean
/** The terminal stop reason. */
readonly stopReason: SubagentResult['stopReason']
/** The child's final assistant output, absent on infrastructure rejection. */
readonly lastAssistantMessage?: ContentBlock[]
}
/**
* Which START-TIME features a provider supports. Checked by the service before delegating to
* {@link SubagentProvider.start}: a request that needs a capability the chosen provider lacks
* is rejected with a typed error rather than accepted-then-ignored (the "fail loud, no silent
* degradation" rule). These static flags cover features needed before a run exists; runtime
* capabilities such as steering and resume are optional {@link SubagentRun} methods whose presence
* is the capability. Each flag corresponds one-to-one to a {@link SubagentStartRequest} option:
* `depthLimit` to `maxDepth`; the other names match.
* degradation" rule). These flags describe the ONE-SHOT
* {@link SubagentProvider.start} path, where the provider composes the child;
* continuable children are composed by the continuation manager itself and are
* gated by {@link SubagentProvider.prepareContinuable} instead. Each flag
* corresponds one-to-one to a {@link SubagentStartRequest} option: `depthLimit`
* to `maxDepth`; the other names match.
*/
export interface SubagentCapabilities {
readonly outputSchema: boolean
@@ -39,12 +86,15 @@ export interface SubagentCapabilities {
}
/**
* What a caller asks for when starting a subagent. The tool layer builds this
* from the model's `{ description, prompt }` plus its own config; the service
* validates {@link SubagentCapabilities} against the named provider, then
* passes it to {@link SubagentProvider.start}.
* What a caller asks for when starting a ONE-SHOT subagent. The tool layer
* builds this from the model's `{ description, prompt }` plus its own config;
* the service validates {@link SubagentCapabilities} against the named provider
* and resolves the durable descriptor before dispatching to
* {@link SubagentProvider.start}.
*/
export interface SubagentStartRequest {
/** Optional short display label persisted with a session-backed child. */
readonly label?: string
/** Content delivered as the child's user message. */
readonly prompt: ContentBlock[]
/**
@@ -57,8 +107,8 @@ export interface SubagentStartRequest {
* Cancellation signal from the spawning context (the tool's `exec.signal`).
* This is the canonical cancellation channel both before and after startup:
* a provider rejects `start()` after cleaning partial resources when it
* fires before publication, and cancels a published child when it fires
* afterward.
* fires before the run is published, and cancels the published run's
* remaining turn work when it fires afterward.
*/
readonly signal: AbortSignal
readonly agentOptions?: AgentOptions
@@ -93,6 +143,49 @@ export interface SubagentStartRequest {
readonly persona?: string
}
/**
* Provider-facing one-shot request after {@link SubagentService.start} resolves
* the durable child descriptor.
*/
export interface ResolvedSubagentStartRequest extends SubagentStartRequest {
/** Detached descriptor a session-backed provider persists in the child log. */
readonly descriptor: SubagentDescriptorData
}
/**
* What the continuation manager asks a provider for while materializing one
* continuable child's FIRST activation. The manager has already reserved the
* durable child identity and owns every later operation, so this request
* carries only what distinguishes a fresh child from one seeded with parent
* history.
*/
export interface ContinuableCreateRequest {
/** The reserved durable child session id, for provider diagnostics. */
readonly sessionId: SessionId
/** The delegating parent agent whose history a seeding provider reads. */
readonly parent: Agent
/**
* Caller cancellation, which owns preparation only until the manager accepts
* the initial prompt into the child's inbox.
*/
readonly signal: AbortSignal
}
/**
* A provider's detached contribution to one continuable child's creation. This
* is DATA, never a capability: it carries no Agent, `AgentHandle`, prompt
* delivery, result, disposal, or resume operation, because the continuation
* manager owns the child's whole lifecycle after preparation.
*/
export interface ContinuableCreateSpec {
/**
* Completed-turn prefix of the parent's log to seed the child session with,
* or absent for a fresh child. Same durable contract as
* `CreateAgentOptions.seed`: contiguous from seq 0, lossless JSON, balanced.
*/
readonly seed?: readonly SessionEvent[]
}
/**
* Why a subagent run ended. Merge-extensible (a backend may add variants);
* consumers branch on the known cases and fall through `default`. The known
@@ -134,9 +227,13 @@ export interface SubagentResult {
}
/**
* Child handle returned only after readiness. Consumers await {@link result} and must always
* {@link dispose} to cancel remaining work and reach quiescence. Optional methods are runtime
* capability discovery; narrow their presence before calling.
* ONE-SHOT child handle returned after publication. Prompt submission, turn
* work, and infrastructure faults after that boundary belong to {@link result}.
* Consumers await that result and must always {@link dispose} to cancel
* remaining work and reach quiescence. A run is one disposable foreground
* delegation with one result; continuable conversations have no run — the
* continuation manager holds their `AgentHandle` directly and orders every
* turn through the child's own inbox.
*/
export interface SubagentRun {
/**
@@ -155,8 +252,8 @@ export interface SubagentRun {
* Resolves with the child's terminal {@link SubagentResult} when the run
* settles. Does NOT reject on a child-level failure — a model/transport
* failure resolves with `stopReason: 'error'` so the consumer maps it to an
* `isError` tool result. Rejects only on an infrastructure fault the seam
* cannot represent as a stop reason.
* `isError` tool result. Rejects on an infrastructure fault the seam cannot
* represent as a stop reason.
*/
readonly result: Promise<SubagentResult>
/**
@@ -164,16 +261,6 @@ export interface SubagentRun {
* Idempotent.
*/
dispose(): Promise<void>
/**
* OPTIONAL (steering capability): send additional content to the running
* child between steps. Present only on providers that support live steering.
*/
sendMessage?(content: ContentBlock[]): void
/**
* OPTIONAL (resume capability): send a follow-up task to a settled child,
* continuing its session, and return a fresh run for the continuation.
*/
resume?(content: ContentBlock[]): Promise<SubagentRun>
}
/**
@@ -193,12 +280,28 @@ export interface SubagentProvider {
*/
readonly inheritsParentContext: boolean
/**
* Establish a child and return its handle only after publication. The
* service has already validated that every requested start-time capability
* is supported, so an implementation may assume e.g. `request.maxDepth` is
* honorable when present. If setup fails or `request.signal` aborts before
* fulfillment, the provider owns and cleans all partial resources before this
* promise rejects. Ownership transfers to the caller only on fulfillment.
* Establish a ONE-SHOT child and return its handle after publication.
* The service has already validated that every requested start-time
* capability is supported and resolved `request.descriptor`, so a
* session-backed implementation appends that descriptor inside the child's
* initial turn. Before fulfillment, the provider owns setup and cleans any
* unpublished partial resources before rejecting. Ownership transfers on
* fulfillment; subsequent turn or infrastructure failure settles through
* the returned run.
*/
start(request: SubagentStartRequest): Promise<SubagentRun>
start(request: ResolvedSubagentStartRequest): Promise<SubagentRun>
/**
* OPTIONAL (continuable-creation capability): contribute the detached
* creation inputs that distinguish this provider's continuable children —
* today only whether the child session is seeded with parent history. Method
* presence IS the capability: the service rejects continuable starts on
* providers without it, while a provider that has it may still serve
* ordinary one-shot delegations.
*
* This is the provider's ONLY participation in a continuable child. The
* continuation manager owns identity reservation, composition, Agent
* creation, prompt delivery, cold resume, ownership, and disposal, so a
* provider never sees the child's Agent, handle, turns, or teardown.
*/
prepareContinuable?(request: ContinuableCreateRequest): Promise<ContinuableCreateSpec>
}

View File

@@ -0,0 +1,164 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import SubagentActivationSetupRegistry from '../src/activation-setup-registry.ts'
/** A child-like scoped context with observable disposal. */
function childContext(): { ctx: Context; close: () => Promise<void> } {
const root = new Context()
const scope = root.plugin(function child() {})
return { ctx: scope.ctx, close: async () => { await scope.dispose() } }
}
describe('SubagentActivationSetupRegistry', () => {
it('installs contributions in registration order and commits them', () => {
const registry = new SubagentActivationSetupRegistry()
const order: string[] = []
registry.register(() => { order.push('first'); return () => order.push('undo-first') })
registry.register(() => { order.push('second'); return () => order.push('undo-second') })
const child = childContext()
const transaction = registry.apply(child.ctx)
expect(order).toEqual(['first', 'second'])
expect(() => { transaction.assertIntact() }).not.toThrow()
transaction.commit()
expect(order).toEqual(['first', 'second'])
})
it('makes repeated removal and converging ownership idempotent', async () => {
const registry = new SubagentActivationSetupRegistry()
let disposals = 0
const remove = registry.register(() => () => { disposals += 1 })
const child = childContext()
registry.apply(child.ctx).commit()
remove()
remove()
await child.close()
expect(disposals).toBe(1)
})
it('makes the opposite ownership convergence idempotent', async () => {
const registry = new SubagentActivationSetupRegistry()
let disposals = 0
const remove = registry.register(() => () => { disposals += 1 })
const child = childContext()
registry.apply(child.ctx).commit()
await child.close()
remove()
expect(disposals).toBe(1)
})
it('skips a contribution removed before a child is applied', () => {
const registry = new SubagentActivationSetupRegistry()
const installed: string[] = []
const remove = registry.register(() => { installed.push('gone'); return () => {} })
registry.register(() => { installed.push('kept'); return () => {} })
remove()
registry.apply(childContext().ctx).commit()
expect(installed).toEqual(['kept'])
})
it('invalidates a provisioning batch revoked before commit', () => {
const registry = new SubagentActivationSetupRegistry()
let disposals = 0
const remove = registry.register(() => () => { disposals += 1 })
const transaction = registry.apply(childContext().ctx)
remove()
expect(disposals).toBe(1)
expect(() => { transaction.assertIntact() }).toThrow(/revoked while this child was being built/)
})
it('catches a contribution revoked inside its own installer', () => {
const registry = new SubagentActivationSetupRegistry()
let disposals = 0
const self: { remove?: () => void } = {}
self.remove = registry.register(() => {
self.remove?.()
return () => { disposals += 1 }
})
const transaction = registry.apply(childContext().ctx)
expect(disposals).toBe(1)
expect(() => { transaction.assertIntact() }).toThrow(/revoked/)
})
it('attempts every contribution-removal disposer before reporting failures', () => {
const registry = new SubagentActivationSetupRegistry()
const released: string[] = []
let seq = 0
const remove = registry.register(() => {
const id = `child-${++seq}`
return () => {
released.push(id)
if (id === 'child-1') throw new Error('disposer exploded')
}
})
for (const child of [childContext(), childContext(), childContext()]) {
registry.apply(child.ctx).commit()
}
expect(() => { remove() }).toThrow(/failed to release 1 installation\(s\)/)
expect(released).toEqual(['child-1', 'child-2', 'child-3'])
})
it('attempts every child-scope disposer before reporting failures', async () => {
const registry = new SubagentActivationSetupRegistry()
const released: string[] = []
registry.register(() => () => {
released.push('a')
throw new Error('first disposer exploded')
})
registry.register(() => () => { released.push('b') })
const child = childContext()
registry.apply(child.ctx).commit()
await child.close().catch(() => undefined)
expect(released).toEqual(['a', 'b'])
})
it('rolls back earlier installations when a later contribution throws', () => {
const registry = new SubagentActivationSetupRegistry()
const undone: string[] = []
registry.register(() => () => undone.push('first'))
registry.register(() => { throw new Error('boom') })
registry.register(() => () => undone.push('third'))
expect(() => registry.apply(childContext().ctx)).toThrow(/boom/)
expect(undone).toEqual(['first'])
})
it('does not dispose twice when revocation precedes setup rollback', () => {
const registry = new SubagentActivationSetupRegistry()
const disposals: string[] = []
const removeFirst = registry.register(() => () => { disposals.push('first') })
registry.register(() => {
removeFirst()
throw new Error('second failed after revoking the first')
})
expect(() => registry.apply(childContext().ctx)).toThrow(/second failed/)
expect(disposals).toEqual(['first'])
})
it('does not cross-release independent child scopes', async () => {
const registry = new SubagentActivationSetupRegistry()
const disposed: string[] = []
let seq = 0
registry.register(() => {
const id = `child-${++seq}`
return () => disposed.push(id)
})
const first = childContext()
const second = childContext()
registry.apply(first.ctx).commit()
registry.apply(second.ctx).commit()
await first.close()
expect(disposed).toEqual(['child-1'])
await second.close()
expect(disposed).toEqual(['child-1', 'child-2'])
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -68,10 +68,10 @@ describe('subagent invariants', () => {
it('rejects malformed and unpaired run transitions', async () => {
const ctx = await setup()
expect(() => { emitRun(ctx, 'subagent/start', start()) }).toThrow(/inactive provider/)
ctx.emit('subagent/provider-added', provider('mock'))
expect(() => { emitRun(ctx, 'subagent/start', start({ provider: '' })) })
.toThrow(/provider, runId, and child id must be non-empty/)
expect(() => { emitRun(ctx, 'subagent/start', start({ runId: SubagentRunId('') })) })
.toThrow(/runId and child id must be non-empty/)
.toThrow(/provider, runId, and child id must be non-empty/)
emitRun(ctx, 'subagent/start', start())
expect(() => { emitRun(ctx, 'subagent/start', start()) }).toThrow(/repeated run id/)
expect(() => { emitRun(ctx, 'subagent/end', end({ runId: SubagentRunId('missing') })) })
@@ -79,4 +79,14 @@ describe('subagent invariants', () => {
expect(() => { emitRun(ctx, 'subagent/end', end({ id: SessionId('other') })) })
.toThrow(/identity diverges/)
})
it('accepts historical provider provenance after registration ends', async () => {
const ctx = await setup()
const historical = provider('historical')
ctx.emit('subagent/provider-added', historical)
ctx.emit('subagent/provider-removed', historical.name)
emitRun(ctx, 'subagent/start', start({ provider: historical.name }))
emitRun(ctx, 'subagent/end', end({ provider: historical.name }))
})
})

View File

@@ -0,0 +1,648 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
import SubagentService, {
SUBAGENT_DESCRIPTOR_VERSION,
SubagentError,
} from '@deepseek-ai/dsh-subagent'
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { TestSessionQueryService } from '../../../session-query/session-query/tests/test-service.ts'
type Script = ConstructorParameters<typeof MockAdapter>[0]
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
/** Boot the continuable stack plus a concrete session-query service. */
async function setup(script: Script, options: { sessionQuery?: boolean } = {}) {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-list-'))
roots.push(root)
await ctx.plugin(JsonlSessionPersistence, { root })
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentService)
await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
await ctx.plugin(SubagentFork, { providerName: 'fork' })
if (options.sessionQuery !== false) await ctx.plugin(TestSessionQueryService)
ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
return { ctx, parent }
}
const testSignal = new AbortController().signal
/** Start one continuable child through the real service path and await Activation release. */
async function startChild(
ctx: Context,
parent: ReturnType<Context['agentLoop']['create']>,
label: string,
): Promise<SessionId> {
const started = await ctx.subagents.startContinuable({
provider: 'spawn',
label,
request: { prompt: [{ type: 'text', text: `task: ${label}` }], parent },
signal: testSignal,
})
await vi.waitFor(() => {
expect(ctx.agents.get(started.childId)).toBeUndefined()
}, { timeout: 5_000 })
return started.childId
}
/** Author one persisted child session directly against the persistence backend. */
async function authorChild(
ctx: Context,
id: string,
header: Partial<SessionHeader>,
events: SessionEvent[],
): Promise<SessionId> {
const sessionId = SessionId(id)
await ctx.sessionPersistence.create({
version: SESSION_FORMAT_VERSION,
id: sessionId,
createdAt: 1,
...header,
})
await ctx.sessionPersistence.append(sessionId, events)
return sessionId
}
/** Minimal complete-turn child log with one descriptor payload. */
function childEvents(descriptor: unknown): SessionEvent[] {
return [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{
type: 'user/message',
seq: 1,
time: 2,
data: createUserMessage({ content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } }),
surfaceOp: 'append',
},
{ type: 'subagent/descriptor', seq: 2, time: 3, data: descriptor },
{ type: 'turn/end', seq: 3, time: 4, data: { turn: 1, reason: { kind: 'completed' } } },
] as SessionEvent[]
}
function descriptorPayload(label: string, version = SUBAGENT_DESCRIPTOR_VERSION) {
return { version, mode: 'continuable' as const, provider: 'spawn', label }
}
describe('SubagentService.listChildren', () => {
it('lists through session query without the Activation continuation runtime', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SubagentService)
await ctx.plugin(TestSessionQueryService)
expect(ctx.get('tasks')).toBeUndefined()
expect(ctx.get('agents')).toBeUndefined()
const parentId = SessionId('query-only-parent')
ctx.sessions.create(parentId)
const childId = SessionId('query-only-child')
const child = ctx.sessions.create(childId, { meta: { parentSession: parentId } })
child.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
child.append('subagent/descriptor', descriptorPayload('query-only child'))
await expect(ctx.subagents.listChildren(parentId)).resolves.toEqual([
{
kind: 'child', id: childId, label: 'query-only child', mode: 'continuable',
activity: 'running', hasChildren: false,
},
])
})
it('fails loud before any work when session query is not loaded', async () => {
const { ctx, parent } = await setup([], { sessionQuery: false })
await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow(
expect.objectContaining({ code: 'SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE' }) as Error,
)
})
it('lists a persisted continuable child as inactive with its durable label', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const childId = await startChild(ctx, parent, 'summarize the doc')
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([
{
kind: 'child', id: childId, label: 'summarize the doc', mode: 'continuable',
activity: 'inactive', hasChildren: false,
},
])
})
it('lists one-shot and continuable children from the same trace', async () => {
const { ctx, parent } = await setup([textResponse('once'), textResponse('again')])
const oneShot = await ctx.subagents.start('spawn', {
prompt: [{ type: 'text', text: 'finish once' }],
parent,
signal: new AbortController().signal,
})
const oneShotId = oneShot.id
await oneShot.result
await oneShot.dispose()
const continuableId = await startChild(ctx, parent, 'continuable child')
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toHaveLength(2)
expect(entries).toContainEqual({
kind: 'child',
id: oneShotId,
mode: 'one-shot',
activity: 'inactive',
hasChildren: false,
})
expect(entries).toContainEqual({
kind: 'child',
id: continuableId,
label: 'continuable child',
mode: 'continuable',
activity: 'inactive',
hasChildren: false,
})
})
it('accepts a persisted (non-live) parent target after restart', async () => {
const { ctx } = await setup([])
// A parent that exists only in persistence — the restart shape.
const coldParent = SessionId('00000000-0000-4000-8000-00000000cccc')
await ctx.sessionPersistence.create({
version: SESSION_FORMAT_VERSION,
id: coldParent,
createdAt: 1,
})
await ctx.sessionPersistence.append(coldParent, [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
] as SessionEvent[])
const childId = await authorChild(ctx, '00000000-0000-4000-8000-00000000cdcd', {
parentSession: coldParent,
}, childEvents(descriptorPayload('persisted parent case')))
const entries = await ctx.subagents.listChildren(coldParent)
expect(entries).toEqual([
{
kind: 'child', id: childId, label: 'persisted parent case', mode: 'continuable',
activity: 'inactive', hasChildren: false,
},
])
})
it('orders children by createdAt then id and omits ordinary forks without a diagnostic', async () => {
const { ctx, parent } = await setup([])
// Authored headers pin the ordering key deterministically: same createdAt
// ties break on id, different createdAt orders ascending.
const late = await authorChild(ctx, '00000000-0000-4000-8000-000000000003', {
parentSession: parent.id,
createdAt: 9,
}, childEvents(descriptorPayload('late child')))
const tieB = await authorChild(ctx, '00000000-0000-4000-8000-000000000002', {
parentSession: parent.id,
createdAt: 5,
}, childEvents(descriptorPayload('tie b')))
const tieA = await authorChild(ctx, '00000000-0000-4000-8000-000000000001', {
parentSession: parent.id,
createdAt: 5,
}, childEvents(descriptorPayload('tie a')))
// An ordinary session fork shares parentSession but has no descriptor.
const fork = ctx.sessions.fork(parent.session, undefined, SessionId('plain-fork'))
await ctx.sessions.flush(fork)
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries.map(entry => entry.id)).toEqual([tieA, tieB, late])
expect(entries.every(entry => entry.kind === 'child')).toBe(true)
})
it('reports a live child as running while keeping settled siblings complete', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const settled = await startChild(ctx, parent, 'settled child')
// A live child session outside persistence: publish a live session with a
// descriptor and the parent lineage, without starting an Activation.
const liveId = SessionId('live-child')
const live = ctx.sessions.create(liveId, { meta: { parentSession: parent.id } })
live.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
live.append('subagent/descriptor', descriptorPayload('live child'))
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toContainEqual({
kind: 'child', id: settled, label: 'settled child', mode: 'continuable',
activity: 'inactive', hasChildren: false,
})
expect(entries).toContainEqual({
kind: 'child', id: liveId, label: 'live child', mode: 'continuable',
activity: 'running', hasChildren: false,
})
})
it('diagnoses duplicate descriptors as corrupt without hiding healthy siblings', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const healthy = await startChild(ctx, parent, 'healthy sibling')
const events = childEvents(descriptorPayload('twice'))
events.splice(3, 0, {
type: 'subagent/descriptor',
seq: 3,
time: 3,
data: descriptorPayload('twice again'),
} as SessionEvent)
events[4] = { ...events[4]!, seq: 4 }
const corrupt = await authorChild(ctx, '00000000-0000-4000-8000-00000000dupe', {
parentSession: parent.id,
}, events)
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toContainEqual({ kind: 'diagnostic', id: corrupt, reason: 'corrupt' })
expect(entries).toContainEqual({
kind: 'child', id: healthy, label: 'healthy sibling', mode: 'continuable',
activity: 'inactive', hasChildren: false,
})
})
it('diagnoses an invalid child event surface as corrupt', async () => {
const { ctx, parent } = await setup([])
// The surface-eligible user/message lacks its required surfaceOp, so the
// per-child listEvents fold fails with SESSION_QUERY_INVALID_SURFACE.
const invalid = await authorChild(ctx, '00000000-0000-4000-8000-0000000000ee', {
parentSession: parent.id,
}, [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{
type: 'user/message',
seq: 1,
time: 2,
data: createUserMessage({ content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } }),
},
{ type: 'subagent/descriptor', seq: 2, time: 3, data: descriptorPayload('broken surface') },
] as SessionEvent[])
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([{ kind: 'diagnostic', id: invalid, reason: 'corrupt' }])
})
it('diagnoses a malformed descriptor payload as corrupt', async () => {
const { ctx, parent } = await setup([])
const malformed = await authorChild(ctx, '00000000-0000-4000-8000-0000000000ff', {
parentSession: parent.id,
}, childEvents({ version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'continuable', provider: 7 }))
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([{ kind: 'diagnostic', id: malformed, reason: 'corrupt' }])
})
it('diagnoses an unknown descriptor version as unsupported', async () => {
const { ctx, parent } = await setup([])
const future = await authorChild(ctx, '00000000-0000-4000-8000-0000000000aa', {
parentSession: parent.id,
}, childEvents(descriptorPayload('from the future', SUBAGENT_DESCRIPTOR_VERSION + 1)))
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([{ kind: 'diagnostic', id: future, reason: 'unsupported' }])
})
it('ignores an ancestor descriptor replayed inside a fork seed', async () => {
const { ctx, parent } = await setup([])
// A fork child whose seed replays a parent log containing a descriptor:
// the seed's descriptor is the ANCESTOR's, not this child's.
const seed = childEvents(descriptorPayload('ancestor label'))
await authorChild(ctx, '00000000-0000-4000-8000-0000000000f0', {
parentSession: parent.id,
seedLength: seed.length,
}, seed)
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([])
})
it('does not filter by provider availability: children of unmounted providers stay listed', async () => {
const { ctx, parent } = await setup([])
const foreign = await authorChild(ctx, '00000000-0000-4000-8000-0000000000bb', {
parentSession: parent.id,
}, childEvents({
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 'not-mounted',
label: 'orphan provider',
}))
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([
{
kind: 'child', id: foreign, label: 'orphan provider', mode: 'continuable',
activity: 'inactive', hasChildren: false,
},
])
})
it('maps a per-child read failure to one unavailable diagnostic after a successful trace', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const childId = await startChild(ctx, parent, 'flaky storage')
const query = ctx.get('sessionQuery')!
const originalListEvents = query.listEvents.bind(query)
query.listEvents = (sessionId) => {
if (sessionId === childId) {
return Promise.reject(new SessionQueryError('backend read failed', 'SESSION_QUERY_PERSISTENCE_FAILED'))
}
return originalListEvents(sessionId)
}
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'unavailable' }])
})
it('maps a mid-scan disappearance to unavailable', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const childId = await startChild(ctx, parent, 'vanishing child')
const query = ctx.get('sessionQuery')!
query.listEvents = () =>
Promise.reject(new SessionQueryError('gone', 'SESSION_QUERY_SESSION_NOT_FOUND'))
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'unavailable' }])
})
it('diagnoses a read whose header no longer names this parent as corrupt', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const childId = await startChild(ctx, parent, 'reparented child')
const query = ctx.get('sessionQuery')!
const originalReadEvent = query.readEvent.bind(query)
query.readEvent = async (request) => {
const window = await originalReadEvent(request)
return {
...window,
session: { ...window.session, parentSession: SessionId('someone-else') },
}
}
const entries = await ctx.subagents.listChildren(parent.id)
// The exact read's conflicting immutable header is per-child corruption.
expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'corrupt' }])
})
it('diagnoses a read whose target is no longer the descriptor event as corrupt', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const childId = await startChild(ctx, parent, 'shifted log')
const query = ctx.get('sessionQuery')!
const originalReadEvent = query.readEvent.bind(query)
query.readEvent = async (request) => {
const window = await originalReadEvent(request)
return { ...window, target: { ...window.target, type: 'turn/start' } as typeof window.target }
}
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'corrupt' }])
})
it('fails the whole call when the initial trace fails', async () => {
const { ctx, parent } = await setup([textResponse('done')])
await startChild(ctx, parent, 'never listed')
const query = ctx.get('sessionQuery')!
query.traceSession = () =>
Promise.reject(new SessionQueryError('listing failed', 'SESSION_QUERY_PERSISTENCE_FAILED'))
await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow(
expect.objectContaining({ code: 'SESSION_QUERY_PERSISTENCE_FAILED' }) as Error,
)
})
it('propagates an unrecognized per-child failure as an operation failure', async () => {
const { ctx, parent } = await setup([textResponse('done')])
await startChild(ctx, parent, 'strange failure')
const query = ctx.get('sessionQuery')!
query.listEvents = () => Promise.reject(new Error('not a query failure'))
await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow('not a query failure')
})
it('propagates a configuration/window query failure instead of diagnosing the child', async () => {
const { ctx, parent } = await setup([textResponse('done')])
await startChild(ctx, parent, 'misconfigured query')
const query = ctx.get('sessionQuery')!
query.listEvents = () =>
Promise.reject(new SessionQueryError('bad window', 'SESSION_QUERY_INVALID_WINDOW'))
await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow(
expect.objectContaining({ code: 'SESSION_QUERY_INVALID_WINDOW' }) as Error,
)
})
it('lists compacted and uncompacted children identically', async () => {
const { ctx, parent } = await setup([])
const plain = await authorChild(ctx, '00000000-0000-4000-8000-00000000c0de', {
parentSession: parent.id,
createdAt: 1,
}, childEvents(descriptorPayload('twin child')))
// The compacted twin: a compaction checkpoint replaces the whole surface,
// while the append-only log retains the model-hidden descriptor event.
const compactedEvents = childEvents(descriptorPayload('twin child'))
compactedEvents.push({
type: 'user/message',
seq: 4,
time: 5,
data: createUserMessage({
content: [{ type: 'text', text: 'summary of everything' }],
source: { kind: 'plugin', plugin: 'compact' },
}),
surfaceOp: { op: 'replace', start: 1, end: 1 },
sourceEventSeqs: [1],
})
const compacted = await authorChild(ctx, '00000000-0000-4000-8000-00000000c1de', {
parentSession: parent.id,
createdAt: 2,
}, compactedEvents)
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([
{
kind: 'child', id: plain, label: 'twin child', mode: 'continuable',
activity: 'inactive', hasChildren: false,
},
{
kind: 'child', id: compacted, label: 'twin child', mode: 'continuable',
activity: 'inactive', hasChildren: false,
},
])
})
it('reports an origin-classified grandchild without reading its events', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const childId = await startChild(ctx, parent, 'direct child')
const grandchildId = await authorChild(ctx, '00000000-0000-4000-8000-0000000000cc', {
parentSession: childId,
origin: 'subagent',
}, childEvents(descriptorPayload('grandchild')))
const query = ctx.get('sessionQuery')!
const originalListEvents = query.listEvents.bind(query)
const inspected: SessionId[] = []
query.listEvents = (sessionId) => {
inspected.push(sessionId)
return originalListEvents(sessionId)
}
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([
{
kind: 'child', id: childId, label: 'direct child', mode: 'continuable',
activity: 'inactive', hasChildren: true,
},
])
expect(inspected).toContain(childId)
expect(inspected).not.toContain(grandchildId)
})
it('does not count an ordinary grandchild without subagent origin', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const childId = await startChild(ctx, parent, 'direct child')
await authorChild(ctx, '00000000-0000-4000-8000-0000000000f1', {
parentSession: childId,
}, [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
] as SessionEvent[])
await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{
kind: 'child', id: childId, label: 'direct child', mode: 'continuable',
activity: 'inactive', hasChildren: false,
}])
})
it('counts an origin-classified diagnostic grandchild', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const childId = await startChild(ctx, parent, 'direct child')
const diagnosticId = await authorChild(ctx, '00000000-0000-4000-8000-0000000000f2', {
parentSession: childId,
origin: 'subagent',
}, childEvents({ version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'continuable', provider: 7 }))
await expect(ctx.subagents.listChildren(childId)).resolves.toEqual([
{ kind: 'diagnostic', id: diagnosticId, reason: 'corrupt' },
])
await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{
kind: 'child', id: childId, label: 'direct child', mode: 'continuable',
activity: 'inactive', hasChildren: true,
}])
})
it('stops the scan at the between-candidates checkpoint when the signal aborts', async () => {
const { ctx, parent } = await setup([textResponse('one'), textResponse('two')])
await startChild(ctx, parent, 'first child')
await startChild(ctx, parent, 'second child')
const controller = new AbortController()
const query = ctx.get('sessionQuery')!
const originalListEvents = query.listEvents.bind(query)
let inspected = 0
query.listEvents = (sessionId) => {
inspected += 1
// Cancel while the first candidate's read is in flight: the loop's next
// between-candidates checkpoint must stop before the second read.
controller.abort()
return originalListEvents(sessionId)
}
await expect(ctx.subagents.listChildren(parent.id, controller.signal)).rejects.toThrow(
expect.objectContaining({ code: 'CANCELLED' }) as Error,
)
expect(inspected).toBe(1)
})
it('forwards cancellation to the initial trace and reports the stable subagent error', async () => {
const { ctx, parent } = await setup([])
const controller = new AbortController()
const query = ctx.get('sessionQuery')!
const entered = Promise.withResolvers<undefined>()
query.traceSession = (_sessionId, signal) => {
entered.resolve(undefined)
return new Promise((_resolve, reject) => {
signal?.addEventListener('abort', () => {
reject(new Error('query trace aborted'))
}, { once: true })
})
}
const listing = ctx.subagents.listChildren(parent.id, controller.signal)
await entered.promise
controller.abort()
await expect(listing).rejects.toThrow(
expect.objectContaining({ code: 'CANCELLED' }) as Error,
)
})
it('forwards cancellation to the exact descriptor read and reports the stable subagent error', async () => {
const { ctx, parent } = await setup([textResponse('done')])
await startChild(ctx, parent, 'cancelled exact read')
const controller = new AbortController()
const query = ctx.get('sessionQuery')!
const entered = Promise.withResolvers<undefined>()
query.readEvent = (_request, signal) => {
entered.resolve(undefined)
return new Promise((_resolve, reject) => {
signal?.addEventListener('abort', () => {
reject(new Error('query read aborted'))
}, { once: true })
})
}
const listing = ctx.subagents.listChildren(parent.id, controller.signal)
await entered.promise
controller.abort()
await expect(listing).rejects.toThrow(
expect.objectContaining({ code: 'CANCELLED' }) as Error,
)
})
it('stops after a per-child read when the signal aborts mid-inspection', async () => {
const { ctx, parent } = await setup([textResponse('done')])
await startChild(ctx, parent, 'cancelled mid-read')
const controller = new AbortController()
const query = ctx.get('sessionQuery')!
const originalReadEvent = query.readEvent.bind(query)
let exactReads = 0
query.readEvent = async (request) => {
exactReads += 1
const window = await originalReadEvent(request)
controller.abort()
return window
}
// The post-read checkpoint throws a subagent error, which is not a
// session-query failure and therefore propagates instead of becoming a
// per-child diagnostic.
await expect(ctx.subagents.listChildren(parent.id, controller.signal))
.rejects.toThrow(expect.objectContaining({ code: 'CANCELLED' }) as Error)
expect(exactReads).toBe(1)
})
it('a mapped per-child failure during an abort cannot become a successful result', async () => {
const { ctx, parent } = await setup([textResponse('done')])
await startChild(ctx, parent, 'aborted behind a diagnostic')
const controller = new AbortController()
const query = ctx.get('sessionQuery')!
query.listEvents = () => {
// The read fails with a diagnostic-mapped code while the caller aborts:
// cancellation normalization must fail the scan rather than return a
// one-diagnostic success.
controller.abort()
return Promise.reject(new SessionQueryError('backend read failed', 'SESSION_QUERY_PERSISTENCE_FAILED'))
}
await expect(ctx.subagents.listChildren(parent.id, controller.signal)).rejects.toThrow(
expect.objectContaining({ code: 'CANCELLED' }) as Error,
)
})
it('a pre-aborted signal stops before any candidate read', async () => {
const { ctx, parent } = await setup([textResponse('done')])
await startChild(ctx, parent, 'never read')
const controller = new AbortController()
controller.abort()
const query = ctx.get('sessionQuery')!
query.listEvents = () => Promise.reject(new Error('must not be called'))
await expect(ctx.subagents.listChildren(parent.id, controller.signal)).rejects.toThrow(
expect.objectContaining({ code: 'CANCELLED' }) as Error,
)
})
it('returns an empty array for a parent with no children', async () => {
const { ctx, parent } = await setup([])
await ctx.sessions.flush(parent.session)
await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([])
})
it('SubagentError from listChildren is typed with its stable code', async () => {
const { ctx, parent } = await setup([], { sessionQuery: false })
const caught: unknown = await ctx.subagents.listChildren(parent.id).catch((error: unknown) => error)
expect(caught).toBeInstanceOf(SubagentError)
expect((caught as SubagentError).code).toBe('SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE')
})
})

View File

@@ -0,0 +1,13 @@
import { describe, expect, it, vi } from 'vitest'
describe('@deepseek-ai/dsh-subagent optional session-query peer', () => {
it('loads ordinary subagent operations without evaluating the optional query package', async () => {
vi.doMock('@deepseek-ai/dsh-session-query', () => {
throw new Error('optional session-query runtime was loaded eagerly')
})
const subagent = await import('../src/index.ts')
expect(subagent.SubagentService).toBeTypeOf('function')
})
})

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import { SessionId } from '@deepseek-ai/dsh-session'
import { settleRun } from '../src/index.ts'
describe('outcome mapping helpers', () => {
it.each([
['completed', { status: 'completed', output: 'partial' }],
['aborted', { status: 'killed' }],
['error', { status: 'failed', detail: 'error' }],
['max-tokens', { status: 'failed', detail: 'max-tokens' }],
['refusal', { status: 'failed', detail: 'refusal' }],
['paused', { status: 'failed', detail: 'paused' }],
] as const)('settleRun maps the %s stop reason onto its Task outcome', async (stopReason, expected) => {
const output = [{ type: 'text' as const, text: 'partial' }]
await expect(settleRun({
id: SessionId('child'),
localAgent: undefined,
result: Promise.resolve({ output, stopReason: stopReason as never }),
dispose: () => Promise.resolve(),
})).resolves.toEqual(expected)
})
it('settleRun disposes the run before reporting, on both result paths', async () => {
const order: string[] = []
const completed = await settleRun({
id: SessionId('child-1'),
localAgent: undefined,
result: Promise.resolve({ output: [{ type: 'text' as const, text: 'ok' }], stopReason: 'completed' as const }),
dispose() { order.push('dispose'); return Promise.resolve() },
})
order.push('reported')
expect(completed).toEqual({ status: 'completed', output: 'ok' })
expect(order).toEqual(['dispose', 'reported'])
// An infrastructure rejection still disposes and reports failed.
let disposed = false
const failed = await settleRun({
id: SessionId('child-2'),
localAgent: undefined,
result: Promise.reject(new Error('transport gone')),
dispose() { disposed = true; return Promise.resolve() },
})
expect(failed).toEqual({ status: 'failed', detail: 'Error: transport gone' })
expect(disposed).toBe(true)
const disposeFailed = await settleRun({
id: SessionId('child-4'),
localAgent: undefined,
result: Promise.resolve({ output: [], stopReason: 'completed' }),
dispose: () => Promise.reject(new Error('reap failed')),
})
expect(disposeFailed).toEqual({ status: 'failed', detail: 'dispose failed: Error: reap failed' })
const bothFailed = await settleRun({
id: SessionId('child-5'),
localAgent: undefined,
result: Promise.reject(new Error('result failed')),
dispose: () => Promise.reject(new Error('reap failed')),
})
expect(bothFailed).toEqual({
status: 'failed',
detail: 'Error: result failed; dispose failed: Error: reap failed',
})
})
})

View File

@@ -1,19 +1,23 @@
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
import { Context } from 'cordis'
import { type Agent } from '@deepseek-ai/dsh-agent'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import { carrierKeyOf } from '@deepseek-ai/dsh-scope'
import SubagentService, {
foldSubagentDescriptor,
snapshotSubagentDescriptor,
SUBAGENT_DESCRIPTOR_VERSION,
SubagentError,
assertSubagentMaxDepth,
type ResolvedSubagentStartRequest,
type SubagentCapabilities,
type SubagentProvider,
type SubagentResult,
type SubagentRun,
type SubagentStartRequest,
} from '@deepseek-ai/dsh-subagent'
import { SessionId } from '@deepseek-ai/dsh-session'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
function fakeParent(id = 'parent-1'): Agent {
return { id: SessionId(id) } as unknown as Agent
@@ -34,6 +38,7 @@ function baseRequest(overrides: Partial<SubagentStartRequest> = {}): SubagentSta
class StubProvider implements SubagentProvider {
readonly inheritsParentContext = false
startCount = 0
lastRequest: ResolvedSubagentStartRequest | undefined
constructor(
readonly name: string,
@@ -44,8 +49,9 @@ class StubProvider implements SubagentProvider {
},
) {}
async start(request: SubagentStartRequest): Promise<SubagentRun> {
async start(request: ResolvedSubagentStartRequest): Promise<SubagentRun> {
this.startCount += 1
this.lastRequest = request
return {
id: SessionId(`child:${this.name}:${request.parent.id}`),
localAgent: undefined,
@@ -99,6 +105,50 @@ describe('SubagentService', () => {
.rejects.toMatchObject({ code: 'NO_PROVIDER' })
})
it('resolves the one-shot descriptor and exposes no provider continuation operations', async () => {
const { subagents } = await service()
const provider = new StubProvider('one-shot')
subagents.registerProvider(provider)
const request = baseRequest()
await subagents.start('one-shot', request)
expect(provider.lastRequest).toEqual({
...request,
descriptor: {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'one-shot',
provider: 'one-shot',
},
})
expect(provider.lastRequest).not.toBe(request)
expectTypeOf<Parameters<SubagentService['start']>[1]>().toExtend<SubagentStartRequest>()
expect('resume' in subagents).toBe(false)
expect('resume' in provider).toBe(false)
})
it('does not expose manager teardown and treats a scoped drain as a no-op when no manager was bound', async () => {
const { subagents } = await service()
// Without `ctx.agents` no manager exists, so nothing was ever materialized.
expect('drainContinuable' in subagents).toBe(false)
await expect(subagents.drainContinuableDescendants([])).resolves.toBeUndefined()
})
it('rejects continuable operations when their runtime services are absent', async () => {
const { subagents } = await service()
await expect(subagents.startContinuable({
provider: 'unused',
label: 'unused child',
request: baseRequest(),
signal: new AbortController().signal,
})).rejects.toMatchObject({ code: 'CONTINUATION_UNAVAILABLE' })
await expect(subagents.followup(
fakeParent(),
SessionId('child'),
[{ type: 'text', text: 'hello' }],
{ source: { kind: 'user' }, signal: new AbortController().signal },
)).rejects.toMatchObject({ code: 'CONTINUATION_UNAVAILABLE' })
})
it.each([
['outputSchema', { outputSchema: { type: 'object', properties: {} } }],
['depthLimit', { maxDepth: 1 }],
@@ -247,3 +297,177 @@ describe('SubagentService', () => {
expect(error.code).toBe('NO_PROVIDER')
})
})
describe('subagent descriptors', () => {
const event = (data: unknown): SessionEvent<'subagent/descriptor'> => ({
type: 'subagent/descriptor',
data,
} as unknown as SessionEvent<'subagent/descriptor'>)
it('omits absent fields, recovers a complete payload, and rejects unsupported versions', () => {
expect(foldSubagentDescriptor([])).toBeUndefined()
const minimal = snapshotSubagentDescriptor({ mode: 'one-shot', provider: 'spawn' })
expect(minimal).toEqual({
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'one-shot',
provider: 'spawn',
})
expect(foldSubagentDescriptor([event(minimal)])).toEqual(minimal)
expect(snapshotSubagentDescriptor({
mode: 'one-shot',
provider: 'spawn',
label: 'child work',
})).toEqual({ ...minimal, label: 'child work' })
const complete = {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable' as const,
provider: 'spawn',
label: 'complete child',
agentProvider: 'deepseek',
agentModel: 'chat',
persona: 'reviewer',
toolFilter: { allow: ['read'], deny: ['bash'] },
}
expect(snapshotSubagentDescriptor({
mode: 'continuable',
provider: complete.provider,
label: complete.label,
agentProvider: complete.agentProvider,
agentModel: complete.agentModel,
persona: complete.persona,
toolFilter: complete.toolFilter,
})).toEqual(complete)
expect(foldSubagentDescriptor([event(complete)])).toEqual(complete)
expect(foldSubagentDescriptor([
event({
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 'spawn',
label: 'l',
toolFilter: { allow: ['read'] },
}),
])).toMatchObject({ toolFilter: { allow: ['read'] } })
expect(foldSubagentDescriptor([
event({
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 'spawn',
label: 'l',
toolFilter: { deny: ['bash'] },
}),
])).toMatchObject({ toolFilter: { deny: ['bash'] } })
expect(foldSubagentDescriptor([
event({ version: SUBAGENT_DESCRIPTOR_VERSION + 1, provider: 'spawn' }),
])).toBeUndefined()
expect(() => snapshotSubagentDescriptor({
mode: 'continuable',
provider: 'spawn',
label: 'bad',
toolFilter: { deny: [Symbol('not-json')] as unknown as string[] },
})).toThrow('not losslessly JSON-serializable')
})
it.each([
['string payload', 'invalid', 'payload must be an object'],
['null payload', null, 'payload must be an object'],
['array payload', [], 'payload must be an object'],
['missing version', { provider: 'spawn' }, 'version must be a number'],
['string version', { version: '1', provider: 'spawn' }, 'version must be a number'],
['missing mode', { version: SUBAGENT_DESCRIPTOR_VERSION }, 'mode must be "one-shot" or "continuable"'],
['invalid mode', { version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'later' }, 'mode must be "one-shot" or "continuable"'],
['unknown one-shot field', {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'one-shot',
provider: 'spawn',
label: 'l',
persona: 'reviewer',
}, 'payload has unknown field "persona"'],
['invalid one-shot label', {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'one-shot',
provider: 'spawn',
label: 7,
}, 'label must be a string'],
['unknown payload field', {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 'spawn',
extra: true,
}, 'payload has unknown field "extra"'],
['missing provider', { version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'continuable' }, 'provider must be a string'],
['missing label', {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 'spawn',
}, 'label must be a string'],
['invalid label', {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 'spawn',
label: 7,
}, 'label must be a string'],
['invalid provider', {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 7,
}, 'provider must be a string'],
['invalid agent provider', {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 'spawn',
label: 'l',
agentProvider: 7,
}, 'agentProvider must be a string'],
['invalid agent model', {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 'spawn',
label: 'l',
agentModel: [],
}, 'agentModel must be a string'],
['invalid persona', {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 'spawn',
label: 'l',
persona: {},
}, 'persona must be a string'],
['non-object tool filter', {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 'spawn',
label: 'l',
toolFilter: [],
}, 'toolFilter must be an object'],
['unknown tool-filter field', {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 'spawn',
label: 'l',
toolFilter: { except: ['bash'] },
}, 'toolFilter has unknown field "except"'],
['empty tool filter', {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 'spawn',
label: 'l',
toolFilter: {},
}, 'toolFilter must declare allow and/or deny'],
['non-array allow list', {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 'spawn',
label: 'l',
toolFilter: { allow: 'read' },
}, 'toolFilter.allow must be an array of strings'],
['non-string deny item', {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 'spawn',
label: 'l',
toolFilter: { deny: [7] },
}, 'toolFilter.deny must be an array of strings'],
])('rejects a malformed persisted descriptor: %s', (_case, data, detail) => {
expect(() => foldSubagentDescriptor([event(data)])).toThrow(detail)
})
})

View File

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