diff --git a/docs/config-catalog.md b/docs/config-catalog.md index dc766ae25f..30a4c8a1a3 100644 --- a/docs/config-catalog.md +++ b/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 } diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 715fb0b9db..9aed426ac0 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/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) diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 4f2a1bc33d..c6dc808068 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/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 = 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. diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 95d26dabdf..194c63997c 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/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()