feat(tool-subagent): default maxDepth 1 with at-cap schema hiding

An omitted maxDepth meant unbounded recursion, and the shipped examples
shipped that default. maxDepth now defaults to 1; a numeric cap requires
the provider's depthLimit capability (the mount fails loud and points to
the explicit 'provider-managed' opt-out for out-of-process providers),
and a child AT the cap loses the delegating tool from its own schema via
the child toolFilter — prompt-face hiding on top of the execution-face
depth check. Examples pin maxDepth explicitly. The ACP snapshot harness
gains Scenario.childToolOmissions so a child session may legitimately
omit declared delegation tools from its pinned header and prompt;
affected subagent/workflow goldens are re-recorded.
This commit is contained in:
Yichen Jiang
2026-07-19 17:20:49 +08:00
parent 0d00106fa8
commit cb74477d42
30 changed files with 1811 additions and 1763 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ With `run_in_background: true`, the tool registers the parent-owned task before
| `agentOptions` | Default child options, currently including `model`. |
| `persona` | Per-child persona; requires provider `persona` capability. |
| `toolFilter` | Per-child global-tool restriction; requires `toolFilter` capability. |
| `maxDepth` | Absolute delegation-depth cap; requires `depthLimit` capability. |
| `maxDepth` | Absolute delegation-depth cap, default `1` (`0` forbids delegation); a numeric cap requires the `depthLimit` capability and fails the mount without it. `'provider-managed'` sends no cap — for an out-of-process provider whose budget belongs to the child harness. A child AT the cap also loses this tool from its schema when the provider supports `toolFilter` (prompt-face hiding; the service still rejects on the execution face). |
## Concurrency
+46 -13
View File
@@ -12,7 +12,7 @@ import z from 'schemastery'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { assertSubagentMaxDepth } from '@deepseek-ai/dsh-subagent'
import { assertSubagentMaxDepth, delegationDepthOf } from '@deepseek-ai/dsh-subagent'
import type { SubagentProvider, SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
import type { TaskOutcome } from '@deepseek-ai/dsh-tasks'
@@ -45,8 +45,7 @@ export interface Config {
/**
* 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. Children otherwise see this tool,
* so deny it or set `maxDepth` to bound recursion.
* capability; unknown names fail startup.
*/
toolFilter?: {
/** Global tool names the child keeps; everything else is removed. */
@@ -55,10 +54,16 @@ export interface Config {
deny?: string[]
}
/**
* Maximum child depth. Requires the provider's `depthLimit` capability and a
* non-negative safe integer. Omission is unbounded.
* Maximum child depth: a non-negative safe integer (default `1`; `0` forbids
* delegation entirely), or `'provider-managed'` to send no cap. A numeric cap
* requires the provider's `depthLimit` capability (mount fails loud
* otherwise), and a child AT the cap additionally loses this tool from its
* schema when the provider supports `toolFilter` — the prompt face of the
* budget; the service keeps rejecting on the execution face.
* `'provider-managed'` is for an out-of-process provider (ACP) whose
* recursion budget belongs to the child harness's own deployment.
*/
maxDepth?: number
maxDepth?: number | 'provider-managed'
}
export const Config: z<Config> = z.object({
@@ -76,7 +81,7 @@ export const Config: z<Config> = 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.natural().max(Number.MAX_SAFE_INTEGER),
maxDepth: z.union([z.natural().max(Number.MAX_SAFE_INTEGER), z.const('provider-managed' as const)]).default(1),
})
/**
@@ -194,15 +199,29 @@ function providerWording(inheritsConversation: boolean): { description: string;
}
}
function startRequest(config: Config, prompt: string, parent: Agent, signal: AbortSignal): SubagentStartRequest {
function startRequest(
config: Config,
prompt: string,
parent: Agent,
signal: AbortSignal,
hideAtCapToolName: string | undefined,
): SubagentStartRequest {
const maxDepth = typeof config.maxDepth === 'number' ? config.maxDepth : undefined
// A child AT the cap cannot delegate further: deny it this tool so its
// schema hides what the service would reject anyway (prompt face; the
// depth check at start remains the execution face).
const childAtCap = maxDepth !== undefined && delegationDepthOf(parent) + 1 >= maxDepth
const toolFilter = childAtCap && hideAtCapToolName !== undefined
? { ...config.toolFilter, deny: [...config.toolFilter?.deny ?? [], hideAtCapToolName] }
: config.toolFilter
return {
prompt: [{ type: 'text', text: prompt }],
parent,
signal,
...config.agentOptions !== undefined ? { agentOptions: config.agentOptions } : {},
...config.persona !== undefined ? { persona: config.persona } : {},
...config.toolFilter !== undefined ? { toolFilter: config.toolFilter } : {},
...config.maxDepth !== undefined ? { maxDepth: config.maxDepth } : {},
...toolFilter !== undefined ? { toolFilter } : {},
...maxDepth !== undefined ? { maxDepth } : {},
}
}
@@ -218,8 +237,9 @@ async function settleStart(start: Promise<SubagentRun>, signal: AbortSignal): Pr
}
export function apply(ctx: Context, config: Config): void {
// Direct apply() bypasses Schemastery's numeric constraints.
assertSubagentMaxDepth(config.maxDepth)
// 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')
@@ -228,6 +248,18 @@ export function apply(ctx: Context, config: Config): void {
// 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',
)
}
// Schema hiding rides the child toolFilter, so it needs that capability;
// without it the depth check at start remains the only fence.
const hideAtCapToolName = provider.capabilities.toolFilter ? config.toolName ?? 'subagent' : undefined
const wording = providerWording(provider.inheritsParentContext)
const backgroundEnabled = config.enableRunInBackground !== false
disposeTool = ctx.tools.register(defineTool({
@@ -282,7 +314,7 @@ export function apply(ctx: Context, config: Config): void {
const controller = new AbortController()
const start = ctx.subagents.start(
config.provider,
startRequest(config, args.prompt, parent, controller.signal),
startRequest(config, args.prompt, parent, controller.signal, hideAtCapToolName),
)
return {
cancel: (reason?: string) => {
@@ -301,6 +333,7 @@ export function apply(ctx: Context, config: Config): void {
args.prompt,
parent,
exec.signal ?? new AbortController().signal,
hideAtCapToolName,
)
const run: SubagentRun = await ctx.subagents.start(config.provider, request)
@@ -7,6 +7,7 @@ import ToolRegistry from '@deepseek-ai/dsh-tools'
import { type Agent } from '@deepseek-ai/dsh-agent'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import SubagentService from '@deepseek-ai/dsh-subagent'
import type { SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
import TaskService from '@deepseek-ai/dsh-tasks'
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import * as mock from './scripted-provider.ts'
@@ -22,9 +23,13 @@ import { SessionId } from '@deepseek-ai/dsh-session'
* shipping code path.
*/
/** A minimal parent Agent — the tool reads `agent.id` for `parent`. */
function fakeAgent(id = 'parent-1'): Agent {
return { id: SessionId(id) } as unknown as Agent
/** A minimal parent Agent: the tool reads `agent.id` plus the delegation depth off its header/options. */
function fakeAgent(id = 'parent-1', delegationDepth?: number): Agent {
return {
id: SessionId(id),
options: {},
session: { header: { ...delegationDepth === undefined ? {} : { delegationDepth } } },
} as unknown as Agent
}
async function setup(toolConfig: tool.Config, mockConfig: Partial<mock.Config> = {}) {
@@ -85,7 +90,7 @@ describe('dsh-tool-subagent', () => {
// Schema omission is advertising, not enforcement: the arg validator
// allows undeclared keys, so the opt-out must also hold in execute().
const ctx = await setup({ provider: 'mock', enableRunInBackground: false })
const parent = { id: SessionId('sess-off'), inject: () => {}, session: { header: { version: 0, id: 'sess-off', createdAt: 0 } } } as unknown as Agent
const parent = { id: SessionId('sess-off'), inject: () => {}, options: {}, session: { header: { version: 0, id: 'sess-off', createdAt: 0 } } } as unknown as Agent
const forced = await callSubagent(ctx, { description: 'd', prompt: 'p', run_in_background: true }, { agent: parent })
expect(forced.isError).toBe(true)
@@ -162,7 +167,7 @@ describe('dsh-tool-subagent', () => {
dispose: async () => {},
}),
})
await ctx.plugin(tool, { provider: 'weird' })
await ctx.plugin(tool, { provider: 'weird', maxDepth: 'provider-managed' })
const result = await callSubagent(ctx, { description: 'd', prompt: 'p' })
expect(result.isError).toBe(true)
@@ -191,7 +196,7 @@ describe('dsh-tool-subagent', () => {
}
},
})
await ctx.plugin(tool, { provider: 'capture', agentOptions: { model: 'child-model' } })
await ctx.plugin(tool, { provider: 'capture', agentOptions: { model: 'child-model' }, maxDepth: 'provider-managed' })
await callSubagent(ctx, { description: 'd', prompt: 'p' })
expect(seen?.agentOptions).toEqual({ model: 'child-model' })
@@ -348,7 +353,7 @@ describe('dsh-tool-subagent', () => {
dispose: async () => void disposed(),
}),
})
await ctx.plugin(tool, { provider: 'spy' })
await ctx.plugin(tool, { provider: 'spy', maxDepth: 'provider-managed' })
await callSubagent(ctx, { description: 'd', prompt: 'p' })
expect(disposed).toHaveBeenCalledTimes(1)
@@ -371,7 +376,7 @@ describe('dsh-tool-subagent', () => {
dispose: async () => void disposed(),
}),
})
await ctx.plugin(tool, { provider: 'spy' })
await ctx.plugin(tool, { provider: 'spy', maxDepth: 'provider-managed' })
const result = await callSubagent(ctx, { description: 'd', prompt: 'p' })
expect(result.isError).toBe(true)
@@ -404,7 +409,7 @@ describe('dsh-tool-subagent', () => {
}
},
})
await ctx.plugin(tool, { provider: 'spy' })
await ctx.plugin(tool, { provider: 'spy', maxDepth: 'provider-managed' })
const controller = new AbortController()
const pending = callSubagent(ctx, { description: 'd', prompt: 'p' }, { signal: controller.signal })
@@ -432,7 +437,7 @@ describe('dsh-tool-subagent', () => {
throw new Error('start aborted')
},
})
await ctx.plugin(tool, { provider: 'spy' })
await ctx.plugin(tool, { provider: 'spy', maxDepth: 'provider-managed' })
const controller = new AbortController()
controller.abort() // already aborted BEFORE the tool runs
@@ -511,7 +516,6 @@ describe('dsh-tool-subagent', () => {
})
it.each([
{ label: 'null', value: null as unknown as number },
{ label: 'a string', value: '1' as unknown as number },
{ label: 'NaN', value: Number.NaN },
{ label: 'positive infinity', value: Number.POSITIVE_INFINITY },
@@ -555,7 +559,7 @@ describe('dsh-tool-subagent', () => {
}
},
})
await ctx.plugin(tool, { provider: 'capture3', toolFilter: { deny: ['subagent'] } })
await ctx.plugin(tool, { provider: 'capture3', toolFilter: { deny: ['subagent'] }, maxDepth: 'provider-managed' })
await callSubagent(ctx, { description: 'd', prompt: 'p' })
expect(seen?.toolFilter).toEqual({ deny: ['subagent'] })
expect(seen?.toolFilter).not.toHaveProperty('allow')
@@ -585,7 +589,7 @@ describe('dsh-tool-subagent', () => {
}
},
})
await ctx.plugin(tool, { provider: 'capture4' })
await ctx.plugin(tool, { provider: 'capture4', maxDepth: 'provider-managed' })
await callSubagent(ctx, { description: 'd', prompt: 'p' })
expect(seen).toBeDefined()
expect(seen).not.toHaveProperty('agentOptions')
@@ -616,6 +620,7 @@ describe('dsh-tool-subagent background mode', () => {
id,
ctx: scopeFiber.ctx,
inject,
options: {},
session: { id, header: { version: 0, id, createdAt: 0 } },
} as unknown as Agent
ctx.agents.register(agent)
@@ -846,6 +851,7 @@ describe('background preflight failure (no orphaned child, by construction)', ()
id,
ctx: scopeFiber.ctx,
inject: () => {},
options: {},
session: { id, header: { version: 0, id, createdAt: 0 } },
} as unknown as Agent
ctx.agents.register(parent)
@@ -879,3 +885,107 @@ describe('background preflight failure (no orphaned child, by construction)', ()
expect(starts).toBe(0)
})
})
describe('depth budget defaults and schema hiding', () => {
/** Mount the tool over a request-capturing provider with full capabilities. */
async function captureSetup(config: Omit<tool.Config, 'provider'> = {}) {
const requests: SubagentStartRequest[] = []
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(SubagentService)
ctx.subagents.registerProvider({
name: 'capture',
capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: true },
inheritsParentContext: false,
start: async (request) => {
requests.push(request)
return {
id: SessionId(`capture-child-${requests.length}`),
localAgent: undefined,
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
dispose: async () => {},
}
},
})
await ctx.plugin(tool, { provider: 'capture', ...config })
return { ctx, requests }
}
it('defaults maxDepth to 1 and forwards it in the start request', async () => {
const { ctx, requests } = await captureSetup()
await callSubagent(ctx, { description: 'd', prompt: 'p' })
expect(requests[0]?.maxDepth).toBe(1)
})
it('denies its own toolName to a child at the depth cap', async () => {
// The child of a depth-0 parent under maxDepth 1 sits AT the cap: any
// delegation it attempted would be rejected, so the tool must not appear in
// its schema at all (prompt-face hiding; the service still rejects).
const { ctx, requests } = await captureSetup()
await callSubagent(ctx, { description: 'd', prompt: 'p' })
expect(requests[0]?.toolFilter?.deny).toContain('subagent')
})
it('merges the cap denial into a configured tool filter', async () => {
const { ctx, requests } = await captureSetup({ toolFilter: { deny: ['dangerous'] } })
await callSubagent(ctx, { description: 'd', prompt: 'p' })
expect(requests[0]?.toolFilter?.deny).toEqual(expect.arrayContaining(['dangerous', 'subagent']))
})
it('keeps the tool visible for a child below the cap', async () => {
const { ctx, requests } = await captureSetup({ maxDepth: 2 })
await callSubagent(ctx, { description: 'd', prompt: 'p' })
expect(requests[0]?.maxDepth).toBe(2)
expect(requests[0]?.toolFilter?.deny ?? []).not.toContain('subagent')
})
it('counts the parent by its persisted header depth when hiding', async () => {
// A resumed depth-1 parent under maxDepth 2: its child is AT the cap and
// must lose the tool even though the parent's runtime options carry no depth.
const { ctx, requests } = await captureSetup({ maxDepth: 2 })
await callSubagent(ctx, { description: 'd', prompt: 'p' }, { agent: fakeAgent('resumed-parent', 1) })
expect(requests[0]?.toolFilter?.deny).toContain('subagent')
})
it('rejects a numeric maxDepth on a provider without the depthLimit capability at mount', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(SubagentService)
ctx.subagents.registerProvider({
name: 'no-depth',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: async () => { throw new Error('unreachable') },
})
await expect(ctx.plugin(tool, { provider: 'no-depth' }))
.rejects.toThrow(/provider-managed/)
})
it("'provider-managed' omits the cap so a capability-less provider mounts and starts", async () => {
const requests: SubagentStartRequest[] = []
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(SubagentService)
ctx.subagents.registerProvider({
name: 'external',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: async (request) => {
requests.push(request)
return {
id: SessionId('external-child'),
localAgent: undefined,
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
dispose: async () => {},
}
},
})
await ctx.plugin(tool, { provider: 'external', maxDepth: 'provider-managed' })
await callSubagent(ctx, { description: 'd', prompt: 'p' })
expect(requests[0]?.maxDepth).toBeUndefined()
expect(requests[0]?.toolFilter).toBeUndefined()
})
})
+55 -2
View File
@@ -100,6 +100,18 @@ export interface Scenario {
* {@link headerClass}.
*/
configPath?: string
/**
* Global tool names allowed to be ABSENT from a non-primary (child) session's
* request/header relative to the class pin — the delegation tool a child at
* its depth cap loses to tool-subagent's schema hiding. Each child header is
* compared against the pin minus exactly the declared names it actually
* omitted, so any other divergence (or an undeclared omission) still fails.
* A child that omitted a declared tool also skips the text-level initial
* system prompt pin: the prompt embeds the toolset (Code Mode SDK sections),
* so a reduced child cannot equal the full-composition golden — the
* structural header assertion remains its pin. Meaningless on the primary log.
*/
childToolOmissions?: string[]
}
/** One suite's inputs: the agent to boot, where its fixtures live, and its scenario table. */
@@ -282,6 +294,36 @@ export function restorePinnedToolSchemas(header: unknown, schemas: readonly unkn
return { ...header, tools: schemas }
}
/**
* The pinned header with exactly the DECLARED omissions a child actually made
* removed from its tool list. A child at its depth cap legitimately lacks the
* delegation tool that spawned it (tool-subagent schema hiding); removing only
* declared-AND-actually-absent names keeps every other divergence — including
* an undeclared omission — a loud mismatch.
* @param pinned The class-pinned full header (tool schemas restored).
* @param actual The child session's normalized header under comparison.
* @param allowed The scenario's declared {@link Scenario.childToolOmissions}.
* @returns The expected header for this child log.
*/
export function applyChildToolOmissions(pinned: unknown, actual: unknown, allowed: readonly string[]): unknown {
if (pinned === null || typeof pinned !== 'object' || Array.isArray(pinned)) {
throw new Error('acp-snapshot: pinned request header must be an object')
}
const toolNames = (header: unknown): Set<string> => {
const tools = (header as { tools?: unknown }).tools
return new Set(Array.isArray(tools)
? tools.map(tool => (tool as { name?: unknown }).name).filter((name): name is string => typeof name === 'string')
: [])
}
const actualNames = toolNames(actual)
const pinnedTools = (pinned as { tools?: unknown[] }).tools ?? []
const tools = pinnedTools.filter((tool) => {
const name = (tool as { name?: unknown }).name
return !(typeof name === 'string' && allowed.includes(name) && !actualNames.has(name))
})
return { ...pinned, tools }
}
/**
* Render a normalized prompt as a repository-friendly Markdown snapshot.
* Prompt text is unchanged except that a missing terminal newline is added so
@@ -625,9 +667,20 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
.toBe(headers.length)
for (const [k, header] of headers.entries()) {
const expected = expectedChanges > 0 ? pinnedHeaders[k] : pinnedHeaders[0]
// A child (non-primary) log may omit declared delegation tools —
// schema hiding at the depth cap; see Scenario.childToolOmissions.
const childOmissions = logIndex === 0 ? [] : scenario.childToolOmissions ?? []
const target = childOmissions.length === 0
? expected
: applyChildToolOmissions(expected, header, childOmissions)
expect(header, `session ${log.id}: request/header #${k + 1} diverged from the pinned (${pinningScenario.name}) header`)
.toEqual(expected)
if (expectedChanges === 0) {
.toEqual(target)
// A child that omitted a declared tool cannot equal the text-level
// prompt pin (the prompt embeds the toolset); its header assertion
// above remains the structural pin.
const omittedDeclaredTool = target !== expected
&& (target as { tools?: unknown[] }).tools?.length !== (expected as { tools?: unknown[] }).tools?.length
if (expectedChanges === 0 && !omittedDeclaredTool) {
expect(formatSystemPromptSnapshot(prompts[k] as string), `session ${log.id}: initial system prompt #${k + 1} diverged from ${pinningScenario.name}/${SYSTEM_PROMPT_SNAPSHOT}`)
.toEqual(initialPromptSnapshot)
}
@@ -16,6 +16,7 @@ import {
parseToolSchemasSnapshot,
refreshFixtureReplacements,
sessionFixtureNames,
applyChildToolOmissions,
restorePinnedToolSchemas,
stabilizeRefreshLog,
unknownToolCallIds,
@@ -366,6 +367,37 @@ describe('tool-schema snapshots', () => {
})
})
describe('applyChildToolOmissions', () => {
const pinned = { system: 's', tools: [{ name: 'bash' }, { name: 'subagent' }, { name: 'subagent_fork' }] }
it('removes exactly the declared tools the child actually omitted', () => {
const actual = { system: 's', tools: [{ name: 'bash' }, { name: 'subagent_fork' }] }
expect(applyChildToolOmissions(pinned, actual, ['subagent', 'subagent_fork']))
.toEqual({ system: 's', tools: [{ name: 'bash' }, { name: 'subagent_fork' }] })
})
it('keeps a declared tool the child still carries and an undeclared omission', () => {
// The child omitted `bash` (undeclared) — the expectation keeps it, so the
// equality assertion downstream still fails loudly on the real divergence.
const actual = { system: 's', tools: [{ name: 'subagent' }, { name: 'subagent_fork' }] }
expect(applyChildToolOmissions(pinned, actual, ['subagent']))
.toEqual(pinned)
})
it('tolerates a headerless tool list and unnamed tool entries', () => {
expect(applyChildToolOmissions({ system: 's' }, { tools: 'not-an-array' }, ['subagent']))
.toEqual({ system: 's', tools: [] })
const unnamed = { system: 's', tools: [{ name: 42 }] }
expect(applyChildToolOmissions(unnamed, { tools: [] }, ['subagent'])).toEqual(unnamed)
})
it('rejects a non-object pinned header', () => {
expect(() => applyChildToolOmissions(null, {}, [])).toThrow(/must be an object/)
expect(() => applyChildToolOmissions([], {}, [])).toThrow(/must be an object/)
expect(() => applyChildToolOmissions('x', {}, [])).toThrow(/must be an object/)
})
})
describe('unknownToolCallIds', () => {
it('returns structured UNKNOWN_TOOL call ids and ignores other results', () => {
const log = [