fix(tool-subagent): enforce depth only at runtime

This commit is contained in:
Tianyi Cui
2026-07-20 17:58:57 +08:00
parent c40e63d04b
commit 46d60d6d90
26 changed files with 192 additions and 302 deletions

View File

@@ -69,9 +69,8 @@ describe('startInProcessRun', () => {
})
it('counts a RESUMED child by its persisted header depth, not the absent runtime depth', async () => {
// The review-reproduced failure chain: a depth-1 child comes back from
// persistence with a fresh AgentOptions (no subagentDepth). Its header must
// stay authoritative, or maxDepth: 1 would let it delegate as top-level.
// Resume rebuilds runtime options, so the durable header must keep this
// depth-1 child from delegating as though it were top-level.
const { ctx } = await setup([textResponse('unused')])
const resumed = (await ctx.agents.create({
sessionId: SessionId('resumed-child'),

View File

@@ -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 `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). |
| `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. The tool stays visible at the cap; each attempted start checks the calling agent's current depth and returns an errored tool result when rejected. |
## Concurrency

View File

@@ -12,7 +12,7 @@ import z from 'schemastery'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { assertSubagentMaxDepth, delegationDepthOf } from '@deepseek-ai/dsh-subagent'
import { assertSubagentMaxDepth } from '@deepseek-ai/dsh-subagent'
import type { SubagentProvider, SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
import type { TaskOutcome } from '@deepseek-ai/dsh-tasks'
@@ -57,9 +57,8 @@ export interface Config {
* 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
* schema when the provider supports `toolFilter` — the prompt face of the
* budget; the service keeps rejecting on the execution face.
* otherwise). The provider checks the calling agent's current depth at every
* start; the tool remains model-visible so runtime policy owns rejection.
* `'provider-managed'` is for an out-of-process provider (ACP) whose
* recursion budget belongs to the child harness's own deployment.
*/
@@ -199,28 +198,15 @@ function providerWording(inheritsConversation: boolean): { description: string;
}
}
function startRequest(
config: Config,
prompt: string,
parent: Agent,
signal: AbortSignal,
hideAtCapToolName: string | undefined,
): SubagentStartRequest {
function startRequest(config: Config, prompt: string, parent: Agent, signal: AbortSignal): SubagentStartRequest {
const maxDepth = typeof config.maxDepth === 'number' ? config.maxDepth : undefined
// A child AT the cap cannot delegate further: deny it this tool so its
// schema hides what the service would reject anyway (prompt face; the
// depth check at start remains the execution face).
const childAtCap = maxDepth !== undefined && delegationDepthOf(parent) + 1 >= maxDepth
const toolFilter = childAtCap && hideAtCapToolName !== undefined
? { ...config.toolFilter, deny: [...config.toolFilter?.deny ?? [], hideAtCapToolName] }
: config.toolFilter
return {
prompt: [{ type: 'text', text: prompt }],
parent,
signal,
...config.agentOptions !== undefined ? { agentOptions: config.agentOptions } : {},
...config.persona !== undefined ? { persona: config.persona } : {},
...toolFilter !== undefined ? { toolFilter } : {},
...config.toolFilter !== undefined ? { toolFilter: config.toolFilter } : {},
...maxDepth !== undefined ? { maxDepth } : {},
}
}
@@ -257,9 +243,6 @@ export function apply(ctx: Context, config: Config): void {
+ 'set maxDepth: \'provider-managed\' to leave the recursion budget to the provider',
)
}
// Schema hiding rides the child toolFilter, so it needs that capability;
// without it the depth check at start remains the only fence.
const hideAtCapToolName = provider.capabilities.toolFilter ? config.toolName ?? 'subagent' : undefined
const wording = providerWording(provider.inheritsParentContext)
const backgroundEnabled = config.enableRunInBackground !== false
disposeTool = ctx.tools.register(defineTool({
@@ -314,7 +297,7 @@ export function apply(ctx: Context, config: Config): void {
const controller = new AbortController()
const start = ctx.subagents.start(
config.provider,
startRequest(config, args.prompt, parent, controller.signal, hideAtCapToolName),
startRequest(config, args.prompt, parent, controller.signal),
)
return {
cancel: (reason?: string) => {
@@ -333,7 +316,6 @@ export function apply(ctx: Context, config: Config): void {
args.prompt,
parent,
exec.signal ?? new AbortController().signal,
hideAtCapToolName,
)
const run: SubagentRun = await ctx.subagents.start(config.provider, request)

View File

@@ -23,13 +23,9 @@ import { SessionId } from '@deepseek-ai/dsh-session'
* shipping code path.
*/
/** A minimal parent Agent: the tool reads `agent.id` plus the delegation depth off its header/options. */
function fakeAgent(id = 'parent-1', delegationDepth?: number): Agent {
return {
id: SessionId(id),
options: {},
session: { header: { ...delegationDepth === undefined ? {} : { delegationDepth } } },
} as unknown as Agent
/** A minimal parent Agent passed through to the provider request. */
function fakeAgent(id = 'parent-1'): Agent {
return { id: SessionId(id) } as unknown as Agent
}
async function setup(toolConfig: tool.Config, mockConfig: Partial<mock.Config> = {}) {
@@ -886,7 +882,7 @@ describe('background preflight failure (no orphaned child, by construction)', ()
})
})
describe('depth budget defaults and schema hiding', () => {
describe('depth budget configuration', () => {
/** Mount the tool over a request-capturing provider with full capabilities. */
async function captureSetup(config: Omit<tool.Config, 'provider'> = {}) {
const requests: SubagentStartRequest[] = []
@@ -916,37 +912,14 @@ describe('depth budget defaults and schema hiding', () => {
const { ctx, requests } = await captureSetup()
await callSubagent(ctx, { description: 'd', prompt: 'p' })
expect(requests[0]?.maxDepth).toBe(3)
expect(requests[0]?.toolFilter?.deny ?? []).not.toContain('subagent')
expect(requests[0]?.toolFilter).toBeUndefined()
})
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({ maxDepth: 1 })
it('forwards an explicit tool filter unchanged instead of encoding the depth policy into it', async () => {
const { ctx, requests } = await captureSetup({ toolFilter: { deny: ['dangerous'] }, maxDepth: 0 })
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'] }, maxDepth: 1 })
await callSubagent(ctx, { description: 'd', prompt: 'p' })
expect(requests[0]?.toolFilter?.deny).toEqual(expect.arrayContaining(['dangerous', 'subagent']))
})
it('keeps the tool visible for a child below the cap', async () => {
const { ctx, requests } = await captureSetup({ maxDepth: 2 })
await callSubagent(ctx, { description: 'd', prompt: 'p' })
expect(requests[0]?.maxDepth).toBe(2)
expect(requests[0]?.toolFilter?.deny ?? []).not.toContain('subagent')
})
it('counts the parent by its persisted header depth when hiding', async () => {
// A resumed depth-1 parent under maxDepth 2: its child is AT the cap and
// must lose the tool even though the parent's runtime options carry no depth.
const { ctx, requests } = await captureSetup({ maxDepth: 2 })
await callSubagent(ctx, { description: 'd', prompt: 'p' }, { agent: fakeAgent('resumed-parent', 1) })
expect(requests[0]?.toolFilter?.deny).toContain('subagent')
expect(requests[0]?.maxDepth).toBe(0)
expect(requests[0]?.toolFilter).toEqual({ deny: ['dangerous'] })
})
it('rejects a numeric maxDepth on a provider without the depthLimit capability at mount', async () => {