fix(tool-subagent): default maxDepth to 3

This commit is contained in:
Tianyi Cui
2026-07-20 17:12:14 +08:00
parent 27f37942df
commit 1a1ff56ab7
6 changed files with 15 additions and 12 deletions

View File

@@ -49,9 +49,11 @@ The global registry remains live. A deny-only filter admits a later global name
The depth limit bounds recursive delegation independently of tool visibility. A top-level agent has depth zero; an in-process child has its parent's validated depth plus one. `maxDepth` is an absolute non-negative safe integer, and a start rejects before child ownership begins when the derived child depth is greater than the cap.
Every public entry validates the domain rather than relying on one model-facing configuration path. Negative values, fractions, negative zero, non-finite values, unsafe integers, malformed stored parent depth, and derived overflow all reject. Omitting the cap leaves depth unbounded by this mechanism.
The effective parent depth is the greater of durable `SessionHeader.delegationDepth` and runtime `AgentOptions.subagentDepth`. An in-process child records its derived depth in the session header, and resume restores that header, so a restart cannot lower the recursion count.
A deployment can combine depth and filtering. For example, it may keep the delegation tool visible at depth one but set `maxDepth: 1`, or deny the delegation tool entirely in children. Neither choice changes the provider's conversation-history behavior.
Every public entry validates the domain rather than relying on one model-facing configuration path. Negative values, fractions, negative zero, non-finite values, unsafe integers, malformed stored parent depth, and derived overflow all reject. A direct `SubagentStartRequest` may omit the cap to leave depth unbounded; loader-resolved `dsh-tool-subagent` configuration instead defaults to `3`, accepts a numeric override, and uses explicit `'provider-managed'` to omit the cap for an out-of-process provider whose deployment owns its recursion budget. A numeric tool cap fails at provider mount when the provider lacks `depthLimit`.
A deployment can combine depth and filtering. When a numeric tool cap and `toolFilter` are supported, `dsh-tool-subagent` denies its configured tool name in a child whose derived depth is at the cap; the provider's independent depth check still rejects direct or alternate starts beyond it. A deployment may also deny delegation tools entirely in children. Neither choice changes the provider's conversation-history behavior.
### Capability gating keeps providers honest

View File

@@ -1180,7 +1180,7 @@ export interface Config {
deny?: string[]
}
/**
* Maximum child depth: a non-negative safe integer (default `1`; `0` forbids
* 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), and a child AT the cap additionally loses this tool from its

View File

@@ -105,7 +105,7 @@ const SCENARIOS: Scenario[] = [
},
{ name: 'cancel', hasModelTurn: true, recorded: false, overridden: true },
{ name: 'cancel-tool-calls', hasModelTurn: true, recorded: false, overridden: true },
// Children sit AT the default depth cap (maxDepth 1), so each child's header
// Children sit at this example's configured depth cap (`maxDepth: 1`), so each child's header
// legitimately omits the delegation tool that spawned it (schema hiding).
{ name: 'subagent-spawn', hasModelTurn: true, recorded: true, childToolOmissions: ['subagent'] },
{ name: 'subagent-multi', hasModelTurn: true, recorded: true, childToolOmissions: ['subagent'] },
@@ -125,7 +125,7 @@ const SCENARIOS: Scenario[] = [
pinsHeader: true,
headerClass: 'advanced',
configPath: ADVANCED_CONFIG,
// The direct spawn child sits AT the default cap and loses `subagent`;
// The direct spawn child sits at the configured cap and loses `subagent`;
// workflow children bypass tool-subagent and keep the full set.
childToolOmissions: ['subagent'],
},

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, 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). |
| `maxDepth` | Absolute delegation-depth cap, default `3` (`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

View File

@@ -54,7 +54,7 @@ export interface Config {
deny?: string[]
}
/**
* Maximum child depth: a non-negative safe integer (default `1`; `0` forbids
* 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), and a child AT the cap additionally loses this tool from its
@@ -81,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.union([z.natural().max(Number.MAX_SAFE_INTEGER), z.const('provider-managed' as const)]).default(1),
maxDepth: z.union([z.natural().max(Number.MAX_SAFE_INTEGER), z.const('provider-managed' as const)]).default(3),
})
/**

View File

@@ -912,23 +912,24 @@ describe('depth budget defaults and schema hiding', () => {
return { ctx, requests }
}
it('defaults maxDepth to 1 and forwards it in the start request', async () => {
it('defaults maxDepth to 3 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)
expect(requests[0]?.maxDepth).toBe(3)
expect(requests[0]?.toolFilter?.deny ?? []).not.toContain('subagent')
})
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()
const { ctx, requests } = await captureSetup({ maxDepth: 1 })
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'] } })
const { ctx, requests } = await captureSetup({ toolFilter: { deny: ['dangerous'] }, maxDepth: 1 })
await callSubagent(ctx, { description: 'd', prompt: 'p' })
expect(requests[0]?.toolFilter?.deny).toEqual(expect.arrayContaining(['dangerous', 'subagent']))
})