Merge origin/master into worktree/remove-sdk-project-toolchain
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/subagent/subagent-fork/README.md
|
||||
README.md: 55475aee7841e91960de79887dfe9bf37afdf9da
|
||||
README.zh.md: 379218970c406162309659277a4fcb2da885d9ea
|
||||
README.md: 2bd72058ab7d112f8f317a33842fc4d95b719017
|
||||
README.zh.md: 40f9e34c5a8ae8c74de2ae0c9e4676866f0bd083
|
||||
@@ -39,7 +39,7 @@ Forking duplicates retained completed history into separate child requests; the
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
The child may reuse the inherited byte-identical prefix under the same provider and model. Persona, tool-filter, generated-SDK, or route changes may invalidate reuse before inherited history; later child history is append-only.
|
||||
The child may reuse the inherited byte-identical prefix under the same provider and model. Persona, tool-filter, generated-SDK, or route changes may invalidate reuse before inherited history; later child history is append-only. Shipped compositions therefore bind this provider to `backgroundMode: one-shot`, because a continuable child additionally carries the child-scoped `report` tool and its prompt section — deltas that precede the inherited history and so invalidate all of it ([the fork-one-shot Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md)).
|
||||
|
||||
### Parent tool result, indirectly
|
||||
|
||||
@@ -58,3 +58,4 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **The seed is a one-time snapshot** — the child sees the parent's completed turns as of the fork and nothing the parent logs afterwards; there is no live context sharing.
|
||||
- **No shipped composition creates a continuable fork child** — `prepareContinuable` remains implemented and the seam accepts it, but every shipped `cordis.yml` sets `backgroundMode: one-shot` on the fork delegation tool, so the provider's continuable path has no production caller. Reopening it requires the child's system prompt and tool schemas to match the parent's byte for byte, which the [`report` return channel](../tool-subagent-report/README.md) currently prevents. Rationale and the reintroduction condition: [the fork-one-shot Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md).
|
||||
@@ -39,7 +39,7 @@ fork 会把保留的已完成历史复制到独立的子 agent 请求中;随
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
在提供方和模型相同的前提下,子 agent 可以复用继承的逐字节相同前缀。persona、工具过滤、生成 SDK 或路由变化可能在继承历史之前使复用失效;后续子 agent 历史仅追加。
|
||||
在提供方和模型相同的前提下,子 agent 可以复用继承的逐字节相同前缀。persona、工具过滤、生成 SDK 或路由变化可能在继承历史之前使复用失效;后续子 agent 历史仅追加。因此随附组合把本提供方绑定为 `backgroundMode: one-shot`:可继续子 agent 还会额外携带作用域局部的 `report` 工具及其提示词 section,而这些增量位于继承历史之前,会使继承历史整体失效(见 [fork 保持 one-shot 的 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md))。
|
||||
|
||||
### 父 agent 工具结果(间接)
|
||||
|
||||
@@ -58,3 +58,4 @@ fork 会把保留的已完成历史复制到独立的子 agent 请求中;随
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **初始内容是一次性快照**:子 agent 只能看到 fork 时父 agent 已完成的轮次,看不到父 agent 此后记录的任何内容;不会实时共享上下文。
|
||||
- **没有任何随附组合会创建可继续的 fork 子 agent**:`prepareContinuable` 仍然实现完好,seam 也接受它,但每份随附的 `cordis.yml` 都在 fork 委派工具上设置 `backgroundMode: one-shot`,因此该提供方的可继续路径没有生产调用方。重新开放它需要子 agent 的系统提示词与工具 schema 与父 agent 逐字节一致,而这一点目前被 [`report` 返回通道](../tool-subagent-report/README.md)阻止。理由与重新开放条件见 [fork 保持 one-shot 的 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md)。
|
||||
@@ -74,6 +74,12 @@ class ForkProvider implements SubagentProvider {
|
||||
})
|
||||
}
|
||||
|
||||
// TODO(fork-continuable-prefix-reuse): no shipped composition calls this —
|
||||
// they bind fork to `backgroundMode: one-shot` because a continuable child's
|
||||
// `report` tool and prompt section precede the inherited history, defeating
|
||||
// the prefix reuse a fork exists for. Reopening needs a byte-identical child
|
||||
// system prompt and tool schemas; see issue #2124 and
|
||||
// .agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md.
|
||||
prepareContinuable(request: ContinuableCreateRequest): Promise<ContinuableCreateSpec> {
|
||||
// The fork prefix is captured ONCE, at creation: it becomes part of the
|
||||
// child's own durable transcript, so a later cold resume replays that
|
||||
|
||||
@@ -13,8 +13,9 @@
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { foldConsumedWork } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent'
|
||||
import { findLastMessageTurnEnd, SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import { createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import {
|
||||
appendDelegatedPolicyOverrides,
|
||||
@@ -52,6 +53,10 @@ function toStopReason(reason: TurnEndReason | undefined): SubagentStopReason {
|
||||
return 'max-tokens'
|
||||
case 'aborted':
|
||||
return 'aborted'
|
||||
// A pre-step rejection discarded the claimed prompt: the task was
|
||||
// declined, and the caller must not read the run as done.
|
||||
case 'blocked':
|
||||
return 'refusal'
|
||||
case 'error':
|
||||
case 'interrupted':
|
||||
default:
|
||||
@@ -207,7 +212,11 @@ function readResult(
|
||||
structured?: { captured?: { value: unknown } | undefined },
|
||||
): SubagentResult {
|
||||
const own = child.session.events.slice(boundary)
|
||||
const lastEnd = findLastMessageTurnEnd(own)
|
||||
// `droppedUnrun` is deliberately unread: a one-shot prompt is claimed by its
|
||||
// awaited first turn almost immediately, and the owner's own teardown is the
|
||||
// `cancelled` flag below. A cancellation with no accounting turn resolves
|
||||
// `error` through `toStopReason(undefined)`, which never overstates success.
|
||||
const lastEnd = foldConsumedWork(own).end
|
||||
// The seam's canonical selection rule; a partial answer survives cancel and truncation.
|
||||
const output: ContentBlock[] = finalAssistantOutput(own) ?? []
|
||||
const recorded = toStopReason(lastEnd?.data.reason)
|
||||
|
||||
@@ -82,6 +82,20 @@ describe('startInProcessRun', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('reports a prompt a pre-step rejection discarded as refusal, not completion', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
// A UserPromptSubmit deny or a policy plugin: the child claims its prompt,
|
||||
// the rejection discards it, and the turn closes `blocked` with no step.
|
||||
ctx.on('agent/pre-step', async ({ agent: subject }, next) => {
|
||||
if (subject === parent) return next()
|
||||
return { kind: 'reject' as const }
|
||||
})
|
||||
|
||||
const run = await startInProcessRun(request(parent), {})
|
||||
await expect(run.result).resolves.toMatchObject({ stopReason: 'refusal' })
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('does not add a final durability checkpoint to a foreground run', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('driver answer')])
|
||||
let flushes = 0
|
||||
|
||||
@@ -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: b9f65befab71edc0685f1a6cca767c803afcb76c
|
||||
README.zh.md: f58975815742ccbe790ca1cf096449831144a2f3
|
||||
README.md: 27b01188cc1663d9cf59d3d24c91b3dbb35bea5a
|
||||
README.zh.md: e28a8794cfb227dd475ea2b4c42605a90ec7fa1d
|
||||
@@ -76,6 +76,14 @@ The manager derives three internal residency conditions from Agent quiescence an
|
||||
|
||||
The manager reserves the child identity, resolves the durable descriptor, calls `ctx.agents.create()` (or `ctx.agents.resume()` for cold resume) through a private activation-owner scope, installs the returned `AgentHandle` in the Activation, establishes any continuable-parent ownership, and then submits the prompt. Cold resume never dispatches through a provider because the persisted Session already holds the initial prefix and the folded descriptor is the whole reconstruction input.
|
||||
|
||||
### Settlement delivery
|
||||
|
||||
When a resident Activation settles, the manager tells the child's durable direct parent, in the parent's own turn stream, that the child produced everything it is going to. Delivery is unconditional for every child whose id a caller actually received: it does not consider whether the child called `report`, because the endings that most need an account — a token ceiling, a model failure, cancellation, teardown — are exactly the ones where the child never got to choose. A materialization rolled back before its first accepted message stays silent, since that caller was told the child was not established. The message carries the epoch's stop reason, its final assistant content when it produced any, and durable provenance `{ kind: 'subagent-settled', form: 'notice', senderSessionId: <child-id> }` — a different source kind from a child-authored `subagent-report`, so a transcript never credits the child with words the runtime wrote.
|
||||
|
||||
Two ordering rules make the delivery reliable rather than lucky, and both are why this belongs to the manager instead of an external `subagent/end` listener. First, the send happens **before** the child's ownership release, while the parent still counts the child and is therefore structurally unable to be judged settled. Second, a parent that is itself a resident Activation receives the message through the same waking-admission accounting as a report, so the window between the synchronous send and the microtask that admits it is not mistaken for quiescence — `Agent.status` folds context maintenance into `idle`, and a waking send behind maintenance only arms a deferred wake. Without either rule the parent can be disposed with the notice still in an inbox that `cancel()` clears, which loses it silently.
|
||||
|
||||
An idle parent receives the notice as one ordinary later turn. A busy parent is steered into its nearest step boundary instead, so several children settling together cost one step rather than one turn each; steering rather than injecting also means a driver that retires between the status read and the send still claims the message. A parent whose own lineage is already draining receives the notice by injection, with no wake at all: `Agent.followup()` on a quiescent parent starts a turn and `cancel()` does not arm against a later one, so waking during teardown would spend a model request on an Agent its host is about to dispose — once per tree layer, since each layer's notice then wakes the layer above it. The injected message reaches a parent that is still reading its inbox, and the log records the account either way, but it does not outlive that parent's own disposal: `AgentHandle.dispose()` is a `keepInbox: false` cancel, which durably cancels an unclaimed notice. A resumed parent therefore has no pending notice to read: `list_agents` tells it which children exist and whether each is live or stored, while the outcome itself stays in the child's own Session, which a `send_message` reaches by resuming that child. A parent that has left the registry is not an error: the notice is dropped and the child's own Session remains the durable record. Delivery never blocks or fails teardown — a rejected send is logged, because retaining a child to retry a notice would pin its whole ancestry in `waiting` forever.
|
||||
|
||||
A continuation-managed parent Activation records each child Session id in an `ownedChildren` set before the child can run and disposes only after every owned child Activation completes `AgentHandle` disposal (child-first). Teardown propagates Agent cancellation top-down before awaiting slow descendants, while handle release remains child-first. Top-level and other non-continuation Agents have no Activation and stay outside this waiting graph. Final settlement awaits a best-effort `ctx.sessions.flush(child.session)` before handle disposal. A listener rejection is logged without failing the Activation because listener participation does not identify a persistence backend; the persisted state may therefore be missing or stale on resume.
|
||||
|
||||
## Lifecycle events
|
||||
@@ -94,17 +102,31 @@ When `ctx.sessionProjections` is available, the service registers two projection
|
||||
|
||||
## Collection model
|
||||
|
||||
The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool, whose generic status, collection, and cancellation tools own later interaction, and persists its model-supplied `description` as the optional display label. Continuable background delegation calls `ctx.subagents.startContinuable()` and returns only the durable child id; the child owns its own turns from inbox acceptance, so there is no Task and no result promise — a caller sends later work with the `send_message` follow-up tool, and `interrupt()` stops only the current turn without disposing the child, and the durable child Session remains the source of the child's detailed output. The continuation manager exists only while `ctx.agents` is available, and session persistence is resolved per continuation operation. Independently, `listChildren()` enumerates the live-preferred merge of the live session store and optional session persistence — live-only when persistence is absent, since a cold child cannot be resumed then either — and serves each child's durable mode/label from the registered `subagent` projection unit: the registry's watermark snapshot for a live child; for a cold one, a durable projection-cache row when it serves an own-suffix identity — its `seq` gate proves the value postdates the fork seed, where a child's own descriptor is immutable once appended — else one bounded-concurrency persistence inspection folded through the registry, whose result must still name the enumerated lifecycle (a re-published id degrades to a `corrupt` diagnostic). A throwing cache read renders no verdict — the cache is derived data — and silently falls through to that authoritative re-fold. The projection fold is the single classification authority; listing parses no descriptor itself. A served identity produces a child row; a settled candidate whose fold served no identity is a `corrupt` diagnostic, a failed inspection is a transient `unavailable` retried on the next listing, and a running candidate without an identity yet is omitted (the creation window before its descriptor is appended). It never consults the continuation manager, Agent registrations, Activations, or providers. Each child row derives its read-time `hasChildren` hint from merged headers carrying durable `origin: 'subagent'`; it does not read descendant event logs, and the descriptor-backed child catalog remains authoritative when expanded. Service consumers such as a UI can retain both modes and choose a fallback for an unlabeled one-shot child; the model-facing `list_agents` tool projects only `continuable` entries and refines status through the live Agent registry (`running`/`idle`/`complete`) and walks `listDescendants()` for its `descendants` scope. The listing forwards the caller's signal to every persistence read, checks cancellation around each of those awaits, and reports every observed abort as `SubagentError` code `CANCELLED`; an unmounted projection registry fails loud with `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`, and a missing session store with `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [durable catalog Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts.
|
||||
The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool, whose generic status, collection, and cancellation tools own later interaction, and persists its model-supplied `description` as the optional display label. Continuable background delegation calls `ctx.subagents.startContinuable()` and returns only the durable child id; the child owns its own turns from inbox acceptance, so there is no Task and no result promise — a caller sends later work with the `send_message` follow-up tool, and `interrupt()` stops only the current turn without disposing the child, and the durable child Session remains the source of the child's detailed output. The continuation manager exists only while `ctx.agents` is available, and session persistence is resolved per continuation operation. Independently, `listChildren()` enumerates the live-preferred merge of the live session store and optional session persistence — live-only when persistence is absent, since a cold child cannot be resumed then either — and serves each child's durable mode/label from the registered `subagent` projection unit: the registry's watermark snapshot for a live child; for a cold one, a durable projection-cache row when it serves an own-suffix identity — its `seq` gate proves the value postdates the fork seed, where a child's own descriptor is immutable once appended — else one bounded-concurrency persistence inspection folded through the registry, whose result must still name the enumerated lifecycle (a re-published id degrades to a `corrupt` diagnostic). A throwing cache read renders no verdict — the cache is derived data — and silently falls through to that authoritative re-fold. The projection fold is the single classification authority; listing parses no descriptor itself. A served identity produces a child row; a settled candidate whose fold served no identity is a `corrupt` diagnostic, a failed inspection is a transient `unavailable` retried on the next listing, and a running candidate without an identity yet is omitted (the creation window before its descriptor is appended). It never consults the continuation manager, Agent registrations, Activations, or providers. Each child row derives its read-time `hasChildren` hint from merged headers carrying durable `origin: 'subagent'`; it does not read descendant event logs, and the descriptor-backed child catalog remains authoritative when expanded. Service consumers such as a UI can retain both modes and choose a fallback for an unlabeled one-shot child; the model-facing `list_agents` tool projects only `continuable` entries and refines status through the live Agent registry and maps storage-only to its resumable-not-terminal `ready` (`running`/`idle`/`ready`) and walks `listDescendants()` for its `descendants` scope. The listing forwards the caller's signal to every persistence read, checks cancellation around each of those awaits, and reports every observed abort as `SubagentError` code `CANCELLED`; an unmounted projection registry fails loud with `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`, and a missing session store with `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [durable catalog Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts.
|
||||
|
||||
Continuable Activations await a best-effort final session flush without treating listener participation as durability confirmation. One-shot runs retain best-effort session checkpointing, so a completed one-shot child is discoverable after disposal only when its session actually reached persistence; the service does not invent a catalog entry from Task history when that checkpoint is absent.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Settlement notice
|
||||
|
||||
#### What the model sees
|
||||
|
||||
One user-role parent message opening with the outcome — `Background subagent <child-id> finished and will do no further work unless you send it more.`, or the matching line for a child that was stopped, ran out of room, declined, or failed — followed by `Its closing message:` and the child's final assistant content, or `It left no closing message.` when it produced none. This is the service's only direct parent-side contribution; delegation schemas, parent continuation and discovery, and the child-scoped `report` belong to `dsh-tool-subagent`, `dsh-tool-subagent-control`, and `dsh-tool-subagent-report`.
|
||||
|
||||
#### Token effect
|
||||
|
||||
One notice per settled Activation in the parent's request, sized by the child's final message. A child that both reports and settles costs the parent both.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only in the parent: the notice follows its reusable request prefix. Reaching an idle parent starts one independent model request; reaching a busy one does not.
|
||||
|
||||
### Child delegation-scope statement
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Every in-process child's runtime-context snapshot carries the `subagent:delegation` statement below, after the sandbox-policy and approval-policy sentences; parent-side rendering stays with `dsh-tool-subagent` (delegation schemas), `dsh-tool-subagent-control` (continuation and discovery), and `dsh-tool-subagent-report` (the child-scoped `report`).
|
||||
Every in-process child's runtime-context snapshot carries the `subagent:delegation` statement below, after the sandbox-policy and approval-policy sentences.
|
||||
|
||||
##### The delegation-scope statement
|
||||
|
||||
|
||||
@@ -76,6 +76,14 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
|
||||
管理器预留子 agent 身份、解析持久化描述符,通过私有的 activation-owner 作用域调用 `ctx.agents.create()`(冷恢复时为 `ctx.agents.resume()`),把返回的 `AgentHandle` 安装到 Activation 中,建立任何可继续父级所有权,然后提交提示词。冷恢复绝不通过提供方分发,因为持久化 Session 已持有初始前缀,折叠后的描述符即是全部重建输入。
|
||||
|
||||
### 结算投递
|
||||
|
||||
当一个驻留 Activation 结算时,管理器会在父级自身的轮次流中告知该子级持久化的直接父级:这个子级已经产出它将产出的全部内容。对每个调用方真正拿到过 id 的子级,这条投递都是无条件的:它不考虑该子级是否调用过 `report`,因为最需要这条投递的结束方式——token 上限、模型失败、取消、拆卸——恰恰是子级根本没有机会选择的那些。在第一条消息被接受之前就回滚的物化保持静默,因为那位调用方已被告知该子级未建立。消息会携带该 epoch 的终止原因、它产出过的最终 assistant 内容,以及持久化来源 `{ kind: 'subagent-settled', form: 'notice', senderSessionId: <child-id> }`——与子级自撰的 `subagent-report` 是不同的来源 kind,因此 transcript(文本记录)绝不会把运行时写下的话算到子级头上。
|
||||
|
||||
有两条顺序规则让这条投递可靠而非侥幸,它们也正是这件事属于管理器而非外部 `subagent/end` listener 的原因。第一,发送发生在子级所有权释放**之前**,此时父级仍然计入该子级,因此在结构上不可能被判定为已结算。第二,本身就是驻留 Activation 的父级会通过与 report 相同的唤醒准入记账接收该消息,因此同步发送与承认它的那个 microtask 之间的窗口不会被误判为静止——`Agent.status` 会把上下文维护折叠成 `idle`,而维护期间的唤醒发送只会预置一次延后唤醒。缺少其中任一条规则,父级都可能在通知仍留在 inbox 时被 dispose,而 `cancel()` 会清空该 inbox,于是通知被静默丢失。
|
||||
|
||||
空闲父级会以一个普通的后续轮次收到该通知。繁忙父级则被 steer 到其最近的 step 边界,因此同时结算的多个子级只消耗一个 step,而不是各自一个轮次;采用 steer 而非 inject 还意味着:即便驱动在状态读取与发送之间退出,该消息仍会被认领。若父级自身所在的谱系已在 draining,则该通知改为 inject 送达,完全不唤醒:对静息父级调用 `Agent.followup()` 会开启一个轮次,而 `cancel()` 不会对之后的轮次设防,因此在拆卸期间唤醒,会在宿主即将 dispose 的 Agent 上白花一次模型请求——而且每层树各一次,因为每层自己的通知又会唤醒它上面那层。被 inject 的消息会送达仍在读取自身 inbox 的父级,而无论如何日志都会记录这份记账;但它不会比该父级自身的 dispose 活得更久:`AgentHandle.dispose()` 是一次 `keepInbox: false` 的 cancel,会持久地取消尚未被认领的通知。因此 resume 后的父级没有待处理通知可读:`list_agents` 只告诉它有哪些子级、各自是在线还是仅存于存储;结局本身留在子级自己的 Session 里,一次 `send_message` 会通过 resume 该子级把它取回。已离开注册表的父级不算错误:通知被丢弃,子级自身的 Session 仍是持久记录。投递绝不会阻塞或使拆卸失败——发送被拒只会记录日志,因为为了重试一条通知而保留子级,会把它的整条祖先链永久钉在 `waiting` 上。
|
||||
|
||||
受继续执行管理的父级 Activation 会在子 agent 能够运行之前,把每个子 agent 的 Session id 记录到 `ownedChildren` 集合中,并且只有在每个所拥有的子 agent Activation 完成 `AgentHandle` dispose 之后才会 dispose(子先于父)。拆卸会先自顶向下传播 Agent 取消,再等待缓慢的后代,而 handle 释放仍保持 child-first。顶层及其他非继续执行的 Agent 没有 Activation,处于该等待图之外。最终结算会在 dispose handle 前等待 best-effort 的 `ctx.sessions.flush(child.session)`。listener rejection 会被记录,但不会使 Activation 失败,因为 listener 是否参与无法标识持久化后端;因此,恢复时持久化状态可能缺失或陈旧。
|
||||
|
||||
## 生命周期事件
|
||||
@@ -94,17 +102,31 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
|
||||
## 收集模型
|
||||
|
||||
面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task,其通用状态、收集和取消工具负责后续交互,并将模型提供的 `description` 持久化为可选显示标签。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、也没有结果 promise——调用方通过 `send_message` 后续操作工具发送后续工作,`interrupt()` 只停止当前轮次而不 dispose 子 agent,而持久化子 agent Session 仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 枚举在线会话存储与可选会话持久化的在线优先合并——持久化缺席时仅枚举在线 child,因为那时冷 child 本就无法恢复——并由已注册的 `subagent` 投影单元供给每个 child 的持久化模式与标签:在线 child 取注册表的水位快照;冷 child 先取可选投影缓存的持久化行,且仅当其 `seq` 门证明该值折叠自 child 自身后缀(fork 种子之后——自有描述符一经追加即不可变)才直接采用,否则经一次有界并发的持久化 inspect 再经注册表折叠,且 inspect 结果必须仍指向枚举时的生命周期(同 id 被重新发布的会话降级为 `corrupt` diagnostic)。缓存读取抛错不产生判决——缓存是派生数据——静默落到该权威重折。投影折叠是唯一的分类权威;列表自身不解析任何描述符。取得身份值即产出 child 行;已定局而折叠未产出身份的候选是 `corrupt` diagnostic,inspect 失败是瞬时的 `unavailable`(下次列表重试),运行中而暂无身份值的候选整行省略(描述符尚未追加的创建窗口)。它不查询继续执行管理器、Agent 注册信息、Activation 或提供方。每个 child 行都会根据合并结果中携带持久化 `origin: 'subagent'` 的 header 派生读取时的 `hasChildren` 提示;它不会读取后代事件日志,展开后仍以描述符支撑的 child 目录为权威依据。UI 等服务消费方可以保留两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 工具只投影 `continuable` 条目,通过在线 Agent 注册表细化状态(`running`/`idle`/`complete`),并在 `descendants` scope 下遍历 `listDescendants()`。列表操作会把调用方的取消信号转发到每次持久化读取,在这些 await 前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`;投影注册表未挂载则以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 响亮失败,会话存储缺失则以 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` 响亮失败。完整约定见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)、[持久化目录 Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)、[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。
|
||||
面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task,其通用状态、收集和取消工具负责后续交互,并将模型提供的 `description` 持久化为可选显示标签。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、也没有结果 promise——调用方通过 `send_message` 后续操作工具发送后续工作,`interrupt()` 只停止当前轮次而不 dispose 子 agent,而持久化子 agent Session 仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 枚举在线会话存储与可选会话持久化的在线优先合并——持久化缺席时仅枚举在线 child,因为那时冷 child 本就无法恢复——并由已注册的 `subagent` 投影单元供给每个 child 的持久化模式与标签:在线 child 取注册表的水位快照;冷 child 先取可选投影缓存的持久化行,且仅当其 `seq` 门证明该值折叠自 child 自身后缀(fork 种子之后——自有描述符一经追加即不可变)才直接采用,否则经一次有界并发的持久化 inspect 再经注册表折叠,且 inspect 结果必须仍指向枚举时的生命周期(同 id 被重新发布的会话降级为 `corrupt` diagnostic)。缓存读取抛错不产生判决——缓存是派生数据——静默落到该权威重折。投影折叠是唯一的分类权威;列表自身不解析任何描述符。取得身份值即产出 child 行;已定局而折叠未产出身份的候选是 `corrupt` diagnostic,inspect 失败是瞬时的 `unavailable`(下次列表重试),运行中而暂无身份值的候选整行省略(描述符尚未追加的创建窗口)。它不查询继续执行管理器、Agent 注册信息、Activation 或提供方。每个 child 行都会根据合并结果中携带持久化 `origin: 'subagent'` 的 header 派生读取时的 `hasChildren` 提示;它不会读取后代事件日志,展开后仍以描述符支撑的 child 目录为权威依据。UI 等服务消费方可以保留两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 工具只投影 `continuable` 条目,通过在线 Agent 注册表细化状态,并把仅存于存储的状态映射为可恢复而非终态的 `ready`(`running`/`idle`/`ready`),并在 `descendants` scope 下遍历 `listDescendants()`。列表操作会把调用方的取消信号转发到每次持久化读取,在这些 await 前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`;投影注册表未挂载则以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 响亮失败,会话存储缺失则以 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` 响亮失败。完整约定见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)、[持久化目录 Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)、[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。
|
||||
|
||||
可继续 Activation 会等待 best-effort 的最终会话 flush,但不会把 listener 参与视为持久性确认。一次性运行保留尽力执行的会话检查点,因此已完成的一次性 child 只有在其会话确实进入持久化存储时,才可在 dispose 后继续被发现;如果该检查点缺失,服务不会根据 Task 历史虚构目录条目。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 结算通知
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
一条用户角色的父级消息,开头是结果本身——`Background subagent <child-id> finished and will do no further work unless you send it more.`,或子级被停止、耗尽额度、拒绝任务或失败时的对应句子——随后是 `Its closing message:` 与子级的最终 assistant 内容;若子级没有产出内容,则是 `It left no closing message.`。这是本服务面向父级的唯一直接贡献;委派 schema、父级延续与发现以及子级作用域的 `report` 分别归 `dsh-tool-subagent`、`dsh-tool-subagent-control` 和 `dsh-tool-subagent-report` 所有。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
父级请求中,每个已结算的 Activation 一条通知,长度取决于子级的最终消息。既上报又结算的子级会让父级同时支付两份。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
在父级中仅追加:通知位于其可复用请求前缀之后。到达空闲父级会启动一次独立的模型请求,到达繁忙父级则不会。
|
||||
|
||||
### 子级委派范围声明
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
每个进程内子 agent 的运行时上下文快照都携带下方的 `subagent:delegation` 声明,位于沙箱策略与审批策略语句之后;父级侧的渲染仍归 `dsh-tool-subagent`(委派 schema)、`dsh-tool-subagent-control`(延续与发现)和 `dsh-tool-subagent-report`(子级作用域的 `report`)所有。
|
||||
每个进程内子 agent 的运行时上下文快照都携带下方的 `subagent:delegation` 声明,位于沙箱策略与审批策略语句之后。
|
||||
|
||||
##### 委派范围声明
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Internal continuable-subagent manager: stable child ids, descriptor
|
||||
* persistence, activation admission, the live ownership graph, cold resume,
|
||||
* and child-first disposal behind `ctx.subagents`.
|
||||
* child-first disposal, and settlement delivery to the parent, behind
|
||||
* `ctx.subagents`.
|
||||
*
|
||||
* A continuable child has one durable Session and at most one process-local
|
||||
* {@link Activation} — one residency epoch for a reconstructed child Agent. An
|
||||
@@ -11,6 +12,12 @@
|
||||
* residency while the Agent loop owns all turn ordering and execution. No
|
||||
* continuable path creates a Task or an intermediate result-bearing wrapper.
|
||||
*
|
||||
* Because residency is this manager's alone to end, telling the parent that a
|
||||
* child settled is its job too. An external `subagent/end` listener cannot do
|
||||
* it correctly: that payload names no parent, the child handle is already
|
||||
* disposed by then, and the release that wakes the parent's own settlement
|
||||
* watcher has already run. See {@link SubagentContinuationManager.notifySettlement}.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent
|
||||
*/
|
||||
|
||||
@@ -23,7 +30,7 @@ import type {
|
||||
AgentSetupCommit,
|
||||
CreateAgentOptions,
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import { boundContextSummary, createUserMessage, errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, MessageId, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
@@ -42,8 +49,8 @@ import {
|
||||
import type { DelegatedPolicyOverrides } from './child-agent.ts'
|
||||
import { assertSubagentMaxDepth } from './depth.ts'
|
||||
import { seedDescriptorTurn } from './descriptor-seed.ts'
|
||||
import type { ContinuableCreateRequest, ContinuableCreateSpec, SubagentStartRequest } from './types.ts'
|
||||
import type { ActivationObserver } from './lifecycle.ts'
|
||||
import type { ContinuableCreateRequest, ContinuableCreateSpec, SubagentResult, SubagentStartRequest } from './types.ts'
|
||||
import type { ActivationObserver, ActivationTerminal } from './lifecycle.ts'
|
||||
import { SubagentError } from './error.ts'
|
||||
import type SubagentActivationSetupRegistry from './activation-setup-registry.ts'
|
||||
|
||||
@@ -65,10 +72,28 @@ export interface SubagentReportMessageSource {
|
||||
readonly senderSessionId: SessionId
|
||||
}
|
||||
|
||||
/**
|
||||
* Durable attribution for the runtime's own account of a continuable child
|
||||
* settling. Deliberately a different kind from
|
||||
* {@link SubagentReportMessageSource}: a report is content the child chose,
|
||||
* while this message is the manager stating what became of the child, and a
|
||||
* transcript that merged them would credit the child with words it never wrote.
|
||||
*/
|
||||
export interface SubagentSettledMessageSource {
|
||||
readonly kind: 'subagent-settled'
|
||||
/** A runtime account shown without expanding the row (`notice` context form). */
|
||||
readonly form: 'notice'
|
||||
/** One-line account of how the child ended. */
|
||||
readonly summary: string
|
||||
/** Session id of the child that settled. */
|
||||
readonly senderSessionId: SessionId
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-llm' {
|
||||
interface MessageSourceMap {
|
||||
coordinator: CoordinatorMessageSource
|
||||
'subagent-report': SubagentReportMessageSource
|
||||
'subagent-settled': SubagentSettledMessageSource
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +191,13 @@ interface ContinuationHost {
|
||||
interface Activation {
|
||||
/** The durable child this Activation is an epoch of. */
|
||||
readonly childId: SessionId
|
||||
/**
|
||||
* The durable direct parent, stored because settlement delivery must resolve
|
||||
* that parent after the child handle is gone. {@link ancestry} cannot answer
|
||||
* it: a `WeakSet` is not enumerable, and the child's own header is only
|
||||
* reachable through a handle disposal has already released.
|
||||
*/
|
||||
readonly parentSession: SessionId
|
||||
/** The provider name recorded in the durable descriptor. */
|
||||
readonly provider: string
|
||||
/** The retained live Agent handle, disposed exactly once at settlement. */
|
||||
@@ -197,6 +229,12 @@ interface Activation {
|
||||
* microtask that admits it, so settlement must not treat that gap as quiet.
|
||||
*/
|
||||
readonly accepted: Set<MessageId>
|
||||
/**
|
||||
* Whether any delivery to this child was ever accepted. A materialization
|
||||
* rolled back before its first acceptance is a child the caller was told does
|
||||
* not exist, so its teardown owes the parent no settlement account.
|
||||
*/
|
||||
announced: boolean
|
||||
/** Renewed whenever a settlement watcher must re-observe quiescence. */
|
||||
poke: PromiseWithResolvers<void>
|
||||
}
|
||||
@@ -243,6 +281,36 @@ function disposalOf(activation: Activation): Promise<void> | undefined {
|
||||
return activation.disposal
|
||||
}
|
||||
|
||||
/**
|
||||
* One line telling a parent that a background child is finished and why, in
|
||||
* the parent's own task vocabulary.
|
||||
* @param childId - the durable child the parent knows by id.
|
||||
* @param stopReason - how the child's last ordinary turn ended.
|
||||
* @returns the model-facing opening line of the settlement notice.
|
||||
*/
|
||||
function settlementSummary(childId: SessionId, stopReason: SubagentResult['stopReason']): string {
|
||||
const subject = `Background subagent ${childId}`
|
||||
switch (stopReason) {
|
||||
case 'completed':
|
||||
return `${subject} finished and will do no further work unless you send it more.`
|
||||
case 'aborted':
|
||||
return `${subject} was stopped before it finished.`
|
||||
case 'max-tokens':
|
||||
return `${subject} ran out of room before it finished.`
|
||||
// A pre-step rejection — a hook deny, a policy plugin — discarded input
|
||||
// the child had claimed, so the parent must not treat the task as done.
|
||||
case 'refusal':
|
||||
return `${subject} declined the task.`
|
||||
case 'error':
|
||||
return `${subject} failed before it finished.`
|
||||
/* v8 ignore next 4 -- `SubagentResult['stopReason']` is merge-extensible, so this arm
|
||||
* needs a backend that adds a variant; an unnameable ending is reported as unfinished
|
||||
* rather than silently as success. */
|
||||
default:
|
||||
return `${subject} ended abnormally (${String(stopReason)}) before it finished.`
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether one settlement attempt opened the disposal transaction. */
|
||||
type SettlementAttempt =
|
||||
| { readonly settling: false }
|
||||
@@ -576,19 +644,36 @@ export class SubagentContinuationManager {
|
||||
senderSessionId: activation.childId,
|
||||
},
|
||||
})
|
||||
const parentActivation = this.activations.get(parent.id)
|
||||
if (delivery === 'wakeup'
|
||||
&& parentActivation !== undefined
|
||||
&& parentActivation.handle.agent === parent) {
|
||||
this.admitWaking(parentActivation, message.id, () => {
|
||||
this.sendReport(parent, message, delivery)
|
||||
})
|
||||
if (delivery === 'wakeup') {
|
||||
this.sendWaking(parent, message, () => { this.sendReport(parent, message, delivery) })
|
||||
} else {
|
||||
this.sendReport(parent, message, delivery)
|
||||
}
|
||||
return message.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform one waking send to a parent, accounted against that parent's own
|
||||
* Activation when it has one. Registering the id before the send is what
|
||||
* keeps a continuation-managed parent from being judged quiescent in the
|
||||
* window between `followup()` and the microtask that admits it.
|
||||
* @param parent - the exact live parent receiving the waking message.
|
||||
* @param message - the message whose id is accounted.
|
||||
* @param send - the synchronous waking send to perform.
|
||||
*/
|
||||
private sendWaking(
|
||||
parent: Agent,
|
||||
message: ReturnType<typeof createUserMessage>,
|
||||
send: () => void,
|
||||
): void {
|
||||
const parentActivation = this.activations.get(parent.id)
|
||||
if (parentActivation !== undefined && parentActivation.handle.agent === parent) {
|
||||
this.admitWaking(parentActivation, message.id, send)
|
||||
} else {
|
||||
send()
|
||||
}
|
||||
}
|
||||
|
||||
/** Send one report while translating only the parent's own rejection. */
|
||||
private sendReport(
|
||||
parent: Agent,
|
||||
@@ -745,23 +830,32 @@ export class SubagentContinuationManager {
|
||||
return lineage
|
||||
}
|
||||
|
||||
/** Reject new admission once the manager or this exact parent tree began draining. */
|
||||
private assertAdmitting(agent: Agent): void {
|
||||
if (this.draining) {
|
||||
throw new SubagentError(
|
||||
'continuable subagents are draining; the operation was not admitted',
|
||||
'DRAINING',
|
||||
)
|
||||
}
|
||||
/**
|
||||
* The teardown that closed continuable admission for this agent's lineage.
|
||||
* `'manager'` is the whole manager draining; an Agent is the exact scoped root
|
||||
* whose forest is closing.
|
||||
* @param agent - the agent whose lineage is tested.
|
||||
* @returns the closing teardown, or `undefined` while admission is open.
|
||||
*/
|
||||
private closingTeardownFor(agent: Agent): Agent | 'manager' | undefined {
|
||||
if (this.draining) return 'manager'
|
||||
const lineage = this.liveLineage(agent)
|
||||
for (const [root, members] of this.closingScopes) {
|
||||
if (members.has(agent) || lineage.includes(root)) {
|
||||
throw new SubagentError(
|
||||
`continuable subagents below parent "${root.id}" are draining; the operation was not admitted`,
|
||||
'DRAINING',
|
||||
)
|
||||
}
|
||||
if (members.has(agent) || lineage.includes(root)) return root
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Reject new admission once the manager or this exact parent tree began draining. */
|
||||
private assertAdmitting(agent: Agent): void {
|
||||
const closing = this.closingTeardownFor(agent)
|
||||
if (closing === undefined) return
|
||||
throw new SubagentError(
|
||||
closing === 'manager'
|
||||
? 'continuable subagents are draining; the operation was not admitted'
|
||||
: `continuable subagents below parent "${closing.id}" are draining; the operation was not admitted`,
|
||||
'DRAINING',
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -930,6 +1024,10 @@ export class SubagentContinuationManager {
|
||||
|
||||
const activation: Activation = {
|
||||
childId,
|
||||
// The durable lineage, not merely the caller: creation stamps this same
|
||||
// agent into the child's header, and cold resume authorized it against
|
||||
// the persisted header before materializing.
|
||||
parentSession: parent.id,
|
||||
provider,
|
||||
handle,
|
||||
ancestry: new WeakSet([handle.agent, ...parentLineage]),
|
||||
@@ -937,6 +1035,7 @@ export class SubagentContinuationManager {
|
||||
observer,
|
||||
disposal: undefined,
|
||||
accepted: new Set(),
|
||||
announced: false,
|
||||
poke: Promise.withResolvers<void>(),
|
||||
}
|
||||
// After transfer, any failure must dispose the created handle, remove the
|
||||
@@ -1038,9 +1137,13 @@ export class SubagentContinuationManager {
|
||||
// establish it before the message can enter the child's inbox.
|
||||
this.acquireOwnership(parent, activation.childId)
|
||||
const message = createUserMessage({ content, source })
|
||||
return this.admitWaking(activation, message.id, () => {
|
||||
const accepted = this.admitWaking(activation, message.id, () => {
|
||||
activation.handle.agent.followup(message)
|
||||
})
|
||||
// Past this point the caller has an id for this child, so its eventual
|
||||
// settlement is something the parent is owed an account of.
|
||||
activation.announced = true
|
||||
return accepted
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1261,6 +1364,12 @@ export class SubagentContinuationManager {
|
||||
// makes a racing delivery wait for release rather than cold-resume into the
|
||||
// still-registered agent.
|
||||
this.activations.delete(childId)
|
||||
// BEFORE releasing ownership, while the parent still counts this child and
|
||||
// therefore cannot be judged settled. Delivering after the release would
|
||||
// race a parent watcher that resumes one microtask later, finds itself
|
||||
// childless and quiet, and disposes an Agent whose `cancel()` clears the
|
||||
// inbox this notice is sitting in.
|
||||
this.notifySettlement(activation, activation.observer.terminal(failure))
|
||||
// Release ownership even on failure: a retained failed child would pin its
|
||||
// ancestors in `waiting` forever.
|
||||
this.releaseOwnership(childId)
|
||||
@@ -1270,6 +1379,75 @@ export class SubagentContinuationManager {
|
||||
if (failure !== undefined) throw failure
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the durable direct parent that this child produced everything it is
|
||||
* going to. Unconditional for every child the caller received an id for: it
|
||||
* does not consider whether the child reported, because the cases that most
|
||||
* need it — a token ceiling, a model failure, cancellation, teardown — are
|
||||
* exactly the ones where the child never got to choose. A materialization
|
||||
* rolled back before its first acceptance stays silent, since the caller was
|
||||
* told that child was not established. A parent that is no longer live is not
|
||||
* an error; the child's own Session remains the durable record either way.
|
||||
* A parent whose own lineage is already closing receives the notice without a
|
||||
* wake, because teardown is not a reason to start a turn.
|
||||
*
|
||||
* Never blocks disposal. A delivery failure is logged and dropped, because
|
||||
* retaining a child to retry a notice would pin its whole ancestry in
|
||||
* `waiting` forever.
|
||||
* @param activation - the settling Activation, still owned by its parent.
|
||||
* @param terminal - how this epoch ended, as the terminal edge will report it.
|
||||
*/
|
||||
private notifySettlement(activation: Activation, terminal: ActivationTerminal): void {
|
||||
if (!activation.announced) return
|
||||
try {
|
||||
const parent = this.ctx.agents.get(activation.parentSession)
|
||||
if (parent === undefined) return
|
||||
const summary = settlementSummary(activation.childId, terminal.stopReason)
|
||||
const message = createUserMessage({
|
||||
content: [
|
||||
{ type: 'text' as const, text: summary },
|
||||
...terminal.output === undefined
|
||||
? [{ type: 'text' as const, text: 'It left no closing message.' }]
|
||||
: [{ type: 'text' as const, text: 'Its closing message:' }, ...terminal.output],
|
||||
],
|
||||
source: {
|
||||
kind: 'subagent-settled' as const,
|
||||
form: 'notice' as const,
|
||||
summary: boundContextSummary(summary),
|
||||
senderSessionId: activation.childId,
|
||||
},
|
||||
})
|
||||
// A parent whose own teardown already began must not be woken. Waking is
|
||||
// not a queue operation: `followup()` on a quiescent Agent starts a turn,
|
||||
// and `cancel()` does not arm against a later one, so a notice arriving
|
||||
// during teardown would spend a model request on an Agent its host is
|
||||
// about to dispose — once per tree layer, since each layer's own notice
|
||||
// then wakes the layer above it. Injecting delivers to a parent still
|
||||
// reading its inbox and records the account in the log either way; it
|
||||
// does NOT survive that parent's own disposal, whose `keepInbox: false`
|
||||
// cancel durably clears whatever it never claimed.
|
||||
if (this.closingTeardownFor(parent) !== undefined) {
|
||||
parent.inject(message)
|
||||
return
|
||||
}
|
||||
// An idle parent has nothing else to look at, so it gets one ordinary
|
||||
// turn. A busy parent is steered instead of woken: `Inbox.claim()` takes
|
||||
// the whole next-step batch at one boundary, so several children settling
|
||||
// together cost one step rather than one turn each. Steering rather than
|
||||
// injecting closes the window where a driver retires between this status
|
||||
// read and the send, which would strand the notice unclaimed.
|
||||
this.sendWaking(parent, message, () => {
|
||||
if (parent.status === 'idle') parent.followup(message)
|
||||
else parent.steer(message)
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
this.ctx.logger.warn(
|
||||
`subagent "${activation.childId}" settlement notice was not delivered to its parent: `
|
||||
+ errorChain(error),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a best-effort final session flush after the child is quiescent.
|
||||
* Listener failure is logged because flush participation cannot identify a
|
||||
|
||||
@@ -119,6 +119,7 @@ export type {
|
||||
SubagentReportDelivery,
|
||||
SubagentReportMessageSource,
|
||||
SubagentReportOptions,
|
||||
SubagentSettledMessageSource,
|
||||
} from './continuation.ts'
|
||||
export type { ContinuableSetupContribution } from './activation-setup-registry.ts'
|
||||
export type { SubagentDescendantListEntry, SubagentListEntry } from './list-children.ts'
|
||||
|
||||
@@ -18,12 +18,23 @@ import { randomUUID } from 'node:crypto'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { findLastMessageTurnEnd } from '@deepseek-ai/dsh-session'
|
||||
import { foldConsumedWork } from '@deepseek-ai/dsh-agent'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { finalAssistantOutput } from './assistant-output.ts'
|
||||
import { SubagentRunId } from './types.ts'
|
||||
import type { SubagentResult, SubagentRun, SubagentRunEndInfo, SubagentRunInfo } from './types.ts'
|
||||
|
||||
/**
|
||||
* How one Activation's residency epoch ended, as both the terminal lifecycle
|
||||
* edge and the manager's own parent delivery report it.
|
||||
*/
|
||||
export interface ActivationTerminal {
|
||||
/** Why this epoch's last ordinary turn ended, or `error` when teardown failed. */
|
||||
readonly stopReason: SubagentResult['stopReason']
|
||||
/** The epoch's final assistant content, absent when it produced none or failed. */
|
||||
readonly output?: ContentBlock[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle observer for one Activation's residency epoch, so continuable
|
||||
* children emit the same start/end pair as one-shot runs. Package-private: the
|
||||
@@ -43,6 +54,15 @@ export interface ActivationObserver {
|
||||
* @param child - the quiescent child agent about to be released.
|
||||
*/
|
||||
capture(child: Agent): void
|
||||
/**
|
||||
* Resolve the terminal facts {@link settle} will publish, without publishing
|
||||
* them. The manager's parent delivery must run before the ownership release
|
||||
* that lets the parent settle, which is earlier than the terminal edge; both
|
||||
* therefore read one computation instead of restating the failure rule.
|
||||
* @param failure - the teardown or durability failure, or `undefined` on success.
|
||||
* @returns this epoch's stop reason and final assistant content.
|
||||
*/
|
||||
terminal(failure: unknown): ActivationTerminal
|
||||
/**
|
||||
* Publish the terminal edge exactly once, pairing this epoch's {@link start},
|
||||
* after the disposal outcome is known. Called only for a resident epoch: a
|
||||
@@ -165,9 +185,12 @@ export function createActivationObserver(
|
||||
let boundary = 0
|
||||
// Assigned by `capture()`, which the disposal path always runs before
|
||||
// `settle()`; a resident epoch therefore always has its facts by then.
|
||||
let captured: { stopReason: SubagentResult['stopReason']; output?: ContentBlock[] } = {
|
||||
stopReason: 'completed',
|
||||
}
|
||||
let captured: ActivationTerminal = { stopReason: 'completed' }
|
||||
// Teardown failure overrides the epoch's own outcome and withholds its
|
||||
// output: an answer this harness could not durably release is not a result.
|
||||
const terminal = (failure: unknown): ActivationTerminal => failure === undefined
|
||||
? captured
|
||||
: { stopReason: 'error' }
|
||||
return {
|
||||
start: (child: Agent): void => {
|
||||
boundary = child.session.events.length
|
||||
@@ -181,11 +204,12 @@ export function createActivationObserver(
|
||||
...output === undefined ? {} : { output },
|
||||
}
|
||||
},
|
||||
terminal,
|
||||
settle: (failure: unknown): void => {
|
||||
const output = failure === undefined ? captured.output : undefined
|
||||
const { stopReason, output } = terminal(failure)
|
||||
emit('subagent/end', {
|
||||
...identity,
|
||||
stopReason: failure === undefined ? captured.stopReason : 'error',
|
||||
stopReason,
|
||||
...output === undefined ? {} : { lastAssistantMessage: output },
|
||||
}, parent)
|
||||
},
|
||||
@@ -193,18 +217,24 @@ export function createActivationObserver(
|
||||
}
|
||||
|
||||
/**
|
||||
* Why this child's last ordinary turn ended, for the terminal lifecycle edge.
|
||||
* The child's own `turn/end` is authoritative: teardown succeeding says nothing
|
||||
* about whether the model errored, hit its token ceiling, or was cancelled, so
|
||||
* deriving the reason from disposal would report failed work as completed.
|
||||
* Why this child's epoch ended, for the terminal lifecycle edge and the
|
||||
* manager's own parent delivery. The child's own log is authoritative:
|
||||
* teardown succeeding says nothing about whether the model errored, hit its
|
||||
* token ceiling, or was cancelled, so deriving the reason from disposal would
|
||||
* report failed work as completed.
|
||||
*
|
||||
* {@link foldConsumedWork} supplies both halves the raw turn sequence cannot:
|
||||
* which turn accounts for the work this epoch consumed, and whether accepted
|
||||
* work was cancelled after it without any turn opening over it. A recorded
|
||||
* failure still wins over a cancellation — stopping a child that had already
|
||||
* failed does not turn its failure into a cancellation.
|
||||
* @param events - this epoch's own event suffix.
|
||||
* @returns its terminal stop reason; `completed` when no ordinary turn closed.
|
||||
* @returns its terminal stop reason; `completed` only for an epoch that both
|
||||
* closed cleanly and had nothing left to run.
|
||||
*/
|
||||
function epochStopReason(events: readonly SessionEvent[]): SubagentResult['stopReason'] {
|
||||
const reason = findLastMessageTurnEnd(events)?.data.reason
|
||||
// No ordinary turn closed, so nothing failed either.
|
||||
if (reason === undefined) return 'completed'
|
||||
switch (reason.kind) {
|
||||
const { end, droppedUnrun } = foldConsumedWork(events)
|
||||
switch (end?.data.reason.kind) {
|
||||
case 'max-tokens':
|
||||
return 'max-tokens'
|
||||
case 'aborted':
|
||||
@@ -212,8 +242,15 @@ function epochStopReason(events: readonly SessionEvent[]): SubagentResult['stopR
|
||||
return 'aborted'
|
||||
case 'error':
|
||||
return 'error'
|
||||
// A pre-step rejection — a hook deny, a policy plugin — discarded input
|
||||
// this epoch had claimed: the work was declined, not done.
|
||||
case 'blocked':
|
||||
return 'refusal'
|
||||
// A clean ending and no accounting turn at all share one rule: the epoch
|
||||
// finished what it was given unless a cancelled queue says otherwise.
|
||||
case undefined:
|
||||
case 'completed':
|
||||
return 'completed'
|
||||
return droppedUnrun ? 'aborted' : 'completed'
|
||||
/* v8 ignore next 3 -- `TurnEndReason` is merge-extensible, so this arm needs a
|
||||
* backend that adds a variant; treating an unnameable reason as success would
|
||||
* report failed work as completed. */
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { GenerateOptions, MessageId, StreamChunk } from '@deepseek-ai/dsh-l
|
||||
import { CallId, createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import SubagentService, {
|
||||
SubagentError,
|
||||
SUBAGENT_DESCRIPTOR_VERSION,
|
||||
@@ -142,6 +142,18 @@ async function waitNoActivation(ctx: Context, childId: SessionId): Promise<void>
|
||||
}, { timeout: 5_000 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the top-level test parent out of a scripted model corpus. Every child
|
||||
* settlement wakes its parent, so a suite that scripts only child responses
|
||||
* would otherwise spend them on the parent's own turns.
|
||||
*/
|
||||
function parkParent(ctx: Context, parent: Agent): void {
|
||||
ctx.on('agent/pre-step', async ({ agent: subject }, next) => {
|
||||
if (subject !== parent) return next()
|
||||
return { kind: 'reject' as const }
|
||||
})
|
||||
}
|
||||
|
||||
/** Observe calls at the Agent cancellation boundary without a production event. */
|
||||
function observeCancel(agent: Agent, callback: () => void): void {
|
||||
const cancel = agent.cancel.bind(agent)
|
||||
@@ -531,7 +543,10 @@ describe('SubagentService.followup residency routing', () => {
|
||||
await waitNoActivation(ctx, grandchild.childId)
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
const loaded = await ctx.sessionPersistence.load(started.childId)
|
||||
expect(userTexts(loaded.events)).toEqual(['child task', 'while waiting'])
|
||||
// This child is itself a parent, so its grandchild's settlement notice is
|
||||
// an ordinary later user message in its log.
|
||||
expect(userTexts(loaded.events).slice(0, 2)).toEqual(['child task', 'while waiting'])
|
||||
expect(userTexts(loaded.events).slice(2).join('\n')).toContain('finished and will do no further work')
|
||||
})
|
||||
|
||||
it('rejects a parent that is not the durable direct parent', async () => {
|
||||
@@ -1182,6 +1197,7 @@ describe('continuable review regressions', () => {
|
||||
|
||||
it('reports this epoch\'s own output, captured while the child was still live', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('first answer'), textResponse('second answer')])
|
||||
parkParent(ctx, parent)
|
||||
const ends: SubagentRunEndInfo[] = []
|
||||
ctx.on('subagent/end', (info) => { ends.push(info) })
|
||||
|
||||
@@ -1254,9 +1270,10 @@ describe('continuable review regressions', () => {
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
|
||||
await vi.waitFor(() => { expect(ends).toHaveLength(1) })
|
||||
// Reading the whole session would resurrect 'first answer' here.
|
||||
// Reading the whole session would resurrect 'first answer' here. The
|
||||
// rejection discarded the claimed follow-up, so the epoch reads as refused.
|
||||
expect(ends[0]!.lastAssistantMessage).toBeUndefined()
|
||||
expect(ends[0]!.stopReason).toBe('completed')
|
||||
expect(ends[0]!.stopReason).toBe('refusal')
|
||||
})
|
||||
|
||||
it('reports handle-disposal failure on the terminal edge', async () => {
|
||||
@@ -1439,11 +1456,13 @@ describe('continuable review regressions', () => {
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
})
|
||||
|
||||
it('reports completed when no ordinary turn closed', async () => {
|
||||
it('reports a prompt a pre-step rejection discarded as refusal', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
parkParent(ctx, parent)
|
||||
const ends: SubagentRunEndInfo[] = []
|
||||
ctx.on('subagent/end', (info) => { ends.push(info) })
|
||||
// Block admission so the child's only turn never opens.
|
||||
// A UserPromptSubmit deny or a policy plugin: the child claims its prompt,
|
||||
// the rejection discards it, and no step ever runs.
|
||||
ctx.on('agent/pre-step', async ({ agent: subject }, next) => {
|
||||
if (subject === parent) return next()
|
||||
return { kind: 'reject' }
|
||||
@@ -1452,8 +1471,10 @@ describe('continuable review regressions', () => {
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
|
||||
// The parent would otherwise believe a vetoed delivery was done and never
|
||||
// resend it — the one failure the settlement promise says cannot happen.
|
||||
await vi.waitFor(() => { expect(ends).toHaveLength(1) })
|
||||
expect(ends[0]!.stopReason).toBe('completed')
|
||||
expect(ends[0]!.stopReason).toBe('refusal')
|
||||
})
|
||||
|
||||
it('retains the Activation while an accepted message is still in the inbox', async () => {
|
||||
@@ -1482,15 +1503,538 @@ describe('continuable review regressions', () => {
|
||||
expect(ctx.agents.get(started.childId)).toBe(child)
|
||||
releaseFirst.resolve(undefined)
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
// Two child turns; the third request is the parent's own turn on the
|
||||
// settlement notice.
|
||||
expect(adapter.requests.filter(request => request.sessionId === started.childId)).toHaveLength(2)
|
||||
const loaded = await ctx.sessionPersistence.load(started.childId)
|
||||
expect(hasUserText(loaded.events, 'queued')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
/** Every settlement notice this agent received, in order, as flat text. */
|
||||
function settlementNotices(agent: Agent): { sender: string; text: string; summary: string }[] {
|
||||
const logged = agent.session.events.flatMap(event => event.type === 'user/message' ? [event.data] : [])
|
||||
return [...logged, ...agent.inbox.nextStep, ...agent.inbox.nextTurn].flatMap((message) => {
|
||||
if (message.source.kind !== 'subagent-settled') return []
|
||||
return [{
|
||||
sender: message.source.senderSessionId,
|
||||
summary: message.source.summary,
|
||||
text: message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n'),
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
describe('continuable settlement delivery', () => {
|
||||
it('tells the parent what the child finished with, without being asked', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('the answer'), textResponse('parent ack')])
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
|
||||
await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) })
|
||||
const notice = settlementNotices(parent)[0]!
|
||||
expect(notice.sender).toBe(started.childId)
|
||||
expect(notice.text).toBe(
|
||||
`Background subagent ${started.childId} finished and will do no further work unless you send it more.`
|
||||
+ '\nIts closing message:\nthe answer',
|
||||
)
|
||||
// The collapsed row states the outcome without the child's content.
|
||||
expect(notice.summary).toBe(
|
||||
`Background subagent ${started.childId} finished and will do no further work unless you send it more.`,
|
||||
)
|
||||
})
|
||||
|
||||
it('delivers even when the child already reported for itself', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('the answer'), textResponse('parent ack')])
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
const child = await vi.waitFor(() => {
|
||||
const live = ctx.agents.get(started.childId)
|
||||
expect(live).toBeDefined()
|
||||
return live!
|
||||
})
|
||||
await ctx.subagents.reportFrom(child, message('an explicit report'), {
|
||||
delivery: 'quiet',
|
||||
signal: testSignal,
|
||||
})
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
|
||||
// The contract is unconditional precisely so the parent-side tool
|
||||
// description can promise it; bookkeeping "did it report?" would make the
|
||||
// promise conditional on a channel this manager does not own.
|
||||
await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) })
|
||||
})
|
||||
|
||||
it('delivers the terminal reason when the child never had a chance to report', async () => {
|
||||
const { ctx, parent } = await setup([maxTokensResponse('half an ans'), textResponse('parent ack')])
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
|
||||
await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) })
|
||||
expect(settlementNotices(parent)[0]!.text).toBe(
|
||||
`Background subagent ${started.childId} ran out of room before it finished.`
|
||||
+ '\nIts closing message:\nhalf an ans',
|
||||
)
|
||||
})
|
||||
|
||||
it('tells the parent a policy-rejected delivery was declined, not finished', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('parent ack')])
|
||||
// A pre-step rejection on the child — a UserPromptSubmit deny, a policy
|
||||
// plugin — discards the claimed prompt without running it.
|
||||
ctx.on('agent/pre-step', async ({ agent: subject }, next) => {
|
||||
if (subject === parent) return next()
|
||||
return { kind: 'reject' }
|
||||
})
|
||||
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
|
||||
await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) })
|
||||
expect(settlementNotices(parent)[0]!.text).toBe(
|
||||
`Background subagent ${started.childId} declined the task.`
|
||||
+ '\nIt left no closing message.',
|
||||
)
|
||||
})
|
||||
|
||||
it('reports a turn that failed before reaching its first step', async () => {
|
||||
const releaseFirst = Promise.withResolvers<undefined>()
|
||||
const adapter = new GatedAdapter([
|
||||
{ chunks: textResponse('the answer'), gate: releaseFirst.promise },
|
||||
{ chunks: textResponse('parent ack') },
|
||||
])
|
||||
const { ctx, parent } = await setupWith(adapter)
|
||||
// The shipped durability checkpoint (`dsh-session-checkpoint-policy`) is
|
||||
// fail-closed at the step boundary, so a rejected write ends the turn after
|
||||
// it claimed its messages and before it entered a step.
|
||||
ctx.on('agent/pre-step', async ({ agent: subject, turn }, next) => {
|
||||
if (subject.session.header.parentSession === undefined || turn < 2) return next()
|
||||
throw new Error('ENOSPC: no space left on device')
|
||||
})
|
||||
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
await followup(ctx, parent, started.childId, message('second task'))
|
||||
releaseFirst.resolve(undefined)
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
|
||||
await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) })
|
||||
// The parent must not be told the child finished: the delivery it is still
|
||||
// waiting on was claimed out of the inbox and then swallowed by the failure.
|
||||
const child = await ctx.sessionPersistence.load(started.childId)
|
||||
expect(hasUserText(child.events, 'second task')).toBe(false)
|
||||
expect(settlementNotices(parent)[0]!.text).toBe(
|
||||
`Background subagent ${started.childId} failed before it finished.`
|
||||
+ '\nIts closing message:\nthe answer',
|
||||
)
|
||||
})
|
||||
|
||||
it('reports accepted work cut short before its first step as stopped', async () => {
|
||||
const releaseFirst = Promise.withResolvers<undefined>()
|
||||
const releaseCheckpoint = Promise.withResolvers<undefined>()
|
||||
const atCheckpoint = Promise.withResolvers<undefined>()
|
||||
const adapter = new GatedAdapter([{ chunks: textResponse('the answer'), gate: releaseFirst.promise }])
|
||||
const { ctx, parent } = await setupWith(adapter)
|
||||
// A step-boundary participant — the shipped durability checkpoint, a hook,
|
||||
// prompt assembly — holding the child's second turn open before its first
|
||||
// step, which is where teardown cancellation then catches it.
|
||||
ctx.on('agent/pre-step', async ({ agent: subject, turn }, next) => {
|
||||
if (subject.session.header.parentSession === undefined || turn < 2) return next()
|
||||
atCheckpoint.resolve(undefined)
|
||||
await releaseCheckpoint.promise
|
||||
return next()
|
||||
})
|
||||
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
// Queued while turn 1 still runs, so turn 2 opens and claims it without a
|
||||
// second model call: the Activation is mid-turn when the drain cancels it.
|
||||
await followup(ctx, parent, started.childId, message('second task'))
|
||||
releaseFirst.resolve(undefined)
|
||||
await atCheckpoint.promise
|
||||
const drained = drainManager(ctx)
|
||||
releaseCheckpoint.resolve(undefined)
|
||||
await drained
|
||||
|
||||
// Turn 2 leaves a balanced no-step `aborted` end, so the log alone would
|
||||
// answer with turn 1's clean completion and tell the parent its still-unrun
|
||||
// task had finished.
|
||||
await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) })
|
||||
expect(settlementNotices(parent)[0]!.text).toBe(
|
||||
`Background subagent ${started.childId} was stopped before it finished.`
|
||||
+ '\nIts closing message:\nthe answer',
|
||||
)
|
||||
})
|
||||
|
||||
it('reports a child stopped before it ever reached the model as stopped', async () => {
|
||||
const releaseCheckpoint = Promise.withResolvers<undefined>()
|
||||
const atCheckpoint = Promise.withResolvers<undefined>()
|
||||
const { ctx, parent } = await setupWith(new GatedAdapter([]))
|
||||
ctx.on('agent/pre-step', async ({ agent: subject }, next) => {
|
||||
if (subject.session.header.parentSession === undefined) return next()
|
||||
atCheckpoint.resolve(undefined)
|
||||
await releaseCheckpoint.promise
|
||||
return next()
|
||||
})
|
||||
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
await atCheckpoint.promise
|
||||
const drained = drainManager(ctx)
|
||||
releaseCheckpoint.resolve(undefined)
|
||||
await drained
|
||||
|
||||
// This epoch closed no stepped turn at all, which on its own reads as "had
|
||||
// nothing to report"; only the interruption distinguishes it from a child
|
||||
// that genuinely finished with no output.
|
||||
await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) })
|
||||
expect(settlementNotices(parent)[0]!.text).toBe(
|
||||
`Background subagent ${started.childId} was stopped before it finished.`
|
||||
+ '\nIt left no closing message.',
|
||||
)
|
||||
})
|
||||
|
||||
it('reports a child an ancestor interrupted before its first step as stopped', async () => {
|
||||
const atCheckpoint = Promise.withResolvers<undefined>()
|
||||
const releaseCheckpoint = Promise.withResolvers<undefined>()
|
||||
const adapter = new GatedAdapter([{ chunks: textResponse('parent ack') }])
|
||||
const { ctx, parent } = await setupWith(adapter)
|
||||
ctx.on('agent/pre-step', async ({ agent: subject }, next) => {
|
||||
if (subject.session.header.parentSession === undefined) return next()
|
||||
atCheckpoint.resolve(undefined)
|
||||
await releaseCheckpoint.promise
|
||||
return next()
|
||||
})
|
||||
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
await atCheckpoint.promise
|
||||
// The shipped interrupt path: nothing about it runs inside this manager, so
|
||||
// no pre-cancel sample could see it — the child's own log has to say so.
|
||||
ctx.subagents.interrupt(started.childId, { kind: 'ancestor', agent: parent })
|
||||
releaseCheckpoint.resolve(undefined)
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
|
||||
await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) })
|
||||
expect(settlementNotices(parent)[0]!.text).toBe(
|
||||
`Background subagent ${started.childId} was stopped before it finished.`
|
||||
+ '\nIt left no closing message.',
|
||||
)
|
||||
})
|
||||
|
||||
it('reports accepted work cancelled before any turn could open as stopped', async () => {
|
||||
const releaseChild = Promise.withResolvers<undefined>()
|
||||
const releaseGrandchild = Promise.withResolvers<undefined>()
|
||||
const releaseMaintenance = Promise.withResolvers<undefined>()
|
||||
const adapter = new GatedAdapter([
|
||||
{ chunks: textResponse('the answer'), gate: releaseChild.promise },
|
||||
{ chunks: textResponse('grandchild'), gate: releaseGrandchild.promise },
|
||||
])
|
||||
const { ctx, parent } = await setupWith(adapter)
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
const child = await vi.waitFor(() => {
|
||||
const live = ctx.agents.get(started.childId)
|
||||
expect(live).toBeDefined()
|
||||
return live!
|
||||
})
|
||||
// A descendant keeps the child resident once its own turn closes, so the
|
||||
// maintenance phase below is reachable without racing settlement.
|
||||
const grandchild = await ctx.subagents.startContinuable(startSpec(child))
|
||||
await vi.waitFor(() => { expect(ctx.agents.get(grandchild.childId)).toBeDefined() })
|
||||
releaseChild.resolve(undefined)
|
||||
await vi.waitFor(() => { expect(child.status).toBe('idle') })
|
||||
|
||||
// Context maintenance folds into `idle` and defers waking work, so this
|
||||
// delivery is accepted with no turn to claim it.
|
||||
const maintaining = child.runMaintenance(async () => { await releaseMaintenance.promise })
|
||||
await followup(ctx, parent, started.childId, message('never runs'))
|
||||
const drained = drainManager(ctx)
|
||||
releaseMaintenance.resolve(undefined)
|
||||
releaseGrandchild.resolve(undefined)
|
||||
await maintaining
|
||||
await drained
|
||||
|
||||
// Turn 1 closed cleanly and no later turn opened, so the cancelled queue is
|
||||
// the only record that this epoch was cut short.
|
||||
expect(hasUserText(child.session.events, 'never runs')).toBe(false)
|
||||
await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) })
|
||||
expect(settlementNotices(parent)[0]!.text).toBe(
|
||||
`Background subagent ${started.childId} was stopped before it finished.`
|
||||
+ '\nIts closing message:\nthe answer',
|
||||
)
|
||||
})
|
||||
|
||||
it('withholds an outcome the harness could not durably release', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('the answer'), textResponse('parent ack')])
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
const manager = (ctx.subagents as unknown as {
|
||||
continuations: { activations: Map<SessionId, { handle: { dispose(): Promise<void> } }> }
|
||||
}).continuations
|
||||
const activation = await vi.waitFor(() => {
|
||||
const live = manager.activations.get(started.childId)
|
||||
expect(live).toBeDefined()
|
||||
return live!
|
||||
})
|
||||
const dispose = activation.handle.dispose.bind(activation.handle)
|
||||
activation.handle.dispose = async () => {
|
||||
await dispose()
|
||||
throw new Error('scope unwind failed')
|
||||
}
|
||||
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) })
|
||||
expect(settlementNotices(parent)[0]!.text).toBe(
|
||||
`Background subagent ${started.childId} failed before it finished.\nIt left no closing message.`,
|
||||
)
|
||||
})
|
||||
|
||||
it('gives an idle parent one ordinary turn on the notice', async () => {
|
||||
const { ctx, parent, adapter } = await setup([textResponse('the answer'), textResponse('parent ack')])
|
||||
const turnStarts: number[] = []
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (session.id === parent.id && event.type === 'turn/start') turnStarts.push(event.data.turn)
|
||||
})
|
||||
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
await vi.waitFor(() => {
|
||||
expect(adapter.requests.filter(request => request.sessionId === parent.id)).toHaveLength(1)
|
||||
})
|
||||
expect(turnStarts).toEqual([1])
|
||||
})
|
||||
|
||||
it('batches simultaneous notices into one step of a busy parent', async () => {
|
||||
const releaseChildren = Promise.withResolvers<undefined>()
|
||||
const releaseParent = Promise.withResolvers<undefined>()
|
||||
const adapter = new GatedAdapter([
|
||||
{ chunks: textResponse('parent works'), gate: releaseParent.promise },
|
||||
{ chunks: textResponse('first child'), gate: releaseChildren.promise },
|
||||
{ chunks: textResponse('second child'), gate: releaseChildren.promise },
|
||||
{ chunks: textResponse('parent reacts') },
|
||||
])
|
||||
const { ctx, parent } = await setupWith(adapter)
|
||||
// Open a parent turn first, so both notices arrive while it is running.
|
||||
parent.followup(createUserMessage({ content: message('start working'), source: { kind: 'user' } }))
|
||||
await vi.waitFor(() => { expect(parent.status).toBe('running') })
|
||||
|
||||
const first = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
const second = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
releaseChildren.resolve(undefined)
|
||||
await waitNoActivation(ctx, first.childId)
|
||||
await waitNoActivation(ctx, second.childId)
|
||||
|
||||
// Both notices are waiting for the same step boundary, not two turns.
|
||||
expect(parent.inbox.nextStep).toHaveLength(2)
|
||||
expect(parent.inbox.nextTurn).toHaveLength(0)
|
||||
const turnStarts: number[] = []
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (session.id === parent.id && event.type === 'turn/start') turnStarts.push(event.data.turn)
|
||||
})
|
||||
releaseParent.resolve(undefined)
|
||||
await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(2) })
|
||||
expect(turnStarts).toEqual([])
|
||||
// Both children released together, so which settles first is not ordered.
|
||||
expect(new Set(settlementNotices(parent).map(entry => entry.sender)))
|
||||
.toEqual(new Set([first.childId, second.childId]))
|
||||
})
|
||||
|
||||
it('holds a maintaining parent live until it can read the notice', async () => {
|
||||
const releaseFirst = Promise.withResolvers<undefined>()
|
||||
const releaseSecond = Promise.withResolvers<undefined>()
|
||||
const adapter = new GatedAdapter([
|
||||
{ chunks: textResponse('outer') },
|
||||
{ chunks: textResponse('first inner'), gate: releaseFirst.promise },
|
||||
{ chunks: textResponse('second inner'), gate: releaseSecond.promise },
|
||||
{ chunks: textResponse('outer reacts') },
|
||||
{ chunks: textResponse('root reacts') },
|
||||
])
|
||||
const { ctx, parent } = await setupWith(adapter)
|
||||
const outer = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
const middle = await vi.waitFor(() => {
|
||||
const live = ctx.agents.get(outer.childId)
|
||||
expect(live).toBeDefined()
|
||||
return live!
|
||||
})
|
||||
const first = await ctx.subagents.startContinuable(startSpec(middle))
|
||||
const second = await ctx.subagents.startContinuable(startSpec(middle))
|
||||
await vi.waitFor(() => { expect(middle.status).toBe('idle') })
|
||||
|
||||
// `Agent.status` folds maintenance into `idle`, and a waking send behind it
|
||||
// only arms a deferred wake. The first child's release moves the middle
|
||||
// Activation's settlement watcher onto its quiescence race; the second one
|
||||
// then arrives at exactly the point where an unaccounted delivery would be
|
||||
// judged quiet, settled, and cancelled — clearing the inbox it sits in.
|
||||
const maintaining = Promise.withResolvers<undefined>()
|
||||
const maintenance = middle.runMaintenance(async () => { await maintaining.promise })
|
||||
releaseFirst.resolve(undefined)
|
||||
await waitNoActivation(ctx, first.childId)
|
||||
releaseSecond.resolve(undefined)
|
||||
await waitNoActivation(ctx, second.childId)
|
||||
expect(ctx.agents.get(outer.childId)).toBe(middle)
|
||||
|
||||
maintaining.resolve(undefined)
|
||||
await maintenance
|
||||
await vi.waitFor(() => { expect(settlementNotices(middle)).toHaveLength(2) })
|
||||
expect(settlementNotices(middle).map(entry => entry.sender))
|
||||
.toEqual([first.childId, second.childId])
|
||||
await waitNoActivation(ctx, outer.childId)
|
||||
})
|
||||
|
||||
it('delivers before releasing the ownership that lets the parent settle', async () => {
|
||||
const releaseChild = Promise.withResolvers<undefined>()
|
||||
const adapter = new GatedAdapter([
|
||||
{ chunks: textResponse('outer') },
|
||||
{ chunks: textResponse('inner'), gate: releaseChild.promise },
|
||||
{ chunks: textResponse('outer reacts') },
|
||||
])
|
||||
const { ctx, parent } = await setupWith(adapter)
|
||||
const outer = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
const middle = await vi.waitFor(() => {
|
||||
const live = ctx.agents.get(outer.childId)
|
||||
expect(live).toBeDefined()
|
||||
return live!
|
||||
})
|
||||
const inner = await ctx.subagents.startContinuable(startSpec(middle))
|
||||
await vi.waitFor(() => { expect(middle.status).toBe('idle') })
|
||||
|
||||
const manager = (ctx.subagents as unknown as {
|
||||
continuations: { activations: Map<SessionId, { ownedChildren: Set<SessionId> }> }
|
||||
}).continuations
|
||||
let ownedAtDelivery: SessionId[] | undefined
|
||||
ctx.on('agent/inbox/inserted', ({ agent, message }) => {
|
||||
if (agent !== middle || message.source.kind !== 'subagent-settled') return
|
||||
ownedAtDelivery = [...manager.activations.get(middle.id)!.ownedChildren]
|
||||
})
|
||||
|
||||
releaseChild.resolve(undefined)
|
||||
await waitNoActivation(ctx, inner.childId)
|
||||
// Still owned at delivery: the parent is structurally unable to settle in
|
||||
// the window the notice crosses, rather than winning a race against it.
|
||||
expect(ownedAtDelivery).toEqual([inner.childId])
|
||||
await waitNoActivation(ctx, outer.childId)
|
||||
})
|
||||
|
||||
it('does not wake a parent whose own teardown already began', async () => {
|
||||
const hold = Promise.withResolvers<undefined>()
|
||||
const adapter = new GatedAdapter([{ chunks: textResponse('interrupted'), gate: hold.promise }])
|
||||
const { ctx, parent } = await setupWith(adapter)
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
await vi.waitFor(() => { expect(ctx.agents.get(started.childId)).toBeDefined() })
|
||||
|
||||
const drained = drainManager(ctx)
|
||||
hold.resolve(undefined)
|
||||
await drained
|
||||
|
||||
// Delivered and durably logged, but no turn: waking a parent the host is
|
||||
// about to dispose spends a model request nothing reads. What happens to the
|
||||
// message when that parent is disposed next is pinned by the test below.
|
||||
expect(settlementNotices(parent)).toHaveLength(1)
|
||||
expect(settlementNotices(parent)[0]!.text).toBe(
|
||||
`Background subagent ${started.childId} was stopped before it finished.`
|
||||
+ '\nIt left no closing message.',
|
||||
)
|
||||
expect(parent.session.events.some(event => event.type === 'agent/inbox/spliced')).toBe(true)
|
||||
expect(parent.session.events.some(event => event.type === 'turn/start')).toBe(false)
|
||||
expect(parent.status).toBe('idle')
|
||||
})
|
||||
|
||||
it('does not wake a parent below a scoped teardown root', async () => {
|
||||
const hold = Promise.withResolvers<undefined>()
|
||||
const adapter = new GatedAdapter([{ chunks: textResponse('interrupted'), gate: hold.promise }])
|
||||
const { ctx, parent } = await setupWith(adapter)
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
await vi.waitFor(() => { expect(ctx.agents.get(started.childId)).toBeDefined() })
|
||||
|
||||
const drained = ctx.subagents.drainContinuableDescendants([parent])
|
||||
hold.resolve(undefined)
|
||||
await drained
|
||||
|
||||
expect(settlementNotices(parent)).toHaveLength(1)
|
||||
expect(parent.session.events.some(event => event.type === 'turn/start')).toBe(false)
|
||||
})
|
||||
|
||||
it('records but cannot deliver a teardown notice once the parent is disposed too', async () => {
|
||||
const hold = Promise.withResolvers<undefined>()
|
||||
const adapter = new GatedAdapter([{ chunks: textResponse('interrupted'), gate: hold.promise }])
|
||||
const { ctx } = await setupWith(adapter)
|
||||
const parentId = SessionId('closing-parent')
|
||||
const host = await ctx.agents.create({
|
||||
sessionId: parentId,
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
const started = await ctx.subagents.startContinuable(startSpec(host.agent))
|
||||
await vi.waitFor(() => { expect(ctx.agents.get(started.childId)).toBeDefined() })
|
||||
|
||||
const drained = ctx.subagents.drainContinuableDescendants([host.agent])
|
||||
hold.resolve(undefined)
|
||||
await drained
|
||||
expect(settlementNotices(host.agent)).toHaveLength(1)
|
||||
|
||||
// Disposal is a `keepInbox: false` cancel, so it durably cancels the notice
|
||||
// it never claimed. Teardown delivery therefore reaches a parent that is
|
||||
// still resident — a resumed one reads the log, not a pending message — and
|
||||
// no wording anywhere may promise otherwise.
|
||||
await host.dispose()
|
||||
const resumed = await ctx.agents.resume({
|
||||
resumeSessionId: parentId,
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
expect(settlementNotices(resumed.agent)).toEqual([])
|
||||
await resumed.dispose()
|
||||
// The account is still in the durable log: delivered, then cancelled unread.
|
||||
const persisted = await ctx.sessionPersistence.load(parentId)
|
||||
expect(persisted.events.flatMap(event => event.type === 'agent/inbox/spliced'
|
||||
? [{ inserted: event.data.inserted.length, removed: event.data.removedCount ?? 0 }]
|
||||
: [])).toEqual([{ inserted: 1, removed: 0 }, { inserted: 0, removed: 1 }])
|
||||
})
|
||||
|
||||
it('drops the notice without disturbing teardown when the parent is gone', async () => {
|
||||
const releaseChild = Promise.withResolvers<undefined>()
|
||||
const adapter = new GatedAdapter([{ chunks: textResponse('answer'), gate: releaseChild.promise }])
|
||||
const { ctx } = await setupWith(adapter)
|
||||
const warnings: string[] = []
|
||||
ctx.logger.warn = (text: string) => { warnings.push(text) }
|
||||
const host = await ctx.agents.create({
|
||||
sessionId: SessionId('disposable-parent'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
const started = await ctx.subagents.startContinuable(startSpec(host.agent))
|
||||
const ends: SubagentRunEndInfo[] = []
|
||||
ctx.on('subagent/end', (info) => { ends.push(info) })
|
||||
|
||||
releaseChild.resolve(undefined)
|
||||
await host.dispose()
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
await vi.waitFor(() => { expect(ends).toHaveLength(1) })
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('logs a rejected notice instead of failing the child\'s teardown', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('the answer')])
|
||||
const warnings: string[] = []
|
||||
ctx.logger.warn = (text: string) => { warnings.push(text) }
|
||||
vi.spyOn(parent, 'followup').mockImplementation(() => {
|
||||
throw new Error('parent closed during delivery')
|
||||
})
|
||||
const ends: SubagentRunEndInfo[] = []
|
||||
ctx.on('subagent/end', (info) => { ends.push(info) })
|
||||
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
await vi.waitFor(() => { expect(ends).toHaveLength(1) })
|
||||
expect(ends[0]!.stopReason).toBe('completed')
|
||||
expect(warnings.some(warning => warning.includes('settlement notice was not delivered'))).toBe(true)
|
||||
})
|
||||
|
||||
it('stays silent about a child the caller was told does not exist', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const drains: Promise<void>[] = []
|
||||
ctx.on('subagent/start', () => { drains.push(drainManager(ctx)) })
|
||||
|
||||
await expect(ctx.subagents.startContinuable(startSpec(parent)))
|
||||
.rejects.toMatchObject({ code: 'DRAINING' })
|
||||
await Promise.all(drains)
|
||||
expect(settlementNotices(parent)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('continuable lifecycle observation', () => {
|
||||
it('emits one paired start/end per residency epoch', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('first'), textResponse('second')])
|
||||
parkParent(ctx, parent)
|
||||
const starts: SubagentRunInfo[] = []
|
||||
const ends: SubagentRunEndInfo[] = []
|
||||
ctx.on('subagent/start', (info) => { starts.push(info) })
|
||||
@@ -1510,6 +2054,8 @@ describe('continuable lifecycle observation', () => {
|
||||
expect(starts.map(info => info.provider)).toEqual(['spawn', 'spawn'])
|
||||
// Each end pairs its own start's runId.
|
||||
expect(ends.map(info => info.runId)).toEqual(starts.map(info => info.runId))
|
||||
// Both epochs ran their own scripted response; neither exhausted the corpus.
|
||||
expect(ends.map(info => info.stopReason)).toEqual(['completed', 'completed'])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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-control/README.md
|
||||
README.md: 460617378841b791f7d8750962a9e4549643928e
|
||||
README.zh.md: 5939a1636a5f92dc797e5788c88bba7048ec7c96
|
||||
README.md: 91f8d23ac092049e5315418070cdaae025054860
|
||||
README.zh.md: 981e3f4efa2a904918d3f4174a2188fc2965c626
|
||||
@@ -8,7 +8,7 @@ The tool performs no lifecycle routing — residency and cold resume belong to t
|
||||
|
||||
`interrupt_agent(agent_id)` passes `exec.agent` as the exact live ancestor authority for `ctx.subagents.interrupt()`: the target may be a direct child or a deeper descendant, and the service — never this tool — verifies the caller against the target Activation's recorded lineage. Only the target's current turn stops (`keepInbox`): queued messages stay parked until a later `send_message`, published descendants keep running, and the child stays available for follow-ups. The call returns as soon as the stop request is accepted, without waiting for target quiescence; an absent or already-settled target is an accepted no-op, while self, sibling, stale, and non-ancestor callers become errored results.
|
||||
|
||||
`list_agents` takes one optional `scope` argument, derives the root id from the calling agent, and projects the service catalog to continuable children without a cursor. The default `children` scope reads `ctx.subagents.listChildren()`; `descendants` reads `ctx.subagents.listDescendants()`, whose one-corpus walk crosses ordinary sessions and one-shot children and renders surviving rows in stable pre-order with `parent=<id> depth=<n>`. The `parent` annotation is the durable direct-parent session id and may name an ordinary session omitted from the output. For the calling agent, only depth-1 child entries are `send_message` candidates; deeper child entries are `interrupt_agent` candidates only. Status comes from the live Agent registry: `running` (active driver), `idle` (resident between turns, possibly waiting on agents it started), `complete` (storage only). The service result also contains one-shot session-backed subagents for consumers such as a UI, but those entries are omitted from this model tool because they cannot accept `send_message`. Diagnostics remain visible, with positions in the descendants scope. Durable identity and mode come from each child's descriptor, while delivery-time authority and Activation ownership checks remain the service's.
|
||||
`list_agents` takes one optional `scope` argument, derives the root id from the calling agent, and projects the service catalog to continuable children without a cursor. The default `children` scope reads `ctx.subagents.listChildren()`; `descendants` reads `ctx.subagents.listDescendants()`, whose one-corpus walk crosses ordinary sessions and one-shot children and renders surviving rows in stable pre-order with `parent=<id> depth=<n>`. The `parent` annotation is the durable direct-parent session id and may name an ordinary session omitted from the output. For the calling agent, only depth-1 child entries are `send_message` candidates; deeper child entries are `interrupt_agent` candidates only. Status comes from the live Agent registry: `running` (active driver), `idle` (resident between turns, possibly waiting on agents it started), or `ready` (storage only and resumable rather than terminal). The service result also contains one-shot session-backed subagents for consumers such as a UI, but those entries are omitted from this model tool because they cannot accept `send_message`. Diagnostics remain visible, with positions in the descendants scope. Durable identity and mode come from each child's descriptor, while delivery-time authority and Activation ownership checks remain the service's.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -58,7 +58,7 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
#### What the model sees
|
||||
|
||||
One line per continuable child in stable catalog order: `<id> [<status>] — <label>` (`running` = active driver, `idle` = resident between turns, `complete` = storage only; a direct child in that state can be resumed by `send_message`), plus `<id> [diagnostic: <reason>]` for a candidate that could not be read (`corrupt`, `unsupported`, or `unavailable`). The `descendants` scope inserts ` parent=<id> depth=<n>` before the label dash on every line, in pre-order. One-shot children are intentionally absent; `(no subagents)` means no continuable child or diagnostic survived the projection. Diagnostics never expose descriptor contents.
|
||||
One line per continuable child in stable catalog order: `<id> [<status>] — <label>` (`running` = active driver, `idle` = resident between turns, `ready` = storage only; resumable rather than terminal, not a result waiting to be collected — a direct child in that state can be resumed by `send_message`), plus `<id> [diagnostic: <reason>]` for a candidate that could not be read (`corrupt`, `unsupported`, or `unavailable`). The `descendants` scope inserts ` parent=<id> depth=<n>` before the label dash on every line, in pre-order. One-shot children are intentionally absent; `(no subagents)` means no continuable child or diagnostic survived the projection. Diagnostics never expose descriptor contents.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -72,5 +72,5 @@ Append-only; each result follows the reusable request prefix.
|
||||
|
||||
- **A queued message has no independent result** — acceptance returns only its inbox `messageId`; the child's work lands in the durable child Session and is never collected through this tool. A child granted `report` may send selected content back separately, but that message is not this call's result.
|
||||
- **No steering of the current turn** — every message opens a later FIFO turn, so a message sent while the child is working runs only after its current turn finishes and cannot redirect it.
|
||||
- **Listing is a snapshot, not a delivery promise** — it may race publication, disposal, or a later message, and another process may activate a child this process reports as `complete`; cross-process accuracy requires a shared lease. `interrupt_agent` performs the authoritative live-lineage check itself, so discovery staleness cannot grant authority.
|
||||
- **Listing is a snapshot, not a delivery promise** — it may race publication, disposal, or a later message, and another process may activate a child this process reports as `ready`; cross-process accuracy requires a shared lease. `interrupt_agent` performs the authoritative live-lineage check itself, so discovery staleness cannot grant authority.
|
||||
- **No pagination or deletion** — the complete stably ordered set is returned, and persisted children remain listed for as long as their sessions remain in persistence; a service-level bound or delete operation is a later product decision.
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
`interrupt_agent(agent_id)` 将 `exec.agent` 作为 `ctx.subagents.interrupt()` 的确切在线 ancestor 授权传入:目标可以是直接 child 或更深的后代,由服务——而不是本工具——依据目标 Activation 记录的 lineage 校验调用方。只有目标的当前轮次会停止(`keepInbox`):已排队的消息保持暂停直到之后的 `send_message`,已发布的后代继续运行,child 也仍可接受后续消息。调用在停止请求被接受后立即返回,不等待目标完全停稳;目标不存在或已结算是被接受的 no-op,而 self、sibling、过期与非 ancestor 调用方会成为出错结果。
|
||||
|
||||
`list_agents` 接受一个可选的 `scope` 参数,会从调用它的 agent 推导根 id,并且不使用 cursor,将服务目录投影为可继续 child。默认的 `children` scope 读取 `ctx.subagents.listChildren()`;`descendants` 读取 `ctx.subagents.listDescendants()`,其单份语料的遍历会穿过普通会话与一次性 child,并按稳定 pre-order 以 `parent=<id> depth=<n>` 渲染保留下来的条目。`parent` 注释是持久化直接 parent 会话 id,可能指向输出中省略的普通会话。对于调用本工具的 agent,只有 depth-1 child 条目可作为 `send_message` 候选;更深的 child 条目只能作为 `interrupt_agent` 候选。状态来自在线 Agent 注册表:`running`(driver 活跃)、`idle`(驻留但处于轮次之间,可能在等待它启动的 agent)、`complete`(仅存于存储)。服务结果还包含由会话支撑的一次性 subagent,以供 UI 等消费方使用;但这些条目无法接受 `send_message`,因此会从这个模型工具中排除。diagnostic 仍然可见,并在 descendants scope 中带有位置。持久化身份和模式来自每个子 agent 的描述符,消息送达时的鉴权和 Activation 所有权检查仍归服务负责。
|
||||
`list_agents` 接受一个可选的 `scope` 参数,会从调用它的 agent 推导根 id,并且不使用 cursor,将服务目录投影为可继续 child。默认的 `children` scope 读取 `ctx.subagents.listChildren()`;`descendants` 读取 `ctx.subagents.listDescendants()`,其单份语料的遍历会穿过普通会话与一次性 child,并按稳定 pre-order 以 `parent=<id> depth=<n>` 渲染保留下来的条目。`parent` 注释是持久化直接 parent 会话 id,可能指向输出中省略的普通会话。对于调用本工具的 agent,只有 depth-1 child 条目可作为 `send_message` 候选;更深的 child 条目只能作为 `interrupt_agent` 候选。状态来自在线 Agent 注册表:`running`(driver 活跃)、`idle`(驻留但处于轮次之间,可能在等待它启动的 agent)或 `ready`(仅存于存储,表示可恢复而非终态)。服务结果还包含由会话支撑的一次性 subagent,以供 UI 等消费方使用;但这些条目无法接受 `send_message`,因此会从这个模型工具中排除。diagnostic 仍然可见,并在 descendants scope 中带有位置。持久化身份和模式来自每个子 agent 的描述符,消息送达时的鉴权和 Activation 所有权检查仍归服务负责。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
按稳定目录顺序,每个可继续 child 占一行:渲染为 `<id> [<status>] — <label>`(`running` 表示 driver 活跃,`idle` 表示驻留但处于轮次之间,`complete` 表示仅存于存储;处于该状态的直接 child 可通过 `send_message` 恢复),另为无法读取的候选项渲染 `<id> [diagnostic: <reason>]`(`corrupt`、`unsupported` 或 `unavailable`)。`descendants` scope 会在每行 label 破折号之前插入 ` parent=<id> depth=<n>`,按 pre-order 排列。一次性 child 会被有意排除;`(no subagents)` 表示投影后没有留下可继续 child 或 diagnostic。诊断信息绝不会暴露描述符内容。
|
||||
按稳定目录顺序,每个可继续 child 占一行:渲染为 `<id> [<status>] — <label>`(`running` 表示 driver 活跃,`idle` 表示驻留但处于轮次之间,`ready` 表示仅存于存储;可恢复而非终态,也不表示有结果等待收集——处于该状态的直接 child 可通过 `send_message` 恢复),另为无法读取的候选项渲染 `<id> [diagnostic: <reason>]`(`corrupt`、`unsupported` 或 `unavailable`)。`descendants` scope 会在每行 label 破折号之前插入 ` parent=<id> depth=<n>`,按 pre-order 排列。一次性 child 会被有意排除;`(no subagents)` 表示投影后没有留下可继续 child 或 diagnostic。诊断信息绝不会暴露描述符内容。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
@@ -72,5 +72,5 @@
|
||||
|
||||
- **已排队的消息没有独立结果**:接受时只返回其 inbox `messageId`;子 agent 的工作会落入持久化子 agent 会话,绝不会通过本工具收集。获得 `report` 的子 agent 可以单独发回选定内容,但该消息不是本次调用的结果。
|
||||
- **不对当前轮次进行 steering(中途引导)**:每条消息都会开启后续 FIFO 轮次,因此在子 agent 工作时发送的消息只会在其当前轮次结束后运行,无法将其重定向。
|
||||
- **列表是快照,而非投递承诺**:它可能与发布、dispose(资源释放)或后续消息发生竞态,另一个进程也可能激活当前进程报告为 `complete` 的 child;跨进程准确性需要共享租约。`interrupt_agent` 自己执行权威的在线 lineage 检查,因此过期的发现结果不会授予权限。
|
||||
- **列表是快照,而非投递承诺**:它可能与发布、dispose(资源释放)或后续消息发生竞态,另一个进程也可能激活当前进程报告为 `ready` 的 child;跨进程准确性需要共享租约。`interrupt_agent` 自己执行权威的在线 lineage 检查,因此过期的发现结果不会授予权限。
|
||||
- **没有分页或删除**:系统返回完整且稳定排序的集合;只要 child 会话仍在持久化存储中,它就会继续出现在列表中,服务级上限或删除操作留待后续产品决策。
|
||||
@@ -32,7 +32,7 @@ type ListAgentsEntry =
|
||||
readonly kind: 'child'
|
||||
readonly id: SessionId
|
||||
readonly label: string
|
||||
readonly status: 'running' | 'idle' | 'complete'
|
||||
readonly status: 'running' | 'idle' | 'ready'
|
||||
readonly parent?: SessionId
|
||||
readonly depth?: number
|
||||
}
|
||||
@@ -52,11 +52,13 @@ function resolveListAgentsRequest(request: ListAgentsRequest): ListAgentsSpec {
|
||||
/**
|
||||
* Refine one candidate's status through the live Agent registry: `running`
|
||||
* for an active driver, `idle` for a resident Agent between turns (possibly
|
||||
* waiting on agents it started), and `complete` when no live Agent remains.
|
||||
* waiting on agents it started), and `ready` when no live Agent remains.
|
||||
* `ready` preserves resumability without presenting an inactive conversation
|
||||
* as a terminal result to collect.
|
||||
*/
|
||||
function statusOf(agents: { get(id: SessionId): Agent | undefined }, id: SessionId): 'running' | 'idle' | 'complete' {
|
||||
function statusOf(agents: { get(id: SessionId): Agent | undefined }, id: SessionId): 'running' | 'idle' | 'ready' {
|
||||
const agent = agents.get(id)
|
||||
if (agent === undefined) return 'complete'
|
||||
if (agent === undefined) return 'ready'
|
||||
return agent.status === 'running' ? 'running' : 'idle'
|
||||
}
|
||||
|
||||
@@ -90,10 +92,12 @@ export function apply(ctx: Context): void {
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'list_agents',
|
||||
description:
|
||||
'List your continuable background subagents by durable id and label. Status comes from the live '
|
||||
'List your continuable background subagents by durable id and label. Use it to recall which ones '
|
||||
+ 'you started, not to poll for completion — you are told when one finishes. Status comes from the live '
|
||||
+ 'registry: running means the agent is working right now, idle means it is loaded but between turns '
|
||||
+ '(it may be waiting on agents it started), and complete means it exists only in storage — a '
|
||||
+ 'direct child remains a `send_message` candidate in every status. The snapshot is not a delivery '
|
||||
+ '(it may be waiting on agents it started), and ready means it exists only in storage — resumable, not '
|
||||
+ 'terminal, and not a result waiting to be collected; a `send_message` starts a new turn on the same '
|
||||
+ 'conversation, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery '
|
||||
+ 'promise — `send_message` performs the authoritative check and may still fail. Children that could '
|
||||
+ 'not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` '
|
||||
+ 'walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent '
|
||||
@@ -118,7 +122,7 @@ export function apply(ctx: Context): void {
|
||||
kind: { type: 'string', required: true, enum: ['child'] },
|
||||
id: { type: 'string', required: true },
|
||||
label: { type: 'string', required: true },
|
||||
status: { type: 'string', required: true, enum: ['running', 'idle', 'complete'] },
|
||||
status: { type: 'string', required: true, enum: ['running', 'idle', 'ready'] },
|
||||
parent: { type: 'string' },
|
||||
depth: { type: 'number' },
|
||||
},
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import * as tool from '../src/list-agents.ts'
|
||||
import { parkParent } from './park-parent.ts'
|
||||
|
||||
/** One scripted response that may wait on a caller-released gate before streaming. */
|
||||
interface GatedEntry {
|
||||
@@ -63,6 +64,7 @@ async function setupWith(adapter: MockAdapter | GatedAdapter) {
|
||||
await ctx.plugin(tool)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
|
||||
parkParent(ctx, parent)
|
||||
return { ctx, parent, adapter }
|
||||
}
|
||||
|
||||
@@ -177,8 +179,10 @@ describe('dsh-tool-subagent-control/list-agents', () => {
|
||||
vi.spyOn(ctx.agents, 'get').mockImplementation(id => agents.get(id) as never)
|
||||
const result = await callTool(ctx, 'list_agents', {}, parent)
|
||||
expect(result.isError).toBe(false)
|
||||
// `ready` is the resumable counterpart to a live `running` record, not a
|
||||
// claim that the child's conversation ended with a result to collect.
|
||||
expect(text(result)).toBe(
|
||||
`${started.childId} [complete] — real child\n`
|
||||
`${started.childId} [ready] — real child\n`
|
||||
+ 'running-child [running] — still working\n'
|
||||
+ 'waiting-child [idle] — waiting on descendants\n'
|
||||
+ 'broken-child [diagnostic: corrupt]',
|
||||
@@ -215,7 +219,21 @@ describe('dsh-tool-subagent-control/list-agents', () => {
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
const result = await callTool(ctx, 'list_agents', {}, parent)
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toBe(`${started.childId} [complete] — summarize the doc`)
|
||||
expect(text(result)).toBe(`${started.childId} [ready] — summarize the doc`)
|
||||
})
|
||||
|
||||
it('describes ready as resumable and pins the status vocabulary', async () => {
|
||||
const { ctx } = await setup([])
|
||||
const schema = ctx.tools.schemas().find(candidate => candidate.name === 'list_agents')
|
||||
// Completion reaches the parent through its notice; listing is discovery,
|
||||
// so its inactive status must not send the model looking for a result.
|
||||
expect(schema?.description).toContain('you are told when one finishes')
|
||||
expect(schema?.description).toContain('resumable, not terminal')
|
||||
// The enum is the closed vocabulary the model renders, so pin it rather than
|
||||
// scanning prose that legitimately reads "not to poll for completion".
|
||||
const variants = ctx.tools.get('list_agents')?.output.schema.items?.oneOf ?? []
|
||||
const child = variants.find(variant => variant.properties?.kind?.enum?.includes('child'))
|
||||
expect(child?.properties?.status?.enum).toEqual(['running', 'idle', 'ready'])
|
||||
})
|
||||
|
||||
it('fails loud when invoked without a calling agent', async () => {
|
||||
@@ -321,7 +339,7 @@ describe('dsh-tool-subagent-control/list-agents', () => {
|
||||
const result = await callTool(ctx, 'list_agents', { scope: 'descendants' }, parent)
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toBe(
|
||||
'deep-leaf [complete] parent=one-shot-mid depth=2 — deep leaf\n'
|
||||
'deep-leaf [ready] parent=one-shot-mid depth=2 — deep leaf\n'
|
||||
+ `broken-node [diagnostic: unavailable] parent=${parent.id} depth=1`,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Shared suite helper: keep this package's stand-in parent out of a scripted
|
||||
* model corpus.
|
||||
* @module park-parent
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/**
|
||||
* Reject every step of the stand-in parent. Each child settlement wakes its
|
||||
* parent, and these suites size their scripts for child turns only; the tests
|
||||
* assert on delivery rather than on the parent's own turn.
|
||||
* @param ctx - the booted test context.
|
||||
* @param parent - the stand-in parent whose turns must not reach the model.
|
||||
*/
|
||||
export function parkParent(ctx: Context, parent: { id: SessionId }): void {
|
||||
ctx.on('agent/pre-step', async ({ agent: subject }, next) => {
|
||||
if (subject.id !== parent.id) return next()
|
||||
return { kind: 'reject' as const }
|
||||
})
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import * as tool from '../src/index.ts'
|
||||
import { parkParent } from './park-parent.ts'
|
||||
|
||||
/** One scripted response that may wait on a caller-released gate before streaming. */
|
||||
interface GatedEntry {
|
||||
@@ -62,6 +63,7 @@ async function setupWith(adapter: MockAdapter | GatedAdapter) {
|
||||
await ctx.plugin(tool)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
|
||||
parkParent(ctx, parent)
|
||||
return { ctx, parent, adapter }
|
||||
}
|
||||
|
||||
|
||||
@@ -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-report/README.md
|
||||
README.md: 1772b72d83563e89ed0e3814e515b06a2b36fe0f
|
||||
README.zh.md: 94101fbc9611a04c56ed3a9171889499c9144c21
|
||||
README.md: b38b6b541d9c03174de1515df7a0babe5da055b4
|
||||
README.zh.md: 0c5c063eded1e9057ee8886f03dd6494dd9eeed8
|
||||
@@ -2,15 +2,15 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The optional child-scoped `report` tool is a thin adapter over `ctx.subagents.reportFrom()`. It gives every continuable in-process child a return channel to the Agent that started it. The package registers a continuable-child setup contribution instead of a global tool, so `report` exists only inside those children. Roots, one-shot subagents, remote subagent providers, sibling scopes, and agentless tool execution never present or execute it. Installing this package grants only that child-scoped capability; the parent-to-child direction remains the independent [`@deepseek-ai/dsh-tool-subagent-control`](../tool-subagent-control/README.md), and continuable mode depends on neither package.
|
||||
The optional child-scoped `report` tool is a thin adapter over `ctx.subagents.reportFrom()`. It gives every continuable in-process child a return channel to the Agent that started it, and installs the prompt section that instructs the child to use it. The package registers a continuable-child setup contribution instead of a global tool, so the tool and its guidance exist only inside those children. Roots, one-shot subagents, remote subagent providers, sibling scopes, and agentless tool execution never present or execute it. Installing this package grants only that child-scoped capability; the parent-to-child direction remains the independent [`@deepseek-ai/dsh-tool-subagent-control`](../tool-subagent-control/README.md), and continuable mode depends on neither package.
|
||||
|
||||
A child may call `report` zero or many times in one turn. A successful call neither concludes the turn, settles the Activation, nor prevents later parent follow-ups, and finishing a turn never reports automatically. The tool accepts no recipient: `exec.agent` is the sender's exact live Agent and the authority credential, and the service derives the sole recipient from that child's durable `parentSession`. Success returns the stable `MessageId` of the parent-accepted message, not a read receipt, an inbox-occurrence id, a parent-log acknowledgement, a turn-completion receipt, or a persistence flush. A parent absent from the registry fails the call with `direct parent is not live; report was not delivered` — registry presence governs parent resolution, and a registered parent already in host-owned disposal still accepts while its log admits appends. The service performs no injection, parent cold resume, or offline mailbox write; the durable child transcript remains the recovery source, and a failed tool call does not prove non-delivery (a later `tools/post-execute` veto can fail a call whose report was already accepted).
|
||||
The child-scoped `tool:report` prompt section instructs the child to call `report` once before finishing, with a self-contained answer, and earlier whenever a partial finding changes what the parent should do next. The instruction is guidance, not enforcement: the mechanism still accepts zero or many calls in one turn, and no runtime path rejects a child that never reports. A successful call neither concludes the turn, settles the Activation, nor prevents later parent follow-ups, and finishing a turn never reports automatically. The tool accepts no recipient: `exec.agent` is the sender's exact live Agent and the authority credential, and the service derives the sole recipient from that child's durable `parentSession`. Success returns the stable `MessageId` of the parent-accepted message, not a read receipt, an inbox-occurrence id, a parent-log acknowledgement, a turn-completion receipt, or a persistence flush. A parent absent from the registry fails the call with `direct parent is not live; report was not delivered` — registry presence governs parent resolution, and a registered parent already in host-owned disposal still accepts while its log admits appends. The service performs no injection, parent cold resume, or offline mailbox write; the durable child transcript remains the recovery source, and a failed tool call does not prove non-delivery (a later `tools/post-execute` veto can fail a call whose report was already accepted).
|
||||
|
||||
`reportDelivery` selects parent scheduling for every accepted report. `quiet` (the default) uses `parent.inject()`, adding model-facing context without starting a parent model request: an idle parent's append completes before the call returns, while a report reaching an admitting or running parent stages for the next safe log position. `wakeup` uses `parent.followup()`, creating exactly one ordinary later parent turn and waking a parked parent driver; it never steers an open turn. This is deployment scheduling policy, so the model-facing schema cannot select or override it per call.
|
||||
`reportDelivery` selects parent scheduling for every accepted report. `wakeup` (the default) uses `parent.followup()`, creating exactly one ordinary later parent turn and waking a parked parent driver; it never steers an open turn. It is the default because a parent that already parked has no other reason to look, so quiet delivery would leave an accepted report unread until something unrelated woke it. `quiet` uses `parent.inject()`, adding model-facing context without starting a parent model request: an idle parent's append completes before the call returns, while a report reaching an admitting or running parent stages for the next safe log position. This is deployment scheduling policy, so the model-facing schema cannot select or override it per call.
|
||||
|
||||
Scope-local registration deliberately survives the child's global `toolFilter`, so a delegation allow-list cannot remove the only return channel. A deployment that requires a child with no return channel omits this package.
|
||||
|
||||
The contribution body is exported as `installReportTool(childCtx, ctx, delivery)` so inspection consumers can install `report` into a minted child scope. The generated tool catalog uses that path because the global registry cannot expose a scope-local schema. Production composition still enters through `apply()`; the subagent seam's contribution registry remains private.
|
||||
The contribution body is exported as `installReportTool(childCtx, ctx, delivery)` so inspection consumers can install `report` and its guidance into a minted child scope, and returns the one disposer revoking both. The generated tool catalog uses that path because the global registry cannot expose a scope-local schema. Production composition still enters through `apply()`; the subagent seam's contribution registry remains private.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -18,15 +18,15 @@ The contribution body is exported as `installReportTool(childCtx, ctx, delivery)
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The generated [`report` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-report): one required `output` string. Its description states that reporting is explicit and repeatable, reaches only the Agent that started the child, and does not end the turn. It carries no recipient or delivery-mode parameter.
|
||||
The generated [`report` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-report): one required `output` string. Its description states that the child must report once before finishing, that reporting reaches only the Agent that started the child, and that it does not end the turn. It carries no recipient or delivery-mode parameter. The separate `tool:report` prompt section repeats the obligation outside the schema, where a child that ignores tool descriptions still reads it.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Fixed schema cost per continuable-child request, and none in any other Agent's requests.
|
||||
Fixed schema and prompt-section cost per continuable-child request, and none in any other Agent's requests.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Prefix-stable within a child; the schema does not change at runtime. Removing the package revokes the schema from resident children, which changes their next request prefix.
|
||||
Prefix-stable within a child; neither the schema nor the section changes at runtime. Removing the package revokes both from resident children, which changes their next request prefix.
|
||||
|
||||
### Report result
|
||||
|
||||
@@ -36,7 +36,7 @@ Prefix-stable within a child; the schema does not change at runtime. Removing th
|
||||
|
||||
#### Token effect
|
||||
|
||||
One short acknowledgement per call in the reporting child. The reported content is additionally billed to the parent: quiet delivery adds it to the parent's next request, while waking delivery makes it the sole ordinary message of one new parent turn.
|
||||
One short acknowledgement per call in the reporting child. The reported content is additionally billed to the parent: waking delivery makes it the sole ordinary message of one new parent turn, while quiet delivery adds it to the parent's next request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
@@ -61,6 +61,6 @@ Append-only; the report follows the parent's reusable request prefix. Waking del
|
||||
- **A parent whose host-owned disposal already started can still accept** — `AgentHandle.dispose()` cancels, awaits quiescence, and only then unwinds the scope and leaves the registry; it exposes no signal for "disposal started." A report accepted in that window is appended to the parent's transcript, but that parent will not act on it in this process. A continuation-manager-owned parent rejects forest teardown through the manager's admission boundary.
|
||||
- **Acceptance is weaker than durable delivery** — there is no durable mailbox, idempotency key, delivery receipt, retry protocol, or exactly-once claim. A process failure after one side recorded acceptance leaves the outcome ambiguous, and an external retry may duplicate the report.
|
||||
- **A staged quiet report is not immediately reconstructable** — acceptance returns its stable `MessageId`, but the parent Session reconstructs the framed content only after pending context reaches its ordinary log boundary.
|
||||
- **Granting waits for the next Activation; revocation is immediate** — installing this package after a child becomes resident grants `report` only on that child's next Activation, while removing the package revokes the schema from resident children immediately.
|
||||
- **Granting waits for the next Activation; revocation is immediate** — installing this package after a child becomes resident grants `report` and its guidance only on that child's next Activation, while removing the package revokes both from resident children immediately.
|
||||
- **Nested reporting reaches exactly one edge upward** — a grandchild reports to its direct child parent, never to the top-level coordinator, which must explicitly report a derived update later.
|
||||
- **No rate limiting** — `wakeup` mode can amplify model work when nested children report frequently; the deployment owns that choice by selecting the mode.
|
||||
- **No rate limiting** — the default `wakeup` mode can amplify model work when nested children report frequently; a deployment that accepts unread reports over that amplification selects `quiet`.
|
||||
@@ -2,15 +2,15 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
可选的子级作用域 `report` 工具是 `ctx.subagents.reportFrom()` 之上的轻量适配器。它为每个可继续的进程内子级提供一条返回通道,指向启动该子级的 Agent(智能体)。本包注册的是可继续子级设置贡献,而不是全局工具,因此 `report` 只存在于这些子级内部。根 Agent、一次性 subagent、远程 subagent 提供方、同级作用域以及不关联 Agent 的工具执行都不会提供或执行它。安装本包只授予这项子级作用域能力;父到子方向仍由独立的 [`@deepseek-ai/dsh-tool-subagent-control`](../tool-subagent-control/README.md) 负责,可继续模式不依赖这两个包中的任一个。
|
||||
可选的子级作用域 `report` 工具是 `ctx.subagents.reportFrom()` 之上的轻量适配器。它为每个可继续的进程内子级提供一条返回通道,指向启动该子级的 Agent(智能体),并安装指示子级使用该通道的提示词 section。本包注册的是可继续子级设置贡献,而不是全局工具,因此该工具及其指引只存在于这些子级内部。根 Agent、一次性 subagent、远程 subagent 提供方、同级作用域以及不关联 Agent 的工具执行都不会提供或执行它。安装本包只授予这项子级作用域能力;父到子方向仍由独立的 [`@deepseek-ai/dsh-tool-subagent-control`](../tool-subagent-control/README.md) 负责,可继续模式不依赖这两个包中的任一个。
|
||||
|
||||
子级可以在一个轮次中调用 `report` 零次或多次。调用成功既不会结束轮次或结算 Activation,也不会阻止父级后续消息;轮次结束也绝不会自动上报。该工具不接受接收方参数:`exec.agent` 是发送方确切在线的 Agent,也是权限凭据;服务根据该子级持久化的 `parentSession` 推导唯一接收方。成功时返回父级已接受消息的稳定 `MessageId`,不表示已读回执、inbox 中该次出现的 id、父级日志确认、轮次完成回执或持久化刷盘。父级解析由注册表中的存在性决定:父级不在注册表时,调用失败并返回 `direct parent is not live; report was not delivered`;已开始由宿主管理的 dispose(资源释放)但仍在注册表中的父级在其日志仍接受追加时仍会接受。服务不会执行注入、父级冷恢复或离线 mailbox 写入;持久化子级 transcript(文本记录)仍是恢复依据,且工具调用失败不能证明未送达(后续 `tools/post-execute` 否决可能让报告已被接受的调用以失败结束)。
|
||||
子级作用域的 `tool:report` 提示词 section 要求子级在结束前调用一次 `report` 并给出自足的答案,并在部分发现会改变父级下一步动作时提前上报。该指令是引导而非强制:机制本身仍接受一个轮次中调用零次或多次,也没有任何运行时路径会拒绝从不上报的子级。调用成功既不会结束轮次或结算 Activation,也不会阻止父级后续消息;轮次结束也绝不会自动上报。该工具不接受接收方参数:`exec.agent` 是发送方确切在线的 Agent,也是权限凭据;服务根据该子级持久化的 `parentSession` 推导唯一接收方。成功时返回父级已接受消息的稳定 `MessageId`,不表示已读回执、inbox 中该次出现的 id、父级日志确认、轮次完成回执或持久化刷盘。父级解析由注册表中的存在性决定:父级不在注册表时,调用失败并返回 `direct parent is not live; report was not delivered`;已开始由宿主管理的 dispose(资源释放)但仍在注册表中的父级在其日志仍接受追加时仍会接受。服务不会执行注入、父级冷恢复或离线 mailbox 写入;持久化子级 transcript(文本记录)仍是恢复依据,且工具调用失败不能证明未送达(后续 `tools/post-execute` 否决可能让报告已被接受的调用以失败结束)。
|
||||
|
||||
`reportDelivery` 为每条已接受的报告选择父级调度方式。`quiet`(默认值)使用 `parent.inject()`,在不启动父级模型请求的情况下添加面向模型的上下文:父级空闲时,追加操作会在调用返回前完成;报告到达正在准入或运行的父级时,则会暂存到下一个安全日志位置。`wakeup` 使用 `parent.followup()`,恰好创建一个普通的后续父级轮次,并唤醒停驻的父级驱动;它绝不会对正在运行的轮次进行 steering(中途引导)。这是部署调度策略,因此面向模型的 schema 不能在单次调用中选择或覆盖该策略。
|
||||
`reportDelivery` 为每条已接受的报告选择父级调度方式。`wakeup`(默认值)使用 `parent.followup()`,恰好创建一个普通的后续父级轮次,并唤醒停驻的父级驱动;它绝不会对正在运行的轮次进行 steering(中途引导)。之所以作为默认值:已经停驻的父级没有别的理由再去查看,静默投递会让一条已被接受的报告一直无人阅读,直到别的事件把父级唤醒。`quiet` 使用 `parent.inject()`,在不启动父级模型请求的情况下添加面向模型的上下文:父级空闲时,追加操作会在调用返回前完成;报告到达正在准入或运行的父级时,则会暂存到下一个安全日志位置。这是部署调度策略,因此面向模型的 schema 不能在单次调用中选择或覆盖该策略。
|
||||
|
||||
作用域局部注册有意不受子级全局 `toolFilter` 影响,因此委派允许列表无法移除唯一的返回通道。需要子级不具备返回通道的部署应省略本包。
|
||||
|
||||
贡献体以 `installReportTool(childCtx, ctx, delivery)` 导出,以便检查类消费方把 `report` 安装到新创建的子级作用域中。全局注册表无法公开作用域局部 schema,因此生成的工具目录会使用这条路径。生产组合仍通过 `apply()` 进入;subagent seam 的贡献注册表保持私有。
|
||||
贡献体以 `installReportTool(childCtx, ctx, delivery)` 导出,以便检查类消费方把 `report` 及其指引安装到新创建的子级作用域中,并返回同时撤销两者的唯一 disposer。全局注册表无法公开作用域局部 schema,因此生成的工具目录会使用这条路径。生产组合仍通过 `apply()` 进入;subagent seam 的贡献注册表保持私有。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -18,15 +18,15 @@
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
已生成的 [`report` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-report):包含一个必填 `output` 字符串。其描述说明上报需要显式调用且可以重复,只会到达启动该子级的 Agent,并且不会结束轮次。它不包含接收方或投递模式参数。
|
||||
已生成的 [`report` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-report):包含一个必填 `output` 字符串。其描述说明子级必须在结束前上报一次,上报只会到达启动该子级的 Agent,并且不会结束轮次。它不包含接收方或投递模式参数。独立的 `tool:report` 提示词 section 在 schema 之外重申该义务,使忽略工具描述的子级仍能读到。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
每个可继续子级请求支付固定的 schema 成本,其他任何 Agent 的请求均无此成本。
|
||||
每个可继续子级请求支付固定的 schema 与提示词 section 成本,其他任何 Agent 的请求均无此成本。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
子级中的前缀保持稳定;schema 不会在运行时改变。移除本包会从驻留子级中撤销该 schema,从而改变其下一次请求前缀。
|
||||
子级中的前缀保持稳定;schema 与该 section 都不会在运行时改变。移除本包会从驻留子级中撤销两者,从而改变其下一次请求前缀。
|
||||
|
||||
### 上报结果
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
#### Token 影响
|
||||
|
||||
每次调用都会在执行上报的子级中产生一条简短确认消息。父级还会为上报内容支付 token 成本:静默投递会把内容加入父级的下一次请求,唤醒投递则会使该内容成为一个新父级轮次中唯一的普通消息。
|
||||
每次调用都会在执行上报的子级中产生一条简短确认消息。父级还会为上报内容支付 token 成本:唤醒投递会使该内容成为一个新父级轮次中唯一的普通消息,静默投递则把内容加入父级的下一次请求。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
@@ -61,6 +61,6 @@
|
||||
- **父级可能在宿主启动 dispose 后继续接受报告**:`AgentHandle.dispose()` 会先取消并等待完全停稳,然后才撤销作用域并离开注册表;它不公开「dispose 已开始」信号。在该窗口内接受的报告会追加到父级 transcript,但该父级不会在本进程中处理它。对于由继续执行管理器拥有的父级,管理器的准入边界会在整片森林拆卸期间拒绝该上报。
|
||||
- **接受弱于持久投递**:没有持久化 mailbox、幂等键、投递回执、重试协议,也不保证恰好一次。任一侧记录接受后若进程失败,结果都不明确;外部重试可能产生重复上报。
|
||||
- **暂存的静默报告无法立即重建**:接受时会返回其稳定 `MessageId`,但只有当待处理上下文到达普通日志边界后,父级 Session 才能重建带前缀的内容。
|
||||
- **授权须等到下一个 Activation,撤销则立即生效**:子级驻留后再安装本包,只会在该子级的下一个 Activation 中授予 `report`;移除本包则会立即从驻留子级撤销该 schema。
|
||||
- **授权须等到下一个 Activation,撤销则立即生效**:子级驻留后再安装本包,只会在该子级的下一个 Activation 中授予 `report` 及其指引;移除本包则会立即从驻留子级撤销两者。
|
||||
- **嵌套上报只向上到达一条直接边**:孙级只向作为其直接父级的子级上报,不会直接到达顶层协调器;该直接父级必须随后显式发出一条衍生更新。
|
||||
- **没有速率限制**:嵌套子级频繁上报时,`wakeup` 模式会放大模型工作量;部署通过选择模式自行承担这一取舍。
|
||||
- **没有速率限制**:嵌套子级频繁上报时,默认的 `wakeup` 模式会放大模型工作量;宁可接受报告无人阅读也要避免这种放大的部署应选择 `quiet`。
|
||||
@@ -35,6 +35,7 @@
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
@@ -52,6 +53,7 @@
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-spawn": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-subagent-control": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* The child-scoped `report` tool, installed into every continuable in-process
|
||||
* child's unpublished context. Roots, one-shot children, remote providers, and
|
||||
* agentless executions never see the registration.
|
||||
* The child-scoped `report` tool and its usage guidance, installed into every
|
||||
* continuable in-process child's unpublished context. Roots, one-shot children,
|
||||
* remote providers, and agentless executions never see the registration.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-subagent-report
|
||||
*/
|
||||
@@ -11,86 +11,131 @@ import z from '@deepseek-ai/schemastery'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { SubagentReportDelivery } from '@deepseek-ai/dsh-subagent'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
export const name = 'tool-subagent-report'
|
||||
// The contribution registers only through childCtx.tools, but declaring tools
|
||||
// makes Loader ordering fail at load instead of the next child materialization.
|
||||
export const inject = ['subagents', 'tools']
|
||||
// The contribution registers only through childCtx.tools and
|
||||
// childCtx.systemPrompt, but declaring both services makes Loader ordering fail
|
||||
// at load instead of at the next child materialization.
|
||||
export const inject = ['subagents', 'tools', 'systemPrompt']
|
||||
|
||||
/** Guidance order after every per-tool section a continuable child can carry. */
|
||||
const REPORT_SECTION_ORDER = 117
|
||||
|
||||
/** Config: how accepted reports are scheduled on the parent. */
|
||||
export interface Config {
|
||||
/**
|
||||
* Parent scheduling (default `quiet`). `quiet` adds context without waking;
|
||||
* `wakeup` creates one ordinary later parent turn.
|
||||
* Parent scheduling (default `wakeup`). `wakeup` creates one ordinary later
|
||||
* parent turn; `quiet` adds context without waking, so a parked parent learns
|
||||
* of the report only when something else wakes it.
|
||||
*/
|
||||
reportDelivery?: SubagentReportDelivery
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
reportDelivery: z.union(['quiet', 'wakeup'] as const).default('quiet'),
|
||||
reportDelivery: z.union(['quiet', 'wakeup'] as const).default('wakeup'),
|
||||
})
|
||||
|
||||
/**
|
||||
* Install `report` into one continuable child's scope.
|
||||
* @param childCtx - child-scoped context receiving the tool.
|
||||
* Install `report` and its usage guidance into one continuable child's scope.
|
||||
* Both registrations are owned by that scope and are therefore invisible to the
|
||||
* child's parent and siblings.
|
||||
* @param childCtx - child-scoped context receiving the tool and the guidance.
|
||||
* @param ctx - service context used for delivery.
|
||||
* @param delivery - resolved deployment scheduling policy.
|
||||
* @returns disposer for this one registration.
|
||||
* @returns disposer that attempts both child registrations before reporting cleanup failures.
|
||||
*/
|
||||
export function installReportTool(
|
||||
childCtx: Context,
|
||||
ctx: Context,
|
||||
delivery: SubagentReportDelivery,
|
||||
): () => void {
|
||||
return childCtx.tools.register(defineTool({
|
||||
name: 'report',
|
||||
description:
|
||||
'Report selected content to the agent that started you. Call this zero or more times for progress, '
|
||||
+ 'findings, or a final answer. Reporting does not end your turn or finish your work, and only your '
|
||||
+ 'direct parent receives it. A failed call may still have arrived, so do not blindly repeat it.',
|
||||
parameters: {
|
||||
output: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Self-contained content for your parent; it does not see your private work.',
|
||||
},
|
||||
},
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
messageId: { type: 'string', required: true },
|
||||
const disposeSection = childCtx.systemPrompt.section({
|
||||
name: 'tool:report',
|
||||
order: REPORT_SECTION_ORDER,
|
||||
text: 'Deliver your result with the report tool before you finish: call it once with a self-contained '
|
||||
+ 'answer. The agent that started you shares your workspace but does not automatically receive your '
|
||||
+ 'transcript, tool output, or reasoning, so a closing remark such as "done" leaves it nothing it can '
|
||||
+ 'use. Report earlier as well whenever a partial finding changes what that agent should do next; '
|
||||
+ 'reporting never ends your turn.',
|
||||
})
|
||||
let disposeTool: () => void
|
||||
try {
|
||||
disposeTool = childCtx.tools.register(defineTool({
|
||||
name: 'report',
|
||||
description:
|
||||
'Report selected content to the agent that started you. Call this once before you finish, with a '
|
||||
+ 'self-contained final result, and earlier for progress or findings that change what that agent does '
|
||||
+ 'next. That agent shares your workspace but does not automatically receive your transcript, tool '
|
||||
+ 'output, or reasoning, so finishing your work is not itself a result. Reporting does not end your '
|
||||
+ 'turn or finish your work, and only your direct parent receives it. A failed call may still have '
|
||||
+ 'arrived, so do not blindly repeat it.',
|
||||
parameters: {
|
||||
output: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Actionable content for your parent; summarize conclusions and reference relevant shared paths.',
|
||||
},
|
||||
},
|
||||
render: (_args, value) => [{
|
||||
type: 'text',
|
||||
text: `report accepted by the agent that started you as message ${value.messageId}`,
|
||||
}],
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const content: ContentBlock[] = [{ type: 'text', text: args.output }]
|
||||
// Scope-local resolution guarantees an Agent. The service still verifies
|
||||
// its exact live Activation identity at the authority boundary.
|
||||
const messageId = await ctx.subagents.reportFrom(exec.agent as Agent, content, {
|
||||
delivery,
|
||||
signal: exec.signal,
|
||||
})
|
||||
return { messageId }
|
||||
},
|
||||
}))
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
messageId: { type: 'string', required: true },
|
||||
},
|
||||
},
|
||||
render: (_args, value) => [{
|
||||
type: 'text',
|
||||
text: `report accepted by the agent that started you as message ${value.messageId}`,
|
||||
}],
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const content: ContentBlock[] = [{ type: 'text', text: args.output }]
|
||||
// Scope-local resolution guarantees an Agent. The service still verifies
|
||||
// its exact live Activation identity at the authority boundary.
|
||||
const messageId = await ctx.subagents.reportFrom(exec.agent as Agent, content, {
|
||||
delivery,
|
||||
signal: exec.signal,
|
||||
})
|
||||
return { messageId }
|
||||
},
|
||||
}))
|
||||
} catch (error: unknown) {
|
||||
try {
|
||||
disposeSection()
|
||||
} catch (rollbackError: unknown) {
|
||||
throw new AggregateError(
|
||||
[error, rollbackError],
|
||||
'failed to register the report tool and roll back its prompt guidance',
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
return () => {
|
||||
const failures: unknown[] = []
|
||||
for (const dispose of [disposeTool, disposeSection]) {
|
||||
try {
|
||||
dispose()
|
||||
} catch (error: unknown) {
|
||||
failures.push(error)
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(failures, 'failed to revoke report tool and prompt registrations')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the continuable-child contribution.
|
||||
* @param ctx - context carrying tools and the subagent service.
|
||||
* @param ctx - context carrying tools, the system prompt, and the subagent service.
|
||||
* @param config - deployment scheduling policy.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config = {}): void {
|
||||
// Config() applies the schema default ('quiet') at runtime; the schemastery
|
||||
// return type keeps the input's optional shape, so assert the resolved
|
||||
// shape here — no runtime fallback exists or is wanted.
|
||||
// Config() applies the schema default at runtime; the schemastery return
|
||||
// type keeps the input's optional shape, so assert the resolved one.
|
||||
const { reportDelivery } = Config(config) as { reportDelivery: SubagentReportDelivery }
|
||||
ctx.subagents.registerContinuableSetup(childCtx =>
|
||||
installReportTool(childCtx, ctx, reportDelivery))
|
||||
|
||||
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { assembleContextFor } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
@@ -96,6 +97,17 @@ function callReport(ctx: Context, child: Agent, output: string, signal = testSig
|
||||
})
|
||||
}
|
||||
|
||||
/** Occupy the child-local report name to force installation rollback. */
|
||||
function registerReportConflict(child: Agent): () => void {
|
||||
return child.ctx.tools.register({
|
||||
name: 'report',
|
||||
description: 'conflicting report fixture',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
output: { schema: { type: 'object', properties: {} }, render: () => [] },
|
||||
execute: () => Promise.resolve({}),
|
||||
})
|
||||
}
|
||||
|
||||
/** Reports already visible or still pending in one Agent. */
|
||||
function reports(agent: Agent): { id: string; text: string; sender: string }[] {
|
||||
const visible = agent.session.events.flatMap(event => event.type === 'user/message' ? [event.data] : [])
|
||||
@@ -113,6 +125,12 @@ function renderedText(result: { content: { type: string; text?: string }[] }): s
|
||||
return result.content.flatMap(block => block.type === 'text' ? [block.text ?? ''] : []).join('')
|
||||
}
|
||||
|
||||
/** The prompt sections one agent's scope assembles, by name. */
|
||||
async function sectionNames(ctx: Context, agent: Agent): Promise<string[]> {
|
||||
const assembly = await ctx.systemPrompt.assemble(assembleContextFor(agent))
|
||||
return assembly.sections.map(section => section.name)
|
||||
}
|
||||
|
||||
describe('dsh-tool-subagent-report', () => {
|
||||
it('registers report only in continuable child scopes', async () => {
|
||||
const { ctx, parent } = await setup()
|
||||
@@ -309,16 +327,101 @@ describe('dsh-tool-subagent-report', () => {
|
||||
const { ctx, parent, fiber } = await setup()
|
||||
const { child } = await startChild(ctx, parent)
|
||||
expect(ctx.tools.schemas(child).map(schema => schema.name)).toContain('report')
|
||||
expect(await sectionNames(ctx, child)).toContain('tool:report')
|
||||
|
||||
await fiber?.dispose()
|
||||
expect(ctx.tools.schemas(child).map(schema => schema.name)).not.toContain('report')
|
||||
expect(await sectionNames(ctx, child)).not.toContain('tool:report')
|
||||
expect((await callReport(ctx, child, 'revoked')).isError).toBe(true)
|
||||
|
||||
const late = await ctx.plugin(tool, { reportDelivery: 'quiet' })
|
||||
expect(ctx.tools.schemas(child).map(schema => schema.name)).not.toContain('report')
|
||||
expect(await sectionNames(ctx, child)).not.toContain('tool:report')
|
||||
await late.dispose()
|
||||
})
|
||||
|
||||
it('rolls back prompt guidance when tool registration fails', async () => {
|
||||
const { ctx, parent } = await setup({ load: false })
|
||||
const { child } = await startChild(ctx, parent)
|
||||
const disposeConflict = registerReportConflict(child)
|
||||
|
||||
expect(() => tool.installReportTool(child.ctx, ctx, 'quiet')).toThrow(/already registered in this scope/)
|
||||
expect(await sectionNames(ctx, child)).not.toContain('tool:report')
|
||||
disposeConflict()
|
||||
})
|
||||
|
||||
it('aggregates a registration failure with a prompt rollback failure', async () => {
|
||||
const { ctx, parent } = await setup({ load: false })
|
||||
const { child } = await startChild(ctx, parent)
|
||||
const disposeConflict = registerReportConflict(child)
|
||||
const rollbackFailure = new Error('prompt rollback listener failed')
|
||||
let promptChanges = 0
|
||||
const off = ctx.on('system-prompt/change', () => {
|
||||
promptChanges++
|
||||
if (promptChanges === 2) throw rollbackFailure
|
||||
})
|
||||
|
||||
let failure: unknown
|
||||
try {
|
||||
tool.installReportTool(child.ctx, ctx, 'quiet')
|
||||
} catch (error: unknown) {
|
||||
failure = error
|
||||
}
|
||||
off()
|
||||
|
||||
expect(failure).toBeInstanceOf(AggregateError)
|
||||
if (!(failure instanceof AggregateError)) throw new Error('expected aggregate installation failure')
|
||||
expect(failure.errors).toHaveLength(2)
|
||||
expect(String(failure.errors[0])).toContain('already registered in this scope')
|
||||
expect(failure.errors[1]).toBe(rollbackFailure)
|
||||
expect(await sectionNames(ctx, child)).not.toContain('tool:report')
|
||||
disposeConflict()
|
||||
})
|
||||
|
||||
it('attempts both revocations and aggregates change-listener failures', async () => {
|
||||
const { ctx, parent } = await setup({ load: false })
|
||||
const { child } = await startChild(ctx, parent)
|
||||
const dispose = tool.installReportTool(child.ctx, ctx, 'quiet')
|
||||
const toolFailure = new Error('tool removal listener failed')
|
||||
const promptFailure = new Error('prompt removal listener failed')
|
||||
const offTool = ctx.on('tools/change', () => { throw toolFailure })
|
||||
const offPrompt = ctx.on('system-prompt/change', () => { throw promptFailure })
|
||||
|
||||
let failure: unknown
|
||||
try {
|
||||
dispose()
|
||||
} catch (error: unknown) {
|
||||
failure = error
|
||||
}
|
||||
offPrompt()
|
||||
offTool()
|
||||
|
||||
expect(failure).toBeInstanceOf(AggregateError)
|
||||
if (!(failure instanceof AggregateError)) throw new Error('expected aggregate revocation failure')
|
||||
expect(failure.errors).toEqual([toolFailure, promptFailure])
|
||||
expect(ctx.tools.schemas(child).map(schema => schema.name)).not.toContain('report')
|
||||
expect(await sectionNames(ctx, child)).not.toContain('tool:report')
|
||||
})
|
||||
|
||||
it('scopes the report guidance to the child that owns it', async () => {
|
||||
const { ctx, parent } = await setup()
|
||||
const { child } = await startChild(ctx, parent, 'first child')
|
||||
const { child: sibling } = await startChild(ctx, parent, 'second child')
|
||||
|
||||
const assembly = await ctx.systemPrompt.assemble(assembleContextFor(child))
|
||||
const guidance = assembly.sections.find(section => section.name === 'tool:report')
|
||||
// Pins the model-visible instruction that makes the return channel a
|
||||
// contract rather than an option the child may quietly skip.
|
||||
expect(guidance?.text).toContain('Deliver your result with the report tool before you finish')
|
||||
expect(guidance?.text).toContain('reporting never ends your turn')
|
||||
|
||||
expect(await sectionNames(ctx, parent)).not.toContain('tool:report')
|
||||
// A sibling installs its own copy; neither child can observe the other's.
|
||||
expect(await sectionNames(ctx, sibling)).toContain('tool:report')
|
||||
expect((await ctx.systemPrompt.assemble()).sections.map(section => section.name))
|
||||
.not.toContain('tool:report')
|
||||
})
|
||||
|
||||
it('rolls back materialization when a setup contribution revokes itself', async () => {
|
||||
const { ctx, parent } = await setup({ load: false })
|
||||
const self: { revoke?: () => void } = {}
|
||||
@@ -405,10 +508,29 @@ describe('dsh-tool-subagent-report', () => {
|
||||
it('keeps the namespace plugin shape and validates its default', () => {
|
||||
expect('default' in tool).toBe(false)
|
||||
expect(tool.name).toBe('tool-subagent-report')
|
||||
expect(tool.inject).toEqual(['subagents', 'tools'])
|
||||
expect(tool.Config({}).reportDelivery).toBe('quiet')
|
||||
expect(tool.inject).toEqual(['subagents', 'tools', 'systemPrompt'])
|
||||
// Waking is the default because a report that never wakes its parent
|
||||
// cannot deliver a result to an agent that already parked.
|
||||
expect(tool.Config({}).reportDelivery).toBe('wakeup')
|
||||
expect(() => tool.Config({ reportDelivery: 'shout' } as never)).toThrow()
|
||||
})
|
||||
|
||||
it('wakes the parent under the default configuration', async () => {
|
||||
const { ctx, parent, adapter } = await setup({ config: {} })
|
||||
const { child } = await startChild(ctx, parent)
|
||||
const enqueues: string[] = []
|
||||
ctx.on('agent/inbox/inserted', ({ agent, message }) => {
|
||||
if (agent === parent) {
|
||||
enqueues.push(agent.inbox.nextTurn.some(queued => queued.id === message.id) ? 'queued' : 'steering')
|
||||
}
|
||||
})
|
||||
|
||||
expect((await callReport(ctx, child, 'DEFAULT_WAKES')).isError).toBe(false)
|
||||
expect(enqueues).toEqual(['queued'])
|
||||
await vi.waitFor(() => {
|
||||
expect(adapter.requests.some(request => request.sessionId === parent.id)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
/** Prove report delivery uses ordinary logged user messages (runtime-context snapshots excluded). */
|
||||
@@ -427,6 +549,9 @@ describe('dsh-tool-subagent-report result independence', () => {
|
||||
expect(ctx.agents.get(started.childId) === undefined).toBe(true)
|
||||
}, { timeout: 5_000 })
|
||||
|
||||
// The parent does learn the child settled — that account is the
|
||||
// continuation service's, carried under its own `subagent-settled` source.
|
||||
// Nothing turns the child's final answer into a report it did not send.
|
||||
expect(reports(parent)).toEqual([])
|
||||
expect(userTexts((await ctx.sessionPersistence.load(started.childId)).events)).toEqual(['child task'])
|
||||
expect(ctx.get('tasks')).toBeUndefined()
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
|
||||
@@ -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: 578ea4786e1d996251360c4aed92f2e882553681
|
||||
README.zh.md: baf91664530a637df96f1e7a51e6f0adabc0a8ae
|
||||
README.md: d4c472d54f87d6006aef96079050cf14885ed8fb
|
||||
README.zh.md: 36f79ac879067dc74fcc6b96122088bb68d53321
|
||||
@@ -10,7 +10,7 @@ Each plugin instance binds one `provider` to one `toolName`; the model receives
|
||||
|
||||
A foreground call passes the execution signal through startup and execution, awaits `run.result`, and always awaits `run.dispose()` before returning. Only `completed` returns the canonical `{ kind: 'foreground', runId, output: JsonValue[] }`, rendered as the same final text; abort, refusal, token limit, and other failures become errored tool results whose message appends the child's preserved partial text (the `SubagentResult.output` selection) after the stop-reason headline, so a truncated answer is never reported as success yet never silently lost. If result collection and disposal both reject, the errored result preserves both diagnostics.
|
||||
|
||||
With `run_in_background: true`, `backgroundMode` selects the route. `one-shot` registers a plain parent-owned Task and returns canonical `{ kind: 'background', taskId }`, rendered as `started background subagent task <id>`, even when the provider supports continuable children; generic task tools own its later status, collection, cancellation, and notices. `continuable` requires a provider with the `prepareContinuable` capability, calls `ctx.subagents.startContinuable()`, and returns `{ kind: 'continuable', subagentId }`, rendered as `started subagent <childId>`. The continuable route resolves at inbox acceptance: the child owns its own turns from there, so this call neither waits for nor collects a result, and the child does not report back — its transcript by that id is the source of its output, and the optional global `send_message` tool sends it more work. Starting continuable work does not require `send_message` to be loaded. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), and the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md).
|
||||
With `run_in_background: true`, `backgroundMode` selects the route. `one-shot` registers a plain parent-owned Task and returns canonical `{ kind: 'background', taskId }`, rendered as `started background subagent task <id>`, even when the provider supports continuable children; generic task tools own its later status, collection, cancellation, and notices. `continuable` requires a provider with the `prepareContinuable` capability, calls `ctx.subagents.startContinuable()`, and returns `{ kind: 'continuable', subagentId }`, rendered as `started subagent <childId>`. The continuable route resolves at inbox acceptance: the child owns its own turns from there, so this call neither waits for nor collects a result. The child's transcript by that id remains the source of its detailed output, and the optional global `send_message` tool sends it more work. The parent is not left guessing when to look, though: the continuation service delivers one settlement notice to it whenever a continuable child's Activation ends, which is why the schema tells the model it will be told and must not poll. Starting continuable work does not require `send_message` to be loaded. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), and the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md).
|
||||
|
||||
`toolFilter` changes the child's global tool layer but is not a parent-derived authority ceiling. See the [agent-scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals).
|
||||
|
||||
@@ -37,7 +37,7 @@ Foreground and background calls are concurrency-safe: sibling delegations in one
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The generated default [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent) under this instance's configured name while its provider exists. Provider context inheritance changes the tool and prompt descriptions; enabled background mode adds `run_in_background`, and continuable mode describes starting a background subagent that keeps its conversation and returns its subagent id, while one-shot mode describes a background task id collected with `task_output` and stopped with `task_kill`.
|
||||
The generated default [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent) under this instance's configured name while its provider exists. Provider context inheritance changes the tool and prompt descriptions; enabled background mode adds `run_in_background`, and continuable mode describes starting a background subagent that keeps its conversation, returns its subagent id, and reports its own completion — so the model is told never to poll or wait on it — while one-shot mode describes a background task id collected with `task_output` and stopped with `task_kill`.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -65,11 +65,11 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Start returns exactly `started subagent <childId>` in configured continuable mode, or `started background subagent task <id>` in configured one-shot mode. In one-shot mode the generic task surface provides later status, final output, cancellation responses, and notices. In continuable mode the child does not report back; an independently loaded `send_message` tool delivers follow-ups, and the child's transcript by its id is the source of its output.
|
||||
Start returns exactly `started subagent <childId>` in configured continuable mode, or `started background subagent task <id>` in configured one-shot mode. In one-shot mode the generic task surface provides later status, final output, cancellation responses, and notices. In continuable mode this tool returns no result of its own; the child's settlement reaches the parent as a [service-owned notice](../subagent/README.md#settlement-notice), an independently loaded `send_message` tool delivers follow-ups, and the child's transcript by its id is the source of its detailed output.
|
||||
|
||||
#### Token effect
|
||||
|
||||
The acknowledgement is retained; a one-shot final output enters parent history only when collected or injected, while a continuable child's output never returns through this tool.
|
||||
The acknowledgement is retained; a one-shot final output enters parent history only when collected or injected, while a continuable child's output never returns through this tool — its settlement notice arrives independently of any tool result.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
@@ -77,6 +77,6 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Background runs expose no result through this tool** — a one-shot task's final output is collected through the generic task surface, and a continuable child's output stays in its own session, read by its subagent id.
|
||||
- **Background runs expose no result through this tool** — a one-shot task's final output is collected through the generic task surface, and a continuable child's output stays in its own session, read by its subagent id. The settlement notice states how that child ended and carries its closing message, but it is not this call's return value and cannot be awaited here.
|
||||
- **Duplicate names across waiting instances are detected late** (`TODO(subagent-dup-toolname)`) — preventing provider-registration rollback requires a registry of intended names.
|
||||
- **Child policy is fixed per instance** — another model, persona, tool filter, or depth cap requires another distinctly named tool.
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
前台调用会让执行信号贯穿启动和执行,等待 `run.result`,并且在返回前总会等待 `run.dispose()`。只有 `completed` 会返回规范值 `{ kind: 'foreground', runId, output: JsonValue[] }`,并渲染为相同的最终文本;中止、拒绝、token 上限和其他失败都会变成出错的工具结果,其消息在终止原因标题之后附带子代理保留下来的部分文本(即 `SubagentResult.output` 的选取结果)——被截断的回答不会被报告为成功,也绝不会被悄悄丢弃。如果结果收集与 dispose(资源释放)都 reject,出错的结果会保留两项诊断信息。
|
||||
|
||||
设置 `run_in_background: true` 后,`backgroundMode` 会选择路由。`one-shot` 会注册一个归父级所有的普通 Task,并返回规范值 `{ kind: 'background', taskId }`,渲染为 `started background subagent task <id>`,即使提供方支持可继续子 agent 也不例外;通用 Task 工具负责其后续状态、收集、取消和通知。`continuable` 要求提供方具备 `prepareContinuable` 能力,调用 `ctx.subagents.startContinuable()`,并返回 `{ kind: 'continuable', subagentId }`,渲染为 `started subagent <childId>`。可继续路由在 inbox 接受时结算:子 agent 自此拥有自己的轮次,因此该调用既不等待也不收集结果,而且子 agent 不会回报——通过该 id 查看其 transcript(文本记录)即是其输出来源,可选的全局 `send_message` 工具则向其发送更多工作。启动可继续工作不要求加载 `send_message`。见 [后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续的 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)和[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。
|
||||
设置 `run_in_background: true` 后,`backgroundMode` 会选择路由。`one-shot` 会注册一个归父级所有的普通 Task,并返回规范值 `{ kind: 'background', taskId }`,渲染为 `started background subagent task <id>`,即使提供方支持可继续子 agent 也不例外;通用 Task 工具负责其后续状态、收集、取消和通知。`continuable` 要求提供方具备 `prepareContinuable` 能力,调用 `ctx.subagents.startContinuable()`,并返回 `{ kind: 'continuable', subagentId }`,渲染为 `started subagent <childId>`。可继续路由在 inbox 接受时结算:子 agent 自此拥有自己的轮次,因此该调用既不等待也不收集结果。通过该 id 查看其 transcript(文本记录)仍是其详细输出的来源,可选的全局 `send_message` 工具则向其发送更多工作。不过父级无需猜测何时查看:每当可继续子 agent 的 Activation 结束,继续执行服务都会向父级投递一条结算通知——正因如此,schema 才告诉模型它会被通知,且不得轮询。启动可继续工作不要求加载 `send_message`。见 [后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续的 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)和[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。
|
||||
|
||||
`toolFilter` 会改变子 agent 的全局工具层,但不是从父级派生的权限上限。见 [agent 作用域的安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
当提供方存在时,以当前实例配置的名称公开已生成的默认 [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent)。提供方是否继承上下文会改变工具描述和提示词描述;启用后台模式会添加 `run_in_background`,可继续模式描述为启动一个保留其对话并返回子 agent id 的后台子 agent,而一次性模式描述为返回一个用 `task_output` 收集、用 `task_kill` 停止的后台任务 id。
|
||||
当提供方存在时,以当前实例配置的名称公开已生成的默认 [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent)。提供方是否继承上下文会改变工具描述和提示词描述;启用后台模式会添加 `run_in_background`,可继续模式描述为启动一个保留其对话、返回子 agent id 并会自行报告完成的后台子 agent——因此模型被告知绝不要轮询或等待它——而一次性模式描述为返回一个用 `task_output` 收集、用 `task_kill` 停止的后台任务 id。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
@@ -65,11 +65,11 @@
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
在配置的可继续模式下,启动时返回内容恰为 `started subagent <childId>`;在配置的一次性模式下,则返回 `started background subagent task <id>`。一次性模式下,通用 Task 接口提供后续状态、最终输出、取消响应和通知。可继续模式下,子 agent 不会回报;独立加载的 `send_message` 工具会投递后续消息,而通过其 id 查看子 agent 的 transcript 即是其输出来源。
|
||||
在配置的可继续模式下,启动时返回内容恰为 `started subagent <childId>`;在配置的一次性模式下,则返回 `started background subagent task <id>`。一次性模式下,通用 Task 接口提供后续状态、最终输出、取消响应和通知。可继续模式下,本工具不返回自己的结果;子 agent 的结算会以[服务负责的通知](../subagent/README.md#settlement-notice)到达父级,独立加载的 `send_message` 工具会投递后续消息,而通过其 id 查看子 agent 的 transcript 即是其详细输出来源。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
确认消息会被保留;一次性最终输出只在收集或注入时进入父级历史,而可继续子 agent 的输出绝不会通过本工具返回。
|
||||
确认消息会被保留;一次性最终输出只在收集或注入时进入父级历史,而可继续子 agent 的输出绝不会通过本工具返回——其结算通知独立于任何工具结果到达。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
@@ -77,6 +77,6 @@
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **后台运行不通过本工具公开结果**:一次性任务的最终输出通过通用 Task 接口收集,可继续子 agent 的输出留在其自身会话中,按其 subagent id 读取。
|
||||
- **后台运行不通过本工具公开结果**:一次性任务的最终输出通过通用 Task 接口收集,可继续子 agent 的输出留在其自身会话中,按其 subagent id 读取。结算通知会说明该子 agent 如何结束并携带其收尾消息,但它不是本次调用的返回值,也无法在此等待。
|
||||
- **等待中实例的重复名称发现较晚**(`TODO(subagent-dup-toolname)`):若要阻止提供方注册回滚,需要一份预期名称注册表。
|
||||
- **每个实例的子 agent 策略固定**:其他模型、persona、工具过滤器或深度上限都需要另一个名称不同的工具。
|
||||
@@ -262,12 +262,13 @@ export function apply(ctx: Context, config: Config): void {
|
||||
disposeTool = ctx.tools.register(defineTool({
|
||||
name: config.toolName ?? 'subagent',
|
||||
description: wording.description + (backgroundEnabled
|
||||
// The return channel is a separately installed capability this package
|
||||
// cannot observe, so this describes only this call's result.
|
||||
// The completion notice is the continuation service's own behavior, not
|
||||
// a separately installed capability, so this promise holds whenever the
|
||||
// continuable background path is reachable at all.
|
||||
? continuable
|
||||
? ' Set `run_in_background: true` to start a background subagent that keeps its conversation:'
|
||||
+ ' you receive only its subagent id, never its result, and it works on its own. Use this for'
|
||||
+ ' work whose result you do not need returned by this call; `send_message` sends it more work.'
|
||||
+ ' this call returns only its subagent id, and the subagent works on its own from there. You'
|
||||
+ ' are told when it finishes, so never poll or wait on it; `send_message` sends it more work.'
|
||||
: ' Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.'
|
||||
: ''),
|
||||
parameters: {
|
||||
@@ -286,7 +287,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
type: 'boolean' as const,
|
||||
description: continuable
|
||||
? 'Run as a background subagent that keeps its conversation and return only its subagent id. '
|
||||
+ 'This call never returns its result; send it more work with send_message.'
|
||||
+ 'This call does not wait for it; you are told when it finishes. Send it more work with send_message.'
|
||||
: 'Run as a background task and return its id; collect with task_output or stop with task_kill.',
|
||||
},
|
||||
} : {},
|
||||
|
||||
Reference in New Issue
Block a user