diff --git a/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.i18n.yaml new file mode 100644 index 0000000000..be31205827 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.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/feature/2026-08-05-per-agent-tool-presentation.md +2026-08-05-per-agent-tool-presentation.md: 95869e2e6d0cc60237cc15038afe442841f120b6 +2026-08-05-per-agent-tool-presentation.zh.md: f6556f819c2f4ce7e342187fc42c22727e6bfb9b diff --git a/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.md b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.md new file mode 100644 index 0000000000..348f7ab0a2 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.md @@ -0,0 +1,46 @@ +# Agent Note: Per-agent tool presentation, and the `code` preset + +Status: implemented + +English | [中文](2026-08-05-per-agent-tool-presentation.zh.md) + +## Problem + +Agent presets compose an agent's tools per session, but not the FORM those tools reach the model in. Code Mode — one `run_code` tool plus a generated TypeScript SDK, replacing a call sequence with one program — was a deployment-wide `mode` field on the host's `dsh-tools` row. A deployment either ran every session in Code Mode or none, so the obvious product shape ("代码模式" beside 标准/极简/创造 in the preset picker) had nothing to hang on. + +The naive reading of "move tools down to the agent plane" does not work. `ctx.tools` has host-plane consumers that cannot follow it: `dsh-agent-loop` reads the registry's private scheduler seam, `dsh-apiproxy` reads its presenters to render tool cards, and every tool plugin registers into it. By the stack's own rule — a service moves into a preset only when ALL of its consumers move with it — the registry stays where it is. + +## Decision + +Split the registry from its projection. The registry stays host-plane; the **presentation** becomes per-agent state inside it, alongside the per-agent restrictions and guards that already live there. + +`ToolRegistry.presentAs(mode)` is scoped-only and mirrors `restrict()`: it writes one cell on the calling scope's `ToolLayer` through `ScopedLayers.effect`, so it unwinds with the agent that declared it. `modeFor(scope)` resolves that cell against the config `mode`, which becomes the default for agents declaring nothing rather than a process-wide fact. The three reads that decided presentation — the wire schemas, the `run_code` entry in the visibility view, and the generated SDK section — take the scope's mode instead of the service's. + +Two consequences fell out and are load-bearing: + +- **`run_code` is appended per scope.** Previously the transport entered every view whenever the transport existed. Per-agent, a native agent must not find `run_code` in its dispatch table because some other agent in the process presents it — so the append is conditional on that scope's own mode, and the transport is built lazily on first need. +- **The reserved name is now unconditional.** `run_code` was rejected as a registration only while a code mode was configured. Any agent may now select a code mode, so a name that was free to take under a native deployment would become a collision the moment a preset mounted. + +The SDK prompt section is registered globally by a code-mode deployment (unchanged) and additionally per agent by `presentAs`, where it shadows by name. Its body renders empty for a native scope, which the prompt renderer drops — that is what keeps an agent opting OUT of a code-mode deployment free of an SDK section. + +The preset expresses the choice through one row, `@deepseek-ai/dsh-agent-tool-mode`, whose whole body is a `presentAs` call. A code mode waits for `ctx.codeRuntime` through `ctx.inject` rather than assuming it: the runtime is host-plane, and a pending row is what `dsh-agent-presets` already reports as an unusable mount, naming the row — so a preset selecting Code Mode against a runtime-less deployment fails where an operator can act. + +## Alternatives considered + +**A second `ToolRegistry` inside the preset's isolate realm.** Rejected: `dsh-agent-loop` resolves the registry once from the host context through a private symbol, so a per-agent registry would be invisible to the scheduler. Making the loop registry-per-agent is a far larger change than making one field scope-aware. + +**A top-level key in the preset's own YAML.** Rejected for the reason preset display metadata went to a separate `preset.yml`: the composition is a top-level list of plugin rows and cannot carry sibling keys. + +**Naming the package `dsh-tool-mode`.** Rejected by a gate, correctly. `gen-tool-catalog` globs `packages/*/tool-*` and requires every match to publish a model-facing tool schema, because that prefix means "ships a tool" in this repo. This row ships none. + +**Registering the SDK section unconditionally from the constructor.** Rejected after trying it: `renderPrompt` drops empty sections but `PromptAssembly.sections` retains them, so every native deployment would carry a `tools:sdk` entry rendering nothing, and two existing assertions on that list would have had to be weakened to accommodate it. + +**Sharing `standard`'s composition by include.** Rejected per the stack's own convention: `cordis` already duplicates `standard`, and a preset's value is that its whole composition is readable in one file. The cost — a third copy of ~240 lines that must move together — is real and is the strongest argument for a future include mechanism. + +## Consequences + +Two sessions in one process can now present differently, so "which tools does the model see" is no longer answerable from the deployment config alone; it requires the agent. Every diagnostic that quotes a mode now quotes the scope's, not the service's. + +`ctx.tools.schemas(agent)` remains the agent's CAPABILITY catalog and is unchanged by presentation — only the assembly's tools collapse. Tests asserting what the model receives must read the assembly; `web-agent-presets.spec.ts` asserts both sides of that distinction for the shipped `code` preset. + +The shipped roster is four presets (标准/代码/极简/创造), so any golden listing them moves. A deployment that composes no code runtime can compose no code-mode preset; the shipped Web overlay carries one, the base composition does not. diff --git a/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.zh.md b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.zh.md new file mode 100644 index 0000000000..4920ee6eb0 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.zh.md @@ -0,0 +1,46 @@ +# Agent Note: 按 agent 的工具呈现方式,以及 `code` 预设 + +Status: implemented + +[English](2026-08-05-per-agent-tool-presentation.md) | 中文 + +## Problem + +agent preset 已经能按会话组装一个 agent 的工具,却管不了这些工具以何种**形态**抵达模型。Code Mode——一个 `run_code` 工具加一份生成的 TypeScript SDK,用一段程序替代一串调用——此前是宿主 `dsh-tools` 那一行上的部署级 `mode` 字段。一个部署要么所有会话都跑 Code Mode,要么一个都不跑,于是那个显而易见的产品形态(预设选择器里「代码模式」与标准/极简/创造并列)无处安放。 + +「把 tools 下沉到 agent 平面」这个字面读法行不通。`ctx.tools` 有一批跟不下来的宿主平面消费者:`dsh-agent-loop` 读它私有的调度器 seam,`dsh-apiproxy` 读它的 presenter 来渲染工具卡,每个工具插件都往里注册。按本 stack 自己的规则——只有**所有**消费者一起下沉,服务才能下沉——注册表必须留在原地。 + +## Decision + +把注册表和它的投影拆开。注册表留在宿主平面;**呈现方式**变成它内部按 agent 的状态,与已经住在那里的按 agent 限制和守卫并列。 + +`ToolRegistry.presentAs(mode)` 只接受 scoped 上下文,形状照抄 `restrict()`:它通过 `ScopedLayers.effect` 在调用方 scope 的 `ToolLayer` 上写一个单元,因此会随声明它的那个 agent 一起卸载。`modeFor(scope)` 将该单元与 config 的 `mode` 一并解析,后者于是成为「未作声明的 agent」的默认值,而不再是进程级事实。原先决定呈现方式的三处读取——wire schema、可见性视图里的 `run_code` 条目、以及生成的 SDK 段——改为读取该 scope 的模式,而非服务的。 + +有两个随之而来的结果,且都是承重的: + +- **`run_code` 按 scope 追加。** 此前只要传输存在,它就进入每一个视图。按 agent 之后,一个 native agent 不能因为进程里别的 agent 呈现了它、就在自己的分发表里看到 `run_code`——因此这次追加以该 scope 自身的模式为条件,传输也改为首次需要时才构建。 +- **保留名现在无条件生效。** `run_code` 此前只在配置了 code 模式时才被拒绝注册。如今任何 agent 都可能选择 code 模式,因此一个在 native 部署下可以随便占用的名字,会在某个 preset 挂载的那一刻变成冲突。 + +SDK 提示词段由 code 模式的部署全局注册(不变),并由 `presentAs` 额外按 agent 注册一份,后者按名字遮蔽前者。它的正文对 native scope 渲染为空,而提示词渲染器会丢弃空段——正是这一点让「在 code 模式部署下选择退出」的 agent 不带 SDK 段。 + +preset 用一行来表达这个选择:`@deepseek-ai/dsh-agent-tool-mode`,其全部内容就是一次 `presentAs` 调用。code 类模式通过 `ctx.inject` 等待 `ctx.codeRuntime` 而非假定它存在:运行时在宿主平面,而一个 pending 的行正是 `dsh-agent-presets` 已经会报告的「不可用挂载」并会指名该行——于是在无运行时的部署上选择 Code Mode 的 preset,会在操作者能够动手的地方失败。 + +## Alternatives considered + +**在 preset 的 isolate realm 里再起一个 `ToolRegistry`。** 否决:`dsh-agent-loop` 通过一个私有 symbol 从宿主上下文一次性解析注册表,因此按 agent 的注册表对调度器不可见。把 loop 改成按 agent 解析注册表,远比把一个字段变成 scope 感知的改动大。 + +**在 preset 自己的 YAML 里加一个顶层键。** 否决,理由与 preset 展示元数据落到独立 `preset.yml` 相同:组装是一个顶层的插件行列表,装不下并列的键。 + +**把包命名为 `dsh-tool-mode`。** 被一道 gate 否决,而且它是对的。`gen-tool-catalog` 以 `packages/*/tool-*` 通配,并要求每个命中项发布一个面向模型的工具 schema——因为在本仓库里这个前缀就意味着「带工具」。而这一行不带任何工具。 + +**在构造函数里无条件注册 SDK 段。** 试过之后否决:`renderPrompt` 会丢弃空段,但 `PromptAssembly.sections` 会保留它们,于是每个 native 部署都将携带一个什么也不渲染的 `tools:sdk` 条目,而两处既有断言不得不为此放宽。 + +**用 include 共享 `standard` 的组装。** 按本 stack 自己的惯例否决:`cordis` 已经复制了一份 `standard`,而 preset 的价值恰在于整份组装能在一个文件里读完。代价——第三份约 240 行、且必须同步演进的副本——是真实的,也正是未来引入 include 机制最有力的论据。 + +## Consequences + +同一进程内的两个会话现在可以有不同的呈现方式,因此「模型看到哪些工具」不再能只凭部署配置回答,必须给出 agent。凡是引用模式的诊断信息,现在引用的都是该 scope 的,而不是服务的。 + +`ctx.tools.schemas(agent)` 仍然是该 agent 的**能力**清单,不受呈现方式影响——坍缩的只是 assembly 里的工具。断言「模型收到什么」的测试必须读 assembly;`web-agent-presets.spec.ts` 对随附的 `code` 预设同时断言了这个区分的两侧。 + +随附的名单变成四个预设(标准/代码/极简/创造),因此任何列出它们的 golden 都会变动。未组装 code 运行时的部署无法组装任何 code 模式的 preset;随附的 Web overlay 带了一个,base 组装没有。 diff --git a/apps/cli/config/agent-presets/code/agent.cordis.yml b/apps/cli/config/agent-presets/code/agent.cordis.yml new file mode 100644 index 0000000000..64353b90d0 --- /dev/null +++ b/apps/cli/config/agent-presets/code/agent.cordis.yml @@ -0,0 +1,261 @@ +# The `code` agent preset: the standard coding agent, presented as Code Mode. +# +# Everything in `standard` is here unchanged. What is added is the `tool-mode` +# row: instead of one tool call per action, the model writes a TypeScript +# program against a generated SDK and `run_code` executes it, so a sequence +# that would be five round trips becomes one. +# +# The registry itself stays on the host plane — the agent loop's scheduler and +# the API proxy's presenters are its consumers — so what this preset owns is +# the PRESENTATION of that registry for this agent alone. Native sessions run +# beside this one in the same process, each seeing its own catalog. +# +# This file is an AGENT-PLANE composition. It is mounted under one agent's +# scope context, so every tool and prompt section it registers belongs to that +# session alone. The host composition (`base.cordis.yml` + `web.cordis.yml`) +# keeps everything a preset must not own: the registries themselves, the +# sandbox and approval stack, persistence, and the model route. +# +# A service row here MUST sit inside a group carrying an `isolate` realm. +# Without one it publishes into the root realm, where it is process-global +# rather than per-session and the second session mounting this preset collides +# with the first; `dsh-agent-presets` rejects that at mount. `true` means an +# entry-local realm — one private instance per mounted session, which is the +# default this deployment wants. A shared label would instead pool one instance +# across every session naming it. + +# ── identity ──────────────────────────────────────────────────────────────── + +# The preset's own persona, shadowing the deployment default for this agent. +# `{{model}}` and `{{cwd}}` resolve from the agent's own route and workspace. +- id: persona + name: '@deepseek-ai/dsh-persona' + config: + text: >- + You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}. + +- id: workspace-context + name: '@deepseek-ai/dsh-workspace-context' + config: + maxBytes: 65536 + +# ── shell ─────────────────────────────────────────────────────────────────── + +# `tool-bash` reads as a tool but provides the `bashEnv` service, so it needs a +# realm like any other provider. The executor behind it (`bash-sandbox`) stays +# in the host composition, where the sandbox policy owns it. +- id: shell + name: cordis:group + group: true + isolate: + bashEnv: true + config: + # The registry and its consumer share the realm: a consumer left outside + # would resolve the host's `bashEnv`, which this plane no longer provides. + - id: bash-env + name: '@deepseek-ai/dsh-bash-env' + + - id: tool-bash + name: '@deepseek-ai/dsh-tool-bash' + +# ── filesystem ────────────────────────────────────────────────────────────── + +# All three register into the host `tools` registry and provide nothing, so +# they need no realm. The `fs` service and its policy stay in the host. +- id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + +- id: tool-fs-search + name: '@deepseek-ai/dsh-tool-fs-search' + config: + sampleOverCapGlobResults: false + +- id: tool-str-replace-editor + name: '@deepseek-ai/dsh-tool-str-replace-editor' + config: + maxOutputChars: 16000 + +# ── background tasks ──────────────────────────────────────────────────────── + +- id: tasks + name: cordis:group + group: true + isolate: + tasks: true + config: + - id: tasks-local + name: '@deepseek-ai/dsh-tasks-local' + + - id: tool-tasks + name: '@deepseek-ai/dsh-tool-tasks' + +# ── skills ────────────────────────────────────────────────────────────────── + +- id: skills + name: cordis:group + group: true + isolate: + skills: true + config: + - id: skill + name: '@deepseek-ai/dsh-skill' + + - id: skill-local + name: '@deepseek-ai/dsh-skill-local' + + - id: tool-skill + name: '@deepseek-ai/dsh-tool-skill' + +# ── goals ─────────────────────────────────────────────────────────────────── + +- id: goals + name: cordis:group + group: true + isolate: + goals: true + config: + - id: goal + name: '@deepseek-ai/dsh-goal' + + - id: goal-session + name: '@deepseek-ai/dsh-goal-session' + + - id: command-goal + name: '@deepseek-ai/dsh-command-goal' + + - id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' + +# ── plan mode ─────────────────────────────────────────────────────────────── + +# Plan state is per-agent by nature, so an entry-local realm is not a +# workaround here — it is the correct lifetime. +- id: planning + name: cordis:group + group: true + isolate: + planMode: true + config: + - id: plan-mode + name: '@deepseek-ai/dsh-plan-mode' + config: + section: | + You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode. + + Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. + + The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed only to keep the request shape stable. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. + + Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out. + + Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions. + + When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation. + +# ── compaction ────────────────────────────────────────────────────────────── + +# `compact-basic` reads `toolResultPrune` through `ctx.get`, so the pruner must +# share this realm rather than sit outside it. +- id: compaction + name: cordis:group + group: true + isolate: + tokenMeter: true + compact: true + toolResultPrune: true + config: + - id: token-meter + name: '@deepseek-ai/dsh-token-meter' + + - id: compact-basic + name: '@deepseek-ai/dsh-compact-basic' + + - id: command-compact + name: '@deepseek-ai/dsh-command-compact' + + - id: tool-result-prune + name: '@deepseek-ai/dsh-compact-tool-result-prune' + config: + thresholdChars: 8192 + headChars: 4096 + tailChars: 1024 + +# ── delegation and workflows ──────────────────────────────────────────────── + +# The `subagents` registry and its spawn/fork backends live in the HOST +# composition: the registry is a process singleton whose cross-session queries +# the api-proxy serves to the browser, and a provider name may only be +# registered once. This preset contributes the delegation TOOLS, which resolve +# that host registry. +# +# `workflows` is different — nothing outside an agent reads it — so every row +# that reaches it shares one entry-local realm here, and a consumer left +# outside would resolve a host registry this preset does not populate. +- id: delegation + name: cordis:group + group: true + isolate: + workflows: true + config: + - id: tool-subagent-control + name: '@deepseek-ai/dsh-tool-subagent-control' + + - id: tool-subagent-list-agents + name: '@deepseek-ai/dsh-tool-subagent-control/list-agents' + + - id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + backgroundMode: continuable + + - id: tool-subagent-fork + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: fork + toolName: subagent_fork + backgroundMode: continuable + + - id: tool-subagent-report + name: '@deepseek-ai/dsh-tool-subagent-report' + + - id: workflow-workerthread + name: '@deepseek-ai/dsh-workflow-workerthread' + config: + provider: spawn + + - id: tool-workflow + name: '@deepseek-ai/dsh-tool-workflow' + + - id: tool-ralph + name: '@deepseek-ai/dsh-tool-ralph' + config: + subagentProvider: spawn + maxRounds: 64 + +# ── remaining model-facing rows ───────────────────────────────────────────── + +- id: tool-ask-user + name: '@deepseek-ai/dsh-tool-ask-user' + +- id: tool-todo + name: '@deepseek-ai/dsh-tool-todo' + +# The `web` service and its search provider stay in the host composition; only +# the model-facing tool is per-session. +- id: tool-web + name: '@deepseek-ai/dsh-tool-web' + config: + fetch: false + searchTimeoutMs: 60000 + +# ── presentation ──────────────────────────────────────────────────────────── + +# Code Mode for this agent alone. The row waits for the host's `codeRuntime` +# rather than assuming it: a deployment that composes no TypeScript runtime +# fails this preset at mount, naming this id, instead of at the first request. +- id: tool-mode + name: '@deepseek-ai/dsh-agent-tool-mode' + config: + mode: code diff --git a/apps/cli/config/agent-presets/code/preset.yml b/apps/cli/config/agent-presets/code/preset.yml new file mode 100644 index 0000000000..f3426e52f4 --- /dev/null +++ b/apps/cli/config/agent-presets/code/preset.yml @@ -0,0 +1,3 @@ +name: 代码模式 +description: 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK,一次执行代替多轮工具调用。 +order: 2 diff --git a/apps/cli/config/agent-presets/cordis/preset.yml b/apps/cli/config/agent-presets/cordis/preset.yml index f54750872a..49cb3c6d44 100644 --- a/apps/cli/config/agent-presets/cordis/preset.yml +++ b/apps/cli/config/agent-presets/cordis/preset.yml @@ -1,3 +1,3 @@ name: 创造模式 description: 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。 -order: 3 +order: 4 diff --git a/apps/cli/config/agent-presets/minimal/preset.yml b/apps/cli/config/agent-presets/minimal/preset.yml index 4c9c3b3d7f..5521dda140 100644 --- a/apps/cli/config/agent-presets/minimal/preset.yml +++ b/apps/cli/config/agent-presets/minimal/preset.yml @@ -1,3 +1,3 @@ name: 极简模式 description: 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。 -order: 2 +order: 3 diff --git a/apps/cli/package.json b/apps/cli/package.json index d651ba2537..1553f02ed5 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -17,6 +17,7 @@ "@cordisjs/plugin-include": "workspace:*", "@cordisjs/plugin-loader": "workspace:*", "@cordisjs/plugin-timer": "workspace:*", + "@deepseek-ai/dsh-agent-tool-mode": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-base": "workspace:^", "@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^", diff --git a/apps/cli/tests/web-agent-presets.spec.ts b/apps/cli/tests/web-agent-presets.spec.ts index 40cb8c2b9d..4b6e6b0b5b 100644 --- a/apps/cli/tests/web-agent-presets.spec.ts +++ b/apps/cli/tests/web-agent-presets.spec.ts @@ -88,7 +88,7 @@ describe('the shipped Web composition', () => { it('supplies both shipped presets, and only those, from the system root', async () => { const listed = await ctx.agentPresets.list() - expect(listed.map(preset => preset.id).sort()).toEqual(['cordis', 'minimal', 'standard']) + expect(listed.map(preset => preset.id).sort()).toEqual(['code', 'cordis', 'minimal', 'standard']) expect(listed.every(preset => preset.trust === 'system')).toBe(true) expect(ctx.agentPresets.defaultId).toBe('standard') }) @@ -174,6 +174,38 @@ describe('the shipped Web composition', () => { } }) + it('presents `code` as Code Mode without disturbing a native session beside it', async () => { + const coded = await ctx.agents.create({ + sessionId: SessionId('preset-code'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'code').then(() => undefined), + }) + const native = await ctx.agents.create({ + sessionId: SessionId('preset-code-native'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), + }) + try { + // One tool reaches the MODEL: the transport. The registry's catalog for + // this agent is unchanged — a code mode collapses the presentation, not + // the capabilities — so the assembly is what carries the claim. + const assembly = await ctx.systemPrompt.assemble({ scope: coded.agent }) + expect(assembly.tools.map(tool => tool.name)).toEqual(['run_code']) + expect(toolNames(ctx, coded.agent)).toContain('str_replace_editor') + const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text ?? '' + expect(sdk).toContain('str_replace_editor') + expect(sdk).toContain('web_search') + + // The presentation is this agent's alone: the deployment default is + // native, and the session composed from `standard` still sees it. + const nativeAssembly = await ctx.systemPrompt.assemble({ scope: native.agent }) + expect(nativeAssembly.tools.map(tool => tool.name)).toContain('bash') + expect(nativeAssembly.tools.map(tool => tool.name)).not.toContain('run_code') + expect(nativeAssembly.sections.some(section => section.name === 'tools:sdk')).toBe(false) + } finally { + await native.dispose() + await coded.dispose() + } + }) + it('keeps the self-referential toolset out of every other preset', async () => { const handle = await ctx.agents.create({ sessionId: SessionId('preset-no-cordis'), diff --git a/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md b/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md index 10ffa973f0..fd92ab8b5a 100644 --- a/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md +++ b/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md @@ -2,5 +2,6 @@ - menuitem "标准模式 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。": - text: 标准模式 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。 - img + - menuitem "代码模式 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK,一次执行代替多轮工具调用。" - menuitem "极简模式 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。" - menuitem "创造模式 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。" diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 822fecf79b..e153e377ef 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -225,6 +225,28 @@ Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfi Source: [`packages/examples/agent-spine-demo/src/index.ts:90`](../packages/examples/agent-spine-demo/src/index.ts) +## `@deepseek-ai/dsh-agent-tool-mode` + +Requires: `tools` + +```ts config-catalog +/** Plugin config. */ +export interface Config { + /** + * The form this agent's model sees. `native` sends every visible schema, + * `code` sends only `run_code` plus a generated SDK, `both` sends both. + * Required rather than defaulted: the deployment default is what a profile + * without this row already gets, so an omitted value would mean the row was + * composed for nothing. + */ + mode: ToolPresentationMode +} +``` + +Depends on: [`ToolPresentationMode`](core-data-structures/tools.md) + +Source: [`packages/core/agent-tool-mode/src/index.ts:36`](../packages/core/agent-tool-mode/src/index.ts) + ## `@deepseek-ai/dsh-bash-env` ```ts config-catalog @@ -2206,10 +2228,15 @@ Requires: `systemPrompt` /** Plugin config: how the registered tools are presented to the model. */ export interface Config { /** - * Model presentation. `native` (default) sends every visible schema; `code` - * sends only `run_code` plus a generated SDK prompt; `both` sends both forms. - * Code modes require a TypeScript runtime and fail prompt assembly when it is - * absent or mismatched. Under `code`, native names in `toolOrder` are invalid. + * Model presentation for agents that declare none of their own. `native` + * (default) sends every visible schema; `code` sends only `run_code` plus a + * generated SDK prompt; `both` sends both forms. Code modes require a + * TypeScript runtime and fail prompt assembly when it is absent or + * mismatched. Under `code`, native names in `toolOrder` are invalid. + * + * One agent overrides this for itself with {@link ToolRegistry.presentAs}, + * which is how an agent preset composes a Code Mode agent beside native + * ones in the same process. */ mode?: ToolPresentationMode /** diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 00f55c0140..a6efb2aa20 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2539,6 +2539,17 @@ Source: [`packages/compact/compact-tool-result-prune/src/index.ts:44`](../../pac Tool registry and execution pipeline. Scoped registrations shadow globals; one visibility resolver feeds presentation, lookup, and dispatch. ```ts cordis-catalog +/** + * Present this agent's tools in `mode` instead of the deployment default. + * + * Scoped only, and one declaration per agent: this is how an agent preset + * composes a Code Mode agent beside native ones in the same process, and a + * process-global override would be the `mode` config field instead. + * @param mode - the presentation this agent's model sees. + * @returns the exact disposer that restores the deployment default. + */ +presentAs(mode: ToolPresentationMode): () => void + /** * Register globally or in the calling agent scope. Scoped tools shadow * globals; duplicates within one layer and the reserved `run_code` name fail. @@ -2613,9 +2624,9 @@ executionMode(exec: ToolExecutionInput): ToolExecutionMode async execute(exec: ToolExecutionInput): Promise ``` -Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md) +Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolPresentationMode](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:714`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:726`](../../packages/core/tools/src/index.ts) ## `ctx.typert` — `TypertRegistry` diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index b1e5620493..5c760932c0 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -15,7 +15,7 @@ {"type":"assistant/chunk","seq":13,"time":1785730459883,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":14,"time":1785730459883,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6b62bed7-113a-4d2e-a6aa-b935a1063ee2"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} {"type":"tool/call","seq":15,"time":1785730459883,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":16,"time":1785730459904,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly inbox: Inbox;\n readonly status: AgentStatus;\n readonly ctx: Context;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n runMaintenance(task: (signal: AbortSignal) => Promise): Promise;\n send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n } | {\n readonly kind: 'hook';\n readonly reason: string;\n } | {\n readonly kind: 'disposed';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean | undefined;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export type ContextFormed = {\n readonly form?: never;\n } | {\n readonly form: 'instructions';\n } | {\n readonly form: 'catalog';\n } | {\n readonly form: 'snapshot';\n readonly sections: readonly ContextSnapshotSection[];\n } | {\n readonly form: 'notice';\n readonly summary: string;\n } | {\n readonly form: 'relay';\n } | {\n readonly form: 'recall';\n };\n export interface ContextSnapshotSection {\n readonly name: string;\n readonly text: string;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget, turn: number): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n }\n export interface InboxNotifications {\n inserted(message: UserMessage): void;\n discarded(message: UserMessage): void;\n claimed(message: UserMessage, turn: number): void;\n }\n export type InboxTarget = 'next-turn' | 'next-step';\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n } & ContextFormed;\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: 'subagent';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndCancelCause = AgentCancelCause | {\n readonly kind: 'legacy';\n };\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n reason: TurnEndCancelCause;\n };\n blocked: {\n kind: 'blocked';\n };\n error: {\n kind: 'error';\n error: LlmFailure;\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"847bf2e6-59da-4621-946d-06932a78f0ce"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","seq":16,"time":1785730459904,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Present this agent's tools in `mode` instead of the deployment default.\n *\n * Scoped only, and one declaration per agent: this is how an agent profile\n * composes a Code Mode agent beside native ones in the same process, and a\n * process-global override would be the `mode` config field instead.\n * @param mode - the presentation this agent's model sees.\n * @returns the exact disposer that restores the deployment default.\n */\n presentAs(mode: ToolPresentationMode): () => void\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly inbox: Inbox;\n readonly status: AgentStatus;\n readonly ctx: Context;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n runMaintenance(task: (signal: AbortSignal) => Promise): Promise;\n send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n } | {\n readonly kind: 'hook';\n readonly reason: string;\n } | {\n readonly kind: 'disposed';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean | undefined;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export type ContextFormed = {\n readonly form?: never;\n } | {\n readonly form: 'instructions';\n } | {\n readonly form: 'catalog';\n } | {\n readonly form: 'snapshot';\n readonly sections: readonly ContextSnapshotSection[];\n } | {\n readonly form: 'notice';\n readonly summary: string;\n } | {\n readonly form: 'relay';\n } | {\n readonly form: 'recall';\n };\n export interface ContextSnapshotSection {\n readonly name: string;\n readonly text: string;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget, turn: number): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n }\n export interface InboxNotifications {\n inserted(message: UserMessage): void;\n discarded(message: UserMessage): void;\n claimed(message: UserMessage, turn: number): void;\n }\n export type InboxTarget = 'next-turn' | 'next-step';\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n } & ContextFormed;\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: 'subagent';\n readonly delegationDepth?: number;\n readonly agentProfile?: string;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export type ToolPresentationMode = 'native' | 'code' | 'both';\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndCancelCause = AgentCancelCause | {\n readonly kind: 'legacy';\n };\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n reason: TurnEndCancelCause;\n };\n blocked: {\n kind: 'blocked';\n };\n error: {\n kind: 'error';\n error: LlmFailure;\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"847bf2e6-59da-4621-946d-06932a78f0ce"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","seq":17,"time":1785730459904,"data":{"turn":1,"step":1}} {"type":"step/start","seq":18,"time":1785730459916,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":19,"time":1784449176734,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index a0ed4f8c46..d3c7af4f4a 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1124,6 +1124,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ key: 'tools', summary: 'Tool registry and execution pipeline.', methods: [ + { + signature: 'presentAs(mode: ToolPresentationMode): () => void', + jsDoc: '/**\n * Present this agent\'s tools in `mode` instead of the deployment default.\n *\n * Scoped only, and one declaration per agent: this is how an agent preset\n * composes a Code Mode agent beside native ones in the same process, and a\n * process-global override would be the `mode` config field instead.\n * @param mode - the presentation this agent\'s model sees.\n * @returns the exact disposer that restores the deployment default.\n */', + }, { signature: 'register(definition: ToolDefinition): () => void', jsDoc: '/**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */', @@ -3043,6 +3047,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ToolOutputDefinition', declaration: 'export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n}', }, + { + name: 'ToolPresentationMode', + declaration: 'export type ToolPresentationMode = \'native\' | \'code\' | \'both\';', + }, { name: 'ToolProviderResult', declaration: 'export interface ToolProviderResult {\n readonly schemas: readonly ToolSchema[];\n readonly knownNames?: readonly string[];\n}', diff --git a/packages/core/agent-tool-mode/README.i18n.yaml b/packages/core/agent-tool-mode/README.i18n.yaml new file mode 100644 index 0000000000..1e32675158 --- /dev/null +++ b/packages/core/agent-tool-mode/README.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 packages/core/agent-tool-mode/README.md +README.md: 59b2d7fa817b349ff0bf04d3871f1617c95d9125 +README.zh.md: 6fae0fb20be473bb3f0ffe3a4c72c1986d4f272e diff --git a/packages/core/agent-tool-mode/README.md b/packages/core/agent-tool-mode/README.md new file mode 100644 index 0000000000..0ef7f32c08 --- /dev/null +++ b/packages/core/agent-tool-mode/README.md @@ -0,0 +1,31 @@ +# dsh-agent-tool-mode + +English | [中文](README.zh.md) + +The row an [agent preset](../../preset/agent-presets/README.md) carries to say which form of its tools the model sees: `native` (every schema), `code` (only `run_code` plus a generated TypeScript SDK), or `both`. + +## Why a row rather than a registry + +The tool registry cannot move into a preset. Its consumers are all host-plane — [`dsh-agent-loop`](../agent-loop/README.md) reads its scheduler, [`dsh-apiproxy`](../../host/apiproxy/README.md) reads its presenters to render tool cards, and every tool plugin registers into it — and a service only moves down when all of its consumers move with it. + +What a preset can own is the **presentation** of that registry. `ctx.tools.presentAs()` declares it for the mounting agent alone, so a Code Mode session runs beside native ones in one process, each seeing its own catalog. The deployment's `mode` on the [`dsh-tools`](../tools/README.md) row remains the default that agents declaring nothing get. + +## What it does + +`native` applies immediately. A code mode instead waits for `ctx.codeRuntime`, which is a host-plane service ([`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)): a preset selecting Code Mode against a deployment composing no runtime then holds this row pending, and `dsh-agent-presets` refuses the mount naming this id. The alternative — applying optimistically — moves the failure to the session's first request, where the operator can act on neither the preset nor the composition. + +`mode` is required rather than defaulted, because a preset without this row already gets the deployment default; an omitted value would mean the row was composed for nothing. + +One agent declares one presentation. A second declaration in the same composition is refused rather than merged: two answers to "which form does the model see" is a contradiction, not an override. + +## Model Experience + +Indirectly, through the projection it selects in `dsh-tools`: `code` presents `run_code` plus a generated SDK section, `native` presents every tool schema. + +#### KV Cache effect + +No direct invalidation; the presentation is fixed when the agent is composed, so its request prefix is stable for the session's life. + +## Known Limitations and Deferred Work + +- **The runtime stays host-plane** — a preset can select Code Mode but cannot supply the TypeScript runtime it needs; a deployment that composes none can compose no code-mode preset. diff --git a/packages/core/agent-tool-mode/README.zh.md b/packages/core/agent-tool-mode/README.zh.md new file mode 100644 index 0000000000..974fc4ed57 --- /dev/null +++ b/packages/core/agent-tool-mode/README.zh.md @@ -0,0 +1,31 @@ +# dsh-agent-tool-mode + +[English](README.md) | 中文 + +[agent preset](../../preset/agent-presets/README.md) 用来声明「模型看到的工具是哪一种形态」的那一行:`native`(全部 schema)、`code`(只有 `run_code` 加一份生成的 TypeScript SDK)或 `both`。 + +## 为什么是一行插件,而不是把注册表搬下来 + +工具注册表搬不进 preset。它的消费者全在宿主平面——[`dsh-agent-loop`](../agent-loop/README.md) 读它的调度器,[`dsh-apiproxy`](../../host/apiproxy/README.md) 读它的 presenter 来渲染工具卡,每个工具插件都往里注册——而一个服务只有在**所有**消费者一起下沉时才能下沉。 + +preset 能拥有的是这份注册表的**呈现方式**。`ctx.tools.presentAs()` 只为正在挂载的那个 agent 声明,于是一个 Code Mode 会话可以和多个 native 会话同进程并存,各自看到各自的清单。[`dsh-tools`](../tools/README.md) 那一行上的 `mode` 仍然是默认值,供未作声明的 agent 使用。 + +## 它做什么 + +`native` 立即生效。code 类模式则等待 `ctx.codeRuntime`——这是一个宿主平面服务([`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)):若某个 preset 在未组装运行时的部署上选择 Code Mode,本行就停在 pending,`dsh-agent-presets` 会指名此 id 拒绝挂载。另一种做法——先乐观应用——会把失败推迟到该会话的第一次请求,那时操作者对 preset 和组装都已无从下手。 + +`mode` 是必填而非有默认值:不带这一行的 preset 本来就会拿到部署默认值,省略它等于这一行白组装了。 + +一个 agent 只声明一次呈现方式。同一份组装里的第二次声明会被拒绝而不是合并:对「模型看到哪种形态」给出两个答案是矛盾,不是覆盖。 + +## Model Experience + +Indirectly, through the projection it selects in `dsh-tools`: `code` presents `run_code` plus a generated SDK section, `native` presents every tool schema. + +#### KV Cache effect + +没有直接的失效影响;呈现方式在 agent 组装时即固定,因此其请求前缀在该会话的整个生命周期内保持稳定。 + +## Known Limitations and Deferred Work + +- **运行时仍在宿主平面** —— preset 可以选择 Code Mode,却无法自带它所需的 TypeScript 运行时;未组装运行时的部署也就无法组装任何 code 模式的 preset。 diff --git a/packages/core/agent-tool-mode/package.json b/packages/core/agent-tool-mode/package.json new file mode 100644 index 0000000000..236c9e5891 --- /dev/null +++ b/packages/core/agent-tool-mode/package.json @@ -0,0 +1,45 @@ +{ + "name": "@deepseek-ai/dsh-agent-tool-mode", + "description": "Agent-plane presentation selector: composes one agent's tools as Code Mode, native, or both", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts" + ], + "license": "BSD-3-Clause", + "dependencies": { + "schemastery": "^3.18.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-code-runtime": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/core/agent-tool-mode/src/index.ts b/packages/core/agent-tool-mode/src/index.ts new file mode 100644 index 0000000000..d2f1e8fd49 --- /dev/null +++ b/packages/core/agent-tool-mode/src/index.ts @@ -0,0 +1,70 @@ +/** + * Agent-plane presentation selector: the row an agent preset carries to say + * which form of its tools the model sees. + * + * The tool registry itself stays on the host plane — the agent loop's + * scheduler, the API proxy's presenters, and every tool plugin are all its + * consumers, so it cannot move into a preset. What a preset CAN own is the + * presentation: `ctx.tools.presentAs()` declares it for the mounting agent + * alone, so a Code Mode agent runs beside native ones in one process. + * + * A code mode needs a TypeScript code runtime, which is a host-plane service + * ([`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)). + * This row therefore waits for it rather than assuming it: a preset selecting + * Code Mode against a deployment that composes no runtime fails at mount, named + * in the preset's own activation audit, instead of at the first prompt. + * @module @deepseek-ai/dsh-agent-tool-mode + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import type { ToolPresentationMode } from '@deepseek-ai/dsh-tools' +// Type-only: brings the `ctx.tools` Context merge into this program. +import type {} from '@deepseek-ai/dsh-tools' + +/** Cordis plugin name. */ +export const name = 'tool-mode' + +/** + * Required services. `codeRuntime` is NOT listed: a `native` row must mount in + * a deployment that composes no runtime, and the mode-dependent wait is + * declared inside {@link apply} instead. + */ +export const inject = ['tools'] + +/** Plugin config. */ +export interface Config { + /** + * The form this agent's model sees. `native` sends every visible schema, + * `code` sends only `run_code` plus a generated SDK, `both` sends both. + * Required rather than defaulted: the deployment default is what a preset + * without this row already gets, so an omitted value would mean the row was + * composed for nothing. + */ + mode: ToolPresentationMode +} + +/** Runtime schema. */ +export const Config: z = z.object({ + mode: z.union(['native', 'code', 'both'] as const).required(), +}) + +/** + * Declare this agent's tool presentation. + * @param ctx - the mounting agent's scope context. + * @param config - the selected presentation. + */ +export function apply(ctx: Context, config: Config): void { + // `presentAs` is itself the effect — it registers through the calling + // context and hands back that exact disposer — so the declaration unwinds + // with this row without a second wrapper owning it. + if (config.mode === 'native') { + ctx.tools.presentAs('native') + return + } + // The wait is the loud failure: an entry still pending on `codeRuntime` is + // what `dsh-agent-presets` reports as an unusable row, naming this id. + ctx.inject(['codeRuntime'], (runtimeCtx: Context) => { + runtimeCtx.tools.presentAs(config.mode) + }) +} diff --git a/packages/core/agent-tool-mode/src/invariant.ts b/packages/core/agent-tool-mode/src/invariant.ts new file mode 100644 index 0000000000..bd576cb943 --- /dev/null +++ b/packages/core/agent-tool-mode/src/invariant.ts @@ -0,0 +1,32 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-agent-tool-mode`. + * @module @deepseek-ai/dsh-agent-tool-mode/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-agent-tool-mode' + +/** Cordis companion plugin name. */ +export const name = 'tool-mode-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this package makes exactly one scoped call into + * `ctx.tools` and owns no event or snapshot of its own; the relation it + * establishes — which presentation one agent's assembly uses — is the tool + * registry's to hold, and `dsh-tools` observes it there. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/core/agent-tool-mode/tests/agent-tool-mode.spec.ts b/packages/core/agent-tool-mode/tests/agent-tool-mode.spec.ts new file mode 100644 index 0000000000..ba9b9972ff --- /dev/null +++ b/packages/core/agent-tool-mode/tests/agent-tool-mode.spec.ts @@ -0,0 +1,129 @@ +/** + * The row an agent preset carries to pick its tool presentation. What it owes + * its caller: the choice reaches THIS agent and no other, it unwinds with the + * agent, and a code mode composed against a deployment with no code runtime + * stops at mount — where a preset's activation audit can name it — rather + * than at the first prompt assembly. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { createScope } from '@deepseek-ai/dsh-scope' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' +import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime' +import ToolRegistry, { RUN_CODE_NAME, defineTool } from '@deepseek-ai/dsh-tools' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' +import { apply, Config, inject, name } from '@deepseek-ai/dsh-agent-tool-mode' + +/** A runtime that never runs anything: presentation never dispatches. */ +class StubRuntime extends CodeRuntime { + readonly language = 'typescript' + readonly isolation = 'stub' + + run(_request: CodeRunRequest): Promise { + return Promise.resolve({ logs: [] }) + } +} + +/** A host plane with one tool, optionally carrying a code runtime. */ +async function host(options: { runtime?: boolean } = {}) { + const ctx = new Context() + await ctx.plugin(SystemPrompt, {}) + await ctx.plugin(ToolRegistry, {}) + if (options.runtime !== false) await ctx.plugin(StubRuntime) + ctx.tools.register(defineTool({ + name: 'echo', + description: 'Echo tool.', + parameters: { value: { type: 'string', required: true } }, + output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] }, + execute: args => Promise.resolve(args.value), + })) + return ctx +} + +/** Mount the row under one agent's scope, as a preset subtree does. */ +async function mount(ctx: Context, config: Config, id = 'agent') { + const agent = { id: SessionId(id) } as Agent + let inner!: Context + const fiber = ctx.plugin(Object.assign((host: Context) => { + inner = createScope(host, agent).ctx + }, { inject: ['tools', 'systemPrompt'] })) + await fiber.await() + const row = inner.plugin({ name, inject: [...inject], Config, apply }, config) + await row.await() + return { agent, fiber, row } +} + +describe('the tool-mode row', () => { + it('declares the services it uses without holding a code runtime hostage', () => { + // A `native` row must mount where no runtime is composed, so the wait is + // conditional inside apply rather than static metadata. + expect(inject).toEqual(['tools']) + }) + + it('gives its own agent Code Mode and leaves the rest native', async () => { + const ctx = await host() + const coded = await mount(ctx, { mode: 'code' }, 'coded') + const plain = await mount(ctx, { mode: 'native' }, 'plain') + + const codedAssembly = await ctx.systemPrompt.assemble({ scope: coded.agent }) + const plainAssembly = await ctx.systemPrompt.assemble({ scope: plain.agent }) + + expect(codedAssembly.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME]) + expect(codedAssembly.sections.find(section => section.name === 'tools:sdk')?.text).toContain('echo') + expect(plainAssembly.tools.map(tool => tool.name)).toEqual(['echo']) + }) + + it('presents both forms when asked for both', async () => { + const ctx = await host() + const { agent } = await mount(ctx, { mode: 'both' }) + + const assembly = await ctx.systemPrompt.assemble({ scope: agent }) + + expect(assembly.tools.map(tool => tool.name)).toEqual(['echo', RUN_CODE_NAME]) + }) + + it('restores the deployment default when the agent unloads', async () => { + const ctx = await host() + const { agent, row } = await mount(ctx, { mode: 'code' }) + + await row.dispose() + + // HMR safety: the preset subtree is torn down with its agent, and the + // presentation must go with it rather than outliving the composition. + const assembly = await ctx.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('waits for a code runtime the deployment does not compose', async () => { + const ctx = await host({ runtime: false }) + + const { agent, row } = await mount(ctx, { mode: 'code' }) + + // Pending, not applied: `dsh-agent-presets` rejects a mount holding a row + // that never reached a usable state, naming this id — so the preset fails + // where the operator can act, instead of at the first request. + expect(row.ctx.get('codeRuntime')).toBeUndefined() + const assembly = await ctx.systemPrompt.assemble({ scope: agent }) + expect(assembly.tools.map(tool => tool.name)).toEqual(['echo']) + }) + + it('applies once the runtime arrives', async () => { + const ctx = await host({ runtime: false }) + const { agent } = await mount(ctx, { mode: 'code' }) + + await ctx.plugin(StubRuntime) + + const assembly = await ctx.systemPrompt.assemble({ scope: agent }) + expect(assembly.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME]) + }) + + it('requires a mode rather than defaulting one', () => { + // An omitted value would mean the row was composed for nothing: a preset + // without this row already gets the deployment default. + expect(() => Config({} as never)).toThrow() + }) +}) diff --git a/packages/core/agent-tool-mode/tsconfig.json b/packages/core/agent-tool-mode/tsconfig.json new file mode 100644 index 0000000000..3b0445c30a --- /dev/null +++ b/packages/core/agent-tool-mode/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index 61bfcd005b..1e5e9c9984 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: 80ea3cc93437d48a7ea0ffba0ff4d2ef2407755f -README.zh.md: 691d2f2fcccdaa1bcab5343b2fce661d9c99e8ad +README.md: 99c529880be631663084236a3d56faf96d8055c0 +README.zh.md: 4a4e0258ef315f81af37c8d2c435e2596d802dab diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 80ea3cc934..097209af6a 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Tool registry and execution pipeline. Tool plugins register their schemas and executors; the agent loop executes each call through `tools/pre-execute` (the extensible allow/deny gate) → monotonic registered guards → `tools/execute` (an around-dispatch wrapper for timeout/retry/metrics plugins) → `tools/post-execute` (inspect/replace the result, attach context) → the definition-owned `finalizeContent` boundary → the observe-only `tools/result` notification. The registry also owns HOW its tools are presented to the model — its `mode` config selects native function calling, [Code Mode](#code-mode), or both. +Tool registry and execution pipeline. Tool plugins register their schemas and executors; the agent loop executes each call through `tools/pre-execute` (the extensible allow/deny gate) → monotonic registered guards → `tools/execute` (an around-dispatch wrapper for timeout/retry/metrics plugins) → `tools/post-execute` (inspect/replace the result, attach context) → the definition-owned `finalizeContent` boundary → the observe-only `tools/result` notification. The registry also owns HOW its tools are presented to the model — its `mode` config selects native function calling, [Code Mode](#code-mode), or both, and one agent shadows that default for itself with `presentAs`. ## Service: `ToolRegistry` (ctx key: `tools`) @@ -13,11 +13,12 @@ tools: mode: native # native (default) | code | both ``` -`native` contributes visible tools as function definitions. `code` contributes the reserved `run_code` transport and generated `tools:sdk` section; `both` contributes both forms. The reserved transport cannot be registered, shadowed, restricted, or removed. Non-native modes require a TypeScript `ctx.codeRuntime`, and a `systemPrompt.toolOrder` entry for a tool the mode does not contribute rejects prompt assembly. A `system-prompt/assemble` listener may replace the registry's contributions; its returned assembly is authoritative, so that listener owns preserving a usable Code Mode protocol. +`native` contributes visible tools as function definitions. `code` contributes the reserved `run_code` transport and generated `tools:sdk` section; `both` contributes both forms. This is the default for agents that declare none of their own — an agent preset selects its own with [`dsh-agent-tool-mode`](../agent-tool-mode/README.md). The reserved transport cannot be registered, shadowed, restricted, or removed, and its name is reserved whatever the configured mode, because any agent may select a code mode. Non-native modes require a TypeScript `ctx.codeRuntime`, and a `systemPrompt.toolOrder` entry for a tool the mode does not contribute rejects prompt assembly. A `system-prompt/assemble` listener may replace the registry's contributions; its returned assembly is authoritative, so that listener owns preserving a usable Code Mode protocol. ### Public API - `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 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-explicit-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)). @@ -190,6 +191,6 @@ Append-only; newly visible content follows the reusable request prefix and does - **`tools/pre-execute` deliberately cannot rewrite `exec.arguments`** — logged and rendered args would desync from what ran; the rewrite design is [a proposed Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md). - **Caller-defined subagent and workflow structured outputs remain object-rooted** — this is a consumer-level guard; the shared schema vocabulary and tool outputs support every JSON root. - **`timeoutMs` on a definition is declarative only** — the registry never enforces deadlines; enforcement requires the `@deepseek-ai/dsh-timeout-policy` wrapper. -- **Code Mode is TypeScript-only and the presentation mode is service-wide** — `mode: code`/`both` rejects prompt assembly unless `ctx.codeRuntime.language === 'typescript'`; scoped restrictions/shadows still choose each agent's visible bindings, but one tool cannot be native-only while another is code-only. +- **Code Mode is TypeScript-only, and a presentation is per agent rather than per tool** — `mode: code`/`both` rejects prompt assembly unless `ctx.codeRuntime.language === 'typescript'`; scoped restrictions/shadows and `presentAs` choose each agent's visible bindings and their form, but within one agent no tool can be native-only while another is code-only. - **Code Mode intermediate values are execution-local and unbounded by bytes** — the canonical typed values cannot be reconstructed from session replay and may exhaust process or worker memory; only the outer `run_code` output has the worker's configurable hard cap. The durable log copy of each sub-call IS bounded: the `tools/code-dispatch-log` waterfall lets the spill policy replace an oversized `tool/code-dispatch` content with a preview + locator ([rationale](../../../.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md)). - **`run_code` state is fresh per run** — a persistent REPL-style kernel is rejected for the MVP (cross-call state would be invisible to the log); see [the Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md). diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index 691d2f2fcc..e6b2e131b4 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -工具注册表与执行流水线。工具插件注册各自的 schema 和执行器;agent loop(智能体循环)依次让每次调用经过 `tools/pre-execute`(可扩展的允许/拒绝门禁)→ 已注册的单调守卫 → `tools/execute`(供超时/重试/指标插件使用的环绕分发包装层)→ `tools/post-execute`(检查/替换结果、附加上下文)→ 由定义拥有的 `finalizeContent` 边界 → 仅观测的 `tools/result` 通知。注册表还负责决定如何向模型呈现其工具:`mode` 配置可以选择原生 Function Calling(函数调用)、[Code Mode](#code-mode),或同时选择两者。 +工具注册表与执行流水线。工具插件注册各自的 schema 和执行器;agent loop(智能体循环)依次让每次调用经过 `tools/pre-execute`(可扩展的允许/拒绝门禁)→ 已注册的单调守卫 → `tools/execute`(供超时/重试/指标插件使用的环绕分发包装层)→ `tools/post-execute`(检查/替换结果、附加上下文)→ 由定义拥有的 `finalizeContent` 边界 → 仅观测的 `tools/result` 通知。注册表还负责决定如何向模型呈现其工具:`mode` 配置可以选择原生 Function Calling(函数调用)、[Code Mode](#code-mode),或同时选择两者;单个 agent 可用 `presentAs` 为自己遮蔽该默认值。 ## 服务:`ToolRegistry`(ctx 键:`tools`) @@ -13,11 +13,12 @@ tools: mode: native # native (default) | code | both ``` -`native` 以函数定义的形式贡献可见工具。`code` 贡献保留的 `run_code` 传输和生成的 `tools:sdk` 段;`both` 同时贡献两种形式。不能注册、遮蔽、限制或移除该保留传输。非原生模式要求存在 TypeScript `ctx.codeRuntime`;如果 `systemPrompt.toolOrder` 条目指向当前模式未贡献的工具,系统会拒绝组装提示词。`system-prompt/assemble` 监听器可以替换注册表贡献;它返回的组装结果具有权威性,因此该监听器负责保留可用的 Code Mode 协议。 +`native` 以函数定义的形式贡献可见工具。`code` 贡献保留的 `run_code` 传输和生成的 `tools:sdk` 段;`both` 同时贡献两种形式。这是「未作声明的 agent」的默认值——agent preset 用 [`dsh-agent-tool-mode`](../agent-tool-mode/README.md) 为自己选择。不能注册、遮蔽、限制或移除该保留传输,且无论配置何种模式,该名称都是保留的,因为任何 agent 都可能选择 code 模式。非原生模式要求存在 TypeScript `ctx.codeRuntime`;如果 `systemPrompt.toolOrder` 条目指向当前模式未贡献的工具,系统会拒绝组装提示词。`system-prompt/assemble` 监听器可以替换注册表贡献;它返回的组装结果具有权威性,因此该监听器负责保留可用的 Code Mode 协议。 ### 公开 API - `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 作用域的允许/拒绝掩码;从普通上下文调用会抛出。筛选器在注册时创建快照;多个掩码取交集,随后再合并作用域本地工具。拒绝掩码会接纳后来出现且未点名的全局工具,而允许掩码会排除后来出现的名称。未知、本地或保留名称以及空筛选器都会被拒绝。这是实时可见性组合,不是权限边界;参见[作用域安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-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))。 @@ -190,6 +191,6 @@ The available tools: - **`tools/pre-execute` 有意不允许改写 `exec.arguments`**:否则日志记录和呈现的参数会与实际运行内容失去同步;改写设计记录在[拟议的 Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)中。 - **调用方定义的 subagent 与工作流结构化输出仍要求对象根**:这是消费方层面的守卫;共享 schema 词汇和工具输出支持任意 JSON 根。 - **定义上的 `timeoutMs` 仅为声明**:注册表绝不会强制执行截止时间;要强制执行,必须使用 `@deepseek-ai/dsh-timeout-policy` 包装层。 -- **Code Mode 只支持 TypeScript,且呈现模式在服务内统一**:`mode: code`/`both` 会拒绝组装提示词,除非 `ctx.codeRuntime.language === 'typescript'`;作用域限制/遮蔽仍会选择每个 agent 的可见绑定,但不能让一个工具仅使用 Native,而另一个仅使用 Code。 +- **Code Mode 只支持 TypeScript,且呈现方式按 agent 而非按工具**:`mode: code`/`both` 会拒绝组装提示词,除非 `ctx.codeRuntime.language === 'typescript'`;作用域限制/遮蔽与 `presentAs` 会选择每个 agent 的可见绑定及其形态,但在同一个 agent 内不能让一个工具仅使用 Native,而另一个仅使用 Code。 - **Code Mode 中间值只存在于执行局部,且没有字节上限**:这些规范的类型化值无法从会话回放重建,并可能耗尽进程或 worker 内存;只有外层 `run_code` 输出受 worker 可配置的硬上限约束。每个子调用的持久日志副本则确实有上限:`tools/code-dispatch-log` waterfall 允许 spill 策略把过大的 `tool/code-dispatch` 内容替换为预览加定位符([原理](../../../.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md))。 - **每次运行都会获得全新的 `run_code` 状态**:MVP 不采用持久 REPL 风格内核(跨调用状态不会出现在日志中);参见 [Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)。 diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 72254e2abd..104a5e32d2 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -591,10 +591,15 @@ export type ToolPresentationMode = 'native' | 'code' | 'both' /** Plugin config: how the registered tools are presented to the model. */ export interface Config { /** - * Model presentation. `native` (default) sends every visible schema; `code` - * sends only `run_code` plus a generated SDK prompt; `both` sends both forms. - * Code modes require a TypeScript runtime and fail prompt assembly when it is - * absent or mismatched. Under `code`, native names in `toolOrder` are invalid. + * Model presentation for agents that declare none of their own. `native` + * (default) sends every visible schema; `code` sends only `run_code` plus a + * generated SDK prompt; `both` sends both forms. Code modes require a + * TypeScript runtime and fail prompt assembly when it is absent or + * mismatched. Under `code`, native names in `toolOrder` are invalid. + * + * One agent overrides this for itself with {@link ToolRegistry.presentAs}, + * which is how an agent preset composes a Code Mode agent beside native + * ones in the same process. */ mode?: ToolPresentationMode /** @@ -649,6 +654,12 @@ class ToolLayer implements ScopeLayer { readonly tools: NamedEntries readonly restrictions = new AnonymousEntries() readonly guards = new AnonymousEntries() + /** + * Presentation this scope's agent declared for itself, shadowing the + * deployment default. One cell rather than an entry table: two answers to + * "which form does the model see" is a contradiction, not a merge. + */ + mode: ToolPresentationMode | undefined constructor(scope: ScopeKey | undefined) { this.tools = new NamedEntries(name => new Error(scope === undefined @@ -659,6 +670,7 @@ class ToolLayer implements ScopeLayer { /** Whether every contribution table in this aggregate layer is empty. */ isEmpty(): boolean { return this.tools.isEmpty() && this.restrictions.isEmpty() && this.guards.isEmpty() + && this.mode === undefined } /** Whether every compiled restriction in this layer admits a global tool name. */ @@ -739,41 +751,117 @@ export class ToolRegistry extends Service { scope => new ToolLayer(scope), () => { this.ctx.emit('tools/change') }, ) - private readonly mode: ToolPresentationMode - /** Reserved presentation transport, kept outside the filterable registration layers. */ - private readonly codeTransport: ToolDefinition | undefined + /** Presentation for agents that declare none; {@link presentAs} shadows it per agent. */ + private readonly defaultMode: ToolPresentationMode + private readonly maxParallelSubCalls: number + /** + * Reserved presentation transport, kept outside the filterable registration + * layers. Built on first need rather than at construction: which agents run + * a code mode is no longer known when the service is constructed, and the + * transport is stateless beyond its closures over `this`. + */ + private codeTransport: ToolDefinition | undefined constructor(ctx: Context, config: Config = {}) { super(ctx, 'tools') // The schema already defaulted an omitted mode; the ?? narrows the // optional-input type for direct (non-Loader) construction in tests. - this.mode = config.mode ?? 'native' - // `run_code` is presentation infrastructure, not an end capability. It - // therefore does not enter the global layer: per-agent restrictions must - // not remove it, and a scoped registration must not shadow it. The - // visibility resolver appends this reserved definition after resolving - // the filterable global/scoped capability layers. - this.codeTransport = this.mode === 'native' - ? undefined - : createRunCodeTool(this, { - requireRuntime: () => this.requireCodeRuntime(), - maxParallel: resolveMaxParallelSubCalls(config.maxParallelSubCalls), - shapeDispatchLog: dispatch => this.shapeDispatchLog(dispatch), - }) + this.defaultMode = config.mode ?? 'native' + this.maxParallelSubCalls = resolveMaxParallelSubCalls(config.maxParallelSubCalls) ctx.systemPrompt.tools(context => this.wireSchemas(context.scope)) - if (this.mode !== 'native') { - ctx.systemPrompt.section({ - name: 'tools:sdk', - order: SDK_SECTION_ORDER, - // Regenerate from the calling scope's visible tools in stable order. - text: (context) => { - this.requireCodeRuntime() - return renderToolsSdk(this.sdkSchemas(context.scope)) - }, - }) + if (this.defaultMode !== 'native') { + ctx.systemPrompt.section(this.sdkSection()) } } + /** + * The generated-SDK prompt section, registered globally by a code-mode + * deployment and per agent by {@link presentAs}. + * + * The body regenerates from the CALLING scope, and renders empty for an + * agent presenting natively — an agent that opted out under a code-mode + * deployment still sees the global registration, and an empty section is + * dropped from the rendered prompt. + * @returns the section registration. + */ + private sdkSection(): { name: string; order: number; text: (context: { scope?: ScopeKey }) => string } { + return { + name: 'tools:sdk', + order: SDK_SECTION_ORDER, + // Regenerate from the calling scope's visible tools in stable order. + text: (context) => { + const mode = this.modeFor(context.scope) + if (mode === 'native') return '' + this.requireCodeRuntime(mode) + return renderToolsSdk(this.sdkSchemas(context.scope)) + }, + } + } + + /** + * The presentation one scope's agent sees: its own declaration, else the + * deployment default. + * @param scope - the calling agent, or undefined for the global view. + * @returns the resolved presentation mode. + */ + private modeFor(scope?: ScopeKey): ToolPresentationMode { + return this.layers.peek(scope)?.mode ?? this.defaultMode + } + + /** + * The reserved `run_code` transport, built on first need. + * + * It never enters the global layer: per-agent restrictions must not remove + * it, and a scoped registration must not shadow it. The visibility resolver + * appends it after resolving the filterable global/scoped capability layers, + * and only for scopes whose mode actually presents it. + * @returns the shared transport definition. + */ + private requireCodeTransport(): ToolDefinition { + this.codeTransport ??= createRunCodeTool(this, { + requireRuntime: () => this.requireCodeRuntime(this.defaultMode), + maxParallel: this.maxParallelSubCalls, + shapeDispatchLog: dispatch => this.shapeDispatchLog(dispatch), + }) + return this.codeTransport + } + + /** + * Present this agent's tools in `mode` instead of the deployment default. + * + * Scoped only, and one declaration per agent: this is how an agent preset + * composes a Code Mode agent beside native ones in the same process, and a + * process-global override would be the `mode` config field instead. + * @param mode - the presentation this agent's model sees. + * @returns the exact disposer that restores the deployment default. + */ + presentAs(mode: ToolPresentationMode): () => void { + const ctx = this.ctx + if (scopeOf(ctx) === undefined) { + throw new Error('tools.presentAs() requires a scoped context (agent.ctx): a context-global presentation is the `mode` config field on the tools row') + } + const dispose = ctx.effect(function* (this: ToolRegistry) { + yield this.layers.effect( + ctx, + (layer) => { + if (layer.mode !== undefined) { + throw new Error(`tools.presentAs("${mode}") conflicts with "${layer.mode}" already declared for this agent; one composition selects one presentation`) + } + layer.mode = mode + return () => { layer.mode = undefined } + }, + { label: 'tools.presentAs()' }, + ) + // The SDK section is per agent for the same reason the mode is. Under a + // deployment that already defaults to a code mode this shadows the + // global registration with an identical body, which costs nothing and + // 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 + } + /** * Build one scope's wire schemas and names for prompt-order validation. * Restrictions do not make known tools invalid, but a mode collapse does. @@ -781,11 +869,12 @@ export class ToolRegistry extends Service { private wireSchemas(scope?: ScopeKey): ToolProviderResult { const view = this.view(scope) const schemas = [...view.visible.values()].map(definition => this.schemaOf(definition, false)) - if (this.mode === 'native') { + const mode = this.modeFor(scope) + if (mode === 'native') { return { schemas, knownNames: [...view.knownNames] } } - this.requireCodeRuntime() - if (this.mode === 'code') { + this.requireCodeRuntime(mode) + if (mode === 'code') { return { schemas: schemas.filter(schema => schema.name === RUN_CODE_NAME), knownNames: [RUN_CODE_NAME], @@ -802,13 +891,13 @@ export class ToolRegistry extends Service { * 'native'` (the loop's optional-backend idiom, same as * `sessionPersistence`). */ - private requireCodeRuntime(): CodeRuntime { + private requireCodeRuntime(mode: ToolPresentationMode): CodeRuntime { const runtime = this.ctx.get('codeRuntime') if (!runtime) { - throw new Error(`dsh-tools: mode "${this.mode}" requires a code runtime — load a ctx.codeRuntime implementation (e.g. @deepseek-ai/dsh-code-runtime-worker) or set tools mode to "native"`) + throw new Error(`dsh-tools: mode "${mode}" requires a code runtime — load a ctx.codeRuntime implementation (e.g. @deepseek-ai/dsh-code-runtime-worker) or set tools mode to "native"`) } if (runtime.language !== 'typescript') { - throw new Error(`dsh-tools: mode "${this.mode}" generates a TypeScript SDK, but the loaded code runtime's language is "${runtime.language}"`) + throw new Error(`dsh-tools: mode "${mode}" generates a TypeScript SDK, but the loaded code runtime's language is "${runtime.language}"`) } return runtime } @@ -833,7 +922,10 @@ export class ToolRegistry extends Service { && (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) { throw new TypeError(`tool "${name}" timeoutMs must be a positive finite number`) } - if (this.codeTransport !== undefined && name === RUN_CODE_NAME) { + // Reserved unconditionally: any agent may select a code mode for itself, + // so a name free to take under the deployment default would become a + // collision the moment a preset mounted. + if (name === RUN_CODE_NAME) { throw new Error(`tool name "${RUN_CODE_NAME}" is reserved for the Code Mode presentation transport and cannot be registered or shadowed`) } return this.layers.effect( @@ -864,8 +956,7 @@ export class ToolRegistry extends Service { ...allow !== undefined ? { allow: new Set(allow) } : {}, ...deny !== undefined ? { deny: new Set(deny) } : {}, } - if (this.codeTransport !== undefined - && [...allow ?? [], ...deny ?? []].includes(RUN_CODE_NAME)) { + if ([...allow ?? [], ...deny ?? []].includes(RUN_CODE_NAME)) { throw new Error(`tools.restrict() cannot name reserved Code Mode presentation transport "${RUN_CODE_NAME}"; restrict end-capability tools instead`) } const known = this.view(scope).restrictableNames @@ -931,9 +1022,11 @@ export class ToolRegistry extends Service { } // Presentation infrastructure is resolved last and outside capability // filtering. Registration rejects this reserved name, so the insertion is - // an invariant assertion as well as protection against future layer changes. - if (this.codeTransport !== undefined) { - visible.set(RUN_CODE_NAME, this.codeTransport) + // an invariant assertion as well as protection against future layer + // changes. Per scope: a native agent must not find `run_code` in its + // dispatch table because some other agent in the process presents it. + if (this.modeFor(scope) !== 'native') { + visible.set(RUN_CODE_NAME, this.requireCodeTransport()) } return { visible, knownNames, restrictableNames } } diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index 7b037d266a..3d15b34e26 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -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') + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1ccd303c5..44ed3a2620 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -140,6 +140,9 @@ importers: '@cordisjs/plugin-timer': specifier: workspace:* version: link:../../vendor/timer + '@deepseek-ai/dsh-agent-tool-mode': + specifier: workspace:^ + version: link:../../packages/core/agent-tool-mode '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../packages/ui/app-boot @@ -2806,6 +2809,37 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/core/agent-tool-mode: + dependencies: + schemastery: + specifier: ^3.18.0 + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../agent + '@deepseek-ai/dsh-code-runtime': + specifier: workspace:^ + version: link:../../code-runtime/code-runtime + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../scope + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../tools + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/core/scope: devDependencies: '@deepseek-ai/dsh-invariants': diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index d87b894480..2b272c51e0 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -202,6 +202,7 @@ export const LINK_MAP: Readonly> = { ToolExecutionResult: 'tools.md', ToolExecutionToken: 'tools.md', ToolGuard: 'tools.md', + ToolPresentationMode: 'tools.md', ToolRegistry: 'tools.md', ToolRestriction: 'tools.md', ToolSchema: 'tools.md', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index d36d95e743..4c3f8c3724 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -46,6 +46,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/bash/bash-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-bash.' }, 'packages/bash/pwsh-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-pwsh.' }, 'packages/code-runtime/code-runtime': { kind: 'indirect', reason: 'The service interface delegates model rendering to Code Mode in dsh-tools.' }, + 'packages/core/agent-tool-mode': { kind: 'indirect', reason: 'The row only selects between the two projections dsh-tools owns; it registers no prompt, schema, or result of its own.' }, 'packages/code-runtime/code-runtime-worker': { kind: 'indirect', reason: 'The worker backend delegates model rendering to Code Mode in dsh-tools.' }, 'packages/client/ui-agent-preset': { kind: 'indirect', reason: 'Browser-side settings row; the preset it selects owns every model-facing effect.' }, 'packages/preset/agent-presets': { kind: 'indirect', reason: 'The mount installs a preset\'s own plugins, which own every model-facing registration it makes visible.' }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 9595bd9295..fb254b4fe9 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -135,6 +135,7 @@ { "path": "./packages/ui/user-approval" }, { "path": "./packages/ui/permission" }, { "path": "./packages/core/tools" }, + { "path": "./packages/core/agent-tool-mode" }, { "path": "./packages/skill/skill" }, { "path": "./packages/skill/skill-local" }, { "path": "./packages/skill/tool-skill" },