fix(tools): restrict what a scope inherits, not just the global layer

A restriction was compiled against the global tool layer alone: only
global-layer tools were tested against `admits()`, and every chain-layer
tool was overlaid unfiltered afterward. That read the exempt set as "the
global layer" when what it means is "what this scope registers itself" —
two descriptions of the same set only while every model-facing tool sat in
the host composition.

Moving those rows onto the agent plane separated them. A preset's tools are
an ANCESTOR contribution to a joined agent, so a subagent's `toolFilter`
stopped constraining anything it was given; and with the global layer empty
`restrict()` rejected every name it received as unknown, failing the child
outright. With the same tools in the global layer the filter still admits
and applies normally, which is what makes this a regression of the move
rather than a standing limitation.

`view()` now filters everything a scope inherits — the global layer and
every ancestor layer on its chain — and exempts only the layer the scope
owns. That exemption is load-bearing rather than incidental: the delegation
runtime registers a child's `report` and structured-output tools into the
child's own layer, and a filter naming the capabilities the child may use
must not strip the machinery it answers through. Tool order, and with it
prefix-cache reuse, is unchanged: inherited names keep their global-then-
ancestor position and own-layer names still come last.

The diagnostic said "unknown global tool" while listing what is really the
inherited surface; it now names the surface it checks and says why an
own-layer name is not restrictable.

Fixes #2185
This commit is contained in:
Yichen Jiang
2026-08-10 20:34:45 +08:00
parent 6301320a63
commit 43f3324a7b
14 changed files with 170 additions and 53 deletions

View File

@@ -103,6 +103,21 @@ describe('a child agent composed in-process', () => {
await run.dispose()
})
it('honours a tool filter over the preset tools it inherited', async () => {
const { ctx, parent } = await setupPresetHost()
const run = await startInProcessRun(
{ ...spawnRequest(parent), toolFilter: { deny: ['preset_only'] } },
{},
)
await run.result
// The capability filter is the only thing bounding a delegated child, and
// every tool it can name now arrives from the preset rather than the host.
expect(ctx.tools.schemas(run.localAgent).map(schema => schema.name)).toEqual([])
await run.dispose()
})
it('follows a parent that switched preset while blank', async () => {
const { ctx, parent } = await setupPresetHost()
// A DIFFERENT preset, so the assertion below distinguishes reading the

View File

@@ -298,7 +298,7 @@ describe('startInProcessRun', () => {
await expect(startInProcessRun({
...request(parent),
toolFilter: { deny: ['unknown-tool'] },
}, {})).rejects.toThrow('unknown global tool')
}, {})).rejects.toThrow('unknown inherited tool')
expect(ctx.agents.list()).toHaveLength(beforeAgents)
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
})

View File

@@ -436,7 +436,7 @@ describe('dsh-subagent-spawn', () => {
prompt: [{ type: 'text', text: 'do X' }],
parent,
toolFilter: { deny: ['no_such_tool'] },
})).rejects.toThrow(/unknown global tool "no_such_tool"/)
})).rejects.toThrow(/unknown inherited tool "no_such_tool"/)
expect(ctx.agents.list().length).toBe(before)
})
})