feat(subagent): let ancestors interrupt descendants

interrupt_agent(agent_id) passes the calling agent as the ancestor
authority for ctx.subagents.interrupt(); the service verifies live
registry identity and recorded lineage, so a direct child or deeper
descendant stops with the same generic parameter while send_message keeps
its exact-direct-parent authority.

Discovery: list_agents gains an optional scope. descendants walks the new
SubagentService.listDescendants() — one lineage trace flattened in stable
pre-order across ordinary and one-shot intermediates, each entry carrying
its verified parentId and depth — and every status now comes from the
live Agent registry (running/idle/complete).

Refs #1535
This commit is contained in:
Hypatia May
2026-08-06 13:46:55 +08:00
committed by Tianyi Cui
parent 4b29f9ca7a
commit d769d3cbb7
41 changed files with 1325 additions and 162 deletions
@@ -1,9 +1,11 @@
/**
* The globally named `send_message` tool: a thin model-facing adapter over
* `ctx.subagents.followup()`. It performs no lifecycle routing of its own —
* residency and cold resume belong to the subagent service — and it lives apart
* from the provider-bound `@deepseek-ai/dsh-tool-subagent` instances so multiple
* delegation tools share one control tool.
* The globally named `send_message` and `interrupt_agent` tools: thin
* model-facing adapters over `ctx.subagents.followup()` and
* `ctx.subagents.interrupt()`. They perform no lifecycle routing of their own —
* residency, cold resume, and interrupt authorization belong to the subagent
* service — and they live apart from the provider-bound
* `@deepseek-ai/dsh-tool-subagent` instances so multiple delegation tools share
* one control surface.
* @module @deepseek-ai/dsh-tool-subagent-control
*/
@@ -17,7 +19,7 @@ export const name = 'tool-subagent-control'
export const inject = ['tools', 'subagents']
/**
* Register the `send_message` tool.
* Register the `send_message` and `interrupt_agent` tools.
* @param ctx - context carrying the tool registry and subagent service.
*/
export function apply(ctx: Context): void {
@@ -73,4 +75,46 @@ export function apply(ctx: Context): void {
return { messageId }
},
}))
ctx.tools.register(defineTool({
name: 'interrupt_agent',
description:
'Request cancellation of a background agent\'s current turn by its agent id. The target may be your '
+ 'direct child or a deeper agent created under you. Only the current turn stops: messages already '
+ 'queued for the agent stay parked until a later send_message, agents it started keep running, and '
+ 'the agent itself stays available for follow-ups. This call returns as soon as the stop request is '
+ 'accepted, so the target may keep running briefly; interrupting an agent that already finished is '
+ 'an accepted no-op.',
parameters: {
agent_id: {
type: 'string',
required: true,
description: 'The agent id of the running agent to interrupt.',
},
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
accepted: { type: 'boolean', required: true },
},
},
render: (args, _value) => [{
type: 'text',
text: `interrupt requested for agent ${args.agent_id}`,
}],
},
execute(args, exec) {
const caller = exec.agent
if (!caller) {
// Ancestor authority requires an exact live calling agent.
throw new Error('interrupt_agent requires a calling agent (exec.agent was undefined)')
}
// The service authorizes the exact live caller against the target's
// recorded lineage; the tool adds no authority of its own.
ctx.subagents.interrupt(SessionId(args.agent_id), { kind: 'ancestor', agent: caller })
return Promise.resolve({ accepted: true })
},
}))
}