Merge pull request #2277 from deepseek-harness/codex/background-first-continuable-subagents
feat(subagent): default continuable delegation to background
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.md
|
||||
2026-08-06-manager-owned-subagent-settlement-delivery.md: e06fe2b4ca7dd9a975f70524979de09d13cdcf79
|
||||
2026-08-06-manager-owned-subagent-settlement-delivery.zh.md: 77e8b5e4bae9b05e08bf2c4a4bf997b688cd1157
|
||||
2026-08-06-manager-owned-subagent-settlement-delivery.md: 11669d4603e658d1478557f81c2d2b4d5495bca7
|
||||
2026-08-06-manager-owned-subagent-settlement-delivery.zh.md: f3684777ccb3800226803e998438147ebc1dbf16
|
||||
|
||||
@@ -16,7 +16,7 @@ The signal already existed. `subagent/end` has carried `stopReason` and `lastAss
|
||||
|
||||
The continuation manager delivers the account itself, from inside the disposal transaction that ends the Activation.
|
||||
|
||||
When a resident Activation settles, `notifySettlement()` resolves the child's durable direct parent and sends it one user-role message: the epoch's outcome as a sentence the parent can act on, then the child's final assistant content, or a statement that it produced none. Delivery is unconditional for every child whose id a caller actually received. It does not consult whether the child reported, and it keeps no bookkeeping that could make the promise conditional — that unconditionality is what lets `tool-subagent` tell the model "you are told when it finishes, so never poll or wait on it" and have that be true. A materialization rolled back before its first accepted message stays silent, because the caller was told that child was not established.
|
||||
When a resident Activation settles, `notifySettlement()` resolves the child's durable direct parent and sends it one user-role message: the epoch's outcome as a sentence the parent can act on, then the child's final assistant content, or a statement that it produced none. Delivery is unconditional for every child whose id a caller actually received. It does not consult whether the child reported, and it keeps no bookkeeping that could make the promise conditional — that unconditionality is what lets `tool-subagent` promise a runtime notice containing the outcome and any final assistant message. A materialization rolled back before its first accepted message stays silent, because the caller was told that child was not established.
|
||||
|
||||
### Provenance
|
||||
|
||||
@@ -44,7 +44,7 @@ Delivery never blocks or fails teardown. A rejected send is logged and dropped,
|
||||
|
||||
### The epoch's own log is the whole account
|
||||
|
||||
`epochStopReason()` reads the epoch's outcome from its own log, because teardown succeeding says nothing about whether the model errored, hit its ceiling, or was stopped. Reading turns alone got that wrong twice, in the same shape both times: a turn stopped before its first step leaves a `turn/end` indistinguishable from the balanced no-op turns a rejection or an emptied claim produces, so the filter that skipped those also skipped real endings and answered with the previous turn's clean completion. The durability checkpoint (`dsh-session-checkpoint-policy`, in every shipped profile) and prompt assembly both run at that boundary and both propagate, and `Inbox.claim()` has already taken the messages by then — so the parent was told a child finished while the delivery it was waiting on had been swallowed. Under a promise that says "you are told when it finishes, so never poll", that is the one failure a parent cannot detect and will not retry.
|
||||
`epochStopReason()` reads the epoch's outcome from its own log, because teardown succeeding says nothing about whether the model errored, hit its ceiling, or was stopped. Reading turns alone got that wrong twice, in the same shape both times: a turn stopped before its first step leaves a `turn/end` indistinguishable from the balanced no-op turns a rejection or an emptied claim produces, so the filter that skipped those also skipped real endings and answered with the previous turn's clean completion. The durability checkpoint (`dsh-session-checkpoint-policy`, in every shipped profile) and prompt assembly both run at that boundary and both propagate, and `Inbox.claim()` has already taken the messages by then — so the parent was told a child finished while the delivery it was waiting on had been swallowed. Under the advertised automatic settlement notice, that is the one failure a parent cannot detect and will not retry.
|
||||
|
||||
The missing fact was never the turn's; it was the inbox's. `Inbox` logs every mutation with `removedCount` and marks a cancellation `outcome: 'canceled'`, which separates a turn claiming its input from work being dropped unrun. `foldConsumedWork()` in `dsh-agent` folds both vocabularies into one answer: the latest turn that accounts for consumed work — stepped, or claimed-then-failed, stopped, or rejected — and whether accepted work was cancelled after it with no turn opening over it. A `blocked` end over claimed input is an account too: the pre-step rejection that produced it — a hook deny, a policy plugin — discarded the messages the turn claimed, so the notice says the child declined rather than finished. Only a `blocked` turn that claimed nothing stays invisible.
|
||||
|
||||
@@ -60,7 +60,7 @@ Three assembled ACP scenarios cover the notice: a child that never reports, a ch
|
||||
|
||||
`subagent-continuable` is the one that pins a failure. Its child's last turn dies on the forced durability checkpoint without entering a step, so that transcript is where the stop-reason rule above is visible end to end: the notice says the child *failed*, carries the earlier `SECOND_OK` as its last content rather than as a result, and the parent's own acknowledgement turn reaches the ACP client.
|
||||
|
||||
A keyless headless Loader snapshot covers the user-visible path end to end. Its replay parent starts one continuable child with `run_in_background: true`, never calls `list_agents`, `send_message`, or Task tools, consumes the manager-authored `subagent-settled` notice, and produces its final answer. The child never calls `report`, so the transcript cannot pass through the cooperative report path. A test-only Loader fence holds the parent's post-spawn request until the real manager notice enters its inbox, removing platform scheduling from the transcript without synthesizing the notice.
|
||||
A keyless headless Loader snapshot covers the user-visible path end to end. Its replay parent omits `run_in_background` to exercise the continuable background default, never calls `list_agents`, `send_message`, or Task tools, consumes the manager-authored `subagent-settled` notice, and produces its final answer. The child never calls `report`, so the transcript cannot pass through the cooperative report path. A test-only Loader fence holds the parent's post-spawn request until the real manager notice enters its inbox, removing platform scheduling from the transcript without synthesizing the notice.
|
||||
|
||||
`subagent-report` needed one more concession. With the shipped waking report default, that scenario has two independent parent wakes — the report and the settlement — and whether the second extends the first's turn or opens its own is a genuine coin flip that measured 50/50 across runs. No authored transcript can hold both orders. Its overlay therefore pins `reportDelivery: quiet`, leaving settlement as the only wake, and a snapshot-only pre-step fence holds the child until the parent's spawn turn ends so that wake opens one deterministic turn claiming both messages. The waking report default keeps its coverage in the report package's own tests.
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ Status: implemented
|
||||
|
||||
继续执行管理器自己投递这份记账,就在结束 Activation 的那笔 dispose 事务内部完成。
|
||||
|
||||
当驻留 Activation 结算时,`notifySettlement()` 解析该 child 持久化的直接父级,并向它发送一条用户角色消息:先是父级可据以行动的一句结果说明,然后是 child 的最终 assistant 内容,或一句说明它没有产出内容。对每个调用方真正拿到过 id 的 child,投递都是无条件的。它不查询 child 是否上报过,也不保留任何可能让这项承诺变成有条件的记账——正是这种无条件性,才让 `tool-subagent` 能够告诉模型「它结束时你会被告知,因此绝不要轮询或等待它」并且这句话为真。在第一条消息被接受之前就回滚的物化保持静默,因为调用方已被告知该 child 未建立。
|
||||
当驻留 Activation 结算时,`notifySettlement()` 解析该 child 持久化的直接父级,并向它发送一条用户角色消息:先是父级可据以行动的一句结果说明,然后是 child 的最终 assistant 内容,或一句说明它没有产出内容。对每个调用方真正拿到过 id 的 child,投递都是无条件的。它不查询 child 是否上报过,也不保留任何可能让这项承诺变成有条件的记账——正是这种无条件性,才让 `tool-subagent` 能够承诺一条包含结局与可能存在的最终 assistant 消息的运行时通知。在第一条消息被接受之前就回滚的物化保持静默,因为调用方已被告知该 child 未建立。
|
||||
|
||||
### 来源信息
|
||||
|
||||
@@ -44,7 +44,7 @@ Status: implemented
|
||||
|
||||
### epoch 自己的日志就是全部交代
|
||||
|
||||
`epochStopReason()` 从 epoch 自己的日志读取结局,因为拆卸成功与否,对「模型是否报错、是否撞到上限、是否被停下」什么也没说明。只读轮次这件事已经错了两次,而两次的形状相同:在第一个 step 之前被停下的轮次,其 `turn/end` 与「拒绝」或「被清空的认领」产生的平衡空转轮次长得一模一样,于是那道用来跳过后者的过滤,也把真实的结局一起跳过了,转而用上一个轮次的干净收尾作答。持久化检查点(`dsh-session-checkpoint-policy`,存在于每个随附 profile 中)与提示词组装都运行在这个边界上、且都会向外传播,而此时 `Inbox.claim()` 已经把消息取走了——于是父级被告知 child 已完成,而它正在等待的那条投递已被吞掉。在「你会在它完成时被告知,所以永远不要轮询」这一承诺之下,这恰恰是父级无法察觉、也不会重试的那一种失败。
|
||||
`epochStopReason()` 从 epoch 自己的日志读取结局,因为拆卸成功与否,对「模型是否报错、是否撞到上限、是否被停下」什么也没说明。只读轮次这件事已经错了两次,而两次的形状相同:在第一个 step 之前被停下的轮次,其 `turn/end` 与「拒绝」或「被清空的认领」产生的平衡空转轮次长得一模一样,于是那道用来跳过后者的过滤,也把真实的结局一起跳过了,转而用上一个轮次的干净收尾作答。持久化检查点(`dsh-session-checkpoint-policy`,存在于每个随附 profile 中)与提示词组装都运行在这个边界上、且都会向外传播,而此时 `Inbox.claim()` 已经把消息取走了——于是父级被告知 child 已完成,而它正在等待的那条投递已被吞掉。在已公布的自动结算通知约定下,这恰恰是父级无法察觉、也不会重试的那一种失败。
|
||||
|
||||
缺失的事实从来不属于轮次,而属于 inbox。`Inbox` 会把每次改动连同 `removedCount` 一起记入日志,并给取消标记 `outcome: 'canceled'`,这就把「某个轮次认领了它的输入」与「工作被丢弃且从未运行」区分开来。`dsh-agent` 中的 `foldConsumedWork()` 把两套词汇折叠成一个答案:能为已消费工作作出交代的最新轮次——进入过 step 的,或认领后失败、被停下或被拒绝的——以及此后是否有已接受的工作被取消、而没有任何轮次为它开启过。认领过输入、以 `blocked` 结束的轮次同样是一份交代:产生它的 pre-step 拒绝——hook deny、策略插件——把该轮次认领的消息一并丢弃了,因此通知会说 child 拒绝了任务,而不是完成了任务。只有没认领任何输入的 `blocked` 轮次保持不可见。
|
||||
|
||||
@@ -60,7 +60,7 @@ Status: implemented
|
||||
|
||||
`subagent-continuable` 是其中固定失败结局的那个。它的 child 最后一个轮次在被强制的持久化检查点上死亡,且未进入任何 step,因此该 transcript 正是上面那条终止原因规则的端到端可见之处:通知说该 child **失败**,把此前的 `SECOND_OK` 作为它最后产出的内容而非结果携带,而父级自己的确认轮次会到达 ACP 客户端。
|
||||
|
||||
另有一个无密钥的 headless Loader 快照端到端覆盖用户可见路径。其重放父级通过 `run_in_background: true` 启动一个可继续 child,从不调用 `list_agents`、`send_message` 或 Task 工具,消费管理器写入的 `subagent-settled` 通知,并给出最终答案。child 从不调用 `report`,因此该 transcript 不可能经由协作式上报路径通过。一个仅用于测试的 Loader 栅栏会把父级启动后的请求保持到真实管理器通知进入其 inbox 为止,从 transcript 中排除平台调度差异,但不会伪造该通知。
|
||||
另有一个无密钥的 headless Loader 快照端到端覆盖用户可见路径。其重放父级省略 `run_in_background` 以覆盖可继续后台默认路径,从不调用 `list_agents`、`send_message` 或 Task 工具,消费管理器写入的 `subagent-settled` 通知,并给出最终答案。child 从不调用 `report`,因此该 transcript 不可能经由协作式上报路径通过。一个仅用于测试的 Loader 栅栏会把父级启动后的请求保持到真实管理器通知进入其 inbox 为止,从 transcript 中排除平台调度差异,但不会伪造该通知。
|
||||
|
||||
`subagent-report` 还需要多做一步让步。在随附的唤醒上报默认值下,该场景有两个互相独立的父级唤醒——上报与结算——而第二个究竟是延长第一个的轮次还是另开一个轮次,是一枚真正的硬币,多次运行实测约为五五开。任何手写 transcript 都无法同时容纳两种顺序。因此它的 overlay 固定 `reportDelivery: quiet`,使结算成为唯一唤醒;另一个仅用于快照的 pre-step 栅栏会把 child 保持到父级启动轮次结束,使这次唤醒开启一个确定轮次并同时认领两条消息。唤醒上报默认值的覆盖则保留在 report 包自身的测试中。
|
||||
|
||||
|
||||
@@ -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-11-background-first-continuable-delegation.md
|
||||
2026-08-11-background-first-continuable-delegation.md: e90b0a97cd5eeb273c8084d231ced003a661b978
|
||||
2026-08-11-background-first-continuable-delegation.zh.md: b8f14e0c40c341fc91b2fd0dc8c3f00fc2e14b66
|
||||
@@ -0,0 +1,45 @@
|
||||
# Agent Note: Continuable delegation is background-first
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-11-background-first-continuable-delegation.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
A continuable child already has a durable id, independent turns, follow-up messaging, and a manager-owned settlement notice. Treating an omitted `run_in_background` as foreground makes that lifecycle depend on the model restating `true` on every call. It also obscures the useful scheduling test: the parent should wait only when its next action requires the child's result.
|
||||
|
||||
The child-scoped `report` prompt requires a self-contained final report, while [manager-owned settlement delivery](2026-08-06-manager-owned-subagent-settlement-delivery.md) independently sends the run outcome and closing message. A completed child can therefore wake its parent with a final report and again with settlement. Background-first scheduling must preserve both deliveries: the child-authored handoff remains mandatory guidance, while the manager-authored notice covers every terminal path regardless of model compliance.
|
||||
|
||||
## Decision
|
||||
|
||||
`tool-subagent` resolves an omitted `run_in_background` from the selected lifecycle policy. `backgroundMode: continuable` resolves omission to background and returns the durable child id immediately; explicit `false` selects foreground and waits for the result. `backgroundMode: one-shot` keeps its foreground default because background output still requires Task collection. `enableRunInBackground: false` continues to omit the parameter, reject forced `true`, and run in the foreground. No second default-selection config is added.
|
||||
|
||||
The model-facing text divides responsibility by location:
|
||||
|
||||
- the tool description states the call behavior, durable id, runtime settlement notice, follow-up through `send_message`, and the explicit foreground override;
|
||||
- the `run_in_background` parameter states the lifecycle-specific default and when to override it;
|
||||
- a `tool:<toolName>` system-prompt section tells the model to start independent delegations together, continue useful work while they run, and choose foreground only when the next action depends on the result. The section renders only when that tool remains visible in the assembly scope, so a child tool restriction removes the schema and its guidance together.
|
||||
|
||||
The [continuable child report obligation](2026-08-06-continuable-child-report-obligation.md) remains unchanged: the child prompt requires one self-contained final report and earlier reports for findings that change the parent's next action. Manager-owned settlement remains unconditional and does not inspect whether a report arrived. The two messages may repeat final content, but they retain distinct authors and purposes: `report` is the child's explicit handoff, while settlement records how the run ended and preserves terminal output when the child cannot cooperate. `reportDelivery` remains deployment scheduling policy with `wakeup` as its default.
|
||||
|
||||
The keyless headless `subagent-settlement` scenario omits `run_in_background`, receives the immediate child id, and reaches the final parent answer through the manager-authored settlement notice even though its fixture deliberately does not call `report`. Package tests separately pin explicit `false` as foreground, the parent scheduling text, and the child's mandatory-report prompt.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Replace the field with `run_in_foreground`.** Reversing the boolean makes the common case read positively, but creates a second vocabulary for the same scheduling choice and forces every existing caller and provider-facing transcript to change. Keeping `run_in_background` preserves one field and makes foreground the explicit exception.
|
||||
|
||||
**Add a configurable background default.** A separate default can disagree with `backgroundMode`, the schema wording, and the installed prompt. The lifecycle policy already distinguishes a continuable activation from a one-shot Task, which is the distinction that determines whether background completion is delivered automatically.
|
||||
|
||||
**Change only the prompt.** Prompt preference without runtime resolution still turns an omitted argument into foreground. The model must be able to rely on the advertised default rather than reproduce it perfectly on every tool call.
|
||||
|
||||
**Suppress settlement after a final report arrives.** Conditional settlement reintroduces per-Activation bookkeeping and loses the unconditional runtime guarantee when a child reports progress and then fails. Settlement remains unconditional even when the resulting message overlaps a final report.
|
||||
|
||||
**Use `report` only for progress before settlement.** This removes duplicate final content but also removes the explicit child-authored handoff from the child prompt. The final-report obligation remains, and runtime settlement remains its independent fallback and terminal record.
|
||||
|
||||
## Consequences
|
||||
|
||||
- An ordinary continuable call is non-blocking without spelling `run_in_background: true`; serialized delegation is an explicit `false` choice.
|
||||
- Independent subagent calls in one assistant message overlap under the tool loop's concurrency-safe dispatch, while dependent foreground calls can still be issued one at a time.
|
||||
- Parent guidance, tool schema, runtime resolution, and settlement delivery state the same default.
|
||||
- A compliant child reports one self-contained final result and may report important findings earlier. Every Activation also produces an unconditional settlement notice, so a completed run may deliver overlapping final content twice.
|
||||
- One-shot background Tasks and disabled-background tool instances retain their existing behavior.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Agent Note: 可继续委派采用后台优先
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-11-background-first-continuable-delegation.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
可继续 child 已经具备持久化 id、独立轮次、后续消息以及由管理器负责的结算通知。如果把省略的 `run_in_background` 视为前台,模型就必须在每次调用时重复写出 `true`,才能得到这套生命周期。这样也会掩盖真正有用的调度判断:只有当 parent 的下一步动作需要 child 结果时,parent 才应等待。
|
||||
|
||||
child 作用域的 `report` 提示词要求发送自包含的最终报告,而[由管理器负责的结算投递](2026-08-06-manager-owned-subagent-settlement-delivery.md)会独立发送本次运行的结束结果与收尾消息。已完成的 child 因而可能先用最终报告唤醒 parent,再用结算通知唤醒一次。后台优先调度会保留两次投递:由 child 编写的交接仍是强制提示词指引,由管理器生成的通知则不依赖模型是否遵循指令,覆盖每种终止路径。
|
||||
|
||||
## 决策
|
||||
|
||||
`tool-subagent` 根据选定的生命周期策略解析省略的 `run_in_background`。`backgroundMode: continuable` 会把省略解析为后台并立即返回持久化 child id;显式传入 `false` 会选择前台并等待结果。`backgroundMode: one-shot` 保留前台默认行为,因为它的后台输出仍需通过 Task 收集。`enableRunInBackground: false` 仍会省略该参数、拒绝强制传入的 `true` 并在前台运行。系统不增加第二个默认选择配置。
|
||||
|
||||
面向模型的文本按位置划分职责:
|
||||
|
||||
- 工具描述说明调用行为、持久化 id、运行时结算通知、通过 `send_message` 继续对话,以及显式前台覆盖;
|
||||
- `run_in_background` 参数说明具体生命周期的默认值以及何时覆盖;
|
||||
- `tool:<toolName>` 系统提示词 section 会告诉模型同时启动相互独立的委派、在它们运行时继续有用工作,并且仅当下一步动作依赖结果时选择前台。只有当该工具在组装作用域中仍可见时才会渲染这个 section,因此子级工具限制会同时移除 schema 与对应指引。
|
||||
|
||||
[可继续 child 上报义务](2026-08-06-continuable-child-report-obligation.md)保持不变:child 提示词要求发送一份自包含的最终报告,并在发现会改变 parent 下一步动作的信息时提前报告。由管理器负责的结算仍然无条件执行,不检查报告是否已经到达。这两条消息可能重复最终内容,但作者和用途不同:`report` 是 child 的显式交接,结算则记录本次运行如何结束,并在 child 无法配合时保留终止输出。`reportDelivery` 仍是部署调度策略,默认值仍为 `wakeup`。
|
||||
|
||||
无密钥 headless `subagent-settlement` 场景省略 `run_in_background`,收到立即返回的 child id;尽管 fixture(测试前置数据)有意不调用 `report`,它仍通过管理器生成的结算通知到达 parent 最终答案。包测试另行固定了显式 `false` 的前台语义、parent 调度文本以及 child 的强制报告提示词。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**把字段替换为 `run_in_foreground`。** 反转布尔值会让常见情形以肯定形式表达,却会为同一项调度选择创造第二套词汇,并迫使所有现有调用方与面向提供方的 transcript(文本记录)一起改变。保留 `run_in_background` 可以维持单一字段,并把前台作为显式例外。
|
||||
|
||||
**增加可配置的后台默认值。** 独立默认值可能与 `backgroundMode`、schema 措辞和已安装提示词不一致。生命周期策略已经区分可继续 Activation 与一次性 Task,而这个区别正好决定了后台完成是否会自动投递。
|
||||
|
||||
**只修改提示词。** 如果运行时解析不变,提示词偏好仍会让省略参数的调用进入前台。模型必须能够依赖公布的默认值,而不是在每次工具调用中完美复述它。
|
||||
|
||||
**最终报告到达后抑制结算通知。** 条件结算会重新引入每次 Activation 的记账,并且当 child 先报告进度、随后失败时丢掉无条件运行时保证。即使生成的消息与最终报告重叠,结算仍然无条件执行。
|
||||
|
||||
**只用 `report` 发送结算前的进度。** 这样可以消除重复的最终内容,但也会从 child 提示词中移除由 child 编写的显式交接。最终报告义务保持不变,运行时结算则继续作为它的独立后备和终止记录。
|
||||
|
||||
## 后果
|
||||
|
||||
- 普通可继续调用无需写出 `run_in_background: true` 即为非阻塞;串行委派需要显式选择 `false`。
|
||||
- 同一条 assistant 消息中的独立 subagent 调用会在工具循环的并发安全分发下重叠执行;有依赖的前台调用仍可逐个发出。
|
||||
- parent 指引、工具 schema、运行时解析和结算投递陈述同一个默认值。
|
||||
- 遵循指令的 child 会发送一份自包含的最终结果,也可以更早报告重要发现。每次 Activation 还会产生无条件结算通知,因此已完成的运行可能两次投递相互重叠的最终内容。
|
||||
- 一次性后台 Task 与禁用后台的工具实例保留现有行为。
|
||||
Reference in New Issue
Block a user