/** * Model-facing delegation through one configured `ctx.subagents` provider. * Provider lifecycle controls tool registration and context-sensitive schema * wording. Every delegation starts a managed activation and returns its child * id; the subagent runtime owns result delivery and resource cleanup. * @module @deepseek-ai/dsh-tool-subagent */ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { scopeChainOf, scopeOf } from '@deepseek-ai/dsh-scope' import { defineTool } from '@deepseek-ai/dsh-tools' import type { ToolDefinition } from '@deepseek-ai/dsh-tools' import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent' import { ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { SessionSeq } from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' import { assertSubagentMaxDepth, parentAgentOptionsForDelegation, } from '@deepseek-ai/dsh-subagent' import type { SubagentProvider } from '@deepseek-ai/dsh-subagent' import { assertAllowedModelSelection, hasConfiguredLlmSelection, hasDelegationModelRequest, preflightChildLlmRoute, requestedAgentOptions, } from './model-selection.ts' import type { DelegationModelRequest, ModelSelectionPolicy } from './model-selection.ts' import { registerListSubagentModels } from './list-models.ts' import type {} from './model-selection-settings.ts' import { recordSubagentModelSelection, subagentModelSelectionProjectionDefinition, subagentModelSelectionPolicy, } from './model-selection-state.ts' export const name = 'tool-subagent' export const inject = ['tools', 'subagents', 'systemPrompt', 'sessionProjections'] // Definition identity keeps shared guidance scoped to the actual visible tools, // including scoped overrides and independently loaded tool registries. const delegationTools = new Set() /** 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 /** * Sample the Host `subagent-model-selection` setting for each new top-level * Session and inherit that decision in its child Sessions. */ modelSelectionSettings?: boolean /** * Agent options applied to every child; omitted fields use child-loop defaults. */ agentOptions?: AgentOptions /** * Per-child persona that shadows `deployment:persona-prefix`. 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 (`0` forbids delegation), * 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 whose recursion * budget belongs to the child runtime or its own deployment. Omission reads * the current Host subagent depth setting (default `1`) at each delegation. */ maxDepth?: number | 'provider-managed' } export const Config: z = z.object({ provider: z.string().required(), toolName: z.string().default('subagent'), modelSelectionSettings: z.boolean().default(false), // Prevent Schemastery from materializing omitted agentOptions as `{}`. agentOptions: z.object({ provider: z.string(), model: z.string(), reasoningEffort: z.string().min(1) as z>, maxTokens: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER), }).default(undefined as unknown as { provider: string model: string reasoningEffort: ReturnType 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)]), }) /** * 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). 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 its result, 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) ' + 'to offload focused, independent work — research, a scoped ' + 'implementation, an analysis — so it does not consume this conversation\'s context. The subagent ' + 'returns its result, not its intermediate steps.', promptDescription: 'The complete, self-contained task for the subagent. It does not share this ' + 'conversation\'s context, so include everything it needs.', } } /** * Install one delegation-tool composition. * @param ctx - Context that owns the registrations. * @param config - delegation-tool configuration. * @param session - unpublished Session supplied by a direct Agent setup; omit for a standing composition. */ export function apply(ctx: Context, config: Config, session?: Session): 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') } const toolName = config.toolName ?? 'subagent' const modelSelectionCapable = config.modelSelectionSettings === true ctx.sessionProjections.register(subagentModelSelectionProjectionDefinition) const assertSubagentProviderConfiguration = (subagentProvider: SubagentProvider): void => { if (ctx.subagents.resolveMaxDepth(config.maxDepth) !== undefined && !subagentProvider.capabilities.depthLimit) { throw new Error( `tool-subagent: provider "${subagentProvider.name}" cannot enforce maxDepth (no depthLimit capability) — ` + 'set maxDepth: \'provider-managed\' to leave the recursion budget to the provider', ) } if (config.agentOptions !== undefined && !subagentProvider.capabilities.agentOptions) { throw new Error( `tool-subagent: provider "${subagentProvider.name}" does not support child agentOptions`, ) } if (modelSelectionCapable && !subagentProvider.capabilities.agentOptions) { throw new Error( `tool-subagent: provider "${subagentProvider.name}" does not support child model selection`, ) } } // Validate provider-owned config outside the optional LLM binding so an // invalid provider always rejects its registration or this plugin's load. ctx.on('subagent/provider-added', (subagentProvider) => { if (subagentProvider.name === config.provider) assertSubagentProviderConfiguration(subagentProvider) }) const initialProvider = ctx.subagents.getProvider(config.provider) if (initialProvider !== undefined) assertSubagentProviderConfiguration(initialProvider) const install = (runtimeCtx: Context, modelSelectionPolicy: ModelSelectionPolicy | undefined): void => { const modelSelectionEnabled = modelSelectionPolicy !== undefined if (modelSelectionPolicy !== undefined) registerListSubagentModels(runtimeCtx, modelSelectionPolicy) // Load order and HMR replacement can change provider availability while // this fiber remains active. let mounted: { subagentProvider: SubagentProvider; disposeTool: () => void } | undefined const mount = (subagentProvider: SubagentProvider): void => { assertSubagentProviderConfiguration(subagentProvider) const wording = providerWording(subagentProvider.inheritsParentContext) const providerRouteDefaults = subagentProvider.agentRouteDefaults const selectionDescription = providerRouteDefaults !== undefined ? ' Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and this provider\'s route defaults. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model\'s default effort.' : ' Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model\'s default effort.' const choiceDescription = !modelSelectionEnabled ? '' : selectionDescription + (subagentProvider.inheritsParentContext ? ' Changing the route can prevent provider-side reuse of the inherited conversation prefix.' : '') const definition = defineTool({ name: toolName, description: wording.description + ' This tool starts an independently managed subagent and immediately returns its id. The runtime notifies you when it finishes.' + (subagentProvider.prepareContinuable !== undefined ? ' The child reports results with `send_message`; use `send_message` to steer it while running or continue its conversation after it finishes.' : ' The completion notice includes its final answer. This backend does not accept follow-up messages.') + choiceDescription, parameters: { cwd: { type: 'string', description: 'Initial child working directory. Relative paths use your current directory; omitted inherits it. Later directory changes in either agent are independent.', }, 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, }, ...modelSelectionEnabled ? { provider: { type: 'string' as const, description: providerRouteDefaults !== undefined ? 'LLM provider route for the child. Supply together with model; omit both to use configured child defaults or this provider\'s route defaults.' : 'LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route.', }, model: { type: 'string' as const, description: providerRouteDefaults !== undefined ? 'Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or this provider\'s route defaults.' : 'Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route.', }, reasoning_effort: { type: 'string' as const, description: providerRouteDefaults !== undefined ? 'Adapter-owned reasoning effort for the effective child route. Omit to use a compatible configured effort or the selected model\'s default.' : 'Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model\'s default.', }, } : {}, }, output: { schema: { type: 'object', additionalProperties: false, properties: { kind: { type: 'string', required: true, const: 'activation' }, subagentId: { type: 'string', required: true }, }, }, render: (_args, value) => [{ type: 'text', text: `started subagent ${value.subagentId}` }], }, isConcurrencySafe: () => true, 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 modelRequest = args as DelegationModelRequest const parentOptions = parentAgentOptionsForDelegation(parent) const requiresRoutePreflight = hasDelegationModelRequest(modelRequest) || hasConfiguredLlmSelection(config.agentOptions) const configuredChildAgentOptions = requiresRoutePreflight && providerRouteDefaults !== undefined ? { ...providerRouteDefaults, ...config.agentOptions } : config.agentOptions const requestedChildAgentOptions = requestedAgentOptions( parentOptions, configuredChildAgentOptions, modelRequest, modelSelectionEnabled, ) assertAllowedModelSelection( modelSelectionPolicy, parentOptions, requestedChildAgentOptions, modelRequest, ) if (requiresRoutePreflight) { const llm = runtimeCtx.get('llm') if (llm === undefined) { throw new Error('cannot resolve the selected child LLM route because the `llm` service is unavailable') } await preflightChildLlmRoute( llm, parentOptions, requestedChildAgentOptions, exec.signal, providerRouteDefaults === undefined, ) if (runtimeCtx.subagents.getProvider(config.provider) !== subagentProvider) { throw new Error(`subagent provider "${config.provider}" changed while resolving the child LLM route; retry the delegation`) } } exec.signal.throwIfAborted() const maxDepth = runtimeCtx.subagents.resolveMaxDepth(config.maxDepth) const request = { ...args.cwd === undefined ? {} : { cwd: args.cwd }, prompt: [{ type: 'text', text: args.prompt }] as ContentBlock[], parent, ...requestedChildAgentOptions !== undefined ? { agentOptions: requestedChildAgentOptions } : {}, ...config.persona !== undefined ? { persona: config.persona } : {}, ...config.toolFilter !== undefined ? { toolFilter: config.toolFilter } : {}, ...maxDepth !== undefined ? { maxDepth } : {}, } const started = await runtimeCtx.subagents.startActivation({ provider: config.provider, label: args.description, request, signal: exec.signal, delivery: 'parent', }) return { kind: 'activation' as const, subagentId: started.childId } }, }) const disposeTool = runtimeCtx.effect(() => { const unregister = runtimeCtx.tools.register(definition) delegationTools.add(definition) return () => { delegationTools.delete(definition) unregister() } }) // oxlint-disable-next-line typescript/no-misused-promises -- Tool and guidance disposal are synchronous. mounted = { subagentProvider, disposeTool } } // Register listeners before checking presence so no synchronous change is missed. runtimeCtx.on('subagent/provider-added', (subagentProvider) => { if (subagentProvider.name === config.provider && mounted === undefined) mount(subagentProvider) }) runtimeCtx.on('subagent/provider-removed', (name) => { if (name !== config.provider || mounted === undefined) return mounted.disposeTool() mounted = undefined }) const present = runtimeCtx.subagents.getProvider(config.provider) if (present !== undefined) { mount(present) } else { // A backend fiber may activate later; a misspelled provider remains visible in this log. runtimeCtx.logger.info(`subagent provider "${config.provider}" not registered yet; the "${config.toolName ?? 'subagent'}" tool will register when it appears`) } runtimeCtx.systemPrompt.section({ name: `tool:${toolName}`, order: runtimeCtx.systemPrompt.getSectionOrder('TOOL_SUBAGENT'), text: (context) => { if (mounted === undefined) return '' const visible = [...delegationTools] .filter(tool => runtimeCtx.tools.get(tool.name, context.scope) === tool) .map(tool => tool.name) .sort() if (visible[0] !== toolName) return '' const names = visible.map(name => `\`${name}\``).join(' or ') return `Start independent delegations with ${names} together in one assistant message and continue useful work while they run.` }, }) } if (config.modelSelectionSettings !== true) { install(ctx, undefined) return } const settings = ctx.get('subagentModelSelection') if (settings === undefined) { throw new Error( 'tool-subagent: `modelSelectionSettings` requires ' + '@deepseek-ai/dsh-tool-subagent/model-selection-settings in the Host scope', ) } const selectForSession = (target: Session): ModelSelectionPolicy | undefined => { const freshSession = target.firstLiveSeq === 0 // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. && target.eventAt(SessionSeq(0))?.type !== 'session/end-seed' let allowedModels = subagentModelSelectionPolicy(ctx.sessionProjections, target) if (allowedModels === undefined) { const parentId = target.header.origin === 'subagent' ? target.header.parentSession : undefined if (parentId !== undefined) { const sessions = ctx.get('sessions') if (sessions === undefined) { throw new Error('tool-subagent: child model-selection inheritance requires the Session registry') } const parent = sessions.get(parentId) allowedModels = parent === undefined ? undefined : subagentModelSelectionPolicy(ctx.sessionProjections, parent) } else if (freshSession) { const current = settings.current() allowedModels = current.enabled ? current.allowedModels : undefined } } if (allowedModels !== undefined) { recordSubagentModelSelection(ctx.sessionProjections, target, allowedModels) } return allowedModels === undefined ? undefined : { routes: allowedModels } } if (session !== undefined) { install(ctx, selectForSession(session)) return } const compositionScope = scopeOf(ctx) if (compositionScope === undefined) { throw new Error('tool-subagent: standing `modelSelectionSettings` requires a scoped preset Context') } const agents = ctx.get('agents') /* v8 ignore next -- shipped preset compositions always include the Agent registry. */ if (agents === undefined) throw new Error('tool-subagent: standing `modelSelectionSettings` requires the Agent registry') const scopedInstalls = new WeakMap>() const installing = new WeakSet() const belongsToComposition = (candidate: Agent): boolean => scopeChainOf(scopeOf(candidate.ctx)).includes(compositionScope) const installScoped = (candidate: Agent): ReturnType | undefined => { const existing = scopedInstalls.get(candidate) if (existing !== undefined) return existing if (installing.has(candidate)) return // Reserve before the injected fiber runs: tool registration emits // `tools/change` synchronously, which re-enters the reconciliation below. installing.add(candidate) let fiber: ReturnType try { const policy = selectForSession(candidate.session) fiber = candidate.ctx.inject(['tools', 'subagents', 'systemPrompt'], (runtimeCtx) => { install(runtimeCtx, policy) }) } finally { installing.delete(candidate) } scopedInstalls.set(candidate, fiber) return fiber } const removeScoped = (candidate: Agent): void => { const fiber = scopedInstalls.get(candidate) if (fiber === undefined) return scopedInstalls.delete(candidate) /* v8 ignore next 3 -- Cordis Fiber disposal contains registration cleanup failures; this is the final diagnostic sink. */ void fiber.dispose().catch((error: unknown) => { ctx.logger.warn(`tool-subagent: failed to remove recomposed Agent "${candidate.id}" definitions: ${String(error)}`) }) } const reconcileComposedAgents = (): void => { for (const candidate of agents.list()) { if (belongsToComposition(candidate)) installScoped(candidate) else removeScoped(candidate) } } // The preset-scoped listener admits descendant Agents and installs the // sampled tool definition in each Agent's own scope, so a later settings // change cannot mutate a live session. ctx.on('agent/created', async ({ agent: created }) => { await installScoped(created) }) ctx.on('agent/disposed', ({ agent: disposed }) => { removeScoped(disposed) }) // Reparenting an Agent between standing presets changes its inherited tool // set and emits `tools/change`; reconcile the Agent-owned override with the // new ancestry. Other registry changes are idempotent no-ops here. ctx.on('tools/change', reconcileComposedAgents) reconcileComposedAgents() }