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

@@ -100,18 +100,6 @@ export interface Scenario {
* {@link headerClass}.
*/
configPath?: string
/**
* Global tool names allowed to be ABSENT from a non-primary (child) session's
* request/header relative to the class pin — the delegation tool a child at
* its depth cap loses to tool-subagent's schema hiding. Each child header is
* compared against the pin minus exactly the declared names it actually
* omitted, so any other divergence (or an undeclared omission) still fails.
* A child that omitted a declared tool also skips the text-level initial
* system prompt pin: the prompt embeds the toolset (Code Mode SDK sections),
* so a reduced child cannot equal the full-composition expected output — the
* structural header assertion remains its pin. Meaningless on the primary log.
*/
childToolOmissions?: string[]
}
/** One suite's inputs: the agent to boot, where its fixtures live, and its scenario table. */
@@ -294,36 +282,6 @@ export function restorePinnedToolSchemas(header: unknown, schemas: readonly unkn
return { ...header, tools: schemas }
}
/**
* The pinned header with exactly the DECLARED omissions a child actually made
* removed from its tool list. A child at its depth cap legitimately lacks the
* delegation tool that spawned it (tool-subagent schema hiding); removing only
* declared-AND-actually-absent names keeps every other divergence — including
* an undeclared omission — a loud mismatch.
* @param pinned The class-pinned full header (tool schemas restored).
* @param actual The child session's normalized header under comparison.
* @param allowed The scenario's declared {@link Scenario.childToolOmissions}.
* @returns The expected header for this child log.
*/
export function applyChildToolOmissions(pinned: unknown, actual: unknown, allowed: readonly string[]): unknown {
if (pinned === null || typeof pinned !== 'object' || Array.isArray(pinned)) {
throw new Error('acp-snapshot: pinned request header must be an object')
}
const toolNames = (header: unknown): Set<string> => {
const tools = (header as { tools?: unknown }).tools
return new Set(Array.isArray(tools)
? tools.map(tool => (tool as { name?: unknown }).name).filter((name): name is string => typeof name === 'string')
: [])
}
const actualNames = toolNames(actual)
const pinnedTools = (pinned as { tools?: unknown[] }).tools ?? []
const tools = pinnedTools.filter((tool) => {
const name = (tool as { name?: unknown }).name
return !(typeof name === 'string' && allowed.includes(name) && !actualNames.has(name))
})
return { ...pinned, tools }
}
/**
* Render a normalized prompt as a repository-friendly Markdown snapshot.
* Prompt text is unchanged except that a missing terminal newline is added so
@@ -667,20 +625,9 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
.toBe(headers.length)
for (const [k, header] of headers.entries()) {
const expected = expectedChanges > 0 ? pinnedHeaders[k] : pinnedHeaders[0]
// A child (non-primary) log may omit declared delegation tools —
// schema hiding at the depth cap; see Scenario.childToolOmissions.
const childOmissions = logIndex === 0 ? [] : scenario.childToolOmissions ?? []
const target = childOmissions.length === 0
? expected
: applyChildToolOmissions(expected, header, childOmissions)
expect(header, `session ${log.id}: request/header #${k + 1} diverged from the pinned (${pinningScenario.name}) header`)
.toEqual(target)
// A child that omitted a declared tool cannot equal the text-level
// prompt pin (the prompt embeds the toolset); its header assertion
// above remains the structural pin.
const omittedDeclaredTool = target !== expected
&& (target as { tools?: unknown[] }).tools?.length !== (expected as { tools?: unknown[] }).tools?.length
if (expectedChanges === 0 && !omittedDeclaredTool) {
.toEqual(expected)
if (expectedChanges === 0) {
expect(formatSystemPromptSnapshot(prompts[k] as string), `session ${log.id}: initial system prompt #${k + 1} diverged from ${pinningScenario.name}/${SYSTEM_PROMPT_SNAPSHOT}`)
.toEqual(initialPromptSnapshot)
}

View File

@@ -1,116 +0,0 @@
{
"prompt": "respond",
"echoWorkspace": true,
"logs": [
{
"file": "b/parent.jsonl",
"lines": [
{
"type": "session",
"id": "{{SID}}",
"createdAt": 200,
"cwd": "{{CWD}}"
},
{
"type": "request/header",
"seq": 0,
"time": 5,
"data": {
"header": {
"config": {
"model": "fake"
},
"system": "SYS PROMPT",
"tools": [
{
"name": "t1",
"description": "D1",
"parameters": {
"type": "object"
}
}
]
},
"reason": "initial"
}
},
{
"type": "assistant/chunk",
"seq": 1,
"time": 5,
"data": {
"turn": 1,
"step": 1,
"chunk": {
"type": "text-delta",
"index": 0,
"text": "hi"
}
}
}
]
},
{
"file": "b/child1.jsonl",
"lines": [
{
"type": "session",
"id": "eeeeeeee-1111-4222-8333-444444444444",
"createdAt": 300,
"cwd": "{{CWD}}",
"parentSession": "{{SID}}"
},
{
"type": "request/header",
"seq": 0,
"time": 6,
"data": {
"header": {
"config": {
"model": "fake"
},
"system": "SYS PROMPT",
"tools": []
},
"reason": "initial"
}
}
]
},
{
"file": "b/child2.jsonl",
"lines": [
{
"type": "session",
"id": "ffffffff-2222-4333-8444-555555555555",
"createdAt": 400,
"cwd": "{{CWD}}",
"parentSession": "{{SID}}"
},
{
"type": "request/header",
"seq": 0,
"time": 6,
"data": {
"header": {
"config": {
"model": "fake"
},
"system": "SYS PROMPT",
"tools": [
{
"name": "t1",
"description": "D1",
"parameters": {
"type": "object"
}
}
]
},
"reason": "initial"
}
}
]
}
]
}

View File

@@ -1 +0,0 @@
{ "steps": [{ "op": "initialize" }, { "op": "newSession" }, { "op": "prompt", "text": "plain" }] }

View File

@@ -1,2 +0,0 @@
{"type":"session","id":"eeeeeeee-1111-4222-8333-444444444444","createdAt":12,"cwd":"/rec/plain-cwd","parentSession":"56565656-7878-4989-8a9a-9b9b9b9b9b9b"}
{"type":"request/header","seq":0,"time":12,"data":{"header":{"config":{"model":"fake"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}

View File

@@ -1,2 +0,0 @@
{"type":"session","id":"ffffffff-2222-4333-8444-555555555555","createdAt":13,"cwd":"/rec/plain-cwd","parentSession":"56565656-7878-4989-8a9a-9b9b9b9b9b9b"}
{"type":"request/header","seq":0,"time":12,"data":{"header":{"config":{"model":"fake"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}

View File

@@ -1,3 +0,0 @@
{"type":"session","id":"56565656-7878-4989-8a9a-9b9b9b9b9b9b","createdAt":11,"cwd":"/rec/plain-cwd"}
{"type":"request/header","seq":0,"time":11,"data":{"header":{"config":{"model":"fake"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"assistant/chunk","seq":1,"time":11,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"hi"}}}

View File

@@ -1,5 +0,0 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":false}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"thinking about it"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"workspace:seed.txt"}}}}
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}

View File

@@ -16,7 +16,6 @@ import {
parseToolSchemasSnapshot,
refreshFixtureReplacements,
sessionFixtureNames,
applyChildToolOmissions,
restorePinnedToolSchemas,
stabilizeRefreshLog,
unknownToolCallIds,
@@ -48,10 +47,6 @@ const RECORD_SRC = fileURLToPath(new URL('./fixtures/record-suite', import.meta.
const REPLAY_SCENARIOS: Scenario[] = [
{ name: 'pin-turn', hasModelTurn: true, recorded: true, pinsHeader: true, expectedHeaderChanges: 1, headerClass: 'main' },
{ name: 'plain-turn', hasModelTurn: true, recorded: true, headerClass: 'main', configPath: AGENT.configPath },
// Two scripted children under a declared omission: one omits t1 (header pin
// minus the declared tool, prompt pin skipped), one keeps the full set (pin
// and prompt compared verbatim) — the childToolOmissions branches.
{ name: 'child-omission', hasModelTurn: true, recorded: false, headerClass: 'main', childToolOmissions: ['t1'] },
{ name: 'no-model', hasModelTurn: false, recorded: false, headerClass: 'main' },
{ name: 'blocked-log', hasModelTurn: false, comparesLog: true, recorded: false, headerClass: 'main' },
{ name: 'authored-error', hasModelTurn: true, recorded: false, overridden: true, headerClass: 'main' },
@@ -371,37 +366,6 @@ describe('tool-schema snapshots', () => {
})
})
describe('applyChildToolOmissions', () => {
const pinned = { system: 's', tools: [{ name: 'bash' }, { name: 'subagent' }, { name: 'subagent_fork' }] }
it('removes exactly the declared tools the child actually omitted', () => {
const actual = { system: 's', tools: [{ name: 'bash' }, { name: 'subagent_fork' }] }
expect(applyChildToolOmissions(pinned, actual, ['subagent', 'subagent_fork']))
.toEqual({ system: 's', tools: [{ name: 'bash' }, { name: 'subagent_fork' }] })
})
it('keeps a declared tool the child still carries and an undeclared omission', () => {
// The child omitted `bash` (undeclared) — the expectation keeps it, so the
// equality assertion downstream still fails loudly on the real divergence.
const actual = { system: 's', tools: [{ name: 'subagent' }, { name: 'subagent_fork' }] }
expect(applyChildToolOmissions(pinned, actual, ['subagent']))
.toEqual(pinned)
})
it('tolerates a headerless tool list and unnamed tool entries', () => {
expect(applyChildToolOmissions({ system: 's' }, { tools: 'not-an-array' }, ['subagent']))
.toEqual({ system: 's', tools: [] })
const unnamed = { system: 's', tools: [{ name: 42 }] }
expect(applyChildToolOmissions(unnamed, { tools: [] }, ['subagent'])).toEqual(unnamed)
})
it('rejects a non-object pinned header', () => {
expect(() => applyChildToolOmissions(null, {}, [])).toThrow(/must be an object/)
expect(() => applyChildToolOmissions([], {}, [])).toThrow(/must be an object/)
expect(() => applyChildToolOmissions('x', {}, [])).toThrow(/must be an object/)
})
})
describe('unknownToolCallIds', () => {
it('returns structured UNKNOWN_TOOL call ids and ignores other results', () => {
const log = [