- Make host-user authority unforgeable. `{ kind: 'user' }` was a bare
discriminant, so any plugin holding `ctx.subagents` — including
model-generated cordis_mount code, which the advanced ACP composition ships
alongside continuable subagents — could construct it and skip the
direct-parent check for any known child id. It now carries an opaque grant
that only SubagentService.userAuthority() mints, which composition hands to
trusted host adapters; a model-facing tool uses parent authority from its own
execution context.
- Reconcile a delivery discarded inside its own admission window. An enqueue
listener that cancels fires the discard before followup() returns, so the
discard listener could not clear an id it had not seen; submit() retained it
and residency stayed `running` until an explicit drain.
- Recheck the caller signal after materialization. An abort landing between
publication and inbox acceptance still submitted the prompt and returned both
ids; it now rolls the child back.
- Stop promising the model transcript access that no shipped continuable config
mounts. The tools now state only that a background child does not report back.
- Restate the implemented note as shipped state rather than a proposal, so it
works as current authority.
379 lines
17 KiB
TypeScript
379 lines
17 KiB
TypeScript
/**
|
|
* Model-facing delegation through one configured `ctx.subagents` provider.
|
|
* Provider lifecycle controls tool registration and context-sensitive schema
|
|
* wording. Foreground calls always dispose the run after collection.
|
|
* Background policy is selected by this plugin's configuration: one-shot
|
|
* calls own a plain Task, while continuable calls use
|
|
* `ctx.subagents.startContinuable()`.
|
|
* @module @deepseek-ai/dsh-tool-subagent
|
|
*/
|
|
|
|
import type { Context } from 'cordis'
|
|
import z from 'schemastery'
|
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
import type { AgentOptions } from '@deepseek-ai/dsh-agent'
|
|
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
|
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
|
import { assertSubagentMaxDepth, settleRun } from '@deepseek-ai/dsh-subagent'
|
|
import type { SubagentProvider, SubagentResult, SubagentRun } from '@deepseek-ai/dsh-subagent'
|
|
import type { TaskOutcome } from '@deepseek-ai/dsh-tasks'
|
|
|
|
export const name = 'tool-subagent'
|
|
export const inject = ['tools', 'subagents']
|
|
|
|
/** Config: which registered provider this tool delegates to, plus child defaults. */
|
|
export interface Config {
|
|
/** The `ctx.subagents` provider name to start runs on (e.g. `spawn`, `acp`). */
|
|
provider: string
|
|
/**
|
|
* Model-facing tool name (default `subagent`). Each loaded instance must use
|
|
* a distinct name.
|
|
*/
|
|
toolName?: string
|
|
/**
|
|
* Expose `run_in_background` (default true). Disabled instances omit the
|
|
* parameter and reject forced background calls.
|
|
*/
|
|
enableRunInBackground?: boolean
|
|
/**
|
|
* Background execution policy (default `one-shot`). `continuable` requires a
|
|
* provider with the `prepareContinuable` capability and returns the durable
|
|
* child id; follow-up adapters remain independently optional.
|
|
*/
|
|
backgroundMode?: 'one-shot' | 'continuable'
|
|
/**
|
|
* Agent options applied to every child; omitted fields use child-loop defaults.
|
|
*/
|
|
agentOptions?: AgentOptions
|
|
/**
|
|
* Per-child persona that shadows `deployment:persona`. Requires the
|
|
* provider's `persona` capability; omission preserves the deployment persona.
|
|
*/
|
|
persona?: string
|
|
/**
|
|
* Tool filter applied to every child. Filtered tools disappear from its
|
|
* prompt and reject execution. Requires the provider's `toolFilter`
|
|
* capability; unknown names fail startup.
|
|
*/
|
|
toolFilter?: {
|
|
/** Global tool names the child keeps; everything else is removed. */
|
|
allow?: string[]
|
|
/** Global tool names removed from the child. */
|
|
deny?: string[]
|
|
}
|
|
/**
|
|
* Maximum child depth: a non-negative safe integer (default `3`; `0` forbids
|
|
* delegation entirely), or `'provider-managed'` to send no cap. A numeric cap
|
|
* requires the provider's `depthLimit` capability (mount fails loud
|
|
* otherwise). The provider checks the calling agent's current depth at every
|
|
* start; the tool remains model-visible so runtime policy owns rejection.
|
|
* `'provider-managed'` is for an out-of-process provider (ACP) whose
|
|
* recursion budget belongs to the child harness's own deployment.
|
|
*/
|
|
maxDepth?: number | 'provider-managed'
|
|
}
|
|
|
|
export const Config: z<Config> = z.object({
|
|
provider: z.string().required(),
|
|
toolName: z.string().default('subagent'),
|
|
enableRunInBackground: z.boolean().default(true),
|
|
backgroundMode: z.union(['one-shot', 'continuable'] as const).default('one-shot'),
|
|
// Prevent Schemastery from materializing omitted agentOptions as `{}`.
|
|
agentOptions: z.object({
|
|
provider: z.string(),
|
|
model: z.string(),
|
|
maxTokens: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER),
|
|
}).default(undefined as unknown as { provider: string; model: string; maxTokens: number }),
|
|
persona: z.string(),
|
|
// Preserve omission; Schemastery's `{ allow: [] }` default would deny every tool.
|
|
toolFilter: z.object({
|
|
allow: z.array(z.string()).default(undefined as unknown as string[]),
|
|
deny: z.array(z.string()).default(undefined as unknown as string[]),
|
|
}).default(undefined as unknown as { allow: string[]; deny: string[] }),
|
|
maxDepth: z.union([z.natural().max(Number.MAX_SAFE_INTEGER), z.const('provider-managed' as const)]).default(3),
|
|
})
|
|
|
|
/** Render text blocks from the canonical JSON block array without trusting arbitrary values. */
|
|
function outputValueText(values: JsonValue[]): string {
|
|
return values
|
|
.filter((value): value is { type: 'text'; text: string } =>
|
|
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
&& value.type === 'text' && typeof value.text === 'string')
|
|
.map(value => value.text)
|
|
.join('')
|
|
}
|
|
|
|
/** Settle pending startup without rejecting the task producer contract. */
|
|
async function settleStart(start: Promise<SubagentRun>, signal: AbortSignal): Promise<TaskOutcome> {
|
|
try {
|
|
return await settleRun(await start)
|
|
} catch (error: unknown) {
|
|
return signal.aborted
|
|
? { status: 'killed' }
|
|
: { status: 'failed', detail: String(error) }
|
|
}
|
|
}
|
|
|
|
/** A non-`completed` stop reason means the child did not finish cleanly. */
|
|
function stopReasonError(result: SubagentResult): string | undefined {
|
|
switch (result.stopReason) {
|
|
case 'completed':
|
|
return undefined
|
|
case 'aborted':
|
|
return 'subagent run was cancelled'
|
|
case 'error':
|
|
return 'subagent run failed'
|
|
case 'max-tokens':
|
|
return 'subagent run hit its token limit before finishing'
|
|
case 'refusal':
|
|
return 'subagent declined the task'
|
|
// Merge-extensible union: a backend may add stop reasons. Treat an unknown
|
|
// terminal reason as a failure rather than reporting partial output as success.
|
|
default:
|
|
return `subagent run ended abnormally (${String(result.stopReason)})`
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Model-facing wording from the provider's conversation-history descriptor
|
|
* ({@link SubagentProvider.inheritsParentContext}).
|
|
* A fresh child needs a standalone prompt; a forked child already sees the
|
|
* conversation's completed turns — telling the model to restate everything
|
|
* (or, worse, that the child "does not see this conversation") would be false
|
|
* for a fork.
|
|
* @param inheritsConversation - whether the child's conversation is seeded
|
|
* with the parent's completed turns; this says nothing about tool, service,
|
|
* scope, or authority inheritance.
|
|
* @returns the tool `description` and the `prompt` parameter description.
|
|
*/
|
|
function providerWording(inheritsConversation: boolean): { description: string; promptDescription: string } {
|
|
if (inheritsConversation) {
|
|
return {
|
|
description:
|
|
'Delegate a task to a subagent that inherits this conversation: a child agent seeded with all '
|
|
+ 'completed turns so far (it does not see the current in-flight turn), returning only its final '
|
|
+ 'result. Use this when the subtask builds on this conversation\'s context — a follow-up analysis, '
|
|
+ 'a review, a continuation — without consuming this conversation\'s context for the work itself. '
|
|
+ 'You receive only its final answer, not its intermediate steps.',
|
|
promptDescription:
|
|
'The task for the subagent. It already sees this conversation\'s completed turns, so build on them '
|
|
+ 'freely and state only what is new.',
|
|
}
|
|
}
|
|
return {
|
|
description:
|
|
'Delegate a self-contained task to a subagent (a separate agent that works in its own context) '
|
|
+ 'and return its final result. Use this to offload focused, independent work — research, a scoped '
|
|
+ 'implementation, an analysis — so it does not consume this conversation\'s context. The subagent '
|
|
+ 'runs to completion and you receive only its final answer, not its intermediate steps. Give it a '
|
|
+ 'complete, standalone prompt: it does not see this conversation.',
|
|
promptDescription:
|
|
'The complete, self-contained task for the subagent. It does not share this '
|
|
+ 'conversation\'s context, so include everything it needs.',
|
|
}
|
|
}
|
|
|
|
export function apply(ctx: Context, config: Config): void {
|
|
// Direct apply() bypasses Schemastery's numeric constraints. A direct-apply
|
|
// omission stays capless (the schema default only runs through the loader).
|
|
if (config.maxDepth !== 'provider-managed') assertSubagentMaxDepth(config.maxDepth)
|
|
// Reject an empty explicit filter at load instead of failing every delegation.
|
|
if (config.toolFilter !== undefined && config.toolFilter.allow === undefined && config.toolFilter.deny === undefined) {
|
|
throw new Error('tool-subagent: `toolFilter` is configured but names neither `allow` nor `deny` — remove the key or fill the filter')
|
|
}
|
|
// Mirror provider lifecycle because sibling load order and HMR replacement
|
|
// can change provider availability while this fiber remains active.
|
|
let disposeTool: (() => void) | undefined
|
|
const mount = (provider: SubagentProvider): void => {
|
|
// A numeric cap the provider cannot enforce is a misconfiguration — fail at
|
|
// mount (the earliest point the provider's capabilities are known), not on
|
|
// the first delegation.
|
|
if (typeof config.maxDepth === 'number' && !provider.capabilities.depthLimit) {
|
|
throw new Error(
|
|
`tool-subagent: provider "${provider.name}" cannot enforce maxDepth (no depthLimit capability) — `
|
|
+ 'set maxDepth: \'provider-managed\' to leave the recursion budget to the provider',
|
|
)
|
|
}
|
|
const wording = providerWording(provider.inheritsParentContext)
|
|
const backgroundEnabled = config.enableRunInBackground !== false
|
|
const continuable = (config.backgroundMode ?? 'one-shot') === 'continuable'
|
|
if (continuable && provider.prepareContinuable === undefined) {
|
|
throw new Error(
|
|
`tool-subagent: provider "${provider.name}" does not support \`backgroundMode: continuable\``,
|
|
)
|
|
}
|
|
disposeTool = ctx.tools.register(defineTool({
|
|
name: config.toolName ?? 'subagent',
|
|
description: wording.description + (backgroundEnabled
|
|
? continuable
|
|
? ' Set `run_in_background: true` to start a background subagent that keeps its conversation:'
|
|
+ ' you receive its subagent id and it works on its own. It does not report back, so use this'
|
|
+ ' only for work whose result you do not need returned; `send_message` sends it more work.'
|
|
: ' Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.'
|
|
: ''),
|
|
parameters: {
|
|
description: {
|
|
type: 'string',
|
|
required: true,
|
|
description: 'A short (3-5 word) description of the delegated task, for display.',
|
|
},
|
|
prompt: {
|
|
type: 'string',
|
|
required: true,
|
|
description: wording.promptDescription,
|
|
},
|
|
...backgroundEnabled ? {
|
|
run_in_background: {
|
|
type: 'boolean' as const,
|
|
description: continuable
|
|
? 'Run as a background subagent that keeps its conversation and return its subagent id. '
|
|
+ 'It does not report its result back; send it more work with send_message.'
|
|
: 'Run as a background task and return its id; collect with task_output or stop with task_kill.',
|
|
},
|
|
} : {},
|
|
},
|
|
output: {
|
|
schema: {
|
|
oneOf: [
|
|
{
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
properties: {
|
|
kind: { type: 'string', required: true, const: 'background' },
|
|
taskId: { type: 'string', required: true },
|
|
},
|
|
},
|
|
{
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
properties: {
|
|
kind: { type: 'string', required: true, const: 'continuable' },
|
|
subagentId: { type: 'string', required: true },
|
|
},
|
|
},
|
|
{
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
properties: {
|
|
kind: { type: 'string', required: true, const: 'foreground' },
|
|
runId: { type: 'string', required: true },
|
|
output: { type: 'array', required: true, items: { type: 'json' } },
|
|
},
|
|
},
|
|
],
|
|
},
|
|
render: (_args, value) => [{
|
|
type: 'text',
|
|
text: value.kind === 'background'
|
|
? `started background subagent task ${value.taskId}`
|
|
: value.kind === 'continuable'
|
|
? `started subagent ${value.subagentId}`
|
|
: outputValueText(value.output),
|
|
}],
|
|
},
|
|
async execute(args, exec) {
|
|
const parent = exec.agent
|
|
if (!parent) {
|
|
// Non-agent callers provide no parent for delegation ownership.
|
|
throw new Error('subagent tool requires a calling agent (exec.agent was undefined)')
|
|
}
|
|
|
|
const maxDepth = typeof config.maxDepth === 'number' ? config.maxDepth : undefined
|
|
const request = {
|
|
prompt: [{ type: 'text', text: args.prompt }] as ContentBlock[],
|
|
parent,
|
|
...config.agentOptions !== undefined ? { agentOptions: config.agentOptions } : {},
|
|
...config.persona !== undefined ? { persona: config.persona } : {},
|
|
...config.toolFilter !== undefined ? { toolFilter: config.toolFilter } : {},
|
|
...maxDepth !== undefined ? { maxDepth } : {},
|
|
}
|
|
|
|
if (args.run_in_background === true) {
|
|
// The validator permits undeclared keys, so schema omission also needs
|
|
// execution-time enforcement.
|
|
if (!backgroundEnabled) {
|
|
throw new Error('run_in_background is disabled for this tool instance (enableRunInBackground: false)')
|
|
}
|
|
if (continuable) {
|
|
// Resolves at inbox acceptance: the child owns its own turns from
|
|
// there, so this call neither waits for nor collects a result.
|
|
const started = await ctx.subagents.startContinuable({
|
|
provider: config.provider,
|
|
request,
|
|
signal: exec.signal,
|
|
})
|
|
return { kind: 'continuable' as const, subagentId: started.childId }
|
|
}
|
|
const tasks = ctx.get('tasks')
|
|
if (tasks === undefined) {
|
|
throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
|
|
}
|
|
// One-shot background child: task preflight finishes before the
|
|
// starter can spawn, and the task-owned signal covers startup.
|
|
const id = tasks.start({
|
|
kind: 'subagent',
|
|
label: args.description,
|
|
owner: parent,
|
|
run: () => {
|
|
const controller = new AbortController()
|
|
const start = ctx.subagents.start(config.provider, { ...request, signal: controller.signal })
|
|
return {
|
|
cancel: (reason?: string) => {
|
|
controller.abort(reason ?? 'background subagent task killed')
|
|
},
|
|
done: settleStart(start, controller.signal),
|
|
// No readOutput: the child session owns intermediate detail.
|
|
}
|
|
},
|
|
})
|
|
return { kind: 'background' as const, taskId: id }
|
|
}
|
|
|
|
const run: SubagentRun = await ctx.subagents.start(config.provider, {
|
|
...request,
|
|
signal: exec.signal,
|
|
})
|
|
|
|
try {
|
|
const result = await run.result
|
|
const error = stopReasonError(result)
|
|
if (error !== undefined) {
|
|
// The registry converts this throw to isError; partial output is not success.
|
|
throw new Error(error)
|
|
}
|
|
return {
|
|
kind: 'foreground' as const,
|
|
runId: run.id,
|
|
// Content blocks already cross durable JSON boundaries elsewhere;
|
|
// the registry performs the authoritative lossless snapshot here.
|
|
output: result.output as unknown as JsonValue[],
|
|
}
|
|
} finally {
|
|
// Dispose before returning so no child session outlives the call.
|
|
await run.dispose()
|
|
}
|
|
},
|
|
}))
|
|
}
|
|
|
|
// 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.
|
|
ctx.on('subagent/provider-added', (provider) => {
|
|
if (provider.name === config.provider && disposeTool === undefined) mount(provider)
|
|
})
|
|
ctx.on('subagent/provider-removed', (name) => {
|
|
if (name !== config.provider || disposeTool === undefined) return
|
|
disposeTool()
|
|
disposeTool = undefined
|
|
})
|
|
const present = ctx.subagents.getProvider(config.provider)
|
|
if (present !== undefined) {
|
|
mount(present)
|
|
} else {
|
|
// A backend fiber may activate later; a misspelled provider remains visible in this log.
|
|
ctx.logger.info(`subagent provider "${config.provider}" not registered yet; the "${config.toolName ?? 'subagent'}" tool will register when it appears`)
|
|
}
|
|
}
|