Merge remote-tracking branch 'origin/worktree/context-source-cards' into worktree/context-forms-remaining

# Conflicts:
#	apps/web/tests/snapshots/queue-actions/layout.expected.md
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/core.i18n.yaml
#	docs/core-data-structures/goal.i18n.yaml
#	docs/core-data-structures/goal.md
#	docs/core-data-structures/goal.zh.md
#	docs/event-producer-consumer.md
#	examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl
#	examples/acp-agent/tests/goal-snapshots/goal-wrapup/session.expected.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl
#	examples/acp-agent/tests/snapshots/bash-spill/session.jsonl
#	examples/acp-agent/tests/snapshots/bash-tool-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl
#	examples/acp-agent/tests/snapshots/cancel/session.jsonl
#	examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
#	examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl
#	examples/acp-agent/tests/snapshots/error-finish/session.jsonl
#	examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl
#	examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-edit/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-read/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-write/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl
#	examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl
#	examples/acp-agent/tests/snapshots/missing-sandbox-runner/session.jsonl
#	examples/acp-agent/tests/snapshots/multi-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/packed-chunks/session.jsonl
#	examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl
#	examples/acp-agent/tests/snapshots/partial-landlock-child-failure/session.jsonl
#	examples/acp-agent/tests/snapshots/pty-tools/session.jsonl
#	examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl
#	examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl
#	examples/acp-agent/tests/snapshots/session-sandbox-root/session.jsonl
#	examples/acp-agent/tests/snapshots/session-title-after-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/skill-load/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl
#	examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-list-agents/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-list-agents/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl
#	examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl
#	examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-published-run-failure/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-report/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-report/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl
#	examples/acp-agent/tests/snapshots/text-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/todo-write/session.jsonl
#	examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/web-fetch/session.jsonl
#	examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl
#	examples/acp-agent/tests/snapshots/workflow-run/session.jsonl
#	examples/acp-agent/tests/snapshots/workspace-context/session.jsonl
#	examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl
#	examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl
#	examples/headless-agent/tests/snapshots/pty-tools/session.jsonl
#	examples/headless-agent/tests/snapshots/pty-tools/stream-json.expected.jsonl
#	examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl
#	examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl
#	examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl
#	examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl
#	packages/bash/tool-bash/tests/integration.spec.ts
#	packages/context/time-context/src/index.ts
#	packages/context/tmux-context/src/index.ts
#	packages/core/agent-loop/src/agent.ts
#	packages/core/system-prompt/src/index.ts
#	packages/goal/goal/src/domain.ts
#	packages/goal/goal/src/index.ts
#	packages/goal/goal/src/render.ts
#	packages/plan/plan-mode/src/index.ts
This commit is contained in:
creatixchu
2026-08-06 11:49:03 +08:00
1357 changed files with 27674 additions and 18803 deletions

View File

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

View File

@@ -2,20 +2,18 @@
English | [中文](README.zh.md)
The subagent seam: an agent delegating work to a child agent. Like the [bash](../bash/README.md) and [llm](../llm/README.md) families this is a capability seam (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)) — but with one defining difference: **multiple provider implementations coexist in one context**, registered by name, rather than the single-implementation bash shape. The registry mirrors the LLM adapter registry.
This family lets an agent delegate work to child agents. Multiple named providers may coexist in one context.
| Package | Role | ctx key |
|---|---|---|
| `subagent/` | Subagent service: named-provider registry, vocabulary, durable descriptor, and continuable-child orchestration | `ctx.subagents` |
| `subagent-inprocess/` | Shared in-process run driver (no provider; one cleanup effect per run) | — |
| `subagent-spawn/` | In-process backend: a fresh child agent, with cold resume | (registers on `ctx.subagents`) |
| `subagent-fork/` | In-process backend: a child seeded with the parent's completed-turn prefix, with cold resume | (registers on `ctx.subagents`) |
| `subagent-acp/` | Out-of-process backend: a child agent in a spawned subprocess, driven over ACP (one-shot) | (registers on `ctx.subagents`) |
| `subagent-dsh-sdk/` | Out-of-process backend: a child harness runtime in a spawned subprocess, driven over stdio JSON-RPC through the TypeScript SDK client | (registers on `ctx.subagents`) |
| `tool-subagent/` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) |
| `tool-subagent-control/` | The optional, globally named `send_message` and `list_agents` tools over `ctx.subagents` | (registers on `ctx.tools`) |
| `tool-subagent-report/` | Child-scoped `report` return channel for continuable in-process children | (registers in each child scope) |
| [`subagent/`](subagent/README.md) | Defines provider registration, delegation, and continuation | `ctx.subagents` |
| [`subagent-inprocess/`](subagent-inprocess/README.md) | Provides the shared in-process run driver | — |
| [`subagent-spawn/`](subagent-spawn/README.md) | Starts a fresh in-process child | registers on `ctx.subagents` |
| [`subagent-fork/`](subagent-fork/README.md) | Starts an in-process child from the parent's completed history | registers on `ctx.subagents` |
| [`subagent-acp/`](subagent-acp/README.md) | Starts an out-of-process child over ACP | registers on `ctx.subagents` |
| [`subagent-dsh-sdk/`](subagent-dsh-sdk/README.md) | Starts an out-of-process Harness child through the TypeScript SDK | registers on `ctx.subagents` |
| [`tool-subagent/`](tool-subagent/README.md) | Exposes delegation to the model | registers on `ctx.tools` |
| [`tool-subagent-control/`](tool-subagent-control/README.md) | Exposes child messaging and listing to the model | registers on `ctx.tools` |
| [`tool-subagent-report/`](tool-subagent-report/README.md) | Provides the child-to-parent report channel | registers in child scopes |
The interface and continuation orchestration live at `subagent/subagent/`. One-shot provider `start` dispatch stays independent of persistence; an internal continuation manager owns each durable continuable child as one Session plus at most one process-local Activation, binding no Task, and exists only while the Agent service is present, resolving persistence per continuation operation. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` / `subagent-dsh-sdk` backends spawn their children through the [`subprocess/`](../subprocess/README.md) seam (the shared credential scrub, tree-scoped teardown, and dispose ladder). Tests replace only the child boundary with package-local fixtures.
The design rationale: [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), and [.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md).
See the decisions for the [capability family](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [continuable children](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), and [control tools](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md).

View File

@@ -2,20 +2,18 @@
[English](README.md) | 中文
subagent seam 允许 agent智能体工作委派给子 agent。与 [bash](../bash/README.md) 和 [llm](../llm/README.md) 能力家族一样,这也是一种能力 seam见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)),但有一个关键差异:**多个提供方实现在同一上下文中共存,并按名称注册**,而不是采用 bash 的单实现形态。该注册表仿照 LLM大语言模型适配器注册表
本家族允许一个 agent智能体工作委派给子 agent。多个具名提供方在同一上下文中共存。
| 包 | 角色 | ctx 键 |
| 包 | 职责 | ctx 键 |
|---|---|---|
| `subagent/` | subagent 服务:具名提供方注册表、词汇、持久化描述符与可继续子 agent 编排 | `ctx.subagents` |
| `subagent-inprocess/` | 共享进程内运行驱动器(不含提供方;每次运行使用一个清理 effect | 无 |
| `subagent-spawn/` | 进程内后端:支持冷恢复的全新子 agent | 注册到 `ctx.subagents` |
| `subagent-fork/` | 进程内后端:以父 agent 已完成轮次的前缀作为初始内容、支持冷恢复的子 agent | 注册到 `ctx.subagents` |
| `subagent-acp/` | 进程外后端:在 spawn 出的子进程中运行并通过 ACPAgent Client Protocol驱动的一次性子 agent | 注册到 `ctx.subagents` |
| `subagent-dsh-sdk/` | 进程外后端:在 spawn 出的子进程中运行的子 harness 运行时,经 TypeScript SDK 客户端走 stdio JSON-RPC 驱动 | 注册到 `ctx.subagents` |
| `tool-subagent/` | 面向模型的 `subagent` 委派工具,基于 `ctx.subagents` | 注册到 `ctx.tools` |
| `tool-subagent-control/` | 基于 `ctx.subagents`、可选且全局名称唯一的 `send_message``list_agents` 工具 | 注册到 `ctx.tools` |
| `tool-subagent-report/` | 子级作用域的 `report` 返回通道,用于可继续的进程内子级 | 注册到每个子级作用域 |
| [`subagent/`](subagent/README.md) | 定义提供方注册、委派和继续执行 | `ctx.subagents` |
| [`subagent-inprocess/`](subagent-inprocess/README.md) | 提供共享进程内运行驱动器 | 无 |
| [`subagent-spawn/`](subagent-spawn/README.md) | 启动全新的进程内子 agent | 注册到 `ctx.subagents` |
| [`subagent-fork/`](subagent-fork/README.md) | 从父 agent 已完成的历史记录启动进程内子 agent | 注册到 `ctx.subagents` |
| [`subagent-acp/`](subagent-acp/README.md) | 通过 ACPAgent Client Protocol启动进程外子 agent | 注册到 `ctx.subagents` |
| [`subagent-dsh-sdk/`](subagent-dsh-sdk/README.md) | 通过 TypeScript SDK 启动进程外 Harness 子 agent | 注册到 `ctx.subagents` |
| [`tool-subagent/`](tool-subagent/README.md) | 向模型公开委派操作 | 注册到 `ctx.tools` |
| [`tool-subagent-control/`](tool-subagent-control/README.md) | 向模型公开子级消息发送和列举操作 | 注册到 `ctx.tools` |
| [`tool-subagent-report/`](tool-subagent-report/README.md) | 提供从子级到父级的报告通道 | 注册到子级作用域 |
接口和继续执行编排位于 `subagent/subagent/`。一次性提供方的 `start` 分发不依赖持久化;内部继续执行管理器以一个 Session 和至多一个进程内 Activation 的形式拥有每个持久的可继续子 agent。该管理器不绑定任何 Task仅在 Agent 服务存在期间存在,并为每次继续执行操作分别解析持久化服务。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不含提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` / `subagent-dsh-sdk` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程共享的凭据清除、以进程树为范围的拆卸、dispose资源释放阶梯。测试只用包内 fixture测试前置数据替换子 agent 边界。
设计理由见 [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md) 和 [.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。
参见[能力家族](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可继续执行的子级](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)和[控制工具](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)决策。

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-acp/README.md
README.md: efcc77c442714a83631d009efb712fa7b8f5dfa0
README.zh.md: baeecb623d000778a6beb3fdf90dc7ef3ba365bc
README.md: 83a5f60414528bdb768ffccd29f3091793f44b6b
README.zh.md: 4ea8daef9341897463f3dbedca86fd2c83b45514

View File

@@ -61,8 +61,6 @@ The child spawns through the [`dsh-subprocess`](../../subprocess/subprocess/READ
The package has no default export. Cordis loader unwrapping would otherwise hide the named `inject` metadata; see [postmortem 0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md).
Keyless tests drive a scripted ACP subprocess over real stdio, including a Loader-composed stdio app proving parent-session cwd inheritance end to end. The with-key e2e drives the repository's real ACP agent and self-skips without `DEEPSEEK_API_KEY`.
## Model Experience
### Child-agent request
@@ -100,4 +98,3 @@ Append-only; newly visible content follows the reusable request prefix and does
- **No optional start-time capabilities** — this provider cannot apply the local harness's `outputSchema`, depth cap, tool filter, or persona inside the remote process, so it advertises none and the service rejects requests that require them.
- **Only committed `agent_message_chunk` text is collected** — the automation server keeps reasoning, tool activity, plans, and other trace data in the child session log rather than emitting them on ACP.
- **Permission prompts are auto-answered** (`permission: allow | reject`) — no human is surfaced a child's `session/request_permission` in this cut.
- **No snapshot-tier replay coverage** (`TODO(acp-subagent-replay)`) — an ACP child is its own process with its own replay shape, deferred.

View File

@@ -61,8 +61,6 @@ ACP 不声明任何启动时能力,因为当前进程无法强制执行远程
本包没有默认导出。否则 Cordis loader 的解包会隐藏具名 `inject` 元数据;见[事故复盘postmortem0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)。
无密钥测试通过真实 stdio 驱动脚本化 ACP 子进程,其中包括一个由 Loader 组合的 stdio 应用,用于端到端证明父会话 cwd 继承。带密钥 e2e 会驱动仓库中的真实 ACP agent没有 `DEEPSEEK_API_KEY` 时自行跳过。
## 模型体验
### 子 agent 请求
@@ -100,4 +98,3 @@ ACP 不声明任何启动时能力,因为当前进程无法强制执行远程
- **不支持可选启动时能力**:该提供方无法在远程进程内应用本地 harness 的 `outputSchema`、深度上限、工具过滤器或 persona因此不会声明这些能力服务会拒绝需要它们的请求。
- **只收集已提交的 `agent_message_chunk` 文本**自动化服务器把推理reasoning、工具活动、计划和其他 trace 数据保留在子 agent 会话日志中,不通过 ACP 发出。
- **权限提示自动回答**`permission: allow | reject`):当前版本不会把子 agent 的 `session/request_permission` 呈现给人。
- **没有快照层回放覆盖率**`TODO(acp-subagent-replay)`ACP 子 agent 拥有独立进程和独立回放形态,该工作延期处理。

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-dsh-sdk/README.md
README.md: a0c0811b585b69a634e49a2c838137655f316585
README.zh.md: 2d699dec90f3098b799da912ed184ae2429df0bd
README.md: c0e2f4e9ece366e28492e32d97afa533fd948141
README.zh.md: 4cf6c58fff44b1c11cf0a6c321da2c02d52bb8c9

View File

@@ -10,13 +10,13 @@ The SDK provider runs each subagent as a complete DeepSeek Harness runtime in a
The working directory resolves exactly like the ACP backend, through the seam's shared out-of-process helpers ([`dsh-subagent`](../subagent/README.md)): the configured `cwd` override when set (validated once at load), else the delegating parent session's cwd — never the server process's own cwd. The resolved path becomes the child process cwd and the workspace cwd of its SDK session.
The returned run id is minted in the parent namespace; the child runtime's session id exists only inside the child process. After publication the provider runs one SDK turn and reads the child's answer from its session events: the last complete `assistant/message`, or the `text-delta` stream accumulated so far when the turn was cut short — a partial answer survives cancel and error paths.
The returned run id is minted in the parent namespace; the child runtime's session id exists only inside the child process. After publication the provider owns one SDK activity and reads the child's answer from its session events: the last complete `assistant/message`, or the `text-delta` stream accumulated before the activity was cut short — a partial answer survives cancel and error paths.
`dispose()` is idempotent: it settles the result locally as `aborted` (there is no wire-level prompt cancel), then closes the runtime — a bounded protocol `shutdown` request followed by the shared stdin-EOF → SIGTERM → SIGKILL ladder to actual exit.
## Stop-reason mapping
The child reports its turn outcome as a structured `TurnEndReason` on `session.finished`; the provider maps it into the seam vocabulary. `completed``completed`, `max-tokens``max-tokens`, `aborted``aborted`; everything else — `error`, `interrupted`, `disposed`, a future variant, or a turn that never ran — maps to `error`, so an unclean stop is never reported as success. Transport-level failures after publication flatten to `stopReason: 'error'` through the `onError` diagnostic sink (wired to `ctx.logger.warn`); the seam contract forbids `result` rejecting.
The SDK client returns an owned child activity rather than a prompt result. The provider reads the last durable `turn/end` inside that activity and maps it into the seam vocabulary: `completed``completed`, `max-tokens``max-tokens`, `aborted``aborted`; everything else — `error`, `interrupted`, `disposed`, a future variant, or an activity with no turn — maps to `error`, so an unclean stop is never reported as success. Transport-level failures after publication flatten to `stopReason: 'error'` through the `onError` diagnostic sink (wired to `ctx.logger.warn`); the seam contract forbids `result` rejecting.
## Capabilities and context
@@ -59,8 +59,6 @@ The child environment is the [`dsh-subprocess`](../../subprocess/README.md) seam
The package has no default export. Cordis loader unwrapping would otherwise hide the named `inject` metadata; see [postmortem 0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md).
Keyless tests drive the SDK client package's scripted fake runtime over real stdio, including a Loader-composed e2e where the child is a real second harness runtime proving parent-session cwd inheritance end to end (`tests/loader-composition.e2e.ts`).
## Model Experience
### Child-agent request

View File

@@ -10,13 +10,13 @@ SDK 提供方会在全新的子进程中把每个 subagent 作为完整的 DeepS
工作目录的解析与 ACP 后端完全一致,并使用 seam 共享的进程外辅助工具([`dsh-subagent`](../subagent/README.md)):设置了 `cwd` 覆盖值时使用该值(加载时校验一次),否则使用发起委派的父会话 cwd绝不使用服务器进程自身的 cwd。解析出的路径同时成为子进程 cwd 和其 SDK 会话的工作区 cwd。
返回的 run id 在父级命名空间中生成;子运行时的会话 id 只存在于子进程内部。发布后,提供方运行一个 SDK 轮次,并从子会话事件中读取答案:最后一条完整的 `assistant/message`,或轮次被截断时已累积的 `text-delta` 流;部分答案在取消和错误路径上都得以保留。
返回的 run id 在父级命名空间中生成;子运行时的会话 id 只存在于子进程内部。发布后,提供方拥有一段 SDK 活动,并从子会话事件中读取答案:最后一条完整的 `assistant/message`,或该活动中断前已经累积的 `text-delta` 流;部分答案在取消和错误路径上都得以保留。
`dispose()`(资源释放)是幂等的:先在本地把结果确定为 `aborted`(协议层面没有提示词取消机制),再关闭运行时,即先发出一次有界的协议 `shutdown` 请求,随后通过共享的 stdin-EOF → SIGTERM → SIGKILL 阶梯使进程实际退出。
## 停止原因映射
子进程在 `session.finished` 上以结构化 `TurnEndReason` 报告轮次结果;提供方将其映射为 seam 词汇`completed``completed``max-tokens``max-tokens``aborted``aborted`;其余情况,包括 `error``interrupted``disposed`、未来变体或根本未运行轮次,均映射为 `error`,因此非正常停止绝不会报告为成功。发布后的传输层失败会通过 `onError` 诊断接收器(连接到 `ctx.logger.warn`)压平为 `stopReason: 'error'`seam 契约禁止 `result` 被拒绝。
SDK 客户端返回自有子活动,而不是提示词结果。提供方读取该活动内最后一个持久 `turn/end`,并将其映射为 seam 词汇`completed``completed``max-tokens``max-tokens``aborted``aborted`;其余情况,包括 `error``interrupted``disposed`、未来变体或不含轮次的活动,均映射为 `error`,因此非正常停止绝不会报告为成功。发布后的传输层失败会通过 `onError` 诊断接收器(连接到 `ctx.logger.warn`)压平为 `stopReason: 'error'`seam 契约禁止 `result` 被拒绝。
## 能力与上下文
@@ -59,8 +59,6 @@ Provider 不宣告任何启动期能力(`outputSchema`/`depthLimit`/`toolFilte
本包没有默认导出。否则 Cordis loader 解包会隐藏具名 `inject` 元数据;见[事故复盘postmortem0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)。
免密钥测试通过真实 stdio 驱动 SDK 客户端包的脚本化伪运行时,还包括一个 Loader 组合 e2e子进程是真实的第二个 harness 运行时,端到端证明父会话 cwd 继承(`tests/loader-composition.e2e.ts`)。
## 模型体验
### 子 agent 请求

View File

@@ -70,8 +70,8 @@ export const DEFAULT_SHUTDOWN_TIMEOUT_MS = 1_000
/**
* Map a child turn-end reason to a harness {@link SubagentStopReason}.
* @param reason - the `session.finished` reason, or `undefined` when the
* child settled without running a turn.
* @param reason - the owned child run's final durable turn reason, or
* `undefined` when it settled without running a turn.
* @returns the harness equivalent; an absent or unknown reason maps to
* `error`, so an unclean stop is never reported as `completed`.
*/
@@ -191,7 +191,10 @@ export async function startSdkRun(request: SubagentStartRequest, spec: SdkRunSpe
cancelSettled.then(() => 'cancelled' as const),
])
if (turn === 'cancelled') return { output: collectOutput(), stopReason: 'aborted' }
return { output: collectOutput(), stopReason: sdkStopReason(turn.reason) }
const lastEnd = turn.events.findLast(
(event): event is Extract<SessionEvent, { type: 'turn/end' }> => event.type === 'turn/end',
)
return { output: collectOutput(), stopReason: sdkStopReason(lastEnd?.data.reason) }
},
collectOutput,
cancelled: () => flags.cancelled,

View File

@@ -73,10 +73,10 @@ describe('sdkStopReason', () => {
it('maps each child turn-end reason to the harness vocabulary', () => {
expect(sdkStopReason({ kind: 'completed' })).toBe('completed')
expect(sdkStopReason({ kind: 'max-tokens' })).toBe('max-tokens')
expect(sdkStopReason({ kind: 'aborted' })).toBe('aborted')
expect(sdkStopReason({ kind: 'error', step: 0, message: 'x' })).toBe('error')
expect(sdkStopReason({ kind: 'aborted', reason: { kind: 'user' } })).toBe('aborted')
expect(sdkStopReason({ kind: 'error', error: { message: 'x', code: 'UNKNOWN' } })).toBe('error')
expect(sdkStopReason({ kind: 'interrupted' })).toBe('error')
expect(sdkStopReason({ kind: 'disposed' })).toBe('error')
expect(sdkStopReason({ kind: 'aborted', reason: { kind: 'disposed' } })).toBe('aborted')
})
it('treats an absent or unknown reason as an error', () => {
@@ -165,6 +165,17 @@ describe('dsh-subagent-dsh-sdk provider', () => {
await ctx.fiber.dispose()
})
it('keeps streamed text when a malformed final message prevents completion', async () => {
const ctx = await setup({ FAKE_MALFORMED_MESSAGE: '1', FAKE_TEXT: 'stream-only answer' })
const run = await ctx.subagents.start('dsh-sdk', request())
const result = await run.result
expect(result.stopReason).toBe('error')
expect(text(result.output)).toBe('stream-only answer')
await run.dispose()
await ctx.fiber.dispose()
})
it('reports a settled-without-turn child as an error', async () => {
const ctx = await setup({ FAKE_REASON_KIND: 'none', FAKE_STATUS: 'error' })
const run = await ctx.subagents.start('dsh-sdk', request())
@@ -218,16 +229,15 @@ describe('dsh-subagent-dsh-sdk provider', () => {
}
})
it('keeps accumulated streamed text when the turn is cut short before a full message', async () => {
// The fake streams one text-delta chunk and then violates the protocol on
// the same pipe; frame order guarantees the chunk was dispatched before
// the failure settles, so the accumulated partial text (no complete
// assistant/message ever arrived) must survive into the error result.
it('does not attribute streamed text when prompt acceptance is malformed', async () => {
// The fake streams one text-delta chunk but never returns the MessageId
// needed to establish this run's durable inbox receipt. The text therefore
// lies outside an owned activity interval and cannot become its output.
const ctx = await setup({ FAKE_STREAM_THEN_MALFORMED: '1' }, { shutdownTimeoutMs: 100, disposeEofGraceMs: 200, disposeGraceMs: 200 })
const run = await ctx.subagents.start('dsh-sdk', request())
const result = await run.result
expect(result.stopReason).toBe('error')
expect(text(result.output)).toBe('streamed then cut short')
expect(result.output).toEqual([])
await run.dispose()
await ctx.fiber.dispose()
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/subagent/subagent-inprocess/README.md
README.md: 61e5c8381fcd8972815129a8a2171fcf7d864113
README.zh.md: ab1dde515a26d4a83f5f430e4b22498b24c763f0
README.md: 67f0cf5dd1ecb18542af56953a0eaa40988aca0d
README.zh.md: 7536d265584f1f6c69027cda6bc4d81ad88b7c7c

View File

@@ -2,28 +2,31 @@
English | [中文](README.zh.md)
This package is the shared run driver for the two in-process providers' one-shot delegations. Spawn passes no session seed; fork passes the parent's completed-turn prefix. Everything else—depth, child creation, optional child customization, result reading, cancellation, and disposal—has one implementation here. Continuable children never come through this driver: the continuation manager in `@deepseek-ai/dsh-subagent` composes and drives them directly, so this driver owns exactly one turn with one result.
This package is the shared run driver for the two in-process providers. Spawn passes no session seed; fork passes the parent's completed-turn prefix. Everything else—depth, child creation, optional child customization, result reading, cancellation, and disposal—has one implementation here.
## Start contract
`startInProcessRun(request, options): Promise<SubagentRun>` fulfills as soon as the child is published in `ctx.agents`. A rejected start has already quiesced the agent factory's unpublished creation transaction, while turn or infrastructure failures after publication settle through the returned run without hiding the child id.
`startInProcessRun(request, options): Promise<SubagentRun>` fulfills only after the child is published in `ctx.agents`. A rejected start has already quiesced the agent factory's unpublished creation transaction, so the caller never receives a half-created handle.
The driver follows this sequence:
1. Validate the parent depth and optional absolute `maxDepth`, then derive child depth as parent depth plus one and persist it together with `origin: 'subagent'` in the child session header. Origin is a coarse product-navigation classifier; the later descriptor remains lifecycle and continuation authority.
2. Mint a fresh child session id and call `parent.ctx.agents.create` directly, passing the optional fork seed and required request signal into the factory's creation transaction. During the unpublished setup window, install the requested persona, tool restriction, structured-output runtime, and a one-shot `agent/step` contribution that appends the resolved `subagent/descriptor` event after the initial `turn/start` and before the first request.
3. Publish the child, retain the returned `AgentHandle`, and return its holder-owned run. The run's `result` drives one task with `child.followup(prompt)` followed by `child.whenIdle()`.
4. Read the child's own last assistant message and latest message-triggered turn reason, excluding the fork seed prefix so a seeded parent message is never mistaken for child output.
1. Validate the parent depth and optional absolute `maxDepth`, then derive child depth as parent depth plus one and persist it in the child session header.
2. Call `parent.ctx.agents.create` directly, passing the required request signal into the factory's creation transaction.
3. During that transaction's unpublished setup window, install the requested persona, tool restriction, and structured-output runtime.
4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.followup(prompt)` followed by `child.whenIdle()`.
5. Read the child's own last assistant message and final durable turn reason from the complete owned child run, excluding any fork seed.
The child gets the parent's working-directory/session lineage and inherits the parent provider, model, and output-token cap unless `request.agentOptions` overrides them. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset.
This result boundary is valid because the provider owns an isolated child lifecycle from publication through quiescence. Steering submitted during that lifecycle belongs to the child run; the provider does not pretend the initial follow-up alone owns its output.
When the optional sandbox-policy or approval service is composed, the driver snapshots the parent's explicit session override before child creation and appends a source-tagged event during unpublished setup, after any fork history and before session publication. It never copies deployment defaults or one-shot grants; later child switches still win. See the [policy-inheritance decision](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md).
## Cancellation and ownership
The required request signal covers both startup and the live run. Before publication, `AgentCreationTransaction` observes it, rolls back, and rejects. The factory detaches that creation-only listener before returning; the published run immediately installs its own listener and checks the signal again, closing the handoff race. Once publication has occurred, an abort preserves the returned child id, prevents unsubmitted work, and resolves an incomplete result as `aborted`; an abort during the turn cancels the child.
The required request signal covers both startup and the live run. Before publication, `AgentCreationTransaction` observes it, rolls back, and rejects. The factory detaches that creation-only listener before returning; the driver immediately checks the signal once more before installing a minimal live-run listener, closing the handoff race. After publication, abort cancels the child.
After fulfillment, the caller owns the run. Provider-plugin unload does not revoke it. `dispose()` removes the live abort listener, records cancellation, and awaits both `result` and the returned `AgentHandle.dispose()`; the handle's memoized quiescence transaction stops the loop, removes the agent and session, and unwinds scoped registrations. A result rejection remains on `result`; `dispose()` rejects only when handle disposal fails, after both operations settle. Cancellation owns every non-completed in-flight outcome and reports `aborted`; an already-completed turn remains completed.
After fulfillment, the caller owns the run. Provider-plugin unload does not revoke it. `dispose()` removes the live abort listener, records cancellation, and delegates to the returned `AgentHandle.dispose()`, whose memoized quiescence transaction stops the loop, removes the agent and session, and unwinds scoped registrations. Cancellation owns every non-completed in-flight outcome and reports `aborted`; an already-completed turn remains completed.
## Spawn and fork inputs
@@ -109,4 +112,5 @@ Append-only; newly visible content follows the reusable request prefix and does
## Known Limitations and Deferred Work
- **Runs expose no `sendMessage`/`resume`** — the optional runtime capabilities are absent on in-process runs.
- **Structured capture accepts the `defineTool` schema subset only** — unsupported JSON Schema constructs fail before the child is created; a provider needing a broader schema vocabulary requires a different runtime.

View File

@@ -2,27 +2,31 @@
[English](README.md) | 中文
本包是两个进程内提供方一次性委派共用的运行驱动器。spawn 不传入会话初始内容fork 传入父 agent智能体已完成轮次的前缀。其余机制包括深度、子 agent 创建、可选的子 agent 定制、结果读取、取消和 dispose资源释放都在此共用同一套实现。可继续子 agent 绝不通过本驱动器:`@deepseek-ai/dsh-subagent` 中的继续执行管理器会直接组合并驱动它们,因此本驱动器只拥有一个轮次和一个结果。
本包是两个进程内提供方共用的运行驱动器。spawn 不传入会话初始内容fork 传入父 agent智能体已完成轮次的前缀。其余机制包括深度、子 agent 创建、可选的子 agent 定制、结果读取、取消和 dispose资源释放都在此共用同一套实现。
## 启动契约
`startInProcessRun(request, options): Promise<SubagentRun>` 在子 agent 发布到 `ctx.agents`立即兑现。启动被拒绝时agent 工厂的未发布创建事务已经完全停稳;发布后的轮次或基础设施故障则通过返回的 run 结算,且不会隐藏 child id
`startInProcessRun(request, options): Promise<SubagentRun>` 在子 agent 发布到 `ctx.agents`兑现。启动被拒绝时agent 工厂的未发布创建事务已经完全停稳,因此调用方绝不会收到创建到一半的句柄
驱动器按以下顺序运行:
1. 校验父 agent 深度和可选的绝对 `maxDepth`,然后把子 agent 深度推导为父 agent 深度加一,并`origin: 'subagent'` 一同持久化到子 agent 会话 header。origin 是粗粒度产品导航分类器;后续描述符仍是生命周期与继续执行的权威依据
2. 生成全新的子 agent 会话 id直接调用 `parent.ctx.agents.create`,把可选的 fork 初始内容和必需的请求信号传入工厂的创建事务。在未发布的设置窗口中,安装请求的 persona、工具限制、结构化输出运行时以及一次性的 `agent/step` 贡献项;该贡献项会在初始 `turn/start` 之后、首次请求之前追加已解析的 `subagent/descriptor` 事件。
3. 发布子 agent保留返回的 `AgentHandle`,并返回由持有方拥有的 run。该 run 的 `result` 会通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务
4. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除 fork 初始内容前缀,确保作为初始内容的父 agent 消息绝不会被误认为子 agent 输出
1. 校验父 agent 深度和可选的绝对 `maxDepth`,然后把子 agent 深度推导为父 agent 深度加一,并将其持久化到子 agent 会话 header
2. 直接调用 `parent.ctx.agents.create`,把必需的请求信号传入工厂的创建事务。
3. 在该事务未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时
4. 发布子 agent,保留返回的 `AgentHandle`,并通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务
5. 从完整的自有子运行中读取子 agent 自身最后一条 assistant 消息和最终持久轮次原因,并排除任何 fork 初始内容。
子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型和输出 token 上限。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。
当组合中挂载了可选的沙箱策略或审批服务时,驱动器会在创建子 agent 前对父级的显式会话覆盖项获取快照,并在未发布的设置阶段追加一条带来源标记的事件,使其位于所有 fork 历史之后、会话发布之前。它绝不复制部署默认值或一次性授权;子 agent 后续的切换仍然优先。参见 [策略继承决策](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)
该结果边界成立,是因为提供方拥有从发布到完全停稳的隔离子 agent 生命周期。在该生命周期内提交的 steering中途引导属于子运行提供方不会声称输出只归初始 follow-up 所有
当组合中挂载了可选的沙箱策略或审批服务时,驱动器会在创建子 agent 前对父级的显式会话覆盖项获取快照,并在未发布的设置阶段追加一条带来源标记的事件,使其位于所有 fork 历史之后、会话发布之前。它绝不复制部署默认值或一次性授权;子 agent 后续的切换仍然优先。参见[策略继承决策](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)。
## 取消与所有权
必需的请求信号同时覆盖启动阶段和实时运行。发布前,`AgentCreationTransaction` 会观察该信号、回滚并拒绝。工厂返回前会移除仅用于创建阶段的监听器;已发布的 run 会立即安装自己的监听器并再次检查信号,从而消除交接竞态。一旦完成发布,中止会保留已返回的 child id、阻止尚未提交的工作并以 `aborted` 兑现未完成的结果;轮次期间发生中止时,则会取消子 agent。
必需的请求信号同时覆盖启动阶段和实时运行。发布前,`AgentCreationTransaction` 会观察该信号、回滚并拒绝。工厂返回前会移除仅用于创建阶段的监听器;驱动器随即再次检查信号,然后安装最小化的实时运行监听器,从而消除交接竞态。发布,中止会取消子 agent。
兑现后,调用方拥有该运行。提供方插件卸载不会撤销它。`dispose()` 会移除实时中止监听器、记录取消,并同时等待 `result`返回的 `AgentHandle.dispose()`该句柄通过经记忆化的完全停稳事务停止循环、移除 agent 和会话,并撤销作用域的注册。`result` 的 rejection 仍归 `result` 通道;只有句柄释放失败时,`dispose()` 才会在两项操作都结算后拒绝。取消流程会接管所有尚未完成的进行中结果,并将其报告为 `aborted`;已经完成的轮次仍保持完成状态。
兑现后,调用方拥有该运行。提供方插件卸载不会撤销它。`dispose()` 会移除实时中止监听器、记录取消,并委托给返回的 `AgentHandle.dispose()`后者通过可复用的完全停稳事务停止循环、移除 agent 和会话,并展开有作用域的注册。取消决定所有尚未完成的进行中结果,并将其报告为 `aborted`;已经完成的轮次仍保持完成状态。
## spawn 与 fork 输入
@@ -108,4 +112,5 @@ When you have your final answer, you MUST report it by calling the `structured_o
## 已知限制与暂缓事项
- **运行不公开 `sendMessage`/`resume`**:进程内运行不具备这些可选运行时能力。
- **结构化捕获只接受 `defineTool` schema 子集**:不支持的 JSON Schema 构造会在子 agent 创建前失败;需要更广 schema 词汇的提供方必须采用不同的运行时。

View File

@@ -55,7 +55,6 @@ function toStopReason(reason: TurnEndReason | undefined): SubagentStopReason {
case 'aborted':
return 'aborted'
case 'error':
case 'disposed':
case 'interrupted':
default:
return 'error'
@@ -76,10 +75,13 @@ function prePublicationAbort(): Error {
/** Append one one-shot descriptor inside the child's initial turn before its first request. */
function attachDescriptorAppend(childCtx: Context, descriptor: SubagentDescriptorData): void {
let appended = false
childCtx.on('agent/step', (agent) => {
if (appended) return
appended = true
agent.session.append('subagent/descriptor', descriptor)
childCtx.on('agent/pre-step', async (agent, _messages, _context, next) => {
const decision = await next()
if (!appended && decision.kind === 'enter') {
appended = true
agent.session.append('subagent/descriptor', descriptor)
}
return decision
})
}

View File

@@ -92,7 +92,7 @@ describe('startInProcessRun', () => {
const run = await startInProcessRun(request(parent), {})
await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' })
expect(flushes).toBe(1)
expect(flushes).toBe(0)
await run.dispose()
})
@@ -130,7 +130,7 @@ describe('startInProcessRun', () => {
expect(ctx.agents.list()).toHaveLength(beforeAgents)
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
})
it('reports the message-turn outcome when a later non-message turn completes during flush', async () => {
it('reports the turn outcome when later metadata is appended during flush', async () => {
const { ctx, parent } = await setup([maxTokensResponse('partial answer')])
let injected = false
ctx.on('session/flush', (session) => {
@@ -138,25 +138,19 @@ describe('startInProcessRun', () => {
const lastEnd = session.events.findLast(event => event.type === 'turn/end')
if (lastEnd?.type !== 'turn/end' || lastEnd.data.reason.kind !== 'max-tokens') return
injected = true
const turn = lastEnd.data.turn + 1
session.append('turn/start', {
turn,
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'late-metadata' } },
})
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'late metadata' }],
source: { kind: 'plugin', plugin: 'late-metadata' },
}), { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
})
const run = await startInProcessRun(request(parent), {})
const result = await run.result
const child = ctx.agents.get(run.id)!
expect(injected).toBe(true)
expect(injected).toBe(false)
expect(child.session.events.findLast(event => event.type === 'turn/end'))
.toMatchObject({ data: { reason: { kind: 'completed' } } })
.toMatchObject({ data: { reason: { kind: 'max-tokens' } } })
expect(result.stopReason).toBe('max-tokens')
await run.dispose()
})
@@ -288,7 +282,7 @@ describe('startInProcessRun', () => {
expect(adapter.requests[0]?.signal?.reason).toEqual({ kind: 'parent' })
const child = parent.ctx.agents.get(signalled.id)
const turnEnd = child?.session.events.findLast(event => event.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' })
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: { kind: 'parent' } })
await signalled.dispose()
const disposed = await startInProcessRun(request(parent), {})

View File

@@ -200,18 +200,6 @@ describe('dsh-subagent-spawn', () => {
expect(published).toEqual([])
})
it('a cancel from agent/inbox/enqueue maps a no-turn child log to aborted', async () => {
const { ctx, parent } = await setup([])
const controller = new AbortController()
ctx.on('agent/inbox/enqueue', () => { controller.abort('queued-window') })
const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent, signal: controller.signal })
const result = await run.result
expect(result).toMatchObject({ stopReason: 'aborted', output: [] })
const child = ctx.agents.get(run.id)!
expect(child.session.events.some(event => event.type === 'turn/end')).toBe(false)
await run.dispose()
})
it('cancelling a running child settles the run as aborted (the abort bridge + cancel())', async () => {
// 'hang' makes the child's model stream one chunk then wait until aborted.
const controller = new AbortController()

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: e54f0b98ec3649cec428a47026e6657a9749608b
README.zh.md: 074117cdbad52d754a449a0b6adc10daffa0c2f1
README.md: a21aa6ae2822d68d513fd9409d77b3f3bf74a7a3
README.zh.md: 3caa612aefcdac4f1dcdbcf4a3c1b81adc52c3d3

View File

@@ -4,21 +4,7 @@ English | [中文](README.zh.md)
The subagent seam lets one agent delegate work to a child through a named provider. Callers use one service API (`ctx.subagents`); providers decide whether the child runs in this process, in another process, or through a future transport.
## Package roles
The family separates the stable interface from implementations and model-facing tools:
| Package | Role |
|---|---|
| `@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.
The [subagent family overview](../README.md) maps implementations and model-facing consumers. This package owns the provider registry, shared request and result contracts, durable descriptors, and continuable-child orchestration. Multiple named providers may coexist behind that contract.
## Service API

View File

@@ -4,21 +4,7 @@
subagent seam 允许一个 agent智能体通过具名提供方把工作委派给子 agent。调用方使用统一的服务 API`ctx.subagents`);提供方决定子 agent 在当前进程、另一进程还是未来的传输之上运行。
## 包角色
该系列包把稳定接口与实现、面向模型的工具分开:
| 包 | 角色 |
|---|---|
| `@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而无需改变服务契约。
[subagent 家族概述](../README.md)列出了实现和面向模型的消费方。本包负责提供方注册表、共享请求和结果契约、持久描述符以及可继续子级编排。多个具名提供方可以在该契约背后共存。
## 服务 API
@@ -62,7 +48,7 @@ subagent seam 允许一个 agent智能体通过具名提供方把工作委
该 seam 拥有实现和消费方共享的深度词汇:`AgentOptions.subagentDepth` 声明、`assertSubagentMaxDepth``delegationDepthOf(agent)`。持久化的 `SessionHeader.delegationDepth` 具有权威性且单调:运行时选项可以加深计数,但绝不能降低它,因此恢复后的子 agent 不会被重新计为顶层。
`inheritsParentContext` 只用于描述,不能强制执行。它仅说明子 agent 是否能看到父级已完成的对话历史(`fork` 可以;`spawn` 和 ACP 不可以),不表示是否继承工具、服务或权限。
`inheritsParentContext` 只用于描述,不能强制执行。它仅说明子 agent 是否能看到父级已完成的对话历史(`fork` 可以;`spawn` 和 ACPAgent Client Protocol不可以),不表示是否继承工具、服务或权限。
## 一次性所有权与生命周期

View File

@@ -693,7 +693,7 @@ export class SubagentContinuationManager {
}
/**
* Cold-resume a persisted child: load and authorize its Session, fold the
* Cold-resume a persisted child: inspect and authorize its Session, fold the
* generic descriptor, create the Activation through `ctx.agents.resume()`,
* and submit the waiting turn. This never dispatches through a subagent
* provider — the persisted Session already holds the initial prefix and the
@@ -706,13 +706,13 @@ export class SubagentContinuationManager {
options: SubagentFollowupOptions,
): Promise<MessageId> {
const persistence = this.requirePersistence()
let loaded: Awaited<ReturnType<typeof persistence.load>>
let loaded: Awaited<ReturnType<typeof persistence.inspect>>
try {
loaded = await persistence.load(childId)
loaded = await persistence.inspect(childId, options.signal)
} catch (error: unknown) {
options.signal.throwIfAborted()
throw new SubagentError(`subagent "${childId}" is unavailable`, 'NOT_RESUMABLE', { cause: error })
}
// The persistence seam takes no signal; recheck before any child work.
options.signal.throwIfAborted()
this.assertAdmitting(parent)
// Authorize the persisted header before folding: only the durable child's
@@ -729,17 +729,24 @@ export class SubagentContinuationManager {
'NOT_RESUMABLE',
)
}
const activation = await this.materialize({
childId,
provider: descriptor.provider,
parent,
agentOptions: {
...descriptor.agentProvider !== undefined ? { provider: descriptor.agentProvider } : {},
...descriptor.agentModel !== undefined ? { model: descriptor.agentModel } : {},
},
composition: { persona: descriptor.persona, toolFilter: descriptor.toolFilter },
signal: options.signal,
})
let activation: Activation
try {
activation = await this.materialize({
childId,
provider: descriptor.provider,
parent,
agentOptions: {
...descriptor.agentProvider !== undefined ? { provider: descriptor.agentProvider } : {},
...descriptor.agentModel !== undefined ? { model: descriptor.agentModel } : {},
},
composition: { persona: descriptor.persona, toolFilter: descriptor.toolFilter },
signal: options.signal,
})
} catch (error: unknown) {
options.signal.throwIfAborted()
if (error instanceof SubagentError) throw error
throw new SubagentError(`subagent "${childId}" is unavailable`, 'NOT_RESUMABLE', { cause: error })
}
return this.submitMaterialized(activation, content, options.source, parent, options.signal)
}
@@ -852,16 +859,13 @@ export class SubagentContinuationManager {
// quiet Agent from one whose accepted turn has not been admitted yet.
// Registered through the child's own scoped context, so scope filtering
// already restricts both listeners to this exact agent.
handle.agent.ctx.on('agent/inbox/dequeue', (_agent, item) => {
/* v8 ignore next -- a dequeue of an id this manager never admitted needs
handle.agent.ctx.on('agent/inbox/claimed', (_agent, { message }) => {
/* v8 ignore next -- a claim of an id this manager never admitted needs
* another sender on the same child, which no current path allows. */
if (activation.accepted.delete(item.message.id)) this.wake(activation)
if (activation.accepted.delete(message.id)) this.wake(activation)
})
handle.agent.ctx.on('agent/inbox/discard', (_agent, items) => {
// Deleting every id in the batch is unconditional; waking once afterwards
// costs nothing and avoids branching on which ids this manager admitted.
for (const item of items) activation.accepted.delete(item.message.id)
this.wake(activation)
handle.agent.ctx.on('agent/inbox/discarded', (_agent, { message }) => {
if (activation.accepted.delete(message.id)) this.wake(activation)
})
// Agent creation committed setup at its publication boundary;
// revocations from here on are immediate live revocation.

View File

@@ -207,7 +207,6 @@ function epochStopReason(events: readonly SessionEvent[]): SubagentResult['stopR
return 'max-tokens'
case 'aborted':
case 'interrupted':
case 'disposed':
return 'aborted'
case 'error':
return 'error'

View File

@@ -1,8 +1,9 @@
/**
* 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.
* children. Only descendants with durable `origin: 'subagent'` enter per-child
* inspection. 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
*/
@@ -20,12 +21,13 @@ type SessionQueryRuntime = Pick<
>
/**
* 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.
* One entry of a {@link listChildren} result in trace candidate order. Only a
* candidate whose durable header has `origin: 'subagent'` is inspected. A
* valid descriptor produces a `child`, a per-child inspection failure produces
* a `diagnostic`, and a candidate without its own descriptor 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 =
| {
@@ -69,8 +71,9 @@ export type SubagentListEntry =
}
/**
* Interpret one parent's direct session descendants as session-backed subagents
* without loading or resuming an Agent.
* Interpret one parent's origin-classified direct descendants as session-backed
* subagents without loading or resuming an Agent. Ordinary forks are skipped
* before per-child event inspection.
* @see {@link SubagentService.listChildren} for the public cancellation and
* failure contract.
* @param ctx - context carrying the optional session-query service.
@@ -103,6 +106,7 @@ export async function listChildren(
)
const entries: SubagentListEntry[] = []
for (const node of trace.descendants) {
if (node.session.header.origin !== 'subagent') continue
const hasChildren = node.descendants.some(
descendant => descendant.session.header.origin === 'subagent',
)
@@ -218,6 +222,8 @@ function perChildDiagnosticReason(
): 'corrupt' | 'unavailable' | undefined {
if (!(error instanceof SessionQueryError)) return undefined
switch (error.code) {
case 'SESSION_QUERY_CORRUPT_SESSION':
return 'corrupt'
case 'SESSION_QUERY_SESSION_NOT_FOUND':
case 'SESSION_QUERY_EVENT_NOT_FOUND':
case 'SESSION_QUERY_PERSISTENCE_FAILED':

View File

@@ -142,11 +142,24 @@ async function waitNoActivation(ctx: Context, childId: SessionId): Promise<void>
}, { timeout: 5_000 })
}
/** Observe calls at the Agent cancellation boundary without a production event. */
function observeCancel(agent: Agent, callback: () => void): void {
const cancel = agent.cancel.bind(agent)
let observed = false
vi.spyOn(agent, 'cancel').mockImplementation((cause, options) => {
if (!observed) {
observed = true
callback()
}
cancel(cause, options)
})
}
describe('SubagentService.startContinuable', () => {
it('returns both identities at inbox acceptance, without waiting for the turn or the log', async () => {
const { ctx, parent, adapter } = await setup([textResponse('first answer')])
const enqueued: { id: MessageId; loggedYet: boolean }[] = []
ctx.on('agent/inbox/enqueue', (agent, accepted) => {
ctx.on('agent/inbox/inserted', (agent, accepted) => {
// Acceptance is the boundary `startContinuable` resolves at, so observe
// the log state exactly there rather than after later microtasks.
enqueued.push({ id: accepted.message.id, loggedYet: hasUserText(agent.session.events, 'child task') })
@@ -555,6 +568,47 @@ describe('SubagentService.followup residency routing', () => {
.rejects.toMatchObject({ code: 'NOT_RESUMABLE' })
})
it('propagates cancellation while inspecting a cold child', async () => {
const { ctx, parent } = await setup([textResponse('first')])
const started = await ctx.subagents.startContinuable(startSpec(parent))
await waitNoActivation(ctx, started.childId)
const inspectStarted = Promise.withResolvers<undefined>()
const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect').mockImplementation((_id, signal) => {
return new Promise<never>((_resolve, reject) => {
if (signal === undefined) {
reject(new Error('cold inspection must receive the followup signal'))
return
}
inspectStarted.resolve(undefined)
signal.addEventListener('abort', () => {
reject(reason)
}, { once: true })
})
})
const controller = new AbortController()
const reason = new Error('cold inspection cancelled')
try {
const delivery = followup(ctx, parent, started.childId, message('cancel me'), controller.signal)
await inspectStarted.promise
controller.abort(reason)
await expect(delivery).rejects.toBe(reason)
} finally {
inspect.mockRestore()
}
})
it('preserves a SubagentError raised while cold-materializing a child', async () => {
const { ctx, parent } = await setup([textResponse('first')])
const started = await ctx.subagents.startContinuable(startSpec(parent))
await waitNoActivation(ctx, started.childId)
const failure = new SubagentError('materialization denied', 'UNAUTHORIZED')
ctx.agents.resume = () => Promise.reject(failure)
await expect(followup(ctx, parent, started.childId, message('continue')))
.rejects.toBe(failure)
})
it('cold-resumes a delivery that lost the race with final disposal', async () => {
const { ctx, parent } = await setup([textResponse('first'), textResponse('after the race')])
const started = await ctx.subagents.startContinuable(startSpec(parent))
@@ -737,7 +791,9 @@ describe('continuable durability and teardown', () => {
const grandchild = await ctx.subagents.startContinuable(startSpec(targetChild))
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(3) })
const cancellations: SessionId[] = []
ctx.on('agent/cancel-requested', (agent) => { cancellations.push(agent.id) })
observeCancel(targetChild, () => { cancellations.push(targetChild.id) })
const grandchildAgent = ctx.agents.get(grandchild.childId)!
observeCancel(grandchildAgent, () => { cancellations.push(grandchildAgent.id) })
const drained = ctx.subagents.drainContinuableDescendants([parent])
const convergedDrain = ctx.subagents.drainContinuableDescendants([parent])
@@ -784,7 +840,8 @@ describe('continuable durability and teardown', () => {
const grandchild = await ctx.subagents.startContinuable(startSpec(child))
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) })
const cancellations: SessionId[] = []
ctx.on('agent/cancel-requested', (agent) => { cancellations.push(agent.id) })
const grandchildAgent = ctx.agents.get(grandchild.childId)!
observeCancel(grandchildAgent, () => { cancellations.push(grandchildAgent.id) })
const drained = ctx.subagents.drainContinuableDescendants([child])
@@ -828,7 +885,8 @@ describe('continuable durability and teardown', () => {
expect(ctx.agents.get(intermediateId)).toBeUndefined()
expect(ctx.agents.get(descendant.childId)).toBeDefined()
const cancellations: SessionId[] = []
ctx.on('agent/cancel-requested', (agent) => { cancellations.push(agent.id) })
const descendantAgent = ctx.agents.get(descendant.childId)!
observeCancel(descendantAgent, () => { cancellations.push(descendantAgent.id) })
const drained = ctx.subagents.drainContinuableDescendants([parent])
@@ -926,7 +984,7 @@ describe('continuable durability and teardown', () => {
const drains: Promise<void>[] = []
const accepted: MessageId[] = []
ctx.on('subagent/start', () => { drains.push(drainManager(ctx)) })
ctx.on('agent/inbox/enqueue', (_agent, item) => { accepted.push(item.message.id) })
ctx.on('agent/inbox/inserted', (_agent, item) => { accepted.push(item.message.id) })
await expect(ctx.subagents.startContinuable(startSpec(parent)))
.rejects.toMatchObject({ code: 'DRAINING' })
@@ -967,12 +1025,12 @@ describe('continuable durability and teardown', () => {
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
const child = ctx.agents.get(started.childId)!
const order: string[] = []
child.ctx.on('agent/inbox/enqueue', (_agent, accepted) => {
child.ctx.on('agent/inbox/inserted', (_agent, accepted) => {
if (accepted.message.content.some(block => block.type === 'text' && block.text === 'before drain')) {
order.push('enqueue')
}
})
child.ctx.on('agent/cancel-requested', () => { order.push('cancel') })
observeCancel(child, () => { order.push('cancel') })
const delivery = followup(ctx, parent, started.childId, message('before drain'))
// Let the child-lock operation reach the live admission cutoff. Admission
@@ -1150,9 +1208,9 @@ describe('continuable review regressions', () => {
const ends: SubagentRunEndInfo[] = []
ctx.on('subagent/end', (info) => { ends.push(info) })
// Block the resumed prompt so this epoch produces nothing of its own.
ctx.on('agent/prompt-submit', async (subject, _message, _signal, next) => {
ctx.on('agent/pre-step', async (subject, _messages, _context, next) => {
if (subject === parent) return next()
return { kind: 'block', reason: 'blocked by policy' }
return { kind: 'reject' }
})
await followup(ctx, parent, started.childId, message('again'))
await waitNoActivation(ctx, started.childId)
@@ -1258,7 +1316,7 @@ describe('continuable review regressions', () => {
expect(found).toBeDefined()
return found!
})
child.ctx.on('agent/cancel-requested', () => { order.push('cancel') })
observeCancel(child, () => { order.push('cancel') })
const drained = drainManager(ctx)
hold.resolve(undefined)
@@ -1298,7 +1356,7 @@ describe('continuable review regressions', () => {
// Cancel from the synchronous enqueue observer: the discard fires after the
// id is recorded but before `followup()` returns.
const off = child.ctx.on('agent/inbox/enqueue', (_agent, accepted) => {
const off = child.ctx.on('agent/inbox/inserted', (_agent, accepted) => {
if (accepted.message.content.some(block => block.type === 'text' && block.text === 'doomed')) {
child.cancel({ kind: 'user' })
}
@@ -1330,7 +1388,7 @@ describe('continuable review regressions', () => {
await followup(ctx, parent, started.childId, message('queued'))
expect(activation.accepted.size).toBe(1)
const off = child.ctx.on('agent/inbox/enqueue', (_agent, accepted) => {
const off = child.ctx.on('agent/inbox/inserted', (_agent, accepted) => {
if (accepted.message.content.some(block => block.type === 'text' && block.text === 'doomed')) {
child.cancel({ kind: 'user' })
}
@@ -1348,9 +1406,9 @@ describe('continuable review regressions', () => {
const ends: SubagentRunEndInfo[] = []
ctx.on('subagent/end', (info) => { ends.push(info) })
// Block admission so the child's only turn never opens.
ctx.on('agent/prompt-submit', async (subject, _message, _signal, next) => {
ctx.on('agent/pre-step', async (subject, _messages, _context, next) => {
if (subject === parent) return next()
return { kind: 'block', reason: 'blocked by policy' }
return { kind: 'reject' }
})
const started = await ctx.subagents.startContinuable(startSpec(parent))
@@ -1370,7 +1428,7 @@ describe('continuable review regressions', () => {
const registeredAtEnqueue: boolean[] = []
// A synchronous inbox observer runs before the admitting microtask, the
// exact window where `Agent.status` is still idle.
ctx.on('agent/inbox/enqueue', (agent) => {
ctx.on('agent/inbox/inserted', (agent) => {
if (agent.session.header.parentSession !== undefined) {
registeredAtEnqueue.push(ctx.agents.get(agent.id) === agent)
}

View File

@@ -113,10 +113,11 @@ describe('SubagentService.listChildren', () => {
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 } })
const child = ctx.sessions.create(childId, {
meta: { parentSession: parentId, origin: 'subagent' },
})
child.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
child.append('subagent/descriptor', descriptorPayload('query-only child'))
@@ -193,6 +194,7 @@ describe('SubagentService.listChildren', () => {
] as SessionEvent[])
const childId = await authorChild(ctx, '00000000-0000-4000-8000-00000000cdcd', {
parentSession: coldParent,
origin: 'subagent',
}, childEvents(descriptorPayload('persisted parent case')))
const entries = await ctx.subagents.listChildren(coldParent)
expect(entries).toEqual([
@@ -203,28 +205,33 @@ describe('SubagentService.listChildren', () => {
])
})
it('orders children by createdAt then id and omits ordinary forks without a diagnostic', async () => {
it('orders children by createdAt then id without inspecting ordinary forks', 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,
origin: 'subagent',
}, childEvents(descriptorPayload('late child')))
const tieB = await authorChild(ctx, '00000000-0000-4000-8000-000000000002', {
parentSession: parent.id,
createdAt: 5,
origin: 'subagent',
}, childEvents(descriptorPayload('tie b')))
const tieA = await authorChild(ctx, '00000000-0000-4000-8000-000000000001', {
parentSession: parent.id,
createdAt: 5,
origin: 'subagent',
}, childEvents(descriptorPayload('tie a')))
// An ordinary session fork shares parentSession but has no descriptor.
// An ordinary session fork shares parentSession but has no subagent origin.
const fork = ctx.sessions.fork(parent.session, undefined, SessionId('plain-fork'))
await ctx.sessions.flush(fork)
const listEvents = vi.spyOn(ctx.sessionQuery, 'listEvents')
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)
expect(listEvents).not.toHaveBeenCalledWith(fork.id)
})
it('reports a live child as running while keeping settled siblings complete', async () => {
@@ -233,8 +240,10 @@ describe('SubagentService.listChildren', () => {
// 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' } } })
const live = ctx.sessions.create(liveId, {
meta: { parentSession: parent.id, origin: 'subagent' },
})
live.append('turn/start', { turn: 1 })
live.append('subagent/descriptor', descriptorPayload('live child'))
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toContainEqual({
@@ -260,6 +269,7 @@ describe('SubagentService.listChildren', () => {
events[4] = { ...events[4]!, seq: 4 }
const corrupt = await authorChild(ctx, '00000000-0000-4000-8000-00000000dupe', {
parentSession: parent.id,
origin: 'subagent',
}, events)
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toContainEqual({ kind: 'diagnostic', id: corrupt, reason: 'corrupt' })
@@ -269,12 +279,13 @@ describe('SubagentService.listChildren', () => {
})
})
it('diagnoses an invalid child event surface as corrupt', async () => {
it('diagnoses a child rejected by persisted Session preparation 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.
// The surface-eligible user/message lacks its required surfaceOp. The
// first-party persistence inspection rejects before session-query can fold it.
const invalid = await authorChild(ctx, '00000000-0000-4000-8000-0000000000ee', {
parentSession: parent.id,
origin: 'subagent',
}, [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{
@@ -293,6 +304,7 @@ describe('SubagentService.listChildren', () => {
const { ctx, parent } = await setup([])
const malformed = await authorChild(ctx, '00000000-0000-4000-8000-0000000000ff', {
parentSession: parent.id,
origin: 'subagent',
}, 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' }])
@@ -302,6 +314,7 @@ describe('SubagentService.listChildren', () => {
const { ctx, parent } = await setup([])
const future = await authorChild(ctx, '00000000-0000-4000-8000-0000000000aa', {
parentSession: parent.id,
origin: 'subagent',
}, 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' }])
@@ -315,6 +328,7 @@ describe('SubagentService.listChildren', () => {
await authorChild(ctx, '00000000-0000-4000-8000-0000000000f0', {
parentSession: parent.id,
seedLength: seed.length,
origin: 'subagent',
}, seed)
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([])
@@ -324,6 +338,7 @@ describe('SubagentService.listChildren', () => {
const { ctx, parent } = await setup([])
const foreign = await authorChild(ctx, '00000000-0000-4000-8000-0000000000bb', {
parentSession: parent.id,
origin: 'subagent',
}, childEvents({
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
@@ -354,16 +369,30 @@ describe('SubagentService.listChildren', () => {
expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'unavailable' }])
})
it('maps a mid-scan disappearance to unavailable', async () => {
it.each([
['session', 'SESSION_QUERY_SESSION_NOT_FOUND'],
['descriptor event', 'SESSION_QUERY_EVENT_NOT_FOUND'],
] as const)('maps a missing child %s to unavailable', async (_target, code) => {
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'))
Promise.reject(new SessionQueryError('gone', code))
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'unavailable' }])
})
it('maps an invalid child surface to corrupt', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const childId = await startChild(ctx, parent, 'invalid surface')
const query = ctx.get('sessionQuery')!
query.listEvents = () =>
Promise.reject(new SessionQueryError('invalid surface', 'SESSION_QUERY_INVALID_SURFACE'))
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'corrupt' }])
})
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')
@@ -429,6 +458,7 @@ describe('SubagentService.listChildren', () => {
const plain = await authorChild(ctx, '00000000-0000-4000-8000-00000000c0de', {
parentSession: parent.id,
createdAt: 1,
origin: 'subagent',
}, 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.
@@ -447,6 +477,7 @@ describe('SubagentService.listChildren', () => {
const compacted = await authorChild(ctx, '00000000-0000-4000-8000-00000000c1de', {
parentSession: parent.id,
createdAt: 2,
origin: 'subagent',
}, compactedEvents)
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([

View File

@@ -130,7 +130,6 @@ describe('dsh-tool-subagent-control', () => {
: [])
// A follow-up is its own later turn, never steering inside the first one.
expect(prompts).toEqual(['long work', 'also consider Y'])
expect(loaded.events.some(event => event.type === 'steering/message')).toBe(false)
})
it('reports a delivery failure as an errored, not-delivered result', async () => {

View File

@@ -96,14 +96,15 @@ function callReport(ctx: Context, child: Agent, output: string, signal = testSig
})
}
/** Reports durably visible in one Agent's Session. */
/** Reports already visible or still pending in one Agent. */
function reports(agent: Agent): { id: string; text: string; sender: string }[] {
return agent.session.events.flatMap((event) => {
if (event.type !== 'user/message' || event.data.source.kind !== 'subagent-report') return []
const visible = agent.session.events.flatMap(event => event.type === 'user/message' ? [event.data] : [])
return [...visible, ...agent.inbox.nextStep].flatMap((message) => {
if (message.source.kind !== 'subagent-report') return []
return [{
id: event.data.id,
text: event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n'),
sender: event.data.source.senderSessionId,
id: message.id,
text: message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n'),
sender: message.source.senderSessionId,
}]
})
}
@@ -163,8 +164,10 @@ describe('dsh-tool-subagent-report', () => {
const { started, child } = await startChild(ctx, parent)
const parentRequests = adapter.requests.filter(request => request.sessionId === parent.id).length
const enqueues: string[] = []
ctx.on('agent/inbox/enqueue', (agent, item) => {
if (agent === parent) enqueues.push(item.placement)
ctx.on('agent/inbox/inserted', (agent, item) => {
if (agent === parent) {
enqueues.push(agent.inbox.nextTurn.some(message => message.id === item.message.id) ? 'queued' : 'steering')
}
})
const result = await callReport(ctx, child, 'CHILD_FINDING')
@@ -178,7 +181,7 @@ describe('dsh-tool-subagent-report', () => {
text: `Background subagent ${started.childId} reported:\nCHILD_FINDING`,
sender: started.childId,
}])
expect(enqueues).toEqual([])
expect(enqueues).toEqual(['steering'])
expect(parent.status).toBe('idle')
expect(adapter.requests.filter(request => request.sessionId === parent.id)).toHaveLength(parentRequests)
})
@@ -187,8 +190,10 @@ describe('dsh-tool-subagent-report', () => {
const { ctx, parent, adapter } = await setup({ config: { reportDelivery: 'wakeup' } })
const { child } = await startChild(ctx, parent)
const enqueues: string[] = []
ctx.on('agent/inbox/enqueue', (agent, item) => {
if (agent === parent) enqueues.push(item.placement)
ctx.on('agent/inbox/inserted', (agent, item) => {
if (agent === parent) {
enqueues.push(agent.inbox.nextTurn.some(message => message.id === item.message.id) ? 'queued' : 'steering')
}
})
const result = await callReport(ctx, child, 'WAKE_UP')
@@ -227,9 +232,9 @@ describe('dsh-tool-subagent-report', () => {
expect((await callReport(ctx, grandchild, 'FROM_GRANDCHILD')).isError).toBe(false)
expect(reports(parent)).toEqual([])
// The intermediate parent's turn is open, so quiet context is staged until
// that turn reaches its next safe log boundary.
expect(reports(child)).toEqual([])
// The intermediate parent's turn is open, so quiet context is pending in
// its inbox until that turn reaches its next safe log boundary.
expect(reports(child)).toHaveLength(1)
adapter.release()
await vi.waitFor(() => { expect(reports(child)).toHaveLength(1) })
expect(reports(child)[0]?.sender).toBe(grandchildStart.childId)