feat(subagent): add explicit child reports

This commit is contained in:
Dudu-0223
2026-07-31 22:45:21 +08:00
committed by Tianyi Cui
parent a1b3bebb61
commit 431fb4b035
76 changed files with 3831 additions and 143 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/subagent/tool-subagent-report/README.md
README.md: e15b8b5d5881fd7b6868995fec22048a605f4c7e
README.zh.md: 0c41bc9c1e5aa4d728789b064f2d00c8da8ca6c8

View File

@@ -0,0 +1,67 @@
# @deepseek-ai/dsh-tool-subagent-report
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.
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 missing, disposed, or closing parent fails the call with `direct parent is not live; report was not delivered`; the service performs no injection, parent cold resume, or offline mailbox write, so the durable child transcript remains the recovery source.
`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.
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.
## Model Experience
### Tool schema
#### 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.
#### Token effect
Fixed schema 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.
### Report result
#### What the model sees
`report accepted by the agent that started you as message <messageId>` on acceptance; the canonical output carries the stable `messageId`. A failure from an unauthorized sender, an unavailable parent, or a closing lifecycle is an errored result. The description says a failed call may still have arrived because a later `tools/post-execute` failure can replace the result after `reportFrom()` accepted the message.
#### 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.
#### KV Cache effect
Append-only in the child. In the parent, the framed report follows existing history and preserves the reusable prefix.
### Parent-visible report
#### What the model sees
One user-role parent message framed as `Background subagent <child-id> reported:` followed by the child's exact `output`, with durable provenance `{ kind: 'subagent-report', senderSessionId: <child-id> }`.
#### Token effect
The child's complete `output` plus the one-line frame, uncapped by this package.
#### KV Cache effect
Append-only; the report follows the parent's reusable request prefix. Waking delivery starts an independent parent model request, while quiet delivery does not.
## Known Limitations and Deferred Work
- **Setup revocation can follow lower-level Session publication** — the final revocation check runs after `ctx.agents.create()` or `ctx.agents.resume()` returns, by which point that call has already published its Agent and Session. Revocation in this window rolls back the handle and prevents the subagent Activation start edge, but may leave a persisted Session. Closing this gap requires a future Agent-creation setup transaction seam before lower-level publication.
- **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.
- **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.

View File

@@ -0,0 +1,67 @@
# @deepseek-ai/dsh-tool-subagent-report
[English](README.md) | 中文
可选的子级作用域 `report` 工具是 `ctx.subagents.reportFrom()` 之上的轻量适配器。它为每个可继续的进程内子级提供一条返回通道,指向启动该子级的 Agent(智能体)。本包(package)注册的是可继续子级设置贡献,而不是全局工具,因此 `report` 只存在于这些子级内部。根 Agent、一次性 subagent、远程 subagent 提供方、同级作用域以及不关联 Agent 的工具执行都不会提供或执行它。安装本包只授予这项子级作用域功能;父到子方向仍由独立的 [`@deepseek-ai/dsh-tool-subagent-control`](../tool-subagent-control/README.md) 负责,可继续模式不依赖这两个包中的任一个。
子级可以在一个轮次中调用 `report` 零次或多次。调用成功既不会结束轮次或结算 Activation,也不会阻止父级后续消息;轮次结束也绝不会自动上报。该工具不接受接收方参数:`exec.agent` 是发送方准确的实时 Agent,也是权限凭据;服务根据该子级持久化的 `parentSession` 推导唯一接收方。成功时返回父级已接受消息的稳定 `MessageId`,不表示已读回执、inbox 中该次出现的 id、父级日志确认、轮次完成回执或持久化刷盘。父级不存在、已 dispose(资源释放)或正在关闭时,本次调用会失败并返回 `direct parent is not live; report was not delivered`;服务不会执行注入、父级冷恢复或离线 mailbox 写入,因此持久化子级 transcript(文本记录)仍是恢复真源。
`reportDelivery` 为每条已接受的报告选择父级调度方式。`quiet`(默认值)使用 `parent.inject()`,在不启动父级模型请求的情况下添加面向模型的上下文:父级空闲时,追加操作会在调用返回前完成;报告到达正在准入或运行的父级时,则会暂存到下一个安全日志位置。`wakeup` 使用 `parent.followup()`,准确创建一个普通的后续父级轮次,并唤醒停驻的父级驱动;它绝不会对正在运行的轮次进行 steering(中途引导)。这是部署调度策略,因此面向模型的 schema 不能在单次调用中选择或覆盖该策略。
作用域局部注册有意不受子级全局 `toolFilter` 影响,因此委派允许列表无法移除唯一的返回通道。需要子级不具备返回通道的部署应省略本包。
贡献体以 `installReportTool(childCtx, ctx, delivery)` 导出,以便检查类消费方把 `report` 安装到新创建的子级作用域中。全局注册表无法公开作用域局部 schema,因此生成的工具目录会使用这条路径。生产组合仍通过 `apply()` 进入;subagent seam 的贡献注册表保持私有。
## 模型体验
### 工具 schema
#### 模型看到的内容
已生成的 [`report` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-report):包含一个必填 `output` 字符串。其描述说明上报需要显式调用且可以重复,只会到达启动该子级的 Agent,并且不会结束轮次。它不包含接收方或投递模式参数。
#### Token 影响
每个可继续子级请求支付固定的 schema 成本,其他任何 Agent 的请求均无此成本。
#### KV Cache 影响
子级中的前缀保持稳定;schema 不会在运行时改变。移除本包会从驻留子级中撤销该 schema,从而改变其下一次请求前缀。
### 上报结果
#### 模型看到的内容
接受时返回 `report accepted by the agent that started you as message <messageId>`;规范输出携带稳定的 `messageId`。发送方未授权、父级不可用或生命周期正在关闭时,失败会成为出错的结果。描述中会说明,失败的调用仍可能已经送达,因为 `reportFrom()` 接受消息后,后续 `tools/post-execute` 失败可能替换工具结果。
#### Token 影响
每次调用都会在执行上报的子级中产生一条简短确认消息。父级还会为上报内容支付 token 成本:静默投递会把内容加入父级的下一次请求,唤醒投递则会使该内容成为一个新父级轮次中唯一的普通消息。
#### KV Cache 影响
在子级中仅追加。在父级中,带前缀的报告位于现有历史之后,并保留可复用前缀。
### 父级可见的报告
#### 模型看到的内容
一条用户角色的父级消息,以 `Background subagent <child-id> reported:` 开头,后接子级准确的 `output`,并带有持久化来源 `{ kind: 'subagent-report', senderSessionId: <child-id> }`。
#### Token 影响
子级的完整 `output` 加上一行前缀;本包不设上限。
#### KV Cache 影响
仅追加;报告位于父级可复用请求前缀之后。唤醒投递会启动一次独立的父级模型请求,静默投递则不会。
## 已知限制与暂缓事项
- **setup 撤销可能发生在底层 Session 发布之后**:最终撤销检查发生在 `ctx.agents.create()` 或 `ctx.agents.resume()` 返回之后,此时该调用已发布其 Agent 和 Session。在这个窗口内撤销会回滚 handle,并阻止 subagent Activation 的 start 边,但可能留下持久化 Session。要弥合这个缺口,需要未来在底层发布之前提供 Agent 创建 setup 事务 seam。
- **父级可能在宿主启动 dispose 后继续接受报告**:`AgentHandle.dispose()` 会先取消并等待完全停稳,然后才撤销作用域并离开注册表;它不公开「dispose 已开始」信号。在该窗口内接受的报告会追加到父级 transcript,但该父级不会在本进程中处理它。对于由延续管理器拥有的父级,管理器的准入边界会在整棵子树拆卸期间拒绝该上报。
- **接受弱于持久投递**:没有持久化 mailbox、幂等键、投递回执、重试协议,也不保证恰好一次。任一侧记录接受后若进程失败,结果都不明确;外部重试可能产生重复上报。
- **暂存的静默报告无法立即重建**:接受时会返回其稳定 `MessageId`,但只有当待处理上下文到达普通日志边界后,父级 Session 才能重建带前缀的内容。
- **授权须等到下一个 Activation,撤销则立即生效**:子级驻留后再安装本包,只会在该子级的下一个 Activation 中授予 `report`;移除本包则会立即从驻留子级撤销该 schema。
- **嵌套上报只向上到达一条直接边**:孙级只向作为其直接父级的子级上报,不会直接到达顶层协调器;该直接父级必须随后显式发出一条衍生更新。
- **没有速率限制**:嵌套子级频繁上报时,`wakeup` 模式会放大模型工作量;部署通过选择模式自行承担这一取舍。

View File

@@ -0,0 +1,54 @@
{
"name": "@deepseek-ai/dsh-tool-subagent-report",
"description": "Child-scoped report tool over ctx.subagents continuations",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-subagent": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-subagent-spawn": "workspace:^",
"@deepseek-ai/dsh-tool-subagent-control": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,94 @@
/**
* 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.
*
* @module @deepseek-ai/dsh-tool-subagent-report
*/
import type { Context } from 'cordis'
import z from '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 { 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']
/** 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.
*/
reportDelivery?: SubagentReportDelivery
}
export const Config: z<Config> = z.object({
reportDelivery: z.union(['quiet', 'wakeup'] as const).default('quiet'),
})
/**
* Install `report` into one continuable child's scope.
* @param childCtx - child-scoped context receiving the tool.
* @param ctx - service context used for delivery.
* @param delivery - resolved deployment scheduling policy.
* @returns disposer for this one registration.
*/
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 },
},
},
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 }
},
}))
}
/**
* Register the continuable-child contribution.
* @param ctx - context carrying tools and the subagent service.
* @param config - deployment scheduling policy.
*/
export function apply(ctx: Context, config: Config = {}): void {
const { reportDelivery = 'quiet' } = Config(config)
ctx.subagents.registerContinuableSetup(childCtx =>
installReportTool(childCtx, ctx, reportDelivery))
}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-tool-subagent-report`.
* @module @deepseek-ai/dsh-tool-subagent-report/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-subagent-report'
/** Cordis companion plugin name. */
export const name = 'tool-subagent-report-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this adapter has no independent lifecycle stream;
* sender authorization and delivery relations belong to the subagent service.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - context carrying the invariant service.
* @returns the registration disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,369 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
import type { Agent } 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'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import SubagentService from '@deepseek-ai/dsh-subagent'
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
import * as control from '@deepseek-ai/dsh-tool-subagent-control'
import { textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import * as tool from '../src/index.ts'
const testSignal = new AbortController().signal
/** Adapter that keeps child Activations resident until released. */
class HeldAdapter extends LlmAdapter {
readonly requests: GenerateOptions[] = []
private readonly gate = Promise.withResolvers<undefined>()
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
this.requests.push(options)
await this.gate.promise
for (const chunk of textResponse('held answer')) {
if (options.signal?.aborted) throw new Error('aborted')
yield chunk
}
}
release(): void {
this.gate.resolve(undefined)
}
}
const cleanups: (() => Promise<void>)[] = []
afterEach(async () => {
for (const cleanup of cleanups.splice(0).reverse()) await cleanup()
})
/** Boot the real continuation graph with optional report installation. */
async function setup(options: { load?: boolean; config?: tool.Config } = {}) {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
const root = mkdtempSync(join(tmpdir(), 'dsh-tool-subagent-report-'))
await ctx.plugin(JsonlSessionPersistence, { root })
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentService)
await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
const fiber = options.load === false
? undefined
: await ctx.plugin(tool, options.config ?? { reportDelivery: 'quiet' })
const adapter = new HeldAdapter()
ctx.llm.registerAdapter(['mock'], adapter)
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
cleanups.push(async () => {
adapter.release()
await ctx.fiber.dispose()
rmSync(root, { recursive: true, force: true })
})
return { ctx, parent, adapter, fiber }
}
/** Start and resolve one resident continuable child. */
async function startChild(ctx: Context, parent: Agent, prompt = 'child task') {
const started = await ctx.subagents.startContinuable({
provider: 'spawn',
label: prompt,
request: {
prompt: [{ type: 'text', text: prompt }],
parent,
},
signal: testSignal,
})
const child = await vi.waitFor(() => {
const live = ctx.agents.get(started.childId)
expect(live).toBeDefined()
return live as Agent
})
return { started, child }
}
let calls = 0
function callReport(ctx: Context, child: Agent, output: string, signal = testSignal) {
return ctx.tools.execute({
signal,
callId: CallId(`report-${++calls}`),
name: 'report',
arguments: { output },
agent: child,
})
}
/** Reports durably visible in one Agent's Session. */
function reports(agent: Agent): { id: string; text: string; sender: string }[] {
return agent.session.events.flatMap((event) => {
if (event.type !== 'user/message' || event.data.source.kind !== 'subagent-report') return []
return [{
id: event.data.id,
text: event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n'),
sender: event.data.source.senderSessionId,
}]
})
}
function renderedText(result: { content: { type: string; text?: string }[] }): string {
return result.content.flatMap(block => block.type === 'text' ? [block.text ?? ''] : []).join('')
}
describe('dsh-tool-subagent-report', () => {
it('registers report only in continuable child scopes', async () => {
const { ctx, parent } = await setup()
expect(ctx.tools.schemas().map(schema => schema.name)).not.toContain('report')
expect(ctx.tools.schemas(parent).map(schema => schema.name)).not.toContain('report')
const { child } = await startChild(ctx, parent)
const schemas = ctx.tools.schemas(child).filter(schema => schema.name === 'report')
expect(schemas).toHaveLength(1)
const properties = (schemas[0]?.parameters as { properties: Record<string, unknown> }).properties
expect(Object.keys(properties)).toEqual(['output'])
})
it('adds no implicit capability when the package is absent', async () => {
const { ctx, parent } = await setup({ load: false })
const { child } = await startChild(ctx, parent)
expect(ctx.tools.schemas(child).map(schema => schema.name)).not.toContain('report')
expect((await callReport(ctx, child, 'missing')).isError).toBe(true)
})
it('does not imply parent controls and survives a global-tool allow-list', async () => {
const { ctx, parent } = await setup()
expect(ctx.tools.schemas().map(schema => schema.name)).not.toContain('send_message')
await ctx.plugin(control)
expect(ctx.tools.schemas().map(schema => schema.name)).toContain('send_message')
const started = await ctx.subagents.startContinuable({
provider: 'spawn',
label: 'restricted child',
request: {
prompt: [{ type: 'text', text: 'restricted child' }],
parent,
toolFilter: { allow: [] },
},
signal: testSignal,
})
const child = await vi.waitFor(() => {
const live = ctx.agents.get(started.childId)
expect(live).toBeDefined()
return live as Agent
})
const names = ctx.tools.schemas(child).map(schema => schema.name)
expect(names).toContain('report')
expect(names).not.toContain('send_message')
})
it('delivers quiet reports with stable identity and provenance without waking', async () => {
const { ctx, parent, adapter } = await setup()
const { started, child } = await startChild(ctx, parent)
const parentRequests = adapter.requests.filter(request => request.sessionId === parent.id).length
const enqueues: string[] = []
ctx.on('agent/inbox/enqueue', (agent, item) => {
if (agent === parent) enqueues.push(item.placement)
})
const result = await callReport(ctx, child, 'CHILD_FINDING')
expect(result.isError).toBe(false)
if (result.isError) throw new Error('report unexpectedly failed')
const messageId = (result.value as { messageId: string }).messageId
expect(renderedText(result)).toContain(messageId)
expect(reports(parent)).toEqual([{
id: messageId,
text: `Background subagent ${started.childId} reported:\nCHILD_FINDING`,
sender: started.childId,
}])
expect(enqueues).toEqual([])
expect(parent.status).toBe('idle')
expect(adapter.requests.filter(request => request.sessionId === parent.id)).toHaveLength(parentRequests)
})
it('queues wakeup reports as one later parent turn', async () => {
const { ctx, parent, adapter } = await setup({ config: { reportDelivery: 'wakeup' } })
const { child } = await startChild(ctx, parent)
const enqueues: string[] = []
ctx.on('agent/inbox/enqueue', (agent, item) => {
if (agent === parent) enqueues.push(item.placement)
})
const result = await callReport(ctx, child, 'WAKE_UP')
expect(result.isError).toBe(false)
expect(enqueues).toEqual(['queued'])
await vi.waitFor(() => {
expect(adapter.requests.some(request => request.sessionId === parent.id)).toBe(true)
})
})
it('preserves accepted order across repeated reports', async () => {
const { ctx, parent } = await setup()
const { child } = await startChild(ctx, parent)
expect((await callReport(ctx, child, 'FIRST')).isError).toBe(false)
expect((await callReport(ctx, child, 'SECOND')).isError).toBe(false)
expect(reports(parent).map(report => report.text.split('\n').at(-1))).toEqual(['FIRST', 'SECOND'])
})
it('keeps an accepted report after the child settles', async () => {
const { ctx, parent, adapter } = await setup()
const { started, child } = await startChild(ctx, parent)
expect((await callReport(ctx, child, 'DURABLE_SELECTION')).isError).toBe(false)
adapter.release()
await vi.waitFor(() => { expect(ctx.agents.get(started.childId)).toBeUndefined() })
expect(reports(parent).map(report => report.text)).toEqual([
`Background subagent ${started.childId} reported:\nDURABLE_SELECTION`,
])
})
it('routes nested reports exactly one edge upward', async () => {
const { ctx, parent, adapter } = await setup()
const { child } = await startChild(ctx, parent, 'outer task')
const { started: grandchildStart, child: grandchild } = await startChild(ctx, child, 'inner task')
expect((await callReport(ctx, grandchild, 'FROM_GRANDCHILD')).isError).toBe(false)
expect(reports(parent)).toEqual([])
// The intermediate parent's turn is open, so quiet context is staged until
// that turn reaches its next safe log boundary.
expect(reports(child)).toEqual([])
adapter.release()
await vi.waitFor(() => { expect(reports(child)).toHaveLength(1) })
expect(reports(child)[0]?.sender).toBe(grandchildStart.childId)
expect(reports(child)[0]?.text).toContain('FROM_GRANDCHILD')
})
it('accounts wakeup reports delivered to a resident continuable parent', async () => {
const { ctx, parent, adapter } = await setup({ config: { reportDelivery: 'wakeup' } })
const { child } = await startChild(ctx, parent, 'outer task')
const { started: grandchildStart, child: grandchild } = await startChild(ctx, child, 'inner task')
expect((await callReport(ctx, grandchild, 'WAKE_PARENT_CHILD')).isError).toBe(false)
expect(ctx.agents.get(child.id)).toBe(child)
adapter.release()
await vi.waitFor(() => { expect(reports(child)).toHaveLength(1) })
expect(reports(child)[0]?.sender).toBe(grandchildStart.childId)
expect(reports(child)[0]?.text).toContain('WAKE_PARENT_CHILD')
})
it('normalizes a direct parent send rejection', async () => {
const { ctx, parent } = await setup()
const { child } = await startChild(ctx, parent)
vi.spyOn(parent, 'inject').mockImplementationOnce(() => {
throw new Error('parent closed during delivery')
})
await expect(ctx.subagents.reportFrom(child, [{ type: 'text', text: 'rejected' }], {
delivery: 'quiet',
signal: testSignal,
})).rejects.toMatchObject({ code: 'PARENT_UNAVAILABLE' })
expect(reports(parent)).toEqual([])
})
it('rejects roots, forged same-id senders, absent parents, cancellation, and drain', async () => {
const { ctx, parent, adapter } = await setup()
await expect(ctx.subagents.reportFrom(parent, [{ type: 'text', text: 'root' }], {
delivery: 'quiet',
signal: testSignal,
})).rejects.toMatchObject({ code: 'UNAUTHORIZED' })
const disposable = await ctx.agents.create({
sessionId: SessionId('disposable-parent'),
agentOptions: { provider: 'mock', model: 'mock' },
})
const { child } = await startChild(ctx, disposable.agent)
const forged = { ...child } as Agent
await expect(ctx.subagents.reportFrom(forged, [{ type: 'text', text: 'forged' }], {
delivery: 'quiet',
signal: testSignal,
})).rejects.toMatchObject({ code: 'UNAUTHORIZED' })
const aborted = new AbortController()
aborted.abort()
expect((await callReport(ctx, child, 'cancelled', aborted.signal)).isError).toBe(true)
await disposable.dispose()
expect((await callReport(ctx, child, 'orphaned')).isError).toBe(true)
adapter.release()
const draining = ctx.subagents.drainContinuableDescendants([child])
await expect(ctx.subagents.reportFrom(child, [{ type: 'text', text: 'draining' }], {
delivery: 'quiet',
signal: testSignal,
})).rejects.toMatchObject({ code: 'DRAINING' })
await draining
})
it('revokes resident installations and defers later grants to the next Activation', async () => {
const { ctx, parent, fiber } = await setup()
const { child } = await startChild(ctx, parent)
expect(ctx.tools.schemas(child).map(schema => schema.name)).toContain('report')
await fiber?.dispose()
expect(ctx.tools.schemas(child).map(schema => schema.name)).not.toContain('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')
await late.dispose()
})
it('rolls back materialization when a setup contribution revokes itself', async () => {
const { ctx, parent } = await setup({ load: false })
const self: { revoke?: () => void } = {}
self.revoke = ctx.subagents.registerContinuableSetup((childCtx) => {
const dispose = childCtx.tools.register({
name: 'racing-report',
description: 'racing setup',
parameters: { type: 'object', properties: {} },
output: { schema: { type: 'object', properties: {} }, render: () => [] },
execute: () => Promise.resolve({}),
})
self.revoke?.()
return dispose
})
await expect(ctx.subagents.startContinuable({
provider: 'spawn',
label: 'racing child',
request: {
prompt: [{ type: 'text', text: 'racing child' }],
parent,
},
signal: testSignal,
})).rejects.toMatchObject({ code: 'ACTIVATION_SETUP_REVOKED' })
expect(ctx.agents.list().map(agent => agent.id)).toEqual([parent.id])
})
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.Config({ reportDelivery: 'shout' } as never)).toThrow()
})
})
/** Prove report delivery uses ordinary logged user messages. */
function userTexts(events: readonly SessionEvent[]): string[] {
return events.flatMap(event => event.type === 'user/message'
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
: [])
}
describe('dsh-tool-subagent-report result independence', () => {
it('does not report a final assistant answer automatically or create Tasks', async () => {
const { ctx, parent, adapter } = await setup()
const { started } = await startChild(ctx, parent)
adapter.release()
await vi.waitFor(() => { expect(ctx.agents.get(started.childId)).toBeUndefined() })
expect(reports(parent)).toEqual([])
expect(userTexts((await ctx.sessionPersistence.load(started.childId)).events)).toEqual(['child task'])
expect(ctx.get('tasks')).toBeUndefined()
})
})

View File

@@ -0,0 +1,30 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../llm/llm"
},
{
"path": "../../core/tools"
},
{
"path": "../subagent"
},
{
"path": "../../support/invariants"
}
]
}