feat(tools): let one agent choose its tool presentation, and ship code

Code Mode was a deployment-wide field on the host `tools` row: a
deployment ran every session that way or none. The obvious product
shape — 代码模式 beside 标准/极简/创造 in the preset picker — had
nothing to hang on.

The registry itself cannot move into a preset; the agent loop's
scheduler, the api-proxy's presenters, and every tool plugin are its
consumers. So split the registry from its projection: `presentAs(mode)`
writes one cell on the calling agent's scope layer, exactly as
`restrict()` does, and the three reads that decided presentation take
that scope's mode instead of the service's. The config `mode` becomes
the default agents shadow rather than a process-wide fact.

Two consequences are load-bearing. `run_code` now enters a view only
for scopes whose own mode presents it — a native agent must not find it
dispatchable because another agent in the process does — and the
reserved name holds whatever the configured mode, since any agent may
select a code mode later.

`dsh-agent-tool-mode` is the row a preset carries to declare this. A
code mode waits for the host's `codeRuntime` rather than assuming it,
so a runtime-less deployment fails the preset at mount, naming the
row, instead of at the session's first request.

The shipped `code` preset is `standard` plus that row, ordered second.
This commit is contained in:
Yichen Jiang
2026-08-05 20:31:52 +08:00
parent d247c50c6f
commit 9eaa9d22a5
31 changed files with 1108 additions and 60 deletions
+103
View File
@@ -1481,3 +1481,106 @@ describe('the run_code dispatch bridge', () => {
expect(assembly.sections.some(section => section.name === 'tools:sdk')).toBe(false)
})
})
/**
* Presentation is per agent, because an agent preset composes it: one
* deployment runs a Code Mode agent beside native ones, and neither may see
* the other's catalog. The deployment `mode` is the default those agents
* shadow, not a process-wide fact.
*/
describe('per-agent presentation', () => {
it('gives one agent Code Mode while the deployment stays native', async () => {
const { ctx, systemPrompt } = await setup({ mode: 'native' })
registerEcho(ctx)
const { scope, agent } = await mintAgentScope(ctx)
scope.ctx.tools.presentAs('code')
const coded = await systemPrompt.assemble({ scope: agent })
expect(coded.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME])
expect(coded.sections.find(section => section.name === 'tools:sdk')?.text)
.toContain('echo')
// The deployment default is untouched: an agent that declared nothing —
// and the global view behind it — still sees the native catalog.
const native = await systemPrompt.assemble()
expect(native.tools.map(tool => tool.name)).toEqual(['echo'])
expect(native.sections.some(section => section.name === 'tools:sdk')).toBe(false)
})
it('keeps run_code out of a native agent\'s dispatch table', async () => {
const { ctx } = await setup({ mode: 'native' })
registerEcho(ctx)
const coded = await mintAgentScope(ctx, 'coded')
const plain = await mintAgentScope(ctx, 'plain')
coded.scope.ctx.tools.presentAs('code')
// Not merely hidden from the prompt: the transport one agent presents must
// not be dispatchable by another that never presented it.
expect(ctx.tools.get(RUN_CODE_NAME, coded.agent)).toBeDefined()
expect(ctx.tools.get(RUN_CODE_NAME, plain.agent)).toBeUndefined()
expect(ctx.tools.get(RUN_CODE_NAME)).toBeUndefined()
})
it('lets an agent opt out of a code-mode deployment', async () => {
const { ctx, systemPrompt } = await setup({ mode: 'code' })
registerEcho(ctx)
const { scope, agent } = await mintAgentScope(ctx)
scope.ctx.tools.presentAs('native')
const assembly = await systemPrompt.assemble({ scope: agent })
expect(assembly.tools.map(tool => tool.name)).toEqual(['echo'])
// The deployment's global section still reaches this scope; rendering it
// empty is what keeps the opted-out agent's prompt free of an SDK.
expect(assembly.sections.find(section => section.name === 'tools:sdk')?.text).toBe('')
})
it('restores the deployment default when the agent unloads', async () => {
const { ctx, systemPrompt } = await setup({ mode: 'native' })
registerEcho(ctx)
const { scope, agent } = await mintAgentScope(ctx)
const dispose = scope.ctx.tools.presentAs('code')
dispose()
const assembly = await systemPrompt.assemble({ scope: agent })
expect(assembly.tools.map(tool => tool.name)).toEqual(['echo'])
expect(assembly.sections.some(section => section.name === 'tools:sdk')).toBe(false)
})
it('refuses a second declaration for the same agent', async () => {
const { ctx } = await setup({ mode: 'native' })
const { scope } = await mintAgentScope(ctx)
scope.ctx.tools.presentAs('code')
// Two answers to "which form does the model see" is a contradiction, and
// silently keeping either one would make the composition unreadable.
expect(() => scope.ctx.tools.presentAs('both'))
.toThrow('conflicts with "code" already declared')
})
it('refuses an unscoped declaration', async () => {
const { ctx } = await setup({ mode: 'native' })
expect(() => ctx.tools.presentAs('code'))
.toThrow('requires a scoped context')
})
it('reserves run_code even where no agent presents it', async () => {
const { ctx } = await setup({ mode: 'native' })
// The name must stay free under a native deployment too: an agent preset
// mounting later would otherwise collide with whatever took it.
expect(() => registerEcho(ctx, RUN_CODE_NAME)).toThrow('is reserved')
})
it('reports the missing runtime against the agent\'s own mode', async () => {
const { ctx, systemPrompt } = await setup({ mode: 'native', runtime: false })
registerEcho(ctx)
const { scope, agent } = await mintAgentScope(ctx)
scope.ctx.tools.presentAs('both')
await expect(systemPrompt.assemble({ scope: agent }))
.rejects.toThrow('mode "both" requires a code runtime')
})
})