fix(subagent): validate depth config at load

This commit is contained in:
Tianyi Cui
2026-07-12 10:33:29 +08:00
parent 9fc2260bb6
commit 48067c3a7a
4 changed files with 32 additions and 6 deletions
+3 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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.
@@ -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()