Bladeren bron

fix(subagent): validate depth config at load

Tianyi Cui 3 maanden geleden
bovenliggende
commit
48067c3a7a

+ 3 - 2
docs/config-catalog.md

@@ -893,8 +893,9 @@ export interface Config {
    * Recursion cap applied to every child this tool spawns (see
    * `SubagentStartRequest.maxDepth`): a spawn whose child would sit deeper
    * than this in the delegation tree is rejected. Requires the provider's
-   * `depthLimit` capability. Omitted ⇒ unbounded (bound it in deployments
-   * that expose this tool to children).
+   * `depthLimit` capability. Must be a non-negative safe integer and is
+   * validated when the plugin loads. Omitted ⇒ unbounded (bound it in
+   * deployments that expose this tool to children).
    */
   maxDepth?: number
 }

+ 1 - 1
packages/subagent/tool-subagent/README.md

@@ -17,7 +17,7 @@ The tool description and the `prompt` parameter description are DERIVED from the
 | `agentOptions` | Default per-child `{ model? }` applied to every spawned child. |
 | `persona` | Per-child persona that shadows the deployment persona; requires the provider's `persona` capability. |
 | `toolFilter` | Per-child `{ allow?, deny? }` restriction over global tools; requires the provider's `toolFilter` capability. |
-| `maxDepth` | Maximum delegation depth; requires the provider's `depthLimit` capability. |
+| `maxDepth` | Maximum delegation depth; a non-negative safe integer validated when this plugin loads. Requires the provider's `depthLimit` capability. |
 
 ## Lifecycle (synchronous collect)
 

+ 18 - 3
packages/subagent/tool-subagent/src/index.ts

@@ -84,8 +84,9 @@ export interface Config {
    * Recursion cap applied to every child this tool spawns (see
    * `SubagentStartRequest.maxDepth`): a spawn whose child would sit deeper
    * than this in the delegation tree is rejected. Requires the provider's
-   * `depthLimit` capability. Omitted ⇒ unbounded (bound it in deployments
-   * that expose this tool to children).
+   * `depthLimit` capability. Must be a non-negative safe integer and is
+   * validated when the plugin loads. Omitted ⇒ unbounded (bound it in
+   * deployments that expose this tool to children).
    */
   maxDepth?: number
 }
@@ -115,9 +116,20 @@ 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.number(),
+  maxDepth: z.natural().max(Number.MAX_SAFE_INTEGER),
 })
 
+/** Reject a recursion cap that cannot represent an exact delegation depth. */
+function assertMaxDepth(maxDepth: number | undefined): void {
+  if (maxDepth !== undefined && (
+    !Number.isSafeInteger(maxDepth)
+    || maxDepth < 0
+    || Object.is(maxDepth, -0)
+  )) {
+    throw new Error('tool-subagent: `maxDepth` must be a non-negative safe integer')
+  }
+}
+
 /**
  * Flatten a child's final output blocks to text for the tool result. The child
  * may return non-text blocks; this cut surfaces the text content (the common
@@ -188,6 +200,9 @@ export function providerWording(inherits: boolean): { description: string; promp
 }
 
 export function apply(ctx: Context, config: Config): void {
+  // Keep misconfiguration at plugin load even when a caller invokes apply()
+  // directly and bypasses Schemastery's natural/max metadata.
+  assertMaxDepth(config.maxDepth)
   // Misconfiguration fails loud AT LOAD (the check is self-contained): an
   // explicit `toolFilter: {}` would otherwise pass the capability gate and
   // kill every delegation later, in the child-setup `restrict({})` throw.

+ 10 - 0
packages/subagent/tool-subagent/tests/tool-subagent.spec.ts

@@ -493,6 +493,16 @@ describe('dsh-tool-subagent', () => {
     expect(seen?.maxDepth).toBe(2)
   })
 
+  it.each([
+    { label: 'a negative integer', value: -1 },
+    { label: 'a fractional number', value: 1.5 },
+    { label: 'negative zero', value: -0 },
+    { label: 'an unsafe integer', value: Number.MAX_SAFE_INTEGER + 1 },
+  ])('rejects maxDepth=$label when the plugin loads', async ({ value }) => {
+    await expect(setup({ provider: 'mock', maxDepth: value }))
+      .rejects.toThrow()
+  })
+
   it('a partial toolFilter (deny only) does not materialize an empty allow-list (deny-all trap)', async () => {
     let seen: { toolFilter?: { allow?: string[]; deny?: string[] } } | undefined
     const ctx = new Context()