feat(llm): emit dynamic tool updates and project them per route
Log tool changes as developer messages independently of model capability, fold committed headers and updates into Session.toolHistory(), and let the LLM runtime construct deferred and retained declarations for routes that declare a toolUpdate mode. DeepSeek serializes updates as system-role tool_addition/tool_removal blocks with defer_loading and the tool-changes beta header. An unchanged restored tool continues the series and is re-offered through its recorded blocks instead of rewriting declarations. Replace the obsolete rejection tests with wire assertions and add coverage for the session fold, loop emission, route projection, config validation, and the beta header. Chat and Trajectory changes land separately.
This commit is contained in:
1 parent
e7def469e1
commit
bc8c0dbf40
57 files changed
+946
-85
No files matched your search
+4
-4
@@ -9,11 +9,11 @@
|
||||
en: 4b02ed98a500205c
|
||||
zh: e1a075f6a8251059
|
||||
/agent-note-developer-messages-for-incremental-session-changes/decision:
|
||||
en: 5fecb076fa475b6d
|
||||
zh: b59c76222ec00123
|
||||
en: 986e4806b1d7da88
|
||||
zh: dedecc5248fdd5f3
|
||||
/agent-note-developer-messages-for-incremental-session-changes/alternatives-considered:
|
||||
en: a9ec42fb650d6cd8
|
||||
zh: c748181c603e675f
|
||||
/agent-note-developer-messages-for-incremental-session-changes/consequences:
|
||||
en: 28e92dab15059d0c
|
||||
zh: 31b4f1955d82c02c
|
||||
en: e98265bb86e38a2d
|
||||
zh: a9749feb7d90c22a
|
||||
@@ -20,7 +20,7 @@ Dynamic tool changes need durable, ordered records before providers can preserve
|
||||
|
||||
The V4 codec confines developer roles to the `developer/message` slot, including rejection from inbox and title-request arrays. Native validation checks required developer fields, historical header bindings, and developer-only tool-change blocks while preserving additional JSON properties. Optional metadata additions therefore remain readable under the persistence checker's optional-property rule; additional fields cannot weaken required-field, role, or open-step checks. An ignorable marker does not establish reader support: physical decoding preserves developer payloads until vocabulary-aware admission, while writers and native readers reject malformed known data. Developer admission leaves malformed ordinary message slots to decoder recovery. Installed Session adoption validates surface relationships without converting developer records into released-V3 user records. The [released migration rules](2026-08-31-released-session-format-migrations.md) continue to protect committed predecessor generations.
|
||||
|
||||
The producer requirement has one scoped exception: finalized Session V4 reserves the persisted representation needed by issue #4146. Waiting for provider and UI implementations would couple the format release to those independent integrations. The reservation permits storage and validation only; no shipped profile emits developer records, and unsupported consumers throw. Production emission requires provider, UI, and compaction support together.
|
||||
Session V4 reserves the persisted representation needed by issue #4146. Production emission, provider projection, UI presentation, and compaction integration are described in the [dynamic tool update decision](2026-09-20-dynamic-tool-updates.md).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -32,4 +32,4 @@ The producer requirement has one scoped exception: finalized Session V4 reserves
|
||||
|
||||
## Consequences
|
||||
|
||||
Session V4 persists the tool-change representation for issue #4146. Automatic emission, provider serialization, deferred loading, and UI presentation are intentionally deferred so this type-and-persistence change does not block the V4 merge. Both DeepSeek protocols and pi-ai throw for developer history and deferred-loading requests; Chat and Trajectory throw for developer events. Shipped model-output and SDK snapshot scenarios contain no developer records.
|
||||
Session V4 persists the tool-change representation for issue #4146. Emission, route projection, DeepSeek serialization, and Chat and Trajectory presentation are owned by the [dynamic tool update decision](2026-09-20-dynamic-tool-updates.md).
|
||||
@@ -20,7 +20,7 @@ Status: implemented
|
||||
|
||||
V4 编解码器将 developer 角色限定在 `developer/message` 字段内,并拒绝 inbox 与标题请求数组中的该角色。原生验证检查 developer 必需字段、历史请求头绑定及工具变更块仅限 developer 的约束,同时保留附加 JSON 属性。因此,可选元数据添加符合持久化检查器的可选属性规则,仍可读取;附加字段不能削弱必需字段、角色或打开 step 的检查。可忽略标记不代表读取方支持该事件:物理解码保留 developer 载荷,直到按词表执行接纳;写入方和原生读取方仍拒绝格式错误的已知数据。developer 接纳逻辑将格式错误的普通消息字段留给解码器恢复。已安装的 Session 接纳逻辑验证 surface 关系,不把 developer 记录转换为已发布 V3 的 user 记录。[已发布格式迁移规则](2026-08-31-released-session-format-migrations.zh.md)继续保护已提交的前代文件。
|
||||
|
||||
产生方要求有一个限定例外:已定稿 Session V4 为 issue #4146 保留所需的持久化表示。等待提供方与 UI 实现会将格式发布与这些独立集成耦合。该预留仅允许存储和验证;已提供的 profile 不发出 developer 记录,不支持的消费者会抛出错误。生产环境发出这些记录要求提供方、UI 与压缩支持一起落地。
|
||||
Session V4 预留 issue #4146 所需的持久表示。生产发出、提供方投影、UI 展示和压缩集成见[动态工具更新决策](2026-09-20-dynamic-tool-updates.zh.md)。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -32,4 +32,4 @@ V4 编解码器将 developer 角色限定在 `developer/message` 字段内,并
|
||||
|
||||
## Consequences
|
||||
|
||||
Session V4 为 issue #4146 持久化工具变更表示。自动发出事件、提供方序列化、延迟加载与 UI 展示有意留待后续实现,使此类型与持久化变更不阻塞 V4 合并。DeepSeek 两种协议与 pi-ai 对 developer 历史及延迟加载请求抛出错误;Chat 与 Trajectory 对 developer 事件抛出错误。已提供的模型输出与 SDK 快照场景不包含 developer 记录。
|
||||
Session V4 持久化 issue #4146 的工具变更表示。发出、路由投影、DeepSeek 序列化及 Chat 和 Trajectory 展示由[动态工具更新决策](2026-09-20-dynamic-tool-updates.zh.md)负责。
|
||||
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-09-20-dynamic-tool-updates.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-09-20-dynamic-tool-updates.md
|
||||
/agent-note-active-tool-headers-and-stateful-request-projection:
|
||||
en: c40dd7568bba391f
|
||||
zh: 295ce33341e0aab7
|
||||
/agent-note-active-tool-headers-and-stateful-request-projection/problem:
|
||||
en: 97fe58a13135b4a1
|
||||
zh: 7490373a8d941da5
|
||||
/agent-note-active-tool-headers-and-stateful-request-projection/decision:
|
||||
en: f1a9fc8350515c9e
|
||||
zh: bbd7f7d3a4c11a63
|
||||
/agent-note-active-tool-headers-and-stateful-request-projection/alternatives-considered:
|
||||
en: b78785f2a603eba8
|
||||
zh: b8c268228da7e2e7
|
||||
/agent-note-active-tool-headers-and-stateful-request-projection/consequences:
|
||||
en: 84e1c8d8fb263824
|
||||
zh: 6bfa1f3a71ee7ee9
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: Active tool headers and stateful request projection
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-20-dynamic-tool-updates.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Plugin and MCP tools change during conversations. Providers differ in whether they activate deferred definitions or remove tools through history. Storing retained provider declarations as active tools makes comparisons repeat removals and miss restorations.
|
||||
|
||||
## Decision
|
||||
|
||||
`request/header.tools` records assembled active tools independently of model capability. The loop compares names against the preceding header and emits one `developer/message` with additions and removals under the `tool-registry` source. Additions reference the new header through `headerSeq`. Definition changes remain header changes; unchanged names emit no update. Existing header equality and request-series rules remain in force.
|
||||
|
||||
`Session.toolHistory()` caches a capability-independent fold of committed headers and developer messages. First access restores inherited events; later reads consume only unseen events. Immutable snapshots carry initial declarations and ordered update identities with additions resolved through their historical headers. Explicit series starts and changed retained definitions rebuild declarations. Restoring a retained name with an unchanged definition continues the series: the projection keeps the declaration unchanged and lets the recorded addition re-offer the tool after its earlier removal, so a removal never disables a declared tool and a loading mode never rewrites an earlier declaration.
|
||||
|
||||
Loop and compaction requests carry this snapshot as `GenerateOptions.toolHistory`. At adapter dispatch, `projectToolUpdates` uses the prepared route's `toolUpdate`. Unsupported routes receive active definitions without `deferLoading` or developer messages. Supported routes defer historical additions; `in-history` retains removed definitions and removal blocks, while `addition-only` omits both. Only current-series update identities survive. Missing history or a request prefix omitting recorded updates uses current declarations without developer updates. Explicit deferred loading remains available on capable routes.
|
||||
|
||||
DeepSeek translates projected updates into system-role `tool_addition` and `tool_removal` blocks and `defer_loading` declarations, sending the tool-changes beta header when update blocks occur. The default `deepseek-flash` entry declares `addition-only`; pi-ai declares no mode. Chat and Trajectory display developer messages as context nodes, using the unknown-block renderer for tool changes.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Store provider declarations in headers.** Retained removed definitions are not active tools. Mixing those meanings makes durable updates capability-dependent and breaks change detection.
|
||||
|
||||
**Reconstruct definitions in adapters from messages alone.** Addition messages contain names, not schemas or event-level header references. Session owns that historical relationship; adapters translate projected requests.
|
||||
|
||||
**Scan the complete log on every request.** The cached fold consumes new events and supplies stable snapshots to ordinary and auxiliary calls without adding arbitrary synchronous history readers.
|
||||
|
||||
**Reject same-name schema changes during tool registration.** This restriction is not enforced. Immediate failure for a plugin registering an incompatible tool requires comparing the new registration with historical definitions of tools called on each affected current surface, while excluding calls removed by compaction. That registration-time historical and scope-aware check is deferred because of its implementation complexity. Same-name schema changes remain allowed; they rebuild declarations and invalidate prefix reuse even on `in-history` routes.
|
||||
|
||||
## Consequences
|
||||
|
||||
Tool lists and logical update events are capability-independent; only outgoing declarations and messages vary. Calls to removed retained tools fail through the existing `UNKNOWN_TOOL` path. Changed definitions and incomplete auxiliary prefixes sacrifice prefix reuse for coherent declarations; unchanged restoration keeps the cached prefix on `in-history` routes. No persisted fields or Session format revision are added.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: 有效工具请求头与有状态请求投影
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-20-dynamic-tool-updates.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
插件和 MCP 工具会在对话期间变化。不同提供方对通过历史激活延迟定义或移除工具的支持不同。把为提供方保留的声明当作有效工具存储,会使比较重复记录移除并遗漏恢复。
|
||||
|
||||
## Decision
|
||||
|
||||
`request/header.tools` 记录组装得到的有效工具,不依赖模型能力。循环将名称与前一请求头比较,以 `tool-registry` 来源发出一条包含添加和移除的 `developer/message`。添加通过 `headerSeq` 引用新请求头。定义变化仍体现为请求头变化;名称未变时不发出更新。现有请求头相等判断和请求序列规则保持有效。
|
||||
|
||||
`Session.toolHistory()` 缓存对已提交请求头和 developer 消息的归约,不依赖模型能力。首次访问恢复继承事件;后续读取仅处理新增事件。不可变快照携带初始声明和有序更新标识,添加定义通过历史请求头解析。显式新序列和保留定义变化会重建声明。以未变化的定义恢复保留名称时序列继续:投影保持声明不变,由记录的添加在先前移除之后重新提供该工具,因此移除不会禁用已声明的工具,加载模式也不会改写先前声明。
|
||||
|
||||
循环和压缩请求通过 `GenerateOptions.toolHistory` 携带此快照。适配器分发时,`projectToolUpdates` 使用已准备路由的 `toolUpdate`。不支持的路由接收有效定义,不携带 `deferLoading` 或 developer 消息。支持的路由延迟历史添加;`in-history` 保留已移除定义和移除块,`addition-only` 则省略两者。仅保留当前序列的更新标识。缺少历史或请求前缀遗漏已记录更新时,使用当前声明且不发送 developer 更新。显式延迟加载在支持的路由上仍然可用。
|
||||
|
||||
DeepSeek 把投影后的更新转换为 system 角色的 `tool_addition` 和 `tool_removal` 块以及 `defer_loading` 声明,在存在更新块时发送工具变更 beta 请求头。默认 `deepseek-flash` 条目声明 `addition-only`;pi-ai 不声明模式。Chat 和 Trajectory 将 developer 消息显示为上下文节点,工具变更使用未知块渲染器。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**在请求头存储提供方声明。** 为提供方保留的已移除定义不是有效工具。混合两种含义会使持久更新依赖能力,并破坏变更检测。
|
||||
|
||||
**仅凭消息在适配器重建定义。** 添加消息包含名称,不包含模式或事件级请求头引用。Session 拥有该历史关系;适配器转换投影后的请求。
|
||||
|
||||
**每次请求扫描完整日志。** 缓存归约处理新事件,为普通和辅助调用提供稳定快照,不增加任意同步历史读取接口。
|
||||
|
||||
**在工具注册时拒绝同名 schema 变化。** 当前不执行此限制。要让注册不兼容工具的插件立即收到异常,需要将新注册与各受影响当前 surface 中已调用工具的历史定义比较,同时排除已被压缩移除的调用。由于实现复杂,注册时结合历史和作用域的检查留待后续实现。同名 schema 变化仍被允许;它们会重建声明,即使在 `in-history` 路由上也会使前缀复用失效。
|
||||
|
||||
## Consequences
|
||||
|
||||
工具列表和逻辑更新事件不依赖能力;只有发出的声明和消息不同。调用已移除但保留声明的工具时,通过现有 `UNKNOWN_TOOL` 路径失败。定义变化和不完整辅助前缀为保证声明一致而放弃前缀复用;未变化的恢复在 `in-history` 路由上保留缓存前缀。不增加持久字段或 Session 格式版本。
|
||||
@@ -27,8 +27,8 @@
|
||||
en: 0ea92353b5344090
|
||||
zh: 46ec58a8e6259335
|
||||
/deepseek-harness-architecture/session-log:
|
||||
en: 03a4394c86b18d91
|
||||
zh: f5df5939f9b42990
|
||||
en: 5ba559b6222fcd28
|
||||
zh: b2ad1bb171dac078
|
||||
/deepseek-harness-architecture/capability-seams:
|
||||
en: 93d1dc2eeda4aec2
|
||||
zh: ee7a9da89151ea17
|
||||
|
||||
@@ -122,7 +122,7 @@ The session log is the source of the context the model sees. `deriveMessages()`
|
||||
|
||||
Session consumers know only the current logical format. Header-only `stat` and `list` rescan each Session directory, select its numerically highest canonical generation, and translate a supported historical header without loading events or publishing a successor. A stored-session `open` selects that same generation, refuses a future version, or decodes and composes the static adjacent migration chain once before returning validated current logical events. A read open uses that in-memory result without publishing a successor; a write open first encodes, verifies, and exclusively publishes the final version-named successor beside the unchanged source. Ordinary repair of an unsealed interrupted tail remains a handle consumer responsibility; migration inserts a missing interrupted `turn/end` only for the bounded released restart already sealed by a later `turn/start`. JSONL v0 uses `session.jsonl[.zstd]`, v1 and later use lowercase `session.vN.jsonl[.zstd]`, and committed generation paths are never renamed, replaced, or deleted. The JSONL provider owns physical framing, compression, generation selection, and exclusive publication, while each adjacent migration package owns exactly one `vN -> vN+1` step ([decision](../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md)).
|
||||
|
||||
**Model-visible means logged.** Anything that reaches a model request must be reconstructable from the log, and a runtime invariant asserts it. A new model-visible input requires a session event. Plugins that change existing message content register [pure message projections](subsystems/session.md#plugin-owned-message-projections); detached readers supply the same definitions explicitly.
|
||||
**Model-visible means logged.** A runtime invariant checks that model requests are reconstructable from the log. New model-visible inputs require session events. Plugins that change existing message content register [pure message projections](subsystems/session.md#plugin-owned-message-projections); detached readers supply the same definitions explicitly. Tool changes are capability-independent; [Session tool history](../packages/core/session/README.md) supplies provider-specific declarations.
|
||||
|
||||
**Projection seam.** `dsh-session-projection` owns `ctx.sessionProjections`: registered units fold committed events incrementally, host consumers read one typed state with `stateOf()`, and carriers batch cropped client views with `snapshot()`. A host reader either requires this service during activation or fails explicitly when the registry or required key is absent. Contributors may retain `ctx.inject(['sessionProjections'], ...)` registration without silently defaulting a missing host value. The agent loop registers shared `turnBoundary` state for its readers ([decision](../.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.md)).
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ turn/end
|
||||
|
||||
Session 消费方只了解当前逻辑格式。仅 header 的 `stat` 与 `list` 会重新扫描每个 Session 目录,选择数值最高的规范 generation,并在不加载事件或发布后继的情况下转换受支持的历史 header。已存储 Session 的 `open` 选择同一 generation,拒绝未来版本,或只 Decode 并组合一次构建时静态确定的相邻迁移链,再返回经过校验的当前逻辑事件。只读 open 直接使用这份内存结果,不发布后继;写 open 则先编码、校验并在未改变源的旁边排他发布最终版本命名的后继。未被后续事件封住的普通中断尾部仍由句柄消费方修复;只有在后续 `turn/start` 已经封住一种有限的已发布 restart 时,migration 才会插入缺失的 interrupted `turn/end`。JSONL v0 使用 `session.jsonl[.zstd]`,v1 及后续版本使用小写 `session.vN.jsonl[.zstd]`;已提交 generation 路径绝不重命名、替换或删除。JSONL provider 负责物理 framing、压缩、generation 选择与排他发布,每个相邻迁移包只负责一个 `vN -> vN+1` 步骤([决策](../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md))。
|
||||
|
||||
**模型可见即已记录。** 抵达模型请求的一切都必须能从日志重建,并由一项运行时不变量断言这一点。新增模型可见输入需要一个会话事件。修改现有消息内容的插件注册[纯消息投影](subsystems/session.zh.md#plugin-owned-message-projections),独立读取器显式传入相同的处理器。
|
||||
**模型可见即已记录。** 运行时不变量检查模型请求是否可从日志重建。新增模型可见输入需要会话事件。修改现有消息内容的插件注册[纯消息投影](subsystems/session.zh.md#plugin-owned-message-projections),独立读取器显式传入相同的处理器。 工具变更不依赖能力;[Session 工具历史](../packages/core/session/README.zh.md)提供提供方专用声明。
|
||||
|
||||
**投影 seam。** `dsh-session-projection` 提供 `ctx.sessionProjections`:已注册单元增量折叠已提交事件,host 消费方通过 `stateOf()` 读取单个类型化状态,载体通过 `snapshot()` 批量取得裁剪后的客户端视图。host 读取方要么在激活时要求该服务,要么在注册表或必需 key 缺席时明确失败。贡献方可以保留 `ctx.inject(['sessionProjections'], ...)` 注册,但不能为缺失的 host 值静默提供默认值。agent loop 为读取方注册共享的 `turnBoundary` 状态([决策](../.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.zh.md))。
|
||||
|
||||
|
||||
@@ -1805,8 +1805,8 @@ export type PiAiThinkingTokenBudgetField = NonNullable<OpenAICompletionsCompat['
|
||||
## `@deepseek-ai/dsh-llm-replay`
|
||||
|
||||
- `inject`: `llm`
|
||||
- `refs`: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · [`SystemPromptUpdate`](../packages/llm/llm/src/index.ts)
|
||||
- `source`: [`packages/test-support/llm-replay/src/index.ts:1123`](../packages/test-support/llm-replay/src/index.ts)
|
||||
- `refs`: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · [`SystemPromptUpdate`](../packages/llm/llm/src/index.ts) · [`ToolUpdate`](../packages/llm/llm/src/index.ts)
|
||||
- `source`: [`packages/test-support/llm-replay/src/index.ts:1129`](../packages/test-support/llm-replay/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config: the {@link ReplayConfig} inputs, each defaulting to its `DSH_SNAPSHOT_*` env var in `apply`. */
|
||||
@@ -1874,6 +1874,8 @@ export interface ReplayModelConfig {
|
||||
defaultReasoningEffort?: string
|
||||
/** Optional in-history system prompt replacement for a keyless replay route. */
|
||||
systemPromptUpdate?: SystemPromptUpdate
|
||||
/** Optional mid-conversation tool declaration mode for a keyless replay route. */
|
||||
toolUpdate?: ToolUpdate
|
||||
}
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-llm-replay -->
|
||||
|
||||
@@ -1807,8 +1807,8 @@ export type PiAiThinkingTokenBudgetField = NonNullable<OpenAICompletionsCompat['
|
||||
## `@deepseek-ai/dsh-llm-replay`
|
||||
|
||||
- `inject`: `llm`
|
||||
- `refs`: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · [`SystemPromptUpdate`](../packages/llm/llm/src/index.ts)
|
||||
- `source`: [`packages/test-support/llm-replay/src/index.ts:1123`](../packages/test-support/llm-replay/src/index.ts)
|
||||
- `refs`: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · [`SystemPromptUpdate`](../packages/llm/llm/src/index.ts) · [`ToolUpdate`](../packages/llm/llm/src/index.ts)
|
||||
- `source`: [`packages/test-support/llm-replay/src/index.ts:1129`](../packages/test-support/llm-replay/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config: the {@link ReplayConfig} inputs, each defaulting to its `DSH_SNAPSHOT_*` env var in `apply`. */
|
||||
@@ -1876,6 +1876,8 @@ export interface ReplayModelConfig {
|
||||
defaultReasoningEffort?: string
|
||||
/** Optional in-history system prompt replacement for a keyless replay route. */
|
||||
systemPromptUpdate?: SystemPromptUpdate
|
||||
/** Optional mid-conversation tool declaration mode for a keyless replay route. */
|
||||
toolUpdate?: ToolUpdate
|
||||
}
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-llm-replay -->
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
en: 989057ec9786a505
|
||||
zh: 03e20367da473086
|
||||
/llm-streaming/content-blocks-and-messages:
|
||||
en: b31628f3bdbf62cb
|
||||
zh: 0e6eb420ae5c5a3f
|
||||
en: 365ecc228f756098
|
||||
zh: 396e68661a789854
|
||||
/llm-streaming/streamchunk-the-raw-protocol:
|
||||
en: a18fe401763efde4
|
||||
zh: ef59a76d9c694654
|
||||
|
||||
@@ -17,9 +17,8 @@ Source: [`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts)
|
||||
```ts type-equiv
|
||||
/**
|
||||
* Merge-extensible content blocks keyed by `type`. New core blocks must land
|
||||
* with adapter, UI, and compaction support. Developer tool-change blocks are
|
||||
* reserved for Session V4 persistence; providers and UI reject them until
|
||||
* their producers and consumers are implemented together.
|
||||
* with adapter, UI, and compaction support. Tool-change blocks belong to
|
||||
* developer messages; `projectToolUpdates` selects what each route receives.
|
||||
*/
|
||||
interface ContentBlockMap {
|
||||
'text': TextBlock
|
||||
@@ -32,7 +31,7 @@ interface ContentBlockMap {
|
||||
}
|
||||
```
|
||||
|
||||
The block interfaces (full fields in source): `TextBlock` (`text`), `ReasoningBlock` (thinking, distinct from visible text), `ImageBlock` (a durable [image attachment](attachment.md)), `FileBlock` (a durable verbatim [file attachment](attachment.md) that request assembly projects to handle text for every route), and `ToolCallBlock` (`id: ToolCallId`, `name`, raw-JSON `arguments`). Tool results are first-class `ToolResultMessage` values with `toolCallId`, result `content`, and optional `isError`; they are not content blocks. `ContentBlock = ContentBlockMap[ContentBlockType]`. A new modality belongs in the merge-extensible map only when its adapter, UI, compaction, and durable replay paths honor it. Developer tool-change blocks are a persistence-only exception described in the [decision](../../.agents/notes/implemented/architecture/2026-09-17-developer-session-changes.md).
|
||||
The block interfaces (full fields in source): `TextBlock` (`text`), `ReasoningBlock` (thinking, distinct from visible text), `ImageBlock` (a durable [image attachment](attachment.md)), `FileBlock` (a durable verbatim [file attachment](attachment.md) that request assembly projects to handle text for every route), and `ToolCallBlock` (`id: ToolCallId`, `name`, raw-JSON `arguments`). Tool results are first-class `ToolResultMessage` values with `toolCallId`, result `content`, and optional `isError`; they are not content blocks. `ContentBlock = ContentBlockMap[ContentBlockType]`. A new modality belongs in the merge-extensible map only when its adapter, UI, compaction, and durable replay paths honor it. Developer tool-change blocks are projected according to the resolved route capability.
|
||||
|
||||
Image access belongs to request serialization rather than the durable attachment or deterministic request-image version. `resolveImageAttachmentAccess()` combines the attachment provider's optional host object path with a mapping supplied by the consumer for the current tool execution filesystem. The result is available only for that request and does not participate in `variantId`.
|
||||
|
||||
@@ -71,7 +70,7 @@ interface AssistantProviderMetadata {
|
||||
type Message = MessageRoleMap[keyof MessageRoleMap]
|
||||
```
|
||||
|
||||
`DeveloperMessage` records incremental agent session changes in conversation order with the `developer` role. `ToolAdditionBlock.toolName` activates the definition selected by the containing Session event's historical header reference; `ToolRemovalBlock.toolName` removes the active definition. Both blocks are rejected in other message roles. `deferLoading` independently requests deferred definition loading without requiring an addition record. See [Session](../../packages/core/session/README.md) for header binding and the [LLM package](../../packages/llm/llm/README.md#known-limitations-and-deferred-work) for provider support limits.
|
||||
`DeveloperMessage` records incremental agent session changes in conversation order with the `developer` role. `ToolAdditionBlock.toolName` activates the definition selected by the containing Session event's historical header reference; `ToolRemovalBlock.toolName` removes the active definition. Both blocks are rejected in other message roles. `deferLoading` independently requests deferred definition loading without requiring an addition record. See [Session](../../packages/core/session/README.md) for header binding and the [LLM package](../../packages/llm/llm/README.md#known-limitations-and-deferred-work) for provider support limits. `ToolUpdate` is `in-history` or `addition-only` on resolved and prepared model metadata. `GenerateOptions.toolHistory` carries `ToolHistory`: initial `tools` and ordered `updates`, each binding a developer `messageId` to its historically resolved `additions`. The runtime projects this state into provider declarations; it does not change the active tool list in logged headers.
|
||||
|
||||
Where a message came from is itself a merge-extensible sum type:
|
||||
|
||||
@@ -573,6 +572,8 @@ interface LlmResolvedModelInfo extends LlmModelInfo {
|
||||
reasoning?: LlmModelReasoningInfo
|
||||
/** Declared mid-conversation system prompt handling; absent means only a leading system message is read. */
|
||||
systemPromptUpdate?: SystemPromptUpdate
|
||||
/** Declared mid-conversation tool declaration handling; absent means every request declares the complete tool list. */
|
||||
toolUpdate?: ToolUpdate
|
||||
}
|
||||
```
|
||||
|
||||
@@ -615,6 +616,8 @@ interface GenerateOptions {
|
||||
system?: string
|
||||
/** Tool schemas (adapters map to the provider's `tools` field). */
|
||||
tools?: ToolSchema[]
|
||||
/** Session-folded tool history used for route projection; omission sends complete declarations without tool updates. */
|
||||
toolHistory?: ToolHistory
|
||||
temperature?: number
|
||||
maxTokens?: number
|
||||
/**
|
||||
@@ -792,6 +795,8 @@ interface PreparedLlmCall {
|
||||
readonly inputModalities?: readonly ModelModality[]
|
||||
/** Exact model system prompt update mode captured with the adapter dispatch generation. */
|
||||
readonly systemPromptUpdate?: SystemPromptUpdate
|
||||
/** Exact model tool update mode captured with the adapter dispatch generation. */
|
||||
readonly toolUpdate?: ToolUpdate
|
||||
/** Config fields materialized by the captured adapter rather than proposed by the caller. */
|
||||
readonly adapterDefaults: LlmCallConfigAdapterDefaults
|
||||
/**
|
||||
|
||||
@@ -17,9 +17,8 @@
|
||||
```ts type-equiv
|
||||
/**
|
||||
* Merge-extensible content blocks keyed by `type`. New core blocks must land
|
||||
* with adapter, UI, and compaction support. Developer tool-change blocks are
|
||||
* reserved for Session V4 persistence; providers and UI reject them until
|
||||
* their producers and consumers are implemented together.
|
||||
* with adapter, UI, and compaction support. Tool-change blocks belong to
|
||||
* developer messages; `projectToolUpdates` selects what each route receives.
|
||||
*/
|
||||
interface ContentBlockMap {
|
||||
'text': TextBlock
|
||||
@@ -32,7 +31,7 @@ interface ContentBlockMap {
|
||||
}
|
||||
```
|
||||
|
||||
各块接口(完整字段见源码):`TextBlock`(`text`)、`ReasoningBlock`(thinking,区别于可见文本)、`ImageBlock`(一个持久的[图片附件](attachment.zh.md))、`FileBlock`(一个持久的原样[文件附件](attachment.zh.md),请求组装对每条路由都把它投影为 handle 文本)和 `ToolCallBlock`(`id: ToolCallId`、`name`、原始 JSON `arguments`)。工具结果是一等 `ToolResultMessage`,含有 `toolCallId`、结果 `content` 与可选的 `isError`;它不是内容块。`ContentBlock = ContentBlockMap[ContentBlockType]`。仅当适配器、UI、压缩(compaction)和持久回放路径均支持某种新模态时,才将其纳入可合并扩展的 map。 Developer 工具变更块属于仅持久化的例外,见[决策](../../.agents/notes/implemented/architecture/2026-09-17-developer-session-changes.zh.md)。
|
||||
各块接口(完整字段见源码):`TextBlock`(`text`)、`ReasoningBlock`(thinking,区别于可见文本)、`ImageBlock`(一个持久的[图片附件](attachment.zh.md))、`FileBlock`(一个持久的原样[文件附件](attachment.zh.md),请求组装对每条路由都把它投影为 handle 文本)和 `ToolCallBlock`(`id: ToolCallId`、`name`、原始 JSON `arguments`)。工具结果是一等 `ToolResultMessage`,含有 `toolCallId`、结果 `content` 与可选的 `isError`;它不是内容块。`ContentBlock = ContentBlockMap[ContentBlockType]`。仅当适配器、UI、压缩(compaction)和持久回放路径均支持某种新模态时,才将其纳入可合并扩展的 map。 Developer 工具变更块按已解析路由的能力投影。
|
||||
|
||||
图片访问方式属于请求序列化,不属于持久附件或确定性请求图片版本。`resolveImageAttachmentAccess()` 把附件提供方可选的宿主对象路径,与消费方为当前工具执行文件系统提供的映射组合起来。结果只适用于本次请求,不参与 `variantId`。
|
||||
|
||||
@@ -71,7 +70,7 @@ interface AssistantProviderMetadata {
|
||||
type Message = MessageRoleMap[keyof MessageRoleMap]
|
||||
```
|
||||
|
||||
`DeveloperMessage` 以 `developer` 角色按对话顺序记录增量智能体 Session 变更。`ToolAdditionBlock.toolName` 激活由所在 Session 事件的历史请求头引用所选定的定义;`ToolRemovalBlock.toolName` 移除当前生效的定义。其他消息角色拒绝这两种内容块。`deferLoading` 独立控制工具定义的加载请求,不要求存在添加记录。请求头绑定见 [Session](../../packages/core/session/README.zh.md),提供方支持限制见 [LLM 包](../../packages/llm/llm/README.zh.md#known-limitations-and-deferred-work)。
|
||||
`DeveloperMessage` 以 `developer` 角色按对话顺序记录增量智能体 Session 变更。`ToolAdditionBlock.toolName` 激活由所在 Session 事件的历史请求头引用所选定的定义;`ToolRemovalBlock.toolName` 移除当前生效的定义。其他消息角色拒绝这两种内容块。`deferLoading` 独立控制工具定义的加载请求,不要求存在添加记录。请求头绑定见 [Session](../../packages/core/session/README.zh.md),提供方支持限制见 [LLM 包](../../packages/llm/llm/README.zh.md#known-limitations-and-deferred-work)。 已解析和已准备模型元数据中的 `ToolUpdate` 为 `in-history` 或 `addition-only`。`GenerateOptions.toolHistory` 携带 `ToolHistory`:初始 `tools` 和有序 `updates`,每项把 developer `messageId` 绑定到已解析历史定义的 `additions`。运行时将此状态投影为提供方声明,不修改已记录请求头中的有效工具列表。
|
||||
|
||||
消息来源本身也是一个可合并扩展的和类型:
|
||||
|
||||
@@ -579,6 +578,8 @@ interface LlmResolvedModelInfo extends LlmModelInfo {
|
||||
reasoning?: LlmModelReasoningInfo
|
||||
/** Declared mid-conversation system prompt handling; absent means only a leading system message is read. */
|
||||
systemPromptUpdate?: SystemPromptUpdate
|
||||
/** Declared mid-conversation tool declaration handling; absent means every request declares the complete tool list. */
|
||||
toolUpdate?: ToolUpdate
|
||||
}
|
||||
```
|
||||
|
||||
@@ -621,6 +622,8 @@ interface GenerateOptions {
|
||||
system?: string
|
||||
/** Tool schemas (adapters map to the provider's `tools` field). */
|
||||
tools?: ToolSchema[]
|
||||
/** Session-folded tool history used for route projection; omission sends complete declarations without tool updates. */
|
||||
toolHistory?: ToolHistory
|
||||
temperature?: number
|
||||
maxTokens?: number
|
||||
/**
|
||||
@@ -798,6 +801,8 @@ interface PreparedLlmCall {
|
||||
readonly inputModalities?: readonly ModelModality[]
|
||||
/** Exact model system prompt update mode captured with the adapter dispatch generation. */
|
||||
readonly systemPromptUpdate?: SystemPromptUpdate
|
||||
/** Exact model tool update mode captured with the adapter dispatch generation. */
|
||||
readonly toolUpdate?: ToolUpdate
|
||||
/** Config fields materialized by the captured adapter rather than proposed by the caller. */
|
||||
readonly adapterDefaults: LlmCallConfigAdapterDefaults
|
||||
/**
|
||||
|
||||
@@ -628,6 +628,12 @@ declare class Session {
|
||||
* @returns the latest immutable route metadata.
|
||||
*/
|
||||
requestContext(): RequestContext | undefined;
|
||||
/**
|
||||
* Fold unseen committed events into capability-independent tool history.
|
||||
* Initial access reconstructs inherited history; later reads consume only new events.
|
||||
* @returns an immutable snapshot for LLM request projection, including historical addition definitions.
|
||||
*/
|
||||
toolHistory(): ToolHistory;
|
||||
/**
|
||||
* Derive the LLM message history by walking the ordered sequences of
|
||||
* message-producing events maintained by `surfaceOp` markers. The
|
||||
|
||||
@@ -630,6 +630,12 @@ declare class Session {
|
||||
* @returns the latest immutable route metadata.
|
||||
*/
|
||||
requestContext(): RequestContext | undefined;
|
||||
/**
|
||||
* Fold unseen committed events into capability-independent tool history.
|
||||
* Initial access reconstructs inherited history; later reads consume only new events.
|
||||
* @returns an immutable snapshot for LLM request projection, including historical addition definitions.
|
||||
*/
|
||||
toolHistory(): ToolHistory;
|
||||
/**
|
||||
* Derive the LLM message history by walking the ordered sequences of
|
||||
* message-producing events maintained by `surfaceOp` markers. The
|
||||
|
||||
@@ -45,8 +45,8 @@
|
||||
en: d62d65547dd0b2dc
|
||||
zh: 5c21671f16d94f5a
|
||||
/deepseek-ai-dsh-compaction-basic/understand-the-implementation/summarization-mechanics:
|
||||
en: 5fd70f523bb29ea1
|
||||
zh: 1a1c0f2f3d7f714f
|
||||
en: 2d3d4f4829d612f7
|
||||
zh: a77e84208ccfcd54
|
||||
/deepseek-ai-dsh-compaction-basic/understand-the-implementation/the-region-transaction:
|
||||
en: f4330f6bff22d400
|
||||
zh: 46e16c51bb66a0f7
|
||||
|
||||
@@ -116,7 +116,7 @@ Pressure policy resolves capacity from the adapter that owns the durable route.
|
||||
|
||||
### Summarization mechanics
|
||||
|
||||
A direct `ctx.llm.stream()` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the `AgentOptions` pair, without running the loop-only `agent/request` extension point. The call replays the derived `system/message` at surface node 0 as the leading entry of `messages`, followed by the shadowed-region messages (including a shadowed in-history `system/message` in its surface position), and carries the header's tools verbatim — including image references, which the selected adapter must resolve or explicitly reject — and appends the compaction instruction as the final user message, so it reuses the provider's warm prefix cache instead of invalidating it. An empty-content system head contributes no message but remains outside the compacted range. The final instruction is a frozen `RequestUserInput` without durable identity or source; the replayed history and persisted checkpoint remain durable messages. The call sets `GenerateOptions.purpose` to `compaction`; only returned text enters the checkpoint, excluding reasoning and tool calls. Image output fails with `UNSUPPORTED_CONTENT` rather than disappearing. The replacement user message frames the summary with `<compacted-summary>` tags; the raw summary remains on the `compaction/summary` event.
|
||||
A direct `ctx.llm.stream()` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the `AgentOptions` pair, without running the loop-only `agent/request` extension point. The call replays the derived `system/message` at surface node 0 as the leading entry of `messages`, followed by the shadowed-region messages (including a shadowed in-history `system/message` in its surface position), and carries the header's tools verbatim — including image references, which the selected adapter must resolve or explicitly reject — and appends the compaction instruction as the final user message, so it reuses the provider's warm prefix cache instead of invalidating it. An empty-content system head contributes no message but remains outside the compacted range. The final instruction is a frozen `RequestUserInput` without durable identity or source; the replayed history and persisted checkpoint remain durable messages. The call sets `GenerateOptions.purpose` to `compaction`; only returned text enters the checkpoint, excluding reasoning and tool calls. Image output fails with `UNSUPPORTED_CONTENT` rather than disappearing. The replacement user message frames the summary with `<compacted-summary>` tags; the raw summary remains on the `compaction/summary` event. The call also carries `Session.toolHistory()` so the runtime can project deferred and retained definitions; a prefix missing update messages uses active declarations without developer updates.
|
||||
|
||||
### The region transaction
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ kind: "package-reference"
|
||||
|
||||
### 摘要机制
|
||||
|
||||
直接 `ctx.llm.stream()` 调用使用已配置的提供方/模型对与上限,回退到最新已记录请求目标,然后再回退到 `AgentOptions` 对,而不运行仅用于 agent loop 的 `agent/request` 扩展点。该调用将 surface 节点 0 处派生的 `system/message` 作为 `messages` 的首项回放,后接已遮蔽区域消息(包括位于其 surface 位置的被遮蔽历史内 `system/message`),并逐字携带 header 的工具——包括所选适配器必须解析或明确拒绝的图片引用——并将压缩指令作为最后一条 user 消息追加,从而复用提供方的热前缀 cache,而非使它失效。空内容系统头节点不贡献消息,但仍处于压缩范围之外。最终指令是冻结的 `RequestUserInput`,不含持久身份或来源;回放历史和持久化检查点仍然使用持久消息。调用将 `GenerateOptions.purpose` 设为 `compaction`;只有返回文本进入检查点,推理与工具调用都会被排除。图片输出会以 `UNSUPPORTED_CONTENT` 失败,而不是消失。替换 user 消息用 `<compacted-summary>` 标签框定摘要;原始摘要保留在 `compaction/summary` 事件上。
|
||||
直接 `ctx.llm.stream()` 调用使用已配置的提供方/模型对与上限,回退到最新已记录请求目标,然后再回退到 `AgentOptions` 对,而不运行仅用于 agent loop 的 `agent/request` 扩展点。该调用将 surface 节点 0 处派生的 `system/message` 作为 `messages` 的首项回放,后接已遮蔽区域消息(包括位于其 surface 位置的被遮蔽历史内 `system/message`),并逐字携带 header 的工具——包括所选适配器必须解析或明确拒绝的图片引用——并将压缩指令作为最后一条 user 消息追加,从而复用提供方的热前缀 cache,而非使它失效。空内容系统头节点不贡献消息,但仍处于压缩范围之外。最终指令是冻结的 `RequestUserInput`,不含持久身份或来源;回放历史和持久化检查点仍然使用持久消息。调用将 `GenerateOptions.purpose` 设为 `compaction`;只有返回文本进入检查点,推理与工具调用都会被排除。图片输出会以 `UNSUPPORTED_CONTENT` 失败,而不是消失。替换 user 消息用 `<compacted-summary>` 标签框定摘要;原始摘要保留在 `compaction/summary` 事件上。 调用还携带 `Session.toolHistory()`,供运行时投影延迟和保留定义;若前缀缺少更新消息,则使用有效声明且不发送 developer 更新。
|
||||
|
||||
### 区域事务
|
||||
|
||||
|
||||
@@ -153,6 +153,7 @@ export async function summarizeWithLlm(
|
||||
provider: target.provider,
|
||||
model: target.model,
|
||||
messages,
|
||||
toolHistory: agent.session.toolHistory(),
|
||||
...input.tools === undefined ? {} : { tools: [...input.tools] },
|
||||
maxTokens: config.maxTokens,
|
||||
sessionId: agent.session.id,
|
||||
|
||||
@@ -1453,6 +1453,7 @@ describe('default one-shot summarizer', () => {
|
||||
maxTokens: 321,
|
||||
signal: SIGNAL,
|
||||
sessionId: session.id,
|
||||
toolHistory: session.toolHistory(),
|
||||
purpose: 'compaction',
|
||||
})
|
||||
const instruction = adapter.lastOptions?.messages.at(-1)?.content[0]
|
||||
|
||||
@@ -60,8 +60,8 @@
|
||||
en: 5507d83eb979c737
|
||||
zh: 0ffde85d627b3933
|
||||
/deepseek-ai-dsh-agent-loop/model-experience/complete-conversation-request/what-the-model-sees:
|
||||
en: 6a60e889b0f19e33
|
||||
zh: f45bd9b2b843ca7a
|
||||
en: cd89b20032608049
|
||||
zh: 1f57d4cecc30313b
|
||||
/deepseek-ai-dsh-agent-loop/model-experience/complete-conversation-request/token-effect:
|
||||
en: ad93d8d61968a25d
|
||||
zh: 268ff858b0fa96e8
|
||||
|
||||
@@ -151,7 +151,7 @@ The package-level contract is enough for most consumers; read these when you nee
|
||||
|
||||
#### What the model sees
|
||||
|
||||
For each step, the loop sends the session's derived messages and visible tool schemas. Non-empty `system/message` nodes carry the prompt, with the latest as the effective version; an empty rendering clears all prompt versions from derived history. It supplies `provider`, `model`, and `cwd` variable values but no additional fixed prose.
|
||||
For each step, the loop sends the session's derived messages and visible tool schemas. Non-empty `system/message` nodes carry the prompt, with the latest as the effective version; an empty rendering clears all prompt versions from derived history. It supplies `provider`, `model`, and `cwd` variable values but no additional fixed prose. The request header always records active tools. Comparing their names with the preceding header emits one `developer/message` for additions and removals, independently of model capability; additions reference the new header. Requests also carry `Session.toolHistory()` for the LLM runtime to construct provider declarations.
|
||||
|
||||
#### Token effect
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ const handle = await ctx.agents.create({
|
||||
|
||||
#### 模型看到什么
|
||||
|
||||
每个步骤中,循环会发送会话的派生消息与可见工具 schema。非空的 `system/message` 节点承载提示词,最新一条是有效版本;空渲染文本会从派生历史中清除所有提示词版本。它提供 `provider`、`model` 与 `cwd` 变量值,但不添加固定文案。
|
||||
每个步骤中,循环会发送会话的派生消息与可见工具 schema。非空的 `system/message` 节点承载提示词,最新一条是有效版本;空渲染文本会从派生历史中清除所有提示词版本。它提供 `provider`、`model` 与 `cwd` 变量值,但不添加固定文案。 请求头始终记录当前有效工具。将其名称与前一请求头比较后,添加和移除合并为一条 `developer/message`,不依赖模型能力;添加记录引用新请求头。请求还携带 `Session.toolHistory()`,供 LLM 运行时构造提供方声明。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
|
||||
@@ -20,13 +20,14 @@ import type { GenerateOptions, LlmCallConfig, Message, PreparedLlmCall } from '@
|
||||
import {
|
||||
LlmError,
|
||||
createAssistantMessage,
|
||||
createDeveloperMessage,
|
||||
errorChain,
|
||||
markAgentLoopRequest,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import { assertNever, deepFreeze } from '@deepseek-ai/dsh-util-values'
|
||||
import type { Scope } from '@deepseek-ai/dsh-scope'
|
||||
import { createScope } from '@deepseek-ai/dsh-scope'
|
||||
import type { EpochHeader, RequestContext, Session, SessionId, TurnEndReason, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import type { EpochHeader, RequestContext, Session, SessionId, SessionSeq, TurnEndReason, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session'
|
||||
import { joinContextSections, renderContextSections, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
|
||||
@@ -404,7 +405,7 @@ export class ReactLoopAgent implements Agent {
|
||||
}
|
||||
}
|
||||
firstAttempt = false
|
||||
const request = this.buildRequest(config, preparedCall, assembly.tools, startsRequestSeries, signal)
|
||||
const request = this.buildRequest(config, preparedCall, assembly.tools, { turn, step }, startsRequestSeries, signal)
|
||||
const live = new AssistantStreamAttempt(
|
||||
this.session.id,
|
||||
++this.assistantAttemptCounter,
|
||||
@@ -582,6 +583,7 @@ export class ReactLoopAgent implements Agent {
|
||||
config: LlmCallConfig,
|
||||
preparedCall: PreparedLlmCall | undefined,
|
||||
tools: GenerateOptions['tools'] & object,
|
||||
position: { turn: number; step: number },
|
||||
startsRequestSeries: boolean,
|
||||
signal: AbortSignal,
|
||||
): GenerateOptions {
|
||||
@@ -595,18 +597,39 @@ export class ReactLoopAgent implements Agent {
|
||||
const baseline = this.session.requestHeader()
|
||||
const startsSeries = startsRequestSeries
|
||||
|| this.requestSurfaceGeneration !== surfaceGeneration
|
||||
let headerSeq: SessionSeq | undefined
|
||||
if (!this.requestHeaderLogged) {
|
||||
this.session.append('request/header', { header, reason: baseline === undefined ? 'initial' : 'resume' })
|
||||
// Compaction during the first resumed pre-step must still mark a new series.
|
||||
headerSeq = this.session.append('request/header', {
|
||||
header,
|
||||
reason: baseline === undefined ? 'initial' : 'resume',
|
||||
...startsSeries ? { startsSeries: true } : {},
|
||||
}).seq
|
||||
this.requestHeaderLogged = true
|
||||
} else if (baseline === undefined || !headerEquals(baseline, header)) {
|
||||
this.session.append('request/header', {
|
||||
headerSeq = this.session.append('request/header', {
|
||||
header,
|
||||
reason: 'change',
|
||||
...startsSeries ? { startsSeries: true } : {},
|
||||
})
|
||||
}).seq
|
||||
} else if (startsSeries) {
|
||||
this.session.append('request/header', { header, reason: 'series' })
|
||||
}
|
||||
if (baseline !== undefined && headerSeq !== undefined) {
|
||||
const previousNames = new Set(baseline.tools?.map(tool => tool.name))
|
||||
const currentNames = new Set(tools.map(tool => tool.name))
|
||||
const additions = tools.filter(tool => !previousNames.has(tool.name))
|
||||
.map(tool => ({ type: 'tool-addition' as const, toolName: tool.name }))
|
||||
const removals = (baseline.tools ?? []).filter(tool => !currentNames.has(tool.name))
|
||||
.map(tool => ({ type: 'tool-removal' as const, toolName: tool.name }))
|
||||
if (additions.length > 0 || removals.length > 0) {
|
||||
session.append('developer/message', {
|
||||
...position,
|
||||
message: createDeveloperMessage({ source: { kind: 'tool-registry' }, content: [...additions, ...removals] }),
|
||||
...additions.length > 0 ? { headerSeq } : {},
|
||||
}, { surfaceOp: 'append' })
|
||||
}
|
||||
}
|
||||
this.requestSurfaceGeneration = surfaceGeneration
|
||||
|
||||
const contextWindow = preparedCall?.context?.contextWindow
|
||||
@@ -638,6 +661,7 @@ export class ReactLoopAgent implements Agent {
|
||||
const request = markAgentLoopRequest(Object.freeze({
|
||||
...header.config,
|
||||
messages: boundaryMessages,
|
||||
toolHistory: session.toolHistory(),
|
||||
...header.tools !== undefined ? { tools: header.tools } : {},
|
||||
sessionId: this.session.id,
|
||||
signal,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GenerateOptions, LlmModelReasoningInfo, LlmResolvedModelInfo, StreamChunk, SystemPromptUpdate } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, LlmModelReasoningInfo, LlmResolvedModelInfo, StreamChunk, SystemPromptUpdate, ToolUpdate } from '@deepseek-ai/dsh-llm'
|
||||
import { ToolCallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/** Helpers to write scripted responses tersely. */
|
||||
@@ -73,6 +73,8 @@ export class MockAdapter extends LlmAdapter {
|
||||
requests: GenerateOptions[] = []
|
||||
/** Declared system prompt update mode of every route this adapter serves. */
|
||||
systemPromptUpdate?: SystemPromptUpdate
|
||||
/** Declared tool update mode of every route this adapter serves. */
|
||||
toolUpdate?: ToolUpdate
|
||||
|
||||
constructor(
|
||||
private script: (StreamChunk[] | ((options: GenerateOptions) => StreamChunk[]) | 'hang' | 'hang-slow' | HangAfter)[],
|
||||
@@ -93,6 +95,7 @@ export class MockAdapter extends LlmAdapter {
|
||||
...this.reasoning === undefined ? {} : { reasoning: this.reasoning },
|
||||
...this.defaultMaxTokens === undefined ? {} : { defaultMaxTokens: this.defaultMaxTokens },
|
||||
...this.systemPromptUpdate === undefined ? {} : { systemPromptUpdate: this.systemPromptUpdate },
|
||||
...this.toolUpdate === undefined ? {} : { toolUpdate: this.toolUpdate },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/** The loop logs tool changes as developer messages and carries session tool history to the runtime. */
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import LlmRuntime, { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
||||
|
||||
const contexts: Context[] = []
|
||||
afterEach(async () => {
|
||||
for (const ctx of contexts.splice(0)) await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
async function harness(adapter: MockAdapter) {
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(LlmRuntime)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
await ctx.plugin(SystemPrompt, { personaPrefix: '', personaSuffix: '' })
|
||||
await ctx.plugin(ToolRuntime)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const agent = await ctx.agentLoop.create(SessionId('tool-updates'), { provider: 'mock', model: 'model' })
|
||||
return { ctx, agent }
|
||||
}
|
||||
|
||||
async function send(agent: Agent, text: string) {
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }))
|
||||
await agent.whenIdle()
|
||||
}
|
||||
|
||||
function tool(ctx: Context, name: string) {
|
||||
return ctx.tools.register(defineContentToolFixture({
|
||||
name, description: `${name} tool`, parameters: {}, execute: async () => [{ type: 'text', text: 'done' }],
|
||||
}))
|
||||
}
|
||||
|
||||
const developerEvents = (agent: Agent) => agent.session.snapshotEvents().filter(event => event.type === 'developer/message')
|
||||
const headerEvents = (agent: Agent) => agent.session.snapshotEvents().filter(event => event.type === 'request/header')
|
||||
const developerContent = (request: GenerateOptions) => request.messages.filter(message => message.role === 'developer').map(message => message.content)
|
||||
|
||||
describe('tool update emission', () => {
|
||||
it('logs additions bound to the new header and removals without one, after the first header', async () => {
|
||||
const adapter = new MockAdapter(Array.from({ length: 4 }, () => textResponse('ok')))
|
||||
const { ctx, agent } = await harness(adapter)
|
||||
const disposeSearch = tool(ctx, 'search')
|
||||
await send(agent, 'first')
|
||||
expect(developerEvents(agent)).toEqual([])
|
||||
|
||||
tool(ctx, 'fetch')
|
||||
await send(agent, 'second')
|
||||
const [addition] = developerEvents(agent)
|
||||
expect(addition).toMatchObject({
|
||||
surfaceOp: 'append',
|
||||
data: { turn: 2, step: 1, headerSeq: headerEvents(agent)[1]?.seq, message: {
|
||||
role: 'developer', source: { kind: 'tool-registry' }, content: [{ type: 'tool-addition', toolName: 'fetch' }],
|
||||
} },
|
||||
})
|
||||
|
||||
disposeSearch()
|
||||
await send(agent, 'third')
|
||||
const removal = developerEvents(agent)[1]
|
||||
expect(removal?.data).not.toHaveProperty('headerSeq')
|
||||
expect(removal?.data.message.content).toEqual([{ type: 'tool-removal', toolName: 'search' }])
|
||||
|
||||
await send(agent, 'fourth')
|
||||
expect(developerEvents(agent)).toHaveLength(2)
|
||||
expect(adapter.requests.at(-1)?.toolHistory).toEqual(agent.session.toolHistory())
|
||||
expect(adapter.requests.map(request => request.toolHistory?.updates.length)).toEqual([0, 1, 2, 2])
|
||||
for (const request of adapter.requests) {
|
||||
expect(Object.isFrozen(request.toolHistory)).toBe(true)
|
||||
expect(developerContent(request)).toEqual([])
|
||||
expect(request.tools?.some(schema => schema.deferLoading === true)).toBeFalsy()
|
||||
}
|
||||
expect(adapter.requests.map(request => request.tools?.map(schema => schema.name))).toEqual([
|
||||
['search'], ['fetch', 'search'], ['fetch'], ['fetch'],
|
||||
])
|
||||
})
|
||||
|
||||
it('sends deferred declarations and recorded update blocks on an in-history route', async () => {
|
||||
const adapter = new MockAdapter(Array.from({ length: 3 }, () => textResponse('ok')))
|
||||
adapter.toolUpdate = 'in-history'
|
||||
const { ctx, agent } = await harness(adapter)
|
||||
tool(ctx, 'search')
|
||||
await send(agent, 'first')
|
||||
const disposeFetch = tool(ctx, 'fetch')
|
||||
await send(agent, 'second')
|
||||
disposeFetch()
|
||||
await send(agent, 'third')
|
||||
|
||||
expect(headerEvents(agent).map(event => [event.data.reason, event.data.startsSeries])).toEqual([
|
||||
['initial', undefined], ['change', undefined], ['change', undefined],
|
||||
])
|
||||
const [first, second, third] = adapter.requests
|
||||
expect(first?.tools?.map(schema => schema.name)).toEqual(['search'])
|
||||
expect(second?.tools).toEqual([
|
||||
{ name: 'search', description: 'search tool', parameters: expect.anything() as object },
|
||||
{ name: 'fetch', description: 'fetch tool', parameters: expect.anything() as object, deferLoading: true },
|
||||
])
|
||||
expect(developerContent(second!)).toEqual([[{ type: 'tool-addition', toolName: 'fetch' }]])
|
||||
expect(third?.tools).toEqual(second?.tools)
|
||||
expect(developerContent(third!)).toEqual([
|
||||
[{ type: 'tool-addition', toolName: 'fetch' }], [{ type: 'tool-removal', toolName: 'fetch' }],
|
||||
])
|
||||
expect(agent.session.requestHeader()?.tools?.map(schema => schema.name)).toEqual(['search'])
|
||||
})
|
||||
})
|
||||
@@ -87,8 +87,8 @@
|
||||
en: 5397f067293a55cd
|
||||
zh: 1dcc30d0c214f175
|
||||
/deepseek-ai-dsh-session/model-experience/logged-request-header/what-the-model-sees:
|
||||
en: f22c66d2d6caed26
|
||||
zh: 805bbf2c8fa69a3b
|
||||
en: cfb4fd90e1982948
|
||||
zh: fefc01118b2a5c07
|
||||
/deepseek-ai-dsh-session/model-experience/logged-request-header/token-effect:
|
||||
en: 310b8f36a86443e5
|
||||
zh: 906c4da8d03c5966
|
||||
|
||||
@@ -167,7 +167,7 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The session reconstructs the tool schemas and call config that the loop actually sent; the system prompt is part of `deriveMessages()` as surface node 0 and, after an in-history update, as the latest system node. Header events add no message to history and hold no copy of the prompt.
|
||||
The session reconstructs active tool schemas and call config from request headers; provider-specific deferred and retained declarations are derived at dispatch. `toolHistory()` caches a stateful fold over committed headers and developer messages, consuming only unseen events on subsequent reads. It resolves additions through their historical `headerSeq`, returns immutable snapshots, and restarts declarations on an explicit request series or a changed retained definition. Restoring a retained name with an unchanged definition continues the series; the recorded addition re-offers it. Both loop requests and compaction calls use this state. Headers lacking matching update records use complete active declarations. Same-name schema changes are allowed and invalidate prefix reuse even on `in-history` routes; registration-time rejection against historical tools is deferred ([rationale](../../../.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.md)). The system prompt remains part of `deriveMessages()`; header events add no message or prompt copy.
|
||||
|
||||
#### Token effect
|
||||
|
||||
|
||||
@@ -169,7 +169,7 @@ Fork 生成的结果只描述继承记录:父会话可能已经在所选事件
|
||||
|
||||
#### 模型看到什么
|
||||
|
||||
会话会重建循环实际发送的工具 schema 与调用配置;系统提示词作为 surface 第 0 号节点、并在历史内更新之后作为最新的系统节点,属于 `deriveMessages()` 的一部分。请求头事件不向历史加入任何消息,也不持有提示词的副本。
|
||||
会话从请求头重建有效工具模式和调用配置;提供方专用的延迟声明与保留声明在分发时派生。`toolHistory()` 缓存对已提交请求头和 developer 消息的有状态归约,后续读取仅处理新增事件。它通过历史 `headerSeq` 解析添加定义,返回不可变快照,并在显式新请求序列或保留定义发生变化时重新建立声明。以未变化的定义恢复保留名称时序列继续,由记录的添加重新提供该工具。循环请求和压缩调用均使用此状态。 请求头缺少对应更新记录时,使用完整有效声明。 允许同名工具的 schema 变化,即使在 `in-history` 路由上也会使前缀复用失效;注册时对照历史工具进行拒绝检查留待后续实现([原因](../../../.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.zh.md))。系统提示词仍属于 `deriveMessages()`;请求头事件不添加消息或提示词副本。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ import type { CreateSessionOptions, EpochHeader, PrepareSessionOptions, RequestC
|
||||
import { SurfaceManager, validateSessionEventData, validateSurfaceMetadata } from './surface.ts'
|
||||
import type { SessionSurface, SessionMessageProjection } from './surface.ts'
|
||||
import { foldRequestHeader } from './request-header.ts'
|
||||
import { ToolHistoryProjection } from './tool-history.ts'
|
||||
import type { ToolHistory } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
import { buildForkSeed } from './fork.ts'
|
||||
|
||||
@@ -814,6 +816,22 @@ export class Session {
|
||||
return this.contextFold
|
||||
}
|
||||
|
||||
/** Cached historical tool definitions and updates for request projection. */
|
||||
private readonly toolHistoryProjection = new ToolHistoryProjection()
|
||||
/** Index of the next committed event not yet consumed by the tool-history fold. */
|
||||
private toolHistorySeq = 0
|
||||
|
||||
/**
|
||||
* Fold unseen committed events into capability-independent tool history.
|
||||
* Initial access reconstructs inherited history; later reads consume only new events.
|
||||
* @returns an immutable snapshot for LLM request projection, including historical addition definitions.
|
||||
*/
|
||||
toolHistory(): ToolHistory {
|
||||
for (const event of this.log.slice(this.toolHistorySeq)) this.toolHistoryProjection.apply(event)
|
||||
this.toolHistorySeq = this.log.length
|
||||
return this.toolHistoryProjection.snapshot()
|
||||
}
|
||||
|
||||
/** The derived-message cache: frozen projections, extended per unseen node. */
|
||||
private derived: Message[] = []
|
||||
/** Surface position (nodes projected) the cache has reached. */
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/** Stateful reconstruction of historical tool definitions for request projection. */
|
||||
|
||||
import type { ToolHistory, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import { deepFreeze } from '@deepseek-ai/dsh-util-values'
|
||||
import type { SessionEvent, SessionSeq } from './types.ts'
|
||||
|
||||
/** Folds committed headers and developer messages independently of model capability. */
|
||||
export class ToolHistoryProjection {
|
||||
/** Historical declarations indexed by the header sequence referenced by additions. */
|
||||
private readonly headers = new Map<SessionSeq, readonly ToolSchema[]>()
|
||||
/** Definitions retained in the current declaration series, including removed tools. */
|
||||
private declared = new Map<string, ToolSchema>()
|
||||
/** Active definitions from the latest request header. */
|
||||
private active: readonly ToolSchema[] = []
|
||||
/** Active names reconstructed from the baseline and recorded updates. */
|
||||
private available = new Set<string>()
|
||||
/** Header that starts the current declaration series; absent before the first header. */
|
||||
private baselineSeq: SessionSeq | undefined
|
||||
/** Immutable request snapshot of the current baseline and resolved updates. */
|
||||
private history: ToolHistory = deepFreeze({ tools: [], updates: [] })
|
||||
|
||||
/**
|
||||
* Consume the next committed event in log order.
|
||||
* @param event - a session event, including inherited events during restoration.
|
||||
*/
|
||||
apply(event: SessionEvent): void {
|
||||
if (event.type === 'request/header') {
|
||||
const tools = event.data.header.tools ?? []
|
||||
this.headers.set(event.seq, tools)
|
||||
// A retained name cannot acquire a different definition without losing
|
||||
// prefix reuse; an unchanged restored name is re-offered by its recorded addition.
|
||||
const redeclared = tools.some((tool) => {
|
||||
const before = this.declared.get(tool.name)
|
||||
return before !== undefined && JSON.stringify(before) !== JSON.stringify(tool)
|
||||
})
|
||||
if (this.baselineSeq === undefined || event.data.reason === 'series' || event.data.startsSeries || redeclared) {
|
||||
this.baselineSeq = event.seq
|
||||
this.declared = new Map(tools.map(tool => [tool.name, tool]))
|
||||
this.history = deepFreeze({ tools, updates: [] })
|
||||
this.available = new Set(tools.map(tool => tool.name))
|
||||
}
|
||||
this.active = tools
|
||||
} else if (event.type === 'developer/message') {
|
||||
const { message, headerSeq } = event.data
|
||||
if (headerSeq !== undefined && headerSeq === this.baselineSeq) return
|
||||
const definitions = headerSeq === undefined ? [] : this.headers.get(headerSeq)
|
||||
const additions = message.content.flatMap((block) => {
|
||||
if (block.type !== 'tool-addition') return []
|
||||
const tool = definitions?.find(tool => tool.name === block.toolName)
|
||||
if (tool === undefined) throw new Error(`tool history: missing definition for ${block.toolName}`)
|
||||
return [tool]
|
||||
})
|
||||
for (const tool of additions) this.declared.set(tool.name, tool)
|
||||
for (const block of message.content) {
|
||||
if (block.type === 'tool-addition') this.available.add(block.toolName)
|
||||
else if (block.type === 'tool-removal') this.available.delete(block.toolName)
|
||||
}
|
||||
this.history = deepFreeze({
|
||||
tools: this.history.tools,
|
||||
updates: [...this.history.updates, { messageId: message.id, additions }],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an immutable snapshot; subsequent events do not mutate it.
|
||||
* @returns initial declarations and historically resolved additions for the current series.
|
||||
*/
|
||||
snapshot(): ToolHistory {
|
||||
// Sessions written before tool-update emission have headers without matching updates.
|
||||
if (this.active.length !== this.available.size || this.active.some(tool => !this.available.has(tool.name))) {
|
||||
return deepFreeze({ tools: this.active, updates: [] })
|
||||
}
|
||||
return this.history
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/** Capability-independent tool history folded from committed headers and developer messages. */
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createDeveloperMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import { Session, SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { ToolHistoryProjection } from '../src/tool-history.ts'
|
||||
|
||||
const search: ToolSchema = { name: 'search', description: 'Search', parameters: {} }
|
||||
const fetch: ToolSchema = { name: 'fetch', description: 'Fetch', parameters: {} }
|
||||
const config = { provider: 'test', model: 'test' }
|
||||
|
||||
function opened(tools: ToolSchema[]) {
|
||||
const session = Session.create(SessionId('tool-history'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('request/header', { reason: 'initial', header: { config, tools } })
|
||||
return session
|
||||
}
|
||||
|
||||
function header(session: Session, tools: ToolSchema[], extra: { reason?: 'change' | 'series'; startsSeries?: true } = {}) {
|
||||
return session.append('request/header', {
|
||||
reason: extra.reason ?? 'change',
|
||||
header: { config, ...tools.length === 0 ? {} : { tools } },
|
||||
...extra.startsSeries === undefined ? {} : { startsSeries: true },
|
||||
}).seq
|
||||
}
|
||||
|
||||
function update(session: Session, content: ContentBlock[], headerSeq?: SessionSeq) {
|
||||
const message = createDeveloperMessage({ source: { kind: 'test' }, content })
|
||||
session.append('developer/message', {
|
||||
turn: 1, step: 1, message, ...headerSeq === undefined ? {} : { headerSeq },
|
||||
}, { surfaceOp: 'append' })
|
||||
return message
|
||||
}
|
||||
|
||||
const add = (name: string): ContentBlock => ({ type: 'tool-addition', toolName: name })
|
||||
const remove = (name: string): ContentBlock => ({ type: 'tool-removal', toolName: name })
|
||||
|
||||
describe('Session.toolHistory', () => {
|
||||
it('returns frozen active declarations with no updates for an initial header', () => {
|
||||
const session = opened([search])
|
||||
const history = session.toolHistory()
|
||||
expect(history).toEqual({ tools: [search], updates: [] })
|
||||
expect(Object.isFrozen(history)).toBe(true)
|
||||
expect(Object.isFrozen(history.tools)).toBe(true)
|
||||
expect(session.toolHistory()).toBe(history)
|
||||
})
|
||||
|
||||
it('resolves additions from their referenced header and keeps earlier snapshots unchanged', () => {
|
||||
const session = opened([search])
|
||||
const initial = session.toolHistory()
|
||||
const headerSeq = header(session, [search, fetch])
|
||||
const added = update(session, [add('fetch')], headerSeq)
|
||||
const afterAddition = session.toolHistory()
|
||||
expect(afterAddition).toEqual({ tools: [search], updates: [{ messageId: added.id, additions: [fetch] }] })
|
||||
header(session, [search])
|
||||
const removed = update(session, [remove('fetch'), { type: 'text', text: 'fetch is disabled' }])
|
||||
expect(session.toolHistory()).toEqual({ tools: [search], updates: [
|
||||
{ messageId: added.id, additions: [fetch] },
|
||||
{ messageId: removed.id, additions: [] },
|
||||
] })
|
||||
expect(initial).toEqual({ tools: [search], updates: [] })
|
||||
expect(afterAddition.updates).toHaveLength(1)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ reason: 'series' as const },
|
||||
{ startsSeries: true as const },
|
||||
])('restarts declarations at an explicit series start %#', (extra) => {
|
||||
const session = opened([search])
|
||||
const headerSeq = header(session, [search, fetch])
|
||||
update(session, [add('fetch')], headerSeq)
|
||||
const restart = header(session, [search, fetch], extra)
|
||||
expect(session.toolHistory()).toEqual({ tools: [search, fetch], updates: [] })
|
||||
// An addition bound to the baseline header is already declared by that header.
|
||||
update(session, [add('fetch')], restart)
|
||||
expect(session.toolHistory()).toEqual({ tools: [search, fetch], updates: [] })
|
||||
})
|
||||
|
||||
it('restarts declarations when a retained name returns with a changed definition', () => {
|
||||
const session = opened([search, fetch])
|
||||
header(session, [search])
|
||||
update(session, [remove('fetch')])
|
||||
const changed = { ...fetch, description: 'Fetch v2' }
|
||||
const headerSeq = header(session, [search, changed])
|
||||
update(session, [add('fetch')], headerSeq)
|
||||
expect(session.toolHistory()).toEqual({ tools: [search, changed], updates: [] })
|
||||
})
|
||||
|
||||
it('continues the series when a retained name is restored unchanged', () => {
|
||||
const session = opened([search, fetch])
|
||||
header(session, [search])
|
||||
const removed = update(session, [remove('fetch')])
|
||||
const headerSeq = header(session, [search, fetch])
|
||||
const restored = update(session, [add('fetch')], headerSeq)
|
||||
expect(session.toolHistory()).toEqual({ tools: [search, fetch], updates: [
|
||||
{ messageId: removed.id, additions: [] },
|
||||
{ messageId: restored.id, additions: [fetch] },
|
||||
] })
|
||||
})
|
||||
|
||||
it('falls back to complete active declarations while a header lacks its update record', () => {
|
||||
const session = opened([search])
|
||||
const headerSeq = header(session, [search, fetch])
|
||||
expect(session.toolHistory()).toEqual({ tools: [search, fetch], updates: [] })
|
||||
const added = update(session, [add('fetch')], headerSeq)
|
||||
expect(session.toolHistory()).toEqual({ tools: [search], updates: [{ messageId: added.id, additions: [fetch] }] })
|
||||
header(session, [search])
|
||||
expect(session.toolHistory()).toEqual({ tools: [search], updates: [] })
|
||||
})
|
||||
|
||||
it('reconstructs inherited history on restore and continues from live events', () => {
|
||||
const session = opened([search])
|
||||
const headerSeq = header(session, [search, fetch])
|
||||
update(session, [add('fetch')], headerSeq)
|
||||
const events = session.snapshotEvents()
|
||||
const restored = Session.fromRestore(session.id, events, session.header, SessionLogOffset(0), 'shared-frozen')
|
||||
expect(restored.toolHistory()).toEqual(session.toolHistory())
|
||||
header(restored, [search])
|
||||
const removed = update(restored, [remove('fetch')])
|
||||
expect(restored.toolHistory().updates.at(-1)).toEqual({ messageId: removed.id, additions: [] })
|
||||
expect(session.toolHistory().updates).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('refuses an addition whose referenced header lacks the definition', () => {
|
||||
const projection = new ToolHistoryProjection()
|
||||
const headerEvent = (seq: number, reason: 'initial' | 'change'): SessionEvent<'request/header'> => ({
|
||||
seq: SessionSeq(seq), time: 0, type: 'request/header', data: { reason, header: { config, tools: [search] } },
|
||||
})
|
||||
const message = createDeveloperMessage({ source: { kind: 'test' }, content: [add('fetch')] })
|
||||
const developerEvent: SessionEvent<'developer/message'> = {
|
||||
seq: SessionSeq(2), time: 0, type: 'developer/message', surfaceOp: 'append',
|
||||
data: { turn: 1, step: 1, message, headerSeq: SessionSeq(1) },
|
||||
}
|
||||
projection.apply(headerEvent(0, 'initial'))
|
||||
projection.apply(headerEvent(1, 'change'))
|
||||
expect(() => { projection.apply(developerEvent) }).toThrow('tool history: missing definition for fetch')
|
||||
})
|
||||
})
|
||||
@@ -5059,7 +5059,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'GenerateOptions',
|
||||
declaration: 'export interface GenerateOptions {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n messages: RequestMessage[];\n system?: string;\n tools?: ToolSchema[];\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n signal?: AbortSignal;\n sessionId?: Branded<\'SessionId\'>;\n purpose?: \'compaction\' | \'session-title\';\n}',
|
||||
declaration: 'export interface GenerateOptions {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n messages: RequestMessage[];\n system?: string;\n tools?: ToolSchema[];\n toolHistory?: ToolHistory;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n signal?: AbortSignal;\n sessionId?: Branded<\'SessionId\'>;\n purpose?: \'compaction\' | \'session-title\';\n}',
|
||||
},
|
||||
{
|
||||
name: 'GenericCallView',
|
||||
@@ -5411,7 +5411,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'LlmResolvedModelInfo',
|
||||
declaration: 'export interface LlmResolvedModelInfo extends LlmModelInfo {\n context?: LlmModelContext;\n defaultMaxTokens?: number;\n reasoning?: LlmModelReasoningInfo;\n systemPromptUpdate?: SystemPromptUpdate;\n}',
|
||||
declaration: 'export interface LlmResolvedModelInfo extends LlmModelInfo {\n context?: LlmModelContext;\n defaultMaxTokens?: number;\n reasoning?: LlmModelReasoningInfo;\n systemPromptUpdate?: SystemPromptUpdate;\n toolUpdate?: ToolUpdate;\n}',
|
||||
},
|
||||
{
|
||||
name: 'LlmRuntime',
|
||||
@@ -5747,7 +5747,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'PreparedLlmCall',
|
||||
declaration: 'export interface PreparedLlmCall {\n readonly config: LlmCallConfig;\n readonly retryPolicy: ResolvedRetryPolicy;\n readonly context?: LlmModelContext;\n readonly inputModalities?: readonly ModelModality[];\n readonly systemPromptUpdate?: SystemPromptUpdate;\n readonly adapterDefaults: LlmCallConfigAdapterDefaults;\n stream(options: GenerateOptions): AsyncIterable<StreamChunk>;\n}',
|
||||
declaration: 'export interface PreparedLlmCall {\n readonly config: LlmCallConfig;\n readonly retryPolicy: ResolvedRetryPolicy;\n readonly context?: LlmModelContext;\n readonly inputModalities?: readonly ModelModality[];\n readonly systemPromptUpdate?: SystemPromptUpdate;\n readonly toolUpdate?: ToolUpdate;\n readonly adapterDefaults: LlmCallConfigAdapterDefaults;\n stream(options: GenerateOptions): AsyncIterable<StreamChunk>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'PreparedReferencedMessage',
|
||||
@@ -6099,7 +6099,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'Session',
|
||||
declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n readonly inheritedEventCount: SessionLogOffset;\n get id(): SessionId;\n readonly firstLiveSeq: SessionLogOffset;\n readonly firstLifecycleSeq: SessionLogOffset;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader, inheritedEventCount?: SessionLogOffset, projections?: readonly SessionMessageProjection[]): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader, inheritedEventCount: SessionLogOffset, eventState: SessionSeedEventState, projections?: readonly SessionMessageProjection[]): Session;\n eventAt(seq: SessionSeq): SessionEvent | undefined;\n snapshotEvents(fromSeq: SessionLogOffset = SessionLogOffset(0), toSeqExclusive: SessionLogOffset = this.seq): readonly SessionEvent[];\n ownEvents(): readonly SessionEvent[];\n isOwnSeq(seq: SessionSeq): boolean;\n get seq(): SessionLogOffset;\n append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent<T>\n ] : [\n ]): SessionEvent<T>;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}',
|
||||
declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n readonly inheritedEventCount: SessionLogOffset;\n get id(): SessionId;\n readonly firstLiveSeq: SessionLogOffset;\n readonly firstLifecycleSeq: SessionLogOffset;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader, inheritedEventCount?: SessionLogOffset, projections?: readonly SessionMessageProjection[]): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader, inheritedEventCount: SessionLogOffset, eventState: SessionSeedEventState, projections?: readonly SessionMessageProjection[]): Session;\n eventAt(seq: SessionSeq): SessionEvent | undefined;\n snapshotEvents(fromSeq: SessionLogOffset = SessionLogOffset(0), toSeqExclusive: SessionLogOffset = this.seq): readonly SessionEvent[];\n ownEvents(): readonly SessionEvent[];\n isOwnSeq(seq: SessionSeq): boolean;\n get seq(): SessionLogOffset;\n append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent<T>\n ] : [\n ]): SessionEvent<T>;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n toolHistory(): ToolHistory;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionAccess',
|
||||
@@ -7301,6 +7301,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'ToolGuard',
|
||||
declaration: 'export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined;',
|
||||
},
|
||||
{
|
||||
name: 'ToolHistory',
|
||||
declaration: 'export interface ToolHistory {\n readonly tools: readonly ToolSchema[];\n readonly updates: readonly {\n readonly messageId: MessageId;\n readonly additions: readonly ToolSchema[];\n }[];\n}',
|
||||
},
|
||||
{
|
||||
name: 'ToolMessageSource',
|
||||
declaration: 'export interface ToolMessageSource {\n kind: \'tool\';\n callId: ToolCallId;\n}',
|
||||
@@ -7353,6 +7357,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'ToolSchema',
|
||||
declaration: 'export interface ToolSchema {\n deferLoading?: true;\n name: string;\n description: string;\n parameters: Record<string, unknown>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ToolUpdate',
|
||||
declaration: 'export type ToolUpdate = \'in-history\' | \'addition-only\';',
|
||||
},
|
||||
{
|
||||
name: 'Transcript',
|
||||
declaration: 'export interface Transcript {\n readonly text: string;\n readonly audioSeconds: number;\n readonly inferenceSeconds: number;\n}',
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
en: 2248932972465067
|
||||
zh: d1e4b7988d80d3b2
|
||||
/deepseek-ai-dsh-llm-deepseek/use-this-package/endpoint-and-wire-format:
|
||||
en: 97d75cb85c9ecf8d
|
||||
zh: 7dbf79d6b5bfb0ec
|
||||
en: fce88d2ebad6296c
|
||||
zh: 0c351f26b472d521
|
||||
/deepseek-ai-dsh-llm-deepseek/use-this-package/account-credentials:
|
||||
en: 3853ba1a1ffbb46c
|
||||
zh: 97203e80ad933e59
|
||||
|
||||
@@ -81,7 +81,7 @@ When [proactive compaction](../../compaction/compaction-basic/README.md#use-this
|
||||
|
||||
The official root is `https://api.deepseek.com/anthropic`. An explicit `baseURL` or `$DEEPSEEK_BASE_URL` supplies a Messages-compatible root. Model and Files requests append `/v1/messages` and `/v1/files`, except that an exact final `/v1` segment is reused. Trailing slashes do not change these results. A base URL must use HTTP(S) without credentials, query, or fragment.
|
||||
|
||||
Messages sends text, thinking, tool calls, and tool results as content blocks, reasoning effort as `output_config.effort`, and images as Files references or inline base64. Models declaring `systemPromptUpdate: in-history` retain the initial top-level system and send new system snapshots after their corresponding user/tool-result turn; undeclared models use the latest snapshot as the top-level system. Replay metadata preserves the model and thinking signatures. Invalid replay metadata emits a warning and omits signatures while retaining text and tool history.
|
||||
Messages sends text, thinking, tool calls, and tool results as content blocks, reasoning effort as `output_config.effort`, and images as Files references or inline base64. Models declaring `systemPromptUpdate: in-history` retain the initial top-level system and send new system snapshots after their corresponding user/tool-result turn; undeclared models use the latest snapshot as the top-level system. Replay metadata preserves the model and thinking signatures. Invalid replay metadata emits a warning and omits signatures while retaining text and tool history. Model entries may declare `toolUpdate: addition-only` or `in-history`; the default `deepseek-flash` entry declares `addition-only`. Projected developer tool updates become system-role `tool_addition` and `tool_removal` blocks referencing declared names, and deferred declarations carry `defer_loading`. Requests containing those blocks send the `mid-conversation-tool-changes-2026-07-01` beta header.
|
||||
|
||||
### Account credentials
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ kind: "package-reference"
|
||||
|
||||
官方根地址为 `https://api.deepseek.com/anthropic`。显式 `baseURL` 或 `$DEEPSEEK_BASE_URL` 提供兼容 Messages 的根地址。模型与 Files 请求分别追加 `/v1/messages` 和 `/v1/files`,但末尾严格匹配的 `/v1` 路径段会直接复用。末尾斜线不改变这些结果。基址必须使用 HTTP(S),且不含凭据、查询或片段。
|
||||
|
||||
Messages 以内容块发送文本、思考、工具调用和工具结果,以 `output_config.effort` 发送推理强度,并以 Files 引用或内联 base64 发送图片。声明 `systemPromptUpdate: in-history` 的模型保留初始顶层 system,在对应 user/tool-result 轮次之后发送新的 system 快照;未声明能力时,使用最新快照作为顶层 system。回放元数据保留模型与思考签名。无效的回放元数据产生警告并省略签名,不丢弃文本或工具历史。
|
||||
Messages 以内容块发送文本、思考、工具调用和工具结果,以 `output_config.effort` 发送推理强度,并以 Files 引用或内联 base64 发送图片。声明 `systemPromptUpdate: in-history` 的模型保留初始顶层 system,在对应 user/tool-result 轮次之后发送新的 system 快照;未声明能力时,使用最新快照作为顶层 system。回放元数据保留模型与思考签名。无效的回放元数据产生警告并省略签名,不丢弃文本或工具历史。 模型条目可声明 `toolUpdate: addition-only` 或 `in-history`;默认 `deepseek-flash` 条目声明 `addition-only`。投影后的 developer 工具更新转换为 system 角色的 `tool_addition` 和 `tool_removal` 块,引用已声明名称,延迟声明携带 `defer_loading`。包含这些块的请求发送 `mid-conversation-tool-changes-2026-07-01` beta 请求头。
|
||||
|
||||
### 账号凭据
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout'
|
||||
import { modelInfo } from './model-info.ts'
|
||||
import type { DeepSeekAdapterOptions, DeepSeekConnectionOptions as Connection } from './types.ts'
|
||||
import { DeepSeekFileStore } from './file-store.ts'
|
||||
import { MESSAGES_FILES_BETA, messagesApiRoot } from './messages-api.ts'
|
||||
import { MESSAGES_FILES_BETA, MESSAGES_TOOL_CHANGES_BETA, messagesApiRoot } from './messages-api.ts'
|
||||
import { FileResolutionFailure, RequestFiles } from './request-files.ts'
|
||||
import { prepareRequestExtensions } from './request-extensions.ts'
|
||||
import { imagePricing, inlineImages, prepareFileIds, prepareImages } from './images.ts'
|
||||
@@ -109,6 +109,12 @@ export class DeepSeekAdapter<C extends Connection = Connection> extends LlmAdapt
|
||||
...options.purpose === undefined ? {} : { purpose: options.purpose },
|
||||
}, this.dependencies.prepareExtensions)
|
||||
signal.throwIfAborted()
|
||||
const betas = [
|
||||
...fileIds !== undefined && fileIds.size > 0 ? [MESSAGES_FILES_BETA] : [],
|
||||
...body.messages.some(message => message.content.some(block => block.type === 'tool_addition' || block.type === 'tool_removal'))
|
||||
? [MESSAGES_TOOL_CHANGES_BETA]
|
||||
: [],
|
||||
]
|
||||
const response = await fetch(`${messagesApiRoot(connection.baseURL)}/messages`, {
|
||||
method: 'POST', signal, body: extensions.payload, redirect: 'error',
|
||||
headers: {
|
||||
@@ -116,7 +122,7 @@ export class DeepSeekAdapter<C extends Connection = Connection> extends LlmAdapt
|
||||
'content-type': 'application/json', 'accept': 'text/event-stream',
|
||||
...auth.headers,
|
||||
'anthropic-version': '2023-06-01',
|
||||
...fileIds === undefined || fileIds.size === 0 ? {} : { 'anthropic-beta': MESSAGES_FILES_BETA },
|
||||
...betas.length === 0 ? {} : { 'anthropic-beta': betas.join(',') },
|
||||
'x-deepseek-harness-user-id': this.dependencies.resolveUserId(),
|
||||
...options.sessionId === undefined ? {} : { 'x-deepseek-harness-session-id': String(options.sessionId) },
|
||||
...options.purpose === 'compaction' ? { 'x-deepseek-harness-compact': '1' } : {},
|
||||
|
||||
@@ -75,6 +75,7 @@ const catalogModel: z<DeepSeekCatalogModel> = z.object({
|
||||
imagePixelBudget: z.union([z.number().step(1).min(1), 'low']),
|
||||
imageMaxBytes: z.number().step(1).min(1),
|
||||
systemPromptUpdate: z.const('in-history'),
|
||||
toolUpdate: z.union(['in-history', 'addition-only'] as const),
|
||||
})
|
||||
|
||||
/** Shared schema fields for Messages protocol options. */
|
||||
@@ -163,6 +164,10 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee
|
||||
if (systemPromptUpdate !== undefined && systemPromptUpdate !== 'in-history') {
|
||||
throw new Error(`llm-deepseek: catalog model "${model.id}" systemPromptUpdate must be "in-history" when present`)
|
||||
}
|
||||
const toolUpdate: string | undefined = model.toolUpdate
|
||||
if (toolUpdate !== undefined && toolUpdate !== 'in-history' && toolUpdate !== 'addition-only') {
|
||||
throw new Error(`llm-deepseek: catalog model "${model.id}" toolUpdate must be "in-history" or "addition-only" when present`)
|
||||
}
|
||||
if (seen.has(model.id)) throw new Error(`llm-deepseek: duplicate catalog model "${model.id}"`)
|
||||
seen.add(model.id)
|
||||
return {
|
||||
@@ -172,6 +177,7 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee
|
||||
...model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow },
|
||||
...model.maxTokens === undefined ? {} : { maxTokens: model.maxTokens },
|
||||
...model.systemPromptUpdate === undefined ? {} : { systemPromptUpdate: model.systemPromptUpdate },
|
||||
...model.toolUpdate === undefined ? {} : { toolUpdate: model.toolUpdate },
|
||||
inputModalities: [...inputModalities],
|
||||
...hasImage
|
||||
? {
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
/** Required opt-in for Messages file operations and file-referenced image requests. */
|
||||
export const MESSAGES_FILES_BETA = 'files-api-2025-04-14'
|
||||
|
||||
/** Required opt-in for mid-conversation `tool_addition` and `tool_removal` blocks. */
|
||||
export const MESSAGES_TOOL_CHANGES_BETA = 'mid-conversation-tool-changes-2026-07-01'
|
||||
|
||||
/**
|
||||
* Resolve the API root without duplicating an explicit provider version path.
|
||||
* @param baseURL - validated configured endpoint root.
|
||||
|
||||
@@ -76,6 +76,7 @@ export function modelInfo(
|
||||
context: { contextWindow },
|
||||
defaultMaxTokens: configured?.maxTokens ?? connection.maxTokens,
|
||||
...configured?.systemPromptUpdate === undefined ? {} : { systemPromptUpdate: configured.systemPromptUpdate },
|
||||
...configured?.toolUpdate === undefined ? {} : { toolUpdate: configured.toolUpdate },
|
||||
...connection.defaults.thinking === 'disabled'
|
||||
? {
|
||||
reasoning: {
|
||||
|
||||
@@ -10,6 +10,7 @@ export const DEFAULT_MODELS: DeepSeekCatalogModel[] = [
|
||||
contextWindow: DEFAULT_CONTEXT_WINDOW,
|
||||
inputModalities: ['text', 'image'],
|
||||
systemPromptUpdate: 'in-history',
|
||||
toolUpdate: 'addition-only',
|
||||
},
|
||||
{
|
||||
id: 'deepseek-v4-pro',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Map system snapshots and conversation turns to Messages using the configured route capability. */
|
||||
/** Map system snapshots, tool changes, and conversation turns to Messages using the configured route capability. */
|
||||
|
||||
import { LlmError, requestImageHandleText } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, GenerateOptions, ImageAttachmentAccessResolver, Message, RequestMessage } from '@deepseek-ai/dsh-llm'
|
||||
@@ -41,6 +41,9 @@ function assistant(message: Message, model: string, onReplayDegrade?: (reason: s
|
||||
/** Serialize one complete request using already prepared image bytes.
|
||||
* User and tool-result content omits reasoning and tool-call blocks.
|
||||
* Empty user messages are skipped; empty tool results retain their call ids.
|
||||
* Developer messages, already projected for this route by `LlmRuntime`, become
|
||||
* system-role updates after the preceding user turn, with tool changes as
|
||||
* `tool_addition` and `tool_removal` references to the declared tool.
|
||||
* @param options - provider-neutral request.
|
||||
* @param connection - validated defaults and thinking policy.
|
||||
* @param history - image-projected history with complete system snapshots; durable messages remain unchanged.
|
||||
@@ -83,11 +86,19 @@ export function serialize(
|
||||
if (messages.at(-1)?.role !== 'user') return unsupported('system update without a preceding user or tool-result turn')
|
||||
messages.push(...systemUpdates.splice(0))
|
||||
}
|
||||
// Deferred definitions are persisted for V4; provider loading is intentionally deferred.
|
||||
if (options.tools?.some(tool => tool.deferLoading === true)) return unsupported('deferred tool loading')
|
||||
for (const message of history) {
|
||||
// Developer history is persisted for V4; provider serialization is intentionally deferred.
|
||||
if (message.role === 'developer') return unsupported('developer message')
|
||||
if (message.role === 'developer') {
|
||||
const content = message.content.flatMap((block): WireBlock[] => {
|
||||
switch (block.type) {
|
||||
case 'text': return block.text.length === 0 ? [] : [{ type: 'text', text: block.text }]
|
||||
case 'tool-addition': return [{ type: 'tool_addition', tool: { type: 'tool_reference', name: block.toolName } }]
|
||||
case 'tool-removal': return [{ type: 'tool_removal', tool: { type: 'tool_reference', name: block.toolName } }]
|
||||
default: return unsupported(`developer content ${block.type}`)
|
||||
}
|
||||
})
|
||||
if (content.length > 0) systemUpdates.push({ role: 'system', content })
|
||||
continue
|
||||
}
|
||||
if (message.content.some(block => block.type === 'tool-addition' || block.type === 'tool-removal')) {
|
||||
return unsupported('tool-change blocks outside developer messages')
|
||||
}
|
||||
@@ -146,7 +157,12 @@ export function serialize(
|
||||
...options.temperature === undefined ? {} : { temperature: options.temperature },
|
||||
...options.stop === undefined ? {} : { stop_sequences: options.stop },
|
||||
...options.tools === undefined ? {} : {
|
||||
tools: options.tools.map(tool => ({ name: tool.name, description: tool.description, input_schema: tool.parameters })),
|
||||
tools: options.tools.map(tool => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
input_schema: tool.parameters,
|
||||
...tool.deferLoading === true ? { defer_loading: true as const } : {},
|
||||
})),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Model catalog and request-local dependencies for DeepSeek Messages. */
|
||||
import type { LlmModelInfo, ModelModality, SystemPromptUpdate, ResolvedRetryPolicy, ImageAttachmentAccess } from '@deepseek-ai/dsh-llm'
|
||||
import type { LlmModelInfo, ModelModality, SystemPromptUpdate, ToolUpdate, ResolvedRetryPolicy, ImageAttachmentAccess } from '@deepseek-ai/dsh-llm'
|
||||
import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type { AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id'
|
||||
import type { DeepSeekLlmApiExtensionRequest, PreparedDeepSeekLlmApiExtensions } from '@deepseek-ai/dsh-deepseek-llm-api-extensions'
|
||||
@@ -33,6 +33,13 @@ export interface DeepSeekCatalogModel {
|
||||
* system prompt; omission means only a leading system message is read.
|
||||
*/
|
||||
systemPromptUpdate?: SystemPromptUpdate
|
||||
/**
|
||||
* `'addition-only'` declares that the endpoint activates a `defer_loading`
|
||||
* tool from a later `tool_addition` block; `'in-history'` additionally
|
||||
* reads removal notices in conversation order. Omission declares the
|
||||
* complete tool list on every request.
|
||||
*/
|
||||
toolUpdate?: ToolUpdate
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,8 @@ export type WireInput =
|
||||
|
||||
/** Content serialized into one Messages conversation turn. */
|
||||
export type WireBlock = WireInput
|
||||
/** Activates or removes a declared tool by name in conversation order. */
|
||||
| { type: 'tool_addition' | 'tool_removal'; tool: { type: 'tool_reference'; name: string } }
|
||||
| { type: 'thinking'; thinking: string; signature?: string }
|
||||
| { type: 'tool_use'; id: string; name: string; input: Record<string, unknown> }
|
||||
| { type: 'tool_result'; tool_use_id: string; content: WireInput[]; is_error?: boolean }
|
||||
@@ -29,5 +31,11 @@ export type WireRequest = {
|
||||
output_config?: { effort: 'low' | 'high' | 'max' }
|
||||
temperature?: number
|
||||
stop_sequences?: string[]
|
||||
tools?: { name: string; description: string; input_schema: Record<string, unknown> }[]
|
||||
tools?: {
|
||||
name: string
|
||||
description: string
|
||||
input_schema: Record<string, unknown>
|
||||
/** Delays tool availability until a tool_addition block activates it. */
|
||||
defer_loading?: true
|
||||
}[]
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import Include from '@deepseek-ai/cordis-plugin-include'
|
||||
import LlmRuntime, { createAssistantMessage, createSystemMessage, createToolResultMessage, createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm'
|
||||
import LlmRuntime, { createAssistantMessage, createDeveloperMessage, createSystemMessage, createToolResultMessage, createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import LocalCredentials from '@deepseek-ai/dsh-credentials-local'
|
||||
@@ -109,6 +109,22 @@ describe('direct Messages HTTP', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('sends the tool-changes beta header only when update blocks are present', async () => {
|
||||
const http = await endpoint()
|
||||
const llm = adapter({ baseURL: http.url })
|
||||
const tools = [{ name: 'search', description: 'Search', parameters: {}, deferLoading: true as const }]
|
||||
const changes = createDeveloperMessage({ source: { kind: 'tool-registry' }, content: [{ type: 'tool-addition', toolName: 'search' }] })
|
||||
await assemble(llm.stream(options({ messages: [user(), changes], tools })))
|
||||
expect(http.requests[0]).toMatchObject({
|
||||
headers: { 'anthropic-beta': 'mid-conversation-tool-changes-2026-07-01' },
|
||||
body: { tools: [{ name: 'search', defer_loading: true }], messages: [
|
||||
{ role: 'user' }, { role: 'system', content: [{ type: 'tool_addition', tool: { type: 'tool_reference', name: 'search' } }] },
|
||||
] },
|
||||
})
|
||||
await assemble(llm.stream(options({ tools })))
|
||||
expect(http.requests[1]?.headers).not.toHaveProperty('anthropic-beta')
|
||||
})
|
||||
|
||||
it('uses the Messages endpoint, authentication, attribution and final usage', async () => {
|
||||
const http = await endpoint()
|
||||
const llm = adapter({ baseURL: http.url })
|
||||
|
||||
@@ -38,9 +38,12 @@ describe('Messages request conversion', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects deferred tool definitions until provider loading is implemented', () => {
|
||||
expect(() => body([], { tools: [{ name: 'search', description: '', parameters: {}, deferLoading: true }] }))
|
||||
.toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' }))
|
||||
it('declares deferred tools with defer_loading and leaves others unmarked', () => {
|
||||
const tools = [{ name: 'search', description: 'Search', parameters: {}, deferLoading: true as const }, { name: 'fetch', description: 'Fetch', parameters: {} }]
|
||||
expect(body([user()], { tools }).tools).toEqual([
|
||||
{ name: 'search', description: 'Search', input_schema: {}, defer_loading: true },
|
||||
{ name: 'fetch', description: 'Fetch', input_schema: {} },
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves the exact request with request-only text after durable tool results', () => {
|
||||
@@ -73,9 +76,26 @@ describe('Messages request conversion', () => {
|
||||
expect(input).not.toHaveProperty('source')
|
||||
})
|
||||
|
||||
it('rejects developer history while provider serialization is unsupported', () => {
|
||||
const message = createDeveloperMessage({ content: [{ type: 'tool-addition', toolName: 'search' }], source: { kind: 'tool-registry' } })
|
||||
expect(() => body([message])).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' }))
|
||||
it('places developer tool changes as a system update after the preceding user turn', () => {
|
||||
const developer = (content: ContentBlock[]) => createDeveloperMessage({ content, source: { kind: 'tool-registry' } })
|
||||
const changes = developer([
|
||||
{ type: 'tool-addition', toolName: 'search' }, { type: 'tool-removal', toolName: 'fetch' },
|
||||
{ type: 'text', text: '' }, { type: 'text', text: 'search replaces fetch' },
|
||||
])
|
||||
expect(body([user(), changes, assistant([{ type: 'text', text: 'ok' }])]).messages).toEqual([
|
||||
{ role: 'user', content: [{ type: 'text', text: 'hello' }] },
|
||||
{ role: 'system', content: [
|
||||
{ type: 'tool_addition', tool: { type: 'tool_reference', name: 'search' } },
|
||||
{ type: 'tool_removal', tool: { type: 'tool_reference', name: 'fetch' } },
|
||||
{ type: 'text', text: 'search replaces fetch' },
|
||||
] },
|
||||
{ role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
|
||||
])
|
||||
expect(body([user(), developer([{ type: 'text', text: '' }])]).messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'hello' }] }])
|
||||
expect(body([changes, user()]).messages.map(message => message.role)).toEqual(['user', 'system'])
|
||||
expect(() => body([changes])).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' }))
|
||||
expect(() => body([user(), developer([{ type: 'plugin:note', text: 'x' } as never])]))
|
||||
.toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' }))
|
||||
})
|
||||
|
||||
it('keeps the original top-level prompt and cached prefix while appending native system updates', () => {
|
||||
@@ -382,6 +402,11 @@ describe('validated configuration', () => {
|
||||
expect(modelInfo(connection, 'deepseek-official', 'custom').systemPromptUpdate).toBeUndefined()
|
||||
expect(modelInfo(capable, 'deepseek-official', MODEL).systemPromptUpdate).toBe('in-history')
|
||||
expect(modelInfo(capable, 'deepseek-official', 'custom').systemPromptUpdate).toBeUndefined()
|
||||
expect(modelInfo(connection, 'deepseek-official', MODEL).toolUpdate).toBeUndefined()
|
||||
expect(modelInfo(connection, 'deepseek-official', 'deepseek-flash').toolUpdate).toBe('addition-only')
|
||||
const inHistoryTools = resolveAdapterOptions({ models: [{ id: MODEL, toolUpdate: 'in-history' }] })
|
||||
expect(modelInfo(inHistoryTools, 'deepseek-official', MODEL).toolUpdate).toBe('in-history')
|
||||
expect(modelInfo(inHistoryTools, 'deepseek-official', 'custom').toolUpdate).toBeUndefined()
|
||||
expect(modelInfo(resolveAdapterOptions({ thinking: 'disabled' }), 'deepseek-official', MODEL).reasoning?.efforts).toMatchObject([{ id: 'off', name: 'Off' }])
|
||||
expect(resolveAdapterOptions({ baseURL: 'https://example.com/anthropic///' }).baseURL).toBe('https://example.com/anthropic///')
|
||||
})
|
||||
@@ -393,6 +418,7 @@ describe('validated configuration', () => {
|
||||
{ baseURL: 'https://example.com/?key=x' }, { baseURL: 'https://example.com/#x' },
|
||||
{ maxTokens: 0 }, { streamIdleTimeoutMs: 0 },
|
||||
{ models: [{ id: MODEL, systemPromptUpdate: 'unsupported' }] },
|
||||
{ models: [{ id: MODEL, toolUpdate: 'unsupported' }] },
|
||||
])('rejects invalid composition input %#', (value) => {
|
||||
expect(() => resolveAdapterOptions(value as Config)).toThrow()
|
||||
})
|
||||
|
||||
@@ -54,8 +54,8 @@
|
||||
en: a81634136baef72f
|
||||
zh: b92dda883986b0e1
|
||||
/deepseek-ai-dsh-llm/known-limitations-and-deferred-work:
|
||||
en: 9576799a43bd0c10
|
||||
zh: 3a42264f05da8682
|
||||
en: 0d8f25316669a32c
|
||||
zh: 1d14da732ebc788a
|
||||
/deepseek-ai-dsh-llm/known-limitations-and-deferred-work/dev-note:
|
||||
en: ecbc333b88e14d88
|
||||
zh: 77c725d92efd8646
|
||||
|
||||
@@ -159,7 +159,7 @@ These limits define where this service stops and other packages or future work b
|
||||
- **Variants normally require a producer** — `prefill`, per-tool `strict`, block `cache` hints, and the `agent` message-source variant have no producer ([Agent Note](../../../.agents/notes/archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md)).
|
||||
- **`BlockAssembler` handles core block kinds only** — a plugin-added block type whose stream is never closed by `block-end` makes `blocks()` throw.
|
||||
- **`GenerateOptions.sessionId` is a locally-declared brand** — importing dsh-session's `SessionId` would create a dependency cycle.
|
||||
- **Session-change types are a V4 persistence exception** — `DeveloperMessage` carries incremental session changes. Additions and removals name tools; the containing Session event binds additions to a historical request header that owns their definitions. See [Session references](../../core/session/README.md) for admission and restoration. Provider serialization, deferred loading, and UI presentation remain deferred. Both DeepSeek protocols and pi-ai reject developer history and `deferLoading` requests; Chat and Trajectory reject developer events. Ordinary requests retain their existing behavior.
|
||||
- **Tool updates require session history** — `GenerateOptions.tools` contains active definitions. `toolHistory` supplies the initial declarations and historically resolved additions from `Session.toolHistory()`. At adapter dispatch, `projectToolUpdates` constructs deferred declarations and retains removed definitions for `in-history`; `addition-only` omits removed definitions and removal messages. Unsupported routes receive active tools without developer messages or `deferLoading`. Missing history or a request prefix omitting recorded updates falls back to current declarations without developer messages.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### Dev Note
|
||||
|
||||
@@ -159,7 +159,7 @@ for await (const chunk of ctx.llm.stream({
|
||||
- **变体通常要求实际产生方**——`prefill`、逐工具 `strict`、内容块 `cache` 提示和 `agent` 消息来源变体都没有产生方(见 [Agent Note](../../../.agents/notes/archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md))。
|
||||
- **`BlockAssembler` 只处理核心块类型**——插件添加块类型的流若从未由 `block-end` 关闭,`blocks()` 会抛出异常。
|
||||
- **`GenerateOptions.sessionId` 是本地声明的品牌类型**——导入 dsh-session 的 `SessionId` 会产生依赖循环。
|
||||
- **Session 变更类型是 V4 持久化的例外** — `DeveloperMessage` 承载增量 Session 变更。添加与移除块记录工具名称;所在的 Session 事件将添加绑定到拥有其定义的历史请求头。接纳和恢复规则见 [Session 引用](../../core/session/README.zh.md)。提供方序列化、延迟加载与 UI 展示仍留待后续实现。DeepSeek 两种协议与 pi-ai 均拒绝 developer 历史及 `deferLoading` 请求;Chat 与 Trajectory 拒绝 developer 事件。普通请求的行为不变。
|
||||
- **工具更新需要会话历史** — `GenerateOptions.tools` 包含当前有效定义。`toolHistory` 提供 `Session.toolHistory()` 派生的初始声明及已解析历史定义的添加记录。适配器分发时,`projectToolUpdates` 构造延迟声明,并在 `in-history` 模式保留已移除定义;`addition-only` 省略已移除定义和移除消息。不支持更新的路由接收有效工具,不携带 developer 消息或 `deferLoading`。缺少历史或请求前缀遗漏已记录更新时,回退为当前声明且不发送 developer 消息。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### 开发备注
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Content-block structure helpers. @module @deepseek-ai/dsh-llm/content */
|
||||
|
||||
import type { ContentBlock, ImageBlock, LlmImageRequestBudget } from './types.ts'
|
||||
import type { ContentBlock, ImageBlock, LlmImageRequestBudget, ToolSchema, ToolUpdate, ToolHistory } from './types.ts'
|
||||
import type { RequestMessage } from './types.ts'
|
||||
import type { Message } from './message.ts'
|
||||
import type {
|
||||
@@ -364,3 +364,68 @@ export function projectImagesForTextModel(messages: readonly RequestMessage[]):
|
||||
return content === message.content ? message : { ...message, content }
|
||||
})
|
||||
}
|
||||
|
||||
/** Request messages and tools after one route's tool update projection. */
|
||||
export interface ProjectedToolUpdates {
|
||||
/** History with only the developer updates supported by this route and declaration series. */
|
||||
readonly messages: readonly RequestMessage[]
|
||||
/** Provider declarations, including deferred and retained definitions when supported. */
|
||||
readonly tools: readonly ToolSchema[] | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct provider declarations from session-folded history without changing logged active tools.
|
||||
* Unsupported routes and incomplete history use current declarations without developer updates.
|
||||
* @param messages - complete request inputs, or the prefix selected for an auxiliary call.
|
||||
* @param tools - currently active tool schemas.
|
||||
* @param toolUpdate - the resolved route's update mode.
|
||||
* @param history - immutable state folded from committed headers and developer messages.
|
||||
* @returns provider declarations and the corresponding filtered history.
|
||||
*/
|
||||
export function projectToolUpdates(
|
||||
messages: readonly RequestMessage[],
|
||||
tools: readonly ToolSchema[] | undefined,
|
||||
toolUpdate: ToolUpdate | undefined,
|
||||
history?: ToolHistory,
|
||||
): ProjectedToolUpdates {
|
||||
const messageIds = new Set(messages.flatMap(message => message.role === 'developer' ? [message.id] : []))
|
||||
const incremental = toolUpdate !== undefined && history !== undefined
|
||||
&& history.updates.every(update => messageIds.has(update.messageId))
|
||||
const updates = new Map(incremental ? history.updates.map(update => [update.messageId, update]) : [])
|
||||
const declarations = new Map<string, ToolSchema>()
|
||||
if (incremental) {
|
||||
for (const tool of history.tools) declarations.set(tool.name, tool)
|
||||
for (const update of history.updates) {
|
||||
for (const tool of update.additions) {
|
||||
if (!declarations.has(tool.name)) declarations.set(tool.name, { ...tool, deferLoading: true })
|
||||
}
|
||||
}
|
||||
if (toolUpdate === 'addition-only') {
|
||||
const active = new Set(tools?.map(tool => tool.name))
|
||||
for (const name of declarations.keys()) if (!active.has(name)) declarations.delete(name)
|
||||
}
|
||||
}
|
||||
const projectedTools = incremental ? [...declarations.values()]
|
||||
: toolUpdate === undefined && tools?.some(tool => tool.deferLoading === true)
|
||||
? tools.map(({ deferLoading: _loading, ...tool }) => tool)
|
||||
: tools
|
||||
// Names the provider currently offers; a block survives only when it changes that set.
|
||||
const offered = new Set(incremental ? history.tools.map(tool => tool.name) : [])
|
||||
const projectedMessages = messages.flatMap((message): RequestMessage[] => {
|
||||
if (message.role !== 'developer') return [message]
|
||||
if (!updates.has(message.id)) return []
|
||||
const content = message.content.filter((block) => {
|
||||
if (block.type === 'tool-addition') {
|
||||
if (!declarations.has(block.toolName) || offered.has(block.toolName)) return false
|
||||
offered.add(block.toolName)
|
||||
} else if (block.type === 'tool-removal') {
|
||||
if (toolUpdate !== 'in-history' || !offered.has(block.toolName)) return false
|
||||
offered.delete(block.toolName)
|
||||
}
|
||||
return true
|
||||
})
|
||||
return content.length === 0 ? [] : [content.length === message.content.length ? message : { ...message, content }]
|
||||
})
|
||||
const changed = projectedMessages.length !== messages.length || projectedMessages.some((message, index) => message !== messages[index])
|
||||
return { messages: changed ? projectedMessages : messages, tools: projectedTools }
|
||||
}
|
||||
@@ -24,6 +24,8 @@ import type {
|
||||
ModelModality,
|
||||
StreamChunk,
|
||||
SystemPromptUpdate,
|
||||
ToolSchema,
|
||||
ToolUpdate,
|
||||
} from './types.ts'
|
||||
import { freezeMessage } from './message.ts'
|
||||
import { resolveRetryPolicy } from './retry-policy.ts'
|
||||
@@ -35,7 +37,7 @@ import { HarnessError, INVALID_CREDENTIAL_CODE } from './error.ts'
|
||||
import { normalizeLlmFailure } from './adapter-failure.ts'
|
||||
import { normalizeApiKey } from './api-key.ts'
|
||||
import {
|
||||
contentHasFile, contentHasImage, fileHandleText, projectFilesToText, projectImagesForTextModel,
|
||||
contentHasFile, contentHasImage, fileHandleText, projectFilesToText, projectImagesForTextModel, projectToolUpdates,
|
||||
} from './content.ts'
|
||||
import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
|
||||
@@ -175,6 +177,8 @@ export interface PreparedLlmCall {
|
||||
readonly inputModalities?: readonly ModelModality[]
|
||||
/** Exact model system prompt update mode captured with the adapter dispatch generation. */
|
||||
readonly systemPromptUpdate?: SystemPromptUpdate
|
||||
/** Exact model tool update mode captured with the adapter dispatch generation. */
|
||||
readonly toolUpdate?: ToolUpdate
|
||||
/** Config fields materialized by the captured adapter rather than proposed by the caller. */
|
||||
readonly adapterDefaults: LlmCallConfigAdapterDefaults
|
||||
/**
|
||||
@@ -789,6 +793,14 @@ export class LlmRuntime extends TypertRemoteService {
|
||||
'INVALID_MODEL_INFO',
|
||||
)
|
||||
}
|
||||
// Widened for the same reason: catalog config supplies the tool update mode as text.
|
||||
const toolUpdate: string | undefined = resolved.toolUpdate
|
||||
if (toolUpdate !== undefined && toolUpdate !== 'in-history' && toolUpdate !== 'addition-only') {
|
||||
throw new LlmError(
|
||||
`adapter returned invalid tool update mode for provider "${provider}" model "${model}"`,
|
||||
'INVALID_MODEL_INFO',
|
||||
)
|
||||
}
|
||||
const defaultMaxTokens = resolved.defaultMaxTokens
|
||||
if (defaultMaxTokens !== undefined
|
||||
&& (!Number.isSafeInteger(defaultMaxTokens) || defaultMaxTokens <= 0)) {
|
||||
@@ -806,6 +818,7 @@ export class LlmRuntime extends TypertRemoteService {
|
||||
...context === undefined ? {} : { context: { contextWindow: context.contextWindow } },
|
||||
...defaultMaxTokens === undefined ? {} : { defaultMaxTokens },
|
||||
...resolved.systemPromptUpdate === undefined ? {} : { systemPromptUpdate: resolved.systemPromptUpdate },
|
||||
...resolved.toolUpdate === undefined ? {} : { toolUpdate: resolved.toolUpdate },
|
||||
}
|
||||
const reasoning = resolved.reasoning
|
||||
if (reasoning === undefined) return info
|
||||
@@ -947,6 +960,7 @@ export class LlmRuntime extends TypertRemoteService {
|
||||
? {}
|
||||
: { inputModalities: Object.freeze([...modelInfo.inputModalities]) },
|
||||
...modelInfo.systemPromptUpdate === undefined ? {} : { systemPromptUpdate: modelInfo.systemPromptUpdate },
|
||||
...modelInfo.toolUpdate === undefined ? {} : { toolUpdate: modelInfo.toolUpdate },
|
||||
stream: (options: GenerateOptions): AsyncIterable<StreamChunk> => {
|
||||
if (dispatched) {
|
||||
throw new LlmError('a prepared LLM call can only be dispatched once', 'INVALID_PREPARED_CALL')
|
||||
@@ -1058,11 +1072,22 @@ export class LlmRuntime extends TypertRemoteService {
|
||||
&& projectedMessages.some(message => contentHasImage(message.content))) {
|
||||
projectedMessages = projectImagesForTextModel(projectedMessages)
|
||||
}
|
||||
const projectedOptions = projectedMessages === resolvedOptions.messages
|
||||
// Tool changes are logged on every route; the route's declared mode selects what it receives.
|
||||
const projectedTools = projectToolUpdates(projectedMessages, resolvedOptions.tools, modelInfo.toolUpdate, resolvedOptions.toolHistory)
|
||||
projectedMessages = projectedTools.messages
|
||||
const projectedOptions = projectedMessages === resolvedOptions.messages && projectedTools.tools === resolvedOptions.tools
|
||||
? resolvedOptions
|
||||
: Object.isFrozen(resolvedOptions)
|
||||
? deepFreeze({ ...resolvedOptions, messages: projectedMessages as RequestMessage[] })
|
||||
: { ...resolvedOptions, messages: projectedMessages as RequestMessage[] }
|
||||
? deepFreeze({
|
||||
...resolvedOptions,
|
||||
messages: projectedMessages as RequestMessage[],
|
||||
...projectedTools.tools === undefined ? {} : { tools: projectedTools.tools as ToolSchema[] },
|
||||
})
|
||||
: {
|
||||
...resolvedOptions,
|
||||
messages: projectedMessages as RequestMessage[],
|
||||
...projectedTools.tools === undefined ? {} : { tools: projectedTools.tools as ToolSchema[] },
|
||||
}
|
||||
const stream = dispatch(this.forAdapter(projectedOptions, adapter))
|
||||
iterator = stream[Symbol.asyncIterator]()
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ToolCallId, ProviderRequestId, ReasoningEffortId } from './brand.ts'
|
||||
import type { MessageId, ToolCallId, ProviderRequestId, ReasoningEffortId } from './brand.ts'
|
||||
import type { Message, UserMessage } from './message.ts'
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
@@ -131,9 +131,8 @@ export interface ToolRemovalBlock {
|
||||
|
||||
/**
|
||||
* Merge-extensible content blocks keyed by `type`. New core blocks must land
|
||||
* with adapter, UI, and compaction support. Developer tool-change blocks are
|
||||
* reserved for Session V4 persistence; providers and UI reject them until
|
||||
* their producers and consumers are implemented together.
|
||||
* with adapter, UI, and compaction support. Tool-change blocks belong to
|
||||
* developer messages; `projectToolUpdates` selects what each route receives.
|
||||
*/
|
||||
export interface ContentBlockMap {
|
||||
'text': TextBlock
|
||||
@@ -396,6 +395,17 @@ export interface LlmModelReasoningInfo {
|
||||
*/
|
||||
export type SystemPromptUpdate = 'in-history'
|
||||
|
||||
/**
|
||||
* How a model accepts native tool declarations that change mid-conversation.
|
||||
* `'addition-only'`: the model reads a `tool-addition` block in a later
|
||||
* developer message as activating a tool declared with `deferLoading`, so an
|
||||
* added tool follows the cached history instead of rewriting the declaration
|
||||
* list. `'in-history'`: the model additionally reads `tool-removal` blocks,
|
||||
* so a removed tool keeps its declaration and the removal follows the history.
|
||||
* Absent means every request declares the complete current tool list.
|
||||
*/
|
||||
export type ToolUpdate = 'in-history' | 'addition-only'
|
||||
|
||||
/** Exact-route model metadata resolved by its owning adapter. */
|
||||
export interface LlmResolvedModelInfo extends LlmModelInfo {
|
||||
/** Provider-owned context capacity when known. */
|
||||
@@ -406,6 +416,8 @@ export interface LlmResolvedModelInfo extends LlmModelInfo {
|
||||
reasoning?: LlmModelReasoningInfo
|
||||
/** Declared mid-conversation system prompt handling; absent means only a leading system message is read. */
|
||||
systemPromptUpdate?: SystemPromptUpdate
|
||||
/** Declared mid-conversation tool declaration handling; absent means every request declares the complete tool list. */
|
||||
toolUpdate?: ToolUpdate
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -482,6 +494,19 @@ export interface RequestUserInput {
|
||||
/** A durable conversation message or a user input used only for one request. */
|
||||
export type RequestMessage = Message | RequestUserInput
|
||||
|
||||
/** Logged tool declarations and update identities since the last declaration reset. */
|
||||
export interface ToolHistory {
|
||||
/** Complete active declarations at the start of this history. */
|
||||
readonly tools: readonly ToolSchema[]
|
||||
/** Ordered developer messages, with additions resolved from their historical headers. */
|
||||
readonly updates: readonly {
|
||||
/** Identity used to locate this update in the derived request history. */
|
||||
readonly messageId: MessageId
|
||||
/** Added definitions resolved from the event's referenced request header. */
|
||||
readonly additions: readonly ToolSchema[]
|
||||
}[]
|
||||
}
|
||||
|
||||
/** A single model request, fully assembled. */
|
||||
export interface GenerateOptions {
|
||||
/** Registered provider route selecting the adapter instance. */
|
||||
@@ -503,6 +528,8 @@ export interface GenerateOptions {
|
||||
system?: string
|
||||
/** Tool schemas (adapters map to the provider's `tools` field). */
|
||||
tools?: ToolSchema[]
|
||||
/** Session-folded tool history used for route projection; omission sends complete declarations without tool updates. */
|
||||
toolHistory?: ToolHistory
|
||||
temperature?: number
|
||||
maxTokens?: number
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { AttachmentStore, ImageMediaType } from '@deepseek-ai/dsh-attachmen
|
||||
import {
|
||||
ToolCallId,
|
||||
contentHasFile,
|
||||
createDeveloperMessage,
|
||||
createToolResultMessage,
|
||||
createUserMessage,
|
||||
fileHandleText,
|
||||
@@ -11,11 +12,12 @@ import {
|
||||
offloadedImageText,
|
||||
projectImagesForTextModel,
|
||||
projectOffloadedImages,
|
||||
projectToolUpdates,
|
||||
requiredImageOffload,
|
||||
resolveImageAttachmentAccess,
|
||||
requestImageHandleText,
|
||||
} from '../src/index.ts'
|
||||
import type { ContentBlock, RequestUserInput } from '../src/index.ts'
|
||||
import type { ContentBlock, RequestMessage, RequestUserInput, ToolHistory, ToolSchema } from '../src/index.ts'
|
||||
|
||||
const source = { kind: 'test' as const }
|
||||
|
||||
@@ -399,3 +401,112 @@ describe('file projection', () => {
|
||||
expect(messages[2]!.content[0]!.type).toBe('file')
|
||||
})
|
||||
})
|
||||
|
||||
describe('projectToolUpdates', () => {
|
||||
const search: ToolSchema = { name: 'search', description: 'Search', parameters: {} }
|
||||
const fetch: ToolSchema = { name: 'fetch', description: 'Fetch', parameters: {} }
|
||||
const developer = (content: ContentBlock[]) => createDeveloperMessage({ source, content })
|
||||
const prompt = createUserMessage({ source, content: [{ type: 'text', text: 'hi' }] })
|
||||
const roles = (messages: readonly RequestMessage[]) => messages.filter(message => message.role === 'developer').map(message => message.content)
|
||||
|
||||
it('returns the input history and tools by identity when nothing is projected', () => {
|
||||
const messages = [prompt]
|
||||
const tools = [search]
|
||||
const projected = projectToolUpdates(messages, tools, 'in-history', { tools, updates: [] })
|
||||
expect(projected.messages).toBe(messages)
|
||||
expect(projected.tools).toEqual(tools)
|
||||
expect(projectToolUpdates(messages, tools, undefined).messages).toBe(messages)
|
||||
})
|
||||
|
||||
it('strips deferred loading and developer updates when the route declares no mode', () => {
|
||||
const added = developer([{ type: 'tool-addition', toolName: 'fetch' }])
|
||||
const history: ToolHistory = { tools: [search], updates: [{ messageId: added.id, additions: [fetch] }] }
|
||||
const deferred = [search, { ...fetch, deferLoading: true as const }]
|
||||
const projected = projectToolUpdates([prompt, added, prompt], deferred, undefined, history)
|
||||
expect(projected.tools).toEqual([search, fetch])
|
||||
expect(projected.messages).toEqual([prompt, prompt])
|
||||
const plain = [search, fetch]
|
||||
expect(projectToolUpdates([prompt], plain, undefined, history).tools).toBe(plain)
|
||||
expect(projectToolUpdates([prompt], undefined, undefined, history).tools).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses current declarations without updates when history is missing or the prefix omits an update', () => {
|
||||
const added = developer([{ type: 'tool-addition', toolName: 'fetch' }])
|
||||
const history: ToolHistory = { tools: [search], updates: [{ messageId: added.id, additions: [fetch] }] }
|
||||
const tools = [search, fetch]
|
||||
const missing = projectToolUpdates([prompt, added], tools, 'in-history')
|
||||
expect(missing.tools).toBe(tools)
|
||||
expect(missing.messages).toEqual([prompt])
|
||||
const prefix = projectToolUpdates([prompt], tools, 'in-history', history)
|
||||
expect(prefix.tools).toBe(tools)
|
||||
expect(prefix.messages).toEqual([prompt])
|
||||
})
|
||||
|
||||
it('defers historical additions and retains removed definitions for in-history routes', () => {
|
||||
const added = developer([{ type: 'tool-addition', toolName: 'fetch' }, { type: 'text', text: 'fetch is available' }])
|
||||
const removed = developer([{ type: 'tool-removal', toolName: 'search' }])
|
||||
const history: ToolHistory = { tools: [search], updates: [
|
||||
{ messageId: added.id, additions: [fetch] },
|
||||
{ messageId: removed.id, additions: [] },
|
||||
] }
|
||||
const projected = projectToolUpdates([prompt, added, prompt, removed, prompt], [fetch], 'in-history', history)
|
||||
expect(projected.tools).toEqual([search, { ...fetch, deferLoading: true }])
|
||||
expect(projected.messages[1]).toBe(added)
|
||||
expect(roles(projected.messages)).toEqual([
|
||||
[{ type: 'tool-addition', toolName: 'fetch' }, { type: 'text', text: 'fetch is available' }],
|
||||
[{ type: 'tool-removal', toolName: 'search' }],
|
||||
])
|
||||
})
|
||||
|
||||
it('omits removed definitions and removal blocks for addition-only routes', () => {
|
||||
const swapped = developer([{ type: 'tool-addition', toolName: 'fetch' }, { type: 'tool-removal', toolName: 'search' }])
|
||||
const removed = developer([{ type: 'tool-removal', toolName: 'fetch' }])
|
||||
const history: ToolHistory = { tools: [search], updates: [
|
||||
{ messageId: swapped.id, additions: [fetch] },
|
||||
{ messageId: removed.id, additions: [] },
|
||||
] }
|
||||
const projected = projectToolUpdates([prompt, swapped, prompt, removed, prompt], [], 'addition-only', history)
|
||||
expect(projected.tools).toEqual([])
|
||||
expect(projected.messages).toEqual([prompt, prompt, prompt])
|
||||
const partial = projectToolUpdates([prompt, swapped, prompt], [fetch], 'addition-only', { tools: [search], updates: [history.updates[0]!] })
|
||||
expect(partial.tools).toEqual([{ ...fetch, deferLoading: true }])
|
||||
expect(partial.messages[1]).not.toBe(swapped)
|
||||
expect(roles(partial.messages)).toEqual([[{ type: 'tool-addition', toolName: 'fetch' }]])
|
||||
})
|
||||
|
||||
it('re-offers an unchanged restored tool through its recorded blocks', () => {
|
||||
const removed = developer([{ type: 'tool-removal', toolName: 'search' }])
|
||||
const restored = developer([{ type: 'tool-addition', toolName: 'search' }])
|
||||
const history: ToolHistory = { tools: [search, fetch], updates: [
|
||||
{ messageId: removed.id, additions: [] },
|
||||
{ messageId: restored.id, additions: [search] },
|
||||
] }
|
||||
const messages = [prompt, removed, prompt, restored, prompt]
|
||||
const inHistory = projectToolUpdates(messages, [search, fetch], 'in-history', history)
|
||||
expect(inHistory.tools).toEqual([search, fetch])
|
||||
expect(roles(inHistory.messages)).toEqual([
|
||||
[{ type: 'tool-removal', toolName: 'search' }],
|
||||
[{ type: 'tool-addition', toolName: 'search' }],
|
||||
])
|
||||
const additionOnly = projectToolUpdates(messages, [search, fetch], 'addition-only', history)
|
||||
expect(additionOnly.tools).toEqual([search, fetch])
|
||||
expect(roles(additionOnly.messages)).toEqual([])
|
||||
})
|
||||
|
||||
it('declares a deferred tool once across removal and re-addition', () => {
|
||||
const added = developer([{ type: 'tool-addition', toolName: 'fetch' }])
|
||||
const removed = developer([{ type: 'tool-removal', toolName: 'fetch' }])
|
||||
const restored = developer([{ type: 'tool-addition', toolName: 'fetch' }])
|
||||
const history: ToolHistory = { tools: [search], updates: [
|
||||
{ messageId: added.id, additions: [fetch] },
|
||||
{ messageId: removed.id, additions: [] },
|
||||
{ messageId: restored.id, additions: [fetch] },
|
||||
] }
|
||||
const messages = [prompt, added, prompt, removed, prompt, restored, prompt]
|
||||
const inHistory = projectToolUpdates(messages, [search, fetch], 'in-history', history)
|
||||
expect(inHistory.tools).toEqual([search, { ...fetch, deferLoading: true }])
|
||||
expect(roles(inHistory.messages)).toHaveLength(3)
|
||||
const additionOnly = projectToolUpdates(messages, [search, fetch], 'addition-only', history)
|
||||
expect(roles(additionOnly.messages)).toEqual([[{ type: 'tool-addition', toolName: 'fetch' }]])
|
||||
})
|
||||
})
|
||||
@@ -14,6 +14,7 @@ import LlmRuntime, {
|
||||
resolveRetryPolicy,
|
||||
StreamChunk,
|
||||
createMessage,
|
||||
createDeveloperMessage,
|
||||
createUserMessage,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
@@ -197,6 +198,24 @@ describe('LlmRuntime', () => {
|
||||
expect(chunks).toEqual(SCRIPT)
|
||||
})
|
||||
|
||||
it('sends complete declarations without deferred loading or developer updates on an undeclared route', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmRuntime)
|
||||
const adapter = new RecordingAdapter(SCRIPT)
|
||||
ctx.llm.registerAdapter(['test-provider'], adapter)
|
||||
const changes = createDeveloperMessage({ source: { kind: 'test' }, content: [{ type: 'tool-addition', toolName: 'search' }] })
|
||||
const search = { name: 'search', description: 'Search', parameters: {} }
|
||||
await collect(ctx.llm.stream({
|
||||
provider: 'test-provider',
|
||||
model: 'test-model',
|
||||
messages: [createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }), changes],
|
||||
tools: [{ ...search, deferLoading: true }],
|
||||
toolHistory: { tools: [], updates: [{ messageId: changes.id, additions: [search] }] },
|
||||
}))
|
||||
expect(adapter.lastOptions?.tools).toEqual([search])
|
||||
expect(adapter.lastOptions?.messages.map(message => message.role)).toEqual(['user'])
|
||||
})
|
||||
|
||||
it('trusts the immutable message creation boundary for direct calls', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmRuntime)
|
||||
@@ -650,6 +669,7 @@ describe('LlmRuntime', () => {
|
||||
[{ provider: 'route', id: 'model', name: 1 }, 'non-string name'],
|
||||
[{ provider: 'route', id: 'model', name: '' }, 'empty name'],
|
||||
[{ provider: 'route', id: 'model', name: 'Model', description: 1 }, 'non-string description'],
|
||||
[{ provider: 'route', id: 'model', name: 'Model', toolUpdate: 'sometimes' }, 'unknown tool update mode'],
|
||||
] as const)('rejects invalid exact model metadata (%s: %s)', async (metadata, _label) => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmRuntime)
|
||||
@@ -672,6 +692,7 @@ describe('LlmRuntime', () => {
|
||||
return Promise.resolve({
|
||||
provider: 'route', id: 'model', name: 'Model',
|
||||
inputModalities: ['text', 'image'],
|
||||
toolUpdate: 'in-history',
|
||||
})
|
||||
}
|
||||
}(SCRIPT)
|
||||
@@ -682,7 +703,9 @@ describe('LlmRuntime', () => {
|
||||
await expect(ctx.llm.resolveModelInfo('route', 'model')).resolves.toEqual({
|
||||
provider: 'route', id: 'model', name: 'Model',
|
||||
inputModalities: ['text', 'image'],
|
||||
toolUpdate: 'in-history',
|
||||
})
|
||||
expect((await ctx.llm.prepareCall({ provider: 'route', model: 'model' })).toolUpdate).toBe('in-history')
|
||||
})
|
||||
|
||||
it('resolves detached model context independently of advisory catalog membership', async () => {
|
||||
|
||||
@@ -32,6 +32,7 @@ import type {
|
||||
StreamChunk,
|
||||
SystemPromptUpdate,
|
||||
TokenUsage,
|
||||
ToolUpdate,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import { LlmAdapter, LlmError, ReasoningEffortId, expandAssistantStream, offloadedImageText, requestImageHandleText, resolveRetryPolicy } from '@deepseek-ai/dsh-llm'
|
||||
import { assertNever } from '@deepseek-ai/dsh-util-values'
|
||||
@@ -104,6 +105,8 @@ export interface ReplayModelConfig {
|
||||
defaultReasoningEffort?: string
|
||||
/** Optional in-history system prompt replacement for a keyless replay route. */
|
||||
systemPromptUpdate?: SystemPromptUpdate
|
||||
/** Optional mid-conversation tool declaration mode for a keyless replay route. */
|
||||
toolUpdate?: ToolUpdate
|
||||
}
|
||||
|
||||
/** One provider route exposed by the replay adapter. */
|
||||
@@ -921,6 +924,9 @@ class ReplayAdapter extends LlmAdapter {
|
||||
...configuredModel?.systemPromptUpdate === undefined
|
||||
? {}
|
||||
: { systemPromptUpdate: configuredModel.systemPromptUpdate },
|
||||
...configuredModel?.toolUpdate === undefined
|
||||
? {}
|
||||
: { toolUpdate: configuredModel.toolUpdate },
|
||||
...configuredModel?.reasoningEfforts === undefined
|
||||
? {}
|
||||
: {
|
||||
@@ -1172,6 +1178,13 @@ function validateConfiguredModels(providers: ReplayProviderConfig[] | undefined)
|
||||
+ 'must be "in-history" when present',
|
||||
)
|
||||
}
|
||||
const toolUpdate: unknown = model.toolUpdate
|
||||
if (toolUpdate !== undefined && toolUpdate !== 'in-history' && toolUpdate !== 'addition-only') {
|
||||
throw new Error(
|
||||
`llm-replay: provider "${provider.id}" model "${model.id}" toolUpdate `
|
||||
+ 'must be "in-history" or "addition-only" when present',
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1416,6 +1416,7 @@ describe('installLlmReplay (through the real LlmRuntime)', () => {
|
||||
reasoningEfforts: ['off', 'max'],
|
||||
defaultReasoningEffort: 'max',
|
||||
systemPromptUpdate: 'in-history',
|
||||
toolUpdate: 'addition-only',
|
||||
},
|
||||
{ id: 'pro', name: 'Pro', description: 'Larger model', reasoningEfforts: ['high'] },
|
||||
],
|
||||
@@ -1442,8 +1443,10 @@ describe('installLlmReplay (through the real LlmRuntime)', () => {
|
||||
defaultEffort: 'max',
|
||||
},
|
||||
systemPromptUpdate: 'in-history',
|
||||
toolUpdate: 'addition-only',
|
||||
})
|
||||
await expect(ctx.llm.resolveModelInfo('deepseek', 'pro')).resolves.not.toHaveProperty('systemPromptUpdate')
|
||||
await expect(ctx.llm.resolveModelInfo('deepseek', 'pro')).resolves.not.toHaveProperty('toolUpdate')
|
||||
await expect(ctx.llm.resolveModelInfo('deepseek', 'pro')).resolves.not.toHaveProperty('inputModalities')
|
||||
await expect(ctx.llm.resolveModelInfo('deepseek', 'pro')).resolves.not.toHaveProperty('context')
|
||||
// Efforts without a configured default preserve the provider's own default.
|
||||
@@ -2216,6 +2219,14 @@ describe('apply (the plugin entry)', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects an unknown toolUpdate mode during load', () => {
|
||||
const ctx = new Context()
|
||||
const providers: NonNullable<Config['providers']> = [{ id: 'm', models: [{ id: 'm', toolUpdate: 'always' as never }] }]
|
||||
expect(() => { apply(ctx, { file, providers }) }).toThrow(
|
||||
'llm-replay: provider "m" model "m" toolUpdate must be "in-history" or "addition-only" when present',
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects an unknown systemPromptUpdate mode during load', () => {
|
||||
const ctx = new Context()
|
||||
const providers = [{ id: 'm', models: [{ id: 'm', systemPromptUpdate: 'leading' }] }] as unknown as
|
||||
|
||||
Reference in new issue
Block a user