Merge branch 'master' into feat/produced-files-folder

This commit is contained in:
Ziya
2026-08-11 13:25:22 +08:00
committed by GitHub
25 changed files with 325 additions and 25 deletions
@@ -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 .agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md
2026-07-10-parallel-tool-call-execution.md: 830691b7596accd1c5746fc51e38a9022f09e501
2026-07-10-parallel-tool-call-execution.zh.md: bd336919adbf9b96f64477c1e24964172cde6d92
2026-07-10-parallel-tool-call-execution.md: 47a95e88c3d0d8bb9e3ddd820b1e63849effd914
2026-07-10-parallel-tool-call-execution.zh.md: 024e5d4e4cdbf9d1d980bc67db5969fb34941752
@@ -60,7 +60,7 @@ Any shared state touched during execution must be concurrency-safe. This include
`maxParallelToolCalls` is a positive AgentLoop deployment cap shared by every agent the factory creates. It defaults to `10`; `1` preserves serial execution. Exact fields and defaults live in the generated [configuration catalog](../../../../docs/config-catalog.md).
The shipped declarations are conservative. Web search, web fetch, and filesystem read opt in. Filesystem writes and edits, bash tools, subagent delegation, workflow, user interaction, todo mutation, Code Mode, and Cordis mutation tools remain exclusive. A subagent may share its parent's workspace or external resources, and the unary classifier cannot prove that sibling delegations have disjoint effects. Bash has no proven input-sensitive classifier and remains exclusive.
The shipped declarations are conservative. Web search, web fetch, filesystem read, the session-query trace/read tools, and subagent delegation opt in — delegation because a child works in its own session and its run never mutates the parent session, with sibling workspace coordination owned by the model ([parallel subagent Agent Note](2026-08-09-parallel-subagent-delegations.md)). Filesystem writes and edits, bash tools, the session-query search tools, workflow, user interaction, todo mutation, Code Mode, and Cordis mutation tools remain exclusive. Bash has no proven input-sensitive classifier and remains exclusive.
Filesystem read relies on a narrow recorder exception: its synchronous observation updates may settle out of order, but write and edit re-check the observed version before mutation, so stale state only produces `FS_STALE_VERSION`.
@@ -60,7 +60,7 @@ Code Mode 仍不使用此调度器,因为模型只会发出一个原生 `run_c
`maxParallelToolCalls` 是 AgentLoop 的正整数部署上限,由工厂创建的所有 agent(智能体)共享。默认值为 `10``1` 保持串行执行。字段和默认值的精确定义见生成的[配置目录](../../../../docs/config-catalog.md)。
当前实现中的声明保持保守。Web 搜索、Web 获取文件系统读取选择并行。文件系统写入与编辑、bash 工具、subagent 委派、工作流、用户交互、todo 变更、Code Mode 以及 Cordis 变更工具仍按独占方式执行。subagent 可能共享父级的工作区或外部资源,而一元分类器无法证明并列委派的作用互不重叠。Bash 没有已证明的输入敏感分类器,因此仍按独占方式执行。
当前实现中的声明保持保守。Web 搜索、Web 获取文件系统读取、会话查询的 trace/read 工具和 subagent 委派选择并行;委派之所以并行,是因为子 agent 在自己的会话中工作,其运行绝不变更父会话,并列委派间的工作区协调由模型负责([并行 subagent Agent Note](2026-08-09-parallel-subagent-delegations.md)。文件系统写入与编辑、bash 工具、会话查询的 search 工具、工作流、用户交互、todo 变更、Code Mode 以及 Cordis 变更工具仍按独占方式执行。Bash 没有已证明的输入敏感分类器,因此仍按独占方式执行。
文件系统读取依赖一个范围很窄的记录器例外:其同步观察更新可以不按顺序结算,但写入和编辑在变更前会重新检查已观察的版本,因此陈旧状态只会导致 `FS_STALE_VERSION`
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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 .agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.md
2026-08-09-parallel-subagent-delegations.md: 01dc2043c9a79666857c4f5aa988fc45dc7cdf56
2026-08-09-parallel-subagent-delegations.zh.md: 2c2bd700e362000b41adbd1f3cec9ba26ff60b93
@@ -0,0 +1,47 @@
# Agent Note: Parallel subagent delegations
Status: implemented
English | [中文](2026-08-09-parallel-subagent-delegations.zh.md)
## Problem
A model that wants fan-out batches several `subagent` calls into one assistant message — that batch is the parallel intent. The delegation tool declared no `isConcurrencySafe` classifier, so the fail-closed scheduler ([parallel tool-call Agent Note](2026-07-10-parallel-tool-call-execution.md)) treated every foreground delegation as an exclusive barrier: nine cards in the GUI, one child running, eight queued behind it for its full runtime.
The original conservative stance — a unary classifier cannot prove that sibling delegations have disjoint workspace effects — had stopped protecting anything. `run_in_background: true` and continuable delegations already overlap with every later call, including writes; `dsh-workflow-workerthread` already runs up to its concurrency ceiling of children through the same `ctx.subagents.start()` providers against the shared workspace. Only the foreground variant was serialized.
## Decision
`dsh-tool-subagent` declares `isConcurrencySafe: () => true` for every call form (foreground, one-shot background, continuable), so sibling delegations in one assistant step overlap under the loop's rolling pool up to `maxParallelToolCalls`, with results still committed in model order.
The declaration satisfies the scheduler's safety contract structurally: a child works in its own session, a run never mutates the parent session (the start-time appends — `sandbox/mode`, `approval/policy`, `subagent/descriptor` — land only in the child's own log), and the tool returns its outputs to the loop for ordered commit. The one-shot background form's one parent-owned write is registering a Task through `tasks.start` — a synchronous, commutative insertion that satisfies the scheduler note's shared-state clause rather than the stronger no-mutation property. The provider seam requires concurrent starts and continuable preparations for distinct children to isolate operation-local state, cancellation, settlement, and cleanup. The bundled providers satisfy that contract: spawn and fork keep no mutable state between starts, fork reads only the parent's completed-turn prefix, out-of-process providers allocate state per run, and the continuation manager reserves a unique child identity and lock for each preparation.
Coordinating sibling workspace effects is the model's responsibility, the stance the product already takes for background, continuable, and workflow children. Peer harnesses agree: Claude Code's Task tool is unconditionally concurrency-safe (cap 10), oh-my-pi's task tool defaults to its overlapping `shared` class, opencode's task tool runs unbounded under its SDK, and Codex sidesteps the question by making delegation an asynchronous spawn/wait mailbox.
Capacity stays where the scheduler note put it: `maxParallelToolCalls` caps one step's unsettled tool calls — and therefore concurrently running foreground children — while background and continuable calls settle at start and free their pool slot, so children they leave running are not capped by it. LLM providers own their own capacity controls.
## Testing
Package tests pin the classifier for both call forms. A gate test drives the registry directly with two children that each block until both have started, proving the half the declaration depends on: the tool body and provider start path tolerate concurrent dispatch — hidden serialization in that stack would deadlock instead of passing silently. A continuable gate holds two provider preparations at the same await, cancels one caller before publication, and proves that the cancelled child leaves no Agent or durable Session while its sibling reaches inbox acceptance and persists independently. The scheduling half, classification actually producing overlap, is owned by the classifier pin and the snapshot below.
The authored `subagent-parallel` snapshot pins the assembled-app transcript: one assistant message carries two subagent calls, the parent log records `tool/call, tool/call, tool/result, tool/result` (serial execution would interleave call/result pairs), and both children complete as separate sessions. Its twin delegations are deliberately identical: `dsh-llm-replay` binds child scripts by first-call order and the harvester orders children by `createdAt`, and neither is deterministic across concurrent children (the `XXX(concurrent-subagents)` marker), so only interchangeable twins replay race-free today.
## Alternatives considered
**Keep delegations exclusive.** The status quo protected nothing: background and workflow children already overlap freely with writes, so serializing the foreground variant only added latency and contradicted the model's explicit batching intent.
**An input-sensitive classifier.** The call's arguments are a free-text description and prompt; nothing in them distinguishes a safe delegation from an unsafe one, so a conditional classifier would be theater.
**A Codex-style asynchronous spawn/wait redesign.** Continuable children plus `send_message` already provide the asynchronous channel; rebuilding the foreground contract around a mailbox would discard a working synchronous result path to solve a scheduling problem one declaration fixes.
**A per-instance `concurrencySafe` config knob.** No consumer needs a serial deployment: `maxParallelToolCalls: 1` already restores global serial execution, and peer-harness prior art defaults delegation to concurrency-safe.
## Consequences
Sibling children can race on shared workspace or external resources; the model owns that coordination, as it already does for every other overlapping child. Concurrent children also compete for LLM provider quota; `maxParallelToolCalls` caps only unsettled calls, not children a background or continuable call left running.
Two one-shot background delegations in one message acquire their model-visible task ids (`subagent-<n>`) in dispatch-race order. The ids are logged, so replay stays valid, but a snapshot scenario that distinguishes its background children would inherit the same determinism constraint as twin child sessions.
Ordered commits may hold a fast child's result behind a slow earlier sibling — the trade the [scheduler note](2026-07-10-parallel-tool-call-execution.md) already accepted; live surfaces still show each child's own progress.
A concurrent-children snapshot scenario with distinct prompts still needs replay-harness support (deterministic child-script binding and harvest ordering); until then such scenarios must use interchangeable twin delegations.
@@ -0,0 +1,47 @@
# Agent Note: 并行 subagent 委派
Status: implemented
[English](2026-08-09-parallel-subagent-delegations.md) | 中文
## 问题
想要扇出的模型会把多个 `subagent` 调用合并进同一条 assistant 消息:这个批次本身就是并行意图。委派工具此前没有声明 `isConcurrencySafe` 分类器,按安全侧原则设计的调度器([并行工具调用 Agent Note](2026-07-10-parallel-tool-call-execution.md))便把每个前台委派都当作独占屏障:GUI 里显示九张卡片,却只有一个子 agent(智能体)在运行,其余八个要在它的整个运行期间排在其后等待。
最初的保守立场(一元分类器无法证明同级委派的工作区效果互不相交)已经不再保护任何东西:`run_in_background: true` 和可继续委派本来就会与其后的每个调用重叠执行,包括写入;`dsh-workflow-workerthread` 也早已通过同样的 `ctx.subagents.start()` 提供方在共享工作区上并发运行子 agent,数量可达其并发上限。只有前台形态被串行化。
## 决策
`dsh-tool-subagent` 为每种调用形态(前台、一次性后台、可继续)都声明 `isConcurrencySafe: () => true`,因此同一 assistant 步骤中的同级委派会在循环的滚动池下重叠执行,上限为 `maxParallelToolCalls`,结果仍按模型顺序提交。
该声明在结构上满足调度器的安全约定:子 agent 在自己的会话中工作,运行绝不变更父会话(启动时追加的 `sandbox/mode``approval/policy``subagent/descriptor` 只落在子 agent 自己的日志里),工具把输出返回给循环,由循环按顺序提交。一次性后台形态对父级拥有状态的唯一写入是通过 `tasks.start` 注册一个 Task——这是一次同步、可交换的插入,满足的是调度器 Agent Note 中的共享状态条款,而非更强的「无变更」性质。提供方 seam 要求针对不同子 agent 的并发启动和可继续准备分别隔离操作局部状态、取消、结算和清理。内置提供方满足这项约定:spawn 和 fork 在各次启动之间不保留可变状态,fork 只读取父级已完成轮次的前缀,进程外提供方按每次运行分配状态,继续执行管理器则为每次准备预留唯一的子 agent 身份和锁。
协调同级工作区效果是模型的职责,产品对后台、可继续和工作流子 agent 已经采取同样的立场。同类 harness 的做法一致:Claude Code 的 Task 工具无条件并发安全(上限 10);oh-my-pi 的 task 工具默认归入其可重叠的 `shared` 类别;opencode 的 task 工具在其 SDK 下不设上限地运行;Codex 则把委派做成异步 spawn/wait 信箱,绕开了这个问题。
容量控制仍保持在调度器 Agent Note 所定的位置:`maxParallelToolCalls` 限制单个步骤中未结算的工具调用数量——因而也限制并发运行的前台子 agent 数量——而后台和可继续调用在启动时即结算并释放池位,它们留下运行的子 agent 不受该上限约束。LLM(大语言模型)提供方负责自身的容量控制。
## 测试
包测试固定了两种调用形态的分类器。一个门控测试直接驱动注册表,其两个子 agent 各自阻塞,直到两者都已启动,以此证明该声明所依赖的那一半:工具体和提供方启动路径能容忍并发分发——这条栈中任何隐藏的串行化都会造成死锁,而不是静默通过。一个可继续门控测试让两项提供方准备停在同一个 await 上,在发布前取消其中一个调用方,并证明已取消的子 agent 不会留下 agent 或持久会话,而其同级则到达 inbox 接受状态并独立持久化。另一半(分类真正产生重叠执行)由分类器 pin 测试和下述快照负责。
人工编写的 `subagent-parallel` 快照固定了组装后应用的 transcript(文本记录):一条 assistant 消息携带两个 subagent 调用,父级日志记录为 `tool/call, tool/call, tool/result, tool/result`(串行执行会让调用/结果成对交错出现),两个子 agent 各自作为独立会话完成。其中的孪生委派刻意做成完全相同:`dsh-llm-replay` 按首次调用顺序绑定子脚本,harvester 按 `createdAt` 对子 agent 排序,二者在并发子 agent 之间都不具确定性(即 `XXX(concurrent-subagents)` 标记),因此目前只有可互换的孪生委派才能无竞态地回放。
## 备选方案
**保持委派独占。** 现状没有保护任何东西:后台和工作流子 agent 本来就可以带着写入自由重叠,串行化前台形态只会增加延迟,还违背模型显式表达的批量意图。
**使用输入敏感的分类器。** 该调用的参数只有自由文本的描述和提示词;其中没有任何内容能区分安全委派与不安全委派,因此条件式分类器只会流于形式。
**按 Codex 风格重新设计为异步 spawn/wait。** 可继续子 agent 加上 `send_message` 已经提供了异步通道;围绕信箱重建前台约定,等于为了解决一条声明就能修复的调度问题,丢弃一条可用的同步结果路径。
**按实例提供 `concurrencySafe` 配置开关。** 没有消费方需要串行部署:`maxParallelToolCalls: 1` 已能恢复全局串行执行,同类 harness 的先例也默认委派并发安全。
## 影响
同级子 agent 可能在共享工作区或外部资源上发生竞态;这项协调由模型负责,正如模型对其他所有重叠子 agent 已经承担的那样。并发子 agent 还会争用 LLM 提供方配额;`maxParallelToolCalls` 只限制未结算的调用,不限制后台或可继续调用留下运行的子 agent。
同一条消息中的两个一次性后台委派按分发竞态顺序获得各自模型可见的 task id(`subagent-<n>`)。这些 id 已被记录,因此回放仍然有效;但需要区分后台子 agent 的快照场景会继承与孪生子会话相同的确定性约束。
有序提交可能让快速子 agent 的结果排在更早的缓慢同级之后等待,这是[调度器 Agent Note](2026-07-10-parallel-tool-call-execution.md)已经接受的取舍;实时界面仍会展示每个子 agent 各自的进度。
使用不同提示词的并发子 agent 快照场景仍需要回放 harness 的支持(确定性的子脚本绑定与收集排序);在此之前,此类场景必须使用可互换的孪生委派。
+2 -2
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 docs/subsystems/subagent.md
subagent.md: 42359215b2e21028e13734c2a4cd2488f86a07af
subagent.zh.md: 0d1d4c42fa774867679ff248bfc99b0024b04257
subagent.md: cf728d9f15fdaaf8199954e91b564167e8efe439
subagent.zh.md: 1a8e2e5837b8ac88f7d7b7ca767fb7aa21391a9f
+8 -2
View File
@@ -390,7 +390,10 @@ Each provider is a named child-agent transport, and multiple providers may coexi
/**
* One registered transport for running child agents. Providers are trusted
* same-process implementations; callers treat descriptors and returned values
* as borrowed immutable data.
* as borrowed immutable data. The service may call one provider concurrently
* for distinct children. Providers isolate operation-local mutable state; a
* shared capacity controller may delay an operation but must not couple its
* settlement or cleanup to a sibling.
*/
interface SubagentProvider {
/** Unique registry name (e.g. `spawn`, `fork`, `acp`). */
@@ -411,7 +414,8 @@ interface SubagentProvider {
* 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.
* the returned run. Distinct starts may overlap; cancellation, failure,
* result settlement, and disposal remain independent for each run.
*/
start(request: ResolvedSubagentStartRequest): Promise<SubagentRun>
/**
@@ -426,6 +430,8 @@ interface SubagentProvider {
* 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.
* Distinct preparations may overlap; each follows its own signal and returns
* data belonging only to `request.sessionId`.
*/
prepareContinuable?(request: ContinuableCreateRequest): Promise<ContinuableCreateSpec>
}
+8 -2
View File
@@ -392,7 +392,10 @@ interface SubagentRun {
/**
* One registered transport for running child agents. Providers are trusted
* same-process implementations; callers treat descriptors and returned values
* as borrowed immutable data.
* as borrowed immutable data. The service may call one provider concurrently
* for distinct children. Providers isolate operation-local mutable state; a
* shared capacity controller may delay an operation but must not couple its
* settlement or cleanup to a sibling.
*/
interface SubagentProvider {
/** Unique registry name (e.g. `spawn`, `fork`, `acp`). */
@@ -413,7 +416,8 @@ interface SubagentProvider {
* 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.
* the returned run. Distinct starts may overlap; cancellation, failure,
* result settlement, and disposal remain independent for each run.
*/
start(request: ResolvedSubagentStartRequest): Promise<SubagentRun>
/**
@@ -428,6 +432,8 @@ interface SubagentProvider {
* 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.
* Distinct preparations may overlap; each follows its own signal and returns
* data belonging only to `request.sessionId`.
*/
prepareContinuable?(request: ContinuableCreateRequest): Promise<ContinuableCreateSpec>
}
+6
View File
@@ -372,6 +372,12 @@ const SCENARIOS: Scenario[] = [
// parent's tool result must retain that assistant output and stop reason.
{ name: 'subagent-max-tokens-partial', hasModelTurn: true, recorded: false },
{ name: 'subagent-multi', hasModelTurn: true, recorded: true },
// Authored keyless replay: one assistant message carries two subagent calls
// and the parent log pins call/call/result/result instead of the serial
// interleaving. The twin delegations must stay identical: replay binds child
// scripts and harvest order nondeterministically across concurrent children
// (XXX(concurrent-subagents) in dsh-llm-replay).
{ name: 'subagent-parallel', hasModelTurn: true, recorded: false },
{ name: 'subagent-fork', hasModelTurn: true, recorded: true },
{ name: 'subagent-mixed', hasModelTurn: true, recorded: true },
// Authored continuable-subagent transcript: a background delegation returns
@@ -0,0 +1,7 @@
{
"steps": [
{ "op": "initialize" },
{ "op": "newSession" },
{ "op": "prompt", "text": "Use the subagent tool TWICE in the SAME assistant message (two parallel tool calls in one response), each delegating the identical subtask: 'Reply with exactly the word ALPHA and nothing else.' Give both calls the description 'Say the word ALPHA'. After both subagents return, reply with the single word PARENT_DONE and stop. Do not use the bash tool." }
]
}
@@ -0,0 +1,18 @@
{"type":"session","version":0,"id":"bbbbbbbb-0000-4000-8000-000000000002","createdAt":1783352127000,"cwd":"{{cwd}}","parentSession":"aaaaaaaa-0000-4000-8000-000000000001","origin":"subagent","delegationDepth":1}
{"type":"approval/policy","seq":0,"time":1786373947132,"data":{"policy":"never","source":"delegation"}}
{"type":"agent/inbox/spliced","seq":1,"time":1786373947134,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"f5d60a48-78a3-4d75-91d7-478017516c5c"}]}}
{"type":"turn/start","seq":2,"time":1786373947134,"data":{"turn":1}}
{"type":"agent/inbox/spliced","seq":3,"time":1786373947134,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"subagent/descriptor","seq":4,"time":1786373947165,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Say the word ALPHA"}}
{"type":"step/start","seq":5,"time":1786373947168,"data":{"turn":1,"step":1}}
{"type":"user/message","seq":6,"time":1786338530759,"data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"f5d60a48-78a3-4d75-91d7-478017516c5c"},"surfaceOp":"append"}
{"type":"user/message","seq":7,"time":1786373947168,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"635fd4be-77c6-4891-90ba-2cafa163e166"},"surfaceOp":"append"}
{"type":"session/title","seq":8,"time":1786373947168,"data":{"title":"Reply with exactly the word","messageSeqs":[6],"source":{"kind":"fallback"}}}
{"type":"request/header","seq":9,"time":1786338530759,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/context","seq":10,"time":1786338530759,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
{"type":"assistant/chunk","seq":11,"time":1786338530769,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","seq":12,"time":1786338530769,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ALPHA"}}}}
{"type":"assistant/chunk","seq":13,"time":1786338530769,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":14,"time":1786338530769,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"4d13c6bf-dc50-4e57-91b0-59561b58986e"}},"sourceEventSeqs":[11,12,13],"surfaceOp":"append"}
{"type":"step/end","seq":15,"time":1786338530769,"data":{"turn":1,"step":1}}
{"type":"turn/end","seq":16,"time":1786338530769,"data":{"turn":1,"reason":{"kind":"completed"}}}
@@ -0,0 +1,18 @@
{"type":"session","version":0,"id":"cccccccc-0000-4000-8000-000000000003","createdAt":1783352127001,"cwd":"{{cwd}}","parentSession":"aaaaaaaa-0000-4000-8000-000000000001","origin":"subagent","delegationDepth":1}
{"type":"approval/policy","seq":0,"time":1786373947132,"data":{"policy":"never","source":"delegation"}}
{"type":"agent/inbox/spliced","seq":1,"time":1786373947133,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"afacd216-5e1b-46e7-afc4-f0eba9da1814"}]}}
{"type":"turn/start","seq":2,"time":1786373947133,"data":{"turn":1}}
{"type":"agent/inbox/spliced","seq":3,"time":1786373947134,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"subagent/descriptor","seq":4,"time":1786373947170,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Say the word ALPHA"}}
{"type":"step/start","seq":5,"time":1786373947173,"data":{"turn":1,"step":1}}
{"type":"user/message","seq":6,"time":1786338530749,"data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"afacd216-5e1b-46e7-afc4-f0eba9da1814"},"surfaceOp":"append"}
{"type":"user/message","seq":7,"time":1786373947174,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"dde02975-239c-422e-9b63-27d98f73346a"},"surfaceOp":"append"}
{"type":"session/title","seq":8,"time":1786373947174,"data":{"title":"Reply with exactly the word","messageSeqs":[6],"source":{"kind":"fallback"}}}
{"type":"request/header","seq":9,"time":1786338530749,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/context","seq":10,"time":1786338530749,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
{"type":"assistant/chunk","seq":11,"time":1786338530759,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","seq":12,"time":1786338530759,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ALPHA"}}}}
{"type":"assistant/chunk","seq":13,"time":1786338530759,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":14,"time":1786338530759,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"11e93a53-9112-4e80-9447-de974373ca94"}},"sourceEventSeqs":[11,12,13],"surfaceOp":"append"}
{"type":"step/end","seq":15,"time":1786338530760,"data":{"turn":1,"step":1}}
{"type":"turn/end","seq":16,"time":1786338530760,"data":{"turn":1,"reason":{"kind":"completed"}}}
@@ -0,0 +1,28 @@
{"type":"session","version":0,"id":"aaaaaaaa-0000-4000-8000-000000000001","createdAt":1783352126000,"cwd":"{{cwd}}","delegationDepth":0}
{"type":"agent/inbox/spliced","seq":0,"time":1786338530687,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the subagent tool TWICE in the SAME assistant message (two parallel tool calls in one response), each delegating the identical subtask: 'Reply with exactly the word ALPHA and nothing else.' Give both calls the description 'Say the word ALPHA'. After both subagents return, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"02062dd0-83d4-4b40-ab23-2fbcb0a8be96"}]}}
{"type":"turn/start","seq":1,"time":1786338530688,"data":{"turn":1}}
{"type":"agent/inbox/spliced","seq":2,"time":1786338530688,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","seq":3,"time":1786338530716,"data":{"turn":1,"step":1}}
{"type":"user/message","seq":4,"time":1786338530716,"data":{"content":[{"type":"text","text":"Use the subagent tool TWICE in the SAME assistant message (two parallel tool calls in one response), each delegating the identical subtask: 'Reply with exactly the word ALPHA and nothing else.' Give both calls the description 'Say the word ALPHA'. After both subagents return, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"02062dd0-83d4-4b40-ab23-2fbcb0a8be96"},"surfaceOp":"append"}
{"type":"user/message","seq":5,"time":1786338530716,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"2dd192ab-72ed-4c20-a487-40aa14bd5c07"},"surfaceOp":"append"}
{"type":"session/title","seq":6,"time":1786338530716,"data":{"title":"Use the subagent tool TWICE","messageSeqs":[4],"source":{"kind":"fallback"}}}
{"type":"request/header","seq":7,"time":1786338530717,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/context","seq":8,"time":1786338530717,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
{"type":"assistant/chunk","seq":9,"time":1786338530722,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":10,"time":1786338530722,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_parallel_alpha_1","name":"subagent","arguments":"{\"description\": \"Say the word ALPHA\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}}}}
{"type":"assistant/chunk","seq":11,"time":1786338530722,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":12,"time":1786338530722,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_parallel_alpha_2","name":"subagent","arguments":"{\"description\": \"Say the word ALPHA\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}}}}
{"type":"assistant/chunk","seq":13,"time":1786338530722,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":14,"time":1786338530723,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_parallel_alpha_1","name":"subagent","arguments":"{\"description\": \"Say the word ALPHA\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"},{"type":"tool-call","id":"call_parallel_alpha_2","name":"subagent","arguments":"{\"description\": \"Say the word ALPHA\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6ff33634-55af-4c37-a491-dd5b8673923f"}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"}
{"type":"tool/call","seq":15,"time":1786338530723,"data":{"turn":1,"step":1,"callId":"call_parallel_alpha_1","name":"subagent","arguments":"{\"description\": \"Say the word ALPHA\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}}
{"type":"tool/call","seq":16,"time":1786338530723,"data":{"turn":1,"step":1,"callId":"call_parallel_alpha_2","name":"subagent","arguments":"{\"description\": \"Say the word ALPHA\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}}
{"type":"tool/result","seq":17,"time":1786338530770,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_parallel_alpha_1"},"content":[{"type":"tool-result","toolCallId":"call_parallel_alpha_1","content":[{"type":"text","text":"ALPHA"}],"isError":false}],"role":"user","id":"9fa4fa59-7326-4bc8-8ea2-120b37d91ea3"}},"sourceEventSeqs":[15],"surfaceOp":"append"}
{"type":"tool/result","seq":18,"time":1786338530770,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_parallel_alpha_2"},"content":[{"type":"tool-result","toolCallId":"call_parallel_alpha_2","content":[{"type":"text","text":"ALPHA"}],"isError":false}],"role":"user","id":"895bb620-534e-4857-9743-68273126eccf"}},"sourceEventSeqs":[16],"surfaceOp":"append"}
{"type":"step/end","seq":19,"time":1786338530770,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":20,"time":1786338530778,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":21,"time":1786338530782,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","seq":22,"time":1786338530782,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_DONE"}}}}
{"type":"assistant/chunk","seq":23,"time":1786338530782,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":24,"time":1786338530782,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"84586dd8-4985-4286-ab4b-fa9965803fb8"}},"sourceEventSeqs":[21,22,23],"surfaceOp":"append"}
{"type":"step/end","seq":25,"time":1786338530782,"data":{"turn":1,"step":2}}
{"type":"turn/end","seq":26,"time":1786338530782,"data":{"turn":1,"reason":{"kind":"completed"}}}
@@ -0,0 +1,4 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PARENT_DONE"}}}}
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}
+2 -2
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: 28f649ef54bbf88feda24a9ce197c2c366f8349b
README.zh.md: 595c5e5e7fffc367f2e3fd8142b779dc22fb3b79
README.md: b9f65befab71edc0685f1a6cca767c803afcb76c
README.zh.md: f58975815742ccbe790ca1cf096449831144a2f3
+1 -1
View File
@@ -44,7 +44,7 @@ Every in-process child is composed by one call, `applyChildComposition(childCtx,
`childSessionMeta()` records the joined preset id on the child's durable header for the same reason a top-level session records its own: the preset decides the tool schemas and prompt sections the model saw, so a cold read of the child's history has to rebuild that composition rather than the deployment default. It is read from the parent's live scope chain, not from the parent header, because a parent that switched preset while blank runs on the newer composition while its header still names the older one.
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.
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 service may invoke one provider concurrently for distinct siblings: each start or preparation owns its mutable state and cancellation path, and one operation's failure, result, or cleanup must not settle or release another. A provider may queue its own capacity internally without changing that independence contract.
## The durable descriptor
+1 -1
View File
@@ -44,7 +44,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
`childSessionMeta()` 把所加入的 preset id 记在子 agent 的持久化 header 上,理由与顶层会话记录自己的那一个相同:preset 决定了模型所见的工具 schema 与提示段,因此冷读子 agent 的历史时必须重建那份组装,而不是部署默认值。该值从父方**活着的** scope 链读取,而不是从父方 header 读取,因为在空白期切换过 preset 的父方运行在更新的那份组装上,而它的 header 仍写着旧的那个。
可继续创建对应可选的 `SubagentProvider.prepareContinuable?()` 方法:方法是否存在就是能力检查,因此服务会在没有该方法的提供方上拒绝已配置的可继续启动,而具备该方法的提供方仍可服务普通一次性委派。该方法只返回分离的 `ContinuableCreateSpec``{ seed? }`)——这是数据,绝非能力:它不携带任何 Agent、`AgentHandle`、提示词投递、结果、dispose 或恢复操作,因为准备之后,继续执行管理器拥有身份预留、组合、Agent 创建、提示词投递、冷恢复、所有权和 dispose。一次性 `SubagentRun` 表示一次可 dispose 的前台委派,只有一个结果,且没有冷恢复操作。
可继续创建对应可选的 `SubagentProvider.prepareContinuable?()` 方法:方法是否存在就是能力检查,因此服务会在没有该方法的提供方上拒绝已配置的可继续启动,而具备该方法的提供方仍可服务普通一次性委派。该方法只返回分离的 `ContinuableCreateSpec``{ seed? }`)——这是数据,绝非能力:它不携带任何 Agent、`AgentHandle`、提示词投递、结果、dispose 或恢复操作,因为准备之后,继续执行管理器拥有身份预留、组合、Agent 创建、提示词投递、冷恢复、所有权和 dispose。一次性 `SubagentRun` 表示一次可 dispose 的前台委派,只有一个结果,且没有冷恢复操作。服务可以针对不同的同级子 agent 并发调用同一提供方:每次启动或准备都拥有各自的可变状态和取消路径,一项操作的失败、结果或清理不得使另一项操作结算或释放。提供方可以在内部按自身容量排队,但不得改变这项独立性约定。
## 持久化描述符
+8 -2
View File
@@ -277,7 +277,10 @@ export interface SubagentRun {
/**
* One registered transport for running child agents. Providers are trusted
* same-process implementations; callers treat descriptors and returned values
* as borrowed immutable data.
* as borrowed immutable data. The service may call one provider concurrently
* for distinct children. Providers isolate operation-local mutable state; a
* shared capacity controller may delay an operation but must not couple its
* settlement or cleanup to a sibling.
*/
export interface SubagentProvider {
/** Unique registry name (e.g. `spawn`, `fork`, `acp`). */
@@ -298,7 +301,8 @@ export interface SubagentProvider {
* 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.
* the returned run. Distinct starts may overlap; cancellation, failure,
* result settlement, and disposal remain independent for each run.
*/
start(request: ResolvedSubagentStartRequest): Promise<SubagentRun>
/**
@@ -313,6 +317,8 @@ export interface SubagentProvider {
* 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.
* Distinct preparations may overlap; each follows its own signal and returns
* data belonging only to `request.sessionId`.
*/
prepareContinuable?(request: ContinuableCreateRequest): Promise<ContinuableCreateSpec>
}
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/subagent/tool-subagent/README.md
README.md: ac3ec0563cce9128608ca31860b034a103dc1a3a
README.zh.md: d64831d7cf64800ad3307ce6cb7f294500a0a6f0
README.md: 578ea4786e1d996251360c4aed92f2e882553681
README.zh.md: baf91664530a637df96f1e7a51e6f0adabc0a8ae
+1 -1
View File
@@ -29,7 +29,7 @@ With `run_in_background: true`, `backgroundMode` selects the route. `one-shot` r
## Concurrency
Foreground and background calls are exclusive. Children may share the parent's workspace or external resources, and a unary classifier cannot prove that sibling delegations have disjoint effects. See the [parallel tool-call Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md).
Foreground and background calls are concurrency-safe: sibling delegations in one assistant message overlap under the loop's rolling pool (`maxParallelToolCalls`), and results still commit in model order. Children work in their own sessions and a run never mutates the parent session; the one-shot background form's one parent-owned write — registering a Task — is a synchronous, commutative insertion that tolerates concurrent dispatch, so overlapping background calls acquire their task ids in dispatch-race order. Coordinating sibling workspace effects belongs to the model, exactly as it already does for background and continuable children. See the [parallel subagent Agent Note](../../../.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.md) and the [parallel tool-call Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md).
## Model Experience
+1 -1
View File
@@ -29,7 +29,7 @@
## 并发
前台调用和后台调用均互斥。子 agent 可能共享父级工作区或外部资源,一元分类器无法证明同级委派的效果彼此不相交。见 [并行工具调用 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md)。
前台调用和后台调用均并发安全:同一条 assistant 消息中的同级委派会在循环的滚动池(`maxParallelToolCalls`)下重叠执行,结果仍按模型顺序提交。子 agent 在各自的会话中工作,一次运行绝不变更父会话;一次性后台形态对父级拥有状态的唯一写入是注册一个 Task——这是一次同步、可交换、能容忍并发分发的插入,因此重叠的后台调用按分发竞态顺序获得各自的 task id。协调同级工作区效果由模型负责,正如模型已经对后台和可继续子 agent 所承担的那样。见 [并行 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.md) 和 [并行工具调用 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md)。
## 模型体验
@@ -330,6 +330,9 @@ export function apply(ctx: Context, config: Config): void {
: outputValueText(value.output),
}],
},
// Children never mutate the parent session; the one parent-owned write
// (tasks.start) is a synchronous commutative insertion.
isConcurrencySafe: () => true,
async execute(args, exec) {
const parent = exec.agent
if (!parent) {
@@ -33,6 +33,8 @@ export interface Config {
inheritsParentContext?: boolean
/** Structured value returned when the request asks for one. */
structured?: unknown
/** Observes each start; the child's result additionally waits for the returned promise. */
onStart?: (request: SubagentStartRequest) => Promise<void> | void
}
/** Scripted provider whose result aborts if its signal or disposer wins first. */
@@ -68,9 +70,10 @@ class ScriptedSubagentProvider implements SubagentProvider {
...wantsStructured ? { structured: this.config.structured ?? { reply } } : {},
stopReason: state.cancelled ? 'aborted' : stopReason,
})
const result = new Promise<SubagentResult>((resolve) => {
const gate = Promise.resolve(this.config.onStart?.(request))
const result = gate.then(() => new Promise<SubagentResult>((resolve) => {
setTimeout(() => { resolve(resultFor()) }, 0)
}).finally(() => {
})).finally(() => {
request.signal.removeEventListener('abort', onAbort)
})
@@ -128,20 +128,42 @@ describe('dsh-tool-subagent', () => {
expect(foreground.isError).toBe(false)
})
it('keeps foreground and background calls exclusive', async () => {
it('classifies foreground and background calls concurrency-safe (sibling delegations overlap)', async () => {
const ctx = await setup({ provider: 'mock' })
expect(ctx.tools.executionMode({
signal: testToolSignal,
callId: CallId('subagent-foreground'),
name: 'subagent',
arguments: { description: 'do work', prompt: 'Reply OK' },
})).toEqual({ kind: 'exclusive' })
})).toEqual({ kind: 'parallel' })
expect(ctx.tools.executionMode({
signal: testToolSignal,
callId: CallId('subagent-background'),
name: 'subagent',
arguments: { description: 'do work', prompt: 'Reply OK', run_in_background: true },
})).toEqual({ kind: 'exclusive' })
})).toEqual({ kind: 'parallel' })
})
it('overlaps sibling foreground delegations dispatched concurrently', async () => {
// Two children each block until both have started: hidden serialization
// in the tool body, registry pipeline, or provider start path would
// deadlock here instead of passing silently.
const started: string[] = []
let releaseBoth!: () => void
const bothStarted = new Promise<void>((resolve) => { releaseBoth = resolve })
const ctx = await setup({ provider: 'mock', enableRunInBackground: false }, {
onStart: (request: SubagentStartRequest) => {
started.push(request.label ?? '(unlabeled)')
if (started.length === 2) releaseBoth()
return bothStarted
},
})
const results = await Promise.all([
callSubagent(ctx, { description: 'first', prompt: 'p1' }),
callSubagent(ctx, { description: 'second', prompt: 'p2' }),
])
expect(started.sort()).toEqual(['first', 'second'])
for (const result of results) expect(result.isError).toBe(false)
})
it.each([
@@ -957,6 +979,16 @@ describe('dsh-tool-subagent continuable background mode', () => {
return { ctx, parent }
}
it('classifies continuable background calls concurrency-safe', async () => {
const { ctx } = await continuableSetup()
expect(ctx.tools.executionMode({
signal: testToolSignal,
callId: CallId('subagent-continuable'),
name: 'subagent',
arguments: { description: 'do work', prompt: 'Reply OK', run_in_background: true },
})).toEqual({ kind: 'parallel' })
})
it('starts a continuable child and returns only its durable id, creating no Task', async () => {
const { ctx, parent } = await continuableSetup()
const schema = ctx.tools.schemas().find(s => s.name === 'subagent')!
@@ -986,6 +1018,69 @@ describe('dsh-tool-subagent continuable background mode', () => {
expect(loaded.events.some(event => event.type === 'assistant/message')).toBe(true)
})
it('isolates a cancelled continuable preparation from a concurrent sibling', async () => {
const { ctx, parent } = await continuableSetup()
const bothPreparing = Promise.withResolvers<undefined>()
const releasePreparations = Promise.withResolvers<undefined>()
const cancelled = new AbortController()
let preparationCount = 0
let cancelledChildId: ReturnType<typeof SessionId> | undefined
let survivingChildId: ReturnType<typeof SessionId> | undefined
ctx.subagents.registerProvider({
name: 'gated',
capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: true },
inheritsParentContext: false,
start: async () => { throw new Error('continuable policy must not start a one-shot child') },
prepareContinuable: async (request) => {
preparationCount += 1
if (request.signal === cancelled.signal) cancelledChildId = request.sessionId
else survivingChildId = request.sessionId
if (preparationCount === 2) bothPreparing.resolve(undefined)
await releasePreparations.promise
return {}
},
})
tool.apply(ctx, {
provider: 'gated',
toolName: 'subagent_gated',
backgroundMode: 'continuable',
maxDepth: 3,
})
const execute = (callId: string, description: string, signal: AbortSignal) => ctx.tools.execute({
signal,
callId: CallId(callId),
name: 'subagent_gated',
arguments: { description, prompt: 'work', run_in_background: true },
agent: parent,
})
const cancelledResult = execute('continuable-cancelled', 'cancelled sibling', cancelled.signal)
const survivingResult = execute('continuable-surviving', 'surviving sibling', testToolSignal)
await bothPreparing.promise
cancelled.abort()
releasePreparations.resolve(undefined)
const [failed, succeeded] = await Promise.all([cancelledResult, survivingResult])
expect(preparationCount).toBe(2)
expect(failed.isError).toBe(true)
expect(succeeded.isError).toBe(false)
expect(cancelledChildId).toBeDefined()
expect(survivingChildId).toBeDefined()
expect(ctx.agents.get(cancelledChildId!)).toBeUndefined()
await expect(ctx.sessionPersistence.load(cancelledChildId!)).rejects.toThrow(/not found/)
expect(succeeded.isError ? undefined : succeeded.value).toEqual({
kind: 'continuable',
subagentId: survivingChildId,
})
await vi.waitFor(() => {
expect(ctx.agents.get(survivingChildId!)).toBeUndefined()
}, { timeout: 5_000 })
const loaded = await ctx.sessionPersistence.load(survivingChildId!)
expect(loaded.events.some(event => event.type === 'subagent/descriptor')).toBe(true)
expect(loaded.events.some(event => event.type === 'assistant/message')).toBe(true)
})
})
describe('background preflight failure (no orphaned child, by construction)', () => {