fix(subagent): keep one-shot labels optional

This commit is contained in:
Dudu-0223
2026-07-27 17:22:40 +08:00
committed by Tianyi Cui
parent 774ee34b9a
commit 03d95d0d84
28 changed files with 124 additions and 147 deletions

View File

@@ -1817,7 +1817,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ContinuableSubagentDescriptorData',
declaration: 'export interface ContinuableSubagentDescriptorData extends SubagentDescriptorBase {\n readonly mode: \'continuable\';\n readonly agentProvider?: string;\n readonly agentModel?: string;\n readonly persona?: string;\n readonly toolFilter?: ToolRestriction;\n}',
declaration: 'export interface ContinuableSubagentDescriptorData extends SubagentDescriptorBase {\n readonly mode: \'continuable\';\n readonly label: string;\n readonly agentProvider?: string;\n readonly agentModel?: string;\n readonly persona?: string;\n readonly toolFilter?: ToolRestriction;\n}',
},
{
name: 'CreateAgentOptions',
@@ -2133,7 +2133,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'OneShotSubagentDescriptorData',
declaration: 'export interface OneShotSubagentDescriptorData extends SubagentDescriptorBase {\n readonly mode: \'one-shot\';\n}',
declaration: 'export interface OneShotSubagentDescriptorData extends SubagentDescriptorBase {\n readonly mode: \'one-shot\';\n readonly label?: string;\n}',
},
{
name: 'PermissionSelect',
@@ -2713,7 +2713,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SubagentListEntry',
declaration: 'export type SubagentListEntry = {\n readonly kind: \'child\';\n readonly id: SessionId;\n readonly label: string;\n readonly mode: \'one-shot\' | \'continuable\';\n readonly activity: \'running\' | \'inactive\';\n} | {\n readonly kind: \'diagnostic\';\n readonly id: SessionId;\n readonly reason: \'corrupt\' | \'unsupported\' | \'unavailable\';\n};',
declaration: 'export type SubagentListEntry = {\n readonly kind: \'child\';\n readonly id: SessionId;\n readonly activity: \'running\' | \'inactive\';\n} & ({\n readonly mode: \'one-shot\';\n readonly label?: string;\n} | {\n readonly mode: \'continuable\';\n readonly label: string;\n}) | {\n readonly kind: \'diagnostic\';\n readonly id: SessionId;\n readonly reason: \'corrupt\' | \'unsupported\' | \'unavailable\';\n};',
},
{
name: 'SubagentProvider',
@@ -2729,7 +2729,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SubagentStartRequest',
declaration: 'export interface SubagentStartRequest {\n readonly label: string;\n readonly prompt: ContentBlock[];\n readonly parent: Agent;\n readonly signal: AbortSignal;\n readonly agentOptions?: AgentOptions;\n readonly outputSchema?: ObjectJsonSchema;\n readonly maxDepth?: number;\n readonly toolFilter?: ToolRestriction;\n readonly persona?: string;\n}',
declaration: 'export interface SubagentStartRequest {\n readonly label?: string;\n readonly prompt: ContentBlock[];\n readonly parent: Agent;\n readonly signal: AbortSignal;\n readonly agentOptions?: AgentOptions;\n readonly outputSchema?: ObjectJsonSchema;\n readonly maxDepth?: number;\n readonly toolFilter?: ToolRestriction;\n readonly persona?: string;\n}',
},
{
name: 'SubagentStopReason',

View File

@@ -64,7 +64,6 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive
})
const run = await ctx.subagents.start('acp', {
label: 'reply pong',
prompt: [{ type: 'text', text: 'Reply with exactly the word PONG and nothing else. Do not use any tools.' }],
parent: fakeParent,
signal: new AbortController().signal,
@@ -96,7 +95,6 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive
})
const run = await ctx.subagents.start('acp', {
label: 'write proof file',
prompt: [{ type: 'text', text:
'Use the bash tool to write the text ACP_CHILD_WAS_HERE into a file named proof.txt '
+ 'in the current directory. Then reply DONE.' }],

View File

@@ -27,7 +27,7 @@ const mockServer = fileURLToPath(new URL('./mock-acp-server.ts', import.meta.url
const fakeParent = { id: 'parent', session: { header: { cwd: process.cwd() } } } as unknown as Agent
function request(text = 'p', signal = new AbortController().signal) {
return { label: text, prompt: [{ type: 'text' as const, text }], parent: fakeParent, signal }
return { prompt: [{ type: 'text' as const, text }], parent: fakeParent, signal }
}
interface SetupEnv {
@@ -210,9 +210,7 @@ describe('cwd resolution', () => {
try {
const ctx = await setup({ MOCK_ECHO_CWD: '1' })
const parent = { id: 'parent', session: { header: { cwd: workdir } } } as unknown as Agent
const run = await ctx.subagents.start('acp', {
label: 'p', prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal,
})
const run = await ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal })
const result = await run.result
await run.dispose()
// Line 1: where the child process actually ran; line 2: the workspace the
@@ -233,9 +231,7 @@ describe('cwd resolution', () => {
// A command that would create the sentinel if the child were ever spawned.
await ctx.plugin(acp, { providerName: 'acp', command: 'touch', args: [sentinel], permission: 'reject', env: {} })
const parent = { id: 'parent', session: { header: {} } } as unknown as Agent
await expect(ctx.subagents.start('acp', {
label: 'p', prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal,
}))
await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }))
.rejects.toThrow('no working directory')
// Resolution failed BEFORE the process boundary — nothing was launched.
expect(existsSync(sentinel)).toBe(false)
@@ -260,9 +256,7 @@ describe('cwd resolution', () => {
env: { MOCK_ECHO_CWD: '1' },
})
const parent = { id: 'parent', session: { header: { cwd: parentDir } } } as unknown as Agent
const run = await ctx.subagents.start('acp', {
label: 'p', prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal,
})
const run = await ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal })
const result = await run.result
await run.dispose()
expect(text(result.output)).toBe(`${configured}\n${configured}`)
@@ -358,9 +352,7 @@ describe('cwd resolution', () => {
// re-introduce the launch-directory dependency this resolution removes.
const ctx = await setup({})
const parent = { id: 'parent', session: { header: { cwd: 'relative/workspace' } } } as unknown as Agent
await expect(ctx.subagents.start('acp', {
label: 'p', prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal,
}))
await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }))
.rejects.toThrow('must be an absolute path')
})
@@ -371,9 +363,7 @@ describe('cwd resolution', () => {
try {
const ctx = await setup({})
const parent = { id: 'parent', session: { header: { cwd: file } } } as unknown as Agent
await expect(ctx.subagents.start('acp', {
label: 'p', prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal,
}))
await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }))
.rejects.toThrow('not an accessible directory')
} finally {
rmSync(tmp, { recursive: true, force: true })
@@ -389,9 +379,7 @@ describe('cwd resolution', () => {
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(acp, { providerName: 'acp', command: 'touch', args: [sentinel], permission: 'reject', env: {} })
const parent = { id: 'parent', session: { header: { cwd: join(tmp, 'vanished') } } } as unknown as Agent
await expect(ctx.subagents.start('acp', {
label: 'p', prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal,
}))
await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }))
.rejects.toThrow('not an accessible directory')
expect(existsSync(sentinel)).toBe(false)
} finally {

View File

@@ -22,16 +22,8 @@ async function mountInvariants(ctx: Context): Promise<void> {
await ctx.plugin(AgentLoopInvariant)
}
function start(
ctx: Context,
provider: string,
request: Omit<SubagentStartRequest, 'label' | 'signal'> & { label?: string; signal?: AbortSignal },
) {
return ctx.subagents.start(provider, {
label: request.label ?? 'child task',
signal: request.signal ?? new AbortController().signal,
...request,
})
function start(ctx: Context, provider: string, request: Omit<SubagentStartRequest, 'signal'> & { signal?: AbortSignal }) {
return ctx.subagents.start(provider, { signal: request.signal ?? new AbortController().signal, ...request })
}
/**

View File

@@ -25,16 +25,8 @@ async function mountInvariants(ctx: Context): Promise<void> {
await ctx.plugin(AgentLoopInvariant)
}
function start(
ctx: Context,
provider: string,
request: Omit<SubagentStartRequest, 'label' | 'signal'> & { label?: string; signal?: AbortSignal },
) {
return ctx.subagents.start(provider, {
label: request.label ?? 'child task',
signal: request.signal ?? new AbortController().signal,
...request,
})
function start(ctx: Context, provider: string, request: Omit<SubagentStartRequest, 'signal'> & { signal?: AbortSignal }) {
return ctx.subagents.start(provider, { signal: request.signal ?? new AbortController().signal, ...request })
}
/** A bare `stop` finish that streams no content → the turn ends `completed`

View File

@@ -49,16 +49,8 @@ function text(blocks: { type: string; text?: string }[]): string {
return blocks.filter(b => b.type === 'text').map(b => b.text).join('')
}
function start(
ctx: Context,
provider: string,
request: Omit<SubagentStartRequest, 'label' | 'signal'> & { label?: string; signal?: AbortSignal },
) {
return ctx.subagents.start(provider, {
label: request.label ?? 'child task',
signal: request.signal ?? new AbortController().signal,
...request,
})
function start(ctx: Context, provider: string, request: Omit<SubagentStartRequest, 'signal'> & { signal?: AbortSignal }) {
return ctx.subagents.start(provider, { signal: request.signal ?? new AbortController().signal, ...request })
}
/** Invoke the child lifecycle effect while its parent-owned setup is still unpublished. */

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/subagent/README.md
README.md: df2582518e97bb38d124f8c33259c30b9d11d759
README.zh.md: d788f30f31d92c3fd919b62891e9b03624f455f7
README.md: da03249d2957a67a0a0a1b3b935e90a9398125c8
README.zh.md: 74aebb4efbae05375b016f9a39ab8cd134eb030a

View File

@@ -34,7 +34,7 @@ Multiple providers may coexist under different names. This lets a deployment exp
| `drainContinuableDescendants(parents)` | Close admission below exact live host-owned parent Agents, stop only their visible continuable descendants, await materializations admitted below those roots through publication or rollback, then release the selected forests child-first. The cutoff lasts until each exact parent leaves the registry; unrelated parent forests and manager-wide admission remain live. |
| `listChildren(parentSessionId, signal?)` | List direct session-backed subagents with their `one-shot`/`continuable` mode and `running`/`inactive` activity, plus per-child diagnostics, in stable trace order without loading or resuming them. Requires session query; it does not require `ctx.agents` or the continuation manager. |
`SubagentStartRequest.label` is the short durable display label for a session-backed child. `signal` is required and is the canonical cancellation channel for a one-shot `start`. An abort before publication makes `start()` reject after rollback; an abort after publication cancels the live child. The request may also select a model, require structured output, cap delegation depth, restrict child tools, or set a child persona. For a continuable start or follow-up, the caller signal owns lookup, materialization, and admission only until inbox acceptance; afterward the manager owns the Activation independently, so later caller cancellation neither cancels the accepted turn nor disposes the child.
`SubagentStartRequest.label` is an optional short durable display label for a session-backed one-shot child. Model-facing delegation supplies its existing `description`; lower-level callers need not invent presentation metadata. Continuable starts always carry their own required label. `signal` is required and is the canonical cancellation channel for a one-shot `start`. An abort before publication makes `start()` reject after rollback; an abort after publication cancels the live child. The request may also select a model, require structured output, cap delegation depth, restrict child tools, or set a child persona. For a continuable start or follow-up, the caller signal owns lookup, materialization, and admission only until inbox acceptance; afterward the manager owns the Activation independently, so later caller cancellation neither cancels the accepted turn nor disposes the child.
Follow-up authority comes from the exact live direct parent recorded in the child's durable header. Cold resume checks that authority before reconstruction and again in the final no-await inbox-admission span, so a parent unregistered or replaced during materialization cannot authorize delivery. The `source` on a follow-up is durable provenance retained on the delivered message and grants no authority.
@@ -53,7 +53,7 @@ Continuable creation is the optional `SubagentProvider.prepareContinuable?()` me
## The durable descriptor
The seam owns the versioned `subagent/descriptor` session event vocabulary (`src/descriptor.ts`): `snapshotSubagentDescriptor()` validates and detaches the record before provider work, and `foldSubagentDescriptor()` validates the complete current-version payload before recovering it from a loaded child log. Every local session-backed start appends one descriptor with the durable creation label, provider name, and lifecycle `mode`. A `one-shot` descriptor stops there; a `continuable` descriptor additionally records resolved child `agentOptions.provider`/`model` and optional `persona`/`toolFilter` for cold resume. These are explicit fields, never the merge-extensible `AgentOptions` object, so an unrelated extension value cannot break continuation. The descriptor omits `subagentDepth` (the persisted header's `delegationDepth` is the monotone floor) and `outputSchema` (an activation's result contract). The event is log-only: no `surfaceOp`, absent from model history, and retained by the append-only log across compaction. Malformed current-version payloads are corrupt; unsupported versions cannot be classified by this runtime.
The seam owns the versioned `subagent/descriptor` session event vocabulary (`src/descriptor.ts`): `snapshotSubagentDescriptor()` validates and detaches the record before provider work, and `foldSubagentDescriptor()` validates the complete current-version payload before recovering it from a loaded child log. Every local session-backed start appends one descriptor with the provider name and lifecycle `mode`. A `one-shot` descriptor optionally carries the caller-owned durable display `label`; a `continuable` descriptor requires its durable creation label and additionally records resolved child `agentOptions.provider`/`model` and optional `persona`/`toolFilter` for cold resume. These are explicit fields, never the merge-extensible `AgentOptions` object, so an unrelated extension value cannot break continuation. The descriptor omits `subagentDepth` (the persisted header's `delegationDepth` is the monotone floor) and `outputSchema` (an Activation's result contract). The event is log-only: no `surfaceOp`, absent from model history, and retained by the append-only log across compaction. Malformed current-version payloads are corrupt; unsupported versions cannot be classified by this runtime.
## Delegation depth
@@ -89,7 +89,7 @@ Provider additions and removals also emit `subagent/provider-added` and `subagen
## 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. 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, no result promise, and no public subagent cancellation — a caller sends later work with the `send_message` follow-up tool, 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()` resolves session query and dynamically imports its optional runtime only when called, then interprets a read-only live-preferred scan of all descriptor-bearing direct children without consulting the continuation manager, Agent registrations, Activations, or providers. Service consumers such as a UI can retain both modes; the model-facing `list_agents` tool projects only `continuable` entries and maps service activity to its existing `running`/`complete` vocabulary. The scan forwards the caller's signal to cancellable trace and exact-read operations, checks cancellation around the remaining event-list read, and reports every observed abort as `SubagentError` code `CANCELLED`. 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, no result promise, and no public subagent cancellation — a caller sends later work with the `send_message` follow-up tool, 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()` resolves session query and dynamically imports its optional runtime only when called, then interprets a read-only live-preferred scan of all descriptor-bearing direct children without consulting the continuation manager, Agent registrations, Activations, or providers. 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 maps service activity to its existing `running`/`complete` vocabulary. The scan forwards the caller's signal to cancellable trace and exact-read operations, checks cancellation around the remaining event-list read, and reports every observed abort as `SubagentError` code `CANCELLED`. 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 require final 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.

View File

@@ -34,7 +34,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
| `drainContinuableDescendants(parents)` | 在由 host 确切拥有的在线 parent Agent 之下关闭准入,只停止其可见的可继续后代,等待在这些根之下已获准的物化过程完成发布或回滚,再按 child-first 顺序释放所选森林。该截止状态会持续到每个确切 parent 离开注册表;无关的 parent 森林和管理器全局准入保持在线。 |
| `listChildren(parentSessionId, signal?)` | 按稳定的追踪顺序列出由会话支撑的直接 subagent,包括其 `one-shot`/`continuable` 模式和 `running`/`inactive` 活动状态,以及逐 child diagnostic,且不会加载或恢复它们。要求会话查询;不要求 `ctx.agents` 或继续执行管理器。 |
`SubagentStartRequest.label` 是由会话支撑的 child 所使用的简短持久化显示标签。`signal` 是必填项,也是一次性 `start` 的规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消实时子 agent。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。对于可继续启动或后续操作,调用方信号只在 inbox 接受之前掌管查找、物化和准入;此后由管理器独立拥有 Activation,因此调用方后续取消既不会取消已接受的轮次,也不会 dispose 子 agent。
`SubagentStartRequest.label` 是由会话支撑的一次性 child 所使用的可选简短持久化显示标签。面向模型的委派会提供其已有的 `description`;底层调用方无需凭空构造展示元数据。可继续启动始终携带自身的必填标签。`signal` 是必填项,也是一次性 `start` 的规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消实时子 agent。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。对于可继续启动或后续操作,调用方信号只在 inbox 接受之前掌管查找、物化和准入;此后由管理器独立拥有 Activation,因此调用方后续取消既不会取消已接受的轮次,也不会 dispose 子 agent。
后续操作的权限来自子 agent 持久化 header 中记录的确切在线直接父级。冷恢复会在重建前检查该权限,并在最终无 await 的 inbox 准入区间再次检查,因此在物化期间被注销或替换的 parent 无法授权投递。后续操作上的 `source` 是保留在所投递消息上的持久化来源,不授予任何权限。
@@ -53,7 +53,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
## 持久化描述符
该 seam 拥有版本化的 `subagent/descriptor` 会话事件词汇(`src/descriptor.ts`):`snapshotSubagentDescriptor()` 会在提供方工作之前校验并分离记录,`foldSubagentDescriptor()` 则会在从已加载子 agent 日志中恢复描述符之前,校验当前版本的完整 payload。每次由本地会话支撑的启动都会追加一个描述符,其中包含持久化创建标签、提供方名称与生命周期 `mode`。`one-shot` 描述符到此为止;`continuable` 描述符还会记录已解析的子 agent `agentOptions.provider`/`model`,以及用于从持久化存储恢复的可选 `persona`/`toolFilter`。这些是显式字段,绝不是可通过合并扩展的 `AgentOptions` 对象,因此无关的扩展值不会破坏继续执行。描述符省略 `subagentDepth`(持久化 header 的 `delegationDepth` 是单调下界)和 `outputSchema`(单次激活的结果契约)。该事件只进入日志:不含 `surfaceOp`,不进入模型历史,并由仅追加日志跨压缩保留。格式错误的当前版本 payload 属于损坏;本运行时无法对不受支持的版本进行分类。
该 seam 拥有版本化的 `subagent/descriptor` 会话事件词汇(`src/descriptor.ts`):`snapshotSubagentDescriptor()` 会在提供方工作之前校验并分离记录,`foldSubagentDescriptor()` 则会在从已加载子 agent 日志中恢复描述符之前,校验当前版本的完整 payload。每次由本地会话支撑的启动都会追加一个带有提供方名称与生命周期 `mode` 的描述符。`one-shot` 描述符可以携带调用方拥有的可选持久化显示 `label`;`continuable` 描述符要求其持久化创建标签,并另外记录已解析的子 agent `agentOptions.provider`/`model`,以及用于从持久化存储恢复的可选 `persona`/`toolFilter`。这些是显式字段,绝不是可通过合并扩展的 `AgentOptions` 对象,因此无关的扩展值不会破坏继续执行。描述符省略 `subagentDepth`(持久化 header 的 `delegationDepth` 是单调下界)和 `outputSchema`(单次 Activation 的结果契约)。该事件只进入日志:不含 `surfaceOp`,不进入模型历史,并由仅追加日志跨压缩保留。格式错误的当前版本 payload 属于损坏;本运行时无法对不受支持的版本进行分类。
## 委派深度
@@ -89,7 +89,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
## 收集模型
面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task,其通用状态、收集和取消工具负责后续交互。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、没有结果 promise,也没有公开的子 agent 取消操作——调用方通过 `send_message` 后续操作工具发送后续工作,而持久化子 agent Session 仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 只在被调用时解析会话查询并动态导入其可选运行时,然后解释对所有带描述符的直接 child 所作的只读、实时优先扫描,且不查询继续执行管理器、Agent 注册信息、Activation 或提供方。UI 等服务消费方可以保留两种模式;面向模型的 `list_agents` 工具只投影 `continuable` 条目,并将服务活动状态映射到现有的 `running`/`complete` 词汇。扫描会把调用方的取消信号转发到可取消的追踪与精确读取操作,在其余事件列表读取的前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`。完整契约见[后台 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,也没有公开的子 agent 取消操作——调用方通过 `send_message` 后续操作工具发送后续工作,而持久化子 agent Session 仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 只在被调用时解析会话查询并动态导入其可选运行时,然后解释对所有带描述符的直接 child 所作的只读、实时优先扫描,且不查询继续执行管理器、Agent 注册信息、Activation 或提供方。UI 等服务消费方可以保留两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 工具只投影 `continuable` 条目,并将服务活动状态映射到现有的 `running`/`complete` 词汇。扫描会把调用方的取消信号转发到可取消的追踪与精确读取操作,在其余事件列表读取的前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`。完整契约见[后台 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 要求最终持久性确认。一次性运行保留尽力执行的会话检查点,因此已完成的一次性 child 只有在其会话确实进入持久化存储时,才可在 dispose 后继续被发现;如果该检查点缺失,服务不会根据 Task 历史虚构目录条目。

View File

@@ -49,22 +49,24 @@ interface SubagentDescriptorBase {
readonly mode: 'one-shot' | 'continuable'
/** The `ctx.subagents` provider name that established the child. */
readonly provider: string
/**
* The initial delegation's short `description`, kept as the child's durable
* creation label so enumeration can identify the conversation without
* replaying parent tool results or exposing the child prompt.
*/
readonly label: string
}
/** A session-backed subagent that cannot be cold-resumed after its run. */
export interface OneShotSubagentDescriptorData extends SubagentDescriptorBase {
readonly mode: 'one-shot'
/**
* The initial delegation's short `description`, kept as the child's durable
* creation label so enumeration can identify the conversation without
* replaying parent tool results or exposing the child prompt.
*/
readonly label?: string
}
/** A session-backed subagent whose declared composition supports cold resume. */
export interface ContinuableSubagentDescriptorData extends SubagentDescriptorBase {
readonly mode: 'continuable'
/** The initial delegation's short `description`, used for durable enumeration. */
readonly label: string
/** Resolved child `agentOptions.provider`, when one was declared. */
readonly agentProvider?: string
/** Resolved child `agentOptions.model`, when one was declared. */
@@ -86,18 +88,20 @@ interface SubagentDescriptorInputBase {
readonly mode: 'one-shot' | 'continuable'
/** The `ctx.subagents` provider name that will establish the child. */
readonly provider: string
/** The initial delegation's short `description`, the durable creation label. */
readonly label: string
}
/** Input for a one-shot child's durable identity. */
export interface OneShotSubagentDescriptorInput extends SubagentDescriptorInputBase {
readonly mode: 'one-shot'
/** Optional initial delegation `description` used as the durable creation label. */
readonly label?: string
}
/** Input for a continuable child's durable identity and resumable composition. */
export interface ContinuableSubagentDescriptorInput extends SubagentDescriptorInputBase {
readonly mode: 'continuable'
/** Initial delegation `description` used for durable enumeration. */
readonly label: string
/** Requested child `agentOptions.provider`. */
readonly agentProvider?: string
/** Requested child `agentOptions.model`. */
@@ -207,18 +211,19 @@ function parseSubagentDescriptor(value: unknown): SubagentDescriptorData | undef
if (typeof provider !== 'string') {
throw new Error('persisted subagent descriptor provider must be a string')
}
const label = value['label']
if (typeof label !== 'string') {
throw new Error('persisted subagent descriptor label must be a string')
}
if (mode === 'one-shot') {
const label = optionalString(value, 'label')
return {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode,
provider,
label,
...label !== undefined ? { label } : {},
}
}
const label = value['label']
if (typeof label !== 'string') {
throw new Error('persisted subagent descriptor label must be a string')
}
const agentProvider = optionalString(value, 'agentProvider')
const agentModel = optionalString(value, 'agentModel')
const persona = optionalString(value, 'persona')
@@ -259,20 +264,23 @@ export function snapshotSubagentDescriptor(
input: ContinuableSubagentDescriptorInput,
): ContinuableSubagentDescriptorData
export function snapshotSubagentDescriptor(input: SubagentDescriptorInput): SubagentDescriptorData {
const candidate: SubagentDescriptorData = {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: input.mode,
provider: input.provider,
label: input.label,
...input.mode === 'continuable'
? {
...input.agentProvider !== undefined ? { agentProvider: input.agentProvider } : {},
...input.agentModel !== undefined ? { agentModel: input.agentModel } : {},
...input.persona !== undefined ? { persona: input.persona } : {},
...input.toolFilter !== undefined ? { toolFilter: input.toolFilter } : {},
}
: {},
}
const candidate: SubagentDescriptorData = input.mode === 'one-shot'
? {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: input.mode,
provider: input.provider,
...input.label !== undefined ? { label: input.label } : {},
}
: {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: input.mode,
provider: input.provider,
label: input.label,
...input.agentProvider !== undefined ? { agentProvider: input.agentProvider } : {},
...input.agentModel !== undefined ? { agentModel: input.agentModel } : {},
...input.persona !== undefined ? { persona: input.persona } : {},
...input.toolFilter !== undefined ? { toolFilter: input.toolFilter } : {},
}
const snapshot = snapshotJsonValue(candidate)
if (snapshot === undefined) {
throw new Error('subagent descriptor is not losslessly JSON-serializable')

View File

@@ -314,7 +314,7 @@ export class SubagentService extends Service {
const descriptor = snapshotSubagentDescriptor({
mode: 'one-shot',
provider: name,
label: request.label,
...request.label !== undefined ? { label: request.label } : {},
})
const resolved: ResolvedSubagentStartRequest = { ...request, descriptor }
return observeRun(this.emitLifecycle, name, request.parent, await provider.start(resolved))

View File

@@ -30,10 +30,6 @@ export type SubagentListEntry =
readonly kind: 'child'
/** The durable child session id, stable across Activations. */
readonly id: SessionId
/** The durable creation label from the child's descriptor. */
readonly label: string
/** Lifecycle policy declared when the child was created. */
readonly mode: 'one-shot' | 'continuable'
/**
* Corpus snapshot activity: `running` means the logical record is live in
* `ctx.sessions`; `inactive` means it exists only in persistence. Neither
@@ -41,7 +37,20 @@ export type SubagentListEntry =
* delivery as an ownership conflict.
*/
readonly activity: 'running' | 'inactive'
}
} & (
| {
/** A terminal one-shot child. */
readonly mode: 'one-shot'
/** Optional durable creation label from the child's descriptor. */
readonly label?: string
}
| {
/** A resumable conversation. */
readonly mode: 'continuable'
/** Durable creation label from the child's descriptor. */
readonly label: string
}
)
| {
readonly kind: 'diagnostic'
/** The traced candidate's session id. */
@@ -139,13 +148,17 @@ async function inspectChild(
if (descriptor === undefined) {
return { kind: 'diagnostic', id: childId, reason: 'unsupported' }
}
return {
kind: 'child',
id: childId,
label: descriptor.label,
mode: descriptor.mode,
activity: candidate.live ? 'running' : 'inactive',
const activity = candidate.live ? 'running' : 'inactive'
if (descriptor.mode === 'one-shot') {
return {
kind: 'child',
id: childId,
mode: descriptor.mode,
...descriptor.label !== undefined ? { label: descriptor.label } : {},
activity,
}
}
return { kind: 'child', id: childId, mode: descriptor.mode, label: descriptor.label, activity }
} catch (error: unknown) {
const reason = perChildDiagnosticReason(error, queryRuntime.SessionQueryError)
if (reason === undefined) throw error

View File

@@ -93,8 +93,8 @@ export interface SubagentCapabilities {
* {@link SubagentProvider.start}.
*/
export interface SubagentStartRequest {
/** Short display label persisted with a session-backed child. */
readonly label: string
/** Optional short display label persisted with a session-backed child. */
readonly label?: string
/** Content delivered as the child's user message. */
readonly prompt: ContentBlock[]
/**

View File

@@ -144,7 +144,6 @@ describe('SubagentService.listChildren', () => {
it('lists one-shot and continuable children from the same trace', async () => {
const { ctx, parent } = await setup([textResponse('once'), textResponse('again')])
const oneShot = await ctx.subagents.start('spawn', {
label: 'one-shot child',
prompt: [{ type: 'text', text: 'finish once' }],
parent,
signal: new AbortController().signal,
@@ -159,7 +158,6 @@ describe('SubagentService.listChildren', () => {
expect(entries).toContainEqual({
kind: 'child',
id: oneShotId,
label: 'one-shot child',
mode: 'one-shot',
activity: 'inactive',
})

View File

@@ -28,7 +28,6 @@ const NO_CAPS: SubagentCapabilities = { outputSchema: false, depthLimit: false,
function baseRequest(overrides: Partial<SubagentStartRequest> = {}): SubagentStartRequest {
return {
label: 'do a thing',
prompt: [{ type: 'text', text: 'do a thing' }],
parent: fakeParent(),
signal: new AbortController().signal,
@@ -119,7 +118,6 @@ describe('SubagentService', () => {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'one-shot',
provider: 'one-shot',
label: 'do a thing',
},
})
expect(provider.lastRequest).not.toBe(request)
@@ -308,14 +306,18 @@ describe('subagent descriptors', () => {
it('omits absent fields, recovers a complete payload, and rejects unsupported versions', () => {
expect(foldSubagentDescriptor([])).toBeUndefined()
const minimal = snapshotSubagentDescriptor({ mode: 'one-shot', provider: 'spawn', label: 'child work' })
const minimal = snapshotSubagentDescriptor({ mode: 'one-shot', provider: 'spawn' })
expect(minimal).toEqual({
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'one-shot',
provider: 'spawn',
label: 'child work',
})
expect(foldSubagentDescriptor([event(minimal)])).toEqual(minimal)
expect(snapshotSubagentDescriptor({
mode: 'one-shot',
provider: 'spawn',
label: 'child work',
})).toEqual({ ...minimal, label: 'child work' })
const complete = {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable' as const,
@@ -380,6 +382,12 @@ describe('subagent descriptors', () => {
label: 'l',
persona: 'reviewer',
}, 'payload has unknown field "persona"'],
['invalid one-shot label', {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'one-shot',
provider: 'spawn',
label: 7,
}, 'label must be a string'],
['unknown payload field', {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',

View File

@@ -12,7 +12,6 @@ function fakeParent(id = 'parent-1'): Agent {
function baseRequest(over: Partial<SubagentStartRequest> = {}): SubagentStartRequest {
return {
label: 'task',
prompt: [{ type: 'text', text: 'task' }],
parent: fakeParent(),
signal: new AbortController().signal,

View File

@@ -91,7 +91,6 @@ async function settleSubagent(
})
try {
const run = await ctx.subagents.start(info.provider, {
label: 'synthetic child',
parent,
prompt: [],
signal: new AbortController().signal,
@@ -557,13 +556,11 @@ describe('HarnessSdkServer', () => {
})
const firstRun = await ctx.subagents.start('reused', {
label: 'first reused child',
parent: oldParent.agent,
prompt: [],
signal: new AbortController().signal,
})
const sameLifetimeRun = await ctx.subagents.start('reused', {
label: 'same lifetime child',
parent: oldParent.agent,
prompt: [],
signal: new AbortController().signal,
@@ -583,7 +580,6 @@ describe('HarnessSdkServer', () => {
})
currentLocalAgent = newChild.agent
const secondRun = await ctx.subagents.start('reused', {
label: 'second reused child',
parent: newParent.agent,
prompt: [],
signal: new AbortController().signal,
@@ -654,7 +650,6 @@ describe('HarnessSdkServer', () => {
}),
})
const localRun = await ctx.subagents.start('reused-provider', {
label: 'local reused child',
parent: parent.agent,
prompt: [],
signal: new AbortController().signal,
@@ -673,7 +668,6 @@ describe('HarnessSdkServer', () => {
}),
})
const remoteRun = await ctx.subagents.start('reused-provider', {
label: 'remote reused child',
parent: parent.agent,
prompt: [],
signal: new AbortController().signal,
@@ -756,7 +750,6 @@ describe('HarnessSdkServer', () => {
// Start before the server subscribes. The terminal payload still carries
// this run's exact local child without reconstructing it from ids.
const missedStartRun = await ctx.subagents.start('fork', {
label: 'missed start child',
parent: parentHandle.agent,
prompt: [],
signal: new AbortController().signal,

View File

@@ -322,7 +322,6 @@ export class WorkerRun implements WorkflowRun {
let run: SubagentRun
try {
run = await this.subagents.start(this.provider, {
label: request.label,
prompt: [{ type: 'text', text: request.prompt }],
parent: this.parent,
signal: this.controller.signal,

View File

@@ -275,7 +275,6 @@ export class WorkflowExecution {
let run: ChildHandle
try {
run = await this.children.startAgent({
label,
prompt: rawPrompt,
...opts.schema !== undefined ? { schema: opts.schema } : {},
...opts.provider !== undefined ? { provider: opts.provider } : {},

View File

@@ -38,8 +38,6 @@ export interface WorkerInit {
/** What the worker asks the host to start for one `agent()` call (options already validated worker-side). */
export interface ChildStartRequest {
/** Short display label resolved by the worker runtime. */
label: string
/** The child's prompt text. */
prompt: string
/** The structured-output schema, if the call passed one (already subset-checked). */

View File

@@ -420,13 +420,10 @@ describe('runWorkerSession over an in-process MessageChannel', () => {
`))
await host.result()
const starts = host.ofType(WorkerToHostType.AgentStart).map(m => m.info)
const requests = host.ofType(WorkerToHostType.ChildStart).map(m => m.request)
expect(starts[0]).toMatchObject({ seq: 1, phase: 'Find' })
expect(starts[0]!.label.length).toBeLessThanOrEqual(48)
expect(starts[0]!.label).not.toContain('second line')
expect(starts[1]).toMatchObject({ seq: 2, label: 'named', phase: 'Custom' })
expect(requests[0]!.label).toBe(starts[0]!.label)
expect(requests[1]!.label).toBe('named')
host.close()
})

View File

@@ -211,7 +211,7 @@ describe('dsh-workflow-workerthread', () => {
reply: () => ({ output: [], structured: { files: ['x.ts', 'y.ts'] }, stopReason: 'completed' }),
})
const result = await run(ctx, parent, scripted(`
const found = await agent('list files', { label: 'inventory', model: 'deepseek-v4-pro', schema: { type: 'object', properties: { files: { type: 'array', items: { type: 'string' } } }, required: ['files'] } })
const found = await agent('list files', { model: 'deepseek-v4-pro', schema: { type: 'object', properties: { files: { type: 'array', items: { type: 'string' } } }, required: ['files'] } })
return { first: found.files[0], count: found.files.length }
`))
expect(result.value).toEqual({ first: 'x.ts', count: 2 })
@@ -221,7 +221,6 @@ describe('dsh-workflow-workerthread', () => {
required: ['files'],
})
expect(provider.runs[0]!.request.agentOptions).toEqual({ model: 'deepseek-v4-pro' })
expect(provider.runs[0]!.request.label).toBe('inventory')
expect(provider.runs[0]!.request.parent).toBeDefined()
})