diff --git a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md index 29f77364fa..6e4b388a26 100644 --- a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md +++ b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md @@ -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 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 07546946e2..d88718229c 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -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 diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 452e0e6d19..849c2230d4 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -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'], }, diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 3a0c2c4139..8121531a7b 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -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 diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 3cde5d6a1a..8eae576f09 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -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 = 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), }) /** diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index f827d6d2ba..7aa8f10d45 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -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'])) })