feat(subagent): catalog one-shot child sessions

This commit is contained in:
Dudu-0223
2026-07-27 16:58:37 +08:00
committed by Tianyi Cui
parent de6e572e51
commit 774ee34b9a
85 changed files with 1034 additions and 539 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/subagent/tool-subagent-control/README.md
README.md: 1fe0e49006d12b95e6c6e38cab7895238b3d3c98
README.zh.md: 9d2d46888b0790548df35cb54613ee2334143db7
README.md: 78621701cd4fe8ac9ad8b3f0985271ee6e2a7e8c
README.zh.md: f6ed8144dcfc8df8f2e6edad89c4d42b641d666e

View File

@@ -6,7 +6,7 @@ The optional, globally named `send_message` and `list_agents` tools are thin ada
The tool performs no lifecycle routing — residency and cold resume belong to the subagent service. It passes `exec.agent` as the exact live parent that authorizes delivery and attributes every message as durable provenance `{ kind: 'coordinator', senderSessionId: parent.id }`, which the service retains but never treats as authority. Every message becomes the subagent's next FIFO turn through `Agent.followup()`: if the child is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The tool forwards its execution signal, which owns admission only until inbox acceptance; once the child accepts the message the accepted turn cannot be cancelled through this tool. The child does not reply to the sender — its transcript by that id is the source of what it did. A delivery failure becomes an errored tool result stating the message was not delivered.
`list_agents` takes no arguments, derives the parent id from the calling agent, and renders `ctx.subagents.listChildren()`'s complete entry array without a cursor. It is discovery only: durable identity comes from each child's descriptor, while delivery-time authority and Activation ownership checks remain `send_message`'s.
`list_agents` takes no arguments, derives the parent id from the calling agent, and projects `ctx.subagents.listChildren()` to continuable children without a cursor. 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. Durable identity and mode come from each child's descriptor, while delivery-time authority and Activation ownership checks remain `send_message`'s.
## Model Experience
@@ -42,7 +42,7 @@ Append-only; newly visible content follows the reusable request prefix and does
#### What the model sees
One line per entry in the trace's stable order: `<id> [<status>] — <label>` for a child (`running` = the logical session is live, `complete` = persisted only and resumable by `send_message`), `<id> [diagnostic: <reason>]` for a candidate that could not be read (`corrupt`, `unsupported`, or `unavailable`), and `(no subagents)` for an empty result. Diagnostics never expose descriptor contents.
One line per continuable child in the trace's stable order: `<id> [<status>] — <label>` (`running` = the logical session is live, `complete` = persisted only and resumable by `send_message`), plus `<id> [diagnostic: <reason>]` for a candidate that could not be read (`corrupt`, `unsupported`, or `unavailable`). One-shot children are intentionally absent; `(no subagents)` means no continuable child or diagnostic survived the projection. Diagnostics never expose descriptor contents.
#### Token effect

View File

@@ -6,7 +6,7 @@
本工具不执行生命周期路由——驻留与冷恢复归 subagent 服务所有。它将 `exec.agent` 作为授权投递的准确实时父级传入,并把每条消息的来源标记为持久化来源 `{ kind: 'coordinator', senderSessionId: parent.id }`;服务会保留该来源,但绝不将其视为权限。每条消息都会通过 `Agent.followup()` 成为子 agent智能体的下一个 FIFO 轮次:如果子 agent 仍在工作,该消息会等待其当前轮次结束,因此无法重定向已经在进行的工作。本工具会转发其执行信号,该信号只在 inbox 接受之前掌管准入;一旦子 agent 接受消息,已接受的轮次便无法再通过本工具取消。子 agent 不会回复发送方——通过该 id 查看其 transcript 即是其所做工作的来源。投递失败会变为出错的工具结果,并明确说明消息未送达。
`list_agents` 不接受参数,从调用 Agent 推导 parent id在没有 cursor 的情况下渲染 `ctx.subagents.listChildren()`完整条目数组。它只负责发现:持久化身份来自每个 child 的描述符,消息送达时的鉴权和 Activation 所有权检查仍归 `send_message` 负责。
`list_agents` 不接受参数,从调用它的 agent 推导 parent id且不使用 cursor,将 `ctx.subagents.listChildren()`结果投影为可继续 child。服务结果还包含由会话支撑的一次性 subagent以供 UI 等消费方使用;但这些条目无法接受 `send_message`因此会从这个模型工具中排除。diagnostic 仍然可见。持久化身份和模式来自每个子 agent 的描述符,消息送达时的鉴权和 Activation 所有权检查仍归 `send_message` 负责。
## 模型体验
@@ -42,7 +42,7 @@
#### 模型看到的内容
每个条目按追踪结果的稳定顺序占一行child 使用 `<id> [<status>] — <label>``running` 表示逻辑会话存活,`complete` 表示存在于持久化存储中且可由 `send_message` 恢复),无法读取的候选使用 `<id> [diagnostic: <reason>]``corrupt``unsupported``unavailable`,空结果使用 `(no subagents)`。Diagnostic 绝不暴露描述符内容。
按追踪结果的稳定顺序,每个可继续 child 占一行:渲染为 `<id> [<status>] — <label>``running` 表示逻辑会话存活,`complete` 表示存在于持久化存储中,可通过 `send_message` 恢复),另为无法读取的候选项渲染 `<id> [diagnostic: <reason>]``corrupt``unsupported``unavailable`。一次性 child 会被有意排除;`(no subagents)` 表示投影后没有留下可继续 child 或 diagnostic。诊断信息绝不暴露描述符内容。
#### Token 影响

View File

@@ -1,6 +1,7 @@
/**
* The globally named `list_agents` tool: a thin model-facing adapter over
* `ctx.subagents.listChildren()`. It is separately loadable from the
* the continuable projection of `ctx.subagents.listChildren()`. It is
* separately loadable from the
* root `send_message` plugin because it additionally requires the session
* query service — a deployment may use `send_message` without loading session
* query, and this plugin catches that misconfiguration at load.
@@ -15,6 +16,19 @@ import type {} from '@deepseek-ai/dsh-subagent'
export const name = 'tool-subagent-list-agents'
export const inject = ['tools', 'subagents', 'sessionQuery']
type ListAgentsEntry =
| {
readonly kind: 'child'
readonly id: string
readonly label: string
readonly status: 'running' | 'complete'
}
| {
readonly kind: 'diagnostic'
readonly id: string
readonly reason: 'corrupt' | 'unsupported' | 'unavailable'
}
/**
* Register the `list_agents` tool.
* @param ctx - context carrying the tool registry, subagent service, and session query.
@@ -23,7 +37,7 @@ export function apply(ctx: Context): void {
ctx.tools.register(defineTool({
name: 'list_agents',
description:
'List your background subagents by durable id and label. Status is a snapshot of the stored '
'List your continuable background subagents by durable id and label. Status is a snapshot of the stored '
+ 'record: running means the subagent session is currently live in this process, complete means '
+ 'it exists only in storage and a `send_message` starts a new turn on the same conversation. '
+ 'The snapshot is not a delivery promise — `send_message` performs the authoritative check and '
@@ -74,7 +88,21 @@ export function apply(ctx: Context): void {
}
// The registry drains started tool bodies, so the scan must observe the
// call's signal rather than finish a slow catalog after cancellation.
return await ctx.subagents.listChildren(parent.id, exec.signal)
const entries = await ctx.subagents.listChildren(parent.id, exec.signal)
const visible: ListAgentsEntry[] = []
for (const entry of entries) {
if (entry.kind === 'diagnostic') {
visible.push(entry)
} else if (entry.mode === 'continuable') {
visible.push({
kind: 'child',
id: entry.id,
label: entry.label,
status: entry.activity === 'running' ? 'running' : 'complete',
})
}
}
return visible
},
}))
}

View File

@@ -90,19 +90,49 @@ describe('dsh-tool-subagent-control/list-agents', () => {
// Pin the render deterministically past the service: the tool is a thin
// adapter, so its fixed text forms are what this test pins.
const entries: SubagentListEntry[] = [
{ kind: 'child', id: started.childId, label: 'real child', status: 'complete' },
{
kind: 'child',
id: SessionId('one-shot-child'),
label: 'finished once',
mode: 'one-shot',
activity: 'inactive',
},
{
kind: 'child',
id: started.childId,
label: 'real child',
mode: 'continuable',
activity: 'inactive',
},
{
kind: 'child',
id: SessionId('running-child'),
label: 'still working',
mode: 'continuable',
activity: 'running',
},
{ kind: 'diagnostic', id: SessionId('broken-child'), reason: 'corrupt' },
]
ctx.subagents.listChildren = () => Promise.resolve(entries)
const result = await callTool(ctx, 'list_agents', {}, parent)
expect(result.isError).toBe(false)
expect(text(result)).toBe(
`${started.childId} [complete] — real child\nbroken-child [diagnostic: corrupt]`,
`${started.childId} [complete] — real child\n`
+ 'running-child [running] — still working\n'
+ 'broken-child [diagnostic: corrupt]',
)
})
it('lists a real settled child end-to-end with its durable label', async () => {
const { ctx, parent } = await setup([textResponse('done')])
it('lists a real settled continuable child and omits a real one-shot sibling', async () => {
const { ctx, parent } = await setup([textResponse('once'), textResponse('done')])
const oneShot = await ctx.subagents.start('spawn', {
label: 'finished once',
prompt: [{ type: 'text', text: 'one-shot task' }],
parent,
signal: new AbortController().signal,
})
await oneShot.result
await oneShot.dispose()
const started = await ctx.subagents.startContinuable({
provider: 'spawn',
label: 'summarize the doc',