feat(tool-subagent): default maxDepth 1 with at-cap schema hiding
An omitted maxDepth meant unbounded recursion, and the shipped examples shipped that default. maxDepth now defaults to 1; a numeric cap requires the provider's depthLimit capability (the mount fails loud and points to the explicit 'provider-managed' opt-out for out-of-process providers), and a child AT the cap loses the delegating tool from its own schema via the child toolFilter — prompt-face hiding on top of the execution-face depth check. Examples pin maxDepth explicitly. The ACP snapshot harness gains Scenario.childToolOmissions so a child session may legitimately omit declared delegation tools from its pinned header and prompt; affected subagent/workflow goldens are re-recorded.
This commit is contained in:
@@ -100,6 +100,18 @@ 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 golden — 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. */
|
||||
@@ -282,6 +294,36 @@ 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
|
||||
@@ -625,9 +667,20 @@ 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(expected)
|
||||
if (expectedChanges === 0) {
|
||||
.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) {
|
||||
expect(formatSystemPromptSnapshot(prompts[k] as string), `session ${log.id}: initial system prompt #${k + 1} diverged from ${pinningScenario.name}/${SYSTEM_PROMPT_SNAPSHOT}`)
|
||||
.toEqual(initialPromptSnapshot)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
parseToolSchemasSnapshot,
|
||||
refreshFixtureReplacements,
|
||||
sessionFixtureNames,
|
||||
applyChildToolOmissions,
|
||||
restorePinnedToolSchemas,
|
||||
stabilizeRefreshLog,
|
||||
unknownToolCallIds,
|
||||
@@ -366,6 +367,37 @@ 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 = [
|
||||
|
||||
Reference in New Issue
Block a user