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:
@@ -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 })
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1,46 +1,95 @@
|
||||
/**
|
||||
* The globally named `list_agents` tool: a thin model-facing adapter over
|
||||
* the continuable projection of `ctx.subagents.listChildren()`. It stays
|
||||
* separately loadable from the root `send_message` plugin so a deployment
|
||||
* can register `send_message` without exposing the list tool.
|
||||
* the continuable projection of `ctx.subagents.listChildren()` and, for the
|
||||
* `descendants` scope, `ctx.subagents.listDescendants()`. It stays separately
|
||||
* loadable from the root `send_message` plugin so a deployment can register
|
||||
* continuation delivery without exposing discovery.
|
||||
* @module @deepseek-ai/dsh-tool-subagent-control/list-agents
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type {} from '@deepseek-ai/dsh-subagent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SubagentDescendantListEntry, SubagentListEntry } from '@deepseek-ai/dsh-subagent'
|
||||
|
||||
export const name = 'tool-subagent-list-agents'
|
||||
export const inject = ['tools', 'subagents']
|
||||
export const inject = ['tools', 'subagents', 'agents']
|
||||
|
||||
type ListAgentsEntry =
|
||||
| {
|
||||
readonly kind: 'child'
|
||||
readonly id: string
|
||||
readonly label: string
|
||||
readonly status: 'running' | 'complete'
|
||||
readonly status: 'running' | 'idle' | 'complete'
|
||||
readonly parent?: string
|
||||
readonly depth?: number
|
||||
}
|
||||
| {
|
||||
readonly kind: 'diagnostic'
|
||||
readonly id: string
|
||||
readonly reason: 'corrupt' | 'unsupported' | 'unavailable'
|
||||
readonly parent?: string
|
||||
readonly depth?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Refine one candidate's status through the live Agent registry: `running`
|
||||
* for an active driver, `idle` for a resident Agent between turns (possibly
|
||||
* waiting on agents it started), and `complete` when no live Agent remains.
|
||||
*/
|
||||
function statusOf(agents: { get(id: SessionId): Agent | undefined }, id: SessionId): 'running' | 'idle' | 'complete' {
|
||||
const agent = agents.get(id)
|
||||
if (agent === undefined) return 'complete'
|
||||
return agent.status === 'running' ? 'running' : 'idle'
|
||||
}
|
||||
|
||||
/** Project one service row into the model-facing entry, or omit a one-shot child. */
|
||||
function project(
|
||||
agents: { get(id: SessionId): Agent | undefined },
|
||||
entry: SubagentListEntry,
|
||||
position?: Pick<SubagentDescendantListEntry, 'parentId' | 'depth'>,
|
||||
): ListAgentsEntry | undefined {
|
||||
const at = position === undefined ? {} : { parent: position.parentId as string, depth: position.depth }
|
||||
if (entry.kind === 'diagnostic') {
|
||||
return { kind: 'diagnostic', id: entry.id, reason: entry.reason, ...at }
|
||||
}
|
||||
// One-shot children cannot be continued by send_message, so the model
|
||||
// never selects them; discovery still traversed them for descendants.
|
||||
if (entry.mode !== 'continuable') return undefined
|
||||
return {
|
||||
kind: 'child',
|
||||
id: entry.id,
|
||||
label: entry.label,
|
||||
status: statusOf(agents, entry.id),
|
||||
...at,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the `list_agents` tool.
|
||||
* @param ctx - context carrying the tool registry and subagent service.
|
||||
* @param ctx - context carrying the tool registry, subagent service, and live Agent registry.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'list_agents',
|
||||
description:
|
||||
'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 '
|
||||
+ 'may still fail. Children that could not be read are reported as diagnostics instead of being '
|
||||
+ 'silently dropped.',
|
||||
parameters: {},
|
||||
'List your continuable background subagents by durable id and label. Status comes from the live '
|
||||
+ 'registry: running means the agent is working right now, idle means it is loaded but between turns '
|
||||
+ '(it may be waiting on agents it started), and complete means it exists only in storage — a '
|
||||
+ 'direct child remains a `send_message` candidate in every status. The snapshot is not a delivery '
|
||||
+ 'promise — `send_message` performs the authoritative check and may still fail. Children that could '
|
||||
+ 'not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` '
|
||||
+ 'walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent '
|
||||
+ 'session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are '
|
||||
+ 'candidates for `interrupt_agent` only.',
|
||||
parameters: {
|
||||
scope: {
|
||||
type: 'string',
|
||||
enum: ['children', 'descendants'],
|
||||
description: 'children (default) lists direct children only; descendants walks the complete tree below you.',
|
||||
},
|
||||
},
|
||||
output: {
|
||||
schema: {
|
||||
type: 'array',
|
||||
@@ -53,7 +102,9 @@ export function apply(ctx: Context): void {
|
||||
kind: { type: 'string', required: true, enum: ['child'] },
|
||||
id: { type: 'string', required: true },
|
||||
label: { type: 'string', required: true },
|
||||
status: { type: 'string', required: true, enum: ['running', 'complete'] },
|
||||
status: { type: 'string', required: true, enum: ['running', 'idle', 'complete'] },
|
||||
parent: { type: 'string' },
|
||||
depth: { type: 'number' },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -63,21 +114,31 @@ export function apply(ctx: Context): void {
|
||||
kind: { type: 'string', required: true, enum: ['diagnostic'] },
|
||||
id: { type: 'string', required: true },
|
||||
reason: { type: 'string', required: true, enum: ['corrupt', 'unsupported', 'unavailable'] },
|
||||
parent: { type: 'string' },
|
||||
depth: { type: 'number' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
render: (_args, entries) => [{
|
||||
render: (args, entries) => [{
|
||||
type: 'text',
|
||||
text: entries.length === 0
|
||||
? '(no subagents)'
|
||||
: entries.map(entry => entry.kind === 'child'
|
||||
? `${entry.id} [${entry.status}] — ${entry.label}`
|
||||
: `${entry.id} [diagnostic: ${entry.reason}]`).join('\n'),
|
||||
: entries.map((entry) => {
|
||||
// A descendants row always carries its position; children rows
|
||||
// never render it. String() spans the schema-optional shape
|
||||
// without a dead fallback branch.
|
||||
const at = args.scope === 'descendants'
|
||||
? ` parent=${String(entry.parent)} depth=${String(entry.depth)}`
|
||||
: ''
|
||||
return entry.kind === 'child'
|
||||
? `${entry.id} [${entry.status}]${at} — ${entry.label}`
|
||||
: `${entry.id} [diagnostic: ${entry.reason}]${at}`
|
||||
}).join('\n'),
|
||||
}],
|
||||
},
|
||||
async execute(_args, exec) {
|
||||
async execute(args, exec) {
|
||||
const parent = exec.agent
|
||||
if (!parent) {
|
||||
// Non-agent callers have no session whose children could be listed.
|
||||
@@ -85,21 +146,16 @@ 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.
|
||||
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',
|
||||
})
|
||||
}
|
||||
if (args.scope === 'descendants') {
|
||||
const entries = await ctx.subagents.listDescendants(parent.id, exec.signal)
|
||||
return entries
|
||||
.map(entry => project(ctx.agents, entry, entry))
|
||||
.filter(entry => entry !== undefined)
|
||||
}
|
||||
return visible
|
||||
const entries = await ctx.subagents.listChildren(parent.id, exec.signal)
|
||||
return entries
|
||||
.map(entry => project(ctx.agents, entry))
|
||||
.filter(entry => entry !== undefined)
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user