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

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/tools/README.md
README.md: 75d18712a02ecc72c2ea3a7203d2a7377cef87c7
README.zh.md: 8d4ae42596483f77aa82b23a0b41168465e2b165
README.md: d556130bf924b8dbd7ba4d5afdd8bdfc792be38f
README.zh.md: d7766b432c5a319d214da80e3df438489519be92

View File

@@ -19,7 +19,7 @@ tools:
- `ctx.tools.register(definition: ToolDefinition): () => void` Register a trusted typed same-process definition with a mandatory canonical `output` declaration. The layer is the calling context's scope: a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. Missing or unsupported output declarations and a non-positive or non-finite `timeoutMs` fail at registration. The optional synchronous `finalizeContent` callback is snapshotted when a call starts and may replace only final model-facing content after every pipeline outcome is normalized, including an error discovered while materializing another result field. Disposed with the calling fiber.
- `ctx.tools.presentAs(mode: ToolPresentationMode): () => void` selects this agent's model-facing presentation, shadowing the `mode` config for that agent alone; it throws from a plain context (a process-wide presentation is the config field) and from a second declaration in the same scope. A code mode also registers that agent's own `tools:sdk` section. The catalog is unchanged — `schemas(agent)` still reports the agent's capabilities; only the assembly's tools collapse. Disposed with the calling fiber.
- `ctx.tools.restrict(filter)` applies an agent-scoped allow/deny mask to the tools that scope INHERITS — the global layer and every ancestor scope on its chain — and throws from a plain context. The scope's OWN registrations are exempt and merge afterwards, which is what keeps a delegated child's reporting and structured-output tools alive under a filter naming only the capabilities it may use. The filter is snapshotted at registration; multiple masks intersect, and a mask on an ancestor reaches every scope nested inside it. Deny masks admit later unnamed inherited tools, while allow masks exclude later names. Unknown, own-layer, or reserved names and empty filters reject. This is live visibility composition, not an authority boundary; see the [scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals).
- `ctx.tools.restrict(filter)` applies an agent-scoped allow/deny mask to global tools and throws from a plain context. The filter is snapshotted at registration; multiple masks intersect and scope-local tools merge afterwards. Deny masks admit later unnamed globals, while allow masks exclude later names. Unknown, local, or reserved names and empty filters reject. This is live visibility composition, not an authority boundary; see the [scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals).
- `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed.
- `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md)).
- `ctx.tools.guard(guard: ToolGuard): () => void` Register a monotonic synchronous execution guard after `tools/pre-execute`: returning a reason denies the call, while `undefined` leaves it unchanged. A plain-context guard applies globally; an `agent.ctx` guard applies only to that agent. Later waterfall listeners cannot turn a guard denial back into permission. Disposed with the calling fiber.
@@ -66,7 +66,7 @@ First-party plugin authors can use the `defineTool()` helper (exported from this
```ts
import { readFile } from 'node:fs/promises'
import type { Context } from '@deepseek-ai/cordis'
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
declare const ctx: Context

View File

@@ -19,7 +19,7 @@ tools:
- `ctx.tools.register(definition: ToolDefinition): () => void`:注册一个受信任、带类型的同进程定义,其中必须包含规范的 `output` 声明。所在层由调用上下文的作用域决定普通插件上下文会全局注册agent 的 `agent.ctx` 只为该 agent 注册,并在此处遮蔽同名全局工具。同一层内名称重复会抛出;非原生模式还会拒绝保留的 `run_code` 传输名称。缺失或不受支持的输出声明,以及非正数或非有限的 `timeoutMs`,都会使注册失败。可选的同步 `finalizeContent` 回调会在调用开始时创建快照;在所有流水线结果规范化之后,它只能替换最终面向模型的内容,包括实体化其他结果字段时发现的错误。随调用 fiber dispose资源释放
- `ctx.tools.presentAs(mode: ToolPresentationMode): () => void`:为本 agent 选择面向模型的呈现方式,仅对该 agent 遮蔽 `mode` 配置;从普通上下文调用会抛出(进程级呈现方式是那个配置字段),同一 scope 内第二次声明也会抛出。code 类模式还会为该 agent 注册它自己的 `tools:sdk` 段。清单本身不变——`schemas(agent)` 报告的仍是该 agent 的能力,坍缩的只是 assembly 里的工具。随调用方 fiber 一同释放。
- `ctx.tools.restrict(filter)`:对该作用域**继承来的**工具——全局层以及其链上的每个祖先作用域——应用 agent 作用域的允许/拒绝掩码;从普通上下文调用会抛出。作用域**自身**的注册不受掩码约束,并在其后合并进来,这正是让被委派子 agent 的回报与结构化输出工具能在只点名其可用能力的筛选器下存活的机制。筛选器在注册时创建快照;多个掩码取交集,祖先上的掩码作用于其内嵌套的每个作用域。拒绝掩码会接纳后来出现且未点名的继承工具,而允许掩码会排除后来出现的名称。未知、自身层或保留名称以及空筛选器都会被拒绝。这是实时可见性组合,不是权限边界;参见[作用域安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。
- `ctx.tools.restrict(filter)`:对全局工具应用 agent 作用域的允许/拒绝掩码;从普通上下文调用会抛出。筛选器在注册时创建快照;多个掩码取交集,随后再合并作用域本地工具。拒绝掩码会接纳后来出现且未点名的全局工具,而允许掩码会排除后来出现的名称。未知、本地或保留名称以及空筛选器都会被拒绝。这是实时可见性组合,不是权限边界;参见[作用域安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。
- `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined`:按某个作用域所见的结果解析(应用遮蔽;被限制掉的全局工具视为不存在)。呈现器会传入发起调用的 agent使卡片与实际执行内容一致。
- `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]`:返回该作用域可见的所有 schema不含 `execute` 函数)。已交付工具的 schema 收录在 [docs/tool-catalog.md](../../../docs/tool-catalog.md) 中;该目录通过启动每个工具插件并采集此方法的结果生成(参见[工具 schema 目录 Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md))。
- `ctx.tools.guard(guard: ToolGuard): () => void`:在 `tools/pre-execute` 之后注册单调同步执行守卫:返回理由会拒绝调用,返回 `undefined` 则保持原决定。普通上下文守卫全局生效;`agent.ctx` 守卫只对该 agent 生效。后续 waterfall瀑布式事件监听器无法将守卫的拒绝重新变为允许。随调用 fiber dispose。
@@ -66,7 +66,7 @@ tools:
```ts
import { readFile } from 'node:fs/promises'
import type { Context } from '@deepseek-ai/cordis'
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
declare const ctx: Context

View File

@@ -4,8 +4,8 @@
* @module @deepseek-ai/dsh-tools
*/
import { Context, Service } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { Context, Service } from 'cordis'
import z from 'schemastery'
import { AnonymousEntries, NamedEntries, ScopedLayers, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
import type { ScopeKey, ScopeLayer, Scoped } from '@deepseek-ai/dsh-scope'
import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
@@ -120,7 +120,7 @@ export type {
WebSource,
} from './presentation.ts'
declare module '@deepseek-ai/cordis' {
declare module 'cordis' {
interface Context {
tools: ToolRegistry
}
@@ -313,6 +313,10 @@ export interface ToolExecutionInput {
* Opaque token of the enclosing transport execution, when one exists. Code
* Mode sets this on SDK sub-dispatches so commit-style observers can wait for
* the outer `run_code` outcome without receiving its live mutable execution.
* The token also marks the call as a transport sub-dispatch rather than a
* model-direct call: under `mode: 'code'`, only calls WITH a parent may
* execute a native tool name — a model-direct call (no parent) is denied as
* `UNKNOWN_TOOL` before the policy pipeline. See {@link ToolRegistry.execute}.
*/
readonly parent?: ToolExecutionToken
/** Required caller-owned cancellation for this invocation. */
@@ -647,14 +651,13 @@ export interface Config {
}
/**
* Per-scope filter over the tools a scope INHERITS — the global layer and
* every ancestor layer on its chain. Restrictions intersect, and do not affect
* the scope's own registrations or the reserved Code Mode transport.
* Per-scope filter over global tools. Restrictions intersect and do not affect
* scoped registrations or the reserved Code Mode transport.
*/
export interface ToolRestriction {
/** Inherited tool names that stay visible; every other inherited one is removed. */
/** Global tool names that stay visible; everything else is removed. */
readonly allow?: readonly string[]
/** Inherited tool names removed from visibility. */
/** Global tool names removed from visibility. */
readonly deny?: readonly string[]
}
@@ -670,7 +673,7 @@ interface ToolView {
readonly visible: ReadonlyMap<string, ToolDefinition>
/** Pre-restriction capability names used by prompt-order validation. */
readonly knownNames: ReadonlySet<string>
/** Current inherited names a scoped restriction may name; its own are exempt. */
/** Current global names that a scoped restriction may name. */
readonly restrictableNames: ReadonlySet<string>
}
@@ -708,7 +711,7 @@ class ToolLayer implements ScopeLayer {
&& this.mode === undefined
}
/** Whether every compiled restriction in this layer admits an inherited tool name. */
/** Whether every compiled restriction in this layer admits a global tool name. */
admits(name: string): boolean {
for (const filter of this.restrictions.values()) {
if ((filter.allow !== undefined && !filter.allow.has(name))
@@ -807,6 +810,17 @@ export class ToolRegistry extends Service {
if (this.defaultMode !== 'native') {
ctx.systemPrompt.section(this.sdkSection())
}
// Under `code` mode, filter out tool-specific guidance sections
// (`tool:*`) that instruct the model to call native tools directly.
// The `tools:sdk` section and SDK types remain — they teach the model
// how to call tools through `run_code`.
if (this.defaultMode === 'code') {
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
const result = await next()
result.sections = result.sections.filter(s => !s.name.startsWith('tool:'))
return result
})
}
}
/**
@@ -913,7 +927,6 @@ export class ToolRegistry extends Service {
// keeps one rule instead of a case analysis.
if (mode !== 'native') yield ctx.systemPrompt.section(this.sdkSection())
}.bind(this), 'tools.presentAs()')
// oxlint-disable-next-line typescript/no-misused-promises -- synchronous composite teardown; direct return preserves disposer identity
return dispose
}
@@ -1032,7 +1045,7 @@ export class ToolRegistry extends Service {
const known = this.view(scope).restrictableNames
const unknown = [...allow ?? [], ...deny ?? []].filter(name => !known.has(name))
if (unknown.length > 0) {
throw new Error(`tools.restrict() names unknown inherited tool${unknown.length > 1 ? 's' : ''} ${unknown.map(n => `"${n}"`).join(', ')}; a restriction filters what this scope inherits, never what it registers itself. Restrictable tools: ${[...known].sort().join(', ') || '(none)'}`)
throw new Error(`tools.restrict() names unknown global tool${unknown.length > 1 ? 's' : ''} ${unknown.map(n => `"${n}"`).join(', ')}; known global tools: ${[...known].sort().join(', ') || '(none)'}`)
}
return this.layers.effect(
this.ctx,
@@ -1073,54 +1086,30 @@ export class ToolRegistry extends Service {
/**
* Resolve every registry fact one scope needs in one layer traversal. The
* visible map applies restrictions to the INHERITED surface, then the
* scope's own registrations and the reserved presentation transport; the
* other sets retain the pre-restriction facts needed by restriction and
* prompt-order validation.
*
* A restriction filters what a scope inherits — the global layer and every
* ancestor layer on its chain — and never what its OWN layer registers.
* That exemption is what a per-child capability filter has to keep intact:
* the delegation runtime registers a child's reporting 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.
*
* Reading the exempt set as "the global layer" instead of "not mine" held
* only while every model-facing tool sat in the host composition. Once
* presets moved them onto the agent plane they became an ANCESTOR
* contribution, so a child's filter silently stopped constraining anything
* it was given.
* visible map applies global restrictions, scoped shadowing, and the reserved
* presentation transport; the other sets retain the pre-restriction facts
* needed by restriction and prompt-order validation.
* @param scope - the viewing scope (the agent), or undefined for the global view.
* @returns the complete derived view for that scope.
*/
private view(scope?: ScopeKey): ToolView {
// Scope-chain layers, farthest ancestor first, the exact scope last.
const layers = this.layers.chainLayers(scope)
// Chain-blind on purpose: this is the ONE layer whose registrations the
// scope owns rather than inherits, and it is absent until the scope
// contributes something.
const own = this.layers.peek(scope)
// Inherited surface, nearest ancestor last: a nearer scope's same-name
// entry shadows a farther one, and the global layer is the farthest.
const inherited = new Map<string, ToolDefinition>(this.layers.global.tools.entries())
for (const layer of layers) {
if (layer === own) continue
for (const [name, definition] of layer.tools.entries()) inherited.set(name, definition)
}
const visible = new Map<string, ToolDefinition>()
const knownNames = new Set<string>()
const restrictableNames = new Set<string>()
for (const [name, definition] of inherited) {
for (const [name, definition] of this.layers.global.tools.entries()) {
knownNames.add(name)
restrictableNames.add(name)
// Restrictions intersect across the whole chain: any scope on it may
// mask an inherited name for everything nested inside it.
// mask a global-surface name for everything nested inside it.
if (layers.every(layer => layer.admits(name))) visible.set(name, definition)
}
// The scope's own registrations last, shadowing an inherited name and
// outside the filter above.
if (own !== undefined) {
for (const [name, definition] of own.tools.entries()) {
// Chain layers second, nearest last: same-name entries REPLACE (shadow)
// the global and farther-scope ones, and scope-local registrations are
// never part of the global filter above.
for (const layer of layers) {
for (const [name, definition] of layer.tools.entries()) {
knownNames.add(name)
visible.set(name, definition)
}

View File

@@ -1,5 +1,5 @@
import { describe, expect, expectTypeOf, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { Context } from 'cordis'
import { createUserMessage, CallId, HarnessError, type ContentBlock } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import type { Agent } from '@deepseek-ai/dsh-agent'
@@ -907,6 +907,25 @@ describe('ToolRegistry', () => {
})
})
it('keeps the normalized content when the final content transform returns undefined', async () => {
const ctx = await setup()
let finalized = 0
ctx.tools.register({
...echoTool,
name: 'identity-finalizer',
finalizeContent() {
finalized += 1
return undefined
},
})
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('identity-finalizer'), name: 'identity-finalizer', arguments: {} })
expect(result.isError).toBe(false)
expect(result.content).toEqual([{ type: 'text', text: '' }])
expect(finalized).toBe(1)
})
it('a block decision can ALSO attach additionalContexts', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)