feat(subagent): add explicit child reports
This commit is contained in:
6
packages/subagent/tool-subagent-report/README.i18n.yaml
Normal file
6
packages/subagent/tool-subagent-report/README.i18n.yaml
Normal 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
|
||||
67
packages/subagent/tool-subagent-report/README.md
Normal file
67
packages/subagent/tool-subagent-report/README.md
Normal 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.
|
||||
67
packages/subagent/tool-subagent-report/README.zh.md
Normal file
67
packages/subagent/tool-subagent-report/README.zh.md
Normal 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` 模式会放大模型工作量;部署通过选择模式自行承担这一取舍。
|
||||
54
packages/subagent/tool-subagent-report/package.json
Normal file
54
packages/subagent/tool-subagent-report/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
94
packages/subagent/tool-subagent-report/src/index.ts
Normal file
94
packages/subagent/tool-subagent-report/src/index.ts
Normal 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))
|
||||
}
|
||||
30
packages/subagent/tool-subagent-report/src/invariant.ts
Normal file
30
packages/subagent/tool-subagent-report/src/invariant.ts
Normal 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 */
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
30
packages/subagent/tool-subagent-report/tsconfig.json
Normal file
30
packages/subagent/tool-subagent-report/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user