feat(tools): admit conditional tools per step

ctx.tools.register(definition, { when }) adds a synchronous or asynchronous
condition. AgentLoop resolves conditions once per proposed step with the
agent, the model selection that step preparation captured, and the turn
signal, before prompt assembly and agent/pre-step. The resulting
ResolvedToolSet governs tool declarations, Native dispatch, PTC bindings,
and nested calls for that step and its retries, and the turn disposes it
when the proposal is rejected or fails, when no step opens, and after the
step. A false condition removes the tool everywhere; a thrown condition
ends the turn with TOOL_ACTIVATION_FAILED. Calls whose registration was
replaced or removed during the step return UNKNOWN_TOOL.

system-prompt/assemble now transforms PromptPresentation without a tool
array, so tool declarations stay provider-owned. exit_plan_mode is declared
only for steps whose selected plan state is active, the same state that
renders plan guidance; it still rejects a call before the selection is
logged.

Model selection stays as master defines it; this change adds no model
selection event or Agent field.
This commit is contained in:
Yichen Jiang committed 2026-10-21 15:02:29 +08:00
1 parent b805300652
commit 9d0c97b9d2
54 files changed
+949 -331

No files matched your search

@@ -15,8 +15,8 @@
en: da694641e1d009de
zh: 9d7492d39039af66
/agent-note-ptc-mode-the-model-writes-typescript-against-the-tool-registry/decision/the-registry-owns-the-mode:
en: 091b7bc49b027353
zh: 42dc77a810202694
en: 4cd76a34f4712e1a
zh: 303ef72a18644d7c
/agent-note-ptc-mode-the-model-writes-typescript-against-the-tool-registry/decision/the-run-code-tool-and-the-dispatch-bridge:
en: 6a0e2698b082978e
zh: fbb03503520b30e9
@@ -40,7 +40,7 @@ The [Node execution decision](../architecture/2026-09-11-sandboxed-node-ptc-runt
**SDK prompt section.** In `'ptc'`, the lazy `tools:sdk` section in the tool-guidance order band renders the loaded runtime's language declarations plus fixed usage instructions for the scope's visible capabilities (TypeScript by default; the [language-dispatch note](../../archived/feature/2026-07-31-ptc-language-dispatch.md) added Python and the `ctx.ptcRuntime.language` renderer table). It shares lookup and execution visibility, excludes `run_code`, and sorts tools lexicographically for byte-stable output.
**Assembly ownership.** `run_code` and `tools:sdk` enter the trusted `system-prompt/assemble` waterfall as normal assembly inputs. A scoped `tools:sdk` section may shadow the global default before dispatch, and a listener may remove or replace either contribution. The waterfall's returned assembly is final, so whoever changes these inputs owns preserving a viable PTC mode protocol when the deployment expects PTC mode to remain usable; no restoration pass overrides deliberate composition.
**Assembly ownership.** `tools:sdk` enters the trusted `system-prompt/assemble` presentation waterfall as a normal section, while `run_code` stays a provider-owned declaration that the waterfall cannot remove or replace. A scoped `tools:sdk` section may shadow the global default before dispatch, and a listener may remove or replace the section. Whoever changes it owns preserving a viable PTC mode protocol when the deployment expects PTC mode to remain usable; no restoration pass overrides deliberate composition.
**Codegen.** `jsonSchemaToTs()` maps the `defineTool` JSON-Schema subset to TypeScript, carries schema descriptions into JSDoc, and degrades unsupported constructs to `unknown`. The SDK exposes tools as quoted object keys, supporting arbitrary names without aliases or collisions. Typing is advisory because the runtime strips types before execution.
@@ -40,7 +40,7 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一
**SDK 提示词段。** 在 `'ptc'` 下,tool-guidance order band 中的惰性 `tools:sdk` 段为当前 scope 的可见能力渲染所加载运行时语言的声明加固定的使用说明(默认 TypeScript;[语言分发 note](../../archived/feature/2026-07-31-ptc-language-dispatch.md) 加入了 Python 与按 `ctx.ptcRuntime.language` 选择的渲染器表)。它共享查找和执行可见性,排除 `run_code`,并按字典序排列工具以获得字节稳定的输出。
**组装所有权。** `run_code` 和 `tools:sdk` 作为正常的组装输入进入受信任的 `system-prompt/assemble` waterfall。一个 scoped 的 `tools:sdk` 段可以在分发前遮蔽全局默认值,监听器也可以移除或替换任一贡献。waterfall 返回的组装结果是最终的,因此修改这些输入的人有责任在部署期望 PTC mode 可用时保持协议面的完整性;没有恢复 pass 会覆盖有意的组合。
**组装所有权。** `tools:sdk` 作为正常段进入受信任的 `system-prompt/assemble` 展示 waterfall,而 `run_code` 仍是提供方持有的声明,waterfall 不能移除或替换它。一个 scoped 的 `tools:sdk` 段可以在分发前遮蔽全局默认值,监听器也可以移除或替换该段。修改它的人有责任在部署期望 PTC mode 可用时保持协议面的完整性;没有恢复 pass 会覆盖有意的组合。
**代码生成。** `jsonSchemaToTs()` 将 `defineTool` 的 JSON Schema 子集映射为 TypeScript,将 schema 描述带入 JSDoc,不支持的构造降级为 `unknown`。SDK 将工具暴露为带引号的对象键,支持任意名称而无需别名或冲突处理。类型是建议性的,因为运行时在执行前会剥离类型。
+3 -1
View File
@@ -25,7 +25,8 @@ sequenceDiagram
Note over Agent,Driver: claim pending next-step input plus one queued prompt
Driver-->>SDK: <code>agent/inbox/spliced</code> pure deletion
Driver-->>SDK: <code>agent/inbox/claimed</code> { message, turn } per message
Driver->>Prompt: <code>system-prompt/assemble</code> waterfall
Driver->>Tools: resolve({ agent, modelSelection, signal }) once per proposed step
Driver->>Prompt: <code>system-prompt/assemble</code> waterfall with the step tool set
Driver->>Hooks: <code>agent/pre-step</code> waterfall
Hooks-->>Driver: authoritative reject or enter(messages)
alt proposed step rejected, first batch empty, or pre-step failed
@@ -65,6 +66,7 @@ sequenceDiagram
end
end
Driver->>Session: <code>step/end</code>
Driver->>Tools: dispose the step tool set
opt natural stop and next-step inbox empty
Driver->>Hooks: <code>agent/turn-stopping</code> serial terminal checkpoint
end
+3 -1
View File
@@ -27,7 +27,8 @@ sequenceDiagram
Note over Agent,Driver: claim pending next-step input plus one queued prompt
Driver-->>SDK: <code>agent/inbox/spliced</code> pure deletion
Driver-->>SDK: <code>agent/inbox/claimed</code> { message, turn } per message
Driver->>Prompt: <code>system-prompt/assemble</code> waterfall
Driver->>Tools: resolve({ agent, modelSelection, signal }) once per proposed step
Driver->>Prompt: <code>system-prompt/assemble</code> waterfall with the step tool set
Driver->>Hooks: <code>agent/pre-step</code> waterfall
Hooks-->>Driver: authoritative reject or enter(messages)
alt proposed step rejected, first batch empty, or pre-step failed
@@ -67,6 +68,7 @@ sequenceDiagram
end
end
Driver->>Session: <code>step/end</code>
Driver->>Tools: dispose the step tool set
opt natural stop and next-step inbox empty
Driver->>Hooks: <code>agent/turn-stopping</code> serial terminal checkpoint
end
+2 -1
View File
@@ -83,7 +83,8 @@ A **step** is one model request plus the tools it calls. A **turn** is zero or m
```text
turn/start
claim next-step input plus one queued message
snapshot Session model selection; assemble prompt sections + tool schemas
snapshot Session model selection; resolve the step's conditional tools
assemble prompt sections + admitted tool schemas
-> agent/pre-step; project runtime context at fallback
reject | enter(messages, startsRequestSeries?)
reject, or a first enter rewritten empty -> close the turn with no step
+2 -1
View File
@@ -87,7 +87,8 @@ AgentLoop 在启动已排队工作前等待串行 `agent/created`,初始化失
```text
turn/start
claim next-step input plus one queued message
snapshot Session model selection; assemble prompt sections + tool schemas
snapshot Session model selection; resolve the step's conditional tools
assemble prompt sections + admitted tool schemas
-> agent/pre-step; project runtime context at fallback
reject | enter(messages, startsRequestSeries?)
reject, or a first enter rewritten empty -> close the turn with no step
+3 -3
View File
@@ -2465,7 +2465,7 @@ export interface Config {
## `@deepseek-ai/dsh-plan-mode`
- `inject`: `tools` · `systemPrompt` · `sessionProjections` · `agents`
- `source`: [`packages/plan/plan-mode/src/index.ts:69`](../packages/plan/plan-mode/src/index.ts)
- `source`: [`packages/plan/plan-mode/src/index.ts:66`](../packages/plan/plan-mode/src/index.ts)
```ts config-catalog
/** Deployment-owned plan guidance. */
@@ -3638,7 +3638,7 @@ export interface Config {
## `@deepseek-ai/dsh-system-prompt`
- `source`: [`packages/core/system-prompt/src/index.ts:258`](../packages/core/system-prompt/src/index.ts)
- `source`: [`packages/core/system-prompt/src/index.ts:263`](../packages/core/system-prompt/src/index.ts)
```ts config-catalog
/** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.personaPrefix} for its contract). */
@@ -4326,7 +4326,7 @@ export interface Config {
## `@deepseek-ai/dsh-tools`
- `inject`: `systemPrompt`
- `source`: [`packages/core/tools/src/index.ts:675`](../packages/core/tools/src/index.ts)
- `source`: [`packages/core/tools/src/index.ts:735`](../packages/core/tools/src/index.ts)
```ts config-catalog
/** Plugin config: how the registered tools are presented to the model. */
+3 -3
View File
@@ -2467,7 +2467,7 @@ export interface Config {
## `@deepseek-ai/dsh-plan-mode`
- `inject`: `tools` · `systemPrompt` · `sessionProjections` · `agents`
- `source`: [`packages/plan/plan-mode/src/index.ts:69`](../packages/plan/plan-mode/src/index.ts)
- `source`: [`packages/plan/plan-mode/src/index.ts:66`](../packages/plan/plan-mode/src/index.ts)
```ts config-catalog
/** Deployment-owned plan guidance. */
@@ -3640,7 +3640,7 @@ export interface Config {
## `@deepseek-ai/dsh-system-prompt`
- `source`: [`packages/core/system-prompt/src/index.ts:258`](../packages/core/system-prompt/src/index.ts)
- `source`: [`packages/core/system-prompt/src/index.ts:263`](../packages/core/system-prompt/src/index.ts)
```ts config-catalog
/** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.personaPrefix} for its contract). */
@@ -4328,7 +4328,7 @@ export interface Config {
## `@deepseek-ai/dsh-tools`
- `inject`: `systemPrompt`
- `source`: [`packages/core/tools/src/index.ts:675`](../packages/core/tools/src/index.ts)
- `source`: [`packages/core/tools/src/index.ts:735`](../packages/core/tools/src/index.ts)
```ts config-catalog
/** Plugin config: how the registered tools are presented to the model. */
+6 -6
View File
@@ -75,12 +75,12 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:158`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code) |
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | `browser-use-runtime`, [`working-directory`](../packages/session/working-directory) |
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:209`](../packages/core/tools/src/index.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:165`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `browser-use-runtime`, `browser-use-stagehand-native`, `claude-code-mods`, `computer-use-cua-driver-native`, [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` |
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:177`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `claude-code-mods`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:154`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) |
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:216`](../packages/core/tools/src/index.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:172`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `browser-use-runtime`, `browser-use-stagehand-native`, `claude-code-mods`, `computer-use-cua-driver-native`, [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` |
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:184`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `claude-code-mods`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:161`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:198`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:206`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) |
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:174`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` |
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
+6 -6
View File
@@ -77,12 +77,12 @@
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:158`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code) |
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | `browser-use-runtime`, [`working-directory`](../packages/session/working-directory) |
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:209`](../packages/core/tools/src/index.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:165`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `browser-use-runtime`, `browser-use-stagehand-native`, `claude-code-mods`, `computer-use-cua-driver-native`, [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` |
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:177`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `claude-code-mods`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:154`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) |
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:216`](../packages/core/tools/src/index.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:172`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `browser-use-runtime`, `browser-use-stagehand-native`, `claude-code-mods`, `computer-use-cua-driver-native`, [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` |
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:184`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `claude-code-mods`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:161`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:198`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:206`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) |
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:174`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` |
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
+4 -4
View File
@@ -1287,8 +1287,8 @@
en: f43a635a14568780
zh: 8d22226b045122eb
/session-persistence-event-catalog/resolved-persistence-types/toolrestriction:
en: d7d3c0238d9b373e
zh: f49d93ee54e7a780
en: 0a56a07b1de985b4
zh: 886c512054d12ad6
/session-persistence-event-catalog/resolved-persistence-types/toolresultmessage:
en: 2258683c3cfa4ce3
zh: 5663aa76c911761b
@@ -1830,8 +1830,8 @@
en: 08be4349548406a9
zh: 8e7cf9966198ef78
/session-persistence-event-catalog/resolved-persistence-types/kind-tool-registry:
en: 809a23c25ded8497
zh: e9cd23e281f8d31f
en: 3d5ec0c7e557a805
zh: 385951658140c9c4
/session-persistence-event-catalog/resolved-persistence-types/kind-user:
en: 43f0c6042d410afd
zh: a0b68de4e5d220bd
+2 -2
View File
@@ -5193,7 +5193,7 @@ Sources: [`packages/llm/llm/src/types.ts:127`](../packages/llm/llm/src/types.ts)
SHA-256: `3cfc3a56502da1f8c6153c2c56657bab4b749056968634dd0991d325ec1c919f`
Sources: [`packages/core/tools/src/index.ts:701`](../packages/core/tools/src/index.ts)
Sources: [`packages/core/tools/src/index.ts:761`](../packages/core/tools/src/index.ts)
| Property | Presence | Type |
|---|---|---|
@@ -7542,7 +7542,7 @@ SHA-256: `c5228b7a55498d84f529591744a0ab26e0b93ade18a70a26db1377a1ecfa6b49`
SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543`
Sources: [`packages/core/tools/src/index.ts:35`](../packages/core/tools/src/index.ts)
Sources: [`packages/core/tools/src/index.ts:42`](../packages/core/tools/src/index.ts)
| Property | Presence | Type |
|---|---|---|
+2 -2
View File
@@ -5195,7 +5195,7 @@ SHA-256: `43e311235aa537df1bacdd92d144df8a952337a76e9ac892cdf670f1a797bad8`
SHA-256: `3cfc3a56502da1f8c6153c2c56657bab4b749056968634dd0991d325ec1c919f`
来源:[`packages/core/tools/src/index.ts:701`](../packages/core/tools/src/index.ts)
来源:[`packages/core/tools/src/index.ts:761`](../packages/core/tools/src/index.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
@@ -7544,7 +7544,7 @@ SHA-256: `c5228b7a55498d84f529591744a0ab26e0b93ade18a70a26db1377a1ecfa6b49`
SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543`
来源:[`packages/core/tools/src/index.ts:35`](../packages/core/tools/src/index.ts)
来源:[`packages/core/tools/src/index.ts:42`](../packages/core/tools/src/index.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
+2 -2
View File
@@ -220,7 +220,7 @@
{"digest":"3bec87f9e6ab3da03346f49923d3c51621debc0159df68605e543c3b2a026f90","names":[],"sources":["packages/llm/llm/src/types.ts:471"]},
{"digest":"3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1","names":[],"sources":[]},
{"digest":"3c2535ca0a2062f172f15344f8040755002b5ac506cf94fc4ebaa822b15feb8c","names":[],"sources":[]},
{"digest":"3cfc3a56502da1f8c6153c2c56657bab4b749056968634dd0991d325ec1c919f","names":["packages/core/tools/src/index.ts#ToolRestriction"],"sources":["packages/core/tools/src/index.ts:701"]},
{"digest":"3cfc3a56502da1f8c6153c2c56657bab4b749056968634dd0991d325ec1c919f","names":["packages/core/tools/src/index.ts#ToolRestriction"],"sources":["packages/core/tools/src/index.ts:761"]},
{"digest":"3d7d07060a7be77984feb651ea2bf1925d3e0477911d9d3ed86330d1e8ca6182","names":["packages/feedback/message-feedback/src/types.ts#MessageFeedbackItem"],"sources":["packages/feedback/message-feedback/src/types.ts:20"]},
{"digest":"3e0eff5946255ff05d88a10839c60305135801f97794ea48d409f8503303317e","names":[],"sources":[]},
{"digest":"3e2ece4967cfebe95f450a93f45523dff99d6749f6269f2f60f84dfecfcf3693","names":[],"sources":[]},
@@ -416,7 +416,7 @@
{"digest":"8e8e91646a5cae3fc78f7532a0013ff2088ae47077642c2edd507a508209b8af","names":["packages/schedule/schedule/src/types.ts#OneShotScheduleDispatchChange"],"sources":["packages/schedule/schedule/src/types.ts:237"]},
{"digest":"8f324cea3916c0517261e88167def8efb3a9994c1135e23d40ed9fd48f8c7bd4","names":[],"sources":[]},
{"digest":"8f6d609794bf5afc01d7ccf3e03811d32f4b8047f58a2c9a98db0154c2ad7cb9","names":[],"sources":[]},
{"digest":"8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543","names":[],"sources":["packages/core/tools/src/index.ts:35"]},
{"digest":"8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543","names":[],"sources":["packages/core/tools/src/index.ts:42"]},
{"digest":"904bf499d98be4a3a5f836fcac533b6c4899569b018071e6604e1d32657d00fd","names":[],"sources":[]},
{"digest":"930c1cd289def119d443e19dd90008916bf035f0a4d23e07241e7f050f2b89b7","names":["packages/interaction/user-approval/src/types.ts#ApprovalOutcome"],"sources":["packages/interaction/user-approval/src/types.ts:32"]},
{"digest":"935c8d486292b13c87b57ca280eb8c58b5b2f5239be86fcd4334c33fdeb87682","names":[],"sources":[]},
+4 -4
View File
@@ -15,8 +15,8 @@
en: ee04468005fae8e1
zh: 1347d554102951f2
/plan-mode/the-exit-tool-and-the-plan-command:
en: b5bbc440552d7ade
zh: 17fb1cfed1bc48ef
en: 9f2a549447f8d374
zh: 8f3064e31d514702
/plan-mode/the-service:
en: 07c3eec12e0dc75c
zh: 183d73f6672e2c5a
en: f3e6d5a7fe0b67f0
zh: 774cc97e493991fa
+3 -3
View File
@@ -30,13 +30,13 @@ A missing, blank, or non-string `section` and any unknown key fail at plugin loa
## The exit tool and the `/plan` command
[`exit_plan_mode`](../tool-catalog.md#deepseek-aidsh-plan-mode) stays registered while plan mode is inactive, so entering or leaving plan mode changes only the prompt section, never the request tool catalog; execution outside plan mode fails. In plan mode it requires a complete markdown plan starting with a `#` heading and presents it for review through the [user-questions seam](user-questions.md). Approval returns `{ approved: true }` and records a silent (non-narrated) pending exit that is appended at the next accepted in-turn pre-step. Plan guidance therefore remains active for the rest of the assistant's current tool batch, and the tool result itself reports the transition. Keep-planning is a failed call carrying the user's feedback, so the model revises and presents again. A dismissed review returns `{ approved: false }` and concludes the turn, so plan mode stays active until the user's next message. A missing interaction channel and a service reload during review also fail the call rather than silently leaving plan mode.
[`exit_plan_mode`](../tool-catalog.md#deepseek-aidsh-plan-mode) is admitted only for steps whose selected plan state is active, so entering or leaving plan mode changes the request tool declarations and the plan guidance together; Native and PTC execution use the same admitted set, and a call outside plan mode returns `UNKNOWN_TOOL`. In plan mode it requires a complete markdown plan starting with a `#` heading and presents it for review through the [user-questions seam](user-questions.md). Approval returns `{ approved: true }` and records a silent (non-narrated) pending exit that is appended at the next accepted in-turn pre-step. Plan guidance therefore remains active for the rest of the assistant's current tool batch, and the tool result itself reports the transition. Keep-planning is a failed call carrying the user's feedback, so the model revises and presents again. A dismissed review returns `{ approved: false }` and concludes the turn, so plan mode stays active until the user's next message. A missing interaction channel and a service reload during review also fail the call rather than silently leaving plan mode.
When [`ctx.commands`](commands.md) is composed, the plugin registers `/plan [off|message]`: bare `/plan` selects plan mode, any other non-empty message selects it and then submits the text through `agent.steer()` so it becomes the next step's ordinary logged user message under plan guidance, and the exact argument `off` selects inactive, which also cancels a pending entry before it is appended and becomes visible to a request.
## The service
`ctx.planMode` owns the logged plan state, applies and narrates selected state at step start, and owns the `plan:policy` section, the `/plan` command, and the stable exit tool; `get`/`set` signatures are in the generated [service catalog](#ctxplanmode--planmodecontroller).
`ctx.planMode` owns the logged plan state, applies and narrates selected state at step start, and owns the `plan:policy` section, the `/plan` command, and the conditional exit tool; `get`/`set` signatures are in the generated [service catalog](#ctxplanmode--planmodecontroller).
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
@@ -50,7 +50,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
### `ctx.planMode` — `PlanModeController`
`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the stable exit tool. Client carriers expose the projection's cropped `{ active, pending }` view. Agent prompt assembly fails while the projection reader is not ready; callers must await plugin activation before running an agent.
`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the conditional exit tool. Client carriers expose the projection's cropped `{ active, pending }` view. Agent prompt assembly fails while the projection reader is not ready; callers must await plugin activation before running an agent.
```ts cordis-catalog
/**
+3 -3
View File
@@ -30,13 +30,13 @@ interface PlanModeConfig {
## 退出工具与 `/plan` 命令
[`exit_plan_mode`](../tool-catalog.zh.md#deepseek-aidsh-plan-mode) 在计划模式未激活时仍保持注册,因此进入或离开计划模式只改变提示词段落,绝不改变请求的工具目录;在计划模式之外执行会失败。在计划模式中,它要求一份以 `#` 标题开头的完整 markdown 计划,并通过[用户交互 seam](user-questions.zh.md) 呈交评审。批准返回 `{ approved: true }`,并记录一个静默(不叙述)的待生效退出,由下一个被接受的轮内 pre-step 追加。因此,计划指引在 assistant 当前这批工具调用的剩余部分继续生效,而工具结果本身会报告这次转换。「继续规划」则是一次携带用户反馈的失败调用,模型据此修订并再次呈交。放弃评审返回 `{ approved: false }` 并结束当前轮次,计划模式保持激活,直到用户发出下一条消息。评审期间交互通道缺失或服务重载同样使调用失败,而不是静默离开计划模式。
[`exit_plan_mode`](../tool-catalog.zh.md#deepseek-aidsh-plan-mode) 只在已选计划状态为激活的步骤中准入,因此进入或离开计划模式会同时改变请求的工具声明与计划指引;原生与 PTC 执行使用同一准入集合,在计划模式之外调用会返回 `UNKNOWN_TOOL`。在计划模式中,它要求一份以 `#` 标题开头的完整 markdown 计划,并通过[用户交互 seam](user-questions.zh.md) 呈交评审。批准返回 `{ approved: true }`,并记录一个静默(不叙述)的待生效退出,由下一个被接受的轮内 pre-step 追加。因此,计划指引在 assistant 当前这批工具调用的剩余部分继续生效,而工具结果本身会报告这次转换。「继续规划」则是一次携带用户反馈的失败调用,模型据此修订并再次呈交。放弃评审返回 `{ approved: false }` 并结束当前轮次,计划模式保持激活,直到用户发出下一条消息。评审期间交互通道缺失或服务重载同样使调用失败,而不是静默离开计划模式。
当 [`ctx.commands`](commands.zh.md) 被组合时,插件注册 `/plan [off|message]`:单独的 `/plan` 选择计划模式;任何其他非空消息先选择计划模式,再通过 `agent.steer()` 提交该文本,使其在计划指引下成为下一步骤的普通已记录用户消息;确切参数 `off` 选择未激活,这还会在待生效条目被追加并对请求可见之前将其取消。
## 服务
`ctx.planMode` 拥有已记录的计划状态,在步骤开始时应用并叙述选中的状态,还拥有 `plan:policy` 段落、`/plan` 命令和稳定注册的退出工具;`get`/`set` 签名见生成的[服务目录](#ctxplanmode--planmodecontroller)。
`ctx.planMode` 拥有已记录的计划状态,在步骤开始时应用并叙述选中的状态,还拥有 `plan:policy` 段落、`/plan` 命令和按条件准入的退出工具;`get`/`set` 签名见生成的[服务目录](#ctxplanmode--planmodecontroller)。
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
@@ -50,7 +50,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
### `ctx.planMode` — `PlanModeController`
`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the stable exit tool. Client carriers expose the projection's cropped `{ active, pending }` view. Agent prompt assembly fails while the projection reader is not ready; callers must await plugin activation before running an agent.
`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the conditional exit tool. Client carriers expose the projection's cropped `{ active, pending }` view. Agent prompt assembly fails while the projection reader is not ready; callers must await plugin activation before running an agent.
```ts cordis-catalog
/**
+10 -10
View File
@@ -193,12 +193,12 @@ refreshContext(assembly: PromptAssembly, context: AssembleContext = {}): PromptA
/**
* Assemble global and scoped providers, detach tool parameters, apply
* canonical ordering, then run the assembly waterfall. Scoped sections and
* variables shadow globals. The returned waterfall value is authoritative
* except that an effective complete section is restored afterwards as the
* sole prompt section.
* @param context - the optional scope and plugin-defined assembly fields.
* @returns the post-waterfall assembly with any complete prompt enforced.
* canonical ordering, then run the presentation waterfall. Scoped sections
* and variables shadow globals. The waterfall's text is authoritative except
* that an effective complete section is restored afterwards as the sole
* prompt section; tool declarations stay provider-owned.
* @param context - the optional scope, step tool set, and plugin-defined assembly fields.
* @returns the post-waterfall assembly with ordered tool declarations and any complete prompt enforced.
*/
async assemble(context: AssembleContext = {}): Promise<PromptAssembly>
```
@@ -213,13 +213,13 @@ Source: [`packages/core/system-prompt/src/index.ts`](../../packages/core/system-
#### `system-prompt/assemble` — waterfall
Expert waterfall over the assembled sections, contexts, tools, and variables. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners receive only that scope's assemblies. The returned value is authoritative. A supplied signal controls only this explicit assembly request and must not be retained to control later turns. A registered complete section is restored after this waterfall, so listeners cannot add to or replace that scope's system prompt.
Presentation waterfall over assembled sections, contexts, and variables. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners receive only that scope's assemblies. The returned text is authoritative; tool declarations remain provider-owned. A supplied signal controls only this explicit assembly request and must not be retained to control later turns. A registered complete section is restored after this waterfall, so listeners cannot add to or replace that scope's system prompt.
```ts cordis-catalog
/**
* Expert waterfall over the assembled sections, contexts, tools, and variables.
* Presentation waterfall over assembled sections, contexts, and variables.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners
* receive only that scope's assemblies. The returned value is authoritative.
* receive only that scope's assemblies. The returned text is authoritative; tool declarations remain provider-owned.
* A supplied signal controls only this explicit assembly request and must not
* be retained to control later turns. A registered complete section is
* restored after this waterfall, so listeners cannot add to or replace
@@ -228,7 +228,7 @@ Expert waterfall over the assembled sections, contexts, tools, and variables. Sc
* @param context - the caller's per-assembly context.
* @mode waterfall
*/
'system-prompt/assemble'(this: Scoped<SystemPrompt>, assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>): Promise<PromptAssembly>
'system-prompt/assemble'(this: Scoped<SystemPrompt>, assembly: PromptPresentation, context: AssembleContext, next: () => Promise<PromptPresentation>): Promise<PromptPresentation>
```
Types: [Scoped](scope.md)
+10 -10
View File
@@ -193,12 +193,12 @@ refreshContext(assembly: PromptAssembly, context: AssembleContext = {}): PromptA
/**
* Assemble global and scoped providers, detach tool parameters, apply
* canonical ordering, then run the assembly waterfall. Scoped sections and
* variables shadow globals. The returned waterfall value is authoritative
* except that an effective complete section is restored afterwards as the
* sole prompt section.
* @param context - the optional scope and plugin-defined assembly fields.
* @returns the post-waterfall assembly with any complete prompt enforced.
* canonical ordering, then run the presentation waterfall. Scoped sections
* and variables shadow globals. The waterfall's text is authoritative except
* that an effective complete section is restored afterwards as the sole
* prompt section; tool declarations stay provider-owned.
* @param context - the optional scope, step tool set, and plugin-defined assembly fields.
* @returns the post-waterfall assembly with ordered tool declarations and any complete prompt enforced.
*/
async assemble(context: AssembleContext = {}): Promise<PromptAssembly>
```
@@ -213,13 +213,13 @@ Source: [`packages/core/system-prompt/src/index.ts`](../../packages/core/system-
#### `system-prompt/assemble` — waterfall
Expert waterfall over the assembled sections, contexts, tools, and variables. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners receive only that scope's assemblies. The returned value is authoritative. A supplied signal controls only this explicit assembly request and must not be retained to control later turns. A registered complete section is restored after this waterfall, so listeners cannot add to or replace that scope's system prompt.
Presentation waterfall over assembled sections, contexts, and variables. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners receive only that scope's assemblies. The returned text is authoritative; tool declarations remain provider-owned. A supplied signal controls only this explicit assembly request and must not be retained to control later turns. A registered complete section is restored after this waterfall, so listeners cannot add to or replace that scope's system prompt.
```ts cordis-catalog
/**
* Expert waterfall over the assembled sections, contexts, tools, and variables.
* Presentation waterfall over assembled sections, contexts, and variables.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners
* receive only that scope's assemblies. The returned value is authoritative.
* receive only that scope's assemblies. The returned text is authoritative; tool declarations remain provider-owned.
* A supplied signal controls only this explicit assembly request and must not
* be retained to control later turns. A registered complete section is
* restored after this waterfall, so listeners cannot add to or replace
@@ -228,7 +228,7 @@ Expert waterfall over the assembled sections, contexts, tools, and variables. Sc
* @param context - the caller's per-assembly context.
* @mode waterfall
*/
'system-prompt/assemble'(this: Scoped<SystemPrompt>, assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>): Promise<PromptAssembly>
'system-prompt/assemble'(this: Scoped<SystemPrompt>, assembly: PromptPresentation, context: AssembleContext, next: () => Promise<PromptPresentation>): Promise<PromptPresentation>
```
Types: [Scoped](scope.zh.md)
+17 -3
View File
@@ -206,6 +206,8 @@ interface ToolExecutionInput {
readonly arguments: unknown
/** The agent on whose behalf the call runs (set by the agent loop). */
readonly agent?: Agent
/** Step admission shared by Native and PTC dispatch. Conditional tools require it. */
readonly toolSet?: ResolvedToolSet
/**
* Opaque token of the enclosing transport execution, when one exists. PTC
* mode sets this on SDK sub-dispatches so commit-style observers can wait for
@@ -525,9 +527,10 @@ 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.
* @param definition - tool schema, execution, and optional finalization/presentation callbacks.
* @param options - optional step-time activation condition.
* @returns the exact disposer that unregisters the tool.
*/
register(definition: ToolDefinition): () => void
register(definition: ToolDefinition, options: ToolRegistrationOptions = {}): () => void
/**
* Restrict global tools for the calling agent scope. Empty filters, unknown
@@ -557,17 +560,28 @@ guard(guard: ToolGuard): () => void
* actually executed.
* @param name - the tool name as registered.
* @param scope - the viewing scope (the agent); omitted = the global view.
* @param toolSet - step admission; omit only to inspect registered definitions.
* @returns the definition the scope resolves, or undefined when none is visible.
*/
get(name: string, scope?: ScopeKey): ToolDefinition | undefined
get(name: string, scope?: ScopeKey, toolSet?: ResolvedToolSet): ToolDefinition | undefined
/**
* Resolve conditions once for this step without changing shared registrations.
* Cancellation or a rejected condition publishes no partial selection. Removed
* registrations cannot be restored by a late condition result.
* @param context - preparing agent, captured model selection, and cancellation.
* @returns the complete selection; the caller disposes it after its tool batch settles.
*/
async resolve(context: ToolActivationContext): Promise<ResolvedToolSet>
/**
* Project visible definitions onto the allowlisted model-facing schema fields,
* excluding execution and presentation callbacks.
* @param scope - the viewing scope (the agent); omitted = the global view.
* @param toolSet - step admission, or omitted for registered-definition inspection.
* @returns one deep-cloned schema per visible tool.
*/
schemas(scope?: ScopeKey): ToolSchema[]
schemas(scope?: ScopeKey, toolSet?: ResolvedToolSet): ToolSchema[]
/**
* Classify a pending call through the caller's visible tool definition. Only
+17 -3
View File
@@ -206,6 +206,8 @@ interface ToolExecutionInput {
readonly arguments: unknown
/** The agent on whose behalf the call runs (set by the agent loop). */
readonly agent?: Agent
/** Step admission shared by Native and PTC dispatch. Conditional tools require it. */
readonly toolSet?: ResolvedToolSet
/**
* Opaque token of the enclosing transport execution, when one exists. PTC
* mode sets this on SDK sub-dispatches so commit-style observers can wait for
@@ -525,9 +527,10 @@ 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.
* @param definition - tool schema, execution, and optional finalization/presentation callbacks.
* @param options - optional step-time activation condition.
* @returns the exact disposer that unregisters the tool.
*/
register(definition: ToolDefinition): () => void
register(definition: ToolDefinition, options: ToolRegistrationOptions = {}): () => void
/**
* Restrict global tools for the calling agent scope. Empty filters, unknown
@@ -557,17 +560,28 @@ guard(guard: ToolGuard): () => void
* actually executed.
* @param name - the tool name as registered.
* @param scope - the viewing scope (the agent); omitted = the global view.
* @param toolSet - step admission; omit only to inspect registered definitions.
* @returns the definition the scope resolves, or undefined when none is visible.
*/
get(name: string, scope?: ScopeKey): ToolDefinition | undefined
get(name: string, scope?: ScopeKey, toolSet?: ResolvedToolSet): ToolDefinition | undefined
/**
* Resolve conditions once for this step without changing shared registrations.
* Cancellation or a rejected condition publishes no partial selection. Removed
* registrations cannot be restored by a late condition result.
* @param context - preparing agent, captured model selection, and cancellation.
* @returns the complete selection; the caller disposes it after its tool batch settles.
*/
async resolve(context: ToolActivationContext): Promise<ResolvedToolSet>
/**
* Project visible definitions onto the allowlisted model-facing schema fields,
* excluding execution and presentation callbacks.
* @param scope - the viewing scope (the agent); omitted = the global view.
* @param toolSet - step admission, or omitted for registered-definition inspection.
* @returns one deep-cloned schema per visible tool.
*/
schemas(scope?: ScopeKey): ToolSchema[]
schemas(scope?: ScopeKey, toolSet?: ResolvedToolSet): ToolSchema[]
/**
* Classify a pending call through the caller's visible tool definition. Only
@@ -0,0 +1,16 @@
# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.2/conditional-tools/guide.md
/:
en: 759962a4bf2e454f
zh: df802b222d1a46f3
/declare-conditional-tools-through-step-admission:
en: 4edb4c80b402de77
zh: 856160ec69c515ea
/declare-conditional-tools-through-step-admission/change:
en: ba47cb460364f666
zh: 1b5db25e4fb99ac2
/declare-conditional-tools-through-step-admission/migration:
en: 676839aab96dcd2d
zh: eae3c9a6795f8d6e
@@ -0,0 +1,25 @@
---
kind: upgrade-guide
description: "Tool declarations come only from step-admitted registrations: `system-prompt/assemble` no longer carries tools, conditional tools need a resolved step set, and `exit_plan_mode` is declared only in plan mode."
---
# Declare conditional tools through step admission
English | [中文](guide.zh.md)
## Change
`ctx.tools.register(definition, { when })` adds a synchronous or asynchronous condition. AgentLoop resolves conditions once per proposed step with `{ agent, modelSelection, signal }`, before prompt assembly and `agent/pre-step`, and uses the resulting `ResolvedToolSet` for tool declarations, Native dispatch, PTC bindings, and nested calls in that step and its retries. A false condition removes the tool from all of them; a thrown condition ends the turn with `TOOL_ACTIVATION_FAILED`.
The `system-prompt/assemble` waterfall now receives sections, contexts, and variables (`PromptPresentation`) without a tool array, and a returned `tools` field is ignored. Plugins that removed or added tool declarations in that waterfall are affected; their removals no longer reach the request.
Model tool calls run only through their step's admitted set. A call to a conditional tool without a resolved set, a call after the set is disposed, and a pending call whose registration was replaced or removed during the step return `UNKNOWN_TOOL` instead of running.
`exit_plan_mode` is declared only for steps whose selected plan state is active, so the request tool list changes when plan mode starts or ends. Existing Sessions require no format migration.
## Migration
1. Replace tool filtering in `system-prompt/assemble` listeners with a `when` condition on the registration; read the step's provider, model, and reasoning effort from the condition's `modelSelection`. Keep only section, context, and variable changes in the waterfall.
2. Make tool guidance follow the same admission: section callbacks pass `context.toolSet` to `ctx.tools.get()` or `ctx.tools.schemas()`.
3. For programmatic calls of conditional tools, call `ctx.tools.resolve({ agent, modelSelection, signal })`, pass the returned `toolSet` to `assemble()` and `ctx.tools.execute()`, and dispose it after the batch settles.
4. Update expectations that listed `exit_plan_mode` outside plan mode. Verify that a disabled tool is absent from the request, that calling its name returns `UNKNOWN_TOOL` without reaching the implementation, and that a failing condition ends the turn with `TOOL_ACTIVATION_FAILED`.
@@ -0,0 +1,25 @@
---
kind: upgrade-guide
description: "工具声明只来自步骤准入的注册:`system-prompt/assemble` 不再携带工具,条件工具需要已解析的步骤集合,`exit_plan_mode` 只在计划模式下声明。"
---
# 通过步骤准入声明条件工具
[English](guide.md) | 中文
## 变更
`ctx.tools.register(definition, { when })` 可以附加同步或异步条件。AgentLoop 在提示词组装与 `agent/pre-step` 之前,以 `{ agent, modelSelection, signal }` 为每个拟议步骤计算一次条件,并在该步骤及其重试中用得到的 `ResolvedToolSet` 决定工具声明、原生分发、PTC 绑定和嵌套调用。条件返回 false 时,工具从以上所有位置移除;条件抛错时,轮次以 `TOOL_ACTIVATION_FAILED` 结束。
`system-prompt/assemble` waterfall 现在接收不含工具数组的段落、上下文与变量(`PromptPresentation`),返回的 `tools` 字段会被忽略。在这个 waterfall 中删除或添加工具声明的插件会受到影响:它们做的删除不再进入请求。
模型的工具调用只通过所属步骤的准入集合执行。没有已解析集合的条件工具调用、集合释放之后的调用,以及注册在步骤内被替换或移除时尚未开始的调用,都会返回 `UNKNOWN_TOOL`,不会执行。
`exit_plan_mode` 只在已选计划状态为激活的步骤中声明,因此进入或离开计划模式时请求的工具列表会变化。现有 Session 无需格式迁移。
## 迁移
1. 把 `system-prompt/assemble` 监听器中的工具过滤改为注册时的 `when` 条件;从条件的 `modelSelection` 读取该步骤的提供方、模型与推理强度。waterfall 中只保留段落、上下文与变量的修改。
2. 让工具指引跟随同一准入:段落回调把 `context.toolSet` 传给 `ctx.tools.get()` 或 `ctx.tools.schemas()`。
3. 程序化调用条件工具时,调用 `ctx.tools.resolve({ agent, modelSelection, signal })`,把返回的 `toolSet` 传给 `assemble()` 与 `ctx.tools.execute()`,并在批次结束后释放。
4. 更新在计划模式之外仍列出 `exit_plan_mode` 的期望。验证被禁用的工具不出现在请求中,用其名称调用会返回 `UNKNOWN_TOOL` 且不进入实现,条件失败时轮次以 `TOOL_ACTIVATION_FAILED` 结束。
+72 -52
View File
@@ -35,6 +35,8 @@ import { canonicalHeader, headerEquals, ToolCallRecovery } from '@deepseek-ai/ds
import { joinContextSections, renderContextSections, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
import type { Context } from '@deepseek-ai/cordis'
import type { ResolvedToolSet } from '@deepseek-ai/dsh-tools'
import { ToolActivationError } from '@deepseek-ai/dsh-tools'
import { ReactLoopInbox } from './inbox.ts'
import { RuntimeContextProjection } from './runtime-context.ts'
import { AssistantStreamAttempt } from './assistant-stream.ts'
@@ -61,6 +63,8 @@ type PreparedStep =
startsRequestSeries?: true
assembly: PromptAssembly
selection: Readonly<ModelSelection>
/** Admission shared by the step's declarations and dispatch; the turn disposes it after the step. */
toolSet: ResolvedToolSet
}
/** Remove adapter-derived values before plugins propose the next request config. */
@@ -279,22 +283,31 @@ export class ReactLoopAgent implements Agent {
if (selection === undefined) {
throw new Error(`agent "${this.id}" has no provider/model: set AgentOptions.provider and AgentOptions.model or record a model selection`)
}
const assembly = await this.loopCtx.systemPrompt.assemble({ ...assembleContextFor(this, signal), modelSelection: selection })
signal.throwIfAborted()
const decision = await this.dispatch.waterfall(
'agent/pre-step', { messages: claimed, ...position, signal, modelSelection: selection },
(): Promise<PreStepDecision> => {
const sections = renderContextSections(assembly)
const context = this.runtimeContext.project(joinContextSections(sections), sections)
return Promise.resolve<PreStepDecision>({
kind: 'enter',
messages: context === undefined ? claimed : [...claimed, context],
})
},
)
signal.throwIfAborted()
if (decision.kind === 'reject') return decision
return { ...decision, assembly, selection }
const toolSet = await this.loopCtx.tools.resolve({ agent: this, modelSelection: selection, signal })
try {
const assembly = await this.loopCtx.systemPrompt.assemble({ ...assembleContextFor(this, signal), modelSelection: selection, toolSet })
signal.throwIfAborted()
const decision = await this.dispatch.waterfall(
'agent/pre-step', { messages: claimed, ...position, signal, modelSelection: selection },
(): Promise<PreStepDecision> => {
const sections = renderContextSections(assembly)
const context = this.runtimeContext.project(joinContextSections(sections), sections)
return Promise.resolve<PreStepDecision>({
kind: 'enter',
messages: context === undefined ? claimed : [...claimed, context],
})
},
)
signal.throwIfAborted()
if (decision.kind === 'reject') {
toolSet.dispose()
return decision
}
return { ...decision, assembly, selection, toolSet }
} catch (error: unknown) {
toolSet.dispose()
throw error
}
}
/** Whether the assembled tool schemas differ from the logged request header's. */
@@ -330,42 +343,47 @@ export class ReactLoopAgent implements Agent {
turnEnds = { kind: 'blocked' }
return false
}
if (turnEnds && decision.messages.length === 0) break
// A removed waking message or an enter decision rewritten to empty
// still owns the initial turn boundary, but it spends no model call.
if (phase.step === 0 && decision.messages.length === 0) {
turnEnds = { kind: 'completed' }
return false
}
signal.throwIfAborted()
this.session.append('step/start', { turn, step })
phase.step = step
const toolRecovery = new ToolCallRecovery()
const stopRecovery = this.ctx.on('session/event', (session, event) => {
if (session === this.session) toolRecovery.observe(event)
})
try {
// max-tokens is sticky: once any step hits the ceiling, later steps
// that complete normally must not downgrade the turn outcome.
const stepEnd = await this.step(decision)
// max-tokens stays sticky: a later completed step must not
// downgrade the turn outcome.
if (turnEnds === null || turnEnds.kind !== 'max-tokens') turnEnds = stepEnd
} catch (error: unknown) {
try {
for (const event of toolRecovery.results()) {
this.session.append('tool/result', event.data, {
surfaceOp: 'append',
...event.sourceEventSeqs === undefined ? {} : { sourceEventSeqs: event.sourceEventSeqs },
})
}
} catch (recoveryError: unknown) {
throw new AggregateError([error, recoveryError], 'Step failed and its pending tool results could not be recorded', { cause: error })
if (turnEnds && decision.messages.length === 0) break
// A removed waking message or an enter decision rewritten to empty
// still owns the initial turn boundary, but it spends no model call.
if (phase.step === 0 && decision.messages.length === 0) {
turnEnds = { kind: 'completed' }
return false
}
signal.throwIfAborted()
this.session.append('step/start', { turn, step })
phase.step = step
const toolRecovery = new ToolCallRecovery()
const stopRecovery = this.ctx.on('session/event', (session, event) => {
if (session === this.session) toolRecovery.observe(event)
})
try {
// max-tokens is sticky: once any step hits the ceiling, later steps
// that complete normally must not downgrade the turn outcome.
const stepEnd = await this.step(decision)
// max-tokens stays sticky: a later completed step must not
// downgrade the turn outcome.
if (turnEnds === null || turnEnds.kind !== 'max-tokens') turnEnds = stepEnd
} catch (error: unknown) {
try {
for (const event of toolRecovery.results()) {
this.session.append('tool/result', event.data, {
surfaceOp: 'append',
...event.sourceEventSeqs === undefined ? {} : { sourceEventSeqs: event.sourceEventSeqs },
})
}
} catch (recoveryError: unknown) {
throw new AggregateError([error, recoveryError], 'Step failed and its pending tool results could not be recorded', { cause: error })
}
throw error
} finally {
stopRecovery()
this.session.append('step/end', { turn, step })
}
throw error
} finally {
stopRecovery()
this.session.append('step/end', { turn, step })
// Admission closes once the step's tool batch settles or no step opens.
decision.toolSet.dispose()
}
signal.throwIfAborted()
if (turnEnds && this.inbox.nextStep.length === 0) {
@@ -382,13 +400,14 @@ export class ReactLoopAgent implements Agent {
turnEnds = { kind: 'aborted', reason: cause }
throw error
}
// Every failure is structured: an `LlmError` keeps its facts, anything
// else flattens to `errorChain` text under the `UNKNOWN` code.
// Every failure is structured: an `LlmError` keeps its facts, a tool
// activation failure keeps its code, and anything else flattens to
// `errorChain` text under the `UNKNOWN` code.
turnEnds = {
kind: 'error',
error: error instanceof LlmError
? error.failure
: { message: errorChain(error), code: 'UNKNOWN' },
: { message: errorChain(error), code: error instanceof ToolActivationError ? error.code : 'UNKNOWN' },
}
this.throwError(error)
} finally {
@@ -563,6 +582,7 @@ export class ReactLoopAgent implements Agent {
const { concluded } = await executeToolCalls(
this.loopCtx, turn, step, toolCalls, signal,
context => this.inbox.splice('next-step', this.inbox.nextStep.length, 0, [context]),
decision.toolSet,
)
return concluded ? { kind: 'completed' } : null
} catch (error: unknown) {
@@ -11,6 +11,7 @@
* @module dsh-agent-loop/tool-calls
*/
import type { ResolvedToolSet } from '@deepseek-ai/dsh-tools'
import type { Context } from '@deepseek-ai/cordis'
import { createToolResultMessage, type ToolCallBlock } from '@deepseek-ai/dsh-llm'
import type { Session, SessionSeq, UserMessage } from '@deepseek-ai/dsh-session'
@@ -56,6 +57,7 @@ interface GroupOutcome {
* @param toolCalls - assistant calls in model order.
* @param signal - abort signal shared by the step.
* @param acceptContext - accepts committed result context for the next step boundary.
* @param toolSet - step-owned admission propagated into every Native dispatch.
*/
export async function executeToolCalls(
ctx: Context,
@@ -64,6 +66,7 @@ export async function executeToolCalls(
toolCalls: ToolCallBlock[],
signal: AbortSignal,
acceptContext: (context: UserMessage) => void,
toolSet: ResolvedToolSet,
): Promise<{ concluded: boolean }> {
const agent = ctx.agents.requireInitiator()
const { session } = agent
@@ -77,6 +80,7 @@ export async function executeToolCalls(
arguments: parseArguments(block.arguments),
agent,
signal,
toolSet,
},
}))
@@ -170,13 +170,12 @@ describe('loop-owned request freezing', () => {
const { ctx } = await harness(adapter)
const schemas: ToolSchema[][] = []
const stops: string[][] = []
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
const assembly = await next()
ctx.systemPrompt.tools(() => {
const tools: ToolSchema[] = [{ name: 'nested', description: 'test', parameters: {
type: 'object', properties: { value: { type: 'array', items: { type: 'string', enum: ['a', 'b'] } } },
} }]
schemas.push(tools)
return { ...assembly, tools }
return { schemas: tools }
})
ctx.on('agent/request', async (_payload, next) => {
const config = await next()
@@ -199,8 +198,8 @@ describe('loop-owned request freezing', () => {
expect(errors).toEqual([])
expect(requests).toHaveLength(2)
for (const [index, request] of requests.entries()) {
expect(request.tools).toBe(schemas[index])
expectFrozen(schemas[index])
expect(request.tools).toEqual(schemas[index])
expectFrozen(request.tools)
expect(() => request.stop!.push('mutate')).toThrow(TypeError)
if (!registered) expect(request.stop).toBe(stops[index])
}
@@ -150,7 +150,7 @@ describe('tool-call scheduler: grouping and barriers', () => {
expect(order).toEqual(['r-start-A1', 'r-end-A1', 'w-A2', 'r-start-A3', 'r-end-A3'])
})
it('reclassifies pending calls after an exclusive barrier replaces their tool', async () => {
it('rejects pending calls whose original registration was replaced', async () => {
const adapter = new MockAdapter([
multiCall([
{ id: 'c1', name: 'replace', args: { id: '0' } },
@@ -181,17 +181,13 @@ describe('tool-call scheduler: grouping and barriers', () => {
const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await until(() => replacement.started.length === 1)
await new Promise(r => setTimeout(r, 5))
expect(replacement.started).toEqual(['1'])
replacement.release('1')
await until(() => replacement.started.length === 2)
expect(replacement.started).toEqual(['1', '2'])
replacement.release('2')
await waitForIdle(ctx, agent)
expect(replacement.started).toEqual([])
expect(events(agent).filter(event => event.type === 'tool/result').slice(1).map(event => event.data.error?.code))
.toEqual(['UNKNOWN_TOOL', 'UNKNOWN_TOOL'])
})
it('stops replenishing when a result observer makes the next call exclusive', async () => {
it('drains started calls and rejects unstarted calls after replacement', async () => {
const adapter = new MockAdapter([
multiCall([
{ id: 'c1', name: 'x', args: { id: '1' } },
@@ -219,10 +215,9 @@ describe('tool-call scheduler: grouping and barriers', () => {
await new Promise(r => setTimeout(r, 5))
expect(replacement.started).toEqual([])
initial.release('2')
await until(() => replacement.started.length === 1)
expect(replacement.started).toEqual(['3'])
replacement.release('3')
await waitForIdle(ctx, agent)
expect(replacement.started).toEqual([])
expect(events(agent).filter(event => event.type === 'tool/result').at(-1)?.data.error?.code).toBe('UNKNOWN_TOOL')
})
})
@@ -9,7 +9,8 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import { MockAdapter, textResponse } from './mock-adapter.ts'
import type { ResolvedToolSet } from '@deepseek-ai/dsh-tools'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
const contexts: Context[] = []
afterEach(async () => {
@@ -112,3 +113,147 @@ describe('tool update emission', () => {
expect(agent.session.requestHeader()?.tools?.map(schema => schema.name)).toEqual(['search'])
})
})
describe('step-time tool activation', () => {
it('uses the step model selection for asynchronous conditions and rejects a historical name after removal', async () => {
const adapter = new MockAdapter([
toolCallResponse('allowed', 'conditional', {}), textResponse('done'),
toolCallResponse('removed', 'conditional', {}), textResponse('done'),
])
const { ctx, agent } = await harness(adapter)
const selectedModels: string[] = []
let calls = 0
agent.model.select({ provider: 'mock', model: 'enabled' })
ctx.tools.register(defineContentToolFixture({
name: 'conditional', description: 'Available on one model.', parameters: {},
async execute(_args, exec) {
expect(exec.agent?.session.requestHeader()?.config.model).toBe('enabled')
calls++
return [{ type: 'text', text: 'done' }]
},
}), { when: async ({ modelSelection }) => {
selectedModels.push(modelSelection.model)
await Promise.resolve()
return modelSelection.model === 'enabled'
} })
await send(agent, 'enabled')
agent.model.select({ provider: 'mock', model: 'disabled' })
await send(agent, 'try the historical name')
expect(calls).toBe(1)
expect(selectedModels).toEqual(['enabled', 'enabled', 'disabled', 'disabled'])
expect(adapter.requests.map(request => request.model)).toEqual(selectedModels)
expect(developerEvents(agent).at(-1)?.data.message.content)
.toEqual([{ type: 'tool-removal', toolName: 'conditional' }])
expect(agent.session.snapshotEvents().find(event => event.type === 'tool/result' && event.data.message.toolCallId === 'removed')?.data)
.toMatchObject({ error: { code: 'UNKNOWN_TOOL' } })
})
it('cancels an asynchronous condition without committing the step request', async () => {
const adapter = new MockAdapter([textResponse('unused')])
const { ctx, agent } = await harness(adapter)
const entered = Promise.withResolvers<undefined>()
const settle = Promise.withResolvers<boolean>()
ctx.tools.register(defineContentToolFixture({
name: 'conditional', description: 'A pending condition.', parameters: {},
execute: async () => [{ type: 'text', text: 'unused' }],
}), { when: () => { entered.resolve(undefined); return settle.promise } })
const running = send(agent, 'cancel during activation')
await entered.promise
agent.cancel({ kind: 'user' })
settle.resolve(true)
await running
expect(adapter.requests).toEqual([])
expect(agent.session.snapshotEvents().filter(event => ['request/header', 'system/message', 'user/message'].includes(event.type))).toEqual([])
})
it('keeps one model selection, tool set, and prompt across retries without admitting users again', async () => {
const adapter = new MockAdapter([
[{ type: 'finish', reason: { kind: 'error', failure: { code: 'SERVER', message: 'retry', status: 503 } } }],
textResponse('done'),
])
const { ctx, agent } = await harness(adapter)
let retried = false
const counts = { conditions: 0, admissions: 0 }
ctx.on('agent/pre-step', (_input, next) => { counts.admissions++; return next() })
agent.model.select({ provider: 'mock', model: 'first' })
ctx.on('agent/request-error', async (_input, next) => {
await next()
retried = true
agent.model.select({ provider: 'mock', model: 'second' })
return { kind: 'retry' }
})
ctx.tools.register(defineContentToolFixture({
name: 'conditional', description: 'Retry-dependent availability.', parameters: {},
execute: async () => [{ type: 'text', text: 'unused' }],
}), { when: () => { counts.conditions++; return retried } })
ctx.systemPrompt.section({
name: 'retry:guidance', order: 0, complete: true,
text: context => `${context.modelSelection?.model}:${ctx.tools.schemas(agent, context.toolSet).map(tool => tool.name).join(',')}`,
})
await send(agent, 'retry once')
expect(adapter.requests.map(request => request.model)).toEqual(['first', 'first'])
expect(adapter.requests.map(request => request.tools ?? [])).toEqual([[], []])
expect(adapter.requests.map(request => request.messages.find(message => message.role === 'system')?.content))
.toEqual([[{ type: 'text', text: 'first:' }], [{ type: 'text', text: 'first:' }]])
expect(counts).toEqual({ conditions: 1, admissions: 1 })
expect(agent.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind === 'user')).toHaveLength(1)
})
it('reports condition failures without admitting a partial request', async () => {
const adapter = new MockAdapter([textResponse('unused')])
const { ctx, agent } = await harness(adapter)
ctx.tools.register(defineContentToolFixture({
name: 'conditional', description: 'An unavailable activation dependency.', parameters: {},
execute: async () => [{ type: 'text', text: 'unused' }],
}), { when: async () => { throw new Error('credential storage unavailable') } })
await send(agent, 'inspect')
expect(adapter.requests).toEqual([])
expect(agent.session.snapshotEvents().filter(event => ['request/header', 'system/message', 'user/message'].includes(event.type))).toEqual([])
expect(agent.session.snapshotEvents().find(event => event.type === 'turn/end')?.data)
.toMatchObject({ reason: { kind: 'error', error: { code: 'TOOL_ACTIVATION_FAILED' } } })
})
it('closes the step tool set after a rejected proposal and after its step', async () => {
const adapter = new MockAdapter([textResponse('done')])
const { ctx, agent } = await harness(adapter)
ctx.tools.register(defineContentToolFixture({
name: 'conditional', description: 'Enabled.', parameters: {},
execute: async () => [{ type: 'text', text: 'unused' }],
}), { when: () => true })
const assembled: { toolSet: ResolvedToolSet; admitted: number }[] = []
ctx.systemPrompt.section({
name: 'capture:tool-set', order: 0,
text: (context) => {
if (context.toolSet !== undefined) assembled.push({ toolSet: context.toolSet, admitted: context.toolSet.view(agent).size })
return ''
},
})
let reject = true
ctx.on('agent/pre-step', async (_input, next) => {
const decision = await next()
return reject ? { kind: 'reject' } : decision
})
await send(agent, 'rejected')
reject = false
await send(agent, 'accepted')
expect(adapter.requests).toHaveLength(1)
expect(assembled.map(entry => entry.admitted)).toEqual([1, 1])
expect(assembled.map(entry => entry.toolSet.view(agent).size)).toEqual([0, 0])
})
it('shares the selected set with prompt guidance', async () => {
const adapter = new MockAdapter([textResponse('done')])
const { ctx, agent } = await harness(adapter)
ctx.tools.register(defineContentToolFixture({
name: 'conditional', description: 'Disabled.', parameters: {},
execute: async () => [{ type: 'text', text: 'unused' }],
}), { when: () => false })
ctx.systemPrompt.section({
name: 'conditional:guidance', order: 0,
text: context => ctx.tools.get('conditional', context.scope, context.toolSet) === undefined ? '' : 'Conditional guidance',
})
await send(agent, 'inspect')
expect(adapter.requests[0]?.tools ?? []).toEqual([])
expect(JSON.stringify(adapter.requests[0]?.messages)).not.toContain('Conditional guidance')
})
})
+8 -8
View File
@@ -21,8 +21,8 @@
en: 87e28893e6cf58d7
zh: 06f476b38c4d2038
/deepseek-ai-dsh-system-prompt/use-this-package/contribute-a-prompt-section:
en: 165fecb5329b00b6
zh: a7c595d1787916c9
en: ef415fcd9e1cf8bb
zh: 6ad4df288f91bdb1
/deepseek-ai-dsh-system-prompt/use-this-package/contribute-a-prompt-variable:
en: b3e4c9b4ced59488
zh: d80c791e72bd5821
@@ -42,8 +42,8 @@
en: d9575bcb07a1145b
zh: 0f1a388780d055b1
/deepseek-ai-dsh-system-prompt/understand-the-implementation/assembly-and-rendering:
en: 54977e6c431f851e
zh: ef60007054b97ff0
en: d51940213af9b87f
zh: f3a2540010624a79
/deepseek-ai-dsh-system-prompt/understand-the-implementation/scoping:
en: 1100ab1dfe961631
zh: c510275c893951b4
@@ -57,8 +57,8 @@
en: cf78b9dd5596495f
zh: bd44560f8c2435e8
/deepseek-ai-dsh-system-prompt/model-experience/system-prompt/what-the-model-sees:
en: 0aaabee3a9883eea
zh: 13e6ead5b455fa30
en: 622e83de7772dc1f
zh: 7b3c54f08a317e93
/deepseek-ai-dsh-system-prompt/model-experience/system-prompt/what-the-model-sees/harness-identity:
en: 40effd71cb503319
zh: 9969356237a4f398
@@ -72,8 +72,8 @@
en: 3039bc5e7a796b21
zh: 6b7a9eaf69c9ea53
/deepseek-ai-dsh-system-prompt/model-experience/tool-schemas/what-the-model-sees:
en: a983ef220593cf73
zh: 28770612ffc1be60
en: a976fa380a4c733c
zh: 57894caf0640898a
/deepseek-ai-dsh-system-prompt/model-experience/tool-schemas/token-effect:
en: f3a7d374c55e1720
zh: 0ee48af678cb875f
+5 -3
View File
@@ -67,6 +67,8 @@ Set `interpolate: false` on a section or dynamic context to preserve its text li
`refreshContext(assembly, context)` refreshes registered runtime facts after pre-step and route preparation. It preserves accepted sections, tools, variables, and contexts contributed only by the assembly waterfall, applies current optional-context suppression, and restores missing required entries. Put changing facts in context providers; registered context text is read again before admission.
The agent loop calls `assemble(context)` once per proposed step before `agent/pre-step`, with the step's captured model selection (`context.modelSelection`) and admitted tool set (`context.toolSet`). Presentation listeners receive sections, contexts, and variables; tools remain provider-owned. Tool-aware section callbacks use `context.toolSet` to inspect the same step selection as execution.
### Contribute a prompt variable
Variables are referenced from section or context text as `{{name}}` and resolved at each assembly; scoped variables shadow a same-named global for that agent. The loop supplies `provider` and `model`; plugins can register the facts they own. The working-directory service supplies directory text as literal user context.
@@ -107,7 +109,7 @@ The package is a registry plus a cooperative assembly pipeline. One `assemble()`
### Assembly and rendering
Assembly resolves and renders in two stages: `assemble()` returns sections with resolved-but-uninterpolated text, the ordered tool schemas, and every registered variable resolved against the context, while `renderPrompt()` interpolates `{{variable}}` references unless a section sets `interpolate: false`, drops empty sections, and joins with blank lines — strictly, an unknown reference, a registered-but-valueless reference, or a malformed complete group throws, because a malformed prompt is worse than a loud failure. `toolOrder` canonicalizes the collected tools before the waterfall (registration order is a plugin-load artifact); a waterfall listener that mutates the list owns the determinism of what it emits.
Assembly resolves and renders in two stages: `assemble()` returns sections with resolved-but-uninterpolated text, the ordered tool schemas, and every registered variable resolved against the context, while `renderPrompt()` interpolates `{{variable}}` references unless a section sets `interpolate: false`, drops empty sections, and joins with blank lines — strictly, an unknown reference, a registered-but-valueless reference, or a malformed complete group throws, because a malformed prompt is worse than a loud failure. `toolOrder` canonicalizes the collected tools before the waterfall (registration order is a plugin-load artifact); the presentation waterfall receives no tool array and cannot replace provider-owned declarations.
### Scoping
@@ -137,7 +139,7 @@ The package-level contract is enough for most consumers; read these when you nee
#### What the model sees
First-party sections render the harness identity, deployment persona prefix (including the model-name introduction), reusable instructions (including the generated tools SDK and structured-output guidance), then the environment-bearing suffix: harness source (`10000`), Web surface (`10100`), and deployment persona suffix (`10200`). External section orders and assembly listeners remain authoritative. `includeHarnessIdentity: false` omits only that fixed opener. Empty sections disappear; scoped sections and variables can shadow globals for one agent. The `system-prompt/assemble` waterfall determines the delivered prompt and tool schemas unless one effective section declares itself complete — that exact section then becomes the whole system prompt while the waterfall's contexts, tools, and variables remain. The rendered prompt reaches the model as a system-role message of derived history — surface node 0, or the latest system node after an in-history update — neither the loop request nor `request/header` carries a separate `system` field. If the complete rendering is empty, the loop clears every active system node through logged empty replacements, so no older prompt remains in model history. Ordered dynamic contexts are separate from sections and become sourced user-role snapshots only when present; `includeRuntimeContext: false` or a scoped suppressor removes optional contributions while required operational context remains.
First-party sections render the harness identity, deployment persona prefix (including the model-name introduction), reusable instructions (including the generated tools SDK and structured-output guidance), then the environment-bearing suffix: harness source (`10000`), Web surface (`10100`), and deployment persona suffix (`10200`). External section orders and assembly listeners remain authoritative. `includeHarnessIdentity: false` omits only that fixed opener. Empty sections disappear; scoped sections and variables can shadow globals for one agent. The `system-prompt/assemble` waterfall determines the delivered prompt unless one effective section declares itself complete — that exact section then becomes the whole system prompt while the waterfall's contexts and variables remain. Tool declarations stay provider-owned. The rendered prompt reaches the model as a system-role message of derived history — surface node 0, or the latest system node after an in-history update — neither the loop request nor `request/header` carries a separate `system` field. If the complete rendering is empty, the loop clears every active system node through logged empty replacements, so no older prompt remains in model history. Ordered dynamic contexts are separate from sections and become sourced user-role snapshots only when present; `includeRuntimeContext: false` or a scoped suppressor removes optional contributions while required operational context remains.
##### Harness identity
@@ -157,7 +159,7 @@ Prefix-stable while identity, persona, variables, section text, and order render
#### What the model sees
For shipped tools, the model receives the per-agent-visible subset of the [generated tool schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tools), ordered by configuration or lexicographically after restrictions and assembly interception. Extensions can contribute additional definitions through the same registry. Sections and schema providers are separate assembly inputs. A restriction does not remove a section registration: tool-guidance plugins use `text({ scope })` and `ctx.tools.get(name, scope)` to return empty text or select applicable fragments. Arbitrary static sections are not automatically rewritten.
For shipped tools, the model receives the per-agent-visible subset of the [generated tool schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tools), ordered by configuration or lexicographically after restrictions and step-time activation. Extensions can contribute additional definitions through the same registry. Sections and schema providers are separate assembly inputs. A restriction does not remove a section registration: tool-guidance plugins use `text({ scope, toolSet })` and `ctx.tools.get(name, scope, toolSet)` to return empty text or select applicable fragments. Arbitrary static sections are not automatically rewritten.
#### Token effect
+5 -3
View File
@@ -67,6 +67,8 @@ ctx.systemPrompt.section({
`refreshContext(assembly, context)` 在 pre-step 与路由准备之后刷新已注册的运行时事实。它保留已接纳的段、工具、变量及仅由组装 waterfall 添加的上下文,应用当前的可选上下文抑制,并恢复缺失的必需条目。将变化的事实放在上下文 provider 中;已注册上下文的文本会在准入前再次读取。
agent loop 在 `agent/pre-step` 之前为每个拟议步骤调用一次 `assemble(context)`,并传入该步骤捕获的模型选择(`context.modelSelection`)与准入工具集合(`context.toolSet`)。展示监听器接收段落、上下文与变量;工具声明仍由提供方持有。依赖工具的段落回调通过 `context.toolSet` 读取与执行相同的步骤集合。
### 贡献提示词变量
变量从段或上下文文本中以 `{{name}}` 引用,每次组装时解析;带作用域的变量会为该 agent 遮蔽同名全局变量。循环提供 `provider` 和 `model`;插件可注册自己拥有的事实。工作目录服务通过不执行插值的用户上下文提供目录文本。
@@ -107,7 +109,7 @@ ctx.systemPrompt.variable('response_language', () => 'English')
### 组装与渲染
组装分两阶段完成求值与渲染:`assemble()` 返回文本已求值但尚未插值的段、有序工具 schema,以及每个已注册变量按当前上下文求得的值;`renderPrompt()` 对未设置 `interpolate: false` 的段插值 `{{variable}}` 引用、删除空段并用空行连接——严格规则:未知引用、已注册但无值的引用或格式错误的完整组都会抛出,因为格式错误的提示词比明确失败更糟。`toolOrder` 在 waterfall 分发前规范化收集到的工具(注册顺序只是插件加载产物);修改列表的 waterfall 监听器对其输出的确定性负责。
组装分两阶段完成求值与渲染:`assemble()` 返回文本已求值但尚未插值的段、有序工具 schema,以及每个已注册变量按当前上下文求得的值;`renderPrompt()` 对未设置 `interpolate: false` 的段插值 `{{variable}}` 引用、删除空段并用空行连接——严格规则:未知引用、已注册但无值的引用或格式错误的完整组都会抛出,因为格式错误的提示词比明确失败更糟。`toolOrder` 在 waterfall 分发前规范化收集到的工具(注册顺序只是插件加载产物);展示 waterfall 不接收工具数组,也不能替换提供方持有的声明。
### 作用域
@@ -137,7 +139,7 @@ ctx.systemPrompt.variable('response_language', () => 'English')
#### 模型看到什么
第一方段落依次渲染 harness 身份、部署 persona 前缀(含模型名称介绍)、可复用指令(包括生成的工具 SDK 和结构化输出指导),最后是携带环境信息的后缀:harness 源码(`10000`)、Web 表层(`10100`)和部署 persona 后缀(`10200`)。外部段落的顺序与组装监听器仍决定其最终结果。`includeHarnessIdentity: false` 仅省略这个固定开场白。空段会消失;带作用域的段与变量可以为一个 agent 遮蔽全局项。`system-prompt/assemble` waterfall 决定交付的提示词与工具 schema,除非一个有效段声明自身为 complete——此时该确切段会成为完整的系统提示词,而 waterfall 得到的上下文、工具与变量保持不变。渲染后的提示词作为派生历史中的 system 角色消息——surface 第 0 号节点,或历史内更新之后最新的系统节点——到达模型;循环请求与 `request/header` 均不含单独的 `system` 字段。完整渲染结果为空时,循环通过有日志记录的空内容替换清除所有生效的系统节点,模型历史不再保留任何旧提示词。有序动态上下文与段分离,只在存在时才会成为带来源的 user 角色快照;`includeRuntimeContext: false` 或带作用域的抑制器会移除可选贡献;必需的操作上下文仍然保留。
第一方段落依次渲染 harness 身份、部署 persona 前缀(含模型名称介绍)、可复用指令(包括生成的工具 SDK 和结构化输出指导),最后是携带环境信息的后缀:harness 源码(`10000`)、Web 表层(`10100`)和部署 persona 后缀(`10200`)。外部段落的顺序与组装监听器仍决定其最终结果。`includeHarnessIdentity: false` 仅省略这个固定开场白。空段会消失;带作用域的段与变量可以为一个 agent 遮蔽全局项。`system-prompt/assemble` waterfall 决定交付的提示词,除非一个有效段声明自身为 complete——此时该确切段会成为完整的系统提示词,而 waterfall 得到的上下文与变量保持不变。工具声明仍由提供方持有。渲染后的提示词作为派生历史中的 system 角色消息——surface 第 0 号节点,或历史内更新之后最新的系统节点——到达模型;循环请求与 `request/header` 均不含单独的 `system` 字段。完整渲染结果为空时,循环通过有日志记录的空内容替换清除所有生效的系统节点,模型历史不再保留任何旧提示词。有序动态上下文与段分离,只在存在时才会成为带来源的 user 角色快照;`includeRuntimeContext: false` 或带作用域的抑制器会移除可选贡献;必需的操作上下文仍然保留。
##### harness 身份
@@ -157,7 +159,7 @@ You are an AI agent powered by DeepSeek Harness.
#### 模型看到什么
对于已交付工具,模型会收到[生成工具 schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tools)中对每个 agent 可见的子集;限制与组装拦截完成后,按配置或字典序排列。扩展可以通过同一注册表贡献其他定义。段与 schema 提供方是独立的组装输入。限制不会移除段落注册:工具指导插件通过 `text({ scope })` 与 `ctx.tools.get(name, scope)` 返回空文本或选择适用片段。任意静态段落不会被自动改写。
对于已交付工具,模型会收到[生成工具 schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tools)中对每个 agent 可见的子集;限制与步骤时激活完成后,按配置或字典序排列。扩展可以通过同一注册表贡献其他定义。段与 schema 提供方是独立的组装输入。限制不会移除段落注册:工具指导插件通过 `text({ scope, toolSet })` 与 `ctx.tools.get(name, scope, toolSet)` 返回空文本或选择适用片段。任意静态段落不会被自动改写。
#### Token 影响
+22 -16
View File
@@ -17,9 +17,9 @@ declare module '@deepseek-ai/cordis' {
interface Events {
/**
* Expert waterfall over the assembled sections, contexts, tools, and variables.
* Presentation waterfall over assembled sections, contexts, and variables.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners
* receive only that scope's assemblies. The returned value is authoritative.
* receive only that scope's assemblies. The returned text is authoritative; tool declarations remain provider-owned.
* A supplied signal controls only this explicit assembly request and must not
* be retained to control later turns. A registered complete section is
* restored after this waterfall, so listeners cannot add to or replace
@@ -28,7 +28,7 @@ declare module '@deepseek-ai/cordis' {
* @param context - the caller's per-assembly context.
* @mode waterfall
*/
'system-prompt/assemble'(this: Scoped<SystemPrompt>, assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>): Promise<PromptAssembly>
'system-prompt/assemble'(this: Scoped<SystemPrompt>, assembly: PromptPresentation, context: AssembleContext, next: () => Promise<PromptPresentation>): Promise<PromptPresentation>
/**
* Emitted when any prompt provider changes. This registry notification is
* unfiltered because a global change affects every scope.
@@ -118,16 +118,21 @@ export interface ToolProviderResult {
}
/**
* Merge-extensible assembled model input. Sections and contexts remain
* uninterpolated until rendered; tools are already in canonical order.
* Prompt text contributions that the assembly waterfall may transform.
* Sections and contexts remain uninterpolated until rendered; tool
* declarations are owned by their providers.
*/
export interface PromptAssembly {
export interface PromptPresentation {
sections: AssembledSection[]
contexts: AssembledContext[]
tools: ToolSchema[]
variables: Record<string, string | undefined>
}
/** Completed model inputs: the presentation plus tool declarations in canonical order. */
export interface PromptAssembly extends PromptPresentation {
tools: ToolSchema[]
}
const SECTION_ORDERS = {
HARNESS_IDENTITY: -1000,
DEPLOYMENT_PERSONA_PREFIX: 0,
@@ -603,12 +608,12 @@ export class SystemPrompt extends Service {
/**
* Assemble global and scoped providers, detach tool parameters, apply
* canonical ordering, then run the assembly waterfall. Scoped sections and
* variables shadow globals. The returned waterfall value is authoritative
* except that an effective complete section is restored afterwards as the
* sole prompt section.
* @param context - the optional scope and plugin-defined assembly fields.
* @returns the post-waterfall assembly with any complete prompt enforced.
* canonical ordering, then run the presentation waterfall. Scoped sections
* and variables shadow globals. The waterfall's text is authoritative except
* that an effective complete section is restored afterwards as the sole
* prompt section; tool declarations stay provider-owned.
* @param context - the optional scope, step tool set, and plugin-defined assembly fields.
* @returns the post-waterfall assembly with ordered tool declarations and any complete prompt enforced.
*/
// Keep configuration failures on the declared asynchronous error path.
async assemble(context: AssembleContext = {}): Promise<PromptAssembly> {
@@ -665,23 +670,24 @@ export class SystemPrompt extends Service {
if (section.complete === true) completeSection = { ...assembled }
return assembled
})
const assembly: PromptAssembly = {
const tools = orderTools(collected, this.toolOrder, knownNames)
const assembly: PromptPresentation = {
sections,
contexts: [...contextByName.values()]
.filter(entry => !runtimeContextSuppressed || entry.required === true)
.sort((a, b) => a.order - b.order)
.map(entry => resolveContext(entry, context)),
tools: orderTools(collected, this.toolOrder, knownNames),
variables,
}
const transformed = await this.ctx.waterfall(
scopeTarget(this, scope), 'system-prompt/assemble', assembly, context,
() => Promise.resolve(assembly),
)
if (completeSection === undefined && !runtimeContextSuppressed) return transformed
if (completeSection === undefined && !runtimeContextSuppressed) return { ...transformed, tools }
const requiredContexts = new Set([...contextByName.values()].filter(entry => entry.required === true).map(entry => entry.name))
return {
...transformed,
tools,
sections: completeSection === undefined ? transformed.sections : [completeSection],
contexts: runtimeContextSuppressed ? transformed.contexts.filter(entry => requiredContexts.has(entry.name)) : transformed.contexts,
}
@@ -78,21 +78,14 @@ describe('SystemPrompt tool order', () => {
expect(assembly.tools.map(t => t.description)).toEqual(['anchor', 'first', 'second'])
})
it('canonicalizes BEFORE the assemble waterfall: listeners see the ordered list and own their own edits', async () => {
it('keeps ordered provider declarations outside presentation transforms', async () => {
const ctx = await mount()
ctx.systemPrompt.tools(() => ({ schemas: [tool('zulu'), tool('alpha')] }))
let seen: string[] | undefined
ctx.on('system-prompt/assemble', function (assembly, _context, next) {
seen = assembly.tools.map(t => t.name)
// A listener-appended tool is NOT re-sorted — same contract as sections:
// canonicalization applies to what the registry contributed, and a
// listener owns the determinism of what it emits.
assembly.tools.push(tool('aardvark'))
return next()
ctx.on('system-prompt/assemble', async (assembly, _context, next) => {
expect(assembly).not.toHaveProperty('tools')
return { ...await next(), tools: [tool('aardvark')] }
})
const assembly = await ctx.systemPrompt.assemble()
expect(seen).toEqual(['alpha', 'zulu'])
expect(names(assembly)).toEqual(['alpha', 'zulu', 'aardvark'])
expect(names(await ctx.systemPrompt.assemble())).toEqual(['alpha', 'zulu'])
})
it.each([
+3
View File
@@ -20,6 +20,9 @@
/deepseek-ai-dsh-tools/use-this-package/register-a-tool:
en: 9d2e6caa39c53d62
zh: 096a777f828c7565
/deepseek-ai-dsh-tools/use-this-package/conditional-tools:
en: efbf6228ebe3cf73
zh: 290f04453f75d29a
/deepseek-ai-dsh-tools/use-this-package/configure-the-presentation-mode:
en: 4e27c45ae889369e
zh: 7a0cd7a9dc7fe139
+8
View File
@@ -63,6 +63,14 @@ ctx.tools.register(defineTool({
The unified schema DSL supports `string`, `number`, `integer`, `boolean`, `null`, `array`, `object`, author-only `json`, and exact-one `oneOf`; `InferValue` preserves exact types through 16 container levels before widening to `JsonValue`. A raw JSON Schema (`JsonSchemaNode`) is the wire-level counterpart shared with subagents, workflows, and MCP.
### Conditional tools
`register(definition, { when })` evaluates a synchronous or asynchronous condition once for each model step. The condition receives `{ agent, modelSelection, signal }`; `modelSelection` is the provider, model, and reasoning effort that step preparation captured for every request attempt in the step, the same value as `AssembleContext.modelSelection`. False omits the tool from Native declarations, PTC bindings, and execution. A thrown condition fails step preparation as `TOOL_ACTIVATION_FAILED`; it is not treated as ordinary unavailability. Conditions must observe cancellation and must not change registrations or perform tool work.
`resolve({ agent, modelSelection, signal })` returns a step-owned `ResolvedToolSet`. Pass it as `toolSet` to prompt assembly and every Native or PTC execution, then dispose it after the step's batch settles; retries in the step reuse it. Registrations stay effect-owned; each selection admits only the original registration in its exact agent scope. Disposal, new restrictions, cancellation, or same-name replacement prevent unstarted calls from using it. Started operations retain their cooperative cancellation obligations. Conditional calls without a resolved set return `UNKNOWN_TOOL`.
`get(name, scope, toolSet)` and `schemas(scope, toolSet)` inspect an admitted set; omitting `toolSet` inspects registered definitions without evaluating conditions. Tool guidance receives the same selection as `AssembleContext.toolSet`. A presentation transform cannot add or remove callable tools.
### Configure the presentation mode
The `mode` config decides what the model sees: `native` (every visible schema) or `ptc` (only `run_code` plus a generated SDK). The runtime supplies evaluation syntax, output syntax and state-retention guidance through `executionInstructions`; this package owns tool-call rules, SDK declarations and image-result attachment.
+8
View File
@@ -63,6 +63,14 @@ ctx.tools.register(defineTool({
统一 schema DSL 支持 `string`、`number`、`integer`、`boolean`、`null`、`array`、`object`、仅供作者使用的 `json` 与恰好匹配一个分支的 `oneOf`;`InferValue` 在 16 层容器内保留精确类型,之后加宽为 `JsonValue`。原始 JSON Schema(`JsonSchemaNode`)是与 subagent、工作流和 MCP 共享的协议级对应类型。
### 条件工具
`register(definition, { when })` 为每个模型步骤计算一次同步或异步条件。条件接收 `{ agent, modelSelection, signal }`;`modelSelection` 是步骤准备时为该步骤所有请求尝试捕获的提供方、模型与推理强度,与 `AssembleContext.modelSelection` 相同。返回 false 会从原生声明、PTC 绑定和执行集合中排除工具。条件抛错会以 `TOOL_ACTIVATION_FAILED` 中止步骤准备,不会被当成正常不可用。条件必须响应取消,不得改变注册或执行工具操作。
`resolve({ agent, modelSelection, signal })` 返回步骤拥有的 `ResolvedToolSet`。将它作为 `toolSet` 传给提示词组装以及每次原生或 PTC 执行,步骤的批次结束后释放;同一步骤内的重试复用它。注册仍由 effect 持有;每份集合只准入原 agent scope 中的原注册。注销、新的限制、取消或同名替换会阻止尚未开始的调用继续使用它。已经开始的操作保留协作取消义务。没有已解析集合的条件工具调用返回 `UNKNOWN_TOOL`。
`get(name, scope, toolSet)` 和 `schemas(scope, toolSet)` 读取已准入集合;省略 `toolSet` 时只查看已注册定义,不计算条件。工具指导文本通过 `AssembleContext.toolSet` 接收同一集合。展示变换不能增删可调用工具。
### 配置呈现模式
`mode` 配置决定模型看到什么:`native`(每个可见 schema)或 `ptc`(只有 `run_code` 加一份生成 SDK)。运行时通过 `executionInstructions` 提供求值语法、输出方式和状态保留指引;本包负责工具调用规则、SDK 声明和图片结果附加。
+148 -28
View File
@@ -11,7 +11,7 @@ import { AnonymousEntries, NamedEntries, ScopedLayers, scopeOf, scopeTarget } fr
import type { ScopeKey, ScopeLayer, Scoped } from '@deepseek-ai/dsh-scope'
import type { ToolCallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { Agent, ModelSelection } from '@deepseek-ai/dsh-agent'
import type { UserMessage } from '@deepseek-ai/dsh-session'
import { assertNever, deepFreeze, snapshotJsonValue, type JsonValue } from '@deepseek-ai/dsh-util-values'
import type { PromptSection, ToolProviderResult } from '@deepseek-ai/dsh-system-prompt'
@@ -29,6 +29,13 @@ import { renderToolsSdk } from './ts-types.ts'
import type { ToolSdkSchema } from './ts-types.ts'
import { renderToolsSdkPy } from './py-types.ts'
declare module '@deepseek-ai/dsh-system-prompt' {
interface AssembleContext {
/** Step-owned tool selection; absent when inspecting registered definitions. */
toolSet?: ResolvedToolSet
}
}
declare module '@deepseek-ai/dsh-llm' {
interface MessageSourceMap {
/** Tool availability changes supplied by the tool registry. */
@@ -220,6 +227,57 @@ export interface ToolOutputDefinition {
presentationMeta?(args: unknown, value: JsonValue): JsonValue
}
/** Inputs for a tool's step-time activation condition. */
export interface ToolActivationContext {
/** Agent preparing the step. */
readonly agent: Agent
/** Provider, model, and optional reasoning effort captured for the step. */
readonly modelSelection: Readonly<ModelSelection>
/** Cancellation of step preparation; asynchronous conditions must observe it. */
readonly signal: AbortSignal
}
/** Lifecycle-owned conditions for a registered tool. */
export interface ToolRegistrationOptions {
/**
* Decide whether the step's requests may call the tool. Omission enables
* it. False excludes it from both declarations and dispatch; rejection fails
* step preparation. Conditions must not mutate registrations or start tool work.
* @param context - preparing agent, captured model selection, and cancellation signal.
* @returns whether the tool participates in this step.
*/
readonly when?: (context: ToolActivationContext) => boolean | Promise<boolean>
}
/** One step's admitted registrations, also used by nested PTC calls. */
export interface ResolvedToolSet {
/** Close admission after the step's tool batch settles. */
dispose(): void
/**
* Read admitted registrations that remain visible in their original scope.
* @param scope - exact requesting agent; another scope is never admitted.
* @returns original definitions excluding removed or restricted registrations.
*/
view(scope: ScopeKey | undefined): ReadonlyMap<string, ToolDefinition>
}
/** Failure to determine a tool's step-time availability. */
export class ToolActivationError extends HarnessError {
/**
* Preserve a condition failure without treating it as normal unavailability.
* @param name - tool whose condition rejected.
* @param cause - underlying condition failure.
*/
constructor(name: string, cause: unknown) {
super(`cannot resolve availability of tool "${name}": ${errorMessage(cause)}`, 'TOOL_ACTIVATION_FAILED', { cause })
}
}
interface ToolRegistration {
readonly definition: ToolDefinition
readonly when: ToolRegistrationOptions['when']
}
/** A registered tool: its schema plus the execution function. */
export interface ToolDefinition extends ToolSchema {
/** Mandatory canonical output declaration. */
@@ -338,6 +396,8 @@ export interface ToolExecutionInput {
readonly arguments: unknown
/** The agent on whose behalf the call runs (set by the agent loop). */
readonly agent?: Agent
/** Step admission shared by Native and PTC dispatch. Conditional tools require it. */
readonly toolSet?: ResolvedToolSet
/**
* Opaque token of the enclosing transport execution, when one exists. PTC
* mode sets this on SDK sub-dispatches so commit-style observers can wait for
@@ -715,6 +775,7 @@ interface CompiledToolRestriction {
interface ToolView {
/** Visible definitions after restrictions, scoped shadowing, and transport insertion. */
readonly visible: ReadonlyMap<string, ToolDefinition>
readonly registrations: ReadonlyMap<string, ToolRegistration>
/** Pre-restriction capability names used by prompt-order validation. */
readonly knownNames: ReadonlySet<string>
/** Current global names that a scoped restriction may name. */
@@ -733,7 +794,7 @@ export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefin
/** One scope's complete tool-registry contribution. */
class ToolLayer implements ScopeLayer {
readonly tools: NamedEntries<ToolDefinition>
readonly tools: NamedEntries<ToolRegistration>
readonly restrictions = new AnonymousEntries<CompiledToolRestriction>()
readonly guards = new AnonymousEntries<ToolGuard>()
/**
@@ -854,7 +915,7 @@ export class ToolRuntime extends Service {
// optional-input type for direct (non-Loader) construction in tests.
this.defaultMode = config.mode ?? 'native'
this.maxParallelSubCalls = resolveMaxParallelSubCalls(config.maxParallelSubCalls)
ctx.systemPrompt.tools(context => this.wireSchemas(context.scope))
ctx.systemPrompt.tools(context => this.wireSchemas(context.scope, context.toolSet))
if (this.defaultMode !== 'native') {
ctx.systemPrompt.section(this.collapseSection())
ctx.systemPrompt.section(this.sdkSection())
@@ -911,7 +972,7 @@ export class ToolRuntime extends Service {
const render = SDK_RENDERERS[runtime.language]
/* v8 ignore next -- requirePtcRuntime rejects an unknown language before this runs. */
if (render === undefined) throw new Error(`dsh-tools: no SDK renderer for ${runtime.language}`)
return render(this.sdkSchemas(context.scope), runtime.programFormat)
return render(this.sdkSchemas(context.scope, context.toolSet), runtime.programFormat)
},
}
}
@@ -1016,11 +1077,11 @@ export class ToolRuntime extends Service {
* Build one scope's wire schemas and names for prompt-order validation.
* Restrictions do not make known tools invalid, but a mode collapse does.
*/
private wireSchemas(scope?: ScopeKey): ToolProviderResult {
private wireSchemas(scope?: ScopeKey, toolSet?: ResolvedToolSet): ToolProviderResult {
const view = this.view(scope)
const mode = this.modeFor(scope)
if (mode === 'native') {
const schemas = [...view.visible.values()].map(definition => this.schemaOf(definition, false))
const schemas = this.definitions(scope, toolSet).map(definition => this.schemaOf(definition, false))
return { schemas, knownNames: [...view.knownNames] }
}
// Validate the runtime language BEFORE projecting schemas: schemaOf reads
@@ -1029,7 +1090,7 @@ export class ToolRuntime extends Service {
// renderer-table rejection the canonical assembly-time error for a
// language with no SDK renderer.
this.requirePtcRuntime(mode)
const schemas = [...view.visible.values()].map(definition => this.schemaOf(definition, false))
const schemas = this.definitions(scope, toolSet).map(definition => this.schemaOf(definition, false))
return {
schemas: schemas.filter(schema => schema.name === RUN_CODE_NAME),
knownNames: [RUN_CODE_NAME],
@@ -1066,9 +1127,10 @@ export class ToolRuntime extends Service {
* Register globally or in the calling agent scope. Scoped tools shadow
* globals; duplicates within one layer and the reserved `run_code` name fail.
* @param definition - tool schema, execution, and optional finalization/presentation callbacks.
* @param options - optional step-time activation condition.
* @returns the exact disposer that unregisters the tool.
*/
register(definition: ToolDefinition): () => void {
register(definition: ToolDefinition, options: ToolRegistrationOptions = {}): () => void {
const name = definition.name
const output = (definition as Partial<ToolDefinition>).output
if (output === undefined || typeof output !== 'object'
@@ -1090,7 +1152,7 @@ export class ToolRuntime extends Service {
}
return this.layers.effect(
this.ctx,
layer => layer.tools.insert(name, definition),
layer => layer.tools.insert(name, { definition, when: options.when }),
{ label: 'tools.register()' },
)
}
@@ -1192,27 +1254,32 @@ export class ToolRuntime extends Service {
const own = this.layers.peek(scope)
// Inherited surface, nearest ancestor last: a nearer scope's same-name
// entry shadows a farther one, and the global layer is the farthest.
const inherited = new Map<string, ToolDefinition>(this.layers.global.tools.entries())
const inherited = new Map(this.layers.global.tools.entries())
for (const layer of layers) {
if (layer === own) continue
for (const [name, definition] of layer.tools.entries()) inherited.set(name, definition)
}
const visible = new Map<string, ToolDefinition>()
const registrations = new Map<string, ToolRegistration>()
const knownNames = new Set<string>()
const restrictableNames = new Set<string>()
for (const [name, definition] of inherited) {
for (const [name, registration] of inherited) {
knownNames.add(name)
restrictableNames.add(name)
// Restrictions intersect across the whole chain: any scope on it may
// mask an inherited name for everything nested inside it.
if (layers.every(layer => layer.admits(name))) visible.set(name, definition)
if (layers.every(layer => layer.admits(name))) {
visible.set(name, registration.definition)
registrations.set(name, registration)
}
}
// The scope's own registrations last, shadowing an inherited name and
// outside the filter above.
if (own !== undefined) {
for (const [name, definition] of own.tools.entries()) {
for (const [name, registration] of own.tools.entries()) {
knownNames.add(name)
visible.set(name, definition)
visible.set(name, registration.definition)
registrations.set(name, registration)
}
}
// Presentation infrastructure is resolved last and outside capability
@@ -1223,7 +1290,7 @@ export class ToolRuntime extends Service {
if (this.modeFor(scope) !== 'native') {
visible.set(RUN_CODE_NAME, this.requirePtcTransport())
}
return { visible, knownNames, restrictableNames }
return { visible, registrations, knownNames, restrictableNames }
}
/**
@@ -1233,10 +1300,58 @@ export class ToolRuntime extends Service {
* actually executed.
* @param name - the tool name as registered.
* @param scope - the viewing scope (the agent); omitted = the global view.
* @param toolSet - step admission; omit only to inspect registered definitions.
* @returns the definition the scope resolves, or undefined when none is visible.
*/
get(name: string, scope?: ScopeKey): ToolDefinition | undefined {
return this.view(scope).visible.get(name)
get(name: string, scope?: ScopeKey, toolSet?: ResolvedToolSet): ToolDefinition | undefined {
return toolSet === undefined ? this.view(scope).visible.get(name) : toolSet.view(scope).get(name)
}
/**
* Resolve conditions once for this step without changing shared registrations.
* Cancellation or a rejected condition publishes no partial selection. Removed
* registrations cannot be restored by a late condition result.
* @param context - preparing agent, captured model selection, and cancellation.
* @returns the complete selection; the caller disposes it after its tool batch settles.
*/
async resolve(context: ToolActivationContext): Promise<ResolvedToolSet> {
const { agent, signal } = context
signal.throwIfAborted()
const view = this.view(agent)
const admitted = new Map<string, ToolDefinition>()
for (const [name, definition] of view.visible) {
const registration = view.registrations.get(name)
let enabled: boolean
try {
enabled = registration?.when === undefined || await registration.when(context)
} catch (cause: unknown) {
signal.throwIfAborted()
throw new ToolActivationError(name, cause)
}
signal.throwIfAborted()
if (enabled) admitted.set(name, definition)
}
let closed = false
return Object.freeze({
dispose: () => { closed = true },
view: (scope: ScopeKey | undefined): ReadonlyMap<string, ToolDefinition> => {
const visible = new Map<string, ToolDefinition>()
if (closed || scope !== agent || signal.aborted) return visible
const current = this.view(agent)
for (const [name, definition] of admitted) {
if (current.visible.get(name) === definition
&& current.registrations.get(name) === view.registrations.get(name)) {
visible.set(name, definition)
}
}
return visible
},
})
}
private definitions(scope?: ScopeKey, toolSet?: ResolvedToolSet): ToolDefinition[] {
if (toolSet === undefined) return [...this.view(scope).visible.values()]
return [...toolSet.view(scope).values()]
}
/**
@@ -1252,8 +1367,11 @@ export class ToolRuntime extends Service {
* @param nested - whether the call is a transport sub-dispatch, not a model-direct call.
* @returns the definition that may run, or undefined when the call must be rejected.
*/
private resolveExecution(name: string, scope: ScopeKey | undefined, nested: boolean): ToolDefinition | undefined {
const tool = this.get(name, scope)
private resolveExecution(
name: string, scope: ScopeKey | undefined, nested: boolean, toolSet?: ResolvedToolSet,
): ToolDefinition | undefined {
if (toolSet === undefined && this.view(scope).registrations.get(name)?.when !== undefined) return undefined
const tool = this.get(name, scope, toolSet)
if (tool === undefined) return undefined
if (this.collapses(name, scope, nested)) return undefined
return tool
@@ -1263,15 +1381,16 @@ export class ToolRuntime extends Service {
* Project visible definitions onto the allowlisted model-facing schema fields,
* excluding execution and presentation callbacks.
* @param scope - the viewing scope (the agent); omitted = the global view.
* @param toolSet - step admission, or omitted for registered-definition inspection.
* @returns one deep-cloned schema per visible tool.
*/
schemas(scope?: ScopeKey): ToolSchema[] {
return [...this.view(scope).visible.values()].map(definition => this.schemaOf(definition, true))
schemas(scope?: ScopeKey, toolSet?: ResolvedToolSet): ToolSchema[] {
return this.definitions(scope, toolSet).map(definition => this.schemaOf(definition, true))
}
/** Project visible callable tools onto the generated PTC mode SDK contract. */
private sdkSchemas(scope?: ScopeKey): ToolSdkSchema[] {
return [...this.view(scope).visible.values()]
private sdkSchemas(scope?: ScopeKey, toolSet?: ResolvedToolSet): ToolSdkSchema[] {
return this.definitions(scope, toolSet)
.filter(definition => definition.name !== RUN_CODE_NAME)
.map((definition): ToolSdkSchema => {
const output = snapshotJsonValue(definition.output.schema)
@@ -1309,7 +1428,7 @@ export class ToolRuntime extends Service {
* @returns the fail-closed scheduling mode.
*/
executionMode(exec: ToolExecutionInput): ToolExecutionMode {
const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined)
const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined, exec.toolSet)
if (!tool?.isConcurrencySafe) return { kind: 'exclusive' }
try {
const concurrencySafe: unknown = tool.isConcurrencySafe(exec.arguments)
@@ -1412,7 +1531,7 @@ export class ToolRuntime extends Service {
// observe — or worse, approve — a call that can only fail. An unknown tool
// keeps the historical dispatch-stage `UNKNOWN_TOOL` path so policy
// listeners still see every name that reaches the registry.
const visible = this.get(name, agent)
const visible = this.get(name, agent, exec.toolSet)
const collapsed = visible !== undefined && this.collapses(name, agent, parent !== undefined)
const concludingExecutions = this.concludingExecutions
const base = {
@@ -1422,6 +1541,7 @@ export class ToolRuntime extends Service {
name,
signal,
...agent !== undefined ? { agent } : {},
...exec.toolSet !== undefined ? { toolSet: exec.toolSet } : {},
...parent !== undefined ? { parent } : {},
...exec.schema !== undefined ? { schema: exec.schema } : {},
deferContext(context: UserMessage): void {
@@ -1583,7 +1703,7 @@ export class ToolRuntime extends Service {
}
exec.signal = signal
try {
const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined)
const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined, exec.toolSet)
if (!tool) throw new ToolNotFoundError(exec.name)
state.bodyInvoked = true
const returned = await tool.execute(exec.arguments, exec)
@@ -1812,7 +1932,7 @@ export class ToolRuntime extends Service {
if (result.isError) {
throw new TypeError('tools/post-execute cannot replace the value of a failed result')
}
const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined)
const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined, exec.toolSet)
if (tool === undefined) throw new ToolNotFoundError(exec.name)
const replaced = this.createSuccessResult(exec, tool, decision.value)
return this.markCanonical(exec, {
@@ -1881,7 +2001,7 @@ export class ToolRuntime extends Service {
...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {},
})
}
const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined)
const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined, exec.toolSet)
if (tool === undefined) throw new ToolNotFoundError(exec.name)
const normalized = this.createSuccessResult(exec, tool, result.value)
return this.markCanonical(exec, {
+2 -1
View File
@@ -551,6 +551,7 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO
schema,
arguments: normalized.dispatched,
...exec.agent ? { agent: exec.agent } : {},
...exec.toolSet !== undefined ? { toolSet: exec.toolSet } : {},
parent: exec.token,
signal: runController.signal,
}
@@ -688,7 +689,7 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO
// restricted globals vanish) — the same view the SDK section declared,
// so a program can bind exactly what its prompt promised; sub-dispatch
// re-resolves per call through the same view (exec.agent threads down).
for (const schema of registry.schemas(exec.agent)) {
for (const schema of registry.schemas(exec.agent, exec.toolSet)) {
if (schema.name === RUN_CODE_NAME) continue
Object.defineProperty(functions, schema.name, { enumerable: true, value: binding(deepFreeze(schema)) })
}
@@ -0,0 +1,129 @@
/** Conditional registrations share step admission with direct and nested execution. */
import { afterEach, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { ToolCallId } from '@deepseek-ai/dsh-llm'
import { createScope, type Scope } from '@deepseek-ai/dsh-scope'
import { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime, { defineTool, ToolActivationError } from '@deepseek-ai/dsh-tools'
const TEST_MODEL_SELECTION = { provider: 'test', model: 'test' }
const contexts: Context[] = []
afterEach(async () => {
for (const ctx of contexts.splice(0)) await ctx.fiber.dispose()
})
async function setup() {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
const agent = { id: SessionId('activation') } as Agent
let scope!: Scope
await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, agent) }, { inject: ['tools'] }))
const controller = new AbortController()
let calls = 0
const tool = defineTool({
name: 'conditional', description: 'A conditional operation.', parameters: {},
output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] },
async execute() { calls++; return 'done' },
})
const input = { agent, name: tool.name, arguments: {}, callId: ToolCallId('call'), signal: controller.signal }
const resolve = () => ctx.tools.resolve({ agent, modelSelection: TEST_MODEL_SELECTION, signal: controller.signal })
return { ctx, agent, scope, controller, tool, input, resolve, calls: () => calls }
}
it('activates and deactivates a registered tool without allowing historical calls', async () => {
const h = await setup()
let enabled = false
h.ctx.tools.register(h.tool, { when: () => enabled })
const unavailable = await h.resolve()
expect(h.ctx.tools.schemas(h.agent, unavailable)).toEqual([])
enabled = true
expect((await h.ctx.tools.execute({ ...h.input, toolSet: unavailable })).error?.info?.code).toBe('UNKNOWN_TOOL')
expect((await h.ctx.tools.execute(h.input)).error?.info?.code).toBe('UNKNOWN_TOOL')
const available = await h.resolve()
expect(h.ctx.tools.schemas(h.agent, available).map(tool => tool.name)).toEqual(['conditional'])
expect((await h.ctx.tools.execute({ ...h.input, toolSet: available })).isError).toBe(false)
enabled = false
const disabled = await h.resolve()
expect((await h.ctx.tools.execute({ ...h.input, toolSet: disabled })).error?.info?.code).toBe('UNKNOWN_TOOL')
expect(h.calls()).toBe(1)
})
it('keeps admissions specific to the requesting agent', async () => {
const h = await setup()
h.ctx.tools.register(h.tool, { when: () => true })
const toolSet = await h.resolve()
const other = { id: SessionId('other') } as Agent
expect((await h.ctx.tools.execute({ ...h.input, agent: other, toolSet })).error?.info?.code).toBe('UNKNOWN_TOOL')
expect(h.calls()).toBe(0)
})
it('applies live restrictions and closes step admission permanently', async () => {
const h = await setup()
h.ctx.tools.register(h.tool, { when: () => true })
const toolSet = await h.resolve()
const lift = h.scope.ctx.tools.restrict({ deny: ['conditional'] })
expect((await h.ctx.tools.execute({ ...h.input, toolSet })).error?.info?.code).toBe('UNKNOWN_TOOL')
lift()
expect((await h.ctx.tools.execute({ ...h.input, toolSet })).isError).toBe(false)
toolSet.dispose()
expect(h.ctx.tools.schemas(h.agent, toolSet)).toEqual([])
expect((await h.ctx.tools.execute({ ...h.input, toolSet })).error?.info?.code).toBe('UNKNOWN_TOOL')
expect(h.calls()).toBe(1)
})
it('does not substitute a same-name registration after admission', async () => {
const h = await setup()
const unregister = h.ctx.tools.register(h.tool, { when: () => true })
const old = await h.resolve()
unregister()
h.ctx.tools.register(h.tool, { when: () => true })
expect((await h.ctx.tools.execute({ ...h.input, toolSet: old })).error?.info?.code).toBe('UNKNOWN_TOOL')
expect((await h.ctx.tools.execute({ ...h.input, toolSet: await h.resolve() })).isError).toBe(false)
expect(h.calls()).toBe(1)
})
it('does not restore a registration removed during its asynchronous condition', async () => {
const h = await setup()
const condition = Promise.withResolvers<boolean>()
const entered = Promise.withResolvers<undefined>()
const unregister = h.ctx.tools.register(h.tool, { when: () => { entered.resolve(undefined); return condition.promise } })
const pending = h.resolve()
await entered.promise
unregister()
condition.resolve(true)
const toolSet = await pending
expect(h.ctx.tools.schemas(h.agent, toolSet)).toEqual([])
expect((await h.ctx.tools.execute({ ...h.input, toolSet })).error?.info?.code).toBe('UNKNOWN_TOOL')
expect(h.calls()).toBe(0)
})
it('propagates cancellation after an asynchronous condition settles', async () => {
const h = await setup()
const condition = Promise.withResolvers<boolean>()
h.ctx.tools.register(h.tool, { when: ({ agent, modelSelection, signal }) => {
expect(agent).toBe(h.agent)
expect(modelSelection).toBe(TEST_MODEL_SELECTION)
expect(signal).toBe(h.controller.signal)
return condition.promise
} })
const pending = h.resolve()
const rejection = expect(pending).rejects.toMatchObject({ name: 'AbortError' })
h.controller.abort()
condition.resolve(true)
await rejection
expect(h.calls()).toBe(0)
})
it('reports condition failures without treating them as disabled tools', async () => {
const h = await setup()
const cause = new Error('credential storage unavailable')
h.ctx.tools.register(h.tool, { when: () => { throw cause } })
await expect(h.resolve()).rejects.toMatchObject({
name: ToolActivationError.name, code: 'TOOL_ACTIVATION_FAILED', cause,
})
expect(h.calls()).toBe(0)
})
+48 -3
View File
@@ -173,6 +173,51 @@ it('logs detached frozen nested metadata without changing the binding value or l
})
describe('mode-aware wire contribution', () => {
it('shares conditional admission with SDK bindings and rejects a replaced cached binding', async () => {
const { ctx, runtime, systemPrompt } = await setup({ mode: 'ptc' })
try {
const agent = { ctx, session: Session.create(SessionId('conditional-ptc')) } as Agent
let active = false
const execute = vi.fn(async () => 'done')
const definition = defineTool({
name: 'conditional', description: 'Available on selected requests.', parameters: {},
output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] },
execute,
})
const unregister = ctx.tools.register(definition, { when: () => active })
for (const enabled of [false, true]) {
active = enabled
const toolSet = await ctx.tools.resolve({ agent, modelSelection: { provider: 'test', model: 'test' }, signal: testToolSignal })
try {
const assembly = await systemPrompt.assemble({ scope: agent, agent, toolSet })
const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text ?? ''
expect(sdk.includes('conditional:')).toBe(enabled)
runtime.behavior = async (request) => {
const functions = request.bindings[0]!.functions
expect(Object.keys(functions)).toEqual(enabled ? ['conditional'] : [])
if (enabled) {
expect(await functions.conditional!({})).toBe('done')
unregister()
ctx.tools.register(definition, { when: () => true })
await expect(functions.conditional!({})).rejects.toThrow('unknown tool "conditional"')
}
return { logs: [] }
}
const result = await ctx.tools.execute({
agent, toolSet, signal: testToolSignal, callId: ToolCallId('conditional-program'),
name: RUN_CODE_NAME, arguments: { code: 'program', description: 'Check conditional bindings' },
})
expect(result).toMatchObject({ isError: false })
} finally {
toolSet.dispose()
}
}
expect(execute).toHaveBeenCalledTimes(1)
} finally {
await ctx.fiber.dispose()
}
})
it('rejects an unsupported presentation mode in configuration', () => {
expect(() => ToolRuntime.Config({ mode: 'both' } as never)).toThrow()
})
@@ -271,7 +316,7 @@ describe('mode-aware wire contribution', () => {
expect(sdk).toContain('deep_output: string | null')
})
it('treats expert assembly output as authoritative in mode ptc', async () => {
it('keeps PTC declarations owned by the registry during presentation transforms', async () => {
const mode = 'ptc'
const { ctx, systemPrompt } = await setup({ mode })
registerEcho(ctx)
@@ -280,13 +325,13 @@ describe('mode-aware wire contribution', () => {
return {
...assembly,
sections: assembly.sections.filter(section => section.name !== 'tools:sdk'),
tools: assembly.tools.filter(tool => tool.name !== RUN_CODE_NAME),
tools: [],
}
}, { prepend: true })
const assembly = await systemPrompt.assemble()
expect(assembly.sections.some(section => section.name === 'tools:sdk')).toBe(false)
expect(assembly.tools.some(tool => tool.name === RUN_CODE_NAME)).toBe(false)
expect(assembly.tools.some(tool => tool.name === RUN_CODE_NAME)).toBe(true)
})
it('lets one scope shadow the default SDK section in mode ptc', async () => {
@@ -1790,8 +1790,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
key: 'planMode',
summary: '`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the stable exit tool.',
description: '`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the stable exit tool. Client carriers expose the projection\'s cropped `{ active, pending }` view. Agent prompt assembly fails while the projection reader is not ready; callers must await plugin activation before running an agent.',
summary: '`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the conditional exit tool.',
description: '`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the conditional exit tool. Client carriers expose the projection\'s cropped `{ active, pending }` view. Agent prompt assembly fails while the projection reader is not ready; callers must await plugin activation before running an agent.',
methods: [
{
signature: 'get(agent: Agent): { active: boolean; pending?: boolean }',
@@ -3337,9 +3337,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: 'async assemble(context: AssembleContext = {}): Promise<PromptAssembly>',
description: 'Assemble global and scoped providers, detach tool parameters, apply canonical ordering, then run the assembly waterfall. Scoped sections and variables shadow globals. The returned waterfall value is authoritative except that an effective complete section is restored afterwards as the sole prompt section.',
parameters: [{ name: 'context', description: 'the optional scope and plugin-defined assembly fields.' }],
returns: 'the post-waterfall assembly with any complete prompt enforced.',
description: 'Assemble global and scoped providers, detach tool parameters, apply canonical ordering, then run the presentation waterfall. Scoped sections and variables shadow globals. The waterfall\'s text is authoritative except that an effective complete section is restored afterwards as the sole prompt section; tool declarations stay provider-owned.',
parameters: [{ name: 'context', description: 'the optional scope, step tool set, and plugin-defined assembly fields.' }],
returns: 'the post-waterfall assembly with ordered tool declarations and any complete prompt enforced.',
},
],
},
@@ -3563,9 +3563,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
returns: 'the exact disposer that restores the deployment default.',
},
{
signature: 'register(definition: ToolDefinition): () => void',
signature: 'register(definition: ToolDefinition, options: ToolRegistrationOptions = {}): () => void',
description: 'Register globally or in the calling agent scope. Scoped tools shadow globals; duplicates within one layer and the reserved `run_code` name fail.',
parameters: [{ name: 'definition', description: 'tool schema, execution, and optional finalization/presentation callbacks.' }],
parameters: [{ name: 'definition', description: 'tool schema, execution, and optional finalization/presentation callbacks.' }, { name: 'options', description: 'optional step-time activation condition.' }],
returns: 'the exact disposer that unregisters the tool.',
},
{
@@ -3581,15 +3581,21 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
returns: 'the exact disposer that unregisters the guard.',
},
{
signature: 'get(name: string, scope?: ScopeKey): ToolDefinition | undefined',
signature: 'get(name: string, scope?: ScopeKey, toolSet?: ResolvedToolSet): ToolDefinition | undefined',
description: 'Look up a tool as one scope sees it (scoped shadows global; a restricted-away global reads as absent). Presenters pass the calling agent so the rendered card matches the definition that actually executed.',
parameters: [{ name: 'name', description: 'the tool name as registered.' }, { name: 'scope', description: 'the viewing scope (the agent); omitted = the global view.' }],
parameters: [{ name: 'name', description: 'the tool name as registered.' }, { name: 'scope', description: 'the viewing scope (the agent); omitted = the global view.' }, { name: 'toolSet', description: 'step admission; omit only to inspect registered definitions.' }],
returns: 'the definition the scope resolves, or undefined when none is visible.',
},
{
signature: 'schemas(scope?: ScopeKey): ToolSchema[]',
signature: 'async resolve(context: ToolActivationContext): Promise<ResolvedToolSet>',
description: 'Resolve conditions once for this step without changing shared registrations. Cancellation or a rejected condition publishes no partial selection. Removed registrations cannot be restored by a late condition result.',
parameters: [{ name: 'context', description: 'preparing agent, captured model selection, and cancellation.' }],
returns: 'the complete selection; the caller disposes it after its tool batch settles.',
},
{
signature: 'schemas(scope?: ScopeKey, toolSet?: ResolvedToolSet): ToolSchema[]',
description: 'Project visible definitions onto the allowlisted model-facing schema fields, excluding execution and presentation callbacks.',
parameters: [{ name: 'scope', description: 'the viewing scope (the agent); omitted = the global view.' }],
parameters: [{ name: 'scope', description: 'the viewing scope (the agent); omitted = the global view.' }, { name: 'toolSet', description: 'step admission, or omitted for registered-definition inspection.' }],
returns: 'one deep-cloned schema per visible tool.',
},
{
@@ -4660,9 +4666,9 @@ export const EVENT_API: readonly EventApiEntry[] = [
{
name: 'system-prompt/assemble',
mode: 'waterfall',
signature: '\'system-prompt/assemble\'(this: Scoped<SystemPrompt>, assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>): Promise<PromptAssembly>',
summary: 'Expert waterfall over the assembled sections, contexts, tools, and variables.',
description: 'Expert waterfall over the assembled sections, contexts, tools, and variables. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners receive only that scope\'s assemblies. The returned value is authoritative. A supplied signal controls only this explicit assembly request and must not be retained to control later turns. A registered complete section is restored after this waterfall, so listeners cannot add to or replace that scope\'s system prompt.',
signature: '\'system-prompt/assemble\'(this: Scoped<SystemPrompt>, assembly: PromptPresentation, context: AssembleContext, next: () => Promise<PromptPresentation>): Promise<PromptPresentation>',
summary: 'Presentation waterfall over assembled sections, contexts, and variables.',
description: 'Presentation waterfall over assembled sections, contexts, and variables. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners receive only that scope\'s assemblies. The returned text is authoritative; tool declarations remain provider-owned. A supplied signal controls only this explicit assembly request and must not be retained to control later turns. A registered complete section is restored after this waterfall, so listeners cannot add to or replace that scope\'s system prompt.',
parameters: [{ name: 'assembly', description: 'the mutable assembly built from registered providers.' }, { name: 'context', description: 'the caller\'s per-assembly context.' }],
},
{
@@ -6775,7 +6781,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'PromptAssembly',
declaration: 'export interface PromptAssembly {\n sections: AssembledSection[];\n contexts: AssembledContext[];\n tools: ToolSchema[];\n variables: Record<string, string | undefined>;\n}',
declaration: 'export interface PromptAssembly extends PromptPresentation {\n tools: ToolSchema[];\n}',
},
{
name: 'PromptContext',
@@ -6793,6 +6799,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'PromptOrigin',
declaration: 'export type PromptOrigin = {\n kind: \'composer\';\n} | {\n kind: \'plugin\';\n name: string;\n};',
},
{
name: 'PromptPresentation',
declaration: 'export interface PromptPresentation {\n sections: AssembledSection[];\n contexts: AssembledContext[];\n variables: Record<string, string | undefined>;\n}',
},
{
name: 'PromptSection',
declaration: 'export interface PromptSection {\n readonly name: string;\n readonly order: number;\n readonly text: string | ((context: AssembleContext) => string);\n readonly interpolate?: boolean;\n readonly complete?: boolean;\n}',
@@ -7049,6 +7059,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'ResolvedSubagentStartRequest',
declaration: 'export interface ResolvedSubagentStartRequest extends SubagentStartRequest {\n readonly cwd: string;\n}',
},
{
name: 'ResolvedToolSet',
declaration: 'export interface ResolvedToolSet {\n dispose(): void;\n view(scope: ScopeKey | undefined): ReadonlyMap<string, ToolDefinition>;\n}',
},
{
name: 'RestartReason',
declaration: 'export type RestartReason = \'no-hmr\' | \'package-moved\' | \'host-module\' | \'unmanaged-plugin\' | \'module-comparison\';',
@@ -8441,6 +8455,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'TokenUsage',
declaration: 'export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n totalTokens?: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n}',
},
{
name: 'ToolActivationContext',
declaration: 'export interface ToolActivationContext {\n readonly agent: Agent;\n readonly modelSelection: Readonly<ModelSelection>;\n readonly signal: AbortSignal;\n}',
},
{
name: 'ToolAdditionBlock',
declaration: 'export interface ToolAdditionBlock {\n type: \'tool-addition\';\n toolName: string;\n tool?: never;\n}',
@@ -8491,7 +8509,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ToolExecutionInput',
declaration: 'export interface ToolExecutionInput {\n readonly callId: ToolCallId;\n readonly rootCallId?: ToolCallId;\n readonly name: string;\n readonly schema?: ToolSchema;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n}',
declaration: 'export interface ToolExecutionInput {\n readonly callId: ToolCallId;\n readonly rootCallId?: ToolCallId;\n readonly name: string;\n readonly schema?: ToolSchema;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly toolSet?: ResolvedToolSet;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n}',
},
{
name: 'ToolExecutionMode',
@@ -8541,6 +8559,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'ToolProviderResult',
declaration: 'export interface ToolProviderResult {\n readonly schemas: readonly ToolSchema[];\n readonly knownNames?: readonly string[];\n}',
},
{
name: 'ToolRegistrationOptions',
declaration: 'export interface ToolRegistrationOptions {\n readonly when?: (context: ToolActivationContext) => boolean | Promise<boolean>;\n}',
},
{
name: 'ToolRemovalBlock',
declaration: 'export interface ToolRemovalBlock {\n type: \'tool-removal\';\n toolName: string;\n}',
@@ -8567,7 +8589,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ToolRuntime',
declaration: 'export class ToolRuntime extends Service {\n static inject;\n static Config: z<Config>;\n readonly [TOOL_RUNTIME_SCHEDULER]: ToolRuntimeScheduler;\n constructor(ctx: Context, config: Config = {});\n presentAs(mode: ToolPresentationMode): () => void;\n register(definition: ToolDefinition): () => void;\n restrict(filter: ToolRestriction): () => void;\n guard(guard: ToolGuard): () => void;\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined;\n schemas(scope?: ScopeKey): ToolSchema[];\n executionMode(exec: ToolExecutionInput): ToolExecutionMode;\n async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>;\n}',
declaration: 'export class ToolRuntime extends Service {\n static inject;\n static Config: z<Config>;\n readonly [TOOL_RUNTIME_SCHEDULER]: ToolRuntimeScheduler;\n constructor(ctx: Context, config: Config = {});\n presentAs(mode: ToolPresentationMode): () => void;\n register(definition: ToolDefinition, options: ToolRegistrationOptions = {}): () => void;\n restrict(filter: ToolRestriction): () => void;\n guard(guard: ToolGuard): () => void;\n get(name: string, scope?: ScopeKey, toolSet?: ResolvedToolSet): ToolDefinition | undefined;\n async resolve(context: ToolActivationContext): Promise<ResolvedToolSet>;\n schemas(scope?: ScopeKey, toolSet?: ResolvedToolSet): ToolSchema[];\n executionMode(exec: ToolExecutionInput): ToolExecutionMode;\n async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>;\n}',
},
{
name: 'ToolRuntimeScheduler',
+8 -8
View File
@@ -45,8 +45,8 @@
en: a7bf35c2a9da52bc
zh: dea9f4b3a41f1a6d
/deepseek-ai-dsh-plan-mode/understand-the-implementation/the-exit-tool:
en: 56798d0f45fb5775
zh: 282e4a522ce6dae0
en: ad84b4025d8ea536
zh: 683b3f56b0b6b368
/deepseek-ai-dsh-plan-mode/understand-the-implementation/session-projection-unit:
en: 396d66816890f715
zh: fab5d67c027b33f5
@@ -90,14 +90,14 @@
en: 9622e96bdc01276d
zh: 53801e68a49f3d1d
/deepseek-ai-dsh-plan-mode/model-experience/exit-tool-schema-and-review-exchange/what-the-model-sees:
en: 16c57e8e64ad5a85
zh: d81932553025c875
en: b0b53f72b11d634b
zh: c7bc8a10a90f7fa3
/deepseek-ai-dsh-plan-mode/model-experience/exit-tool-schema-and-review-exchange/token-effect:
en: d1b1418f20ee119d
zh: c2b89ea3cae57175
en: a1a020eb7ba26e7c
zh: a99208693d331a31
/deepseek-ai-dsh-plan-mode/model-experience/exit-tool-schema-and-review-exchange/kv-cache-effect:
en: 6b1404ea11f1e60d
zh: 348d0579de218652
en: 9d9626fbf4cdc8de
zh: 8237cbc3ca048024
/deepseek-ai-dsh-plan-mode/known-limitations-and-deferred-work:
en: a87aabfede0087fd
zh: 201e9b3766980005
+4 -4
View File
@@ -90,7 +90,7 @@ The command child activates only when a commands service is composed. It maps ba
### The exit tool
`exit_plan_mode` stays registered while plan mode is inactive, so entering or leaving changes only the prompt section, never the request tool catalog. An approved review records a silent pending exit that the next accepted in-turn pre-step appends, keeping plan guidance for the rest of the current tool batch. Without a user-questions channel, or after a service reload while the review is pending, the call fails closed and `/plan off` remains the manual escape.
`exit_plan_mode` is admitted only for steps whose selected plan state is active, the same state that renders plan guidance, so entering or leaving changes the tool declarations and the guidance together; Native and PTC execution use the same admitted set. An approved review records a silent pending exit that the next accepted in-turn pre-step appends, keeping plan guidance for the rest of the current tool batch. Without a user-questions channel, or after a service reload while the review is pending, the call fails closed and `/plan off` remains the manual escape.
### Session projection unit
@@ -166,15 +166,15 @@ The user block is append-only conversation growth. Entering or leaving plan mode
#### What the model sees
The [`exit_plan_mode` schema](../../../docs/tool-catalog.md#deepseek-aidsh-plan-mode) remains available in both states; execution outside plan mode fails, while an approved in-mode review returns the canonical `{ approved: true }` value and renders the existing confirmation text. Rejection remains a failed call carrying review feedback. A dismissed review returns `{ approved: false }`, renders a note that the user will reply, and concludes the turn without another model request.
The [`exit_plan_mode` schema](../../../docs/tool-catalog.md#deepseek-aidsh-plan-mode) is offered only in plan mode; calls outside its admitted set return `UNKNOWN_TOOL`, while an approved in-mode review returns the canonical `{ approved: true }` value and renders the existing confirmation text. Rejection remains a failed call carrying review feedback. A dismissed review returns `{ approved: false }`, renders a note that the user will reply, and concludes the turn without another model request.
#### Token effect
The stable schema is paid according to ToolRuntime mode, and each plan argument and review result remains in conversation history.
The schema is paid only while active, according to ToolRuntime mode, and each plan argument and review result remains in conversation history.
#### KV Cache effect
Mode transitions do not change the tool catalog; plan arguments and review results extend the conversation normally.
Entering plan mode adds the exit tool through the route’s tool-update protocol, and leaving removes it. An `in-history` route records both changes after cached history. An `addition-only` route, such as the default catalog’s `deepseek-flash`, cannot record the removal and drops the tool from later requests, so the first request after leaving can miss part of the cached prefix; a route without tool updates resends its changed declaration list after either transition. Plan arguments and review results extend the conversation normally.
## Known Limitations and Deferred Work
+4 -4
View File
@@ -90,7 +90,7 @@ agent 完成计划后,会以 markdown 形式、从标题开头书写计划并
### 退出工具
`exit_plan_mode` 在计划模式未激活时仍保持注册,因此进入或离开只改变提示词段落,绝不改变请求的工具目录。经批准的评审会记录一个静默的待生效退出,由下一个被接受的轮内 pre-step 追加,当前这批工具调用剩余部分仍保留计划引导。缺少用户交互通道,或评审等待期间服务重载,调用都会以拒绝方式失败,`/plan off` 仍是手动退路。
`exit_plan_mode` 只在已选计划状态为激活的步骤中准入,这一状态也决定是否渲染计划指引,因此进入或离开会同时改变工具声明与指引;原生与 PTC 执行使用同一准入集合。经批准的评审会记录一个静默的待生效退出,由下一个被接受的轮内 pre-step 追加,当前这批工具调用剩余部分仍保留计划引导。缺少用户交互通道,或评审等待期间服务重载,调用都会以拒绝方式失败,`/plan off` 仍是手动退路。
### 会话投影单元
@@ -166,15 +166,15 @@ You are in plan mode. Explore and design before presenting the complete plan thr
#### 模型看到什么
[`exit_plan_mode` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-plan-mode) 在两种状态下均可用;在计划模式之外执行会失败,而计划模式内经批准的评审返回规范的 `{ approved: true }` 值,并渲染既有的确认文本。拒绝仍是携带评审反馈的失败调用。放弃评审返回 `{ approved: false }`,渲染一条说明用户将自行回复的文本,并结束当前轮次,不再发起模型请求。
[`exit_plan_mode` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-plan-mode) 只在计划模式中提供;不在准入集合中的调用返回 `UNKNOWN_TOOL`。模式内获批的评审返回规范值 `{ approved: true }` 并渲染现有确认文本。拒绝仍是携带评审反馈的失败调用。放弃评审返回 `{ approved: false }`,渲染一条说明用户将自行回复的文本,并结束当前轮次,不再发起模型请求。
#### Token 影响
稳定 schema 的成本取决于 ToolRuntime 模式,每次传入的 plan 参数与评审结果都会保留在对话历史中。
schema 仅在启用时按 ToolRuntime 模式计入开销;每份计划参数与评审结果保留在对话历史中。
#### KV Cache 影响
模式转换不改变工具目录;plan 参数与评审结果按常规方式扩展对话。
进入计划模式时,退出工具通过路由的工具更新协议新增,离开时则移除。`in-history` 路由会在已缓存历史之后记录这两种变化。`addition-only` 路由(例如默认目录中的 `deepseek-flash`)无法记录移除,只能在之后的请求中去掉该工具,因此离开后的首个请求可能无法命中部分已缓存前缀;不支持工具更新的路由在任一方向转换后都会重新发送变化后的声明列表。计划参数与评审结果正常扩展对话。
## 已知限制与延期工作
+15 -13
View File
@@ -12,9 +12,9 @@
* assembly, then appends `plan/mode` from `agent/pre-step` only when the step
* is accepted. Same-step request retries reuse their assembly.
*
* The exit tool remains registered while plan mode is inactive, so entering
* or leaving plan mode changes only the prompt section, not the request tool
* catalog.
* The exit tool is admitted only for steps whose selected state is active,
* the same state that renders the guidance section. Native declarations, PTC
* bindings, and execution share that admission.
*
* See packages/plan/plan-mode/README.md.
*
@@ -59,10 +59,7 @@ declare module '@deepseek-ai/cordis' {
}
}
/**
* The model-facing exit tool's name. It stays registered while plan mode is
* inactive so the request tool catalog is stable across transitions.
*/
/** The model-facing exit tool's name, admitted only for steps in plan mode. */
export const EXIT_PLAN_MODE = 'exit_plan_mode'
/** Deployment-owned plan guidance. */
@@ -169,7 +166,7 @@ export const planProjectionDefinition = {
/**
* `ctx.planMode`: owns logged plan state, applies and narrates selected state at step start,
* the `plan:policy` section, the `/plan` command, and the stable exit tool.
* the `plan:policy` section, the `/plan` command, and the conditional exit tool.
* Client carriers expose the projection's cropped `{ active, pending }` view.
* Agent prompt assembly fails while the projection reader is not ready;
* callers must await plugin activation before running an agent.
@@ -200,8 +197,7 @@ export class PlanModeController extends Service {
order: ctx.systemPrompt.getSectionOrder('PLAN_POLICY'),
text: (context) => {
if (context.agent === undefined) return ''
const pending = this.pendingIntents.get(context.agent.session)
return (pending?.active ?? this.loggedActive(context.agent.session)) ? this.section : ''
return this.selectedActive(context.agent.session) ? this.section : ''
},
})
this.planProjectionHandle = await ctx.sessionProjections.register(planProjectionDefinition)
@@ -301,8 +297,9 @@ export class PlanModeController extends Service {
}],
},
execute: async (args, exec) => {
const agent = exec.agent
if (agent === undefined) throw new Error(`${EXIT_PLAN_MODE} requires a calling agent (no session to switch)`)
// oxlint-disable-next-line typescript/no-non-null-assertion -- conditional admission requires the step's Agent
const agent = exec.agent!
// Admission follows the selected state; a failed pre-step append can leave it uncommitted.
if (!this.loggedActive(agent.session)) {
throw new Error(`${EXIT_PLAN_MODE} is only available in plan mode`)
}
@@ -374,13 +371,18 @@ export class PlanModeController extends Service {
title: 'Plan review',
content: result.content,
}),
}))
}), { when: ({ agent }) => this.selectedActive(agent.session) })
}
private loggedActive(session: Session): boolean {
return this.planState(session).active
}
/** The state a proposed step presents: a pending selection, else the logged state. */
private selectedActive(session: Session): boolean {
return this.pendingIntents.get(session)?.active ?? this.loggedActive(session)
}
private hasOpenTurn(session: Session): boolean {
return this.ctx.agents.turnBoundaryOf(session).openTurnStartSeq !== null
}
@@ -53,7 +53,7 @@ describe('plan-mode activation', () => {
agent.followup(prompt)
await agent.whenIdle()
expect(adapter.requests).toHaveLength(1)
expect(adapter.requests[0]?.tools?.map(tool => tool.name)).toContain('exit_plan_mode')
expect((adapter.requests[0]?.tools ?? []).some(tool => tool.name === 'exit_plan_mode')).toBe(active)
const text = adapter.requests[0]?.messages.flatMap(message => message.content)
.filter(block => block.type === 'text').map(block => block.text).join('\n')
expect(text?.includes('Plan the work before executing it.')).toBe(active)
@@ -127,7 +127,7 @@ describe('plan mode through the agent loop', () => {
expect(log.some(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toBe(false)
})
it('a user flip between turns lands at the boundary: one notice and a replaced system node with stable tool schemas', async () => {
it('a user flip between turns lands at the boundary: one notice and an added exit tool', async () => {
const adapter = new MockAdapter([
textResponse('First turn, default mode.'),
textResponse('Second turn, plan mode.'),
@@ -139,7 +139,7 @@ describe('plan mode through the agent loop', () => {
await waitForIdle(ctx, agent)
expect(planActive(ctx, agent)).toBe(false)
const first = findEvent(agent.session.snapshotEvents(), 'request/header')
expect(first.data.header.tools?.map(tool => tool.name)).toEqual(['exit_plan_mode', 'read', 'write'])
expect(first.data.header.tools?.map(tool => tool.name)).toEqual(['read', 'write'])
const firstSystem = findEvent(agent.session.snapshotEvents(), 'system/message')
expect(systemText(agent)).not.toContain(PLAN_CONFIG.section)
@@ -163,10 +163,10 @@ describe('plan mode through the agent loop', () => {
expect(second?.sourceEventSeqs).toEqual([firstSystem.seq])
expect(agent.session.surface.nodes[0]).toBe(second?.seq)
expect(systemText(agent)).toContain('plan mode')
expect(log.filter(event => event.type === 'request/header').map(event => event.data.reason)).toEqual(['initial', 'series'])
expect(log.filter(event => event.type === 'request/header').map(event => event.data.reason)).toEqual(['initial', 'change'])
const secondHeader = findEvent(log, 'request/header', 'last')
expect(secondHeader.data.header.tools?.map(tool => tool.name)).toEqual(['exit_plan_mode', 'read', 'write'])
expect(secondHeader.data.header.tools).toEqual(first.data.header.tools)
expect(secondHeader.data.header.tools).not.toEqual(first.data.header.tools)
})
it('a mode flip at error settlement waits until the step after a same-step retry', async () => {
@@ -204,7 +204,7 @@ describe('plan mode through the agent loop', () => {
expect(adapter.requests).toHaveLength(3)
expect(requestSystem(adapter.requests[2])).toContain(PLAN_CONFIG.section)
expect(adapter.requests[2]?.tools).toEqual(adapter.requests[0]?.tools)
expect(adapter.requests[2]?.tools?.map(tool => tool.name)).toEqual(['exit_plan_mode', 'read', 'write'])
const log = agent.session.snapshotEvents()
const planMode = findEvent(log, 'plan/mode')
const firstEnd = log.find(event => event.type === 'step/end'
+48 -39
View File
@@ -17,6 +17,9 @@ import PlanModeController, { EXIT_PLAN_MODE, planProjectionDefinition, resolveCo
import type { PlanModeConfig } from '../src/index.ts'
import type { PlanUnitState } from '../src/types.ts'
const testToolSignal = new AbortController().signal
const TEST_MODEL_SELECTION = { provider: 'test', model: 'test' }
const TEST_PLAN_SECTION = 'Test plan mode instructions.'
const PLAN_CONFIG = { section: TEST_PLAN_SECTION } satisfies PlanModeConfig
@@ -71,8 +74,9 @@ async function agentWithSession(
return agent
}
function assembleFor(ctx: Context, agent: Agent) {
return ctx.systemPrompt.assemble({ agent, scope: agent })
async function assembleFor(ctx: Context, agent: Agent) {
const toolSet = await ctx.tools.resolve({ agent, modelSelection: TEST_MODEL_SELECTION, signal: testToolSignal })
return ctx.systemPrompt.assemble({ agent, scope: agent, toolSet })
}
function foldPlanMode(events: readonly SessionEvent[], end = events.length): boolean {
@@ -158,7 +162,7 @@ function registerNamedTools(ctx: Context, names: string[]): void {
}
}
/** Assert the mapped PTC mode SDK includes the stable plan exit binding and test tools. */
/** Assert the mapped PTC mode SDK includes the active plan exit binding and test tools. */
function expectPlanPtcSdkBindings(sdk: string): void {
expect(sdk).toContain('interface ToolArgsMap {')
expect(sdk).toContain('read: Record<string, JsonValue>;')
@@ -434,17 +438,17 @@ describe('the boundary flush', () => {
})
describe('the soft layer', () => {
it('keeps the tool schemas identical across default and plan mode', async () => {
it('adds the exit schema together with plan guidance', async () => {
const ctx = await setup()
registerNamedTools(ctx, ['read', 'write'])
const agent = await agentWithSession(ctx)
const defaultAssembly = await assembleFor(ctx, agent)
expect(defaultAssembly.tools.map(tool => tool.name)).toEqual([EXIT_PLAN_MODE, 'read', 'write'])
expect(defaultAssembly.tools.map(tool => tool.name)).toEqual(['read', 'write'])
expect(defaultAssembly.sections.find(section => section.name === 'plan:policy')?.text).toBe('')
agent.session.append('plan/mode', { active: true })
const planAssembly = await assembleFor(ctx, agent)
expect(planAssembly.tools).toEqual(defaultAssembly.tools)
expect(planAssembly.tools.map(tool => tool.name)).toEqual([EXIT_PLAN_MODE, 'read', 'write'])
expect(planAssembly.sections.find(section => section.name === 'plan:policy')?.text).toBe(TEST_PLAN_SECTION)
})
@@ -465,25 +469,15 @@ describe('the soft layer', () => {
expect(assembly.sections.find(section => section.name === 'plan:policy')?.text).toBe(TEST_PLAN_SECTION)
})
it('leaves foreign assemble additions alone (no assemble-layer filtering)', async () => {
// Plan guidance does not filter the registry or later assembly additions.
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
await mountProjectionSeam(ctx)
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
const final = await next()
final.tools = [...final.tools, { name: 'added-later', description: 'added after next()', parameters: {} }]
return final
})
await ctx.plugin(PlanModeController, PLAN_CONFIG)
registerNamedTools(ctx, ['read'])
it('keeps independently registered tools available across plan changes', async () => {
const ctx = await setup()
registerNamedTools(ctx, ['read', 'added-later'])
const planning = await agentWithSession(ctx, 'planning', { active: true })
expect((await assembleFor(ctx, planning)).tools.map(tool => tool.name))
.toEqual(['exit_plan_mode', 'read', 'added-later'])
.toEqual(['added-later', 'exit_plan_mode', 'read'])
const defaulted = await agentWithSession(ctx, 'defaulted')
expect((await assembleFor(ctx, defaulted)).tools.map(tool => tool.name))
.toEqual(['exit_plan_mode', 'read', 'added-later'])
.toEqual(['added-later', 'read'])
})
it('keeps run_code the only wire tool in plan mode under the registry PTC mode; the SDK gains the exit binding', async () => {
@@ -512,7 +506,7 @@ describe('the soft layer', () => {
expectPlanPtcSdkBindings(sdk)
})
it('keeps the PTC mode SDK byte-identical across mode switches', async () => {
it('includes the PTC exit binding only while plan mode is active', async () => {
class FakeRuntime extends PtcRuntime {
resolve(request: import('@deepseek-ai/dsh-ptc-runtime').PtcRunRequest): import('@deepseek-ai/dsh-ptc-runtime').PtcRunSpec { return { ...request, cwd: request.cwd ?? process.cwd(), timeoutMs: request.timeoutMs ?? 120_000 } }
@@ -529,13 +523,11 @@ describe('the soft layer', () => {
registerNamedTools(withPlanMode, ['read', 'write'])
const agent = await agentWithSession(withPlanMode)
const defaultSdk = (await assembleFor(withPlanMode, agent)).sections.find(section => section.name === 'tools:sdk')?.text ?? ''
expectPlanPtcSdkBindings(defaultSdk)
expect(defaultSdk).not.toContain('exit_plan_mode:')
agent.session.append('plan/mode', { active: true })
const planSdk = (await assembleFor(withPlanMode, agent)).sections.find(section => section.name === 'tools:sdk')?.text ?? ''
expect(planSdk).toBe(defaultSdk)
expectPlanPtcSdkBindings(planSdk)
// Loading the plan-mode plugin deliberately adds one stable binding compared
// with a deployment that does not compose plan mode at all.
const bare = new Context()
await bare.plugin(SystemPrompt)
await bare.plugin(ToolRuntime, { mode: 'ptc' })
@@ -543,7 +535,7 @@ describe('the soft layer', () => {
registerNamedTools(bare, ['read', 'write'])
const bareSdk = (await bare.systemPrompt.assemble({ agent })).sections.find(section => section.name === 'tools:sdk')?.text ?? ''
expect(bareSdk).not.toContain('exit_plan_mode:')
expect(defaultSdk).not.toBe(bareSdk)
expect(defaultSdk).toBe(bareSdk)
})
})
@@ -783,8 +775,11 @@ describe('exit_plan_mode', () => {
return { ctx, agent, asked }
}
function callExit(ctx: Context, agent: Agent | undefined, plan = '# The plan\n\ndo things') {
async function callExit(ctx: Context, agent: Agent | undefined, plan = '# The plan\n\ndo things') {
const signal = new AbortController().signal
const toolSet = agent === undefined ? undefined : await ctx.tools.resolve({ agent, modelSelection: TEST_MODEL_SELECTION, signal })
return ctx.tools.execute({
...toolSet === undefined ? {} : { toolSet },
callId: ToolCallId(`call-exit-${++callCounter}`),
name: EXIT_PLAN_MODE,
arguments: { plan },
@@ -806,13 +801,24 @@ describe('exit_plan_mode', () => {
const ctx = await setup()
const result = await callExit(ctx, undefined)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: exit_plan_mode requires a calling agent (no session to switch)' }])
expect(result.content).toEqual([{ type: 'text', text: 'Error: unknown tool "exit_plan_mode"' }])
})
it('rejects a call outside plan mode while remaining advertised', async () => {
it('omits and rejects the exit tool outside plan mode', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
expect(ctx.tools.schemas().map(tool => tool.name)).toContain(EXIT_PLAN_MODE)
expect((await assembleFor(ctx, agent)).tools.map(tool => tool.name)).not.toContain(EXIT_PLAN_MODE)
const result = await callExit(ctx, agent)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: unknown tool "exit_plan_mode"' }])
})
it('admits the exit tool for a selected entry but rejects a call before the entry is logged', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
openTurn(agent.session)
ctx.planMode.set(agent, true)
expect((await assembleFor(ctx, agent)).tools.map(tool => tool.name)).toContain(EXIT_PLAN_MODE)
const result = await callExit(ctx, agent)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: exit_plan_mode is only available in plan mode' }])
@@ -915,6 +921,7 @@ describe('exit_plan_mode', () => {
const agent = await agentWithSession(ctx, 'ptc-exit', { active: true })
const result = await ctx.tools.execute({
toolSet: await ctx.tools.resolve({ agent, modelSelection: TEST_MODEL_SELECTION, signal: testToolSignal }),
callId: ToolCallId(`call-exit-${++callCounter}`),
name: RUN_CODE_NAME,
arguments: { code: `return await tools.${EXIT_PLAN_MODE}({ plan: ${JSON.stringify(plan)} })`, description: 'Submit the plan for review' },
@@ -937,21 +944,22 @@ describe('exit_plan_mode', () => {
expect(ctx.planMode.get(agent)).toEqual({ active: true, pending: false })
})
it('an approved exit projects the next assembly before the boundary and never removes the tool', async () => {
it('an approved exit removes the tool and guidance from the next proposed assembly', async () => {
const { ctx, agent } = await setupWithReview({ selected: ['Approve'] })
const approved = await callExit(ctx, agent)
expect(approved.isError).toBe(false)
// Calls of the SAME assistant response were requested under the existing
// plan-shaped header. Pending state shapes only the proposed next
// assembly; the accepted boundary then commits the matching durable fold.
// plan-shaped header. Pending state shapes the proposed next assembly,
// tool declarations included; the accepted boundary then commits the
// matching durable fold.
expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true)
const assembly = await ctx.systemPrompt.assemble({ agent })
expect(assembly.tools.some(tool => tool.name === EXIT_PLAN_MODE)).toBe(true)
const assembly = await assembleFor(ctx, agent)
expect(assembly.tools.some(tool => tool.name === EXIT_PLAN_MODE)).toBe(false)
expect(assembly.sections.find(section => section.name === 'plan:policy')?.text).toBe('')
await boundary(ctx, agent, 'step-start')
expect(foldPlanMode(agent.session.snapshotEvents())).toBe(false)
const afterExit = await ctx.systemPrompt.assemble({ agent })
expect(afterExit.tools).toEqual(assembly.tools)
const afterExit = await assembleFor(ctx, agent)
expect(afterExit.tools.some(tool => tool.name === EXIT_PLAN_MODE)).toBe(false)
expect(afterExit.sections.find(section => section.name === 'plan:policy')?.text).toBe('')
})
@@ -1066,6 +1074,7 @@ describe('exit_plan_mode', () => {
const { ctx, agent, asked } = await setupWithReview({ selected: ['Approve'] })
const controller = new AbortController()
const result = await ctx.tools.execute({
toolSet: await ctx.tools.resolve({ agent, modelSelection: TEST_MODEL_SELECTION, signal: testToolSignal }),
callId: ToolCallId(`call-exit-${++callCounter}`),
name: EXIT_PLAN_MODE,
arguments: { plan: '# P' },
@@ -1140,7 +1149,7 @@ describe('exit_plan_mode', () => {
})
describe('HMR disposal', () => {
it('unregisters the service, listeners, prompt section, and stable exit tool with the plugin fiber', async () => {
it('unregisters the service, listeners, prompt section, and exit tool with the plugin fiber', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
@@ -6,24 +6,23 @@ import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
export const name = 'snapshot-dynamic-tool-updates'
export const inject = ['tools']
/** Register a tool after reading, then remove it after use without changing prompt text. */
/** Activate a tool after reading, then disable it after use without changing prompt text. */
export function apply(ctx: Context): void {
let removeTool: (() => void) | undefined
let active = false
ctx.tools.register(defineContentToolFixture({
name: 'snapshot_ping', description: 'Return pong once.', parameters: {},
async execute() { return [{ type: 'text', text: 'pong' }] },
}), { when: async () => active })
let requests = 0
ctx.on('tools/post-execute', async (exec, result, next) => {
const downstream = await next()
if (result.isError) return downstream
switch (exec.name) {
case 'read':
removeTool = ctx.tools.register(defineContentToolFixture({
name: 'snapshot_ping',
description: 'Return pong once.',
parameters: {},
async execute() { return [{ type: 'text', text: 'pong' }] },
}))
active = true
break
case 'snapshot_ping':
removeTool?.()
active = false
break
}
return downstream
+14 -14
View File
@@ -1,27 +1,27 @@
/** Add a native tool after a file read and unregister it after its first execution. */
/** Activate a native tool after a file read and disable it after its first execution. */
export const name = 'python-sdk-dynamic-tools'
export const inject = ['tools']
/** @param {import('@deepseek-ai/cordis').Context} ctx - Scenario-owned tool registry. */
export function apply(ctx) {
let removeTool
let active = false
ctx.tools.register({
name: 'snapshot_ping',
description: 'Return pong once.',
parameters: { type: 'object', properties: {}, additionalProperties: false },
output: {
schema: { type: 'string' },
render(_args, value) { return [{ type: 'text', text: value }] },
},
async execute() { return 'pong' },
}, { when: async ({ modelSelection }) => active && modelSelection.provider === 'deepseek-official' })
ctx.on('tools/post-execute', async (exec, result, next) => {
const downstream = await next()
if (result.isError) return downstream
if (exec.name === 'read' && exec.arguments.file_path === 'dynamic-tool-task.txt') {
removeTool = ctx.effect(() => ctx.tools.register({
name: 'snapshot_ping',
description: 'Return pong once.',
parameters: { type: 'object', properties: {}, additionalProperties: false },
output: {
schema: { type: 'string' },
render(_args, value) { return [{ type: 'text', text: value }] },
},
async execute() { return 'pong' },
}))
active = true
} else if (exec.name === 'snapshot_ping') {
removeTool?.()
removeTool = undefined
active = false
}
return downstream
})
+4
View File
@@ -977,7 +977,11 @@ export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
'upgrade route registration contract is owned by packages/host/webserver/src/index.ts',
JsonValue: 'JSON value union is owned by packages/core/session/src/json.ts',
KnobState: 'projection unit state fields are owned by packages/interaction/permission-presets/README.md',
ToolActivationContext: 'conditional activation inputs are owned by packages/core/tools/README.md',
ToolRegistrationOptions: 'conditional registration options are owned by packages/core/tools/README.md',
ResolvedToolSet: 'step-owned admission is owned by packages/core/tools/README.md',
PromptAssembly: 'assembly result is owned by packages/core/system-prompt/README.md',
PromptPresentation: 'presentation inputs are owned by packages/core/system-prompt/README.md',
RequestRunId: 'dynamic-package payload contract is owned by packages/extensions/cordis-host-runner/src/types.ts',
RpcReceipt: 'carrier-layer receipt is owned by packages/client/connection/src/rpc.ts',
SessionForkSource: 'service-local fork input is owned by packages/core/session/src/index.ts',
+3 -1
View File
@@ -1577,7 +1577,8 @@ function renderLifecycle(): string {
' Note over Agent,Driver: claim pending next-step input plus one queued prompt',
` Driver-->>SDK: ${mermaidCode('agent/inbox/spliced')} pure deletion`,
` Driver-->>SDK: ${mermaidCode('agent/inbox/claimed')} { message, turn } per message`,
` Driver->>Prompt: ${mermaidCode('system-prompt/assemble')} waterfall`,
' Driver->>Tools: resolve({ agent, modelSelection, signal }) once per proposed step',
` Driver->>Prompt: ${mermaidCode('system-prompt/assemble')} waterfall with the step tool set`,
` Driver->>Hooks: ${mermaidCode('agent/pre-step')} waterfall`,
' Hooks-->>Driver: authoritative reject or enter(messages)',
' alt proposed step rejected, first batch empty, or pre-step failed',
@@ -1617,6 +1618,7 @@ function renderLifecycle(): string {
' end',
' end',
` Driver->>Session: ${mermaidCode('step/end')}`,
' Driver->>Tools: dispose the step tool set',
' opt natural stop and next-step inbox empty',
` Driver->>Hooks: ${mermaidCode('agent/turn-stopping')} serial terminal checkpoint`,
' end',