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:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user