From 4806fdabab13bf8ca9130848e7461d5be1f7e316 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 10 Aug 2026 23:13:28 +0800 Subject: [PATCH] fix(tools): collapse code-mode executor to run_code for model-direct calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...8-07-code-mode-executor-collapse.i18n.yaml | 6 ++ .../2026-08-07-code-mode-executor-collapse.md | 45 +++++++++++ ...26-08-07-code-mode-executor-collapse.zh.md | 45 +++++++++++ .../core/agent-loop/tests/tool-calls.spec.ts | 76 ++++++++++++++++- packages/core/tools/README.i18n.yaml | 4 +- packages/core/tools/README.md | 4 +- packages/core/tools/README.zh.md | 4 +- packages/core/tools/src/index.ts | 81 ++++++++----------- packages/core/tools/tests/tools.spec.ts | 21 ++++- 9 files changed, 232 insertions(+), 54 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.i18n.yaml new file mode 100644 index 0000000000..b81b0fe922 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md +2026-08-07-code-mode-executor-collapse.md: 817da95678edbceb807c45b3ef858b800eaaa6c2 +2026-08-07-code-mode-executor-collapse.zh.md: 7b4a1b8adc01f6611c50cc4325c02d4b60313898 diff --git a/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md b/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md new file mode 100644 index 0000000000..817da95678 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md @@ -0,0 +1,45 @@ +# Agent Note: Code Mode collapses the executor, not just the wire + +Status: implemented + +English | [中文](2026-08-07-code-mode-executor-collapse.zh.md) + +## Problem + +`mode: 'code'` collapsed only the announcement surface, not the execution surface. `wireSchemas()` sent the model exactly one tool — `run_code` — but the executor resolved every call through `get()`, which returns the full visible map plus the reserved transport. A model that emitted a native tool name (`write`, `read`, `bash`, `subagent`, …) bypassed `run_code` entirely: the call traversed the normal pipeline and executed, even though no schema for it had ever been advertised. Providers do not intercept unadvertised tool names, so schema omission enforced nothing. + +The package contract names this exact anti-pattern: schema omission is not enforcement when a direct caller can bypass it; denial must be tested through the executor. + +## Decision + +`ToolRegistry` resolves callable definitions through a new private `resolveExecution(name, scope, nested)` that applies the mode collapse at the operation boundary that owns it. A model-direct call (`nested = false`) under `code` may only name the reserved `run_code` transport; every native name resolves to `undefined` and surfaces as the executor's existing `UNKNOWN_TOOL` error (an already-aborted caller signal keeps the cancellation contract: `ABORTED_BEFORE_DISPATCH`, with the visible tool's finalizer applied). A collapsed call terminates at `createExecution` — the first stage of `prepare` — BEFORE the extensible policy pipeline, so `tools/pre-execute` listeners, approval `ask`, and guards never observe a call that is deterministically denied; a human is never prompted to approve it. A nested sub-dispatch (`nested = true` — a `parent` token set, which only the `run_code` SDK binding sets in production code) may call any visible tool, so programs keep every binding the generated SDK declared. + +Four execution-path lookups — `executionMode`, `dispatchToolBody`, `postExecute`, `normalizeDispatchResult` — go through `resolveExecution`. `createExecution` applies the same collapse via the shared `collapses(name, nested)` predicate so it can distinguish a collapsed call from a genuinely unknown name before the policy pipeline. The public registry view (`get`) and SDK projection (`schemas`) keep their semantics: presentation, inspection, and binding enumeration still see the full visible set. The wire (`wireSchemas`) and the executor now agree. A collapsed call with non-JSON-serializable arguments reports the parameter `TypeError` (the invalid-args contract), not `UNKNOWN_TOOL` — the body still never runs and policy still does not. + +The collapse is a security-relevant invariant, so acceptance is pinned through the executor: a model-direct native call under `code` returns `UNKNOWN_TOOL`, the same tool via an SDK sub-dispatch succeeds, and `native`/`both` direct calls plus `run_code` itself are unchanged. The base [Code Mode foundation](../feature/2026-06-15-code-mode.md) owns the transport design this note layers the execution boundary onto. + +## Alternatives considered + +### Filter `get()` / the registry view by mode + +The view is consumed by presenters, `tool-cordis` inspection, and the SDK binder; collapsing it would hide from the program surface tools that must still bind, and would change the public resolution contract for every consumer, not just the executor. + +### Filter at the agent-loop entry + +The loop is not the only executor caller, and the distinction that matters (model-direct vs transport sub-dispatch) rides on the execution input, not at the loop boundary. An entry filter would also re-encode mode semantics the registry already owns. + +### Reject via a shipped guard + +Guards are an optional plugin extension; a security invariant must not depend on a deployment composing the right plugin. The registry owns the mode decision and must enforce it itself. + +### Keep schema omission only (status quo) + +No provider guarantees interception of unadvertised names; the reported session proves it does not happen. + +## Consequences + +- `mode: 'code'` now enforces what it announces: a model-direct native call becomes `UNKNOWN_TOOL`, which the model can correct by routing through `run_code` (a pre-aborted call still resolves `ABORTED_BEFORE_DISPATCH`, per the cancellation contract). +- `both` and `native` behavior is unchanged; SDK sub-dispatches are unchanged (the `parent` token is the discriminator). +- A collapsed call is rejected at `prepare`, BEFORE the extensible policy pipeline: pre-execute listeners, approval `ask`, and guards never observe it. `executionMode` also fails closed (`exclusive`), so scheduling has no observable difference. +- Under `code` mode, the imperative native-tool guidance sections (`tool:read`, `tool:write`, `tool:bash`, etc.) are filtered from the system prompt by a `system-prompt/assemble` listener so the model is never told to call a tool it cannot reach directly. The `tools:sdk` section (TypeScript bindings) remains, so programs can still use every tool through `run_code`. +- Any future composite transport that sets a `parent` token opts its sub-dispatches into the full table, matching the nested-call semantics the token already documents. diff --git a/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.zh.md b/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.zh.md new file mode 100644 index 0000000000..7b4a1b8adc --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.zh.md @@ -0,0 +1,45 @@ +# Agent Note: Code Mode 塌缩执行器而非仅通告面 + +Status: implemented + +[English](2026-08-07-code-mode-executor-collapse.md) | 中文 + +## 问题 + +`mode: 'code'` 只塌缩了通告面,没有塌缩执行面。`wireSchemas()` 只向模型发送一个工具——`run_code`——但执行器通过 `get()` 解析所有调用,而 `get()` 返回完整的可见工具表外加保留的传输工具。模型一旦发出原生工具名(`write`、`read`、`bash`、`subagent` 等),就能完全绕过 `run_code`:调用照常走完整流水线并执行成功,尽管它的 schema 从未被通告过。模型提供方不拦截未通告的工具名,因此不发 schema 等于没有约束。 + +包契约点名了这个反模式:当直接调用方可以绕过时,schema 省略不算强制执行;拒绝必须经执行器验证。 + +## 决策 + +`ToolRegistry` 通过新增的私有 `resolveExecution(name, scope, nested)` 解析可执行定义,在拥有该决策的操作边界上应用模式塌缩。`code` 模式下,模型直呼(`nested = false`)只允许命名保留的 `run_code` 传输工具;任何原生名字都解析为 `undefined`,并以执行器既有的 `UNKNOWN_TOOL` 错误呈现(已中止的调用方 signal 保留取消契约:`ABORTED_BEFORE_DISPATCH`,并应用可见工具的 finalizer)。被塌缩的调用在 `createExecution`(`prepare` 的第一阶段)即终止——在可扩展策略流水线之前,因此 `tools/pre-execute` 监听器、approval `ask` 与 guard 永远不会观察到一个注定被拒绝的调用,人类也不会被提示去批准它。嵌套子调用(`nested = true`——即设置了 `parent` token,生产代码中只有 `run_code` SDK 绑定会设置)可以调用任意可见工具,因此程序保留生成 SDK 声明的全部绑定。 + +执行链路的四处查表——`executionMode`、`dispatchToolBody`、`postExecute`、`normalizeDispatchResult`——改走 `resolveExecution`。`createExecution` 通过共享的 `collapses(name, nested)` 谓词应用同一塌缩,以便在策略流水线之前区分被塌缩的调用与真正未知的名字。公共注册表视图(`get`)与 SDK 投影(`schemas`)语义不变:展示、检查与绑定枚举仍看到完整可见集合。通告(`wireSchemas`)与执行器现在一致。带非 JSON 可序列化参数的塌缩调用报告参数 `TypeError`(invalid-args 契约),而非 `UNKNOWN_TOOL`——函数体仍不会运行,策略也不会执行。 + +塌缩是安全相关的不变量,因此验收经执行器钉死:`code` 模式下模型直呼原生工具返回 `UNKNOWN_TOOL`;同一工具经 SDK 子调用成功;`native`/`both` 模式直呼与 `run_code` 本身行为不变。本 note 把执行边界叠加在基础 [Code Mode 基础](../feature/2026-06-15-code-mode.md) 之上,传输设计由后者拥有。 + +## 备选方案 + +### 按模式过滤 `get()` / 注册表视图 + +视图被展示方、`tool-cordis` 检查与 SDK 绑定消费;塌缩视图会从程序表面隐藏仍必须绑定的工具,并改变所有消费者的公共解析契约,而不只是执行器。 + +### 在 agent-loop 入口过滤 + +loop 不是唯一的执行器调用方,且真正要紧的区分(模型直呼 vs 传输子调用)挂在执行输入上,不在 loop 边界。入口过滤还会重复编码注册表已经拥有的模式语义。 + +### 通过内置 guard 拒绝 + +guard 是可选的插件扩展;安全不变量不能依赖部署恰好组装了正确的插件。模式决策归注册表所有,必须由它自己执行。 + +### 只保留 schema 省略(维持现状) + +没有提供方保证拦截未通告的名字;被报告的会话证明拦截不会发生。 + +## 后果 + +- `mode: 'code'` 现在兑现其通告:模型直呼原生工具变为 `UNKNOWN_TOOL`,模型可以通过改走 `run_code` 自行纠正(已中止的调用仍按取消契约解析为 `ABORTED_BEFORE_DISPATCH`)。 +- `both` 与 `native` 行为不变;SDK 子调用不变(判别信号是 `parent` token)。 +- 被塌缩的调用在 `prepare` 阶段即被拒绝——在可扩展策略流水线之前:pre-execute 监听器、approval `ask` 与 guard 永远不会观察到它。`executionMode` 同样 fail-closed(`exclusive`),调度无可观察差异。 +- 在 `code` 模式下,命令式原生工具指引段(`tool:read`、`tool:write`、`tool:bash` 等)现在通过 `system-prompt/assemble` 监听器从系统提示词中过滤,模型不会再被告知去直接调用它无法触达的工具。`tools:sdk` 段(TypeScript 绑定)保留,程序仍可通过 `run_code` 使用所有工具。 +- 未来任何设置 `parent` token 的组合传输,其子调用自动走全表,与该 token 已有的嵌套调用语义一致。 diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index 972c321138..10cbf9f056 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -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 { + 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', + }) + }) +}) diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index b121662390..5ab35d7ce0 100644 --- a/packages/core/tools/README.i18n.yaml +++ b/packages/core/tools/README.i18n.yaml @@ -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 diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 75d18712a0..d556130bf9 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -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 diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index 8d4ae42596..d7766b432c 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -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 diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 01259b8de2..a10962aba9 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -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 /** Pre-restriction capability names used by prompt-order validation. */ readonly knownNames: ReadonlySet - /** Current inherited names a scoped restriction may name; its own are exempt. */ + /** Current global names that a scoped restriction may name. */ readonly restrictableNames: ReadonlySet } @@ -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(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() const knownNames = new Set() const restrictableNames = new Set() - 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) } diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index 217875898d..03d2d6b75b 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -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)