fix(subagent): address background-first review

This commit is contained in:
Dudu-0223
2026-08-11 21:52:53 +08:00
parent 98d4551597
commit c778b5b0db
56 changed files with 173 additions and 146 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/README.md
README.md: 40ece8bbd6f0f8929b3cb112c0b9b0de20299b8b
README.zh.md: 3f19740ef7abe961d6528e24a5063b3975fc1452
README.md: cc872f3decc4c85f26bd997293c8dc4140eb221c
README.zh.md: f7616dc677d10c141b1be606dae36d7dc7bb23ac

View File

@@ -37,7 +37,7 @@ Foreground and background calls are concurrency-safe: sibling delegations in one
#### What the model sees
The generated default [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent) under this instance's configured name while its provider exists. Provider context inheritance changes the tool and prompt descriptions. Enabled background mode adds `run_in_background`: continuable mode documents its `true` default, runtime settlement notice, and explicit foreground override, while one-shot mode documents its `false` default and the task id collected with `task_output` or stopped with `task_kill`. A `tool:<toolName>` system-prompt section tells the model to start independent continuable delegations together, keep working while they run, and choose foreground only when its next action depends on the result.
The generated default [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent) under this instance's configured name while its provider exists. Provider context inheritance changes the tool and prompt descriptions. Enabled background mode adds `run_in_background`: continuable mode documents its `true` default, runtime settlement notice, and explicit foreground override, while one-shot mode documents its `false` default and the task id collected with `task_output` or stopped with `task_kill`. While the tool is visible in an assembly's scope, a `tool:<toolName>` system-prompt section tells the model to start independent continuable delegations together, keep working while they run, and choose foreground only when its next action depends on the result; a tool restriction removes both its schema and this guidance.
#### Token effect
@@ -78,5 +78,5 @@ Append-only; newly visible content follows the reusable request prefix and does
## Known Limitations and Deferred Work
- **Background runs expose no result through this tool** — a one-shot task's final output is collected through the generic task surface, and a continuable child's output stays in its own session, read by its subagent id. The settlement notice states how that child ended and carries any final assistant message, but it is not this call's return value and cannot be awaited here.
- **Duplicate names across waiting instances are detected late** (`TODO(subagent-dup-toolname)`) — preventing provider-registration rollback requires a registry of intended names.
- **Duplicate names across waiting one-shot instances are detected late** (`TODO(subagent-dup-toolname)`) — continuable instances reserve their prompt-section name during plugin application, but preventing provider-registration rollback for waiting one-shot instances requires a registry of intended names.
- **Child policy is fixed per instance** — another model, persona, tool filter, or depth cap requires another distinctly named tool.

View File

@@ -37,7 +37,7 @@
#### 模型看到的内容
当提供方存在时,以当前实例配置的名称公开已生成的默认 [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent)。提供方是否继承上下文会改变工具描述和提示词描述。启用后台模式会添加 `run_in_background`:可继续模式会记录其默认值为 `true`、运行时结算通知与显式前台覆盖;一次性模式会记录其默认值为 `false`,以及用 `task_output` 收集或用 `task_kill` 停止的 task id。一个 `tool:<toolName>` 系统提示词 section 会指示模型同时启动相互独立的可继续委派、在它们运行时继续工作,并且仅当下一步动作依赖结果时选择前台。
当提供方存在时,以当前实例配置的名称公开已生成的默认 [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent)。提供方是否继承上下文会改变工具描述和提示词描述。启用后台模式会添加 `run_in_background`:可继续模式会记录其默认值为 `true`、运行时结算通知与显式前台覆盖;一次性模式会记录其默认值为 `false`,以及用 `task_output` 收集或用 `task_kill` 停止的 task id。当工具在本次组装的作用域中可见时,一个 `tool:<toolName>` 系统提示词 section 会指示模型同时启动相互独立的可继续委派、在它们运行时继续工作,并且仅当下一步动作依赖结果时选择前台;工具限制会同时移除其 schema 和这段指引
#### Token 影响
@@ -78,5 +78,5 @@
## 已知限制与暂缓事项
- **后台运行不通过本工具公开结果**:一次性任务的最终输出通过通用 Task 接口收集,可继续子 agent 的输出留在其自身会话中,按其 subagent id 读取。结算通知会说明该子 agent 如何结束,并携带可能存在的最终 assistant 消息,但它不是本次调用的返回值,也无法在此等待。
- **等待中实例的重复名称发现较晚**`TODO(subagent-dup-toolname)`若要阻止提供方注册回滚,需要一份预期名称注册表。
- **等待中的一次性实例较晚才发现重复名称**`TODO(subagent-dup-toolname)`可继续实例会在插件应用期间预留提示词 section 名称,但若要阻止等待中的一次性实例回滚提供方注册,需要一份预期名称注册表。
- **每个实例的子 agent 策略固定**其他模型、persona、工具过滤器或深度上限都需要另一个名称不同的工具。

View File

@@ -23,7 +23,7 @@ export const name = 'tool-subagent'
export const inject = ['tools', 'subagents', 'systemPrompt']
/** Prompt order after bounded delegation policy and before child reporting. */
const SUBAGENT_SECTION_ORDER = 116
const SUBAGENT_SECTION_ORDER = 116.5
/** Config: which registered provider this tool delegates to, plus child defaults. */
export interface Config {
@@ -301,7 +301,7 @@ export function apply(ctx: Context, config: Config): void {
// a separately installed capability, so this promise holds whenever the
// continuable background path is reachable at all.
? continuable
? ' This tool uses its continuable background route by default and immediately returns a durable subagent id. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` to use a foreground run only when you must receive its result before taking your next action.'
? ' This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.'
: ' This call waits for the result by default. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.'
: ' This call waits for the subagent and returns its result.'),
parameters: {
@@ -319,7 +319,7 @@ export function apply(ctx: Context, config: Config): void {
run_in_background: {
type: 'boolean' as const,
description: continuable
? 'Whether to use the continuable background route. Defaults to true. Set false to run in the foreground and wait only when your next action depends on its result.'
? 'Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it.'
: 'Whether to run as a background task and return its id. Defaults to false; collect with task_output or stop with task_kill.',
},
} : {},
@@ -432,9 +432,11 @@ export function apply(ctx: Context, config: Config): void {
}
// Register listeners before checking presence so no synchronous change is missed.
// TODO(subagent-dup-toolname): two WAITING fibers configured with the same
// toolName collide when their provider appears, and the duplicate-name throw
// rolls back the provider registration. Add an intent registry if this occurs.
// TODO(subagent-dup-toolname): two waiting one-shot fibers configured with the
// same toolName collide when their provider appears, and the duplicate-name
// throw rolls back the provider registration. Continuable instances reserve
// their prompt-section name during apply() and fail earlier. Add an intent
// registry if the late one-shot collision occurs in a shipped composition.
ctx.on('subagent/provider-added', (provider) => {
if (provider.name === config.provider && disposeTool === undefined) mount(provider)
})
@@ -457,9 +459,9 @@ export function apply(ctx: Context, config: Config): void {
ctx.systemPrompt.section({
name: `tool:${toolName}`,
order: SUBAGENT_SECTION_ORDER,
text: () => ctx.subagents.getProvider(config.provider) === undefined
text: context => disposeTool === undefined || ctx.tools.get(toolName, context.scope) === undefined
? ''
: `Use ${toolName}'s continuable background route by default. Start independent delegations together in one assistant message and continue useful work while they run. Set \`run_in_background: false\` only when you cannot take your next action without that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.`,
: `Use ${toolName} in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set \`run_in_background: false\` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.`,
})
}
}

View File

@@ -354,13 +354,26 @@ describe('dsh-tool-subagent', () => {
await ctx.plugin(ToolRegistry)
await ctx.plugin(SubagentService)
// Arm 1: a mounted tool dies with its plugin fiber; the provider survives.
await mock.mountScriptedProvider(ctx, { name: 'mock' })
const mounted = await ctx.plugin(tool, { provider: 'mock' })
// Arm 1: a mounted tool and its prompt section die with the plugin fiber;
// the provider survives.
ctx.subagents.registerProvider({
name: 'continuable',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: async () => { throw new Error('lifecycle test does not start a child') },
prepareContinuable: async () => ({}),
})
const mounted = await ctx.plugin(tool, {
provider: 'continuable',
backgroundMode: 'continuable',
maxDepth: 'provider-managed',
})
expect(ctx.tools.schemas().some(s => s.name === 'subagent')).toBe(true)
expect((await ctx.systemPrompt.assemble()).sections.some(s => s.name === 'tool:subagent')).toBe(true)
await mounted.dispose()
expect(ctx.tools.schemas().some(s => s.name === 'subagent')).toBe(false)
expect(ctx.subagents.getProvider('mock')).toBeDefined()
expect((await ctx.systemPrompt.assemble()).sections.some(s => s.name === 'tool:subagent')).toBe(false)
expect(ctx.subagents.getProvider('continuable')).toBeDefined()
// Arm 2: a fiber disposed while WAITING must not react to the provider
// arriving later — a surviving listener would re-register a tool that no
@@ -1012,16 +1025,16 @@ describe('dsh-tool-subagent continuable background mode', () => {
expect(schema.description).not.toContain('task_output')
expect(schema.description).not.toContain('task_kill')
expect(schema.description).toContain('send_message')
expect(schema.description).toContain('continuable background route by default')
expect(schema.description).toContain('runs in the background by default')
expect(schema.description).not.toContain('never poll or wait on it')
const properties = (schema.parameters as {
properties: Record<string, { description?: string }>
}).properties
expect(properties.run_in_background?.description).toContain('Defaults to true')
const assembly = await ctx.systemPrompt.assemble(assembleContextFor(parent))
const guidance = assembly.sections.find(section => section.name === 'tool:subagent')
expect(guidance?.text).toContain("Use subagent's continuable background route by default")
expect(guidance?.text).toContain('Use subagent in the background by default')
expect(guidance?.text).toContain('runtime sends you a notice containing its outcome')
expect(guidance?.text).not.toContain('do not poll')
const started = await callSubagent(
ctx,
@@ -1044,6 +1057,15 @@ describe('dsh-tool-subagent continuable background mode', () => {
expect(loaded.events.some(event => event.type === 'assistant/message')).toBe(true)
})
it('hides continuable guidance when the current agent cannot see the tool', async () => {
const { ctx, parent } = await continuableSetup()
parent.ctx.tools.restrict({ deny: ['subagent'] })
expect(ctx.tools.get('subagent', parent)).toBeUndefined()
const assembly = await ctx.systemPrompt.assemble(assembleContextFor(parent))
expect(assembly.sections.find(section => section.name === 'tool:subagent')?.text).toBe('')
})
it('waits for a continuable provider only when run_in_background is explicitly false', async () => {
const { ctx, parent } = await continuableSetup()
const result = await callSubagent(

View File

@@ -26,6 +26,9 @@
{
"path": "../../core/tools"
},
{
"path": "../../core/system-prompt"
},
{
"path": "../subagent"
},