fix(tools): collapse code-mode executor to run_code for model-direct calls
wireSchemas() already advertised only run_code under mode: 'code', but the executor resolved every call through get(), which returns the full visible map plus the reserved transport. A model could name a native tool directly and bypass run_code entirely. Route the execution-path lookups through a new private resolveExecution() that applies the mode collapse at the operation boundary: model-direct calls under 'code' may only name run_code (UNKNOWN_TOOL otherwise), while SDK sub-dispatches (parent token set) keep every visible tool. get()/schemas() public semantics are unchanged. The denial happens at createExecution, before the extensible policy pipeline — pre-execute listeners, approval ask, and guards never observe a call that is deterministically denied. A collapsed call honors the pre-dispatch cancellation contract, routes aborted results through the visible tool's finalizeContent, and captures the finalizer before argument materialization. Under code mode, a system-prompt/assemble listener filters out tool:* guidance sections that told the model to call native tools directly. The tools:sdk section and SDK types remain so programs can still use all tools through run_code. Fixes #1815
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { Context } from 'cordis'
|
||||
import { createUserMessage, CallId, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
@@ -13,6 +13,8 @@ import ToolRegistry, { defineContentToolFixture, TOOL_ABORTED_BEFORE_DISPATCH, T
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop'
|
||||
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
||||
import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
|
||||
import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
|
||||
|
||||
async function harness(adapter: MockAdapter, maxParallelToolCalls?: number) {
|
||||
const ctx = new Context()
|
||||
@@ -687,3 +689,75 @@ describe('tool-call scheduler: failure quiescence', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('code-mode native-tool denial through the agent loop', () => {
|
||||
/** A minimal in-process code runtime for test purposes — never actually runs. */
|
||||
class FakeCodeRuntime extends CodeRuntime {
|
||||
readonly language = 'typescript'
|
||||
readonly isolation = 'fake' as const
|
||||
async run(_request: CodeRunRequest): Promise<CodeRunResult> {
|
||||
return { logs: [] }
|
||||
}
|
||||
}
|
||||
|
||||
async function codeModeHarness(adapter: MockAdapter) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await ctx.plugin(ToolRegistry, { mode: 'code' })
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- FakeCodeRuntime is an internal test helper with an opaque type shape
|
||||
await ctx.plugin(FakeCodeRuntime as any)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
}
|
||||
|
||||
it('denies a model-direct native-tool call under code mode: tool body never runs and session records UNKNOWN_TOOL', async () => {
|
||||
let toolInvoked = false
|
||||
const tool = defineContentToolFixture({
|
||||
name: 'write',
|
||||
description: 'Write a file.',
|
||||
parameters: {
|
||||
file_path: { type: 'string', required: true },
|
||||
content: { type: 'string', required: true },
|
||||
},
|
||||
async execute(_args, _exec) {
|
||||
toolInvoked = true
|
||||
return [{ type: 'text', text: 'written' }]
|
||||
},
|
||||
})
|
||||
|
||||
// Scripted model emits a native tool call under code mode — the wire
|
||||
// never advertised it, but a non-compliant provider may still emit one.
|
||||
const adapter = new MockAdapter([
|
||||
[
|
||||
...multiCall([{ id: 'call-1', name: 'write', args: { file_path: '/tmp/test', content: 'hello' } }]),
|
||||
...textResponse('ok'),
|
||||
],
|
||||
])
|
||||
|
||||
const ctx = await codeModeHarness(adapter)
|
||||
ctx.tools.register(tool)
|
||||
|
||||
const agent = ctx.agentLoop.create(SessionId('code-native'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'write a file' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// The tool body must NOT have executed — the collapse denied the call
|
||||
// at createExecution, before the body could start.
|
||||
expect(toolInvoked).toBe(false)
|
||||
|
||||
// The session must record a tool/result with UNKNOWN_TOOL error so the
|
||||
// transcript faithfully captures that the call was denied.
|
||||
const sessionEvents = events(agent)
|
||||
const toolResult = sessionEvents.find(e => e.type === 'tool/result')
|
||||
expect(toolResult).toBeDefined()
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- tool/result data uses a loose event payload union
|
||||
expect((toolResult!.data as any).error).toMatchObject({
|
||||
name: 'ToolNotFoundError',
|
||||
code: 'UNKNOWN_TOOL',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user