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:
Chinesezjc
2026-08-10 23:13:28 +08:00
parent 564a853a04
commit 4806fdabab
9 changed files with 232 additions and 54 deletions

View File

@@ -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',
})
})
})