From 043539da055b01d824c96e4b2cbd375356ff322d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 13 Oct 2026 10:58:57 +0800 Subject: [PATCH] feat(agent): choose provider, model, and effort through agent/model Rename agent/route to agent/model and make its result a complete ModelSelection, so each step fixes its provider, model, and reasoning effort before prompt preparation, agent/pre-step, tool conditions, and every request attempt. agent/request may change only sampling controls; returning another provider, model, or reasoning effort fails the step. installModelSelection installs one agent/model listener and ModelSelectionRef drops assembled. The pre-step payload, AssembleContext, and ToolActivationContext carry modelSelection. Webhook initial selection and the CLI mock effort switch move to agent/model. --- ...bles-and-tool-guidance-ownership.i18n.yaml | 8 +- ...t-variables-and-tool-guidance-ownership.md | 4 +- ...ariables-and-tool-guidance-ownership.zh.md | 4 +- ...6-07-05-reconstructable-requests.i18n.yaml | 4 +- .../2026-07-05-reconstructable-requests.md | 2 +- .../2026-07-05-reconstructable-requests.zh.md | 2 +- ...-14-provider-routed-llm-adapters.i18n.yaml | 4 +- ...2026-07-14-provider-routed-llm-adapters.md | 2 +- ...6-07-14-provider-routed-llm-adapters.zh.md | 2 +- ...02-system-prompt-as-surface-node.i18n.yaml | 4 +- ...026-09-02-system-prompt-as-surface-node.md | 2 +- ...-09-02-system-prompt-as-surface-node.zh.md | 2 +- ...-model-chosen-before-preparation.i18n.yaml | 22 +++++ ...12-step-model-chosen-before-preparation.md | 37 +++++++ ...step-model-chosen-before-preparation.zh.md | 37 +++++++ ...-route-chosen-before-preparation.i18n.yaml | 22 ----- ...12-step-route-chosen-before-preparation.md | 36 ------- ...step-route-chosen-before-preparation.zh.md | 36 ------- .../2026-10-03-pi-extensions-bridge.i18n.yaml | 4 +- .../2026-10-03-pi-extensions-bridge.md | 2 +- .../2026-10-03-pi-extensions-bridge.zh.md | 2 +- apps/cli/tests/fixtures/dsh-badge/snapshot.ts | 2 +- docs/agent-lifecycle.i18n.yaml | 4 +- docs/agent-lifecycle.md | 16 +-- docs/agent-lifecycle.zh.md | 16 +-- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 6 +- docs/architecture.zh.md | 6 +- docs/event-producer-consumer.md | 26 ++--- docs/event-producer-consumer.zh.md | 26 ++--- docs/subsystems/core.i18n.yaml | 8 +- docs/subsystems/core.md | 97 ++++++++++--------- docs/subsystems/core.zh.md | 97 ++++++++++--------- docs/subsystems/llm-streaming.i18n.yaml | 4 +- docs/subsystems/llm-streaming.md | 2 +- docs/subsystems/llm-streaming.zh.md | 2 +- docs/subsystems/session-reference.md | 8 +- docs/subsystems/session-reference.zh.md | 8 +- docs/subsystems/tools.md | 2 +- docs/subsystems/tools.zh.md | 2 +- .../conditional-tools/guide.i18n.yaml | 22 ++--- .../v0.2.1-alpha.2/conditional-tools/guide.md | 14 +-- .../conditional-tools/guide.zh.md | 14 +-- packages/acp/acp/README.i18n.yaml | 4 +- packages/acp/acp/README.md | 2 +- packages/acp/acp/README.zh.md | 2 +- packages/acp/acp/src/model-control.ts | 7 +- packages/acp/acp/tests/bridge.spec.ts | 4 +- packages/api/session-controller/src/agent.ts | 5 +- .../tests/archived-session-gate.host.spec.ts | 2 +- .../commands-queue-attachment.host.spec.ts | 5 +- .../tests/commands-upload-file.host.spec.ts | 5 +- .../tests/session-fork.host.spec.ts | 9 +- .../tests/session-models.host.spec.ts | 24 ++--- packages/bundle/headless/src/index.ts | 2 +- .../tests/compaction-basic.spec.ts | 2 +- .../tests/compaction-loop-repro.spec.ts | 6 +- .../tests/agent-instructions.spec.ts | 30 +++--- .../session-reference/README.i18n.yaml | 4 +- packages/context/session-reference/README.md | 2 +- .../context/session-reference/README.zh.md | 2 +- .../context/session-reference/src/index.ts | 30 +++--- .../tests/loader-composition.spec.ts | 2 +- .../tests/session-reference.spec.ts | 18 ++-- .../time-context/tests/time-context.spec.ts | 2 +- .../tmux-context/tests/tmux-context.spec.ts | 2 +- packages/core/agent-loop/README.i18n.yaml | 8 +- packages/core/agent-loop/README.md | 4 +- packages/core/agent-loop/README.zh.md | 4 +- packages/core/agent-loop/src/agent.ts | 74 +++++++------- packages/core/agent-loop/src/index.ts | 4 +- .../tests/contract-regressions.spec.ts | 17 ++-- packages/core/agent-loop/tests/loop.spec.ts | 22 +++-- .../tests/request-reconstruction.spec.ts | 22 ++--- .../tests/system-prompt-admission.spec.ts | 6 +- .../agent-loop/tests/tool-updates.spec.ts | 32 +++--- packages/core/agent/README.i18n.yaml | 12 +-- packages/core/agent/README.md | 8 +- packages/core/agent/README.zh.md | 8 +- packages/core/agent/src/model-selection.ts | 84 +++++----------- packages/core/agent/src/runtime-types.ts | 51 +++++----- .../core/agent/tests/model-selection.spec.ts | 80 ++++++--------- packages/core/tools/README.i18n.yaml | 4 +- packages/core/tools/README.md | 4 +- packages/core/tools/README.zh.md | 4 +- packages/core/tools/src/index.ts | 10 +- packages/core/tools/tests/activation.spec.ts | 10 +- packages/core/tools/tests/ptc.spec.ts | 2 +- packages/experimental/webhook/src/session.ts | 16 ++- .../webhook/tests/session.spec.ts | 33 ++++--- .../extensions/tool-cordis/src/api-catalog.ts | 34 +++---- .../tests/account-routing.spec.ts | 14 +-- .../llm/llm-deepseek/tests/adapter.spec.ts | 2 +- packages/llm/llm-retry/tests/retry.spec.ts | 4 +- .../plan/plan-mode/tests/plan-mode.spec.ts | 12 +-- .../tests/session-checkpoint-policy.spec.ts | 2 +- .../skill/tool-skill/tests/tool-skill.spec.ts | 8 +- .../tests/model-selection-settings.spec.ts | 2 +- .../tests/fixtures/cli-mock-llm.ts | 6 +- scripts/fixtures/python-sdk-dynamic-tools.mjs | 2 +- scripts/gen-cordis-catalog.ts | 1 - scripts/gen-doc-graphs.ts | 16 +-- .../python-snapshot-workflow-order.spec.ts | 2 +- scripts/type-equiv.manifest.json | 2 +- .../cordis-inspect-jsdoc/session.v4.jsonl | 2 +- .../model-switch-driver.mjs | 8 +- 106 files changed, 693 insertions(+), 738 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-10-12-step-model-chosen-before-preparation.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-10-12-step-model-chosen-before-preparation.md create mode 100644 .agents/notes/implemented/architecture/2026-10-12-step-model-chosen-before-preparation.zh.md delete mode 100644 .agents/notes/implemented/architecture/2026-10-12-step-route-chosen-before-preparation.i18n.yaml delete mode 100644 .agents/notes/implemented/architecture/2026-10-12-step-route-chosen-before-preparation.md delete mode 100644 .agents/notes/implemented/architecture/2026-10-12-step-route-chosen-before-preparation.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml index 349c7778b0..f331bc345e 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml @@ -15,8 +15,8 @@ en: 46e67248692e333a zh: 51c5f2c6152c94d7 /agent-note-prompt-variables-and-tool-guidance-ownership/decision/prompt-variables: - en: 5f33b9ce0f4b0f9b - zh: 811297eddda54d51 + en: f5cbf8a901143c06 + zh: 534836ed5176a8ec /agent-note-prompt-variables-and-tool-guidance-ownership/decision/persona-as-the-order-0-section: en: c2d32c0ac5d28759 zh: 59cd252ba6da6e59 @@ -36,5 +36,5 @@ en: 31ba63101fca5790 zh: af4ceefab2033142 /agent-note-prompt-variables-and-tool-guidance-ownership/consequences: - en: 64e55b14b2511e9d - zh: ea7216af8111b2b2 + en: e57137faf5a35b1e + zh: baf3643398f5e005 diff --git a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md index 5eaf8417c5..73931b9c8e 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md +++ b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md @@ -28,7 +28,7 @@ The assembled system prompt had four defects, all of one family: facts the harne Plugins register `{{name}}` values through `ctx.systemPrompt.variable(name, provider)`. Assembly resolves them into the waterfall-visible variable map. Rendering rejects unknown own-property references, registered providers that return `undefined`, malformed complete references, and unbalanced references that still contain a closing `}}`; a lone unmatched `{{` remains prose, and substituted values are not rescanned. Registration rejects invalid or duplicate variable names, and section names are unique. Sections may set `interpolate: false` to preserve generated documents literally; `tools:sdk` does so because tool descriptions and schemas may document their own `{{…}}` syntax. -`dsh-agent-loop` registers `provider` and `model` as pure projections of the assembly context: the step route that `agent/route` chose, or the agent's `options` pair outside a step. Example personas reference `{{model}}` so the route owns the model name. These variables remain on the loop plugin because they describe the agents it drives; a replacement loop supplies its own. The [Session working-directory decision](2026-09-13-session-working-directory.md) owns directory state and its literal user-context placement; the loop does not register `cwd`. +`dsh-agent-loop` registers `provider` and `model` as pure projections of the assembly context: the step model selection that `agent/model` chose, or the agent's `options` pair outside a step. Example personas reference `{{model}}` so the model selection owns the model name. These variables remain on the loop plugin because they describe the agents it drives; a replacement loop supplies its own. The [Session working-directory decision](2026-09-13-session-working-directory.md) owns directory state and its literal user-context placement; the loop does not register `cwd`. ### Persona as the order-0 section @@ -65,7 +65,7 @@ Per-tool semantics and selection guidance live in tool descriptions. Prompt sect ## Consequences - Every fact in the assembled prompt now has exactly one owner, and the hand-maintained tool prose in leaf YAML is gone: loading or dropping a tool plugin no longer means editing any deployment's persona. -- `{{model}}` reflects the step route that `agent/route` chose, and `agent/request` cannot replace that route, so the rendered prompt and the routed header name the same model. A plugin that supplies or switches the model owns that fact on `agent/route`; a loop test pins the supply path end-to-end. Accepted. +- `{{model}}` reflects the step model selection that `agent/model` chose, and `agent/request` cannot replace that model, so the rendered prompt and the routed header name the same model. A plugin that supplies or switches the model owns that fact on `agent/model`; a loop test pins the supply path end-to-end. Accepted. - While a bound provider is absent (not yet activated, unloaded, mid-HMR-reload), the subagent tool does not exist and a model request in that window simply lacks it. That is the honest state — the alternative was a registered tool whose description or execution could not be trusted. - Strict interpolation rejects missing or valueless variables before a model request. The turn ends with `error` while the loop remains available for later turns; a persona referencing `{{model}}` requires its composing runtime to supply that value. - Inline escapes remain unsupported in interpolated text; literal sections need no escaping. PTC unit tests cover both modes and runtime languages, and the recorded `ptc-turn` scenario preserves tool-template examples in the model-visible prompt. diff --git a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md index adeaf7c0d5..12944274fa 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md @@ -28,7 +28,7 @@ Status: implemented 插件通过 `ctx.systemPrompt.variable(name, provider)` 注册 `{{name}}` 值。组装过程将它们解析到 waterfall 可见的变量映射中。渲染阶段拒绝以下情况:引用未知的自有属性、已注册的提供方返回 `undefined`、格式错误的完整引用、以及仍包含闭合 `}}` 的不平衡引用;孤立的未匹配 `{{` 保留为行文,替换后的值不会被重新扫描。注册阶段拒绝无效或重复的变量名,section 名称也必须唯一。段可设置 `interpolate: false` 来原样保留生成的文档;`tools:sdk` 使用此设置,因为工具描述和 schema 可能会介绍自身的 `{{…}}` 语法。 -`dsh-agent-loop` 注册 `provider` 和 `model`,它们都是组装上下文的纯投影:步骤内取 `agent/route` 选定的步骤路由,步骤外取 agent 的 `options` 这一对值。示例 persona 引用 `{{model}}`,由路由拥有模型名称。这些变量保留在 loop 插件上,因为它们描述该循环驱动的 agent;替换循环自行提供自己的变量。[Session 工作目录决策](2026-09-13-session-working-directory.zh.md) 拥有目录状态及其字面用户上下文位置;循环不注册 `cwd`。 +`dsh-agent-loop` 注册 `provider` 和 `model`,它们都是组装上下文的纯投影:步骤内取 `agent/model` 选定的步骤模型选择,步骤外取 agent 的 `options` 这一对值。示例 persona 引用 `{{model}}`,由模型选择拥有模型名称。这些变量保留在 loop 插件上,因为它们描述该循环驱动的 agent;替换循环自行提供自己的变量。[Session 工作目录决策](2026-09-13-session-working-directory.zh.md) 拥有目录状态及其字面用户上下文位置;循环不注册 `cwd`。 ### Persona 作为 order-0 section @@ -65,7 +65,7 @@ Status: implemented ## 后果 - 组装后的提示词中每个事实现在恰好有一个归属方,leaf YAML 中手工维护的工具行文已消除:加载或卸载一个工具插件不再需要编辑任何部署的 persona。 -- `{{model}}` 反映 `agent/route` 选定的步骤路由,且 `agent/request` 不能替换该路由,因此渲染后的提示词与路由后的请求头指向同一模型。提供或切换模型的插件在 `agent/route` 上拥有这一事实;一个循环测试端到端固定了 supply 路径。已接受。 +- `{{model}}` 反映 `agent/model` 选定的步骤模型选择,且 `agent/request` 不能替换该模型,因此渲染后的提示词与路由后的请求头指向同一模型。提供或切换模型的插件在 `agent/model` 上拥有这一事实;一个循环测试端到端固定了 supply 路径。已接受。 - 当一个已绑定的提供方不存在时(尚未激活、已卸载、HMR(热模块替换)重载中),subagent 工具不存在,该窗口内的模型请求中不会包含它。这是诚实的状态——替代方案是注册一个 description 或执行都不可信的工具。 - 严格插值在模型请求之前拒绝缺失或无值的变量。该轮次以 `error` 结束,循环仍可服务后续轮次;引用 `{{model}}` 的 persona 要求组合它的运行时提供该值。 - 插值文本仍不支持行内转义;字面文本段无需转义。PTC 单元测试覆盖两种模式和运行时语言,录制的 `ptc-turn` 场景在模型可见的提示词中保留工具模板示例。 diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml index 76b76ed269..5809b9a8d4 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml @@ -15,8 +15,8 @@ en: 6da15cf986d59a91 zh: 40282908b54295f9 /agent-note-every-llm-request-is-reconstructable-from-the-session-log/decision/the-mechanism: - en: 8a1ec49eb4f0ecf0 - zh: e9d22685d64826d4 + en: b053c2b01866a634 + zh: cb15fa3a7fdefe60 /agent-note-every-llm-request-is-reconstructable-from-the-session-log/decision/the-minicode-shape-adopted-with-the-event-log-as-the-source: en: e5f2ffaa3d7de0cc zh: 5ba6b36787a0e189 diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md index 2744d4828f..022f6dcb06 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md @@ -24,7 +24,7 @@ Prefix-cache stability is corollary #1, not the headline: an append-only log pro `EpochHeader` records the request's non-history state: call config and tool schemas. Writers omit `tools: []` and `adapterDefaults: {}`; current acceptance rejects those fields and any `header.system`, rather than repairing them. Whitespace-only system-message content, `config.stop: []`, and nested extensions remain intact. The [V3 canonical-envelope decision](2026-09-06-v3-canonical-session-envelopes.md) owns historical conversion. The rendered system prompt is derived history — the `system/message` event at surface node 0, per the [surface-node Agent Note](2026-09-02-system-prompt-as-surface-node.md) — so a prompt change is a surface replacement rather than a header change. Adapter-supplied effort and token defaults retain their `adapterDefaults` ownership; a Web model selection restored from the log omits an adapter-owned effort so the next resolution cannot reclassify the same effective config as an explicit selection and a false change. `request/header` always writes a full snapshot: the first loop instance uses reason `initial`, later instances use `resume`, an in-instance change uses `change`, and an unchanged envelope beginning an explicitly declared message series or following a surface replacement uses `series`. A `change` snapshot carries `startsSeries: true` when the changed request also starts a series, preserving the two independent facts without a duplicate header. Ordinary append-only later Turns, further same-series Steps, and retries inherit the latest snapshot. `foldRequestHeader` selects the latest snapshot. Legacy `request/header-delta` events and the removed `fallback` reason are rejected when appended or loaded. -Each proposed step first claims its inbox batch, lets `agent/route` choose the step's provider and model, prepares runtime context, and runs `agent/pre-step`. Rejection opens no step; enter opens `step/start`, resolves the step's tools, assembles the system prompt, commits a changed prompt as the `system/message` append or node-0 replacement, records the final message batch as `user/message` events, and may use `startsRequestSeries: true` to declare a distinct series. `agent/request` may replace only the frozen call-config controls; the provider and model stay the step route. The loop records the owed initial, resume, change, or series full snapshot, builds `GenerateOptions` from derived messages (system message first) and that header with no `system` field, and freezes it while leaving `AbortSignal` live. The [request-freeze evidence reference](../../../../packages/core/agent-loop/README.md) owns reuse of completed message freezes and per-request local header freezing. The first call config starts from explicit `AgentOptions`, preserving fork overrides and resume reconfiguration; later calls start from the folded header. +Each proposed step first claims its inbox batch, lets `agent/model` choose the step's provider, model, and reasoning effort, prepares runtime context, and runs `agent/pre-step`. Rejection opens no step; enter opens `step/start`, resolves the step's tools, assembles the system prompt, commits a changed prompt as the `system/message` append or node-0 replacement, records the final message batch as `user/message` events, and may use `startsRequestSeries: true` to declare a distinct series. `agent/request` may replace only the frozen call config's sampling controls; the provider, model, and reasoning effort stay the step's model selection. The loop records the owed initial, resume, change, or series full snapshot, builds `GenerateOptions` from derived messages (system message first) and that header with no `system` field, and freezes it while leaving `AbortSignal` live. The [request-freeze evidence reference](../../../../packages/core/agent-loop/README.md) owns reuse of completed message freezes and per-request local header freezing. The first call config starts from explicit `AgentOptions`, preserving fork overrides and resume reconfiguration; later calls start from the folded header. **The open step is the reconstruction boundary.** Its entered `user/message` batch and any newly written `request/header` precede request dispatch. Injection after the atomic claim joins a later request, while a listener that must affect this request returns messages through `agent/pre-step`. Header reconstruction selects the step's `request/header`, or carries the prior snapshot when no new header is written. diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md index 6965fa627b..9b94859d12 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md @@ -24,7 +24,7 @@ Status: implemented `EpochHeader` 记录请求的非历史状态:调用配置和工具 schema。写入方省略 `tools: []` 与 `adapterDefaults: {}`;当前接纳拒绝这些字段以及任何 `header.system`,而不修复它们。仅含空白的系统消息内容、`config.stop: []` 与嵌套扩展保持原样。[V3 规范信封决策](2026-09-06-v3-canonical-session-envelopes.zh.md)负责历史转换。渲染后的系统提示词是派生历史——surface 第 0 号节点上的 `system/message` 事件,见[surface 节点 Agent Note](2026-09-02-system-prompt-as-surface-node.zh.md)——因此提示词变更是 surface 替换而不是 header 变更。适配器提供的推理强度与 token 默认值会保留其 `adapterDefaults` 来源信息;Web 从日志恢复模型选择时会省略适配器持有的推理强度,因此下一次解析不会把相同的有效配置重新归类为显式选择并产生虚假变更。`request/header` 始终写入完整快照:首个循环实例使用 reason `initial`,后续实例使用 `resume`,实例内变更使用 `change`,内容未变的封装显式开启消息序列或跟随表层替换时使用 `series`。如果发生变化的请求同时开启序列,`change` 快照会携带 `startsSeries: true`,无需重复 header 即可保留这两个独立事实。普通的仅追加后续 Turn、同一序列内后续的 Step 与重试沿用最新快照。`foldRequestHeader` 选择最新快照。旧的 `request/header-delta` 事件和已移除的 `fallback` reason 在追加或加载时都会被拒绝。 -每个拟议步骤先领取其 inbox 批次,由 `agent/route` 选定该步骤的提供方与模型,准备运行时上下文,再运行 `agent/pre-step`。reject 不打开步骤;enter 打开 `step/start`,解析该步骤的工具并组装系统提示词,把变化的提示词作为 `system/message` 追加或第 0 号节点替换提交,把最终消息批次记录为 `user/message` 事件,并可使用 `startsRequestSeries: true` 声明独立序列。`agent/request` 只能替换冻结调用配置中的控制参数;提供方与模型保持为步骤路由。循环记录所需的 initial、resume、change 或 series 完整快照,从派生消息(系统消息在先)与该不含 `system` 字段的 header 构建 `GenerateOptions`,冻结请求但保持 `AbortSignal` 活跃。[请求冻结证据说明](../../../../packages/core/agent-loop/README.zh.md)拥有消息完整冻结的复用规则和每次请求的本地 header 冻结规则。首次调用配置从显式的 `AgentOptions` 出发,保留 fork 覆盖和恢复重配置;后续调用从折叠后的 header 出发。 +每个拟议步骤先领取其 inbox 批次,由 `agent/model` 选定该步骤的提供方、模型与推理强度,准备运行时上下文,再运行 `agent/pre-step`。reject 不打开步骤;enter 打开 `step/start`,解析该步骤的工具并组装系统提示词,把变化的提示词作为 `system/message` 追加或第 0 号节点替换提交,把最终消息批次记录为 `user/message` 事件,并可使用 `startsRequestSeries: true` 声明独立序列。`agent/request` 只能替换冻结调用配置中的采样控制参数;提供方、模型与推理强度保持为步骤的模型选择。循环记录所需的 initial、resume、change 或 series 完整快照,从派生消息(系统消息在先)与该不含 `system` 字段的 header 构建 `GenerateOptions`,冻结请求但保持 `AbortSignal` 活跃。[请求冻结证据说明](../../../../packages/core/agent-loop/README.zh.md)拥有消息完整冻结的复用规则和每次请求的本地 header 冻结规则。首次调用配置从显式的 `AgentOptions` 出发,保留 fork 覆盖和恢复重配置;后续调用从折叠后的 header 出发。 **已打开步骤是重建边界。** 进入步骤的 `user/message` 批次与任何新写入的 `request/header` 都位于请求分派之前。原子领取后发生的注入加入后续请求;必须影响本次请求的监听器则通过 `agent/pre-step` 返回消息。header 重建选择该步骤的 `request/header`,或在无新 header 写入时沿用前一个快照。 diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml index 3ef3879354..dc310776ac 100644 --- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml @@ -12,8 +12,8 @@ en: a15c7c13f167e5ac zh: 63bcc2ef60c2f344 /agent-note-provider-routed-llm-adapters-and-a-generic-pi-ai-backend/decision/provider-is-the-adapter-registration-key: - en: ab32d2c01a78ac3f - zh: fbb1d8d2cade9d2e + en: b0586701c1989416 + zh: 5b64205431325b03 /agent-note-provider-routed-llm-adapters-and-a-generic-pi-ai-backend/decision/explicit-pi-ai-provider-profiles: en: 1346315312eb35e2 zh: 3555f91c3afb49f8 diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md index 23023a952c..b9fe53ed7e 100644 --- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md +++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md @@ -18,7 +18,7 @@ The adapter configuration also assumes one DeepSeek API key and endpoint. A gene ### Provider is the adapter registration key -`GenerateOptions` and `LlmCallConfig` carry `provider: string` beside `model: string`; `AgentOptions` carries the corresponding optional creation field. A loop request is valid only after both values are non-empty, and both values are part of the logged request header. `agent/route` may return a replacement pair for any step, so a session can switch providers and models without changing the Cordis plugin lifecycle; every request attempt in the step keeps that pair. +`GenerateOptions` and `LlmCallConfig` carry `provider: string` beside `model: string`; `AgentOptions` carries the corresponding optional creation field. A loop request is valid only after both values are non-empty, and both values are part of the logged request header. `agent/model` may return a replacement pair, with that model's reasoning effort, for any step, so a session can switch providers and models without changing the Cordis plugin lifecycle; every request attempt in the step keeps that selection. `LlmRuntime` registers and resolves adapters by provider. `registerAdapter(providers, adapter)` checks the entire provider list before mutating the registry, rejects a duplicate with `DUPLICATE_ADAPTER`, and disposes the whole registration as one effect. Model ids are not registration keys; the selected adapter still validates or forwards them. The later [LLM catalog and ACP selection Agent Note](../../archived/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md) added advisory `listProviders()` / `listModels()` discovery without turning model membership into request validation. diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md index ef049f3c42..bdde3cd88f 100644 --- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md @@ -18,7 +18,7 @@ Status: implemented ### 提供方作为适配器注册键 -`GenerateOptions` 与 `LlmCallConfig` 在 `model: string` 之外携带 `provider: string`,`AgentOptions` 则携带对应的可选创建字段。只有两个值都非空时,agent loop(智能体循环)请求才有效;两个值也都会写入请求头日志。`agent/route` 可以为任意步骤返回替换后的字段组合,因此会话可以切换提供方与模型,无需改变 Cordis 插件生命周期;该步骤的每次请求尝试都沿用这一组合。 +`GenerateOptions` 与 `LlmCallConfig` 在 `model: string` 之外携带 `provider: string`,`AgentOptions` 则携带对应的可选创建字段。只有两个值都非空时,agent loop(智能体循环)请求才有效;两个值也都会写入请求头日志。`agent/model` 可以为任意步骤返回替换后的字段组合及该模型的推理强度,因此会话可以切换提供方与模型,无需改变 Cordis 插件生命周期;该步骤的每次请求尝试都沿用这一选择。 `LlmRuntime` 按提供方注册和解析适配器。`registerAdapter(providers, adapter)` 在修改注册表前检查整个提供方列表,遇到重复项时返回 `DUPLICATE_ADAPTER`,并以一个 effect 为单位整体 dispose(资源释放)。模型 ID 不作为注册键;仍由选中的适配器负责验证或转发。后续的 [LLM 目录与 ACP 模型选择 Agent Note](../../archived/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md) 增加了建议性的 `listProviders()` / `listModels()` 发现接口,但不会把目录成员关系变成请求校验规则。 diff --git a/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.i18n.yaml index 4d4ce8a93b..ba1077a0ec 100644 --- a/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.i18n.yaml @@ -18,8 +18,8 @@ en: a217a08932938062 zh: 4924d99d55d9c973 /agent-note-the-system-prompt-is-surface-node-0/decision/ownership-in-the-loop: - en: 1c56551cd45b1ed4 - zh: 39c26c6c9799de59 + en: 1626e01049f6d312 + zh: bb000a96944c8168 /agent-note-the-system-prompt-is-surface-node-0/decision/consumers: en: 39f9f4acffcccdc6 zh: 36b23a57e7d28950 diff --git a/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md b/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md index 405f9c2b96..dbcdaea1f2 100644 --- a/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md +++ b/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md @@ -36,7 +36,7 @@ When the initial rendered prompt is empty, the loop reserves an empty system hea `dsh-agent-loop` owns `SystemPromptProjection` beside `RuntimeContextProjection` in `packages/core/agent-loop/src/runtime-context.ts`. It reads the surviving `system/message` nodes from the current surface on every projection, so a compaction or replacement that ran earlier in the same step is already reflected. `project(rendered, { inHistory, startsSeries })` returns `{ message, intent }` — `intent` is `{ surfaceOp: 'append' }` when no system node survives or when the [in-history rule](../feature/2026-09-02-in-history-system-prompt-replacement.md) applies, otherwise a replacement of exactly the latest surviving system node — or `undefined` when the latest node already holds the rendered text. -In `packages/core/agent-loop/src/agent.ts`, `step()` renders the prompt with `renderPrompt(assembly)` after `step/start` and projects it on each attempt after `agent/request` and `prepareCall()`, so a compaction provider's replacement inside `agent/pre-step` or retry recovery is visible to reconciliation; the `system/message` is committed before the step's `user/message` events, so log order is wire order. `buildRequest` sets no `system` on the request: the request is `header.config`, `session.deriveMessages()` (system message first), and `header.tools`. The loop step order is: claim inbox → `agent/route` waterfall → `systemPrompt.prepare()` → `agent/pre-step` waterfall → `step/start` → resolve tools → `systemPrompt.assemble()` → `agent/request` waterfall → `prepareCall()` → commit `system/message` (when changed) → commit `user/message`s → `request/header` → `request/context` → stream. +In `packages/core/agent-loop/src/agent.ts`, `step()` renders the prompt with `renderPrompt(assembly)` after `step/start` and projects it on each attempt after `agent/request` and `prepareCall()`, so a compaction provider's replacement inside `agent/pre-step` or retry recovery is visible to reconciliation; the `system/message` is committed before the step's `user/message` events, so log order is wire order. `buildRequest` sets no `system` on the request: the request is `header.config`, `session.deriveMessages()` (system message first), and `header.tools`. The loop step order is: claim inbox → `agent/model` waterfall → `systemPrompt.prepare()` → `agent/pre-step` waterfall → `step/start` → resolve tools → `systemPrompt.assemble()` → `agent/request` waterfall → `prepareCall()` → commit `system/message` (when changed) → commit `user/message`s → `request/header` → `request/context` → stream. `dsh-token-meter` anchors usage to the priced surface immediately before the successful `assistant/message`, not to `step/start`. The loop admits the system prompt and user messages after step start, and retry recovery can replace nodes before rebuilding the request. Capturing that current surface includes every admitted input once; the embedded provider output remains separately priced so durable assistant rewrites retain their signed delta. The open step stores only turn and step for lifecycle validation, not a second node snapshot. diff --git a/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md b/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md index 7384b5f96c..fada9e3085 100644 --- a/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md @@ -36,7 +36,7 @@ Status: implemented `dsh-agent-loop` 在 `packages/core/agent-loop/src/runtime-context.ts` 中与 `RuntimeContextProjection` 并列拥有 `SystemPromptProjection`。它在每次投影时从当前 surface 读取存活的 `system/message` 节点,因此同一步骤中更早运行的压缩或替换已经反映在内。`project(rendered, { inHistory, startsSeries })` 返回 `{ message, intent }`——没有系统节点存活或[历史内规则](../feature/2026-09-02-in-history-system-prompt-replacement.zh.md)适用时 `intent` 为 `{ surfaceOp: 'append' }`,否则是对最新存活系统节点的精确替换——最新节点已持有渲染文本时返回 `undefined`。 -在 `packages/core/agent-loop/src/agent.ts` 中,`step()` 在 `step/start` 之后用 `renderPrompt(assembly)` 渲染提示词,并在每次尝试的 `agent/request` 与 `prepareCall()` 之后投影它,因此压缩提供者在 `agent/pre-step` 内或重试恢复中做出的替换对协调可见;`system/message` 在该步骤的 `user/message` 事件之前提交,因此日志顺序即协议顺序。`buildRequest` 不在请求上设置 `system`:请求由 `header.config`、`session.deriveMessages()`(系统消息在先)和 `header.tools` 构成。循环步骤顺序为:领取收件箱 → `agent/route` waterfall → `systemPrompt.prepare()` → `agent/pre-step` waterfall → `step/start` → 解析工具 → `systemPrompt.assemble()` → `agent/request` waterfall → `prepareCall()` → 提交 `system/message`(有变化时) → 提交各条 `user/message` → `request/header` → `request/context` → 流式请求。 +在 `packages/core/agent-loop/src/agent.ts` 中,`step()` 在 `step/start` 之后用 `renderPrompt(assembly)` 渲染提示词,并在每次尝试的 `agent/request` 与 `prepareCall()` 之后投影它,因此压缩提供者在 `agent/pre-step` 内或重试恢复中做出的替换对协调可见;`system/message` 在该步骤的 `user/message` 事件之前提交,因此日志顺序即协议顺序。`buildRequest` 不在请求上设置 `system`:请求由 `header.config`、`session.deriveMessages()`(系统消息在先)和 `header.tools` 构成。循环步骤顺序为:领取收件箱 → `agent/model` waterfall → `systemPrompt.prepare()` → `agent/pre-step` waterfall → `step/start` → 解析工具 → `systemPrompt.assemble()` → `agent/request` waterfall → `prepareCall()` → 提交 `system/message`(有变化时) → 提交各条 `user/message` → `request/header` → `request/context` → 流式请求。 `dsh-token-meter` 把用量锚定到成功的 `assistant/message` 之前的已计价 surface,而不是 `step/start`。循环在步骤开始之后接纳系统提示词与用户消息,重试恢复还可能在重建请求之前替换节点。捕获当前 surface 会让每个已接纳输入恰好计入一次;内嵌的提供方输出仍单独计价,因此持久 assistant 改写保留其带符号增量。开放步骤只保存 turn 与 step 以验证生命周期,不保存第二份节点快照。 diff --git a/.agents/notes/implemented/architecture/2026-10-12-step-model-chosen-before-preparation.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-12-step-model-chosen-before-preparation.i18n.yaml new file mode 100644 index 0000000000..3559e84201 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-12-step-model-chosen-before-preparation.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-10-12-step-model-chosen-before-preparation.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 .agents/notes/implemented/architecture/2026-10-12-step-model-chosen-before-preparation.md +/agent-note-choose-the-step-model-before-step-preparation: + en: f068fddb192978d2 + zh: 601deb72d3d90263 +/agent-note-choose-the-step-model-before-step-preparation/problem: + en: cb8d6f058dd5f47d + zh: bb70e1cfe6880da5 +/agent-note-choose-the-step-model-before-step-preparation/decision: + en: d0ee43966ff59a93 + zh: 4f69bc120de87c44 +/agent-note-choose-the-step-model-before-step-preparation/alternatives-considered: + en: 3114082393f6a55f + zh: a3790bd3b18658e0 +/agent-note-choose-the-step-model-before-step-preparation/consequences: + en: c553f9931c8c467b + zh: 0d77c5c71d266d27 +/agent-note-choose-the-step-model-before-step-preparation/related: + en: 00ef1147a7852c0a + zh: 4bd89960e08b6eda diff --git a/.agents/notes/implemented/architecture/2026-10-12-step-model-chosen-before-preparation.md b/.agents/notes/implemented/architecture/2026-10-12-step-model-chosen-before-preparation.md new file mode 100644 index 0000000000..c9a6c8dde9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-12-step-model-chosen-before-preparation.md @@ -0,0 +1,37 @@ +# Agent Note: Choose the step model before step preparation + +Status: implemented + +English | [中文](2026-10-12-step-model-chosen-before-preparation.zh.md) + +## Problem + +Tool availability can depend on the route of the request that declares the tools: `web_search` must stay off routes without search credentials, and plan or team state can withhold other tools. Message preparation needs the same model: session references size snapshots by the model's context window, and model selection announces a switch. The loop assembled tools and prompt sections before `agent/pre-step` and decided the provider, model, and reasoning effort only in `agent/request` after `step/start`, so earlier consumers predicted them from model-selection state. A listener that replaced them in `agent/request`, including on a retry, made the assembled prompt and tool catalog describe another model. + +## Decision + +The provider, model, and reasoning effort are a step fact, chosen together as the user chooses them before sending a message. After claiming input, the loop dispatches `agent/model` once per proposed step. Its default comes from the agent options before this loop logs a request and from the logged header afterwards, with an effort only when one was chosen for that exact model; `installModelSelection` returns its current selection there. The loop passes the resulting `ModelSelection` to `system-prompt/prepare` as `AssembleContext.modelSelection`, to `agent/pre-step` as `payload.modelSelection`, and to tool conditions as `ToolActivationContext.modelSelection`. The `{{provider}}` and `{{model}}` prompt variables read it. + +After `agent/pre-step` accepts the step and `step/start` opens it, the loop resolves conditional tools and assembles the prompt once. Each request attempt then runs `agent/request` and `prepareCall()` immediately before admission. `agent/request` may change sampling controls; a result with another provider, model, or reasoning effort fails the step. Retries reuse the step's model selection, tool set, and rendered assembly, and the step-owned tool set closes after the step's tool batch settles. A selection made during a step applies from the next step. + +## Alternatives considered + +- **Bind the route in `agent/request` before `agent/pre-step` and expose `Agent.currentRoute()`.** The prepared call captures the adapter registration and connection settings, so holding it across compaction, hooks, and session reads ignores settings changed meanwhile, and adapters were prepared for steps that `agent/pre-step` rejects. The getter was valid only inside a step and threw elsewhere, and every Agent implementation and test double had to carry it. +- **Record the model in `agent/pre-step`.** `system-prompt/prepare` runs before that waterfall, and model consumers inside it would depend on listener order. +- **Register the model selection on the Agent without an event.** It adds a single-consumer method to the generic `Agent` interface and removes the listener path that supplies a model for agents created without one. +- **Keep the decision in `agent/request` and predict it for earlier consumers.** A prediction diverges whenever a listener other than model selection changes the model. +- **Choose only the provider and model per step and leave reasoning effort to `agent/request`.** An effort is valid only for the model it was chosen for. The split required `installModelSelection` to capture the selection in one waterfall and apply its effort in another only when no other listener had replaced the model, and it let a retry change effort within a step. + +## Consequences + +- Tool conditions, prompt variables, reference budgets, and switch notices read the provider and model that the request header records. +- `prepareCall()` stays adjacent to dispatch, so its adapter registration and connection snapshot cover only header logging and streaming. +- A step cannot fail over to another provider or change reasoning effort on retry; a policy that needs this must run model selection, tool resolution, and assembly again for the retried attempt. +- The model selection cannot depend on `agent/pre-step` outcomes. Plan state commits inside that waterfall, so a plan-specific model would require committing plan selections before model selection. +- `agent/pre-step` listeners and direct dispatchers must handle the required `modelSelection` field. + +## Related + +- [Provider-routed LLM adapters](2026-07-14-provider-routed-llm-adapters.md) owns provider and model fields in call configuration. +- [Reconstructable requests](2026-07-05-reconstructable-requests.md) owns request reconstruction from the logged header. +- [Prompt variables and tool guidance ownership](2026-07-05-prompt-variables-and-tool-guidance-ownership.md) owns the `{{model}}` variable. diff --git a/.agents/notes/implemented/architecture/2026-10-12-step-model-chosen-before-preparation.zh.md b/.agents/notes/implemented/architecture/2026-10-12-step-model-chosen-before-preparation.zh.md new file mode 100644 index 0000000000..09ef27ac4f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-12-step-model-chosen-before-preparation.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 在步骤准备之前选定步骤模型 + +Status: implemented + +[English](2026-10-12-step-model-chosen-before-preparation.md) | 中文 + +## 问题 + +工具是否可用可能取决于声明这些工具的请求所用的路由:`web_search` 不得出现在没有搜索凭据的路由上,计划或团队状态也可能撤下其他工具。消息准备同样需要这一模型:会话引用按模型的上下文窗口确定快照大小,模型选择会通告模型切换。循环原先在 `agent/pre-step` 之前组装工具与提示词段落,直到 `step/start` 之后才在 `agent/request` 中决定提供方、模型与推理强度,因此更早的消费方只能依据模型选择状态预测它们。若监听器在 `agent/request` 中(包括在重试时)替换它们,已组装的提示词与工具目录就会描述另一个模型。 + +## 决策 + +提供方、模型与推理强度是步骤级事实,像用户在发送消息前那样一起选定。领取输入之后,循环为每个拟议步骤派发一次 `agent/model`。其默认值在本循环记录请求之前取自 agent 选项,之后取自已记录的请求头,且只有为该确切模型选定过的推理强度才会保留;`installModelSelection` 在这里返回其当前选择。循环把得到的 `ModelSelection` 作为 `AssembleContext.modelSelection` 传给 `system-prompt/prepare`,作为 `payload.modelSelection` 传给 `agent/pre-step`,作为 `ToolActivationContext.modelSelection` 传给工具条件。提示词变量 `{{provider}}` 与 `{{model}}` 读取该选择。 + +`agent/pre-step` 接纳步骤且 `step/start` 打开步骤之后,循环解析条件工具并组装提示词,各执行一次。之后每次请求尝试都在准入之前紧接着运行 `agent/request` 与 `prepareCall()`。`agent/request` 可以修改采样控制参数;返回其他提供方、模型或推理强度会使步骤失败。重试复用该步骤的模型选择、工具集合与已渲染组装结果,步骤拥有的工具集合在该步骤的工具批次结束后关闭。步骤进行中做出的选择从下一个步骤开始生效。 + +## 考虑过的替代方案 + +- **在 `agent/pre-step` 之前通过 `agent/request` 绑定路由,并提供 `Agent.currentRoute()`。** 已准备调用会捕获适配器注册与连接设置,因此在压缩、钩子与会话读取期间一直持有它,会忽略期间修改的设置;被 `agent/pre-step` 拒绝的步骤也会准备适配器。该 getter 只在步骤内有效、在其他时刻抛错,且每个 Agent 实现与测试替身都必须提供它。 +- **在 `agent/pre-step` 内记录模型。** `system-prompt/prepare` 在该 waterfall 之前运行,而 waterfall 内的模型消费方将依赖监听器顺序。 +- **不使用事件,直接在 Agent 上登记模型选择。** 这会给通用 `Agent` 接口增加只有单一消费方的方法,并移除为创建时没有模型的 agent 提供模型的监听器途径。 +- **决定留在 `agent/request`,为更早的消费方预测它。** 只要模型选择之外的监听器修改模型,预测就会偏离。 +- **每个步骤只选定提供方与模型,推理强度留给 `agent/request`。** 推理强度只对选定它的模型有效。拆开后,`installModelSelection` 必须在一个 waterfall 中捕获选择,再在另一个 waterfall 中、且仅当其他监听器没有替换模型时应用推理强度;重试也可以在步骤内修改推理强度。 + +## 后果 + +- 工具条件、提示词变量、引用预算与切换通告读取的提供方与模型,与请求头记录的一致。 +- `prepareCall()` 紧邻分发,因此其适配器注册与连接快照只覆盖请求头记录与流式请求。 +- 步骤无法在重试时切换到其他提供方或修改推理强度;需要这种能力的策略必须为重试的尝试重新执行模型选择、工具解析与组装。 +- 模型选择不能依赖 `agent/pre-step` 的结果。计划状态在该 waterfall 内提交,因此按计划模式选择模型需要先在模型选择之前提交计划选择。 +- `agent/pre-step` 监听器与直接派发方必须处理必填的 `modelSelection` 字段。 + +## 相关 + +- [基于提供方路由的 LLM 适配器](2026-07-14-provider-routed-llm-adapters.zh.md)负责调用配置中的提供方与模型字段。 +- [可重建请求](2026-07-05-reconstructable-requests.zh.md)负责依据已记录请求头重建请求。 +- [提示词变量与工具指导归属](2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md)负责 `{{model}}` 变量。 diff --git a/.agents/notes/implemented/architecture/2026-10-12-step-route-chosen-before-preparation.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-12-step-route-chosen-before-preparation.i18n.yaml deleted file mode 100644 index c0331d7d6c..0000000000 --- a/.agents/notes/implemented/architecture/2026-10-12-step-route-chosen-before-preparation.i18n.yaml +++ /dev/null @@ -1,22 +0,0 @@ -# Bilingual-pair consistency record for 2026-10-12-step-route-chosen-before-preparation.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 .agents/notes/implemented/architecture/2026-10-12-step-route-chosen-before-preparation.md -/agent-note-choose-the-step-route-before-step-preparation: - en: cce89c1039110cb5 - zh: fb29015e2a37b74f -/agent-note-choose-the-step-route-before-step-preparation/problem: - en: 2d6bc81525bc277d - zh: b65ec115cb4a00e4 -/agent-note-choose-the-step-route-before-step-preparation/decision: - en: 38e163581b2f798d - zh: 62c89bb0167ded9e -/agent-note-choose-the-step-route-before-step-preparation/alternatives-considered: - en: a704646ef1631668 - zh: f99b24164105d0bb -/agent-note-choose-the-step-route-before-step-preparation/consequences: - en: ebaeafdadf1ab3cd - zh: 12cb3c213c84fe82 -/agent-note-choose-the-step-route-before-step-preparation/related: - en: 00ef1147a7852c0a - zh: 4bd89960e08b6eda diff --git a/.agents/notes/implemented/architecture/2026-10-12-step-route-chosen-before-preparation.md b/.agents/notes/implemented/architecture/2026-10-12-step-route-chosen-before-preparation.md deleted file mode 100644 index 3c347f72b0..0000000000 --- a/.agents/notes/implemented/architecture/2026-10-12-step-route-chosen-before-preparation.md +++ /dev/null @@ -1,36 +0,0 @@ -# Agent Note: Choose the step route before step preparation - -Status: implemented - -English | [中文](2026-10-12-step-route-chosen-before-preparation.zh.md) - -## Problem - -Tool availability can depend on the route of the request that declares the tools: `web_search` must stay off routes without search credentials, and plan or team state can withhold other tools. Message preparation needs the same route: session references size snapshots by the model's context window, and model selection announces a switch. The loop assembled tools and prompt sections before `agent/pre-step` and decided the route only in `agent/request` after `step/start`, so earlier consumers predicted the route from model-selection state. A listener that replaced the route in `agent/request`, including on a retry, made the assembled prompt and tool catalog describe another model. - -## Decision - -The provider and model are a step fact. After claiming input, the loop dispatches `agent/route` once per proposed step. Its default is the agent options route before this loop logs a request and the logged header route afterwards; `installModelSelection` captures its selection there. The loop passes the resulting `StepRoute` to `system-prompt/prepare` as `AssembleContext.route`, to `agent/pre-step` as `payload.route`, and to tool conditions as `ToolActivationContext.route`. The `{{provider}}` and `{{model}}` prompt variables read it. - -After `agent/pre-step` accepts the step and `step/start` opens it, the loop resolves conditional tools and assembles the prompt once. Each request attempt then runs `agent/request` and `prepareCall()` immediately before admission. `agent/request` may change call controls; a result with another provider or model fails the step. Retries reuse the step's route, tool set, and rendered assembly, and the step-owned tool set closes after the step's tool batch settles. A selection made during a step applies from the next step. - -## Alternatives considered - -- **Bind the route in `agent/request` before `agent/pre-step` and expose `Agent.currentRoute()`.** The prepared call captures the adapter registration and connection settings, so holding it across compaction, hooks, and session reads ignores settings changed meanwhile, and adapters were prepared for steps that `agent/pre-step` rejects. The getter was valid only inside a step and threw elsewhere, and every Agent implementation and test double had to carry it. -- **Record the route inside `agent/pre-step`.** `system-prompt/prepare` runs before that waterfall, and route consumers inside it would depend on listener order. -- **Register the model selection on the Agent without an event.** It adds a single-consumer method to the generic `Agent` interface, moves model-selection effort rules into the loop, and removes the listener path that supplies a route for agents created without one. -- **Keep the route decision in `agent/request` and predict it for earlier consumers.** A prediction diverges whenever a listener other than model selection changes the route. - -## Consequences - -- Tool conditions, prompt variables, reference budgets, and switch notices read the route that the request header records. -- `prepareCall()` stays adjacent to dispatch, so its adapter registration and connection snapshot cover only header logging and streaming. -- A step cannot fail over to another provider on retry; a policy that needs this must run routing, tool resolution, and assembly again for the retried attempt. -- The route cannot depend on `agent/pre-step` outcomes. Plan state commits inside that waterfall, so a plan-specific model would require committing plan selections before routing. -- `agent/pre-step` listeners and direct dispatchers must handle the required `route` field. - -## Related - -- [Provider-routed LLM adapters](2026-07-14-provider-routed-llm-adapters.md) owns provider and model fields in call configuration. -- [Reconstructable requests](2026-07-05-reconstructable-requests.md) owns request reconstruction from the logged header. -- [Prompt variables and tool guidance ownership](2026-07-05-prompt-variables-and-tool-guidance-ownership.md) owns the `{{model}}` variable. diff --git a/.agents/notes/implemented/architecture/2026-10-12-step-route-chosen-before-preparation.zh.md b/.agents/notes/implemented/architecture/2026-10-12-step-route-chosen-before-preparation.zh.md deleted file mode 100644 index 204039e9e7..0000000000 --- a/.agents/notes/implemented/architecture/2026-10-12-step-route-chosen-before-preparation.zh.md +++ /dev/null @@ -1,36 +0,0 @@ -# Agent Note: 在步骤准备之前选定步骤路由 - -Status: implemented - -[English](2026-10-12-step-route-chosen-before-preparation.md) | 中文 - -## 问题 - -工具是否可用可能取决于声明这些工具的请求所用的路由:`web_search` 不得出现在没有搜索凭据的路由上,计划或团队状态也可能撤下其他工具。消息准备同样需要这一路由:会话引用按模型的上下文窗口确定快照大小,模型选择会通告模型切换。循环原先在 `agent/pre-step` 之前组装工具与提示词段落,直到 `step/start` 之后才在 `agent/request` 中决定路由,因此更早的消费方只能依据模型选择状态预测路由。若监听器在 `agent/request` 中(包括在重试时)替换路由,已组装的提示词与工具目录就会描述另一个模型。 - -## 决策 - -提供方与模型是步骤级事实。领取输入之后,循环为每个拟议步骤派发一次 `agent/route`。其默认值在本循环记录请求之前取 agent 选项中的路由,之后取已记录请求头中的路由;`installModelSelection` 在这里捕获其选择。循环把得到的 `StepRoute` 作为 `AssembleContext.route` 传给 `system-prompt/prepare`,作为 `payload.route` 传给 `agent/pre-step`,作为 `ToolActivationContext.route` 传给工具条件。提示词变量 `{{provider}}` 与 `{{model}}` 读取该路由。 - -`agent/pre-step` 接纳步骤且 `step/start` 打开步骤之后,循环解析条件工具并组装提示词,各执行一次。之后每次请求尝试都在准入之前紧接着运行 `agent/request` 与 `prepareCall()`。`agent/request` 可以修改调用控制参数;返回其他提供方或模型会使步骤失败。重试复用该步骤的路由、工具集合与已渲染组装结果,步骤拥有的工具集合在该步骤的工具批次结束后关闭。步骤进行中做出的选择从下一个步骤开始生效。 - -## 考虑过的替代方案 - -- **在 `agent/pre-step` 之前通过 `agent/request` 绑定路由,并提供 `Agent.currentRoute()`。** 已准备调用会捕获适配器注册与连接设置,因此在压缩、钩子与会话读取期间一直持有它,会忽略期间修改的设置;被 `agent/pre-step` 拒绝的步骤也会准备适配器。该 getter 只在步骤内有效、在其他时刻抛错,且每个 Agent 实现与测试替身都必须提供它。 -- **在 `agent/pre-step` 内记录路由。** `system-prompt/prepare` 在该 waterfall 之前运行,而 waterfall 内的路由消费方将依赖监听器顺序。 -- **不使用事件,直接在 Agent 上登记模型选择。** 这会给通用 `Agent` 接口增加只有单一消费方的方法,把模型选择的推理强度规则移入循环,并移除为创建时没有路由的 agent 提供路由的监听器途径。 -- **路由决定留在 `agent/request`,为更早的消费方预测路由。** 只要模型选择之外的监听器修改路由,预测就会偏离。 - -## 后果 - -- 工具条件、提示词变量、引用预算与切换通告读取的路由,与请求头记录的路由一致。 -- `prepareCall()` 紧邻分发,因此其适配器注册与连接快照只覆盖请求头记录与流式请求。 -- 步骤无法在重试时切换到其他提供方;需要这种能力的策略必须为重试的尝试重新执行路由选择、工具解析与组装。 -- 路由不能依赖 `agent/pre-step` 的结果。计划状态在该 waterfall 内提交,因此按计划模式选择模型需要先在路由选择之前提交计划选择。 -- `agent/pre-step` 监听器与直接派发方必须处理必填的 `route` 字段。 - -## 相关 - -- [基于提供方路由的 LLM 适配器](2026-07-14-provider-routed-llm-adapters.zh.md)负责调用配置中的提供方与模型字段。 -- [可重建请求](2026-07-05-reconstructable-requests.zh.md)负责依据已记录请求头重建请求。 -- [提示词变量与工具指导归属](2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md)负责 `{{model}}` 变量。 diff --git a/.agents/notes/proposed/feature/2026-10-03-pi-extensions-bridge.i18n.yaml b/.agents/notes/proposed/feature/2026-10-03-pi-extensions-bridge.i18n.yaml index 9ab1268936..e5b7e4e9d5 100644 --- a/.agents/notes/proposed/feature/2026-10-03-pi-extensions-bridge.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-10-03-pi-extensions-bridge.i18n.yaml @@ -18,8 +18,8 @@ en: d95ece799bc6bab7 zh: 1e19d3b49473a57e /agent-note-pi-extensions-bridge/proposal/serving-the-api: - en: 2cb8b4ce6c74d8b5 - zh: d66d52f966310dc4 + en: bebdef0423773026 + zh: 8ee758d07afee560 /agent-note-pi-extensions-bridge/proposal/built-in-tool-calls-as-pi-sees-them: en: c74e4662ad0a8ee7 zh: 653729b41e983b13 diff --git a/.agents/notes/proposed/feature/2026-10-03-pi-extensions-bridge.md b/.agents/notes/proposed/feature/2026-10-03-pi-extensions-bridge.md index 4ba7f9685d..03cdf1c2f3 100644 --- a/.agents/notes/proposed/feature/2026-10-03-pi-extensions-bridge.md +++ b/.agents/notes/proposed/feature/2026-10-03-pi-extensions-bridge.md @@ -42,7 +42,7 @@ Add an experimental bridge that loads unmodified Pi extension sources and serves | `sendUserMessage`, `sendMessage` | `agent.followup()`, `agent.steer()`, `agent.inject()` | | `appendEntry`, `setLabel`, tool `details`, `ctx.sessionManager` | Ignorable Session records and `tool/result` metadata, read back as Pi entries | | `getActiveTools`, `setActiveTools`, `getAllTools` | `ctx.tools.schemas()`; `setActiveTools` applies a scoped `ctx.tools.restrict()` to global tools and adds or removes the bridge's own Agent-scoped registrations, because a restriction does not hide scoped tools | -| `setModel`, `getThinkingLevel`, `setThinkingLevel` | An `agent/route` override for the model and an `agent/request` override for the thinking level | +| `setModel`, `getThinkingLevel`, `setThinkingLevel` | An `agent/model` override for the model and thinking level | | `exec` | `ctx.subprocess`, argv without a shell | | `ctx.ui.select`, `confirm`, `input`, `editor` | `ctx.userQuestions` | | `ctx.ui.notify`, `setStatus`, `setWidget`, `setTitle`, `setEditorText` | A Remote stream the Web package renders | diff --git a/.agents/notes/proposed/feature/2026-10-03-pi-extensions-bridge.zh.md b/.agents/notes/proposed/feature/2026-10-03-pi-extensions-bridge.zh.md index cccc9eec25..49ca8a03f4 100644 --- a/.agents/notes/proposed/feature/2026-10-03-pi-extensions-bridge.zh.md +++ b/.agents/notes/proposed/feature/2026-10-03-pi-extensions-bridge.zh.md @@ -42,7 +42,7 @@ DSH 已为另外两个生态提供桥接:为 Claude Code 与 Codex 编写的 s | `sendUserMessage`、`sendMessage` | `agent.followup()`、`agent.steer()`、`agent.inject()` | | `appendEntry`、`setLabel`、工具 `details`、`ctx.sessionManager` | ignorable Session 记录与 `tool/result` 元数据,读回时还原为 Pi 条目 | | `getActiveTools`、`setActiveTools`、`getAllTools` | `ctx.tools.schemas()`;`setActiveTools` 对全局工具使用作用域内的 `ctx.tools.restrict()`,并增删桥接自己在 Agent 作用域的注册,因为限制不会隐藏作用域内的工具 | -| `setModel`、`getThinkingLevel`、`setThinkingLevel` | `agent/route` 覆盖模型,`agent/request` 覆盖思考等级 | +| `setModel`、`getThinkingLevel`、`setThinkingLevel` | `agent/model` 覆盖模型与思考等级 | | `exec` | `ctx.subprocess`,不经 shell 的 argv | | `ctx.ui.select`、`confirm`、`input`、`editor` | `ctx.userQuestions` | | `ctx.ui.notify`、`setStatus`、`setWidget`、`setTitle`、`setEditorText` | 由 Web 包渲染的 Remote 流 | diff --git a/apps/cli/tests/fixtures/dsh-badge/snapshot.ts b/apps/cli/tests/fixtures/dsh-badge/snapshot.ts index f9e58f262f..f6674047bf 100644 --- a/apps/cli/tests/fixtures/dsh-badge/snapshot.ts +++ b/apps/cli/tests/fixtures/dsh-badge/snapshot.ts @@ -43,7 +43,7 @@ try { } const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - { messages: [], turn: 1, step: 1, route: { provider: 'test', model: 'test' }, signal: new AbortController().signal }, + { messages: [], turn: 1, step: 1, modelSelection: { provider: 'test', model: 'test' }, signal: new AbortController().signal }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), ) const catalog = decision.kind === 'enter' diff --git a/docs/agent-lifecycle.i18n.yaml b/docs/agent-lifecycle.i18n.yaml index 1d465b17fa..0eaf4b0884 100644 --- a/docs/agent-lifecycle.i18n.yaml +++ b/docs/agent-lifecycle.i18n.yaml @@ -6,5 +6,5 @@ en: 41d772192075e133 zh: 305a6826a598b96d /agent-turn-and-step-lifecycle: - en: ee62762f177bb320 - zh: 271f74cbacc86d98 + en: ebc39fdd0332ae21 + zh: a4fc5664fea5d025 diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 28815a0372..b1d099df0b 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -25,17 +25,17 @@ sequenceDiagram Note over Agent,Driver: claim pending next-step input plus one queued prompt Driver-->>SDK: agent/inbox/spliced pure deletion Driver-->>SDK: agent/inbox/claimed { message, turn } per message - Driver->>Hooks: agent/route waterfall chooses the step route - Driver->>Prompt: system-prompt/prepare waterfall on the step route - Driver->>Hooks: agent/pre-step waterfall with the step route + Driver->>Hooks: agent/model waterfall chooses the step provider, model, and effort + Driver->>Prompt: system-prompt/prepare waterfall with the step model selection + Driver->>Hooks: agent/pre-step waterfall with the step model selection Hooks-->>Driver: authoritative reject or enter(messages) alt proposed step rejected, first batch empty, or pre-step failed Driver-->>Driver: claimed batch stays removed, the open turn spends no step else enter proposed step Driver->>Session: step/start - Driver->>Tools: resolve({ agent, route, signal }) once per step + Driver->>Tools: resolve({ agent, modelSelection, signal }) once per step Driver->>Prompt: system-prompt/assemble with the step tool set - Driver->>Hooks: agent/request waterfall on the step route + Driver->>Hooks: agent/request waterfall with the step model selection Driver->>LLM: prepareCall(config, signal) Note over Driver,LLM: cancellation during preparation commits neither system nor users Note over Driver,Session: synchronous admission using the prepared call capability @@ -51,7 +51,7 @@ sequenceDiagram Driver-->>SDK: agent/assistant-stream committed end Driver->>Hooks: agent/request-error waterfall Hooks-->>Driver: return retry action or preserve the original error - Note over Driver,LLM: retry in the open step: request and prepare again on the same route, tool set, and prompt without repeating pre-step or users + Note over Driver,LLM: retry in the open step: request and prepare again with the same model selection, tool set, and prompt without repeating pre-step or users else model request succeeded Driver->>Session: assistant/message Driver-->>SDK: agent/assistant-stream committed end @@ -75,7 +75,7 @@ sequenceDiagram opt next-step input is pending Driver-->>Driver: claim pending next-step input Driver-->>SDK: agent/inbox/claimed { message, turn } per message - Driver->>Hooks: agent/route waterfall + Driver->>Hooks: agent/model waterfall Driver->>Prompt: system-prompt/prepare waterfall Driver->>Hooks: agent/pre-step waterfall Hooks-->>Driver: authoritative reject or enter(messages) @@ -88,7 +88,7 @@ sequenceDiagram The `assistant/message` event records every successful provider call, including content-less and `max-tokens` finishes, and embeds the exact compact timed stream. Empty content stays out of derived history. A failed, retried, cancelled, or stream-error attempt that reaches settlement without a surface message records its stream as `assistant/attempt`. Live `agent/assistant-stream` chunk frames are transient; replay reads either durable settlement, and a hard process loss before settlement leaves no durable attempt stream. -`dsh-compaction-basic` uses `agent/pre-step` for pressure before request derivation and `agent/request-error` only for canonical context overflow. Once either trigger qualifies, optional tool-result pruning runs before summary selection. Recovery runs within the open step and retries only when pruning or summarization advances the surface replacement generation; otherwise the original request error remains authoritative. Each retry prepares its call on the step route and reconciles the retained rendered assembly before request derivation, without repeating routing, tool resolution, assembly, pre-step, or user admission. +`dsh-compaction-basic` uses `agent/pre-step` for pressure before request derivation and `agent/request-error` only for canonical context overflow. Once either trigger qualifies, optional tool-result pruning runs before summary selection. Recovery runs within the open step and retries only when pruning or summarization advances the surface replacement generation; otherwise the original request error remains authoritative. Each retry prepares its call with the step model selection and reconciles the retained rendered assembly before request derivation, without repeating model selection, tool resolution, assembly, pre-step, or user admission. The returned `agent/pre-step` decision is authoritative; listeners wrapping `next()` preserve downstream messages and `startsRequestSeries` unless replacement is intentional. Steering and injected context pass through the same waterfall after a later claim operation takes their next-step batch. diff --git a/docs/agent-lifecycle.zh.md b/docs/agent-lifecycle.zh.md index c1e2b942ab..ebb6dbec73 100644 --- a/docs/agent-lifecycle.zh.md +++ b/docs/agent-lifecycle.zh.md @@ -27,17 +27,17 @@ sequenceDiagram Note over Agent,Driver: claim pending next-step input plus one queued prompt Driver-->>SDK: agent/inbox/spliced pure deletion Driver-->>SDK: agent/inbox/claimed { message, turn } per message - Driver->>Hooks: agent/route waterfall chooses the step route - Driver->>Prompt: system-prompt/prepare waterfall on the step route - Driver->>Hooks: agent/pre-step waterfall with the step route + Driver->>Hooks: agent/model waterfall chooses the step provider, model, and effort + Driver->>Prompt: system-prompt/prepare waterfall with the step model selection + Driver->>Hooks: agent/pre-step waterfall with the step model selection Hooks-->>Driver: authoritative reject or enter(messages) alt proposed step rejected, first batch empty, or pre-step failed Driver-->>Driver: claimed batch stays removed, the open turn spends no step else enter proposed step Driver->>Session: step/start - Driver->>Tools: resolve({ agent, route, signal }) once per step + Driver->>Tools: resolve({ agent, modelSelection, signal }) once per step Driver->>Prompt: system-prompt/assemble with the step tool set - Driver->>Hooks: agent/request waterfall on the step route + Driver->>Hooks: agent/request waterfall with the step model selection Driver->>LLM: prepareCall(config, signal) Note over Driver,LLM: cancellation during preparation commits neither system nor users Note over Driver,Session: synchronous admission using the prepared call capability @@ -53,7 +53,7 @@ sequenceDiagram Driver-->>SDK: agent/assistant-stream committed end Driver->>Hooks: agent/request-error waterfall Hooks-->>Driver: return retry action or preserve the original error - Note over Driver,LLM: retry in the open step: request and prepare again on the same route, tool set, and prompt without repeating pre-step or users + Note over Driver,LLM: retry in the open step: request and prepare again with the same model selection, tool set, and prompt without repeating pre-step or users else model request succeeded Driver->>Session: assistant/message Driver-->>SDK: agent/assistant-stream committed end @@ -77,7 +77,7 @@ sequenceDiagram opt next-step input is pending Driver-->>Driver: claim pending next-step input Driver-->>SDK: agent/inbox/claimed { message, turn } per message - Driver->>Hooks: agent/route waterfall + Driver->>Hooks: agent/model waterfall Driver->>Prompt: system-prompt/prepare waterfall Driver->>Hooks: agent/pre-step waterfall Hooks-->>Driver: authoritative reject or enter(messages) @@ -90,7 +90,7 @@ sequenceDiagram `assistant/message` 事件会记录每次成功的提供方调用,包括返回空内容或以 `max-tokens` 结束的调用,并嵌入精确的紧凑带时间 stream。空内容不会进入派生历史。失败、重试、取消或 stream error attempt 到达 settlement 时,如果没有 surface message,就会把 stream 记录为 `assistant/attempt`。实时 `agent/assistant-stream` chunk frame 是瞬态数据;回放读取任一种持久 settlement,如果进程在 settlement 前硬中断,则不会留下持久 attempt stream。 -`dsh-compaction-basic` 在派生请求之前通过 `agent/pre-step` 处理压力,而 `agent/request-error` 仅用于规范的上下文溢出。任一触发条件满足后,系统都会先执行可选的工具结果剪枝,再选择摘要。恢复发生在仍打开的步骤内,只有剪枝或摘要生成推进 surface replacement generation 时才重试,否则仍以原始请求错误为准。每次重试都会在该步骤的路由上准备调用,并在派生请求之前协调保留的已渲染组装结果,不重复路由选择、工具解析、组装、pre-step 或用户消息准入。 +`dsh-compaction-basic` 在派生请求之前通过 `agent/pre-step` 处理压力,而 `agent/request-error` 仅用于规范的上下文溢出。任一触发条件满足后,系统都会先执行可选的工具结果剪枝,再选择摘要。恢复发生在仍打开的步骤内,只有剪枝或摘要生成推进 surface replacement generation 时才重试,否则仍以原始请求错误为准。每次重试都会按该步骤的模型选择准备调用,并在派生请求之前协调保留的已渲染组装结果,不重复模型选择、工具解析、组装、pre-step 或用户消息准入。 以返回的 `agent/pre-step` 决策为准;通过包装 `next()` 的监听器会保留下游消息与 `startsRequestSeries`,除非有意替换。steering(中途引导)和注入的上下文在后续的认领操作取得其下一步骤批次后,会经过同一 waterfall(瀑布式事件)。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index e865273013..e3e6bbdaa4 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -24,8 +24,8 @@ en: e89802f066e4955d zh: ea2fb4bef94e086d /deepseek-harness-architecture/turn-flow: - en: 14dc97ef8cc7458c - zh: c6fbb5885080f8c6 + en: 5001f8affe8d84f0 + zh: de2632709bb4e93a /deepseek-harness-architecture/session-log: en: a2e35c43438fec6d zh: e27bdac8e9feff32 diff --git a/docs/architecture.md b/docs/architecture.md index 5feeb8c3ba..3c120ed3b9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -83,7 +83,7 @@ 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 - agent/route -> step provider/model + agent/model -> step provider/model/effort prepare variables + runtime context -> agent/pre-step; project runtime context at fallback reject | enter(messages, startsRequestSeries?) @@ -105,11 +105,11 @@ turn/start turn/end ``` -`turn/*`, `step/*`, `system/message`, `user/message`, `assistant/message`, `assistant/attempt`, and `tool/*` are durable session events; the rest are live extension points across three domains. `agent/assistant-stream` publishes process-local start, transient chunk, and end frames. The loop commits the complete compact stream as one message or log-only attempt before a committed end frame, and the Web Session-follow adapter is the live event's only remote consumer. `agent/route`, `agent/pre-step`, `agent/request`, `llm/stream`, and the three `tools/*` events are waterfalls, whose listeners must call `next()` to delegate; `agent/turn-stopping` is serial and has no `next()`. +`turn/*`, `step/*`, `system/message`, `user/message`, `assistant/message`, `assistant/attempt`, and `tool/*` are durable session events; the rest are live extension points across three domains. `agent/assistant-stream` publishes process-local start, transient chunk, and end frames. The loop commits the complete compact stream as one message or log-only attempt before a committed end frame, and the Web Session-follow adapter is the live event's only remote consumer. `agent/model`, `agent/pre-step`, `agent/request`, `llm/stream`, and the three `tools/*` events are waterfalls, whose listeners must call `next()` to delegate; `agent/turn-stopping` is serial and has no `next()`. One inbox feeds the driver; injected context waits for a waking message. AgentLoop’s durable `inbox` projection exposes pending input without live Agents. -`agent/pre-step` decides the accepted input. Listeners may rewrite or reject claimed messages; a rejected or empty first claim closes a durable turn without a step. An enter decision may set `startsRequestSeries`: the loop logs a fresh `request/header` (reason `series`, or `change` with `startsSeries: true` when the envelope also changed). Wrapping listeners preserve that declaration with `{ ...decision, messages }`. `agent/route` fixes the step's provider and model before `agent/pre-step`; accepted steps resolve tools and assemble once. Each attempt's `agent/request` may change only call controls, and `prepareCall()` binds the adapter before prompt and user admission; cancellation in either commits neither. Prepared call capabilities govern prompt admission, not preceding `request/context`. Each attempt refreshes runtime facts, reconciles the prompt, admits users once, restores removed context, logs headers, and freezes the request before streaming; retries reuse the route, tools, and assembly. Surface replacements and image-offload decisions after attachment start a new request series, including during the first resumed pre-step; unchanged resume continues the series. The first admitted step reserves the system head before user messages even for an empty prompt (no wire message). The prompt travels only as `system/message` history: an empty rendering clears all active system nodes, leaving no old prompt model-visible; capable routes append non-empty updates after cached history, including supported tool updates; incapable routes and new request series consolidate non-empty prompt text at the first system node, with logged empty replacements for non-empty later system nodes ([decision](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md); [decision rule](../packages/core/agent-loop/README.md#understand-the-implementation)). +`agent/pre-step` decides the accepted input. Listeners may rewrite or reject claimed messages; a rejected or empty first claim closes a durable turn without a step. An enter decision may set `startsRequestSeries`: the loop logs a fresh `request/header` (reason `series`, or `change` with `startsSeries: true` when the envelope also changed). Wrapping listeners preserve that declaration with `{ ...decision, messages }`. `agent/model` fixes the step's provider, model, and effort before `agent/pre-step`; accepted steps resolve tools and assemble once. Each attempt's `agent/request` may change only sampling, and `prepareCall()` binds the adapter before prompt and user admission; cancellation in either commits neither. Prepared call capabilities govern prompt admission, not preceding `request/context`. Each attempt refreshes runtime facts, reconciles the prompt, admits users once, restores removed context, logs headers, and freezes the request before streaming; retries reuse the selection, tools, and assembly. Surface replacements and image-offload decisions after attachment start a new request series, including during the first resumed pre-step; unchanged resume continues the series. The first admitted step reserves the system head before user messages even for an empty prompt (no wire message). The prompt travels only as `system/message` history: an empty rendering clears all active system nodes, leaving no old prompt model-visible; capable routes append non-empty updates after cached history, including supported tool updates; incapable routes and new request series consolidate non-empty prompt text at the first system node, with logged empty replacements for non-empty later system nodes ([decision](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md); [decision rule](../packages/core/agent-loop/README.md#understand-the-implementation)). The loop sends immutable requests with live cancellation and reuses freeze evidence for frozen identities; [agent-loop](../packages/core/agent-loop/README.md) owns construction and cancellation causes. diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index ace6edf25b..1d8e3718b9 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -87,7 +87,7 @@ AgentLoop 在启动已排队工作前等待串行 `agent/created`,初始化失 ```text turn/start claim next-step input plus one queued message - agent/route -> step provider/model + agent/model -> step provider/model/effort prepare variables + runtime context -> agent/pre-step; project runtime context at fallback reject | enter(messages, startsRequestSeries?) @@ -109,11 +109,11 @@ turn/start turn/end ``` -`turn/*`、`step/*`、`system/message`、`user/message`、`assistant/message`、`assistant/attempt` 和 `tool/*` 是持久会话事件;其余是分属三个事件域的实时扩展点。`agent/assistant-stream` 发布进程本地 start、瞬态 chunk 与 end frame。loop 会在 committed end frame 前把完整紧凑 stream 提交为一个 message 或仅日志 attempt;Web Session-follow adapter 是该 live event 唯一的远程消费方。`agent/route`、`agent/pre-step`、`agent/request`、`llm/stream` 和三个 `tools/*` 事件是 waterfall(瀑布式事件),其监听器必须调用 `next()` 才能委托下去;`agent/turn-stopping` 是 serial 事件,没有 `next()`。 +`turn/*`、`step/*`、`system/message`、`user/message`、`assistant/message`、`assistant/attempt` 和 `tool/*` 是持久会话事件;其余是分属三个事件域的实时扩展点。`agent/assistant-stream` 发布进程本地 start、瞬态 chunk 与 end frame。loop 会在 committed end frame 前把完整紧凑 stream 提交为一个 message 或仅日志 attempt;Web Session-follow adapter 是该 live event 唯一的远程消费方。`agent/model`、`agent/pre-step`、`agent/request`、`llm/stream` 和三个 `tools/*` 事件是 waterfall(瀑布式事件),其监听器必须调用 `next()` 才能委托下去;`agent/turn-stopping` 是 serial 事件,没有 `next()`。 输入通过同一个 inbox 到达驱动器;注入的上下文等待一条唤醒消息。AgentLoop 的持久 `inbox` 投影使待处理输入在没有活跃 Agent 时仍可读取。 -`agent/pre-step` 决定接纳的输入。监听器可以改写或拒绝已领取消息;首次领取被拒绝或为空时,关闭不含步骤的持久轮次。enter 决策可设置 `startsRequestSeries`:循环记录新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。包装监听器通过 `{ ...decision, messages }` 保留该声明。`agent/route` 在 `agent/pre-step` 之前固定该步骤的提供方与模型;被接纳的步骤随后解析工具并组装一次。每次尝试的 `agent/request` 只能修改调用控制参数,`prepareCall()` 在提示词与用户消息准入前绑定适配器;在任一阶段取消都不会提交这两者。提示词准入依据已准备调用的能力,而非先前的 `request/context`。每次尝试刷新已注册运行时事实、协调绑定的提示词、仅接纳一次用户消息并恢复被移除的运行时上下文、按需记录 header/context,再冻结请求并通过绑定调用发起流式请求;重试复用路由、工具与组装结果。附接后的 surface 替换和图片省略决定开启新请求序列,包括恢复后的首次 pre-step 中发生的替换;未变化的恢复延续序列。首次接纳的步骤在用户消息之前预留系统头节点,即使提示词为空(不产生协议消息)。提示词仅通过 `system/message` 历史传递:空渲染文本清除所有生效的系统节点,模型不再看到旧提示词;具备能力的路由可在缓存前缀之后追加非空更新,包括同时发生的受支持工具更新;不具备能力的路由与新请求序列将非空提示词文本归并到首个系统节点,并为非空的后续系统节点记录空内容替换([决策](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md);[决策规则](../packages/core/agent-loop/README.zh.md#understand-the-implementation))。 +`agent/pre-step` 决定接纳的输入。监听器可以改写或拒绝已领取消息;首次领取被拒绝或为空时,关闭不含步骤的持久轮次。enter 决策可设置 `startsRequestSeries`:循环记录新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。包装监听器通过 `{ ...decision, messages }` 保留该声明。`agent/model` 在 `agent/pre-step` 之前固定该步骤的提供方、模型与推理强度;被接纳的步骤随后解析工具并组装一次。每次尝试的 `agent/request` 只能修改采样控制参数,`prepareCall()` 在提示词与用户消息准入前绑定适配器;在任一阶段取消都不会提交这两者。提示词准入依据已准备调用的能力,而非先前的 `request/context`。每次尝试刷新已注册运行时事实、协调绑定的提示词、仅接纳一次用户消息并恢复被移除的运行时上下文、按需记录 header/context,再冻结请求并通过绑定调用发起流式请求;重试复用模型选择、工具与组装结果。附接后的 surface 替换和图片省略决定开启新请求序列,包括恢复后的首次 pre-step 中发生的替换;未变化的恢复延续序列。首次接纳的步骤在用户消息之前预留系统头节点,即使提示词为空(不产生协议消息)。提示词仅通过 `system/message` 历史传递:空渲染文本清除所有生效的系统节点,模型不再看到旧提示词;具备能力的路由可在缓存前缀之后追加非空更新,包括同时发生的受支持工具更新;不具备能力的路由与新请求序列将非空提示词文本归并到首个系统节点,并为非空的后续系统节点记录空内容替换([决策](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md);[决策规则](../packages/core/agent-loop/README.zh.md#understand-the-implementation))。 循环发送不可变请求,同时保持取消有效,仅对完全冻结的对象复用冻结证据;[agent-loop](../packages/core/agent-loop/README.zh.md) 负责请求构建与取消原因。 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index a7736702b5..ac302aefdd 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -10,19 +10,19 @@ This matrix shows which packages dispatch each harness-owned event and which pac | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:249`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:69`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | `remotes` | -| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:394`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | -| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:271`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:424`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry), [`subagent`](../packages/subagent/subagent) | -| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:309`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) | -| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:317`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) | -| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:298`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:331`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:368`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/experimental/webhook) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:384`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/route` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:347`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | -| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:412`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:399`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | +| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:273`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:282`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:429`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry), [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:311`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) | +| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:319`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) | +| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:300`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | +| `agent/model` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:352`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/experimental/webhook) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:333`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:373`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:389`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:292`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:417`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:624`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:604`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:631`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index e5dc43c2b2..cb221a48b9 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -12,19 +12,19 @@ | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:249`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:69`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | `remotes` | -| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:394`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | -| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:271`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:424`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry), [`subagent`](../packages/subagent/subagent) | -| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:309`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) | -| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:317`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) | -| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:298`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:331`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:368`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/experimental/webhook) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:384`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/route` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:347`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | -| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:412`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:399`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | +| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:273`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:282`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:429`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry), [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:311`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) | +| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:319`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) | +| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:300`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | +| `agent/model` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:352`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/experimental/webhook) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:333`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:373`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:389`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:292`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:417`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:624`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:604`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:631`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 3111071a28..35c730cee9 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -12,14 +12,14 @@ en: ab09a38b4e1f0883 zh: 81e2d14c4036ab6c /core/the-agent-handle: - en: 100e13484382eb7a - zh: 6481f8d4f84fc93a + en: fbcbf559b0362d11 + zh: e2cc3f4e28f23687 /core/initiating-agent: en: 6c8c47ac78de2dde zh: c9d527a25a85d1d9 /core/interception-decisions: - en: f4fdc520107dd17b - zh: a9b2f55e50b6279f + en: f50b0e1933657f39 + zh: 961a69f1247951df /core/sessions: en: 9a38f693a00fa80d zh: d0f66664ac85ae64 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index c045e95349..34fc5d394d 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -61,7 +61,7 @@ Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types interface Agent { /** Session-backed Agent identity. */ readonly id: SessionId - /** Creation-time request options; `agent/route` may choose another provider and model for each step. */ + /** Creation-time request options; `agent/model` may choose another provider, model, and effort for each step. */ readonly options: AgentOptions /** The live session this agent drives; its log is the durable source of truth. */ readonly session: Session @@ -206,17 +206,19 @@ interface AgentOptions { } ``` -Each step requires `provider` and `model` after `agent/route`, which may supply or replace the options pair. An explicit `reasoningEffort` seeds the first request on that route; exact-model resolution validates it, while omission allows the adapter default to materialize. When present, `maxTokens` must be a positive safe integer and caps every conversation-model request; omission allows the exact-model adapter default to materialize before the request header, or otherwise leaves provider behavior unchanged. An agent-scoped `deployment:persona-prefix` prompt section may shadow the global default persona. +Each step requires `provider` and `model` after `agent/model`, which may supply or replace the options' provider, model, and effort. An explicit `reasoningEffort` seeds the first step's selection for that model; exact-model resolution validates it, while omission allows the adapter default to materialize. When present, `maxTokens` must be a positive safe integer and caps every conversation-model request; omission allows the exact-model adapter default to materialize before the request header, or otherwise leaves provider behavior unchanged. An agent-scoped `deployment:persona-prefix` prompt section may shadow the global default persona. -The step keeps the chosen route for prompt preparation, `agent/pre-step`, tool conditions, and every request attempt; `agent/request` may change only call controls: +The step keeps its model selection for prompt preparation, `agent/pre-step`, tool conditions, and every request attempt; `agent/request` may change only sampling controls such as `maxTokens`, `temperature`, and `stop`: ```ts type-equiv -/** Provider and model that `agent/route` chose for one step and every request attempt in it. */ -interface StepRoute { +/** Complete provider, model, and optional reasoning effort; `agent/model` fixes one for each step. */ +interface ModelSelection { /** Registered provider route. */ - readonly provider: string - /** Model id interpreted by the provider adapter. */ - readonly model: string + provider: string + /** Provider-owned model id. */ + model: string + /** Adapter-owned reasoning effort, or provider/default behavior when absent. */ + reasoningEffort?: ReasoningEffortId } ``` @@ -323,7 +325,7 @@ Pre-step decisions use the same identified `UserMessage` type as durable user-ro Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) -`agent/pre-step` receives one payload carrying the exclusive claimed batch (`messages`), the proposed step's coordinates (`turn`, `step`), its `route`, and the current turn's cancellation `signal`. The initial proposal runs inside an open turn before any step; a tool continuation may submit an empty claimed batch between steps: +`agent/pre-step` receives one payload carrying the exclusive claimed batch (`messages`), the proposed step's coordinates (`turn`, `step`), its `modelSelection`, and the current turn's cancellation `signal`. The initial proposal runs inside an open turn before any step; a tool continuation may submit an empty claimed batch between steps: It returns a `PreStepDecision`. Reject opens no step. Enter supplies the complete message batch appended after `step/start`; claimed messages omitted by the final decision remain removed, while input inserted after the claim stays pending: @@ -937,6 +939,38 @@ Types: [Scoped](scope.md) · [UserMessage](session.md) Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts) + + +#### `agent/model` — waterfall + +Choose the provider, model, and reasoning effort for one proposed step. `await next()` yields the selection the machine would use: agent options before this loop logs a request, the logged header afterwards, with an effort only when one was chosen for that exact model. Return a replacement to switch; an effort belongs to its model, so a replacement that changes the model names that model's effort or omits it for the provider default. The loop runs this once per proposed step, before context preparation and `agent/pre-step`. Prompt preparation, tool conditions, and every request attempt in the step use the result, so a later choice applies from the next step. Cancellation here opens no step. + +```ts cordis-catalog +/** + * Choose the provider, model, and reasoning effort for one proposed step. + * `await next()` yields the selection the machine would use: agent options + * before this loop logs a request, the logged header afterwards, with an + * effort only when one was chosen for that exact model. Return a + * replacement to switch; an effort belongs to its model, so a replacement + * that changes the model names that model's effort or omits it for the + * provider default. The loop runs this once per proposed step, before + * context preparation and `agent/pre-step`. Prompt preparation, tool + * conditions, and every request attempt in the step use the result, so a + * later choice applies from the next step. Cancellation here opens no step. + * @param payload.agent - the agent proposing the step. + * @param payload.turn - the turn that will own the step. + * @param payload.step - the step proposed by the loop. + * @param payload.signal - the current turn's explicit abort signal. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode waterfall + */ +'agent/model'(this: Scoped, payload: { agent: Agent; turn: number; step: number; signal: AbortSignal }, next: () => Promise): Promise +``` + +Types: [Scoped](scope.md) + +Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts) + #### `agent/pre-step` — waterfall @@ -951,12 +985,12 @@ Reject a proposed step or replace the messages that enter it. Calling `next()` p * @param payload.messages - messages removed from the inbox for this step. * @param payload.turn - the turn that will own the step. * @param payload.step - the step proposed by the loop. - * @param payload.route - the provider and model `agent/route` chose for this step. + * @param payload.modelSelection - the provider, model, and effort `agent/model` chose for this step. * @param payload.signal - the current turn's cancellation signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ -'agent/pre-step'(this: Scoped, payload: { agent: Agent; messages: UserMessage[]; turn: number; step: number; route: StepRoute; signal: AbortSignal }, next: () => Promise): Promise +'agent/pre-step'(this: Scoped, payload: { agent: Agent; messages: UserMessage[]; turn: number; step: number; modelSelection: ModelSelection; signal: AbortSignal }, next: () => Promise): Promise ``` Types: [Scoped](scope.md) · [UserMessage](session.md) @@ -967,16 +1001,16 @@ Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/s #### `agent/request` — waterfall -Replace the frozen call configuration of one request attempt. `await next()` yields the config the machine would use on the step's `agent/route` result: agent options on the first request, the logged header afterwards, and no effort recorded for another model. Return a replacement to change call controls such as reasoning effort; returning another provider or model fails the step. On step admission, this runs after prompt assembly and `step/start`, before the system prompt and accepted user batch are committed, and again for each retry. Cancellation here or during subsequent `prepareCall()` resolution commits neither. The prepared call capability governs prompt admission. Model-visible content must use logged channels; this waterfall cannot mutate messages. +Replace the frozen call configuration of one request attempt. `await next()` yields the step's `agent/model` selection with the machine's other call controls: agent options on the first request, the logged header afterwards. Return a replacement to change sampling controls such as `maxTokens`, `temperature`, or `stop`; returning another provider, model, or reasoning effort fails the step. On step admission, this runs after prompt assembly and `step/start`, before the system prompt and accepted user batch are committed, and again for each retry. Cancellation here or during subsequent `prepareCall()` resolution commits neither. The prepared call capability governs prompt admission. Model-visible content must use logged channels; this waterfall cannot mutate messages. ```ts cordis-catalog /** * Replace the frozen call configuration of one request attempt. `await - * next()` yields the config the machine would use on the step's - * `agent/route` result: agent options on the first request, the logged - * header afterwards, and no effort recorded for another model. Return a - * replacement to change call controls such as reasoning effort; returning - * another provider or model fails the step. On step admission, this runs + * next()` yields the step's `agent/model` selection with the machine's + * other call controls: agent options on the first request, the logged + * header afterwards. Return a replacement to change sampling controls such + * as `maxTokens`, `temperature`, or `stop`; returning another provider, + * model, or reasoning effort fails the step. On step admission, this runs * after prompt assembly and `step/start`, before the system prompt and * accepted user batch are committed, and again for each retry. * Cancellation here or during subsequent `prepareCall()` resolution @@ -1026,35 +1060,6 @@ Types: [LlmFailure](llm-streaming.md) · [ResolvedRetryPolicy](llm-streaming.md) Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts) - - -#### `agent/route` — waterfall - -Choose the provider and model for one proposed step. `await next()` yields the route the machine would use (agent options before this loop logs a request, the logged header afterwards); return a replacement to switch. The loop runs this once per proposed step, before context preparation and `agent/pre-step`. Prompt preparation, tool conditions, and every request attempt in the step use the result, so a later choice applies from the next step. Cancellation here opens no step. - -```ts cordis-catalog -/** - * Choose the provider and model for one proposed step. `await next()` - * yields the route the machine would use (agent options before this loop - * logs a request, the logged header afterwards); return a replacement to - * switch. The loop runs this once per proposed step, before context - * preparation and `agent/pre-step`. Prompt preparation, tool conditions, - * and every request attempt in the step use the result, so a later choice - * applies from the next step. Cancellation here opens no step. - * @param payload.agent - the agent proposing the step. - * @param payload.turn - the turn that will own the step. - * @param payload.step - the step proposed by the loop. - * @param payload.signal - the current turn's explicit abort signal. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @mode waterfall - */ -'agent/route'(this: Scoped, payload: { agent: Agent; turn: number; step: number; signal: AbortSignal }, next: () => Promise): Promise -``` - -Types: [Scoped](scope.md) - -Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts) - #### `agent/status` — emit diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 96d65b5140..59c3b51e19 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -65,7 +65,7 @@ interface AgentHandle { interface Agent { /** Session-backed Agent identity. */ readonly id: SessionId - /** Creation-time request options; `agent/route` may choose another provider and model for each step. */ + /** Creation-time request options; `agent/model` may choose another provider, model, and effort for each step. */ readonly options: AgentOptions /** The live session this agent drives; its log is the durable source of truth. */ readonly session: Session @@ -210,17 +210,19 @@ interface AgentOptions { } ``` -每个步骤在 `agent/route` 之后都要求 `provider` 与 `model` 都存在;该 waterfall 可以补上或替换 options 中的这一对值。显式 `reasoningEffort` 会为该路由的首次请求提供初始值;确切模型解析会校验该值,省略时则允许填入适配器默认值。提供 `maxTokens` 时,它必须是正安全整数,并限制每次对话模型请求的输出;省略时,系统会在写入请求 header 前填入确切模型的适配器默认值,否则提供方行为保持不变。agent 作用域的 `deployment:persona-prefix` 提示词段落可以遮蔽全局默认 persona。 +每个步骤在 `agent/model` 之后都要求 `provider` 与 `model` 都存在;该 waterfall 可以补上或替换 options 中的提供方、模型与推理强度。显式 `reasoningEffort` 会为该模型的首个步骤选择提供初始值;确切模型解析会校验该值,省略时则允许填入适配器默认值。提供 `maxTokens` 时,它必须是正安全整数,并限制每次对话模型请求的输出;省略时,系统会在写入请求 header 前填入确切模型的适配器默认值,否则提供方行为保持不变。agent 作用域的 `deployment:persona-prefix` 提示词段落可以遮蔽全局默认 persona。 -步骤在提示词准备、`agent/pre-step`、工具条件以及每次请求尝试中都沿用所选路由;`agent/request` 只能修改调用控制参数: +步骤在提示词准备、`agent/pre-step`、工具条件以及每次请求尝试中都沿用其模型选择;`agent/request` 只能修改 `maxTokens`、`temperature`、`stop` 等采样控制参数: ```ts type-equiv -/** Provider and model that `agent/route` chose for one step and every request attempt in it. */ -interface StepRoute { +/** Complete provider, model, and optional reasoning effort; `agent/model` fixes one for each step. */ +interface ModelSelection { /** Registered provider route. */ - readonly provider: string - /** Model id interpreted by the provider adapter. */ - readonly model: string + provider: string + /** Provider-owned model id. */ + model: string + /** Adapter-owned reasoning effort, or provider/default behavior when absent. */ + reasoningEffort?: ReasoningEffortId } ``` @@ -331,7 +333,7 @@ pre-step 决策使用与持久 user-role 输入相同、带标识的 `UserMessag 源码:[`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) -`agent/pre-step` 接收一个 payload,携带独占的已领取批次(`messages`)、拟进入步骤的坐标(`turn`、`step`)、其路由 `route` 与当前轮次的取消 `signal`。首次提案在已打开的轮次内、任何步骤开始前运行;工具 continuation 可以在步骤之间提交空的已领取批次: +`agent/pre-step` 接收一个 payload,携带独占的已领取批次(`messages`)、拟进入步骤的坐标(`turn`、`step`)、其模型选择 `modelSelection` 与当前轮次的取消 `signal`。首次提案在已打开的轮次内、任何步骤开始前运行;工具 continuation 可以在步骤之间提交空的已领取批次: 它返回 `PreStepDecision`。reject 不会打开步骤。enter 提供在 `step/start` 后追加的完整消息批次;最终决策省略的已领取消息保持已删除,而领取后插入的输入仍留待后续处理: @@ -949,6 +951,38 @@ Types: [Scoped](scope.zh.md) · [UserMessage](session.zh.md) Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts) + + +#### `agent/model` — waterfall + +Choose the provider, model, and reasoning effort for one proposed step. `await next()` yields the selection the machine would use: agent options before this loop logs a request, the logged header afterwards, with an effort only when one was chosen for that exact model. Return a replacement to switch; an effort belongs to its model, so a replacement that changes the model names that model's effort or omits it for the provider default. The loop runs this once per proposed step, before context preparation and `agent/pre-step`. Prompt preparation, tool conditions, and every request attempt in the step use the result, so a later choice applies from the next step. Cancellation here opens no step. + +```ts cordis-catalog +/** + * Choose the provider, model, and reasoning effort for one proposed step. + * `await next()` yields the selection the machine would use: agent options + * before this loop logs a request, the logged header afterwards, with an + * effort only when one was chosen for that exact model. Return a + * replacement to switch; an effort belongs to its model, so a replacement + * that changes the model names that model's effort or omits it for the + * provider default. The loop runs this once per proposed step, before + * context preparation and `agent/pre-step`. Prompt preparation, tool + * conditions, and every request attempt in the step use the result, so a + * later choice applies from the next step. Cancellation here opens no step. + * @param payload.agent - the agent proposing the step. + * @param payload.turn - the turn that will own the step. + * @param payload.step - the step proposed by the loop. + * @param payload.signal - the current turn's explicit abort signal. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode waterfall + */ +'agent/model'(this: Scoped, payload: { agent: Agent; turn: number; step: number; signal: AbortSignal }, next: () => Promise): Promise +``` + +Types: [Scoped](scope.zh.md) + +Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts) + #### `agent/pre-step` — waterfall @@ -963,12 +997,12 @@ Reject a proposed step or replace the messages that enter it. Calling `next()` p * @param payload.messages - messages removed from the inbox for this step. * @param payload.turn - the turn that will own the step. * @param payload.step - the step proposed by the loop. - * @param payload.route - the provider and model `agent/route` chose for this step. + * @param payload.modelSelection - the provider, model, and effort `agent/model` chose for this step. * @param payload.signal - the current turn's cancellation signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ -'agent/pre-step'(this: Scoped, payload: { agent: Agent; messages: UserMessage[]; turn: number; step: number; route: StepRoute; signal: AbortSignal }, next: () => Promise): Promise +'agent/pre-step'(this: Scoped, payload: { agent: Agent; messages: UserMessage[]; turn: number; step: number; modelSelection: ModelSelection; signal: AbortSignal }, next: () => Promise): Promise ``` Types: [Scoped](scope.zh.md) · [UserMessage](session.zh.md) @@ -979,16 +1013,16 @@ Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/s #### `agent/request` — waterfall -Replace the frozen call configuration of one request attempt. `await next()` yields the config the machine would use on the step's `agent/route` result: agent options on the first request, the logged header afterwards, and no effort recorded for another model. Return a replacement to change call controls such as reasoning effort; returning another provider or model fails the step. On step admission, this runs after prompt assembly and `step/start`, before the system prompt and accepted user batch are committed, and again for each retry. Cancellation here or during subsequent `prepareCall()` resolution commits neither. The prepared call capability governs prompt admission. Model-visible content must use logged channels; this waterfall cannot mutate messages. +Replace the frozen call configuration of one request attempt. `await next()` yields the step's `agent/model` selection with the machine's other call controls: agent options on the first request, the logged header afterwards. Return a replacement to change sampling controls such as `maxTokens`, `temperature`, or `stop`; returning another provider, model, or reasoning effort fails the step. On step admission, this runs after prompt assembly and `step/start`, before the system prompt and accepted user batch are committed, and again for each retry. Cancellation here or during subsequent `prepareCall()` resolution commits neither. The prepared call capability governs prompt admission. Model-visible content must use logged channels; this waterfall cannot mutate messages. ```ts cordis-catalog /** * Replace the frozen call configuration of one request attempt. `await - * next()` yields the config the machine would use on the step's - * `agent/route` result: agent options on the first request, the logged - * header afterwards, and no effort recorded for another model. Return a - * replacement to change call controls such as reasoning effort; returning - * another provider or model fails the step. On step admission, this runs + * next()` yields the step's `agent/model` selection with the machine's + * other call controls: agent options on the first request, the logged + * header afterwards. Return a replacement to change sampling controls such + * as `maxTokens`, `temperature`, or `stop`; returning another provider, + * model, or reasoning effort fails the step. On step admission, this runs * after prompt assembly and `step/start`, before the system prompt and * accepted user batch are committed, and again for each retry. * Cancellation here or during subsequent `prepareCall()` resolution @@ -1038,35 +1072,6 @@ Types: [LlmFailure](llm-streaming.zh.md) · [ResolvedRetryPolicy](llm-streaming. Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts) - - -#### `agent/route` — waterfall - -Choose the provider and model for one proposed step. `await next()` yields the route the machine would use (agent options before this loop logs a request, the logged header afterwards); return a replacement to switch. The loop runs this once per proposed step, before context preparation and `agent/pre-step`. Prompt preparation, tool conditions, and every request attempt in the step use the result, so a later choice applies from the next step. Cancellation here opens no step. - -```ts cordis-catalog -/** - * Choose the provider and model for one proposed step. `await next()` - * yields the route the machine would use (agent options before this loop - * logs a request, the logged header afterwards); return a replacement to - * switch. The loop runs this once per proposed step, before context - * preparation and `agent/pre-step`. Prompt preparation, tool conditions, - * and every request attempt in the step use the result, so a later choice - * applies from the next step. Cancellation here opens no step. - * @param payload.agent - the agent proposing the step. - * @param payload.turn - the turn that will own the step. - * @param payload.step - the step proposed by the loop. - * @param payload.signal - the current turn's explicit abort signal. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @mode waterfall - */ -'agent/route'(this: Scoped, payload: { agent: Agent; turn: number; step: number; signal: AbortSignal }, next: () => Promise): Promise -``` - -Types: [Scoped](scope.zh.md) - -Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts) - #### `agent/status` — emit diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index 433c358122..7cf1e6afb7 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.i18n.yaml @@ -39,8 +39,8 @@ en: 824d18f76f990711 zh: 8a2895513652e528 /llm-streaming/the-model-request/the-request-envelope-llmcallconfig-and-the-logged-header: - en: 2c0198b61bf68192 - zh: d1380ba0acea3ee2 + en: d3b170f622848cd3 + zh: 2c4e998f499efd99 /llm-streaming/official-deepseek-request-extensions: en: 42bc50394b070e96 zh: 007caa743ec3c790 diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 8844d5ba3b..237b8edd1f 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -738,7 +738,7 @@ interface LlmDiscoveredModel { The loop builds each request from logged state. `EpochHeader` records call config, marks the fields supplied by adapter defaults, and records the authoritative returned tool order (configured by `toolOrder`, or lexicographic when unset) through full `request/header` snapshots. The rendered prompt is derived history — the `system/message` at surface node 0, plus any later system node an `in-history` route appended — so the header and the derived history together make the request reconstructable from the session log. See [session.md](session.md#the-request-header-event-requestheader) and the [reconstructability Agent Note](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md). -`agent/request` receives a frozen call-config seed and may return a replacement to change reasoning effort or sampling; provider and model come from `agent/route` and stay fixed for the step. Before the waterfall, the loop removes values marked as adapter defaults so exact-model preparation materializes the selected route's current values; unmarked explicit settings remain in the proposal. After the waterfall, preparation rejects unsupported explicit effort ids without clamping and logs the effective config plus the fields supplied by adapter defaults under the turn signal. On step admission, this waterfall and preparation run after assembly and `step/start` but before the system prompt and accepted user batch are committed; cancellation during either commits neither. The prepared capability governs prompt reconciliation, and the call keeps one adapter registration through dispatch. Requests reaching `llm/stream` are deep-frozen, so mutation throws, and carry a process-local loop identity so observers do not confuse separately logged frozen auxiliary calls with conversation requests. +`agent/request` receives a frozen call-config seed and may return a replacement to change sampling; provider, model, and reasoning effort come from `agent/model` and stay fixed for the step. Before both waterfalls, the loop removes values marked as adapter defaults so exact-model preparation materializes the selected route's current values; unmarked explicit settings remain in the proposal. After `agent/request`, preparation rejects unsupported explicit effort ids without clamping and logs the effective config plus the fields supplied by adapter defaults under the turn signal. On step admission, this waterfall and preparation run after assembly and `step/start` but before the system prompt and accepted user batch are committed; cancellation during either commits neither. The prepared capability governs prompt reconciliation, and the call keeps one adapter registration through dispatch. Requests reaching `llm/stream` are deep-frozen, so mutation throws, and carry a process-local loop identity so observers do not confuse separately logged frozen auxiliary calls with conversation requests. On the wire, a loop-built request is the derived history alone: the rendered prompt travels as the leading `system`-role message (surface node 0, a `system/message` event) and, when the prepared call declares `systemPromptUpdate: 'in-history'`, a non-empty changed prompt may follow the cached history as a later `system`-role message that the model reads as the effective prompt; the request's `system` field is unset — `GenerateOptions.system` serves direct one-shot callers such as title providers. An empty rendering leaves no system messages in derived history, even when earlier requests retained several prompt versions. The logged request ends with the newest `user/message` on a turn's first step and the previous step's tool results on later steps. diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index 500ca3f70e..65b02618b5 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -744,7 +744,7 @@ interface LlmDiscoveredModel { 循环从已记录状态构建每个请求。`EpochHeader` 记录调用配置,标记由适配器默认值提供的字段,并通过完整的 `request/header` 快照记录权威返回工具顺序(由 `toolOrder` 配置;未配置时按字典序)。渲染后的提示词是派生历史——surface 第 0 号节点上的 `system/message`,加上 `in-history` 路由追加的任何后续系统节点——因此请求头与派生历史共同使请求可由会话日志重建。见 [session.md](session.zh.md#the-request-header-event-requestheader) 与[可重建性 Agent Note](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md)。 -`agent/request` 接收冻结的调用配置种子,并可返回替代值以修改推理强度或采样参数;提供方与模型来自 `agent/route`,在整个步骤内保持不变。waterfall(瀑布式事件)开始前,循环会移除标记为适配器默认值的值,使确切模型准备过程填入所选路由的当前值;未带标记的显式设置仍保留在提议中。waterfall 结束后,准备过程会在轮次信号控制下拒绝显式指定但不受支持的推理强度 ID(不自动调整),并记录生效配置以及由适配器默认值提供的字段。步骤准入时,该 waterfall 与准备过程在组装和 `step/start` 之后、系统提示词与已接纳用户批次提交之前运行;在任一阶段取消都不会提交这两者。已准备调用的能力决定提示词协调,调用直至分派完成始终持有同一项适配器注册。到达 `llm/stream` 的请求会被深度冻结,因此变更会抛异常;请求还携带进程本地循环标识,使观察者不会把单独记录的冻结辅助调用误认成对话请求。 +`agent/request` 接收冻结的调用配置种子,并可返回替代值以修改采样参数;提供方、模型与推理强度来自 `agent/model`,在整个步骤内保持不变。这两个 waterfall(瀑布式事件)开始前,循环会移除标记为适配器默认值的值,使确切模型准备过程填入所选路由的当前值;未带标记的显式设置仍保留在提议中。`agent/request` 结束后,准备过程会在轮次信号控制下拒绝显式指定但不受支持的推理强度 ID(不自动调整),并记录生效配置以及由适配器默认值提供的字段。步骤准入时,该 waterfall 与准备过程在组装和 `step/start` 之后、系统提示词与已接纳用户批次提交之前运行;在任一阶段取消都不会提交这两者。已准备调用的能力决定提示词协调,调用直至分派完成始终持有同一项适配器注册。到达 `llm/stream` 的请求会被深度冻结,因此变更会抛异常;请求还携带进程本地循环标识,使观察者不会把单独记录的冻结辅助调用误认成对话请求。 在协议中,循环构建的请求只有派生历史:渲染后的提示词作为开头的 `system` 角色消息(surface 第 0 号节点,即一个 `system/message` 事件)传输,并且当已准备调用声明 `systemPromptUpdate: 'in-history'` 时,变化后的非空提示词可以作为后续的 `system` 角色消息跟在已缓存历史之后,由模型读作有效提示词;请求的 `system` 字段不设置——`GenerateOptions.system` 服务于标题提供方等直接单次调用方。空渲染文本使派生历史不包含任何系统消息,即使先前请求保留了多个提示词版本。已记录的请求会以最新的 `user/message`(轮次首步)或上一步的工具结果(后续步骤)结尾。 diff --git a/docs/subsystems/session-reference.md b/docs/subsystems/session-reference.md index 6a41f8dd3b..517b395729 100644 --- a/docs/subsystems/session-reference.md +++ b/docs/subsystems/session-reference.md @@ -205,7 +205,7 @@ async listCandidates( agent: Agent, query: string = '', limit: number = this.con /** * Snapshot all references for one accepted direct message and return one aggregated durable context. - * Automatic budgets use the step route, or agent options for direct preparation. + * Automatic budgets use the step's model selection, or agent options for direct preparation. * Missing model capacity or adapter uses 64 KiB; other metadata lookup failures and cancellation reject preparation. * Truncated previews include omission facts and a full-snapshot spill locator, or an explicit unavailable notice. * Cancellation prevents context publication, including when storage completes after cancellation. @@ -213,13 +213,13 @@ async listCandidates( agent: Agent, query: string = '', limit: number = this.con * @param content - already host-normalized readable message content. * @param references - structured source sessions in mention order. * @param signal - optional cancellation boundary for the active turn. - * @param route - provider and model of the step admitting the message; omitted for direct preparation. + * @param modelSelection - provider and model of the step admitting the message; omitted for direct preparation. * @returns detached content and optional referenced-session context. */ -async prepare( agent: Agent, content: ContentBlock[], references: SessionReferenceInput[], signal?: AbortSignal, route?: StepRoute, ): Promise +async prepare( agent: Agent, content: ContentBlock[], references: SessionReferenceInput[], signal?: AbortSignal, modelSelection?: ModelSelection, ): Promise ``` -Types: [Agent](core.md) · [ContentBlock](llm-streaming.md) · [StepRoute](core.md) +Types: [Agent](core.md) · [ContentBlock](llm-streaming.md) · [ModelSelection](core.md) Source: [`packages/context/session-reference/src/index.ts`](../../packages/context/session-reference/src/index.ts) diff --git a/docs/subsystems/session-reference.zh.md b/docs/subsystems/session-reference.zh.md index 79fbef73ee..f76a9a7371 100644 --- a/docs/subsystems/session-reference.zh.md +++ b/docs/subsystems/session-reference.zh.md @@ -205,7 +205,7 @@ async listCandidates( agent: Agent, query: string = '', limit: number = this.con /** * Snapshot all references for one accepted direct message and return one aggregated durable context. - * Automatic budgets use the step route, or agent options for direct preparation. + * Automatic budgets use the step's model selection, or agent options for direct preparation. * Missing model capacity or adapter uses 64 KiB; other metadata lookup failures and cancellation reject preparation. * Truncated previews include omission facts and a full-snapshot spill locator, or an explicit unavailable notice. * Cancellation prevents context publication, including when storage completes after cancellation. @@ -213,13 +213,13 @@ async listCandidates( agent: Agent, query: string = '', limit: number = this.con * @param content - already host-normalized readable message content. * @param references - structured source sessions in mention order. * @param signal - optional cancellation boundary for the active turn. - * @param route - provider and model of the step admitting the message; omitted for direct preparation. + * @param modelSelection - provider and model of the step admitting the message; omitted for direct preparation. * @returns detached content and optional referenced-session context. */ -async prepare( agent: Agent, content: ContentBlock[], references: SessionReferenceInput[], signal?: AbortSignal, route?: StepRoute, ): Promise +async prepare( agent: Agent, content: ContentBlock[], references: SessionReferenceInput[], signal?: AbortSignal, modelSelection?: ModelSelection, ): Promise ``` -Types: [Agent](core.zh.md) · [ContentBlock](llm-streaming.zh.md) · [StepRoute](core.zh.md) +Types: [Agent](core.zh.md) · [ContentBlock](llm-streaming.zh.md) · [ModelSelection](core.zh.md) Source: [`packages/context/session-reference/src/index.ts`](../../packages/context/session-reference/src/index.ts) diff --git a/docs/subsystems/tools.md b/docs/subsystems/tools.md index 25eba3d259..5350cc8f7d 100644 --- a/docs/subsystems/tools.md +++ b/docs/subsystems/tools.md @@ -569,7 +569,7 @@ get(name: string, scope?: ScopeKey, toolSet?: ResolvedToolSet): ToolDefinition | * 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, step route, and cancellation. + * @param context - preparing agent, step model selection, and cancellation. * @returns the complete selection; the caller disposes it after its tool batch settles. */ async resolve(context: ToolActivationContext): Promise diff --git a/docs/subsystems/tools.zh.md b/docs/subsystems/tools.zh.md index 999f3f4875..5d33028bde 100644 --- a/docs/subsystems/tools.zh.md +++ b/docs/subsystems/tools.zh.md @@ -569,7 +569,7 @@ get(name: string, scope?: ScopeKey, toolSet?: ResolvedToolSet): ToolDefinition | * 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, step route, and cancellation. + * @param context - preparing agent, step model selection, and cancellation. * @returns the complete selection; the caller disposes it after its tool batch settles. */ async resolve(context: ToolActivationContext): Promise diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/conditional-tools/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.2/conditional-tools/guide.i18n.yaml index de2bf1983b..d97d05ce94 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.2/conditional-tools/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.1-alpha.2/conditional-tools/guide.i18n.yaml @@ -3,14 +3,14 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.2/conditional-tools/guide.md /: - en: a6fa0b92ad5adce4 - zh: 743795b5e3985faf -/update-step-routing-conditional-tools-and-prompt-hooks: - en: 7f578f5ad4a1406b - zh: 9b5984f46d7ae7b4 -/update-step-routing-conditional-tools-and-prompt-hooks/change: - en: a914aff13f6391ff - zh: b8a9dfaeaaa7d76f -/update-step-routing-conditional-tools-and-prompt-hooks/migration: - en: bdb131c08b81d7ec - zh: e76b53840dc3bd8a + en: b56fc42e23dfd970 + zh: cbe86c0b3af8eee7 +/update-step-model-selection-conditional-tools-and-prompt-hooks: + en: 81e0b8c4355e86a3 + zh: 2563f89226c11cfb +/update-step-model-selection-conditional-tools-and-prompt-hooks/change: + en: b2a7c15a9971ce4a + zh: ef217bd430dfb48f +/update-step-model-selection-conditional-tools-and-prompt-hooks/migration: + en: f818cf83d987ae4b + zh: 7d4f431cdb10dd91 diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/conditional-tools/guide.md b/docs/upgrade-guide/v0.2.1-alpha.2/conditional-tools/guide.md index 3cce69c4aa..cff2857104 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.2/conditional-tools/guide.md +++ b/docs/upgrade-guide/v0.2.1-alpha.2/conditional-tools/guide.md @@ -1,15 +1,15 @@ --- kind: upgrade-guide -description: "Step routing moves from `agent/request` to `agent/route`, and custom tool and prompt plugins must use step-owned tool admission." +description: "Step provider, model, and reasoning-effort selection moves from `agent/request` to `agent/model`, and custom tool and prompt plugins must use step-owned tool admission." --- -# Update step routing, conditional tools, and prompt hooks +# Update step model selection, conditional tools, and prompt hooks English | [中文](guide.zh.md) ## Change -`agent/route` chooses each step's provider and model before `system-prompt/prepare` and `agent/pre-step`. Prompt preparation, `agent/pre-step` through its new `route` payload field, tool conditions, and every request attempt in the step use that route. `agent/request` still runs for each attempt after prompt assembly and `step/start`, but it may change only call controls such as `reasoningEffort`; returning another provider or model fails the step. An agent without an options route must receive one from `agent/route`. Plugins that replaced the provider or model in `agent/request`, including on a retry, are affected. +`agent/model` returns each step's `ModelSelection` (provider, model, and optional reasoning effort) before `system-prompt/prepare` and `agent/pre-step`. Prompt preparation, `agent/pre-step` through its new `modelSelection` payload field, tool conditions, and every request attempt in the step use that selection. `agent/request` still runs for each attempt after prompt assembly and `step/start`, but it may change only sampling controls such as `maxTokens`, `temperature`, and `stop`; returning another provider, model, or reasoning effort fails the step. An agent without options provider and model must receive them from `agent/model`. Plugins that replaced the provider, model, or effort in `agent/request`, including on a retry, are affected. `installModelSelection` applies its selection through `agent/model`, and `ModelSelectionRef` no longer has `assembled`. Runtime context preparation uses `system-prompt/prepare`; `system-prompt/assemble` receives presentation fields without a tool array. Tool declarations remain owned by registered providers. Conditional tools require a resolved step set for execution, including nested PTC calls. Replacing a registration does not authorize an outstanding call to run the replacement. @@ -17,8 +17,8 @@ Runtime context preparation uses `system-prompt/prepare`; `system-prompt/assembl ## Migration -1. Move provider and model replacement from `agent/request` listeners to `agent/route` listeners that return `{ provider, model }`; keep only control changes in `agent/request`. Read the route from `payload.route` in `agent/pre-step` and from `AssembleContext.route` in prompt providers. -2. Declare tool conditions with `ctx.tools.register(definition, { when })`; the condition receives `{ agent, route, signal }`. +1. Move provider, model, and reasoning-effort replacement from `agent/request` listeners to `agent/model` listeners that return `{ provider, model, reasoningEffort? }`; an omitted effort uses the model's provider default. Keep only sampling changes in `agent/request`. Read the selection from `payload.modelSelection` in `agent/pre-step` and from `AssembleContext.modelSelection` in prompt providers. +2. Declare tool conditions with `ctx.tools.register(definition, { when })`; the condition receives `{ agent, modelSelection, signal }`. 3. Move runtime-context preparation listeners to `system-prompt/prepare`. Keep presentation changes in `system-prompt/assemble` and stop replacing its tool list. -4. For programmatic conditional calls, resolve `{ agent, route, signal }` through `ctx.tools.resolve()`, pass the resulting `toolSet` to assembly and execution, and dispose it after the batch settles. AgentLoop does this once per step for model calls. Pass `context.toolSet` when tool guidance queries `ctx.tools.get()` or `schemas()`. -5. Add `route` to `agent/pre-step` payloads that tests dispatch directly. Verify that inactive tools are absent from declarations, that a historical name returns `UNKNOWN_TOOL` without reaching the implementation, and that an `agent/request` listener changing the model ends the turn with an error naming `agent/route`. +4. For programmatic conditional calls, resolve `{ agent, modelSelection, signal }` through `ctx.tools.resolve()`, pass the resulting `toolSet` to assembly and execution, and dispose it after the batch settles. AgentLoop does this once per step for model calls. Pass `context.toolSet` when tool guidance queries `ctx.tools.get()` or `schemas()`. +5. Remove `assembled` from `ModelSelectionRef` objects, and add `modelSelection` to `agent/pre-step` payloads that tests dispatch directly. Verify that inactive tools are absent from declarations, that a historical name returns `UNKNOWN_TOOL` without reaching the implementation, and that an `agent/request` listener changing the model or effort ends the turn with an error naming `agent/model`. diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/conditional-tools/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.2/conditional-tools/guide.zh.md index 20db035c69..01fa5cfec8 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.2/conditional-tools/guide.zh.md +++ b/docs/upgrade-guide/v0.2.1-alpha.2/conditional-tools/guide.zh.md @@ -1,15 +1,15 @@ --- kind: upgrade-guide -description: "步骤路由选择从 `agent/request` 移到 `agent/route`,自定义工具和提示词插件必须使用步骤拥有的工具准入集合。" +description: "步骤的提供方、模型与推理强度选择从 `agent/request` 移到 `agent/model`,自定义工具和提示词插件必须使用步骤拥有的工具准入集合。" --- -# 更新步骤路由、条件工具与提示词钩子 +# 更新步骤模型选择、条件工具与提示词钩子 [English](guide.md) | 中文 ## 变更 -`agent/route` 在 `system-prompt/prepare` 与 `agent/pre-step` 之前选定每个步骤的提供方与模型。提示词准备、`agent/pre-step`(通过新增的 payload 字段 `route`)、工具条件以及该步骤的每次请求尝试都使用这一路由。`agent/request` 仍在提示词组装与 `step/start` 之后为每次尝试运行,但只能修改 `reasoningEffort` 等调用控制参数;返回其他提供方或模型会使步骤失败。options 中没有路由的 agent 必须由 `agent/route` 提供路由。在 `agent/request` 中替换提供方或模型的插件(包括在重试时替换的插件)会受到影响。 +`agent/model` 在 `system-prompt/prepare` 与 `agent/pre-step` 之前返回每个步骤的 `ModelSelection`(提供方、模型与可选的推理强度)。提示词准备、`agent/pre-step`(通过新增的 payload 字段 `modelSelection`)、工具条件以及该步骤的每次请求尝试都使用这一选择。`agent/request` 仍在提示词组装与 `step/start` 之后为每次尝试运行,但只能修改 `maxTokens`、`temperature`、`stop` 等采样控制参数;返回其他提供方、模型或推理强度会使步骤失败。options 中没有提供方与模型的 agent 必须由 `agent/model` 提供。在 `agent/request` 中替换提供方、模型或推理强度的插件(包括在重试时替换的插件)会受到影响。`installModelSelection` 通过 `agent/model` 应用其选择,`ModelSelectionRef` 不再包含 `assembled`。 运行时上下文准备使用 `system-prompt/prepare`;`system-prompt/assemble` 接收不含工具数组的展示字段。工具声明仍由已注册提供方持有。条件工具执行需要已解析的步骤集合,包括嵌套 PTC 调用。替换注册不会授权尚未执行的旧调用使用新实现。 @@ -17,8 +17,8 @@ description: "步骤路由选择从 `agent/request` 移到 `agent/route`,自 ## 迁移 -1. 把 `agent/request` 监听器中的提供方与模型替换移到返回 `{ provider, model }` 的 `agent/route` 监听器;`agent/request` 只保留调用控制参数的修改。在 `agent/pre-step` 中从 `payload.route` 读取路由,在提示词提供方中从 `AssembleContext.route` 读取路由。 -2. 使用 `ctx.tools.register(definition, { when })` 声明工具条件;条件接收 `{ agent, route, signal }`。 +1. 把 `agent/request` 监听器中的提供方、模型与推理强度替换移到返回 `{ provider, model, reasoningEffort? }` 的 `agent/model` 监听器;省略推理强度时使用该模型的提供方默认值。`agent/request` 只保留采样参数的修改。在 `agent/pre-step` 中从 `payload.modelSelection` 读取选择,在提示词提供方中从 `AssembleContext.modelSelection` 读取选择。 +2. 使用 `ctx.tools.register(definition, { when })` 声明工具条件;条件接收 `{ agent, modelSelection, signal }`。 3. 将运行时上下文准备监听器迁移到 `system-prompt/prepare`。展示变更留在 `system-prompt/assemble`,停止替换它的工具列表。 -4. 程序化调用条件工具时,通过 `ctx.tools.resolve()` 解析 `{ agent, route, signal }`,将返回的 `toolSet` 传给组装与执行,并在批次结束后释放。AgentLoop 会为模型调用在每个步骤完成一次这些操作。工具指导查询 `ctx.tools.get()` 或 `schemas()` 时传入 `context.toolSet`。 -5. 为测试直接派发的 `agent/pre-step` payload 补上 `route`。验证未启用工具不出现在声明中,使用历史名称调用会返回 `UNKNOWN_TOOL` 且不会进入实现,并验证修改模型的 `agent/request` 监听器会让轮次以指向 `agent/route` 的错误结束。 +4. 程序化调用条件工具时,通过 `ctx.tools.resolve()` 解析 `{ agent, modelSelection, signal }`,将返回的 `toolSet` 传给组装与执行,并在批次结束后释放。AgentLoop 会为模型调用在每个步骤完成一次这些操作。工具指导查询 `ctx.tools.get()` 或 `schemas()` 时传入 `context.toolSet`。 +5. 从 `ModelSelectionRef` 对象中删除 `assembled`,并为测试直接派发的 `agent/pre-step` payload 补上 `modelSelection`。验证未启用工具不出现在声明中,使用历史名称调用会返回 `UNKNOWN_TOOL` 且不会进入实现,并验证修改模型或推理强度的 `agent/request` 监听器会让轮次以指向 `agent/model` 的错误结束。 diff --git a/packages/acp/acp/README.i18n.yaml b/packages/acp/acp/README.i18n.yaml index 8be465f41a..6b0b7637c0 100644 --- a/packages/acp/acp/README.i18n.yaml +++ b/packages/acp/acp/README.i18n.yaml @@ -21,8 +21,8 @@ en: c3f5f144daf83157 zh: a65a64842fd35bef /deepseek-ai-dsh-acp/use-this-package/minimal-configuration: - en: 21dac29aadc0fc57 - zh: 6d2110787c034f8e + en: 7b9e6833800ee1a6 + zh: 8bb7ab276aa912ab /deepseek-ai-dsh-acp/use-this-package/start-a-server: en: 4ed8e37834267edb zh: a32d5dedb4e8e919 diff --git a/packages/acp/acp/README.md b/packages/acp/acp/README.md index fdde64b680..11014dbb31 100644 --- a/packages/acp/acp/README.md +++ b/packages/acp/acp/README.md @@ -33,7 +33,7 @@ Choose it when automation should own the interaction: an out-of-process subagent ### Minimal configuration -Every session the server creates uses the provider and model configured here. Both fields are optional so an `agent/route` listener can supply them; the runnable demo composition sets both. Stdout carries only protocol traffic, so keep logging off it. +Every session the server creates uses the provider and model configured here. Both fields are optional so an `agent/model` listener can supply them; the runnable demo composition sets both. Stdout carries only protocol traffic, so keep logging off it. ```yaml - name: '@deepseek-ai/dsh-acp' diff --git a/packages/acp/acp/README.zh.md b/packages/acp/acp/README.zh.md index 33d5ee9b03..0ef7065344 100644 --- a/packages/acp/acp/README.zh.md +++ b/packages/acp/acp/README.zh.md @@ -33,7 +33,7 @@ kind: "package-reference" ### 最小配置 -服务器创建的每个会话都使用此处配置的提供方与模型。两个字段都是可选的,以便由 `agent/route` 监听器提供;可运行的演示组合会同时设置两者。Stdout 只承载协议流量,因此请让日志远离它。 +服务器创建的每个会话都使用此处配置的提供方与模型。两个字段都是可选的,以便由 `agent/model` 监听器提供;可运行的演示组合会同时设置两者。Stdout 只承载协议流量,因此请让日志远离它。 ```yaml - name: '@deepseek-ai/dsh-acp' diff --git a/packages/acp/acp/src/model-control.ts b/packages/acp/acp/src/model-control.ts index 00860475a0..4adc1ccb1a 100644 --- a/packages/acp/acp/src/model-control.ts +++ b/packages/acp/acp/src/model-control.ts @@ -30,7 +30,7 @@ export class AcpModelConfigError extends Error { /** Project and mutate one Agent's provider/model/reasoning selection through ACP config options. */ export class AcpModelControl { - /** Scoped selection reference consumed by Agent step routing. */ + /** Scoped selection reference taken by each Agent step. */ readonly selection: ModelSelectionRef private tail = Promise.resolve() private selected: ModelSelection | undefined @@ -47,12 +47,11 @@ export class AcpModelControl { this.selection = { get current() { return getCurrent() }, set current(value) { setCurrent(value) }, - assembled: undefined, } } /** - * Install step routing listeners in the unpublished Agent scope. + * Install step model-selection listeners in the unpublished Agent scope. * @param agentCtx - Agent scope that consumes this selection. */ install(agentCtx: Context): void { @@ -61,7 +60,7 @@ export class AcpModelControl { /** * Snapshot the selection attached to the next accepted ACP prompt. - * @returns a detached future selection, or undefined when listeners supply the route. + * @returns a detached future selection, or undefined when listeners supply the model. */ snapshot(): ModelSelection | undefined { return this.selected === undefined ? undefined : { ...this.selected } diff --git a/packages/acp/acp/tests/bridge.spec.ts b/packages/acp/acp/tests/bridge.spec.ts index 1356e32efb..bb6b1f579d 100644 --- a/packages/acp/acp/tests/bridge.spec.ts +++ b/packages/acp/acp/tests/bridge.spec.ts @@ -834,12 +834,12 @@ describe('automation-only ACP bridge', () => { expect(harness.ctx.agents.get(SessionId(sessionId))?.options).toEqual({}) }) - it('allows route listeners to supply a route when ACP has no initial selection', async () => { + it('allows model listeners to supply a model when ACP has no initial selection', async () => { harness = await makeBridgeHarness({ config: { provider: undefined, model: undefined }, script: [textResponse('listener-routed')], }) - harness.ctx.on('agent/route', async (_payload, next) => ({ + harness.ctx.on('agent/model', async (_payload, next) => ({ ...await next(), provider: 'mock', model: 'mock', diff --git a/packages/api/session-controller/src/agent.ts b/packages/api/session-controller/src/agent.ts index 4735b47e47..38e07a293e 100644 --- a/packages/api/session-controller/src/agent.ts +++ b/packages/api/session-controller/src/agent.ts @@ -282,7 +282,7 @@ export class ApiSessionAgentController { } /** - * Install or return the Session-local model selection that chooses each step's route. + * Install or return the Session-local model selection that each step takes. * @param agent - live Agent that owns the selection. * @returns the installed mutable selection reference. */ @@ -324,7 +324,6 @@ export class ApiSessionAgentController { picked = undefined return true }, - assembled: undefined, } installModelSelection(agent.ctx, selection) this.selections.set(agent, selection) @@ -332,7 +331,7 @@ export class ApiSessionAgentController { } /** - * Commit and cache one validated selection for the next step's route. + * Commit and cache one validated selection for the next step. * @param agent - live Agent that owns the selection. * @param selection - validated selection to record and apply. */ diff --git a/packages/api/session-controller/tests/archived-session-gate.host.spec.ts b/packages/api/session-controller/tests/archived-session-gate.host.spec.ts index 3a94d5d5c9..3c9d962f9e 100644 --- a/packages/api/session-controller/tests/archived-session-gate.host.spec.ts +++ b/packages/api/session-controller/tests/archived-session-gate.host.spec.ts @@ -47,7 +47,7 @@ async function liveAgent(ctx: Context, session: Session): Promise { /** Propose one model step for the Agent; the innermost decision enters. */ const proposed = (ctx: Context, agent: Agent) => ctx.waterfall( 'agent/pre-step', - { agent, messages: [], turn: 1, step: 1, route: { provider: 'test', model: 'test' }, signal: new AbortController().signal }, + { agent, messages: [], turn: 1, step: 1, modelSelection: { provider: 'test', model: 'test' }, signal: new AbortController().signal }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), ) diff --git a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts index 5a4d946bf0..70ce7a0c3e 100644 --- a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts +++ b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts @@ -96,10 +96,7 @@ async function commandHarness( currentSelection: () => ({ provider: 'fixture', model: 'fixture-model' }), saveSelection: () => Promise.resolve(), } as never) - const selection: ModelSelectionRef = { - current: { provider: 'fixture', model: 'fixture-model' }, - assembled: undefined, - } + const selection: ModelSelectionRef = { current: { provider: 'fixture', model: 'fixture-model' } } const agents = { resolveAgent: (id: SessionId) => Promise.resolve(id === agent.id ? { agent } diff --git a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts index 53d1137628..a27abe018b 100644 --- a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts +++ b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts @@ -88,10 +88,7 @@ async function uploadHarness(origin?: 'subagent'): Promise<{ listProviders: () => [{ id: 'fixture', name: 'Fixture' }], resolveModelInfo: () => Promise.resolve({ provider: 'fixture', id: 'fixture-model', name: 'Fixture' }), } as never) - const selection: ModelSelectionRef = { - current: { provider: 'fixture', model: 'fixture-model' }, - assembled: undefined, - } + const selection: ModelSelectionRef = { current: { provider: 'fixture', model: 'fixture-model' } } const agents = { resolveAgent: () => Promise.resolve({ agent }), selectionFor: () => selection, diff --git a/packages/api/session-controller/tests/session-fork.host.spec.ts b/packages/api/session-controller/tests/session-fork.host.spec.ts index e7ace3a85c..ed27f6abf5 100644 --- a/packages/api/session-controller/tests/session-fork.host.spec.ts +++ b/packages/api/session-controller/tests/session-fork.host.spec.ts @@ -8,7 +8,6 @@ import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deep import { BasicCompactionEngine } from '@deepseek-ai/dsh-compaction-basic' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { ToolCallId, createMessage, createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' -import type { LlmCallConfig } from '@deepseek-ai/dsh-llm' import SessionStore, { TOOL_OUTCOME_UNKNOWN, SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import { SessionPersistenceRevision, type SessionPersistenceSnapshot } from '@deepseek-ai/dsh-session-persistence' @@ -663,13 +662,9 @@ describe('sessions.fork', () => { if (child === undefined) throw new Error('fork did not publish the child agent') expect(child.session.snapshotEvents().slice(0, child.session.inheritedEventCount)).toEqual(inherited) const signal = new AbortController().signal - const route = await agentEvents(child.ctx, child).waterfall( - 'agent/route', { turn: 1, step: 1, signal }, () => Promise.resolve({ provider: 'default-provider', model: 'default-model' }), - ) - expect(route).toEqual({ provider: 'inherited-provider', model: 'inherited-model' }) await expect(agentEvents(child.ctx, child).waterfall( - 'agent/request', { turn: 1, step: 1, signal }, () => Promise.resolve({ ...route }), - )).resolves.toEqual({ ...route, reasoningEffort: 'high' }) + 'agent/model', { turn: 1, step: 1, signal }, () => Promise.resolve({ provider: 'default-provider', model: 'default-model' }), + )).resolves.toEqual({ provider: 'inherited-provider', model: 'inherited-model', reasoningEffort: 'high' }) await ctx.fiber.dispose() }) }) diff --git a/packages/api/session-controller/tests/session-models.host.spec.ts b/packages/api/session-controller/tests/session-models.host.spec.ts index 099e5637be..21b21f78f2 100644 --- a/packages/api/session-controller/tests/session-models.host.spec.ts +++ b/packages/api/session-controller/tests/session-models.host.spec.ts @@ -1,18 +1,18 @@ /** * Session Controller model-directory and selection behavior: dynamic provider grouping, * provider-local catalog failures, logged-selection restoration without stale - * catalog injection, unavailable-model refusals, and the step-route + * catalog injection, unavailable-model refusals, and the step-model * boundary for a running selection change. */ import { describe, expect, it, vi, onTestFinished } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' -import type { Agent, StepRoute } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' import AttachmentStore from '@deepseek-ai/dsh-attachment' import LlmRuntime, { LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { - GenerateOptions, LlmCallConfig, LlmCallConfigAdapterDefaults, LlmModelInfo, + GenerateOptions, LlmCallConfigAdapterDefaults, LlmModelInfo, LlmModelReasoningInfo, LlmProviderInfo, LlmResolvedModelInfo, StreamChunk, UserMessage, } from '@deepseek-ai/dsh-llm' @@ -486,15 +486,12 @@ describe('Web session model selection', () => { await ctx.fiber.dispose() }) - it('rejects unlisted models and applies a selection from the next step route', async () => { + it('rejects unlisted models and applies a selection from the next step', async () => { const { ctx, agent, sessionId } = await harness() const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' }) const signal = new AbortController().signal - const route = (step: number) => agentEvents(ctx, agent).waterfall( - 'agent/route', { turn: 1, step, signal }, () => Promise.resolve({ provider: 'seed', model: 'seed' }), - ) - const requestOn = (step: number, stepRoute: StepRoute) => agentEvents(ctx, agent).waterfall( - 'agent/request', { turn: 1, step, signal }, () => Promise.resolve({ ...stepRoute, temperature: 0.2 }), + const choose = (step: number) => agentEvents(ctx, agent).waterfall( + 'agent/model', { turn: 1, step, signal }, () => Promise.resolve({ provider: 'seed', model: 'seed' }), ) expect(currentSelection(ctx, sessionId)) @@ -504,9 +501,7 @@ describe('Web session model selection', () => { provider: 'deepseek-official', model: 'deepseek-chat', }))) - const chat = await route(1) - expect(chat).toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' }) - await expect(requestOn(1, chat)).resolves.toEqual({ ...chat, temperature: 0.2, reasoningEffort: 'high' }) + await expect(choose(1)).resolves.toEqual({ provider: 'deepseek-official', model: 'deepseek-chat', reasoningEffort: 'high' }) const selected = expectValue(await remote.selectModel(request({ sessionId, @@ -519,10 +514,7 @@ describe('Web session model selection', () => { model: 'deepseek-reasoner', reasoningEffort: 'max', }) - await expect(requestOn(1, chat)).resolves.toEqual({ ...chat, temperature: 0.2, reasoningEffort: 'high' }) - const reasoner = await route(2) - expect(reasoner).toEqual({ provider: 'deepseek-official', model: 'deepseek-reasoner' }) - await expect(requestOn(2, reasoner)).resolves.toEqual({ ...reasoner, temperature: 0.2, reasoningEffort: 'max' }) + await expect(choose(2)).resolves.toEqual({ provider: 'deepseek-official', model: 'deepseek-reasoner', reasoningEffort: 'max' }) const unsupported = await remote.selectModel(request({ sessionId, diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index 93006eb810..cc51a73039 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -338,7 +338,7 @@ async function run(ctx: Context, config: Config, io: HeadlessIo): Promise // that DOES configure one has to join it here first // (@deepseek-ai/dsh-agent-preset-registry README, "Composing a child agent"). const setup = (agentCtx: Context): void => { - const selected: ModelSelectionRef = { current: selection, assembled: undefined } + const selected: ModelSelectionRef = { current: selection } installModelSelection(agentCtx, selected) } const sessionId = brandString(config.sessionId ?? `session-${randomUUID()}`) diff --git a/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts b/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts index d43a6828d6..a1d1d28162 100644 --- a/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts +++ b/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts @@ -1675,7 +1675,7 @@ describe('default one-shot summarizer', () => { describe('automatic listener and loader composition', () => { function preStep(ctx: Context, owner: Agent, signal = SIGNAL) { return agentEvents(ctx, owner).waterfall( - 'agent/pre-step', { messages: [], turn: 1, step: 1, route: { provider: 'test', model: 'test' }, signal }, + 'agent/pre-step', { messages: [], turn: 1, step: 1, modelSelection: { provider: 'test', model: 'test' }, signal }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), ) } diff --git a/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts b/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts index 84117631aa..15638ed067 100644 --- a/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts +++ b/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts @@ -205,9 +205,9 @@ function overflowHistorySeed(): readonly SessionEvent[] { } describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () => { - it('uses the model actually routed by agent/route for post-step pressure', async () => { + it('uses the model actually selected by agent/model for post-step pressure', async () => { const { ctx } = await harness(8) - ctx.on('agent/route', async (_payload, next) => ({ + ctx.on('agent/model', async (_payload, next) => ({ ...await next(), provider: 'mock', model: 'mock', })) try { @@ -380,7 +380,7 @@ describe('context-overflow recovery across the real loop and compaction-basic', await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(TokenMeter) ctx.llm.registerAdapter(['mock'], adapter) - ctx.on('agent/route', async (_payload, next) => ({ + ctx.on('agent/model', async (_payload, next) => ({ ...await next(), provider: 'mock', model: 'mock', })) await ctx.plugin(BasicCompactionEngine, { diff --git a/packages/context/agent-instructions/tests/agent-instructions.spec.ts b/packages/context/agent-instructions/tests/agent-instructions.spec.ts index d6b22860b9..ef3da9000f 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.spec.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.spec.ts @@ -310,7 +310,7 @@ async function agentInstructionsOf(agent: Agent): Promise { async function syncAgentInstructions(ctx: Context, agent: Agent): Promise { await agentEvents(ctx, agent).waterfall( - 'agent/pre-step', { messages: [], turn: 1, step: 1, route: { provider: 'test', model: 'test' }, signal: testToolSignal }, + 'agent/pre-step', { messages: [], turn: 1, step: 1, modelSelection: { provider: 'test', model: 'test' }, signal: testToolSignal }, async () => ({ kind: 'enter' as const, messages: [] }), ) } @@ -345,13 +345,13 @@ async function composeBaselinePrefix(ctx: Context, agent: TestAgent): Promise Promise.resolve({ kind: 'enter' as const, messages: [] }), ) const claimed = claimInbox(agent, 'next-step') const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - { messages: claimed, turn: 1, step: 2, route: { provider: 'test', model: 'test' }, signal }, + { messages: claimed, turn: 1, step: 2, modelSelection: { provider: 'test', model: 'test' }, signal }, () => Promise.resolve({ kind: 'enter' as const, messages: claimed }), ) const entered = decision.kind === 'enter' ? decision.messages : [] @@ -1724,7 +1724,7 @@ describe('workspace context request injection', () => { const original = await stubAgent(root) await agentEvents(ctx, original).waterfall( 'agent/pre-step', - { messages: [], turn: 1, step: 1, route: { provider: 'test', model: 'test' }, signal: AbortSignal.timeout(requestTimeoutMs) }, + { messages: [], turn: 1, step: 1, modelSelection: { provider: 'test', model: 'test' }, signal: AbortSignal.timeout(requestTimeoutMs) }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), ) const inserted = original.inbox.nextStep[0] @@ -1738,7 +1738,7 @@ describe('workspace context request injection', () => { const claimed = claimInbox(resumed, 'next-step') const decision = await agentEvents(ctx, resumed).waterfall( 'agent/pre-step', - { messages: claimed, turn: 1, step: 1, route: { provider: 'test', model: 'test' }, signal: AbortSignal.timeout(requestTimeoutMs) }, + { messages: claimed, turn: 1, step: 1, modelSelection: { provider: 'test', model: 'test' }, signal: AbortSignal.timeout(requestTimeoutMs) }, () => Promise.resolve({ kind: 'enter' as const, messages: claimed }), ) if (decision.kind !== 'enter') throw new Error('recovered baseline was rejected') @@ -1773,7 +1773,7 @@ describe('workspace context request injection', () => { const original = await stubAgent(root) await agentEvents(ctx, original).waterfall( 'agent/pre-step', - { messages: [], turn: 1, step: 1, route: { provider: 'test', model: 'test' }, signal: AbortSignal.timeout(requestTimeoutMs) }, + { messages: [], turn: 1, step: 1, modelSelection: { provider: 'test', model: 'test' }, signal: AbortSignal.timeout(requestTimeoutMs) }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), ) const stale = original.inbox.nextStep[0] @@ -1788,7 +1788,7 @@ describe('workspace context request injection', () => { const staleClaim = claimInbox(resumed, 'next-step') const staleDecision = await agentEvents(ctx, resumed).waterfall( 'agent/pre-step', - { messages: staleClaim, turn: 1, step: 1, route: { provider: 'test', model: 'test' }, signal: AbortSignal.timeout(requestTimeoutMs) }, + { messages: staleClaim, turn: 1, step: 1, modelSelection: { provider: 'test', model: 'test' }, signal: AbortSignal.timeout(requestTimeoutMs) }, () => Promise.resolve({ kind: 'enter' as const, messages: staleClaim }), ) @@ -1831,7 +1831,7 @@ describe('workspace context request injection', () => { const original = await stubAgent(root) await agentEvents(originalCtx, original).waterfall( 'agent/pre-step', - { messages: [], turn: 1, step: 1, route: { provider: 'test', model: 'test' }, signal: AbortSignal.timeout(requestTimeoutMs) }, + { messages: [], turn: 1, step: 1, modelSelection: { provider: 'test', model: 'test' }, signal: AbortSignal.timeout(requestTimeoutMs) }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), ) const stale = original.inbox.nextStep[0] @@ -1846,7 +1846,7 @@ describe('workspace context request injection', () => { const claimed = claimInbox(resumed, 'next-step') const decision = await agentEvents(resumedCtx, resumed).waterfall( 'agent/pre-step', - { messages: claimed, turn: 1, step: 1, route: { provider: 'test', model: 'test' }, signal: AbortSignal.timeout(requestTimeoutMs) }, + { messages: claimed, turn: 1, step: 1, modelSelection: { provider: 'test', model: 'test' }, signal: AbortSignal.timeout(requestTimeoutMs) }, () => Promise.resolve({ kind: 'enter' as const, messages: claimed }), ) @@ -1959,7 +1959,7 @@ describe('workspace context request injection', () => { const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - { messages: [prompt], turn: 1, step: 1, route: { provider: 'test', model: 'test' }, signal: AbortSignal.timeout(requestTimeoutMs) }, + { messages: [prompt], turn: 1, step: 1, modelSelection: { provider: 'test', model: 'test' }, signal: AbortSignal.timeout(requestTimeoutMs) }, () => Promise.resolve(downstream), ) @@ -2551,7 +2551,7 @@ describe('workspace context request injection', () => { const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - { messages: [], turn: 1, step: 1, route: { provider: 'test', model: 'test' }, signal: AbortSignal.timeout(requestTimeoutMs) }, + { messages: [], turn: 1, step: 1, modelSelection: { provider: 'test', model: 'test' }, signal: AbortSignal.timeout(requestTimeoutMs) }, () => Promise.resolve(downstream), ) @@ -2673,7 +2673,7 @@ describe('workspace context request injection', () => { const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - { messages: [prompt], turn: 2, step: 1, route: { provider: 'test', model: 'test' }, signal: AbortSignal.timeout(requestTimeoutMs) }, + { messages: [prompt], turn: 2, step: 1, modelSelection: { provider: 'test', model: 'test' }, signal: AbortSignal.timeout(requestTimeoutMs) }, () => Promise.resolve({ kind: 'enter' as const, messages: [prompt] }), ) @@ -3137,7 +3137,7 @@ describe('workspace context request injection', () => { const reason = new Error('cancel prefix') const pending = agentEvents(ctx, await stubAgent(root)).waterfall( 'agent/pre-step', - { messages: [], turn: 1, step: 1, route: { provider: 'test', model: 'test' }, signal: controller.signal }, + { messages: [], turn: 1, step: 1, modelSelection: { provider: 'test', model: 'test' }, signal: controller.signal }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), ) @@ -5770,7 +5770,7 @@ describe('workspace context inbox synchronization', () => { controller.abort(new Error('abort pre-step reconciliation')) await expect(agentEvents(ctx, agent).waterfall( - 'agent/pre-step', { messages: [], turn: 1, step: 1, route: { provider: 'test', model: 'test' }, signal: controller.signal }, + 'agent/pre-step', { messages: [], turn: 1, step: 1, modelSelection: { provider: 'test', model: 'test' }, signal: controller.signal }, async () => ({ kind: 'enter' as const, messages: [] }), )).rejects.toThrow('abort pre-step reconciliation') @@ -5889,7 +5889,7 @@ describe('workspace context inbox synchronization', () => { const downstream = { kind: 'enter' as const, messages: claimed } const decision = await agentEvents(ctx, agent).waterfall( - 'agent/pre-step', { messages: claimed, turn: 1, step: 1, route: { provider: 'test', model: 'test' }, signal: testToolSignal }, + 'agent/pre-step', { messages: claimed, turn: 1, step: 1, modelSelection: { provider: 'test', model: 'test' }, signal: testToolSignal }, async () => downstream, ) diff --git a/packages/context/session-reference/README.i18n.yaml b/packages/context/session-reference/README.i18n.yaml index 3022c2b8f5..64a394e658 100644 --- a/packages/context/session-reference/README.i18n.yaml +++ b/packages/context/session-reference/README.i18n.yaml @@ -33,8 +33,8 @@ en: db349483e453caf7 zh: 70035c291e1efe84 /deepseek-ai-dsh-session-reference/understand-the-implementation/design-concept: - en: 364238fb1f73a8eb - zh: ecb21f01b1a15f77 + en: 7171fcac9b67e148 + zh: 0ccd05e624ca8ab9 /deepseek-ai-dsh-session-reference/understand-the-implementation/source-map: en: 7c7b413082507150 zh: 0229eb73d441b305 diff --git a/packages/context/session-reference/README.md b/packages/context/session-reference/README.md index 64f86e7e3c..32a387f879 100644 --- a/packages/context/session-reference/README.md +++ b/packages/context/session-reference/README.md @@ -70,7 +70,7 @@ Preparation reads each referenced session's current surface exactly once, when t The resolver discovers optional storage through `ctx.get("spillStore")` and saves only truncated references. Storage ownership is the target session; the source descriptor identifies the referenced session and label, without a fabricated tool call. Cancellation is checked after the asynchronous save and prevents publication even if an artifact was written. Artifact expiry remains the backend's existing policy. -The budget uses the step route that `agent/route` chose, which `agent/pre-step` carries. Direct `prepare` calls without a route use agent options; session headers do not select the budget model. +The budget uses the step model that `agent/model` chose, which `agent/pre-step` carries as `modelSelection`. Direct `prepare` calls without a model selection use agent options; session headers do not select the budget model. ### Source map diff --git a/packages/context/session-reference/README.zh.md b/packages/context/session-reference/README.zh.md index 546735ad59..129db83741 100644 --- a/packages/context/session-reference/README.zh.md +++ b/packages/context/session-reference/README.zh.md @@ -70,7 +70,7 @@ kind: "package-reference" 解析器通过 `ctx.get("spillStore")` 获取可选存储,只保存被截断的引用。存储归目标会话所有;来源信息标识被引用的源会话与标签,不伪造工具调用。异步保存后会检查取消,即使产物已写入,也会阻止发布。产物过期仍遵循后端既有策略。 -预算使用 `agent/route` 选定、由 `agent/pre-step` 携带的步骤路由。未传入路由的直接 `prepare` 调用使用 agent 选项;Session 请求头不决定预算模型。 +预算使用 `agent/model` 选定、由 `agent/pre-step` 以 `modelSelection` 携带的步骤模型。未传入模型选择的直接 `prepare` 调用使用 agent 选项;Session 请求头不决定预算模型。 ### 源码地图 diff --git a/packages/context/session-reference/src/index.ts b/packages/context/session-reference/src/index.ts index 007d5d6cb2..2d67bc8197 100644 --- a/packages/context/session-reference/src/index.ts +++ b/packages/context/session-reference/src/index.ts @@ -7,7 +7,7 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import type { Agent, PreStepDecision, StepRoute } from '@deepseek-ai/dsh-agent' +import type { Agent, ModelSelection, PreStepDecision } from '@deepseek-ai/dsh-agent' import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { createUserMessage, freezeMessage, LlmError } from '@deepseek-ai/dsh-llm' import type { ContentBlock, LlmResolvedModelInfo, UserMessage } from '@deepseek-ai/dsh-llm' @@ -122,12 +122,12 @@ export class SessionReferenceResolver extends TypertRemoteService { 'SESSION_REFERENCE_INVALID_CONFIG', ) } - ctx.on('agent/pre-step', async ({ agent, route, signal }, next): Promise => { + ctx.on('agent/pre-step', async ({ agent, modelSelection, signal }, next): Promise => { const decision = await next() if (decision.kind === 'reject') return decision return { ...decision, - messages: await this.prepareDirectMessages(agent, route, decision.messages, signal), + messages: await this.prepareDirectMessages(agent, modelSelection, decision.messages, signal), } }, { prepend: true }) } @@ -136,14 +136,14 @@ export class SessionReferenceResolver extends TypertRemoteService { * Replace canonical mentions in direct user messages and place each prepared * snapshot immediately after the message that cited it. * @param agent - agent entering the model step. - * @param route - provider and model of the step. + * @param modelSelection - provider and model of the step. * @param messages - messages accepted by downstream pre-step listeners. * @param signal - active turn cancellation. * @returns direct messages followed by their session-reference context in citation order. */ private async prepareDirectMessages( agent: Agent, - route: StepRoute, + modelSelection: ModelSelection, messages: readonly UserMessage[], signal: AbortSignal, ): Promise { @@ -157,7 +157,7 @@ export class SessionReferenceResolver extends TypertRemoteService { return { type: 'text', text: parsed.text } }) if (references.length === 0) return [message] - const resolved = await this.prepare(agent, content, references, signal, route) + const resolved = await this.prepare(agent, content, references, signal, modelSelection) const direct = freezeMessage({ ...message, content: resolved.content }) /* v8 ignore if -- a parsed canonical mention always leaves one normalized reference */ if (resolved.additionalContext === undefined) { @@ -278,7 +278,7 @@ export class SessionReferenceResolver extends TypertRemoteService { /** * Snapshot all references for one accepted direct message and return one aggregated durable context. - * Automatic budgets use the step route, or agent options for direct preparation. + * Automatic budgets use the step's model selection, or agent options for direct preparation. * Missing model capacity or adapter uses 64 KiB; other metadata lookup failures and cancellation reject preparation. * Truncated previews include omission facts and a full-snapshot spill locator, or an explicit unavailable notice. * Cancellation prevents context publication, including when storage completes after cancellation. @@ -286,7 +286,7 @@ export class SessionReferenceResolver extends TypertRemoteService { * @param content - already host-normalized readable message content. * @param references - structured source sessions in mention order. * @param signal - optional cancellation boundary for the active turn. - * @param route - provider and model of the step admitting the message; omitted for direct preparation. + * @param modelSelection - provider and model of the step admitting the message; omitted for direct preparation. * @returns detached content and optional referenced-session context. */ async prepare( @@ -294,13 +294,13 @@ export class SessionReferenceResolver extends TypertRemoteService { content: ContentBlock[], references: SessionReferenceInput[], signal?: AbortSignal, - route?: StepRoute, + modelSelection?: ModelSelection, ): Promise { const acceptedContent = structuredClone(content) const inputs = normalizeReferences(agent.id, references, this.config.maxReferences) if (inputs.length === 0) return { content: acceptedContent } assertNotCancelled(signal) - const maxReferenceBytes = await this.referenceBudget(agent, route, signal) + const maxReferenceBytes = await this.referenceBudget(agent, modelSelection, signal) assertNotCancelled(signal) let prepared: PreparedSource[] try { @@ -351,10 +351,14 @@ export class SessionReferenceResolver extends TypertRemoteService { return { content: acceptedContent, additionalContext } } - private async referenceBudget(agent: Agent, route: StepRoute | undefined, signal: AbortSignal | undefined): Promise { + private async referenceBudget( + agent: Agent, + modelSelection: ModelSelection | undefined, + signal: AbortSignal | undefined, + ): Promise { if (this.config.maxReferenceBytes !== undefined) return this.config.maxReferenceBytes - // Options seed direct preparation; the step route owns model-step preparation. - const { provider, model } = route ?? agent.options + // Options seed direct preparation; the step's model selection owns model-step preparation. + const { provider, model } = modelSelection ?? agent.options const llm = this.ctx.get('llm') if (provider === undefined || model === undefined || llm === undefined) return DEFAULT_MAX_REFERENCE_BYTES let info: LlmResolvedModelInfo diff --git a/packages/context/session-reference/tests/loader-composition.spec.ts b/packages/context/session-reference/tests/loader-composition.spec.ts index eaca6a673e..f387fdd1f7 100644 --- a/packages/context/session-reference/tests/loader-composition.spec.ts +++ b/packages/context/session-reference/tests/loader-composition.spec.ts @@ -77,7 +77,7 @@ describe('session-reference real Loader composition', () => { source: { kind: 'user' }, }) const decision = await agentEvents(ctx, agent).waterfall('agent/pre-step', { - messages: [direct], turn: 1, step: 1, route: { provider: 'test', model: 'test' }, signal: new AbortController().signal, + messages: [direct], turn: 1, step: 1, modelSelection: { provider: 'test', model: 'test' }, signal: new AbortController().signal, }, () => Promise.resolve({ kind: 'enter' as const, messages: [direct] })) expect(decision.kind).toBe('enter') if (decision.kind !== 'enter') throw new Error('Expected admitted reference') diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts index e1c4f6d9ec..643c868caa 100644 --- a/packages/context/session-reference/tests/session-reference.spec.ts +++ b/packages/context/session-reference/tests/session-reference.spec.ts @@ -466,7 +466,7 @@ describe('session reference spill outcomes', () => { }) const direct = createUserMessage({ source: { kind: 'user' }, content: [{ type: 'text', text: formatSessionReferenceMention({ sessionId: source.id }) }] }) const pending = agentEvents(ctx, fakeAgent(target)).waterfall('agent/pre-step', - { messages: [direct], turn: 1, step: 1, route: { provider: 'test', model: 'test' }, signal: controller.signal }, + { messages: [direct], turn: 1, step: 1, modelSelection: { provider: 'test', model: 'test' }, signal: controller.signal }, () => Promise.resolve({ kind: 'enter' as const, messages: [direct] })) const rejected = expect(pending).rejects.toThrow(expectCode('SESSION_REFERENCE_CANCELLED')) await started.promise @@ -533,12 +533,12 @@ describe('model-relative reference budgets', () => { else expect(resolve).toHaveBeenCalledWith('seed', 'seed', undefined) }) - it('uses the step route rather than agent options', async () => { + it('uses the step model selection rather than agent options', async () => { const { ctx, agent, source, resolve } = await setup() - let route = { provider: 'selected', model: 'large' } + let modelSelection = { provider: 'selected', model: 'large' } const message = createUserMessage({ source: { kind: 'user' }, content: [{ type: 'text', text: formatSessionReferenceMention({ sessionId: source.id }) }] }) const signal = new AbortController().signal - const enter = () => agentEvents(ctx, agent).waterfall('agent/pre-step', { messages: [message], turn: 1, step: 1, route, signal }, + const enter = () => agentEvents(ctx, agent).waterfall('agent/pre-step', { messages: [message], turn: 1, step: 1, modelSelection, signal }, () => Promise.resolve({ kind: 'enter' as const, messages: [message] })) const first = await enter() expect(first.kind).toBe('enter') @@ -547,7 +547,7 @@ describe('model-relative reference budgets', () => { if (firstContext === undefined) throw new Error('expected reference context') expect(bytes({ content: [], additionalContext: firstContext })).toBe(160_000) expect(resolve).toHaveBeenLastCalledWith('selected', 'large', signal) - route = { provider: 'selected', model: 'small' } + modelSelection = { provider: 'selected', model: 'small' } resolve.mockResolvedValue({ provider: 'selected', id: 'small', name: 'small', context: { contextWindow: 8_000 } }) const second = await enter() if (second.kind !== 'enter' || second.messages[1] === undefined) throw new Error('expected reference context') @@ -621,7 +621,7 @@ describe('model-relative reference budgets', () => { expect(resolve).toHaveBeenLastCalledWith('seed', 'seed', undefined) const message = createUserMessage({ source: { kind: 'user' }, content: [{ type: 'text', text: formatSessionReferenceMention({ sessionId: source.id }) }] }) const seed = { kind: 'enter' as const, messages: [message] } - await expect(agentEvents(ctx, agent).waterfall('agent/pre-step', { messages: [message], turn: 1, step: 1, route: { provider: 'test', model: 'test' }, signal: new AbortController().signal }, + await expect(agentEvents(ctx, agent).waterfall('agent/pre-step', { messages: [message], turn: 1, step: 1, modelSelection: { provider: 'test', model: 'test' }, signal: new AbortController().signal }, () => Promise.resolve(seed))).resolves.toBe(seed) }) @@ -877,7 +877,7 @@ describe('session reference discovery and preparation', () => { const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - { messages: [direct, ordinary, plugin], turn: 1, step: 1, route: { provider: 'test', model: 'test' }, signal }, + { messages: [direct, ordinary, plugin], turn: 1, step: 1, modelSelection: { provider: 'test', model: 'test' }, signal }, () => Promise.resolve({ kind: 'enter' as const, messages: [direct, ordinary, plugin] }), ) @@ -913,14 +913,14 @@ describe('session reference discovery and preparation', () => { await expect(agentEvents(ctx, agent).waterfall( 'agent/pre-step', - { messages: [malformed], turn: 1, step: 1, route: { provider: 'test', model: 'test' }, signal }, + { messages: [malformed], turn: 1, step: 1, modelSelection: { provider: 'test', model: 'test' }, signal }, () => Promise.resolve({ kind: 'reject' as const }), )).resolves.toEqual({ kind: 'reject' }) expect(readSurface).not.toHaveBeenCalled() await expect(agentEvents(ctx, agent).waterfall( 'agent/pre-step', - { messages: [malformed], turn: 1, step: 1, route: { provider: 'test', model: 'test' }, signal }, + { messages: [malformed], turn: 1, step: 1, modelSelection: { provider: 'test', model: 'test' }, signal }, () => Promise.resolve({ kind: 'enter' as const, messages: [malformed] }), )).rejects.toThrow(/invalid session reference URI/) }) diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 2fe2f9dc6a..a50195aff8 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -98,7 +98,7 @@ async function fire( }) const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - { messages: [proposed], turn, step, route: { provider: 'test', model: 'test' }, signal }, + { messages: [proposed], turn, step, modelSelection: { provider: 'test', model: 'test' }, signal }, () => Promise.resolve({ kind: 'enter' as const, messages: [proposed] }), ) if (decision.kind === 'enter') { diff --git a/packages/context/tmux-context/tests/tmux-context.spec.ts b/packages/context/tmux-context/tests/tmux-context.spec.ts index 636d592077..50990452e3 100644 --- a/packages/context/tmux-context/tests/tmux-context.spec.ts +++ b/packages/context/tmux-context/tests/tmux-context.spec.ts @@ -151,7 +151,7 @@ async function fire( ): Promise { const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - { messages: [], turn, step, route: { provider: 'test', model: 'test' }, signal }, + { messages: [], turn, step, modelSelection: { provider: 'test', model: 'test' }, signal }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), ) if (decision.kind === 'enter') { diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 03b0a75c41..bd05cdf32e 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/README.i18n.yaml @@ -18,8 +18,8 @@ en: 35a960c0100a6aa4 zh: 438e133fb4c24a9e /deepseek-ai-dsh-agent-loop/use-this-package/configure-declarative-agents: - en: 1da69e82fc23ccd0 - zh: f722cf3827b821bc + en: 88fdef94bc4103dd + zh: e2bfd093a4fa00b8 /deepseek-ai-dsh-agent-loop/use-this-package/create-or-resume-agents-programmatically: en: 82ec792637e8e57f zh: e32f8ec5ee17afcf @@ -45,8 +45,8 @@ en: 3c5721bc00a0bbf4 zh: 711f832343ffbb75 /deepseek-ai-dsh-agent-loop/understand-the-implementation/turn-and-step-flow: - en: 787a9f96297a6024 - zh: e9b34c0c7ff26e25 + en: 0063a07435bc4028 + zh: c873fd82c927a7c2 /deepseek-ai-dsh-agent-loop/understand-the-implementation/failure-and-cancellation: en: eea56903c4ff82f5 zh: c0d5abb5767780ed diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 4e27d6c0a8..09bf22f9b3 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -29,7 +29,7 @@ Mount `dsh-agent-loop` in any composition that should run agents. It supplies th ### Configure declarative agents -Agents declared in the config start automatically when the plugin loads. Each entry needs an `id` label; a model call additionally requires both `provider` and `model` (`agent/route` may supply a missing pair for each step). +Agents declared in the config start automatically when the plugin loads. Each entry needs an `id` label; a model call additionally requires both `provider` and `model` (`agent/model` may supply a missing pair for each step). ```yaml - name: '@deepseek-ai/dsh-agent-loop' @@ -117,7 +117,7 @@ The loop is the production acquisition point for session write handles. When `ct ### Turn and step flow -The driver owns one agent for its lifetime and runs inside `ctx.agents.withInitiator(agent, ...)`. `AgentLoop` registers the standard `inbox` projection for its service lifetime, so cold reads work before any Agent exists and after all Agents unload. Its package-internal `ReactLoopInbox` uses that shared projection for structural commands and loop-only claims. At a turn boundary it opens the durable turn, then atomically claims pending next-step input plus one queued prompt; between steps it claims only next-step input. The driver chooses the step route through `agent/route`, prepares variables and runtime context on that route, then runs `agent/pre-step`; its fallback projects that context. A rejected decision or empty first batch opens no step. Cancellation during routing opens no step. After acceptance, `step/start` precedes asynchronous tool activation and final prompt assembly, both once per step. Each attempt then runs the `agent/request` waterfall and `prepareCall()` on the step route; a listener that returns another provider or model fails the step, and cancellation during either async phase commits neither the pending system prompt nor accepted users. For every attempt, the loop then synchronously reconciles the rendered prompt against the surviving `system/message` nodes using the prepared call capability, refreshes registered runtime facts, appends the accepted `user/message` batch only on the first attempt with its owned context candidate replaced in place, and restores removed runtime context, logs the header and context as needed, and derives and freezes the request before streaming through that bound prepared call. Retries reuse the step's route, tool set, and rendered assembly without repeating routing, tool activation, assembly, `agent/pre-step`, or user admission. The step-owned tool set is closed after the step's tool batch settles. Reconciliation sees pre-step and retry compaction; a broken series consolidates the prompt at the head rather than appending an update after already-committed users. The request is `header.config`, `deriveMessages()`, and `header.tools`; it carries no `system` field. Each model attempt emits one process-local `start`, emits every `chunk` only after the matching durable assistant-frame settlement, and emits exactly one terminal `end`; final assembly or message-append failure settles it as `aborted`, while `committed` follows the durable `assistant/message`. Each successful model call appends one message anchor, and a cancelled stream appends an `interrupted: true` anchor with the delivered prefix so the next request contains what the user saw. Within a step, exclusive calls form barriers and parallel-safe calls use the bounded rolling pool; policy, durable results... (line truncated to 2000 chars) +The driver owns one agent for its lifetime and runs inside `ctx.agents.withInitiator(agent, ...)`. `AgentLoop` registers the standard `inbox` projection for its service lifetime, so cold reads work before any Agent exists and after all Agents unload. Its package-internal `ReactLoopInbox` uses that shared projection for structural commands and loop-only claims. At a turn boundary it opens the durable turn, then atomically claims pending next-step input plus one queued prompt; between steps it claims only next-step input. The driver chooses the step's provider, model, and reasoning effort through `agent/model`, prepares variables and runtime context with that selection, then runs `agent/pre-step`; its fallback projects that context. A rejected decision or empty first batch opens no step. Cancellation during model selection opens no step. After acceptance, `step/start` precedes asynchronous tool activation and final prompt assembly, both once per step. Each attempt then runs the `agent/request` waterfall and `prepareCall()` with the step's model selection; a listener that returns another provider, model, or reasoning effort fails the step, and cancellation during either async phase commits neither the pending system prompt nor accepted users. For every attempt, the loop then synchronously reconciles the rendered prompt against the surviving `system/message` nodes using the prepared call capability, refreshes registered runtime facts, appends the accepted `user/message` batch only on the first attempt with its owned context candidate replaced in place, and restores removed runtime context, logs the header and context as needed, and derives and freezes the request before streaming through that bound prepared call. Retries reuse the step's model selection, tool set, and rendered assembly without repeating model selection, tool activation, assembly, `agent/pre-step`, or user admission. The step-owned tool set is closed after the step's tool batch settles. Reconciliation sees pre-step and retry compaction; a broken series consolidates the prompt at the head rather than appending an update after already-committed users. The request is `header.config`, `deriveMessages()`, and `header.tools`; it carries no `system` field. Each model attempt emits one process-local `start`, emits every `chunk` only after the matching durable assistant-frame settlement, and emits exactly one terminal `end`; final assembly or message-append failure settles it as `aborted`, while `committed` follows the durable `assistant/message`. Each successful model call appends one message anchor, and a cancelled stream appends an `interrupted: true` anchor with the delivered prefix so the next request contains what the user saw. Within a step, exclusive calls form barriers and parallel-safe calls use the bounded rolling pool; policy, durable results... (line truncated to 2000 chars) Prompt admission uses the actual `prepareCall()` result, not the preceding `request/context`. With no system node, even an empty prompt is appended (reserving node 0 without a wire message). On an incapable route or at a new request series, a non-empty rendering is consolidated at the first system node: each non-empty later system node receives a logged per-node empty replacement, then the head is rewritten if needed. Dormant empty tails need no replacement and do not determine the effective text. Consolidation applies even when the latest effective text is unchanged. On a continuing `in-history` series, an unchanged effective prompt produces no event and a non-empty change appends. An empty rendering clears every non-empty later system node through a logged per-node empty replacement, then empties the head if needed, regardless of route or series state. No older instructions remain model-visible. An empty head with no active later system node means no prompt; repeated clears and resume leave it empty. A restored non-empty prompt follows the same route/series rule: a continuing capable route may append it, while an incapable route or new series refills the head. A step starts a series when the pre-step decision declares `startsRequestSeries` or `session.surface.contentGeneration` changed since attachment or the last request (a replacement or image-offload decision). Visible tool-schema changes force prompt consolidation only when the prepared route lacks `toolUpdate`. Routes supporting both tool and system-prompt updates can append a changed prompt and activate added tools without rewriting earlier messages. If another admission condition replaces a system node, the request starts a new series with current tool declarations. Resume and a provider or model swap alone continue the series; the prepared route still governs admission. Per-node empty replacements preserve intervening history without a surface delete operation. diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index ae052e9af1..6761613ee9 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 配置声明式 agent -配置中声明的 agent 会在插件加载时自动启动。每个条目需要一个 `id` 标签;模型调用还同时需要 `provider` 与 `model`(`agent/route` 可以为每个步骤补齐缺失的这一对值)。 +配置中声明的 agent 会在插件加载时自动启动。每个条目需要一个 `id` 标签;模型调用还同时需要 `provider` 与 `model`(`agent/model` 可以为每个步骤补齐缺失的这一对值)。 ```yaml - name: '@deepseek-ai/dsh-agent-loop' @@ -117,7 +117,7 @@ const handle = await ctx.agents.create({ ### 轮次与步骤流程 -驱动器在其整个生命周期内拥有一个 agent,并在 `ctx.agents.withInitiator(agent, ...)` 内运行。`AgentLoop` 在服务生命周期内注册标准 `inbox` 投影,因此冷读取在没有 Agent 和所有 Agent 卸载后都可用。其包内部 `ReactLoopInbox` 使用该共享投影执行结构化命令与仅供 loop 使用的领取操作。在轮次边界,它先打开持久轮次,再原子领取待处理的 next-step 输入与一条排队提示词;在步骤之间则只领取 next-step 输入。驱动器通过 `agent/route` 选定步骤路由,在该路由上准备变量和运行时上下文,然后运行 `agent/pre-step`;fallback 投影该上下文。被拒绝的决定或空的首批输入不打开步骤。路由选择期间取消不会打开步骤。接纳后,`step/start` 先于异步工具激活与最终提示词组装,两者每个步骤各执行一次。每次尝试随后在步骤路由上运行 `agent/request` waterfall 与 `prepareCall()`;监听器若返回其他提供方或模型,步骤会失败,而在任一异步阶段取消都不会提交待定的系统提示词或已接纳用户消息。每次尝试时,循环随后依据已准备调用的能力,同步将渲染后的提示词与存活的 `system/message` 节点协调一致、刷新已注册运行时事实、仅在首次尝试追加已接纳的 `user/message` 批次并原位替换循环自有上下文候选、恢复被移除的运行时上下文、按需记录 header 与 context,再派生并冻结请求,通过该绑定的已准备调用发起流式请求。重试复用该步骤的路由、工具集合与已渲染组装结果,不重复路由选择、工具激活、组装、`agent/pre-step` 或用户消息接纳。步骤拥有的工具集合在该步骤的工具批次结束后关闭。协调过程可见 pre-step 与重试中的压缩;序列中断时将提示词归并到头部,而非在已提交用户消息之后追加更新。请求由 `header.config`、`deriveMessages()` 与 `header.tools` 构成,不携带 `system` 字段。每次模型尝试会发出一个进程本地 `start`,仅在匹配的持久 assistant-frame 结算之后发出各个 `chunk`,并恰好发出一个终态 `end`;最终组装或消息追加失败时以 `aborted` 结算,`committed` 则出现在持久 `assistant/message` 之后。每次成功的模型调用都恰好追加一个 message 锚点,被取消的流则追加带 `interrupted: true` 的锚点并携带已交付前缀,使下一次请求包含用户看到的内容。在步骤内,独占调用形成屏障,并行安全调用使用有界滚动池;策略、持久结果与结果上下文保持模型顺序。 +驱动器在其整个生命周期内拥有一个 agent,并在 `ctx.agents.withInitiator(agent, ...)` 内运行。`AgentLoop` 在服务生命周期内注册标准 `inbox` 投影,因此冷读取在没有 Agent 和所有 Agent 卸载后都可用。其包内部 `ReactLoopInbox` 使用该共享投影执行结构化命令与仅供 loop 使用的领取操作。在轮次边界,它先打开持久轮次,再原子领取待处理的 next-step 输入与一条排队提示词;在步骤之间则只领取 next-step 输入。驱动器通过 `agent/model` 选定步骤的提供方、模型与推理强度,按该选择准备变量和运行时上下文,然后运行 `agent/pre-step`;fallback 投影该上下文。被拒绝的决定或空的首批输入不打开步骤。模型选择期间取消不会打开步骤。接纳后,`step/start` 先于异步工具激活与最终提示词组装,两者每个步骤各执行一次。每次尝试随后按步骤的模型选择运行 `agent/request` waterfall 与 `prepareCall()`;监听器若返回其他提供方、模型或推理强度,步骤会失败,而在任一异步阶段取消都不会提交待定的系统提示词或已接纳用户消息。每次尝试时,循环随后依据已准备调用的能力,同步将渲染后的提示词与存活的 `system/message` 节点协调一致、刷新已注册运行时事实、仅在首次尝试追加已接纳的 `user/message` 批次并原位替换循环自有上下文候选、恢复被移除的运行时上下文、按需记录 header 与 context,再派生并冻结请求,通过该绑定的已准备调用发起流式请求。重试复用该步骤的模型选择、工具集合与已渲染组装结果,不重复模型选择、工具激活、组装、`agent/pre-step` 或用户消息接纳。步骤拥有的工具集合在该步骤的工具批次结束后关闭。协调过程可见 pre-step 与重试中的压缩;序列中断时将提示词归并到头部,而非在已提交用户消息之后追加更新。请求由 `header.config`、`deriveMessages()` 与 `header.tools` 构成,不携带 `system` 字段。每次模型尝试会发出一个进程本地 `start`,仅在匹配的持久 assistant-frame 结算之后发出各个 `chunk`,并恰好发出一个终态 `end`;最终组装或消息追加失败时以 `aborted` 结算,`committed` 则出现在持久 `assistant/message` 之后。每次成功的模型调用都恰好追加一个 message 锚点,被取消的流则追加带 `interrupted: true` 的锚点并携带已交付前缀,使下一次请求包含用户看到的内容。在步骤内,独占调用形成屏障,并行安全调用使用有界滚动池;策略、持久结果与结果上下文保持模型顺序。 提示词准入依据实际的 `prepareCall()` 结果,而非先前的 `request/context`。没有系统节点时,即使提示词为空也追加(预留第 0 号节点,但不产生协议消息)。在不具备能力的路由上或新请求序列开始时,非空渲染文本归并到首个系统节点:每个非空的后续系统节点分别收到有日志记录的空内容替换,随后按需重写头节点。未生效的空尾节点无需替换,也不决定有效文本。即使最新有效文本未变,也执行归并。延续中的 `in-history` 序列在有效提示词不变时不产生事件,非空变更则追加。无论路由或序列状态如何,空渲染文本都会通过有日志记录的逐节点空内容替换清除每个非空的后续系统节点,再按需清空头节点。模型不会继续看到旧指令。空头节点且没有生效的后续系统节点表示没有提示词;重复清除与恢复会话都保持为空。重新提供的非空提示词遵循同一路由/序列规则:延续中的具备能力路由可以追加它,不具备能力的路由或新序列则重新填充头节点。以下情况开启序列:pre-step 决定声明 `startsRequestSeries`,或 `session.surface.contentGeneration` 自附接或上次请求以来发生变化(替换或图片省略决定)。仅当已准备路由未声明 `toolUpdate` 时,可见工具 schema 变化才强制归并提示词。同时支持工具和系统提示词更新的路由可以追加变更后的提示词并激活新增工具,无需改写此前消息。如果其他准入条件替换系统节点,请求会以当前工具声明开启新序列。恢复与单纯的提供方或模型切换都延续序列;准入仍由已准备的路由决定。逐节点的空内容替换保留其间历史,无需 surface 删除操作。 diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 25858436fc..2eac31c365 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -12,9 +12,9 @@ import type { AgentStatus, CancelOptions, InboxTarget, + ModelSelection, PreStepDecision, RequestErrorAction, - StepRoute, } from '@deepseek-ai/dsh-agent' import { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent' import type { GenerateOptions, LlmCallConfig, Message, PreparedLlmCall } from '@deepseek-ai/dsh-llm' @@ -64,10 +64,15 @@ type PreparedStep = kind: 'enter' messages: UserMessage[] startsRequestSeries?: true - route: StepRoute + modelSelection: ModelSelection preparation: PromptPreparation } +/** Name a provider, model, and reasoning effort in diagnostics. */ +function selectionLabel({ provider, model, reasoningEffort }: ModelSelection): string { + return `${provider}/${model} (reasoning effort ${reasoningEffort ?? 'unset'})` +} + /** Remove adapter-derived values before plugins propose the next request config. */ function requestProposal(header: EpochHeader): LlmCallConfig { if (header.adapterDefaults === undefined) return header.config @@ -277,11 +282,11 @@ export class ReactLoopAgent implements Agent { if (this.phase.kind !== 'running') throw new Error(`agent "${this.id}": pre-step outside running phase`) const signal = this.phase.abort.signal const claimed = this.inbox.claim(target, position.turn) - const route = await this.chooseRoute(position.turn, position.step, signal) - const preparation = await this.loopCtx.systemPrompt.prepare({ ...assembleContextFor(this, signal), route }) + const modelSelection = await this.chooseModel(position.turn, position.step, signal) + const preparation = await this.loopCtx.systemPrompt.prepare({ ...assembleContextFor(this, signal), modelSelection }) signal.throwIfAborted() const decision = await this.dispatch.waterfall( - 'agent/pre-step', { messages: claimed, ...position, route, signal }, + 'agent/pre-step', { messages: claimed, ...position, modelSelection, signal }, (): Promise => { const sections = renderContextSections(preparation) const context = this.runtimeContext.project(joinContextSections(sections), sections) @@ -293,21 +298,26 @@ export class ReactLoopAgent implements Agent { ) signal.throwIfAborted() if (decision.kind === 'reject') return decision - return { ...decision, route, preparation } + return { ...decision, modelSelection, preparation } } - /** Choose the provider and model that the proposed step keeps for every request attempt. */ - private async chooseRoute(turn: number, step: number, signal: AbortSignal): Promise { - const { provider, model } = this.requestBase() - const route = await this.dispatch.waterfall( - 'agent/route', { turn, step, signal }, - () => Promise.resolve({ provider, model }), + /** Choose the provider, model, and effort that the proposed step keeps for every request attempt. */ + private async chooseModel(turn: number, step: number, signal: AbortSignal): Promise { + const { provider, model, reasoningEffort } = this.requestBase() + const proposed: ModelSelection = { provider, model, ...reasoningEffort === undefined ? {} : { reasoningEffort } } + const selected = await this.dispatch.waterfall( + 'agent/model', { turn, step, signal }, + () => Promise.resolve(proposed), ) signal.throwIfAborted() - if (!route.provider || !route.model) { - throw new Error(`agent "${this.id}" has no provider/model: set AgentOptions.provider and AgentOptions.model or supply both via the agent/route waterfall`) + if (!selected.provider || !selected.model) { + throw new Error(`agent "${this.id}" has no provider/model: set AgentOptions.provider and AgentOptions.model or supply both via the agent/model waterfall`) } - return Object.freeze({ provider: route.provider, model: route.model }) + return Object.freeze({ + provider: selected.provider, + model: selected.model, + ...selected.reasoningEffort === undefined ? {} : { reasoningEffort: selected.reasoningEffort }, + }) } /** Whether the assembled tool schemas differ from the logged request header's. */ @@ -424,18 +434,18 @@ export class ReactLoopAgent implements Agent { const { turn, step, abort: { signal } } = this.phase signal.throwIfAborted() - const { route } = decision - const toolSet = await this.loopCtx.tools.resolve({ agent: this, route, signal }) + const { modelSelection } = decision + const toolSet = await this.loopCtx.tools.resolve({ agent: this, modelSelection, signal }) try { const assembly = await this.loopCtx.systemPrompt.assemble( - { ...assembleContextFor(this, signal), route, toolSet }, + { ...assembleContextFor(this, signal), modelSelection, toolSet }, decision.preparation, ) const renderedPrompt = renderPrompt(assembly) let firstAttempt = true while (true) { - const { config, preparedCall } = await this.prepareRequest(turn, step, route, signal) - const currentContext = this.loopCtx.systemPrompt.refreshContext(assembly, { ...assembleContextFor(this, signal), route }) + const { config, preparedCall } = await this.prepareRequest(turn, step, modelSelection, signal) + const currentContext = this.loopCtx.systemPrompt.refreshContext(assembly, { ...assembleContextFor(this, signal), modelSelection }) const sections = renderContextSections(currentContext) const context = this.runtimeContext.project(joinContextSections(sections), sections) signal.throwIfAborted() @@ -593,8 +603,9 @@ export class ReactLoopAgent implements Agent { /** * The config this loop instance proposes before plugins act: its declared - * route on the first request, restoring only an explicit effort owned by - * that exact model, then the logged header without adapter defaults. + * provider and model on the first request, restoring only an explicit + * effort owned by that exact model, then the logged header without adapter + * defaults. */ private requestBase(): LlmCallConfig { const persistedHeader = this.session.requestHeader() @@ -616,27 +627,24 @@ export class ReactLoopAgent implements Agent { } } - /** Resolve one attempt's config on the step route and bind its adapter before admitting model-visible input. */ + /** Resolve one attempt's config on the step's model selection and bind its adapter before admitting model-visible input. */ private async prepareRequest( turn: number, step: number, - route: StepRoute, + modelSelection: ModelSelection, signal: AbortSignal, ): Promise { - let seed = this.requestBase() - if (seed.provider !== route.provider || seed.model !== route.model) { - // An effort belongs to one model; another route starts from its own default. - const { reasoningEffort: _effort, ...controls } = seed - seed = { ...controls, provider: route.provider, model: route.model } - } - const seedConfig = deepFreeze(structuredClone(seed)) + const { provider: _provider, model: _model, reasoningEffort: _effort, ...controls } = this.requestBase() + const seedConfig = deepFreeze(structuredClone({ ...controls, ...modelSelection })) const proposedConfig = await this.dispatch.waterfall( 'agent/request', { turn, step, signal }, () => Promise.resolve(seedConfig), ) signal.throwIfAborted() - if (proposedConfig.provider !== route.provider || proposedConfig.model !== route.model) { - throw new Error(`agent "${this.id}": agent/request cannot replace the step route ${route.provider}/${route.model} with ${proposedConfig.provider}/${proposedConfig.model}; choose routes with the agent/route waterfall`) + if (proposedConfig.provider !== modelSelection.provider + || proposedConfig.model !== modelSelection.model + || proposedConfig.reasoningEffort !== modelSelection.reasoningEffort) { + throw new Error(`agent "${this.id}": agent/request cannot replace the step model selection ${selectionLabel(modelSelection)} with ${selectionLabel(proposedConfig)}; choose models with the agent/model waterfall`) } let config: LlmCallConfig let preparedCall: PreparedLlmCall | undefined diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 9fd38c9742..34e7879654 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -386,8 +386,8 @@ export class AgentLoop extends Service implements AgentFactory { }) }, 'agentLoop.transactions()') ctx.effect(() => ctx.agents.setFactory(this), 'agentLoop.setFactory()') - ctx.systemPrompt.variable('provider', context => context.route?.provider ?? context.agent?.options.provider) - ctx.systemPrompt.variable('model', context => context.route?.model ?? context.agent?.options.model) + ctx.systemPrompt.variable('provider', context => context.modelSelection?.provider ?? context.agent?.options.provider) + ctx.systemPrompt.variable('model', context => context.modelSelection?.model ?? context.agent?.options.model) for (const { id, sessionId, cwd, resumeSessionId, ...options } of this.config.agents) { const meta = cwd === undefined ? {} : { cwd } diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 84d702d79c..cfad2fd312 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import LlmRuntime, { type RequestMessage, createUserMessage, ToolCallId, LlmError, MessageSource, ProviderRequestId, StreamChunk } from '@deepseek-ai/dsh-llm' +import LlmRuntime, { type RequestMessage, createUserMessage, ToolCallId, LlmError, MessageSource, ProviderRequestId, ReasoningEffortId, StreamChunk } from '@deepseek-ai/dsh-llm' import type { ContextFormed } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason, type UserMessage } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -450,17 +450,17 @@ describe('adapter registration, routing, and accepted-input ownership', () => { : undefined).toContain('has no provider/model') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error' ? turnEnd.data.reason.error.message - : undefined).toContain('agent/route') + : undefined).toContain('agent/model') expect(agent.session.snapshotEvents().some(event => event.type === 'step/start')).toBe(false) expect(adapter.requests).toHaveLength(0) }) - it('the agent/route waterfall can supply the model for a model-less agent', async () => { + it('the agent/model waterfall can supply the model for a model-less agent', async () => { const adapter = new MockAdapter([textResponse('routed')]) const ctx = await harness(adapter) const agent = await ctx.agentLoop.create(SessionId('a1'), {}) // no model — router plugin decides - ctx.on('agent/route', async (_payload, next) => { + ctx.on('agent/model', async (_payload, next) => { return { ...await next(), provider: 'mock', model: 'mock' } }) @@ -470,19 +470,22 @@ describe('adapter registration, routing, and accepted-input ownership', () => { expect(agent.session.deriveMessages().at(-1)?.content).toEqual([{ type: 'text', text: 'routed' }]) }) - it('agent/request cannot replace the step route', async () => { + it.each([ + ['provider', { provider: 'other' }, 'other/mock (reasoning effort unset)'], + ['reasoning effort', { reasoningEffort: ReasoningEffortId('high') }, 'mock/mock (reasoning effort high)'], + ] as const)('agent/request cannot replace the step %s', async (_field, replacement, replaced) => { const adapter = new MockAdapter([textResponse('never')]) const ctx = await harness(adapter) ctx.llm.registerAdapter(['other'], new MockAdapter([textResponse('never')])) const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/request', async (_payload, next) => ({ ...await next(), provider: 'other' })) + ctx.on('agent/request', async (_payload, next) => ({ ...await next(), ...replacement })) send(agent, 'go') await waitForIdle(ctx, agent) const turnEnd = agent.session.snapshotEvents().findLast(event => event.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error' ? turnEnd.data.reason.error.message - : undefined).toContain('agent/request cannot replace the step route mock/mock with other/mock') + : undefined).toContain(`agent/request cannot replace the step model selection mock/mock (reasoning effort unset) with ${replaced}; choose models with the agent/model waterfall`) expect(agent.session.snapshotEvents().filter(event => ['request/header', 'system/message', 'user/message'].includes(event.type))) .toEqual([]) expect(adapter.requests).toHaveLength(0) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 6f992a1c68..193544367f 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -602,12 +602,12 @@ describe('agent loop', () => { expect(turnEnds[1]?.type === 'turn/end' && turnEnds[1].data.reason.kind).toBe('completed') }) - it('renders a {{model}} persona from the route an agent/route listener supplies', async () => { - // AgentOptions.model unset: the step route arrives through agent/route, + it('renders a {{model}} persona from the model an agent/model listener supplies', async () => { + // AgentOptions.model unset: the step model arrives through agent/model, // before prompt preparation, so the prompt variables already name it. const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter, 'You run on {{model}}.') - ctx.on('agent/route', async (_payload, next) => ({ ...await next(), provider: 'mock', model: 'mock' })) + ctx.on('agent/model', async (_payload, next) => ({ ...await next(), provider: 'mock', model: 'mock' })) const agent = await ctx.agentLoop.create(SessionId('a-late-model'), {}) send(agent, 'hi') @@ -1298,20 +1298,26 @@ describe('agent loop', () => { expect(texts).toContain('late steering') }) - it('agent/route waterfall switches models for the step; the switch is logged', async () => { - const adapter = new MockAdapter([textResponse('ok')]) + it('agent/model waterfall switches model and effort for the step; the switch is logged', async () => { + const low = ReasoningEffortId('low') + const high = ReasoningEffortId('high') + const adapter = new MockAdapter([textResponse('ok')], { + efforts: [{ id: low, name: 'Low' }, { id: high, name: 'High' }], + defaultEffort: high, + }) const ctx = await harness(adapter) const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/route', async (_payload, next) => ({ ...await next(), model: 'other-model' })) + ctx.on('agent/model', async () => ({ provider: 'mock', model: 'other-model', reasoningEffort: low })) send(agent, 'hi') await waitForIdle(ctx, agent) - expect(adapter.requests[0]!.model).toBe('other-model') + expect(adapter.requests[0]).toMatchObject({ model: 'other-model', reasoningEffort: low }) // The header event records what the request ACTUALLY used — the switch is // a reconstructable fact, not silent drift. const headerEvent = agent.session.snapshotEvents().find(e => e.type === 'request/header') - expect(headerEvent?.type === 'request/header' && headerEvent.data.header.config.model).toBe('other-model') + expect(headerEvent?.type === 'request/header' && headerEvent.data.header.config) + .toMatchObject({ model: 'other-model', reasoningEffort: low }) }) it('agent/request waterfall replaces call controls with a replacement config; the change is logged', async () => { diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index 7c0fe9b60e..5f81fe1469 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -218,9 +218,9 @@ describe('request stability across the loop', () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')], reasoning) const ctx = await harness(adapter) const agent = await ctx.agentLoop.create(SessionId('effort'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/request', async ({ turn }, next) => { - const config = await next() - return turn === 2 ? { ...config, reasoningEffort: ReasoningEffortId('max') } : config + ctx.on('agent/model', async ({ turn }, next) => { + const selection = await next() + return turn === 2 ? { ...selection, reasoningEffort: ReasoningEffortId('max') } : selection }) send(agent, 'first') @@ -294,9 +294,9 @@ describe('request stability across the loop', () => { provider: 'deepseek', model: 'deepseek-model', }) - ctx.on('agent/route', async ({ turn }, next) => { - const route = await next() - return turn === 2 ? { provider: 'other', model: 'other-model' } : route + ctx.on('agent/model', async ({ turn }, next) => { + const proposed = await next() + return turn === 2 ? { provider: 'other', model: 'other-model' } : proposed }) send(agent, 'first') @@ -326,9 +326,9 @@ describe('request stability across the loop', () => { model: 'deepseek-model', maxTokens: 4_096, }) - ctx.on('agent/route', async ({ turn }, next) => { - const route = await next() - return turn === 2 ? { provider: 'other', model: 'other-model' } : route + ctx.on('agent/model', async ({ turn }, next) => { + const proposed = await next() + return turn === 2 ? { provider: 'other', model: 'other-model' } : proposed }) send(agent, 'first') @@ -944,7 +944,7 @@ describe('request/context capacity records', () => { send(agent, 'first') await waitForIdle(ctx, agent) - ctx.on('agent/route', ({ agent: subject }, next) => subject === agent + ctx.on('agent/model', ({ agent: subject }, next) => subject === agent ? Promise.resolve({ provider: 'mock', model: 'large' }) : next()) send(agent, 'second') @@ -972,7 +972,7 @@ describe('request/context capacity records', () => { const ctx = await harness(adapter) const agent = await ctx.agentLoop.create(SessionId('capacity-clear'), { provider: 'mock', model: 'known' }) let model = 'known' - ctx.on('agent/route', ({ agent: subject }, next) => subject === agent + ctx.on('agent/model', ({ agent: subject }, next) => subject === agent ? Promise.resolve({ provider: 'mock', model }) : next()) diff --git a/packages/core/agent-loop/tests/system-prompt-admission.spec.ts b/packages/core/agent-loop/tests/system-prompt-admission.spec.ts index b904088576..24d7783174 100644 --- a/packages/core/agent-loop/tests/system-prompt-admission.spec.ts +++ b/packages/core/agent-loop/tests/system-prompt-admission.spec.ts @@ -39,7 +39,7 @@ async function harness(capable = new MockAdapter(Array.from({ length: 8 }, () => ctx.on('system-prompt/assemble', async (_assembly, _context, next) => ({ ...await next(), sections: [{ name: 'test', text: prompt, order: 0 }], })) - const selection: ModelSelectionRef = { current: { provider: 'capable', model: 'model' }, assembled: undefined } + const selection: ModelSelectionRef = { current: { provider: 'capable', model: 'model' } } const agent = await ctx.agentLoop.create(SessionId('admission'), { provider: 'capable', model: 'model' }) installModelSelection(agent.ctx, selection) ctx.on('llm/stream', (request, next) => { @@ -398,11 +398,11 @@ describe('prepared-route prompt admission', () => { expect(types.filter(type => ['system/message', 'user/message', 'request/header'].includes(type))).toEqual([]) }) - it('cancels during route before a step opens', async () => { + it('cancels during model selection before a step opens', async () => { const h = await harness() const entered = Promise.withResolvers() const release = Promise.withResolvers() - h.ctx.on('agent/route', async (_payload, next) => { + h.ctx.on('agent/model', async (_payload, next) => { expect(h.agent.session.snapshotEvents().map(event => event.type)).not.toContain('step/start') entered.resolve(undefined) await release.promise diff --git a/packages/core/agent-loop/tests/tool-updates.spec.ts b/packages/core/agent-loop/tests/tool-updates.spec.ts index 5b7e4157d2..4044aa1e92 100644 --- a/packages/core/agent-loop/tests/tool-updates.spec.ts +++ b/packages/core/agent-loop/tests/tool-updates.spec.ts @@ -114,35 +114,35 @@ describe('tool update emission', () => { }) describe('step-time tool activation', () => { - it('uses the step route for asynchronous conditions and rejects a historical name after removal', async () => { + 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 routed: string[] = [] + const selectedModels: string[] = [] let calls = 0 let model = 'enabled' - ctx.on('agent/route', async (_payload, next) => ({ ...await next(), model })) + ctx.on('agent/model', async (_payload, next) => ({ ...await next(), model })) ctx.tools.register(defineContentToolFixture({ - name: 'conditional', description: 'Available on one route.', parameters: {}, + 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 ({ route }) => { - expect(Object.isFrozen(route)).toBe(true) - routed.push(route.model) + }), { when: async ({ modelSelection }) => { + expect(Object.isFrozen(modelSelection)).toBe(true) + selectedModels.push(modelSelection.model) await Promise.resolve() - return route.model === 'enabled' + return modelSelection.model === 'enabled' } }) await send(agent, 'enabled') model = 'disabled' await send(agent, 'try the historical name') expect(calls).toBe(1) - expect(routed).toEqual(['enabled', 'enabled', 'disabled', 'disabled']) - expect(adapter.requests.map(request => request.model)).toEqual(routed) + 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) @@ -167,17 +167,17 @@ describe('step-time tool activation', () => { expect(agent.session.snapshotEvents().filter(event => ['request/header', 'system/message', 'user/message'].includes(event.type))).toEqual([]) }) - it('keeps one route, tool set, and prompt across retries without admitting users again', async () => { + 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 = { routes: 0, conditions: 0, admissions: 0 } + const counts = { selections: 0, conditions: 0, admissions: 0 } ctx.on('agent/pre-step', (_input, next) => { counts.admissions++; return next() }) - ctx.on('agent/route', async (_input, next) => { - counts.routes++ + ctx.on('agent/model', async (_input, next) => { + counts.selections++ return { ...await next(), model: retried ? 'second' : 'first' } }) ctx.on('agent/request-error', async (_input, next) => { @@ -191,14 +191,14 @@ describe('step-time tool activation', () => { }), { when: () => { counts.conditions++; return retried } }) ctx.systemPrompt.section({ name: 'retry:guidance', order: 0, complete: true, - text: context => `${context.route?.model}:${ctx.tools.schemas(agent, context.toolSet).map(tool => tool.name).join(',')}`, + 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({ routes: 1, conditions: 1, admissions: 1 }) + expect(counts).toEqual({ selections: 1, conditions: 1, admissions: 1 }) expect(agent.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind === 'user')).toHaveLength(1) }) diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index e7e1759e5f..5d7936aae2 100644 --- a/packages/core/agent/README.i18n.yaml +++ b/packages/core/agent/README.i18n.yaml @@ -27,8 +27,8 @@ en: 2594a1e02adea801 zh: cae8860369728efb /deepseek-ai-dsh-agent/use-this-package/intercept-or-observe-work-in-flight: - en: 5d700c9eed84bcf5 - zh: a0498f1814bfee05 + en: bb854bd10523e158 + zh: b59580450c764598 /deepseek-ai-dsh-agent/understand-the-implementation: en: ce7e776e9d516bdb zh: 59d2751743db7795 @@ -36,14 +36,14 @@ en: 7c850051d2857bfa zh: 4cab817553114e15 /deepseek-ai-dsh-agent/understand-the-implementation/step-admission: - en: edd4ed5033c294e7 - zh: 7c627497fe450702 + en: 324af18f63a4f537 + zh: eec28e7f8362a919 /deepseek-ai-dsh-agent/understand-the-implementation/durable-inbox: en: f6802c7f94f623b3 zh: cc3885db80765970 /deepseek-ai-dsh-agent/understand-the-implementation/source-map: - en: 8efa6e74d4dbe28e - zh: 6126170ac464703f + en: 7b78f956b98d7ef1 + zh: c9b063505ddd9f03 /deepseek-ai-dsh-agent/understand-the-implementation/registry-and-lifecycle: en: df92e97cedd0d02e zh: f6040c145bffd933 diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 0ca04dd4e4..a7b91fc1b4 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -64,7 +64,7 @@ await handle.agent.whenIdle() ### Intercept or observe work in flight -The `agent/*` events let plugins act on live work without depending on the loop package. `agent/route` chooses each step's provider and model; `agent/pre-step` can reject a proposed step or replace the messages entering it; `agent/request-error` lets a listener retry a failed model request; `agent/turn-stopping` runs before an otherwise completed turn closes and can steer to keep it open. `agent/assistant-stream` carries one process-local Assistant attempt's ordered start, transient chunk, and end frames. Start names the attempt's turn and step, chunk indexes are dense from zero, and `end.index` is the next chunk position. The loop commits the complete compact stream as one `assistant/message` or `assistant/attempt` before a committed end frame, so the live event remains presentation data rather than the replay source. `agent/status`, `agent/created`, and `agent/disposed` drive UI and coordination state, and the per-message `agent/inbox/*` notifications keep inbox projections in sync. Exact signatures, dispatch modes, and payload contracts live in the generated region of the [core subsystem page](../../../docs/subsystems/core.md#cordis-surface). +The `agent/*` events let plugins act on live work without depending on the loop package. `agent/model` chooses each step's provider, model, and reasoning effort; `agent/pre-step` can reject a proposed step or replace the messages entering it; `agent/request-error` lets a listener retry a failed model request; `agent/turn-stopping` runs before an otherwise completed turn closes and can steer to keep it open. `agent/assistant-stream` carries one process-local Assistant attempt's ordered start, transient chunk, and end frames. Start names the attempt's turn and step, chunk indexes are dense from zero, and `end.index` is the next chunk position. The loop commits the complete compact stream as one `assistant/message` or `assistant/attempt` before a committed end frame, so the live event remains presentation data rather than the replay source. `agent/status`, `agent/created`, and `agent/disposed` drive UI and coordination state, and the per-message `agent/inbox/*` notifications keep inbox projections in sync. Exact signatures, dispatch modes, and payload contracts live in the generated region of the [core subsystem page](../../../docs/subsystems/core.md#cordis-surface). ----- @@ -82,9 +82,9 @@ The package is built on one separation: the public `Agent` surface and registry ### Step admission -`PreStepDecision` is either `{ kind: 'reject' }` or `{ kind: 'enter', messages, startsRequestSeries? }`. The enter branch contains the complete identified, frozen message batch. `agent/route` chooses the step's provider and model before `agent/pre-step`, whose payload carries that `route`. Acceptance does not commit the batch: after `step/start`, tool conditions, prompt assembly, `agent/request`, and `prepareCall()` must succeed before system and user messages are logged. Cancellation during routing opens no step, and cancellation during a later async phase commits neither message kind. `startsRequestSeries: true` declares a distinct model-message series; a wrapping listener preserves that declaration and the batch unless it intentionally replaces either one. Claiming removes offered messages from the inbox, while messages inserted after the claim remain pending for a later boundary. +`PreStepDecision` is either `{ kind: 'reject' }` or `{ kind: 'enter', messages, startsRequestSeries? }`. The enter branch contains the complete identified, frozen message batch. `agent/model` chooses the step's provider, model, and reasoning effort before `agent/pre-step`, whose payload carries that `modelSelection`. Acceptance does not commit the batch: after `step/start`, tool conditions, prompt assembly, `agent/request`, and `prepareCall()` must succeed before system and user messages are logged. Cancellation during model selection opens no step, and cancellation during a later async phase commits neither message kind. `startsRequestSeries: true` declares a distinct model-message series; a wrapping listener preserves that declaration and the batch unless it intentionally replaces either one. Claiming removes offered messages from the inbox, while messages inserted after the claim remain pending for a later boundary. -The step route supplies or replaces the creation-time options pair. Prompt preparation, `agent/pre-step`, tool conditions, and every request attempt in the step use it; an `agent/request` listener that returns another provider or model fails the step. `installModelSelection` captures its selection when `agent/route` runs, so a selection made during a step applies from the next step. +The step's model selection supplies or replaces the creation-time provider, model, and effort. Prompt preparation, `agent/pre-step`, tool conditions, and every request attempt in the step use it; an `agent/request` listener that returns another provider, model, or reasoning effort fails the step. `installModelSelection` returns its current selection, effort included, when `agent/model` runs, so a selection made during a step applies from the next step. ### Durable inbox @@ -101,7 +101,7 @@ The step route supplies or replaces the creation-time options pair. Prompt prepa | [`src/types.ts`](src/types.ts) | `AgentOptions`, cancellation causes, and inbox projection vocabulary | | [`src/dispatch.ts`](src/dispatch.ts) | `agentEvents` fused dispatcher and `assembleContextFor(agent)` | | [`src/consumed-work.ts`](src/consumed-work.ts) | `foldConsumedWork(events)`: what the log's consumed work became | -| [`src/model-selection.ts`](src/model-selection.ts) | `installModelSelection`: capturing a selection for step routing | +| [`src/model-selection.ts`](src/model-selection.ts) | `installModelSelection`: applying a selection to each step's model choice | | [`src/archive-admission.ts`](src/archive-admission.ts) | The `turn` family of the Workspace registry's archive admission: a running turn and its user-cause cancel | ### Registry and lifecycle diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index c8f9a23c29..becdb8417b 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -64,7 +64,7 @@ await handle.agent.whenIdle() ### 拦截或观察进行中的工作 -`agent/*` 事件让插件无需依赖循环包即可作用于实时工作。`agent/route` 选定每个步骤的提供方与模型;`agent/pre-step` 可以拒绝拟进入的步骤或替换进入它的消息;`agent/request-error` 让监听器重试失败的模型请求;`agent/turn-stopping` 在本可完成的轮次关闭前运行,并可通过 steer 使其保持打开。`agent/assistant-stream` 携带一个进程本地 Assistant attempt 的有序 start、瞬态分片与 end frame。start 给出该 attempt 的轮次与步骤,分片索引从零开始密集递增,`end.index` 则是下一个分片位置。loop 会在 committed end frame 前把完整紧凑流提交为一个 `assistant/message` 或 `assistant/attempt`,因此实时事件仍是呈现数据而非回放来源。`agent/status`、`agent/created` 与 `agent/disposed` 驱动 UI 与协调状态,逐消息的 `agent/inbox/*` 通知则让收件箱投影保持同步。确切签名、分发 mode 与 payload 约定见 [core 子系统页](../../../docs/subsystems/core.zh.md#cordis-surface) 的生成区块。 +`agent/*` 事件让插件无需依赖循环包即可作用于实时工作。`agent/model` 选定每个步骤的提供方、模型与推理强度;`agent/pre-step` 可以拒绝拟进入的步骤或替换进入它的消息;`agent/request-error` 让监听器重试失败的模型请求;`agent/turn-stopping` 在本可完成的轮次关闭前运行,并可通过 steer 使其保持打开。`agent/assistant-stream` 携带一个进程本地 Assistant attempt 的有序 start、瞬态分片与 end frame。start 给出该 attempt 的轮次与步骤,分片索引从零开始密集递增,`end.index` 则是下一个分片位置。loop 会在 committed end frame 前把完整紧凑流提交为一个 `assistant/message` 或 `assistant/attempt`,因此实时事件仍是呈现数据而非回放来源。`agent/status`、`agent/created` 与 `agent/disposed` 驱动 UI 与协调状态,逐消息的 `agent/inbox/*` 通知则让收件箱投影保持同步。确切签名、分发 mode 与 payload 约定见 [core 子系统页](../../../docs/subsystems/core.zh.md#cordis-surface) 的生成区块。 ----- @@ -82,9 +82,9 @@ await handle.agent.whenIdle() ### 步骤准入 -`PreStepDecision` 要么是 `{ kind: 'reject' }`,要么是 `{ kind: 'enter', messages, startsRequestSeries? }`。enter 分支包含完整、带标识且冻结的消息批次。`agent/route` 在 `agent/pre-step` 之前选定该步骤的提供方与模型,`agent/pre-step` 的 payload 携带该 `route`。接纳不等于提交:`step/start` 之后,工具条件、提示词组装、`agent/request` 与 `prepareCall()` 都成功后,才记录系统提示词与用户批次。在路由选择期间取消不会打开步骤,在之后任一异步阶段取消也不会提交这两种消息。`startsRequestSeries: true` 声明一个独立的模型消息序列;包装下游 enter 的监听器会保留该声明与批次,除非有意替换其中一项。领取会从 inbox 移除候选消息,领取后插入的消息则等待后续边界。 +`PreStepDecision` 要么是 `{ kind: 'reject' }`,要么是 `{ kind: 'enter', messages, startsRequestSeries? }`。enter 分支包含完整、带标识且冻结的消息批次。`agent/model` 在 `agent/pre-step` 之前选定该步骤的提供方、模型与推理强度,`agent/pre-step` 的 payload 携带该 `modelSelection`。接纳不等于提交:`step/start` 之后,工具条件、提示词组装、`agent/request` 与 `prepareCall()` 都成功后,才记录系统提示词与用户批次。在模型选择期间取消不会打开步骤,在之后任一异步阶段取消也不会提交这两种消息。`startsRequestSeries: true` 声明一个独立的模型消息序列;包装下游 enter 的监听器会保留该声明与批次,除非有意替换其中一项。领取会从 inbox 移除候选消息,领取后插入的消息则等待后续边界。 -步骤路由会补上或替换创建时 options 中的这对值。提示词准备、`agent/pre-step`、工具条件以及该步骤的每次请求尝试都使用它;`agent/request` 监听器若返回其他提供方或模型,步骤会失败。`installModelSelection` 在 `agent/route` 运行时捕获其选择,因此步骤进行中做出的选择从下一个步骤开始生效。 +步骤的模型选择会补上或替换创建时的提供方、模型与推理强度。提示词准备、`agent/pre-step`、工具条件以及该步骤的每次请求尝试都使用它;`agent/request` 监听器若返回其他提供方、模型或推理强度,步骤会失败。`installModelSelection` 在 `agent/model` 运行时返回当前选择(含推理强度),因此步骤进行中做出的选择从下一个步骤开始生效。 ### 持久 inbox @@ -101,7 +101,7 @@ await handle.agent.whenIdle() | [`src/types.ts`](src/types.ts) | `AgentOptions`、取消原因与收件箱投影词汇 | | [`src/dispatch.ts`](src/dispatch.ts) | `agentEvents` 融合分发器与 `assembleContextFor(agent)` | | [`src/consumed-work.ts`](src/consumed-work.ts) | `foldConsumedWork(events)`:日志消费掉的工作最终怎样了 | -| [`src/model-selection.ts`](src/model-selection.ts) | `installModelSelection`:为步骤路由捕获模型选择 | +| [`src/model-selection.ts`](src/model-selection.ts) | `installModelSelection`:把模型选择应用到每个步骤的模型决定 | | [`src/archive-admission.ts`](src/archive-admission.ts) | Workspace 注册表归档准入中的 `turn` 族:运行中的回合及其用户原因取消 | ### 注册表与生命周期 diff --git a/packages/core/agent/src/model-selection.ts b/packages/core/agent/src/model-selection.ts index eaf693beb0..d0e30909aa 100644 --- a/packages/core/agent/src/model-selection.ts +++ b/packages/core/agent/src/model-selection.ts @@ -4,13 +4,8 @@ */ import type { Context } from '@deepseek-ai/cordis' -import { - boundContextSummary, - createUserMessage, - type LlmCallConfig, - type ReasoningEffortId, -} from '@deepseek-ai/dsh-llm' -import type { PreStepDecision, StepRoute } from './runtime-types.ts' +import { boundContextSummary, createUserMessage } from '@deepseek-ai/dsh-llm' +import type { ModelSelection, PreStepDecision } from './runtime-types.ts' import type { ContextFormed } from '@deepseek-ai/dsh-llm' declare module '@deepseek-ai/dsh-llm' { interface MessageSourceMap { @@ -18,35 +13,23 @@ declare module '@deepseek-ai/dsh-llm' { } } -/** Complete provider, model, and optional reasoning effort selected for one live Agent. */ -export interface ModelSelection { - /** Registered provider route. */ - provider: string - /** Provider-owned model id. */ - model: string - /** Adapter-owned reasoning effort, or provider/default behavior when absent. */ - reasoningEffort?: ReasoningEffortId -} - -/** Mutable model selection plus the value captured for the current step. */ +/** Mutable model selection taken by each step that starts. */ export interface ModelSelectionRef { - /** Model selected for the next step to start. */ + /** Model selected for the next step to start; undefined keeps the step's proposed selection. */ current: ModelSelection | undefined - /** Selection captured when the current step chose its route. */ - assembled: ModelSelection | undefined } -function sameRoute(left: StepRoute, right: StepRoute): boolean { +function sameModel(left: ModelSelection, right: ModelSelection): boolean { return left.provider === right.provider && left.model === right.model } -function routeLabel(route: StepRoute, other: StepRoute): string { - return route.provider === other.provider ? route.model : `${route.provider}/${route.model}` +function modelLabel(selection: ModelSelection, other: ModelSelection): string { + return selection.provider === other.provider ? selection.model : `${selection.provider}/${selection.model}` } -function modelSwitchNotice(previous: StepRoute, selected: StepRoute) { - const from = routeLabel(previous, selected) - const to = routeLabel(selected, previous) +function modelSwitchNotice(previous: ModelSelection, selected: ModelSelection) { + const from = modelLabel(previous, selected) + const to = modelLabel(selected, previous) return createUserMessage({ content: [{ type: 'text' as const, @@ -61,15 +44,14 @@ function modelSwitchNotice(previous: StepRoute, selected: StepRoute) { } /** - * Couple one mutable selection to Agent-scoped step routing. Each step - * captures the selection when `agent/route` chooses its provider and model, - * and every request attempt in that step applies the captured effort; a - * selection made during the step applies from the next step. An absent - * selected effort clears inherited effort, restoring the model's provider - * default. + * Couple one mutable selection to Agent-scoped step model choice. Each step + * takes the provider, model, and effort current when `agent/model` runs, and + * every request attempt in that step keeps them; a selection made during the + * step applies from the next step. An absent selected effort leaves the + * model's provider default. * * A provider/model change appends a durable user-role notice to the next - * admitted request. It compares the step route with the latest request + * admitted request. It compares the step's selection with the latest request * header; effort-only changes and empty no-request decisions add no notice. * Failure before header persistence repeats the notice on the next request. * @@ -78,44 +60,26 @@ function modelSwitchNotice(previous: StepRoute, selected: StepRoute) { * @returns Disposer for all scoped waterfall listeners. */ export function installModelSelection(agentCtx: Context, selection: ModelSelectionRef): () => void { - const disposeRoute = agentCtx.on('agent/route', async (_payload, next): Promise => { - const selected = selection.current === undefined ? undefined : { ...selection.current } - selection.assembled = selected - const route = await next() - return selected === undefined ? route : { provider: selected.provider, model: selected.model } + const disposeModel = agentCtx.on('agent/model', async (_payload, next): Promise => { + const selected = selection.current + const proposed = await next() + return selected === undefined ? proposed : { ...selected } }) - const disposeRequest = agentCtx.on( - 'agent/request', - async (_payload, next): Promise => { - const resolved = await next() - const selected = selection.assembled - // Another route listener may have replaced the captured route; its model keeps its own effort. - if (selected === undefined || !sameRoute(selected, resolved)) return resolved - const { reasoningEffort: _inheritedEffort, ...withoutInheritedEffort } = resolved - return { - ...withoutInheritedEffort, - ...selected.reasoningEffort === undefined - ? {} - : { reasoningEffort: selected.reasoningEffort }, - } - }, - ) const disposeNotice = agentCtx.on( 'agent/pre-step', - async ({ agent, messages, route, signal, step }, next): Promise => { + async ({ agent, messages, modelSelection, signal, step }, next): Promise => { const decision = await next() if (decision.kind === 'reject' || signal.aborted) return decision // The loop skips an empty first step and an emptied offered continuation. if (decision.messages.length === 0 && (step === 1 || messages.length > 0)) return decision const previous = agent.session.requestHeader()?.config - if (previous === undefined || sameRoute(route, previous)) return decision - return { ...decision, messages: [...decision.messages, modelSwitchNotice(previous, route)] } + if (previous === undefined || sameModel(modelSelection, previous)) return decision + return { ...decision, messages: [...decision.messages, modelSwitchNotice(previous, modelSelection)] } }, { prepend: true }, ) return () => { - disposeRoute() - disposeRequest() + disposeModel() disposeNotice() } } diff --git a/packages/core/agent/src/runtime-types.ts b/packages/core/agent/src/runtime-types.ts index ec1c03a7c4..c710d95592 100644 --- a/packages/core/agent/src/runtime-types.ts +++ b/packages/core/agent/src/runtime-types.ts @@ -19,17 +19,19 @@ declare module '@deepseek-ai/dsh-system-prompt' { interface AssembleContext { /** Agent for this assembly; absent on diagnostics. When present, `scope` must identify the same agent. */ agent?: Agent - /** Provider and model of the step being assembled; absent outside a step. */ - route?: StepRoute + /** Model selection of the step being assembled; absent outside a step. */ + modelSelection?: ModelSelection } } -/** Provider and model that `agent/route` chose for one step and every request attempt in it. */ -export interface StepRoute { +/** Complete provider, model, and optional reasoning effort; `agent/model` fixes one for each step. */ +export interface ModelSelection { /** Registered provider route. */ - readonly provider: string - /** Model id interpreted by the provider adapter. */ - readonly model: string + provider: string + /** Provider-owned model id. */ + model: string + /** Adapter-owned reasoning effort, or provider/default behavior when absent. */ + reasoningEffort?: ReasoningEffortId } /** Merge-extensible agent creation options. Persona belongs to system-prompt sections. */ @@ -172,7 +174,7 @@ export type AssistantStreamFrame = declare module './types.ts' { interface Agent { - /** Creation-time request options; `agent/route` may choose another provider and model for each step. */ + /** Creation-time request options; `agent/model` may choose another provider, model, and effort for each step. */ readonly options: AgentOptions /** The live session this agent drives; its log is the durable source of truth. */ readonly session: Session @@ -323,20 +325,23 @@ declare module '@deepseek-ai/cordis' { * @param payload.messages - messages removed from the inbox for this step. * @param payload.turn - the turn that will own the step. * @param payload.step - the step proposed by the loop. - * @param payload.route - the provider and model `agent/route` chose for this step. + * @param payload.modelSelection - the provider, model, and effort `agent/model` chose for this step. * @param payload.signal - the current turn's cancellation signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ - 'agent/pre-step'(this: Scoped, payload: { agent: Agent; messages: UserMessage[]; turn: number; step: number; route: StepRoute; signal: AbortSignal }, next: () => Promise): Promise + 'agent/pre-step'(this: Scoped, payload: { agent: Agent; messages: UserMessage[]; turn: number; step: number; modelSelection: ModelSelection; signal: AbortSignal }, next: () => Promise): Promise /** - * Choose the provider and model for one proposed step. `await next()` - * yields the route the machine would use (agent options before this loop - * logs a request, the logged header afterwards); return a replacement to - * switch. The loop runs this once per proposed step, before context - * preparation and `agent/pre-step`. Prompt preparation, tool conditions, - * and every request attempt in the step use the result, so a later choice - * applies from the next step. Cancellation here opens no step. + * Choose the provider, model, and reasoning effort for one proposed step. + * `await next()` yields the selection the machine would use: agent options + * before this loop logs a request, the logged header afterwards, with an + * effort only when one was chosen for that exact model. Return a + * replacement to switch; an effort belongs to its model, so a replacement + * that changes the model names that model's effort or omits it for the + * provider default. The loop runs this once per proposed step, before + * context preparation and `agent/pre-step`. Prompt preparation, tool + * conditions, and every request attempt in the step use the result, so a + * later choice applies from the next step. Cancellation here opens no step. * @param payload.agent - the agent proposing the step. * @param payload.turn - the turn that will own the step. * @param payload.step - the step proposed by the loop. @@ -344,14 +349,14 @@ declare module '@deepseek-ai/cordis' { * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ - 'agent/route'(this: Scoped, payload: { agent: Agent; turn: number; step: number; signal: AbortSignal }, next: () => Promise): Promise + 'agent/model'(this: Scoped, payload: { agent: Agent; turn: number; step: number; signal: AbortSignal }, next: () => Promise): Promise /** * Replace the frozen call configuration of one request attempt. `await - * next()` yields the config the machine would use on the step's - * `agent/route` result: agent options on the first request, the logged - * header afterwards, and no effort recorded for another model. Return a - * replacement to change call controls such as reasoning effort; returning - * another provider or model fails the step. On step admission, this runs + * next()` yields the step's `agent/model` selection with the machine's + * other call controls: agent options on the first request, the logged + * header afterwards. Return a replacement to change sampling controls such + * as `maxTokens`, `temperature`, or `stop`; returning another provider, + * model, or reasoning effort fails the step. On step admission, this runs * after prompt assembly and `step/start`, before the system prompt and * accepted user batch are committed, and again for each retry. * Cancellation here or during subsequent `prepareCall()` resolution diff --git a/packages/core/agent/tests/model-selection.spec.ts b/packages/core/agent/tests/model-selection.spec.ts index c0d028cefe..23663ee2e1 100644 --- a/packages/core/agent/tests/model-selection.spec.ts +++ b/packages/core/agent/tests/model-selection.spec.ts @@ -1,4 +1,4 @@ -/** Model selection chooses each step's route once; requests in that step keep it. */ +/** Model selection fixes each step's provider, model, and effort once; requests in that step keep them. */ import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { @@ -7,9 +7,8 @@ import { type Agent, type ModelSelection, type ModelSelectionRef, - type StepRoute, } from '../src/index.ts' -import { createUserMessage, ReasoningEffortId, type LlmCallConfig } from '@deepseek-ai/dsh-llm' +import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' const contexts: Context[] = [] @@ -18,23 +17,21 @@ afterEach(async () => { }) const signal = new AbortController().signal const input = createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } }) -const seedRoute: StepRoute = { provider: 'seed', model: 'seed' } +const seed: ModelSelection = { provider: 'seed', model: 'seed', reasoningEffort: ReasoningEffortId('max') } function harness(current?: ModelSelection, previous?: ModelSelection) { const ctx = new Context() contexts.push(ctx) - const selection: ModelSelectionRef = { current, assembled: undefined } + const selection: ModelSelectionRef = { current } const dispose = installModelSelection(ctx, selection) const agent = { session: Session.create(SessionId('model-selection')) } as Agent if (previous) agent.session.append('request/header', { header: { config: previous }, reason: 'initial' }) - const route = (step: number, seed: StepRoute = seedRoute) => - agentEvents(ctx, agent).waterfall('agent/route', { turn: 1, step, signal }, () => Promise.resolve(seed)) - const request = (step: number, seed: LlmCallConfig) => - agentEvents(ctx, agent).waterfall('agent/request', { turn: 1, step, signal }, () => Promise.resolve(seed)) - const preStep = (stepRoute: StepRoute, messages = [input], offered = [input], step = 1, stepSignal = signal) => - agentEvents(ctx, agent).waterfall('agent/pre-step', { turn: 1, step, messages: offered, route: stepRoute, signal: stepSignal }, + const choose = (step: number) => + agentEvents(ctx, agent).waterfall('agent/model', { turn: 1, step, signal }, () => Promise.resolve(seed)) + const preStep = (modelSelection: ModelSelection, messages = [input], offered = [input], step = 1, stepSignal = signal) => + agentEvents(ctx, agent).waterfall('agent/pre-step', { turn: 1, step, messages: offered, modelSelection, signal: stepSignal }, () => Promise.resolve({ kind: 'enter' as const, messages })) - return { ctx, agent, selection, dispose, route, request, preStep } + return { ctx, agent, selection, dispose, choose, preStep } } function notice(from: string, to: string) { @@ -42,56 +39,41 @@ function notice(from: string, to: string) { } describe('installModelSelection()', () => { - it('captures one selection per step for its route and every request effort, then disposes routing', async () => { + it('gives each step the current selection, effort included, then disposes', async () => { const h = harness() - expect(await h.route(1)).toBe(seedRoute) - const inherited: LlmCallConfig = { provider: 'alpha', model: 'a1', reasoningEffort: ReasoningEffortId('max'), temperature: 0.2 } - expect(await h.request(1, inherited)).toBe(inherited) + expect(await h.choose(1)).toBe(seed) - h.selection.current = { provider: 'alpha', model: 'a1', reasoningEffort: ReasoningEffortId('high') } - expect(await h.route(2)).toEqual({ provider: 'alpha', model: 'a1' }) + const high = { provider: 'alpha', model: 'a1', reasoningEffort: ReasoningEffortId('high') } + h.selection.current = high + const chosen = await h.choose(2) + expect(chosen).toEqual(high) + expect(chosen).not.toBe(high) + + // An absent selected effort leaves the provider default instead of inheriting the proposal's. h.selection.current = { provider: 'beta', model: 'b1' } - const selected = { ...inherited, reasoningEffort: ReasoningEffortId('high') } - expect(await h.request(2, inherited)).toEqual(selected) - expect(await h.request(2, inherited)).toEqual(selected) - - expect(await h.route(3)).toEqual({ provider: 'beta', model: 'b1' }) - expect(await h.request(3, { ...inherited, provider: 'beta', model: 'b1' })) - .toEqual({ provider: 'beta', model: 'b1', temperature: 0.2 }) + expect(await h.choose(3)).toEqual({ provider: 'beta', model: 'b1' }) h.dispose() - expect(await h.route(4)).toBe(seedRoute) - expect(await h.request(4, inherited)).toBe(inherited) + expect(await h.choose(4)).toBe(seed) }) - it('keeps the effort of a route that an outer listener replaced', async () => { - const h = harness({ provider: 'alpha', model: 'a1', reasoningEffort: ReasoningEffortId('high') }) - h.ctx.on('agent/route', async (_payload, next) => { - await next() - return { provider: 'gamma', model: 'g1' } - }, { prepend: true }) - expect(await h.route(1)).toEqual({ provider: 'gamma', model: 'g1' }) - const config: LlmCallConfig = { provider: 'gamma', model: 'g1', reasoningEffort: ReasoningEffortId('max') } - expect(await h.request(1, config)).toBe(config) - }) - - it('announces same-provider and cross-provider step routes', async () => { + it('announces same-provider and cross-provider model switches', async () => { const h = harness(undefined, { provider: 'alpha', model: 'a0' }) await expect(h.preStep({ provider: 'alpha', model: 'a1' })).resolves.toMatchObject({ messages: [input, notice('a0', 'a1')] }) await expect(h.preStep({ provider: 'beta', model: 'b1' })).resolves.toMatchObject({ messages: [input, notice('alpha/a0', 'beta/b1')] }) }) - it('preserves initial, unchanged, rejected, cancelled, and disposed decisions', async () => { + it('preserves initial, effort-only, rejected, cancelled, and disposed decisions', async () => { const h = harness() - const route = { provider: 'alpha', model: 'a0' } - await expect(h.preStep(route)).resolves.toEqual({ kind: 'enter', messages: [input] }) + const initial = { provider: 'alpha', model: 'a0' } + await expect(h.preStep(initial)).resolves.toEqual({ kind: 'enter', messages: [input] }) h.agent.session.append('request/header', { - header: { config: { ...route, reasoningEffort: ReasoningEffortId('high') } }, + header: { config: { ...initial, reasoningEffort: ReasoningEffortId('high') } }, reason: 'initial', }) - await expect(h.preStep(route)).resolves.toEqual({ kind: 'enter', messages: [input] }) + await expect(h.preStep({ ...initial, reasoningEffort: ReasoningEffortId('low') })).resolves.toEqual({ kind: 'enter', messages: [input] }) const switched = { provider: 'alpha', model: 'a1' } - await expect(agentEvents(h.ctx, h.agent).waterfall('agent/pre-step', { turn: 1, step: 3, messages: [], route: switched, signal }, + await expect(agentEvents(h.ctx, h.agent).waterfall('agent/pre-step', { turn: 1, step: 3, messages: [], modelSelection: switched, signal }, () => Promise.resolve({ kind: 'reject' as const }))).resolves.toEqual({ kind: 'reject' }) await expect(h.preStep(switched, [input], [input], 3, AbortSignal.abort())).resolves.toEqual({ kind: 'enter', messages: [input] }) h.dispose() @@ -100,9 +82,9 @@ describe('installModelSelection()', () => { it('preserves empty claims and announces an empty tool continuation', async () => { const h = harness(undefined, { provider: 'alpha', model: 'a0' }) - const route = { provider: 'alpha', model: 'a1' } - await expect(h.preStep(route, [])).resolves.toEqual({ kind: 'enter', messages: [] }) - await expect(h.preStep(route, [], [input], 2)).resolves.toEqual({ kind: 'enter', messages: [] }) - await expect(h.preStep(route, [], [], 2)).resolves.toMatchObject({ messages: [notice('a0', 'a1')] }) + const switched = { provider: 'alpha', model: 'a1' } + await expect(h.preStep(switched, [])).resolves.toEqual({ kind: 'enter', messages: [] }) + await expect(h.preStep(switched, [], [input], 2)).resolves.toEqual({ kind: 'enter', messages: [] }) + await expect(h.preStep(switched, [], [], 2)).resolves.toMatchObject({ messages: [notice('a0', 'a1')] }) }) }) diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index a3bf51caa5..9103091c77 100644 --- a/packages/core/tools/README.i18n.yaml +++ b/packages/core/tools/README.i18n.yaml @@ -21,8 +21,8 @@ en: 9d2e6caa39c53d62 zh: 096a777f828c7565 /deepseek-ai-dsh-tools/use-this-package/conditional-tools: - en: ddd6bd2f0cf93524 - zh: 17339d994d48bfa2 + en: 33618b68f90858c0 + zh: 10e20f40a5871555 /deepseek-ai-dsh-tools/use-this-package/configure-the-presentation-mode: en: 4e27c45ae889369e zh: 7a0cd7a9dc7fe139 diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index f54f423bb4..d15bbaf35e 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -65,9 +65,9 @@ The unified schema DSL supports `string`, `number`, `integer`, `boolean`, `null` ### Conditional tools -`register(definition, { when })` evaluates a synchronous or asynchronous condition once for each model step. The condition receives `{ agent, route, signal }`, where `route` is the provider and model that `agent/route` chose for every request attempt in the step. 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. +`register(definition, { when })` evaluates a synchronous or asynchronous condition once for each model step. The condition receives `{ agent, modelSelection, signal }`, where `modelSelection` is the provider, model, and reasoning effort that `agent/model` chose for every request attempt in the step. 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, route, 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`. +`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. diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index d0ea16d5da..3b60e0dbec 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -65,9 +65,9 @@ ctx.tools.register(defineTool({ ### 条件工具 -`register(definition, { when })` 为每个模型步骤计算一次同步或异步条件。条件接收 `{ agent, route, signal }`,其中 `route` 是 `agent/route` 为该步骤所有请求尝试选定的提供方与模型。返回 false 会从原生声明、PTC 绑定和执行集合中排除工具。条件抛错会以 `TOOL_ACTIVATION_FAILED` 中止步骤准备,不会被当成正常不可用。条件必须响应取消,不得改变注册或执行工具操作。 +`register(definition, { when })` 为每个模型步骤计算一次同步或异步条件。条件接收 `{ agent, modelSelection, signal }`,其中 `modelSelection` 是 `agent/model` 为该步骤所有请求尝试选定的提供方、模型与推理强度。返回 false 会从原生声明、PTC 绑定和执行集合中排除工具。条件抛错会以 `TOOL_ACTIVATION_FAILED` 中止步骤准备,不会被当成正常不可用。条件必须响应取消,不得改变注册或执行工具操作。 -`resolve({ agent, route, signal })` 返回步骤拥有的 `ResolvedToolSet`。将它作为 `toolSet` 传给提示词组装以及每次原生或 PTC 执行,步骤的批次结束后释放;同一步骤内的重试复用它。注册仍由 effect 持有;每份集合只准入原 agent scope 中的原注册。注销、新的限制、取消或同名替换会阻止尚未开始的调用继续使用它。已经开始的操作保留协作取消义务。没有已解析集合的条件工具调用返回 `UNKNOWN_TOOL`。 +`resolve({ agent, modelSelection, signal })` 返回步骤拥有的 `ResolvedToolSet`。将它作为 `toolSet` 传给提示词组装以及每次原生或 PTC 执行,步骤的批次结束后释放;同一步骤内的重试复用它。注册仍由 effect 持有;每份集合只准入原 agent scope 中的原注册。注销、新的限制、取消或同名替换会阻止尚未开始的调用继续使用它。已经开始的操作保留协作取消义务。没有已解析集合的条件工具调用返回 `UNKNOWN_TOOL`。 `get(name, scope, toolSet)` 和 `schemas(scope, toolSet)` 读取已准入集合;省略 `toolSet` 时只查看已注册定义,不计算条件。工具指导文本通过 `AssembleContext.toolSet` 接收同一集合。展示变换不能增删可调用工具。 diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index e51018b860..90d484052f 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -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, StepRoute } 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' @@ -231,8 +231,8 @@ export interface ToolOutputDefinition { export interface ToolActivationContext { /** Agent preparing the step. */ readonly agent: Agent - /** Provider and model that every request attempt in the step uses. */ - readonly route: StepRoute + /** Provider, model, and reasoning effort that every request attempt in the step uses. */ + readonly modelSelection: ModelSelection /** Cancellation of step preparation; asynchronous conditions must observe it. */ readonly signal: AbortSignal } @@ -243,7 +243,7 @@ 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, step route, and cancellation signal. + * @param context - preparing agent, step model selection, and cancellation signal. * @returns whether the tool participates in this step. */ readonly when?: (context: ToolActivationContext) => boolean | Promise @@ -1311,7 +1311,7 @@ export class ToolRuntime extends Service { * 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, step route, and cancellation. + * @param context - preparing agent, step model selection, and cancellation. * @returns the complete selection; the caller disposes it after its tool batch settles. */ async resolve(context: ToolActivationContext): Promise { diff --git a/packages/core/tools/tests/activation.spec.ts b/packages/core/tools/tests/activation.spec.ts index 168e0e12a3..c9c4125aca 100644 --- a/packages/core/tools/tests/activation.spec.ts +++ b/packages/core/tools/tests/activation.spec.ts @@ -19,7 +19,7 @@ async function setup() { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) const agent = { id: SessionId('activation') } as Agent - const route = { provider: 'test', model: 'test' } + const modelSelection = { provider: 'test', model: 'test' } let scope!: Scope await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, agent) }, { inject: ['tools'] })) const controller = new AbortController() @@ -30,8 +30,8 @@ async function setup() { async execute() { calls++; return 'done' }, }) const input = { agent, name: tool.name, arguments: {}, callId: ToolCallId('call'), signal: controller.signal } - const resolve = () => ctx.tools.resolve({ agent, route, signal: controller.signal }) - return { ctx, agent, route, scope, controller, tool, input, resolve, calls: () => calls } + const resolve = () => ctx.tools.resolve({ agent, modelSelection, signal: controller.signal }) + return { ctx, agent, modelSelection, scope, controller, tool, input, resolve, calls: () => calls } } it('activates and deactivates a registered tool without allowing historical calls', async () => { @@ -104,8 +104,8 @@ it('does not restore a registration removed during its asynchronous condition', it('propagates cancellation after an asynchronous condition settles', async () => { const h = await setup() const condition = Promise.withResolvers() - h.ctx.tools.register(h.tool, { when: ({ route, signal }) => { - expect(route).toBe(h.route) + h.ctx.tools.register(h.tool, { when: ({ modelSelection, signal }) => { + expect(modelSelection).toBe(h.modelSelection) expect(signal).toBe(h.controller.signal) return condition.promise } }) diff --git a/packages/core/tools/tests/ptc.spec.ts b/packages/core/tools/tests/ptc.spec.ts index 496b007a81..15045bf56f 100644 --- a/packages/core/tools/tests/ptc.spec.ts +++ b/packages/core/tools/tests/ptc.spec.ts @@ -187,7 +187,7 @@ describe('mode-aware wire contribution', () => { const unregister = ctx.tools.register(definition, { when: () => active }) for (const enabled of [false, true]) { active = enabled - const toolSet = await ctx.tools.resolve({ agent, route: { provider: 'test', model: 'test' }, signal: testToolSignal }) + 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 ?? '' diff --git a/packages/experimental/webhook/src/session.ts b/packages/experimental/webhook/src/session.ts index ecc9b1b577..fc85f8bb87 100644 --- a/packages/experimental/webhook/src/session.ts +++ b/packages/experimental/webhook/src/session.ts @@ -7,7 +7,7 @@ import { brandString } from '@deepseek-ai/dsh-brand' import type { ModelSelection } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-default-model' import type {} from '@deepseek-ai/dsh-agent-preset-registry' -import { boundContextSummary, createUserMessage, errorChain, type LlmCallConfig } from '@deepseek-ai/dsh-llm' +import { boundContextSummary, createUserMessage, errorChain } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-permission-presets' import type { SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-session-title' @@ -90,16 +90,12 @@ function reportRollbackFailure(ctx: Context, subject: string, error: unknown): v /** Apply the creation-time selection until its first durable request header exists. */ function installInitialModelSelection(agentCtx: Context, selection: ModelSelection): void { - agentCtx.on('agent/request', async ({ agent }, next): Promise => { - const resolved = await next() + agentCtx.on('agent/model', async ({ agent }, next): Promise => { + const proposed = await next() if (agent.session.requestHeader() !== undefined - || resolved.provider !== selection.provider - || resolved.model !== selection.model) return resolved - const { reasoningEffort: _inheritedEffort, ...withoutInheritedEffort } = resolved - return { - ...withoutInheritedEffort, - ...selection.reasoningEffort === undefined ? {} : { reasoningEffort: selection.reasoningEffort }, - } + || proposed.provider !== selection.provider + || proposed.model !== selection.model) return proposed + return { ...selection } }) } diff --git a/packages/experimental/webhook/tests/session.spec.ts b/packages/experimental/webhook/tests/session.spec.ts index 2585023fec..67fa2ede46 100644 --- a/packages/experimental/webhook/tests/session.spec.ts +++ b/packages/experimental/webhook/tests/session.spec.ts @@ -1,5 +1,6 @@ import type { Context } from '@deepseek-ai/cordis' -import { ReasoningEffortId, type LlmCallConfig } from '@deepseek-ai/dsh-llm' +import type { ModelSelection } from '@deepseek-ai/dsh-agent' +import { ReasoningEffortId } from '@deepseek-ai/dsh-llm' import { afterEach, describe, expect, it, vi } from 'vitest' import { WebhookDeliveryId, @@ -182,19 +183,19 @@ async function create(test: SessionHarness, request = test.request): Promise Promise, -) => Promise { - const listener = test.modelListeners.get('agent/request') + next: () => Promise, +) => Promise { + const listener = test.modelListeners.get('agent/model') if (typeof listener !== 'function') { throw new Error('webhook Session did not install its initial model selection') } - const request = listener as ( + const choose = listener as ( payload: unknown, - next: () => Promise, - ) => Promise - return (_payload, next) => request({ agent: test.agent }, next) + next: () => Promise, + ) => Promise + return (_payload, next) => choose({ agent: test.agent }, next) } describe('webhook Session creation', () => { @@ -231,7 +232,7 @@ describe('webhook Session creation', () => { const withoutCap = harness() await create(withoutCap, { ...withoutCap.request, model: { provider: 'p', model: 'm' } }) expect(withoutCap.calls).not.toContain('default-model') - await expect(modelRequestListener(withoutCap)(undefined, async () => ({ + await expect(modelChoiceListener(withoutCap)(undefined, async () => ({ provider: 'p', model: 'm', reasoningEffort: ReasoningEffortId('inherited'), }))).resolves.toEqual({ provider: 'p', model: 'm' }) }) @@ -239,32 +240,32 @@ describe('webhook Session creation', () => { it('preserves default reasoning until the first request header is durable', async () => { const test = harness() await create(test) - const request = modelRequestListener(test) + const choose = modelChoiceListener(test) - await expect(request(undefined, async () => ({ + await expect(choose(undefined, async () => ({ provider: 'other-provider', model: 'default-model', reasoningEffort: ReasoningEffortId('other-provider-effort'), }))).resolves.toMatchObject({ reasoningEffort: 'other-provider-effort' }) - await expect(request(undefined, async () => ({ + await expect(choose(undefined, async () => ({ provider: 'default-provider', model: 'other-model', reasoningEffort: ReasoningEffortId('other-model-effort'), }))).resolves.toMatchObject({ reasoningEffort: 'other-model-effort' }) - const routed: unknown = await request(undefined, async () => ({ + const chosen: unknown = await choose(undefined, async () => ({ provider: 'default-provider', model: 'default-model', reasoningEffort: ReasoningEffortId('inherited'), })) - expect(routed).toEqual({ + expect(chosen).toEqual({ provider: 'default-provider', model: 'default-model', reasoningEffort: 'high', }) test.markRequestHeader() - await expect(request(undefined, async () => ({ + await expect(choose(undefined, async () => ({ provider: 'later-provider', model: 'later-model', reasoningEffort: ReasoningEffortId('later'), diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 43771aec8e..980d59e3b4 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2393,9 +2393,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'mention-carrying candidates in rank order.', }, { - signature: 'async prepare( agent: Agent, content: ContentBlock[], references: SessionReferenceInput[], signal?: AbortSignal, route?: StepRoute, ): Promise', - description: 'Snapshot all references for one accepted direct message and return one aggregated durable context. Automatic budgets use the step route, or agent options for direct preparation. Missing model capacity or adapter uses 64 KiB; other metadata lookup failures and cancellation reject preparation. Truncated previews include omission facts and a full-snapshot spill locator, or an explicit unavailable notice. Cancellation prevents context publication, including when storage completes after cancellation.', - parameters: [{ name: 'agent', description: 'target agent; references to it are rejected.' }, { name: 'content', description: 'already host-normalized readable message content.' }, { name: 'references', description: 'structured source sessions in mention order.' }, { name: 'signal', description: 'optional cancellation boundary for the active turn.' }, { name: 'route', description: 'provider and model of the step admitting the message; omitted for direct preparation.' }], + signature: 'async prepare( agent: Agent, content: ContentBlock[], references: SessionReferenceInput[], signal?: AbortSignal, modelSelection?: ModelSelection, ): Promise', + description: 'Snapshot all references for one accepted direct message and return one aggregated durable context. Automatic budgets use the step\'s model selection, or agent options for direct preparation. Missing model capacity or adapter uses 64 KiB; other metadata lookup failures and cancellation reject preparation. Truncated previews include omission facts and a full-snapshot spill locator, or an explicit unavailable notice. Cancellation prevents context publication, including when storage completes after cancellation.', + parameters: [{ name: 'agent', description: 'target agent; references to it are rejected.' }, { name: 'content', description: 'already host-normalized readable message content.' }, { name: 'references', description: 'structured source sessions in mention order.' }, { name: 'signal', description: 'optional cancellation boundary for the active turn.' }, { name: 'modelSelection', description: 'provider and model of the step admitting the message; omitted for direct preparation.' }], returns: 'detached content and optional referenced-session context.', }, ], @@ -3366,7 +3366,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { signature: 'async resolve(context: ToolActivationContext): Promise', 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, step route, and cancellation.' }], + parameters: [{ name: 'context', description: 'preparing agent, step model selection, and cancellation.' }], returns: 'the complete selection; the caller disposes it after its tool batch settles.', }, { @@ -3992,10 +3992,18 @@ export const EVENT_API: readonly EventApiEntry[] = [ description: 'One message entered the live inbox.', parameters: [{ name: 'payload', description: '.message - the inserted message. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.' }], }, + { + name: 'agent/model', + mode: 'waterfall', + signature: '\'agent/model\'(this: Scoped, payload: { agent: Agent; turn: number; step: number; signal: AbortSignal }, next: () => Promise): Promise', + summary: 'Choose the provider, model, and reasoning effort for one proposed step.', + description: 'Choose the provider, model, and reasoning effort for one proposed step. `await next()` yields the selection the machine would use: agent options before this loop logs a request, the logged header afterwards, with an effort only when one was chosen for that exact model. Return a replacement to switch; an effort belongs to its model, so a replacement that changes the model names that model\'s effort or omits it for the provider default. The loop runs this once per proposed step, before context preparation and `agent/pre-step`. Prompt preparation, tool conditions, and every request attempt in the step use the result, so a later choice applies from the next step. Cancellation here opens no step.', + parameters: [{ name: 'payload', description: '.signal - the current turn\'s explicit abort signal. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.' }], + }, { name: 'agent/pre-step', mode: 'waterfall', - signature: '\'agent/pre-step\'(this: Scoped, payload: { agent: Agent; messages: UserMessage[]; turn: number; step: number; route: StepRoute; signal: AbortSignal }, next: () => Promise): Promise', + signature: '\'agent/pre-step\'(this: Scoped, payload: { agent: Agent; messages: UserMessage[]; turn: number; step: number; modelSelection: ModelSelection; signal: AbortSignal }, next: () => Promise): Promise', summary: 'Reject a proposed step or replace the messages that enter it.', description: 'Reject a proposed step or replace the messages that enter it. Calling `next()` preserves the current messages.', parameters: [{ name: 'payload', description: '.signal - the current turn\'s cancellation signal. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.' }], @@ -4005,7 +4013,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ mode: 'waterfall', signature: '\'agent/request\'(this: Scoped, payload: { agent: Agent; turn: number; step: number; signal: AbortSignal }, next: () => Promise): Promise', summary: 'Replace the frozen call configuration of one request attempt.', - description: 'Replace the frozen call configuration of one request attempt. `await next()` yields the config the machine would use on the step\'s `agent/route` result: agent options on the first request, the logged header afterwards, and no effort recorded for another model. Return a replacement to change call controls such as reasoning effort; returning another provider or model fails the step. On step admission, this runs after prompt assembly and `step/start`, before the system prompt and accepted user batch are committed, and again for each retry. Cancellation here or during subsequent `prepareCall()` resolution commits neither. The prepared call capability governs prompt admission. Model-visible content must use logged channels; this waterfall cannot mutate messages.', + description: 'Replace the frozen call configuration of one request attempt. `await next()` yields the step\'s `agent/model` selection with the machine\'s other call controls: agent options on the first request, the logged header afterwards. Return a replacement to change sampling controls such as `maxTokens`, `temperature`, or `stop`; returning another provider, model, or reasoning effort fails the step. On step admission, this runs after prompt assembly and `step/start`, before the system prompt and accepted user batch are committed, and again for each retry. Cancellation here or during subsequent `prepareCall()` resolution commits neither. The prepared call capability governs prompt admission. Model-visible content must use logged channels; this waterfall cannot mutate messages.', parameters: [{ name: 'payload', description: '.signal - the current turn\'s explicit abort signal. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.' }], }, { @@ -4016,14 +4024,6 @@ export const EVENT_API: readonly EventApiEntry[] = [ description: 'Handle one failed model-request attempt before the loop retries or closes its step. A listener returns `{ kind: \'retry\' }` without calling `next()` when it owns recovery, or calls `next()` to delegate. The default `undefined` leaves the failure terminal.', parameters: [{ name: 'payload', description: '.signal - the turn abort signal. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.' }], }, - { - name: 'agent/route', - mode: 'waterfall', - signature: '\'agent/route\'(this: Scoped, payload: { agent: Agent; turn: number; step: number; signal: AbortSignal }, next: () => Promise): Promise', - summary: 'Choose the provider and model for one proposed step.', - description: 'Choose the provider and model for one proposed step. `await next()` yields the route the machine would use (agent options before this loop logs a request, the logged header afterwards); return a replacement to switch. The loop runs this once per proposed step, before context preparation and `agent/pre-step`. Prompt preparation, tool conditions, and every request attempt in the step use the result, so a later choice applies from the next step. Cancellation here opens no step.', - parameters: [{ name: 'payload', description: '.signal - the current turn\'s explicit abort signal. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.' }], - }, { name: 'agent/status', mode: 'emit', @@ -7572,10 +7572,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SshStreamEndpoint', declaration: 'export type SshStreamEndpoint = z.infer;', }, - { - name: 'StepRoute', - declaration: 'export interface StepRoute {\n readonly provider: string;\n readonly model: string;\n}', - }, { name: 'StorageBackend', declaration: 'export interface StorageBackend {\n readonly kv?: KvFacet;\n close(): Promise;\n}', @@ -7966,7 +7962,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolActivationContext', - declaration: 'export interface ToolActivationContext {\n readonly agent: Agent;\n readonly route: StepRoute;\n readonly signal: AbortSignal;\n}', + declaration: 'export interface ToolActivationContext {\n readonly agent: Agent;\n readonly modelSelection: ModelSelection;\n readonly signal: AbortSignal;\n}', }, { name: 'ToolAdditionBlock', diff --git a/packages/llm/llm-deepseek/tests/account-routing.spec.ts b/packages/llm/llm-deepseek/tests/account-routing.spec.ts index 1db78b540d..5bd8df5261 100644 --- a/packages/llm/llm-deepseek/tests/account-routing.spec.ts +++ b/packages/llm/llm-deepseek/tests/account-routing.spec.ts @@ -94,12 +94,12 @@ it('replaces the active provider only after the next step request binds', async const proceed = Promise.withResolvers() const streaming = Promise.withResolvers() let steps = 0 - ctx.on('agent/route', async (_event, next) => { - const route = await next() - if (++steps === 1) return route + ctx.on('agent/model', async (_event, next) => { + const proposed = await next() + if (++steps === 1) return proposed preparing.resolve(undefined) await proceed.promise - return { ...route, provider: 'deepseek-official' } + return { ...proposed, provider: 'deepseek-official' } }) ctx.on('llm/stream', async function* (request) { if (request.provider === 'deepseek-account') { @@ -251,11 +251,11 @@ it('ignores idle account history but uses the last bound route during a new turn expect(cancel).not.toHaveBeenCalled() const preparing = Promise.withResolvers() const proceed = Promise.withResolvers() - ctx.on('agent/route', async (_event, next) => { - const route = await next() + ctx.on('agent/model', async (_event, next) => { + const proposed = await next() preparing.resolve(undefined) await proceed.promise - return { ...route, provider: 'deepseek-official' } + return { ...proposed, provider: 'deepseek-official' } }) try { send(agent) diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 9cc4193e1c..7283896dcf 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -454,7 +454,7 @@ describe('Cordis provider composition', () => { ctx.on('system-prompt/assemble', async (_assembly, _context, next) => ({ ...await next(), sections: [{ name: 'test', text: prompt, order: 0 }], })) - const selection: ModelSelectionRef = { current: { provider: 'deepseek-official', model: MODEL }, assembled: undefined } + const selection: ModelSelectionRef = { current: { provider: 'deepseek-official', model: MODEL } } const agent = await ctx.agentLoop.create(SessionId('capability-switch'), selection.current) installModelSelection(agent.ctx, selection) await send(agent, 'first') diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index 3ab84ba450..c493e97f7c 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -524,7 +524,7 @@ describe('provider-routed retry policy', () => { expect(adapter.requests.map(request => request.provider)).toEqual(['mock', 'other', 'other']) }) - it('selects an always policy from the provider chosen by agent/route', async () => { + it('selects an always policy from the provider chosen by agent/model', async () => { vi.useFakeTimers() const adapter = new ScriptedAdapter([ new LlmError('rerouted auth failed', 'AUTH'), @@ -533,7 +533,7 @@ describe('provider-routed retry policy', () => { ;({ ctx: context } = await harness(adapter, { other: alwaysConfig({ initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 }), }, (ctx) => { - ctx.on('agent/route', async (_payload, next) => ({ + ctx.on('agent/model', async (_payload, next) => ({ ...await next(), provider: 'other', })) diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index cbbbf44fa6..562dedadf8 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -19,7 +19,7 @@ import type { PlanModeConfig } from '../src/index.ts' import type { PlanUnitState } from '../src/types.ts' const testToolSignal = new AbortController().signal -const TEST_ROUTE = { provider: 'test', model: 'test' } +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 @@ -76,7 +76,7 @@ async function agentWithSession( } async function assembleFor(ctx: Context, agent: Agent) { - const toolSet = await ctx.tools.resolve({ agent, route: TEST_ROUTE, signal: testToolSignal }) + const toolSet = await ctx.tools.resolve({ agent, modelSelection: TEST_MODEL_SELECTION, signal: testToolSignal }) return ctx.systemPrompt.assemble({ agent, scope: agent, toolSet }) } @@ -117,7 +117,7 @@ async function boundary(ctx: Context, agent: Agent & { session: Session }, type: const signal = new AbortController().signal const decision = await events.waterfall( 'agent/pre-step', - { messages: [message], turn: 1, step: 1, route: { provider: 'test', model: 'test' }, signal }, + { messages: [message], turn: 1, step: 1, modelSelection: { provider: 'test', model: 'test' }, signal }, () => Promise.resolve({ kind: 'enter' as const, messages: [message] }), ) if (decision.kind === 'enter') { @@ -798,7 +798,7 @@ describe('exit_plan_mode', () => { 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, route: TEST_ROUTE, 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}`), @@ -933,7 +933,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, route: TEST_ROUTE, signal: testToolSignal }), + 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' }, @@ -1083,7 +1083,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, route: TEST_ROUTE, signal: testToolSignal }), + toolSet: await ctx.tools.resolve({ agent, modelSelection: TEST_MODEL_SELECTION, signal: testToolSignal }), callId: ToolCallId(`call-exit-${++callCounter}`), name: EXIT_PLAN_MODE, arguments: { plan: '# P' }, diff --git a/packages/session/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts b/packages/session/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts index 84814ab160..d7991f7c0c 100644 --- a/packages/session/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts +++ b/packages/session/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts @@ -220,7 +220,7 @@ describe('session-checkpoint-policy tool and step boundaries', () => { ctx.on('session/flush', (current) => { flushed.push(current.id) }) const signal = new AbortController().signal await agentEvents(ctx, agent).waterfall( - 'agent/pre-step', { messages: [], turn: 1, step: 1, route: { provider: 'test', model: 'test' }, signal }, + 'agent/pre-step', { messages: [], turn: 1, step: 1, modelSelection: { provider: 'test', model: 'test' }, signal }, () => Promise.resolve({ kind: 'enter', messages: [] }), ) expect(flushed).toEqual([session.id]) diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index d2d8ef53ed..b1bfb6776f 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -116,7 +116,7 @@ async function fireStep(ctx: Context, agent: Agent, turn: number, step: number): const signal = new AbortController().signal const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - { messages: [], turn, step, route: { provider: 'test', model: 'test' }, signal }, + { messages: [], turn, step, modelSelection: { provider: 'test', model: 'test' }, signal }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), ) if (decision.kind === 'enter') { @@ -134,7 +134,7 @@ async function proposeStep( const signal = new AbortController().signal return await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - { messages, turn: 1, step: 1, route: { provider: 'test', model: 'test' }, signal }, + { messages, turn: 1, step: 1, modelSelection: { provider: 'test', model: 'test' }, signal }, () => Promise.resolve({ kind: 'enter' as const, messages }), ) } @@ -166,7 +166,7 @@ async function composePrefix(ctx: Context, cwd: string, signal = new AbortContro async function composePrefixForAgent(ctx: Context, agent: Agent, signal = new AbortController().signal): Promise { const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - { messages: [], turn: 1, step: 1, route: { provider: 'test', model: 'test' }, signal }, + { messages: [], turn: 1, step: 1, modelSelection: { provider: 'test', model: 'test' }, signal }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), ) if (decision.kind === 'enter') { @@ -1104,7 +1104,7 @@ describe('user-explicit invocation injection', () => { const signal = new AbortController().signal const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - { messages: [gesture('/hidden-demo blocked step')], turn: 1, step: 1, route: { provider: 'test', model: 'test' }, signal }, + { messages: [gesture('/hidden-demo blocked step')], turn: 1, step: 1, modelSelection: { provider: 'test', model: 'test' }, signal }, () => Promise.resolve({ kind: 'reject' as const }), ) expect(decision).toEqual({ kind: 'reject' }) diff --git a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts index 641b82384b..73facabf80 100644 --- a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts +++ b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts @@ -274,7 +274,7 @@ describe('SubagentModelSelectionConfig', () => { messages: [], turn: 1, step: 1, - route: { provider: 'test', model: 'test' }, + modelSelection: { provider: 'test', model: 'test' }, signal: new AbortController().signal, } await expect(ctx.waterfall(ctx as never, 'agent/pre-step', payload, next)) diff --git a/packages/test-support/loader-smoke/tests/fixtures/cli-mock-llm.ts b/packages/test-support/loader-smoke/tests/fixtures/cli-mock-llm.ts index 4dbdb23b0b..38f7e23537 100644 --- a/packages/test-support/loader-smoke/tests/fixtures/cli-mock-llm.ts +++ b/packages/test-support/loader-smoke/tests/fixtures/cli-mock-llm.ts @@ -72,8 +72,8 @@ export const inject = ['llm'] /** Register the keyless `cli-mock` adapter. */ export function apply(ctx: Context): void { ctx.llm.registerAdapter(['cli-mock'], new CliMockAdapter()) - ctx.on('agent/request', async ({ step }, next) => { - const config = await next() - return step === 2 ? { ...config, reasoningEffort: OFF } : config + ctx.on('agent/model', async ({ step }, next) => { + const selection = await next() + return step === 2 ? { ...selection, reasoningEffort: OFF } : selection }) } diff --git a/scripts/fixtures/python-sdk-dynamic-tools.mjs b/scripts/fixtures/python-sdk-dynamic-tools.mjs index f884b925a5..99fc3722db 100644 --- a/scripts/fixtures/python-sdk-dynamic-tools.mjs +++ b/scripts/fixtures/python-sdk-dynamic-tools.mjs @@ -14,7 +14,7 @@ export function apply(ctx) { render(_args, value) { return [{ type: 'text', text: value }] }, }, async execute() { return 'pong' }, - }, { when: async ({ route }) => active && route.provider === 'deepseek-official' }) + }, { 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 diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 8c850099de..cc6c784e55 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -334,7 +334,6 @@ export const LINK_MAP: Readonly> = { InspectorJsonValue: 'extensions.md', MessageId: 'llm-streaming.md', ResumeAgentOptions: 'core.md', - StepRoute: 'core.md', SettleReason: 'core.md', AdapterRegistrationHandle: 'llm-streaming.md', DirectoryRegistrationHandle: 'llm-streaming.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 984bbb8ccd..01c3f8376b 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -1548,17 +1548,17 @@ 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->>Hooks: ${mermaidCode('agent/route')} waterfall chooses the step route`, - ` Driver->>Prompt: ${mermaidCode('system-prompt/prepare')} waterfall on the step route`, - ` Driver->>Hooks: ${mermaidCode('agent/pre-step')} waterfall with the step route`, + ` Driver->>Hooks: ${mermaidCode('agent/model')} waterfall chooses the step provider, model, and effort`, + ` Driver->>Prompt: ${mermaidCode('system-prompt/prepare')} waterfall with the step model selection`, + ` Driver->>Hooks: ${mermaidCode('agent/pre-step')} waterfall with the step model selection`, ' Hooks-->>Driver: authoritative reject or enter(messages)', ' alt proposed step rejected, first batch empty, or pre-step failed', ' Driver-->>Driver: claimed batch stays removed, the open turn spends no step', ' else enter proposed step', ` Driver->>Session: ${mermaidCode('step/start')}`, - ' Driver->>Tools: resolve({ agent, route, signal }) once per step', + ' Driver->>Tools: resolve({ agent, modelSelection, signal }) once per step', ` Driver->>Prompt: ${mermaidCode('system-prompt/assemble')} with the step tool set`, - ` Driver->>Hooks: ${mermaidCode('agent/request')} waterfall on the step route`, + ` Driver->>Hooks: ${mermaidCode('agent/request')} waterfall with the step model selection`, ' Driver->>LLM: prepareCall(config, signal)', ' Note over Driver,LLM: cancellation during preparation commits neither system nor users', ' Note over Driver,Session: synchronous admission using the prepared call capability', @@ -1574,7 +1574,7 @@ function renderLifecycle(): string { ` Driver-->>SDK: ${mermaidCode('agent/assistant-stream')} committed end`, ` Driver->>Hooks: ${mermaidCode('agent/request-error')} waterfall`, ' Hooks-->>Driver: return retry action or preserve the original error', - ' Note over Driver,LLM: retry in the open step: request and prepare again on the same route, tool set, and prompt without repeating pre-step or users', + ' Note over Driver,LLM: retry in the open step: request and prepare again with the same model selection, tool set, and prompt without repeating pre-step or users', ' else model request succeeded', ` Driver->>Session: ${mermaidCode('assistant/message')}`, ` Driver-->>SDK: ${mermaidCode('agent/assistant-stream')} committed end`, @@ -1598,7 +1598,7 @@ function renderLifecycle(): string { ' opt next-step input is pending', ' Driver-->>Driver: claim pending next-step input', ` Driver-->>SDK: ${mermaidCode('agent/inbox/claimed')} { message, turn } per message`, - ` Driver->>Hooks: ${mermaidCode('agent/route')} waterfall`, + ` Driver->>Hooks: ${mermaidCode('agent/model')} waterfall`, ` Driver->>Prompt: ${mermaidCode('system-prompt/prepare')} waterfall`, ` Driver->>Hooks: ${mermaidCode('agent/pre-step')} waterfall`, ' Hooks-->>Driver: authoritative reject or enter(messages)', @@ -1611,7 +1611,7 @@ function renderLifecycle(): string { '', 'The `assistant/message` event records every successful provider call, including content-less and `max-tokens` finishes, and embeds the exact compact timed stream. Empty content stays out of derived history. A failed, retried, cancelled, or stream-error attempt that reaches settlement without a surface message records its stream as `assistant/attempt`. Live `agent/assistant-stream` chunk frames are transient; replay reads either durable settlement, and a hard process loss before settlement leaves no durable attempt stream.', '', - '`dsh-compaction-basic` uses `agent/pre-step` for pressure before request derivation and `agent/request-error` only for canonical context overflow. Once either trigger qualifies, optional tool-result pruning runs before summary selection. Recovery runs within the open step and retries only when pruning or summarization advances the surface replacement generation; otherwise the original request error remains authoritative. Each retry prepares its call on the step route and reconciles the retained rendered assembly before request derivation, without repeating routing, tool resolution, assembly, pre-step, or user admission.', + '`dsh-compaction-basic` uses `agent/pre-step` for pressure before request derivation and `agent/request-error` only for canonical context overflow. Once either trigger qualifies, optional tool-result pruning runs before summary selection. Recovery runs within the open step and retries only when pruning or summarization advances the surface replacement generation; otherwise the original request error remains authoritative. Each retry prepares its call with the step model selection and reconciles the retained rendered assembly before request derivation, without repeating model selection, tool resolution, assembly, pre-step, or user admission.', '', 'The returned `agent/pre-step` decision is authoritative; listeners wrapping `next()` preserve downstream messages and `startsRequestSeries` unless replacement is intentional. Steering and injected context pass through the same waterfall after a later claim operation takes their next-step batch.', '', diff --git a/scripts/python-snapshot-workflow-order.spec.ts b/scripts/python-snapshot-workflow-order.spec.ts index 6a6881797b..3461e5faf6 100644 --- a/scripts/python-snapshot-workflow-order.spec.ts +++ b/scripts/python-snapshot-workflow-order.spec.ts @@ -44,7 +44,7 @@ async function harness(direct = false) { runId: WorkflowRunId('run'), seq: 1, label: 'workflow-child', childId, }) const step = (overrides = {}) => agentEvents(ctx, agent).waterfall('agent/pre-step', { - turn: 1, step: 1, messages, route: { provider: 'test', model: 'test' }, signal: controller.signal, ...overrides, + turn: 1, step: 1, messages, modelSelection: { provider: 'test', model: 'test' }, signal: controller.signal, ...overrides, }, next) return { ctx, fiber, parent, other, session, agent, controller, decision, next, start, step } } diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 9c8493b0c3..87722d211c 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -2153,7 +2153,7 @@ }, { "doc": "docs/subsystems/core.md", - "symbol": "StepRoute", + "symbol": "ModelSelection", "source": "packages/core/agent/src/runtime-types.ts" }, { diff --git a/snapshots/session/cordis-inspect-jsdoc/session.v4.jsonl b/snapshots/session/cordis-inspect-jsdoc/session.v4.jsonl index f53dd034a2..1ba3ffbed5 100644 --- a/snapshots/session/cordis-inspect-jsdoc/session.v4.jsonl +++ b/snapshots/session/cordis-inspect-jsdoc/session.v4.jsonl @@ -14,7 +14,7 @@ {"type":"session/title","data":{"title":"Inspect the exact tools service","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788778145981,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788778145981,"index":0,"dt":[],"id":"inspect-tools-api","name":"cordis_inspect_query","args":["{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"]},{"type":"chunk","time":1788778145981,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"}}},{"type":"chunk","time":1788778145981,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788778145981,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"inspect-tools-api"},"toolCallId":"inspect-tools-api","content":[{"type":"text","text":"{\n \"platform\": \"host\",\n \"provider\": \"Service\",\n \"method\": \"listService\",\n \"data\": {\n \"mode\": \"service\",\n \"service\": {\n \"key\": \"tools\",\n \"description\": \"Tool registry and execution pipeline. Scoped registrations shadow globals; one visibility resolver feeds presentation, lookup, and dispatch.\",\n \"access\": {\n \"optional\": {\n \"expression\": \"ctx.get(\\\"tools\\\")\",\n \"requiresUndefinedCheck\": true\n },\n \"hardDependency\": {\n \"inject\": [\n \"tools\"\n ],\n \"expression\": \"ctx.tools\"\n }\n },\n \"methods\": [\n {\n \"signature\": \"presentAs(mode: ToolPresentationMode): () => void\",\n \"description\": \"Present the calling scope's tools in `mode` instead of the deployment default. Nearest scope on the chain wins, so a preset's standing declaration covers every agent joined under it.\\n\\nScoped only, and one declaration per scope: this is how an agent preset composes PTC mode agents beside native ones in the same process, and a process-global override would be the `mode` config field instead.\",\n \"parameters\": [\n {\n \"name\": \"mode\",\n \"description\": \"the presentation the covered agents' models see.\"\n }\n ],\n \"returns\": \"the exact disposer that restores the deployment default.\"\n },\n {\n \"signature\": \"register(definition: ToolDefinition, options: ToolRegistrationOptions = {}): () => void\",\n \"description\": \"Register globally or in the calling agent scope. Scoped tools shadow globals; duplicates within one layer and the reserved `run_code` name fail.\",\n \"parameters\": [\n {\n \"name\": \"definition\",\n \"description\": \"tool schema, execution, and optional finalization/presentation callbacks.\"\n },\n {\n \"name\": \"options\",\n \"description\": \"optional step-time activation condition.\"\n }\n ],\n \"returns\": \"the exact disposer that unregisters the tool.\"\n },\n {\n \"signature\": \"restrict(filter: ToolRestriction): () => void\",\n \"description\": \"Restrict global tools for the calling agent scope. Empty filters, unknown names, scope-local names, and reserved transport names fail. Restrictions intersect; scoped registrations remain visible.\",\n \"parameters\": [\n {\n \"name\": \"filter\",\n \"description\": \"global-tool mask: `allow` (keep only) and/or `deny` (remove).\"\n }\n ],\n \"returns\": \"the exact disposer that lifts this restriction.\"\n },\n {\n \"signature\": \"guard(guard: ToolGuard): () => void\",\n \"description\": \"Register a monotonic guard after the extensible `tools/pre-execute` waterfall. A plain-context guard applies globally; one registered through `agent.ctx` applies only to that agent. Any matching guard may deny by returning a reason, while no guard can force-allow a call another guard denied. The exact effect disposer is returned for ordered ownership and HMR cleanup.\",\n \"parameters\": [\n {\n \"name\": \"guard\",\n \"description\": \"synchronous check; a returned string denies the execution.\"\n }\n ],\n \"returns\": \"the exact disposer that unregisters the guard.\"\n },\n {\n \"signature\": \"get(name: string, scope?: ScopeKey, toolSet?: ResolvedToolSet): ToolDefinition | undefined\",\n \"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.\",\n \"parameters\": [\n {\n \"name\": \"name\",\n \"description\": \"the tool name as registered.\"\n },\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n },\n {\n \"name\": \"toolSet\",\n \"description\": \"step admission; omit only to inspect registered definitions.\"\n }\n ],\n \"returns\": \"the definition the scope resolves, or undefined when none is visible.\"\n },\n {\n \"signature\": \"async resolve(context: ToolActivationContext): Promise\",\n \"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.\",\n \"parameters\": [\n {\n \"name\": \"context\",\n \"description\": \"preparing agent, step route, and cancellation.\"\n }\n ],\n \"returns\": \"the complete selection; the caller disposes it after its tool batch settles.\"\n },\n {\n \"signature\": \"schemas(scope?: ScopeKey, toolSet?: ResolvedToolSet): ToolSchema[]\",\n \"description\": \"Project visible definitions onto the allowlisted model-facing schema fields, excluding execution and presentation callbacks.\",\n \"parameters\": [\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n },\n {\n \"name\": \"toolSet\",\n \"description\": \"step admission, or omitted for registered-definition inspection.\"\n }\n ],\n \"returns\": \"one deep-cloned schema per visible tool.\"\n },\n {\n \"signature\": \"executionMode(exec: ToolExecutionInput): ToolExecutionMode\",\n \"description\": \"Classify a pending call through the caller's visible tool definition. Only an exact `true` is parallel; unknown, hidden, undeclared, invalid, or throwing classifiers are exclusive.\",\n \"parameters\": [\n {\n \"name\": \"exec\",\n \"description\": \"call name, parsed arguments, and optional agent scope.\"\n }\n ],\n \"returns\": \"the fail-closed scheduling mode.\"\n },\n {\n \"signature\": \"async execute(exec: ToolExecutionInput): Promise\",\n \"description\": \"Execute through pre-policy, guards, around-dispatch, post-policy, definition-owned content finalization, and final notification. Tool and listener failures resolve as materialized error results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen snapshot final observers receive. Cancellation arriving after entry and before final result materialization skips a not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a successful started outcome with `ABORTED`; already-started work is still drained and may retain a tool-owned structured error.\",\n \"parameters\": [\n {\n \"name\": \"exec\",\n \"description\": \"the typed same-process call input. The registry assigns its correlation token before policy begins.\"\n }\n ],\n \"returns\": \"the materialized final result.\"\n }\n ]\n },\n \"referencedTypes\": [\n {\n \"name\": \"Agent\",\n \"declaration\": \"export interface Agent {\\n readonly id: SessionId;\\n}\"\n },\n {\n \"name\": \"Branded\",\n \"declaration\": \"export type Branded = string & {\\n readonly [BRAND]: B;\\n};\"\n },\n {\n \"name\": \"DiffCallView\",\n \"declaration\": \"export interface DiffCallView {\\n card: 'diff';\\n title: string;\\n diffs: FileDiff[];\\n locations?: FileLocation[];\\n}\"\n },\n {\n \"name\": \"DiffResultView\",\n \"declaration\": \"export interface DiffResultView {\\n card: 'diff';\\n title?: string;\\n diffs: FileDiff[];\\n}\"\n },\n {\n \"name\": \"FileDiff\",\n \"declaration\": \"export interface FileDiff {\\n path: string;\\n oldText: string | null;\\n newText: string;\\n}\"\n },\n {\n \"name\": \"FileLocation\",\n \"declaration\": \"export interface FileLocation {\\n path: string;\\n line?: number;\\n}\"\n },\n {\n \"name\": \"GenericCallView\",\n \"declaration\": \"export interface GenericCallView {\\n card: 'generic';\\n title: string;\\n kind?: ToolCallKind;\\n rawInput?: unknown;\\n content?: ContentBlock[];\\n locations?: FileLocation[];\\n}\"\n },\n {\n \"name\": \"GenericResultView\",\n \"declaration\": \"export interface GenericResultView {\\n card: 'generic';\\n title?: string;\\n content?: ContentBlock[];\\n}\"\n },\n {\n \"name\": \"JsonSchemaNode\",\n \"declaration\": \"export interface JsonSchemaNode {\\n type?: JsonSchemaType;\\n oneOf?: JsonSchemaNode[];\\n properties?: Record;\\n required?: string[];\\n additionalProperties?: boolean;\\n items?: JsonSchemaNode;\\n enum?: JsonSchemaScalar[];\\n const?: JsonSchemaScalar;\\n description?: string;\\n title?: string;\\n default?: JsonValue;\\n examples?: JsonValue;\\n}\"\n },\n {\n \"name\": \"JsonSchemaScalar\",\n \"declaration\": \"export type JsonSchemaScalar = string | number | boolean | null;\"\n },\n {\n \"name\": \"JsonSchemaType\",\n \"declaration\": \"export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\"\n },\n {\n \"name\": \"JsonValue\",\n \"declaration\": \"export type JsonValue = null | boolean | number | string | JsonValue[] | {\\n [key: string]: JsonValue;\\n};\"\n },\n {\n \"name\": \"ReadFileLine\",\n \"declaration\": \"export interface ReadFileLine {\\n number: number;\\n text: string;\\n}\"\n },\n {\n \"name\": \"ReadResultView\",\n \"declaration\": \"export interface ReadResultView {\\n card: 'read';\\n title?: string;\\n path: string;\\n offset: number;\\n lines: ReadFileLine[];\\n totalLines: number;\\n lang?: string;\\n content?: ContentBlock[];\\n}\"\n },\n {\n \"name\": \"ResolvedToolSet\",\n \"declaration\": \"export interface ResolvedToolSet {\\n dispose(): void;\\n view(scope: ScopeKey | undefined): ReadonlyMap;\\n}\"\n },\n {\n \"name\": \"ScopeKey\",\n \"declaration\": \"export type ScopeKey = object;\"\n },\n {\n \"name\": \"SearchFileMatches\",\n \"declaration\": \"export interface SearchFileMatches {\\n path: string;\\n matches: SearchLineMatch[];\\n}\"\n },\n {\n \"name\": \"SearchLineMatch\",\n \"declaration\": \"export interface SearchLineMatch {\\n lineNumber: number;\\n line: string;\\n}\"\n },\n {\n \"name\": \"SearchMatchesResultView\",\n \"declaration\": \"export interface SearchMatchesResultView {\\n card: 'search';\\n shape: 'matches';\\n title?: string;\\n files: SearchFileMatches[];\\n truncated: boolean;\\n total: number;\\n}\"\n },\n {\n \"name\": \"SearchPathsResultView\",\n \"declaration\": \"export interface SearchPathsResultView {\\n card: 'search';\\n shape: 'paths';\\n title?: string;\\n paths: string[];\\n truncated: boolean;\\n total: number;\\n}\"\n },\n {\n \"name\": \"SearchResultView\",\n \"declaration\": \"export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\"\n },\n {\n \"name\": \"SessionId\",\n \"declaration\": \"export type SessionId = Branded<'SessionId'>;\"\n },\n {\n \"name\": \"StepRoute\",\n \"declaration\": \"export interface StepRoute {\\n readonly provider: string;\\n readonly model: string;\\n}\"\n },\n {\n \"name\": \"TerminalCallView\",\n \"declaration\": \"export interface TerminalCallView {\\n card: 'terminal';\\n title: string;\\n description?: string;\\n cwd?: string;\\n}\"\n },\n {\n \"name\": \"TerminalResultView\",\n \"declaration\": \"export interface TerminalResultView {\\n card: 'terminal';\\n title?: string;\\n output?: string;\\n exitCode?: number;\\n signal?: string;\\n}\"\n },\n {\n \"name\": \"ToolActivationContext\",\n \"declaration\": \"export interface ToolActivationContext {\\n readonly agent: Agent;\\n readonly route: StepRoute;\\n readonly signal: AbortSignal;\\n}\"\n },\n {\n \"name\": \"ToolCallId\",\n \"declaration\": \"export type ToolCallId = Branded<'ToolCallId'>;\"\n },\n {\n \"name\": \"ToolCallKind\",\n \"declaration\": \"export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\"\n },\n {\n \"name\": \"ToolCallView\",\n \"declaration\": \"export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\"\n },\n {\n \"name\": \"ToolDefinition\",\n \"declaration\": \"export interface ToolDefinition extends ToolSchema {\\n readonly output: ToolOutputDefinition;\\n execute(args: unknown, exec: ToolRunContext): Promise;\\n projectContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\\n timeoutMs?: number;\\n isConcurrencySafe?(args: unknown): boolean;\\n presentCall?(args: unknown): ToolCallView | undefined;\\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\\n}\"\n },\n {\n \"name\": \"ToolErrorInfo\",\n \"declaration\": \"export interface ToolErrorInfo {\\n name: string;\\n code: string;\\n reason?: string;\\n}\"\n },\n {\n \"name\": \"ToolExecution\",\n \"declaration\": \"export interface ToolExecution extends ToolExecutionInput {\\n readonly rootCallId: ToolCallId;\\n readonly token: ToolExecutionToken;\\n}\"\n },\n {\n \"name\": \"ToolExecutionFailure\",\n \"declaration\": \"export interface ToolExecutionFailure {\\n readonly isError: true;\\n readonly error: ToolFailure;\\n readonly value?: never;\\n readonly content: ContentBlock[];\\n readonly meta?: JsonValue;\\n readonly additionalContexts?: UserMessage[];\\n readonly concludesTurn?: never;\\n}\"\n },\n {\n \"name\": \"ToolExecutionInput\",\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}\"\n },\n {\n \"name\": \"ToolExecutionMode\",\n \"declaration\": \"export type ToolExecutionMode = {\\n kind: 'parallel';\\n} | {\\n kind: 'exclusive';\\n};\"\n },\n {\n \"name\": \"ToolExecutionResult\",\n \"declaration\": \"export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\"\n },\n {\n \"name\": \"ToolExecutionSuccess\",\n \"declaration\": \"export interface ToolExecutionSuccess {\\n readonly isError: false;\\n readonly value: JsonValue;\\n readonly content: ContentBlock[];\\n readonly error?: never;\\n readonly meta?: JsonValue;\\n readonly additionalContexts?: UserMessage[];\\n readonly concludesTurn?: true;\\n}\"\n },\n {\n \"name\": \"ToolExecutionToken\",\n \"declaration\": \"export type ToolExecutionToken = symbol & {\\n readonly [toolExecutionTokenBrand]: true;\\n};\"\n },\n {\n \"name\": \"ToolFailure\",\n \"declaration\": \"export interface ToolFailure {\\n message: string;\\n info?: ToolErrorInfo;\\n}\"\n },\n {\n \"name\": \"ToolGuard\",\n \"declaration\": \"export type ToolGuard = (execution: Readonly) => string | undefined;\"\n },\n {\n \"name\": \"ToolOutputDefinition\",\n \"declaration\": \"export interface ToolOutputDefinition {\\n readonly schema: JsonSchemaNode;\\n render(args: unknown, value: JsonValue): ContentBlock[];\\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\\n}\"\n },\n {\n \"name\": \"ToolPresentationMode\",\n \"declaration\": \"export type ToolPresentationMode = 'native' | 'ptc';\"\n },\n {\n \"name\": \"ToolRegistrationOptions\",\n \"declaration\": \"export interface ToolRegistrationOptions {\\n readonly when?: (context: ToolActivationContext) => boolean | Promise;\\n}\"\n },\n {\n \"name\": \"ToolRestriction\",\n \"declaration\": \"export interface ToolRestriction {\\n readonly allow?: readonly string[];\\n readonly deny?: readonly string[];\\n}\"\n },\n {\n \"name\": \"ToolResult\",\n \"declaration\": \"export interface ToolResult {\\n content: ContentBlock[];\\n isError: boolean;\\n meta?: JsonValue;\\n}\"\n },\n {\n \"name\": \"ToolResultView\",\n \"declaration\": \"export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\"\n },\n {\n \"name\": \"ToolRunContext\",\n \"declaration\": \"export interface ToolRunContext extends ToolExecution {\\n deferContext(context: UserMessage): void;\\n concludeTurn(): void;\\n}\"\n },\n {\n \"name\": \"ToolSchema\",\n \"declaration\": \"export interface ToolSchema {\\n deferLoading?: true;\\n name: string;\\n description: string;\\n parameters: Record;\\n}\"\n },\n {\n \"name\": \"UserMessage\",\n \"declaration\": \"export interface UserMessage extends MessageBase {\\n readonly role: 'user';\\n}\"\n },\n {\n \"name\": \"WebFetchResultView\",\n \"declaration\": \"export interface WebFetchResultView {\\n card: 'web';\\n kind: 'fetch';\\n title?: string;\\n url: string;\\n statusCode: number;\\n truncated: boolean;\\n}\"\n },\n {\n \"name\": \"WebResultView\",\n \"declaration\": \"export type WebResultView = WebSearchResultView | WebFetchResultView;\"\n },\n {\n \"name\": \"WebSearchResultView\",\n \"declaration\": \"export interface WebSearchResultView {\\n card: 'web';\\n kind: 'search';\\n title?: string;\\n sources: WebSource[];\\n answer?: string;\\n truncated: boolean;\\n}\"\n },\n {\n \"name\": \"WebSource\",\n \"declaration\": \"export interface WebSource {\\n url: string;\\n title?: string;\\n snippet?: string;\\n publishedAt?: string;\\n}\"\n }\n ]\n }\n}"}],"isError":false,"id":"{{message:5}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"inspect-tools-api"},"toolCallId":"inspect-tools-api","content":[{"type":"text","text":"{\n \"platform\": \"host\",\n \"provider\": \"Service\",\n \"method\": \"listService\",\n \"data\": {\n \"mode\": \"service\",\n \"service\": {\n \"key\": \"tools\",\n \"description\": \"Tool registry and execution pipeline. Scoped registrations shadow globals; one visibility resolver feeds presentation, lookup, and dispatch.\",\n \"access\": {\n \"optional\": {\n \"expression\": \"ctx.get(\\\"tools\\\")\",\n \"requiresUndefinedCheck\": true\n },\n \"hardDependency\": {\n \"inject\": [\n \"tools\"\n ],\n \"expression\": \"ctx.tools\"\n }\n },\n \"methods\": [\n {\n \"signature\": \"presentAs(mode: ToolPresentationMode): () => void\",\n \"description\": \"Present the calling scope's tools in `mode` instead of the deployment default. Nearest scope on the chain wins, so a preset's standing declaration covers every agent joined under it.\\n\\nScoped only, and one declaration per scope: this is how an agent preset composes PTC mode agents beside native ones in the same process, and a process-global override would be the `mode` config field instead.\",\n \"parameters\": [\n {\n \"name\": \"mode\",\n \"description\": \"the presentation the covered agents' models see.\"\n }\n ],\n \"returns\": \"the exact disposer that restores the deployment default.\"\n },\n {\n \"signature\": \"register(definition: ToolDefinition, options: ToolRegistrationOptions = {}): () => void\",\n \"description\": \"Register globally or in the calling agent scope. Scoped tools shadow globals; duplicates within one layer and the reserved `run_code` name fail.\",\n \"parameters\": [\n {\n \"name\": \"definition\",\n \"description\": \"tool schema, execution, and optional finalization/presentation callbacks.\"\n },\n {\n \"name\": \"options\",\n \"description\": \"optional step-time activation condition.\"\n }\n ],\n \"returns\": \"the exact disposer that unregisters the tool.\"\n },\n {\n \"signature\": \"restrict(filter: ToolRestriction): () => void\",\n \"description\": \"Restrict global tools for the calling agent scope. Empty filters, unknown names, scope-local names, and reserved transport names fail. Restrictions intersect; scoped registrations remain visible.\",\n \"parameters\": [\n {\n \"name\": \"filter\",\n \"description\": \"global-tool mask: `allow` (keep only) and/or `deny` (remove).\"\n }\n ],\n \"returns\": \"the exact disposer that lifts this restriction.\"\n },\n {\n \"signature\": \"guard(guard: ToolGuard): () => void\",\n \"description\": \"Register a monotonic guard after the extensible `tools/pre-execute` waterfall. A plain-context guard applies globally; one registered through `agent.ctx` applies only to that agent. Any matching guard may deny by returning a reason, while no guard can force-allow a call another guard denied. The exact effect disposer is returned for ordered ownership and HMR cleanup.\",\n \"parameters\": [\n {\n \"name\": \"guard\",\n \"description\": \"synchronous check; a returned string denies the execution.\"\n }\n ],\n \"returns\": \"the exact disposer that unregisters the guard.\"\n },\n {\n \"signature\": \"get(name: string, scope?: ScopeKey, toolSet?: ResolvedToolSet): ToolDefinition | undefined\",\n \"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.\",\n \"parameters\": [\n {\n \"name\": \"name\",\n \"description\": \"the tool name as registered.\"\n },\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n },\n {\n \"name\": \"toolSet\",\n \"description\": \"step admission; omit only to inspect registered definitions.\"\n }\n ],\n \"returns\": \"the definition the scope resolves, or undefined when none is visible.\"\n },\n {\n \"signature\": \"async resolve(context: ToolActivationContext): Promise\",\n \"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.\",\n \"parameters\": [\n {\n \"name\": \"context\",\n \"description\": \"preparing agent, step model selection, and cancellation.\"\n }\n ],\n \"returns\": \"the complete selection; the caller disposes it after its tool batch settles.\"\n },\n {\n \"signature\": \"schemas(scope?: ScopeKey, toolSet?: ResolvedToolSet): ToolSchema[]\",\n \"description\": \"Project visible definitions onto the allowlisted model-facing schema fields, excluding execution and presentation callbacks.\",\n \"parameters\": [\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n },\n {\n \"name\": \"toolSet\",\n \"description\": \"step admission, or omitted for registered-definition inspection.\"\n }\n ],\n \"returns\": \"one deep-cloned schema per visible tool.\"\n },\n {\n \"signature\": \"executionMode(exec: ToolExecutionInput): ToolExecutionMode\",\n \"description\": \"Classify a pending call through the caller's visible tool definition. Only an exact `true` is parallel; unknown, hidden, undeclared, invalid, or throwing classifiers are exclusive.\",\n \"parameters\": [\n {\n \"name\": \"exec\",\n \"description\": \"call name, parsed arguments, and optional agent scope.\"\n }\n ],\n \"returns\": \"the fail-closed scheduling mode.\"\n },\n {\n \"signature\": \"async execute(exec: ToolExecutionInput): Promise\",\n \"description\": \"Execute through pre-policy, guards, around-dispatch, post-policy, definition-owned content finalization, and final notification. Tool and listener failures resolve as materialized error results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen snapshot final observers receive. Cancellation arriving after entry and before final result materialization skips a not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a successful started outcome with `ABORTED`; already-started work is still drained and may retain a tool-owned structured error.\",\n \"parameters\": [\n {\n \"name\": \"exec\",\n \"description\": \"the typed same-process call input. The registry assigns its correlation token before policy begins.\"\n }\n ],\n \"returns\": \"the materialized final result.\"\n }\n ]\n },\n \"referencedTypes\": [\n {\n \"name\": \"Agent\",\n \"declaration\": \"export interface Agent {\\n readonly id: SessionId;\\n}\"\n },\n {\n \"name\": \"Branded\",\n \"declaration\": \"export type Branded = string & {\\n readonly [BRAND]: B;\\n};\"\n },\n {\n \"name\": \"DiffCallView\",\n \"declaration\": \"export interface DiffCallView {\\n card: 'diff';\\n title: string;\\n diffs: FileDiff[];\\n locations?: FileLocation[];\\n}\"\n },\n {\n \"name\": \"DiffResultView\",\n \"declaration\": \"export interface DiffResultView {\\n card: 'diff';\\n title?: string;\\n diffs: FileDiff[];\\n}\"\n },\n {\n \"name\": \"FileDiff\",\n \"declaration\": \"export interface FileDiff {\\n path: string;\\n oldText: string | null;\\n newText: string;\\n}\"\n },\n {\n \"name\": \"FileLocation\",\n \"declaration\": \"export interface FileLocation {\\n path: string;\\n line?: number;\\n}\"\n },\n {\n \"name\": \"GenericCallView\",\n \"declaration\": \"export interface GenericCallView {\\n card: 'generic';\\n title: string;\\n kind?: ToolCallKind;\\n rawInput?: unknown;\\n content?: ContentBlock[];\\n locations?: FileLocation[];\\n}\"\n },\n {\n \"name\": \"GenericResultView\",\n \"declaration\": \"export interface GenericResultView {\\n card: 'generic';\\n title?: string;\\n content?: ContentBlock[];\\n}\"\n },\n {\n \"name\": \"JsonSchemaNode\",\n \"declaration\": \"export interface JsonSchemaNode {\\n type?: JsonSchemaType;\\n oneOf?: JsonSchemaNode[];\\n properties?: Record;\\n required?: string[];\\n additionalProperties?: boolean;\\n items?: JsonSchemaNode;\\n enum?: JsonSchemaScalar[];\\n const?: JsonSchemaScalar;\\n description?: string;\\n title?: string;\\n default?: JsonValue;\\n examples?: JsonValue;\\n}\"\n },\n {\n \"name\": \"JsonSchemaScalar\",\n \"declaration\": \"export type JsonSchemaScalar = string | number | boolean | null;\"\n },\n {\n \"name\": \"JsonSchemaType\",\n \"declaration\": \"export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\"\n },\n {\n \"name\": \"JsonValue\",\n \"declaration\": \"export type JsonValue = null | boolean | number | string | JsonValue[] | {\\n [key: string]: JsonValue;\\n};\"\n },\n {\n \"name\": \"ReadFileLine\",\n \"declaration\": \"export interface ReadFileLine {\\n number: number;\\n text: string;\\n}\"\n },\n {\n \"name\": \"ReadResultView\",\n \"declaration\": \"export interface ReadResultView {\\n card: 'read';\\n title?: string;\\n path: string;\\n offset: number;\\n lines: ReadFileLine[];\\n totalLines: number;\\n lang?: string;\\n content?: ContentBlock[];\\n}\"\n },\n {\n \"name\": \"ResolvedToolSet\",\n \"declaration\": \"export interface ResolvedToolSet {\\n dispose(): void;\\n view(scope: ScopeKey | undefined): ReadonlyMap;\\n}\"\n },\n {\n \"name\": \"ScopeKey\",\n \"declaration\": \"export type ScopeKey = object;\"\n },\n {\n \"name\": \"SearchFileMatches\",\n \"declaration\": \"export interface SearchFileMatches {\\n path: string;\\n matches: SearchLineMatch[];\\n}\"\n },\n {\n \"name\": \"SearchLineMatch\",\n \"declaration\": \"export interface SearchLineMatch {\\n lineNumber: number;\\n line: string;\\n}\"\n },\n {\n \"name\": \"SearchMatchesResultView\",\n \"declaration\": \"export interface SearchMatchesResultView {\\n card: 'search';\\n shape: 'matches';\\n title?: string;\\n files: SearchFileMatches[];\\n truncated: boolean;\\n total: number;\\n}\"\n },\n {\n \"name\": \"SearchPathsResultView\",\n \"declaration\": \"export interface SearchPathsResultView {\\n card: 'search';\\n shape: 'paths';\\n title?: string;\\n paths: string[];\\n truncated: boolean;\\n total: number;\\n}\"\n },\n {\n \"name\": \"SearchResultView\",\n \"declaration\": \"export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\"\n },\n {\n \"name\": \"SessionId\",\n \"declaration\": \"export type SessionId = Branded<'SessionId'>;\"\n },\n {\n \"name\": \"TerminalCallView\",\n \"declaration\": \"export interface TerminalCallView {\\n card: 'terminal';\\n title: string;\\n description?: string;\\n cwd?: string;\\n}\"\n },\n {\n \"name\": \"TerminalResultView\",\n \"declaration\": \"export interface TerminalResultView {\\n card: 'terminal';\\n title?: string;\\n output?: string;\\n exitCode?: number;\\n signal?: string;\\n}\"\n },\n {\n \"name\": \"ToolActivationContext\",\n \"declaration\": \"export interface ToolActivationContext {\\n readonly agent: Agent;\\n readonly modelSelection: ModelSelection;\\n readonly signal: AbortSignal;\\n}\"\n },\n {\n \"name\": \"ToolCallId\",\n \"declaration\": \"export type ToolCallId = Branded<'ToolCallId'>;\"\n },\n {\n \"name\": \"ToolCallKind\",\n \"declaration\": \"export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\"\n },\n {\n \"name\": \"ToolCallView\",\n \"declaration\": \"export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\"\n },\n {\n \"name\": \"ToolDefinition\",\n \"declaration\": \"export interface ToolDefinition extends ToolSchema {\\n readonly output: ToolOutputDefinition;\\n execute(args: unknown, exec: ToolRunContext): Promise;\\n projectContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\\n timeoutMs?: number;\\n isConcurrencySafe?(args: unknown): boolean;\\n presentCall?(args: unknown): ToolCallView | undefined;\\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\\n}\"\n },\n {\n \"name\": \"ToolErrorInfo\",\n \"declaration\": \"export interface ToolErrorInfo {\\n name: string;\\n code: string;\\n reason?: string;\\n}\"\n },\n {\n \"name\": \"ToolExecution\",\n \"declaration\": \"export interface ToolExecution extends ToolExecutionInput {\\n readonly rootCallId: ToolCallId;\\n readonly token: ToolExecutionToken;\\n}\"\n },\n {\n \"name\": \"ToolExecutionFailure\",\n \"declaration\": \"export interface ToolExecutionFailure {\\n readonly isError: true;\\n readonly error: ToolFailure;\\n readonly value?: never;\\n readonly content: ContentBlock[];\\n readonly meta?: JsonValue;\\n readonly additionalContexts?: UserMessage[];\\n readonly concludesTurn?: never;\\n}\"\n },\n {\n \"name\": \"ToolExecutionInput\",\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}\"\n },\n {\n \"name\": \"ToolExecutionMode\",\n \"declaration\": \"export type ToolExecutionMode = {\\n kind: 'parallel';\\n} | {\\n kind: 'exclusive';\\n};\"\n },\n {\n \"name\": \"ToolExecutionResult\",\n \"declaration\": \"export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\"\n },\n {\n \"name\": \"ToolExecutionSuccess\",\n \"declaration\": \"export interface ToolExecutionSuccess {\\n readonly isError: false;\\n readonly value: JsonValue;\\n readonly content: ContentBlock[];\\n readonly error?: never;\\n readonly meta?: JsonValue;\\n readonly additionalContexts?: UserMessage[];\\n readonly concludesTurn?: true;\\n}\"\n },\n {\n \"name\": \"ToolExecutionToken\",\n \"declaration\": \"export type ToolExecutionToken = symbol & {\\n readonly [toolExecutionTokenBrand]: true;\\n};\"\n },\n {\n \"name\": \"ToolFailure\",\n \"declaration\": \"export interface ToolFailure {\\n message: string;\\n info?: ToolErrorInfo;\\n}\"\n },\n {\n \"name\": \"ToolGuard\",\n \"declaration\": \"export type ToolGuard = (execution: Readonly) => string | undefined;\"\n },\n {\n \"name\": \"ToolOutputDefinition\",\n \"declaration\": \"export interface ToolOutputDefinition {\\n readonly schema: JsonSchemaNode;\\n render(args: unknown, value: JsonValue): ContentBlock[];\\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\\n}\"\n },\n {\n \"name\": \"ToolPresentationMode\",\n \"declaration\": \"export type ToolPresentationMode = 'native' | 'ptc';\"\n },\n {\n \"name\": \"ToolRegistrationOptions\",\n \"declaration\": \"export interface ToolRegistrationOptions {\\n readonly when?: (context: ToolActivationContext) => boolean | Promise;\\n}\"\n },\n {\n \"name\": \"ToolRestriction\",\n \"declaration\": \"export interface ToolRestriction {\\n readonly allow?: readonly string[];\\n readonly deny?: readonly string[];\\n}\"\n },\n {\n \"name\": \"ToolResult\",\n \"declaration\": \"export interface ToolResult {\\n content: ContentBlock[];\\n isError: boolean;\\n meta?: JsonValue;\\n}\"\n },\n {\n \"name\": \"ToolResultView\",\n \"declaration\": \"export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\"\n },\n {\n \"name\": \"ToolRunContext\",\n \"declaration\": \"export interface ToolRunContext extends ToolExecution {\\n deferContext(context: UserMessage): void;\\n concludeTurn(): void;\\n}\"\n },\n {\n \"name\": \"ToolSchema\",\n \"declaration\": \"export interface ToolSchema {\\n deferLoading?: true;\\n name: string;\\n description: string;\\n parameters: Record;\\n}\"\n },\n {\n \"name\": \"UserMessage\",\n \"declaration\": \"export interface UserMessage extends MessageBase {\\n readonly role: 'user';\\n}\"\n },\n {\n \"name\": \"WebFetchResultView\",\n \"declaration\": \"export interface WebFetchResultView {\\n card: 'web';\\n kind: 'fetch';\\n title?: string;\\n url: string;\\n statusCode: number;\\n truncated: boolean;\\n}\"\n },\n {\n \"name\": \"WebResultView\",\n \"declaration\": \"export type WebResultView = WebSearchResultView | WebFetchResultView;\"\n },\n {\n \"name\": \"WebSearchResultView\",\n \"declaration\": \"export interface WebSearchResultView {\\n card: 'web';\\n kind: 'search';\\n title?: string;\\n sources: WebSource[];\\n answer?: string;\\n truncated: boolean;\\n}\"\n },\n {\n \"name\": \"WebSource\",\n \"declaration\": \"export interface WebSource {\\n url: string;\\n title?: string;\\n snippet?: string;\\n publishedAt?: string;\\n}\"\n }\n ]\n }\n}"}],"isError":false,"id":"{{message:5}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-subprocess-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788778146017,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788778146017,"index":0,"dt":[],"id":"inspect-subprocess-api","name":"cordis_inspect_query","args":["{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"]},{"type":"chunk","time":1788778146017,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"inspect-subprocess-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"}}},{"type":"chunk","time":1788778146017,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788778146017,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} diff --git a/snapshots/session/model-switch-notice/model-switch-driver.mjs b/snapshots/session/model-switch-notice/model-switch-driver.mjs index 4ce42062b5..ade04ebf97 100644 --- a/snapshots/session/model-switch-notice/model-switch-driver.mjs +++ b/snapshots/session/model-switch-notice/model-switch-driver.mjs @@ -7,15 +7,15 @@ export const name = 'model-switch-driver' export const inject = ['agents'] /** - * Change the step route after `todo_write`; headless owns the selection notice. + * Change the step model after `todo_write`; headless owns the selection notice. * @param {import('@deepseek-ai/cordis').Context} ctx - composition context. */ export function apply(ctx) { ctx.on('session/event', (session, event) => { if (event.type === 'todo/write') switched.add(session) }) - ctx.on('agent/route', async ({ agent }, next) => { - const route = await next() - return switched.has(agent.session) ? SELECTED : route + ctx.on('agent/model', async ({ agent }, next) => { + const proposed = await next() + return switched.has(agent.session) ? SELECTED : proposed }) }