Просмотр исходного кода

fix(tool-subagent): default maxDepth to 3

Tianyi Cui 2 месяцев назад
Родитель
Сommit
1a1ff56ab7

+ 4 - 2
.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
 

+ 1 - 1
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

+ 2 - 2
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'],
   },

+ 1 - 1
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
 

+ 2 - 2
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<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),
 })
 
 /**

+ 5 - 4
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']))
   })