diff --git a/.agents/notes/implemented/architecture/2026-07-30-adapter-owned-max-token-defaults.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-adapter-owned-max-token-defaults.i18n.yaml index 0cd4e5f83e..752dc0f1aa 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-adapter-owned-max-token-defaults.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-adapter-owned-max-token-defaults.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-adapter-owned-max-token-defaults.md -2026-07-30-adapter-owned-max-token-defaults.md: c6fc9f014124607a3e2c3520f9f3b9023b77935f -2026-07-30-adapter-owned-max-token-defaults.zh.md: e0670f43eb5f27c2307c734e01a5b6718c09a67e +2026-07-30-adapter-owned-max-token-defaults.md: a522848fd4482f84859e587505b6a5e6f5c72d60 +2026-07-30-adapter-owned-max-token-defaults.zh.md: 8db6a06199fc1c4e73c86492d12dc86edafe8c7e diff --git a/.agents/notes/implemented/architecture/2026-07-30-adapter-owned-max-token-defaults.md b/.agents/notes/implemented/architecture/2026-07-30-adapter-owned-max-token-defaults.md index c6fc9f0141..a522848fd4 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-adapter-owned-max-token-defaults.md +++ b/.agents/notes/implemented/architecture/2026-07-30-adapter-owned-max-token-defaults.md @@ -10,9 +10,9 @@ An LLM adapter could serialize an explicit `GenerateOptions.maxTokens`, but its ## Decision -`LlmResolvedModelInfo.defaultMaxTokens` carries an optional adapter-configured per-request output cap for one exact provider/model route. `LlmService` validates it as a positive safe integer and materializes it into `LlmCallConfig.maxTokens` only when the caller omitted a value. Explicit request or Agent options therefore win without clamping. +`LlmResolvedModelInfo.defaultMaxTokens` carries an optional adapter-configured per-request output cap for one exact provider/model route. `LlmService` validates it as a positive safe integer and materializes it into `LlmCallConfig.maxTokens` only when the caller omitted a value. A prepared call identifies materialized `maxTokens` and `reasoningEffort` fields as adapter defaults; explicit request or Agent options remain unmarked and therefore win without clamping. -The agent loop continues to prepare calls before logging `request/header`, so an adapter default becomes a durable request fact before dispatch. Direct `LlmService.stream()` calls resolve the same default at the final adapter boundary. The field is a request default rather than a hard model output limit; adapters that preserve provider-owned defaults omit it. +The agent loop continues to prepare calls before logging `request/header`, so the effective config and its adapter-default provenance become durable request facts before dispatch. Before the next `agent/request` waterfall, the loop removes marked fields from the proposal; exact-model resolution then materializes the current route's defaults again. A provider/model switch therefore cannot mistake a previous adapter's default for an explicit override, while explicit conversation values persist. Direct `LlmService.stream()` calls resolve the same default at the final adapter boundary. The field is a request default rather than a hard model output limit; adapters that preserve provider-owned defaults omit it. The native DeepSeek adapter exposes `maxTokens` in Cordis config with a 256,000-token default and maps the effective value to `max_tokens`. Its default context capacity is 1,000,000 tokens: both built-in V4 entries publish that exact capacity, while configured entries without capacity and unlisted pass-through ids inherit the same adapter-wide fallback. @@ -28,6 +28,6 @@ The native DeepSeek adapter exposes `maxTokens` in Cordis config with a 256,000- ## Consequences -DeepSeek conversations send `max_tokens: 256000` by default, and the same value appears in the session request header. Deployments can change the adapter default through `llm-deepseek.config.maxTokens`; per-agent and per-request values override it. Other adapters retain their existing behavior until they intentionally publish `defaultMaxTokens`. +DeepSeek conversations send `max_tokens: 256000` by default, and the same value plus its adapter provenance appear in the session request header. Deployments can change the adapter default through `llm-deepseek.config.maxTokens`; per-agent and per-request values override it. Changing the route rematerializes the new exact adapter's default instead of carrying DeepSeek's derived value forward. Other adapters retain their existing behavior until they intentionally publish `defaultMaxTokens`. The 256,000-token output budget reserves a large part of the one-million-token context on endpoints that pre-allocate requested output. Deployments whose gateway or model supports a smaller budget must lower `maxTokens`; the explicit configuration is preferable to an undocumented provider fallback. diff --git a/.agents/notes/implemented/architecture/2026-07-30-adapter-owned-max-token-defaults.zh.md b/.agents/notes/implemented/architecture/2026-07-30-adapter-owned-max-token-defaults.zh.md index e0670f43eb..8db6a06199 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-adapter-owned-max-token-defaults.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-adapter-owned-max-token-defaults.zh.md @@ -10,9 +10,9 @@ LLM(大语言模型)适配器可以序列化显式的 `GenerateOptions.maxTo ## Decision -`LlmResolvedModelInfo.defaultMaxTokens` 携带一条确切提供方/模型路由的可选单次请求输出上限,该值由适配器配置。`LlmService` 将其校验为正安全整数,并且仅在调用方省略值时才填入 `LlmCallConfig.maxTokens`。因此,显式请求值或 Agent 选项优先,且不会被自动调整。 +`LlmResolvedModelInfo.defaultMaxTokens` 携带一条确切提供方/模型路由的可选单次请求输出上限,该值由适配器配置。`LlmService` 将其校验为正安全整数,并且仅在调用方省略值时才填入 `LlmCallConfig.maxTokens`。准备后的调用会将已填入的 `maxTokens` 和 `reasoningEffort` 字段标记为适配器默认值;显式请求值或 Agent 选项不带该标记,因此优先且不会被自动调整。 -agent loop 仍在记录 `request/header` 前准备调用,因此适配器默认值会在分派前成为持久请求事实。直接调用 `LlmService.stream()` 时,也会在最终适配器边界解析同一默认值。该字段是请求默认值,而非模型输出硬上限;保留提供方持有默认值的适配器会省略它。 +agent loop 仍在记录 `request/header` 前准备调用,因此生效配置及其适配器默认值来源会在分派前成为持久请求事实。下一次 `agent/request` waterfall(瀑布式事件)前,agent loop 会从提议中移除带标记字段,随后精确模型解析会再次填入当前路由的默认值。因此,切换提供方/模型不会把前一个适配器的默认值误当成显式覆盖,而显式对话值则会保留。直接调用 `LlmService.stream()` 时,也会在最终适配器边界解析同一默认值。该字段是请求默认值,而非模型输出硬上限;保留提供方持有默认值的适配器会省略它。 原生 DeepSeek 适配器在 Cordis 配置中公开 `maxTokens`,默认值为 256,000 token,并将生效值映射为 `max_tokens`。其默认上下文容量为 1,000,000 token:两个内置 V4 配置项均公布这一精确容量;不含容量的已配置项和未列出的原样传递 id 则继承同一个适配器级回退值。 @@ -28,6 +28,6 @@ agent loop 仍在记录 `request/header` 前准备调用,因此适配器默认 ## Consequences -DeepSeek 对话默认发送 `max_tokens: 256000`,会话请求 header 中也会出现相同的值。部署可以通过 `llm-deepseek.config.maxTokens` 更改适配器默认值;每个 agent 和每次请求的值都会覆盖它。其他适配器会保留现有行为,直至主动公布 `defaultMaxTokens`。 +DeepSeek 对话默认发送 `max_tokens: 256000`,会话请求 header 中也会出现相同的值及其适配器来源。部署可以通过 `llm-deepseek.config.maxTokens` 更改适配器默认值;每个 agent 和每次请求的值都会覆盖它。更改路由会重新填入新的精确适配器默认值,而不是继续沿用 DeepSeek 派生出的值。其他适配器会保留现有行为,直至主动公布 `defaultMaxTokens`。 对于预分配请求输出的端点,256,000 token 的输出预算会占用 1,000,000 token 上下文中的很大部分。如果部署使用的 gateway 或模型仅支持较小预算,则必须调低 `maxTokens`;显式配置优于未记录的提供方回退值。 diff --git a/apps/cli/config/tui.cordis.yml b/apps/cli/config/tui.cordis.yml index b271a11f05..f7475a8378 100644 --- a/apps/cli/config/tui.cordis.yml +++ b/apps/cli/config/tui.cordis.yml @@ -37,7 +37,7 @@ factual. # Shipped default: full thinking at max effort on every request. Exact-model -# resolution materializes the effort before the request header is logged. +# resolution materializes request defaults before the request header is logged. - id: llm-deepseek config: apiKey: !!js process.env.DEEPSEEK_API_KEY diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index dfff50708f..c3b5c6d8f8 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 1fd9bd128d1bcc0dd91d46131981ea4fc331bd74 -architecture.zh.md: 8521f09c6e415f9f8d1c0a44f7534b59c876decc +architecture.md: ef93b47702cdc8a2a6c68dc07edd629f0ab43170 +architecture.zh.md: 4f1f05a3ce10f5de7edf192ff8d04e43a4dcb5c9 diff --git a/docs/architecture.md b/docs/architecture.md index 1fd9bd128d..ef93b47702 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -94,7 +94,7 @@ forever: assemble system prompt and tool schemas snapshot the derived messages (the reconstruction boundary) 'step/start' - agent/request (config only) -> prepare reasoning/default under turn signal -> log request/header -> llm/stream (frozen, registration-bound) + agent/request (config only) -> prepare adapter defaults/provenance under turn signal -> log request/header -> llm/stream (frozen, registration-bound) 'assistant/chunk' 'assistant/message' schedule tool calls by ctx.tools.executionMode: @@ -143,7 +143,7 @@ Each agent owns scoped `agent.ctx`; shared storage overlays its tool, prompt, an The session log is authoritative. `deriveMessages()` projects model history; raw `assistant/chunk` events preserve replay and UI fidelity. Fork, resume, transcript rendering, telemetry, and persistence derive from this stream. -**Model-visible ⟺ logged**: messages at `step/start` plus the folded `request/header` reconstruct every request; package-owned `dsh-agent-loop/invariant` can assert this through `ctx.invariants` ([reconstructability](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)). +**Model-visible ⟺ logged**: messages at `step/start` plus the folded `request/header` reconstruct every request; the header also marks adapter-materialized defaults so the next proposal can discard them and resolve the selected route without losing explicit conversation settings. Package-owned `dsh-agent-loop/invariant` can assert reconstructability through `ctx.invariants` ([reconstructability](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)). Durability is a plugin concern. Backends eagerly drain synchronous `session/event` notifications. `session/flush` barriers precede each request and top-level tool dispatch, then follow `turn/end` before another queued turn or idle observation. `SessionPersistence` stores `SessionEvent` directly and metadata in `SessionHeader`; JSONL defaults to checksummed Zstandard, while SQLite shares the contract ([decision](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md)). diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 8521f09c6e..4f1f05a3ce 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -94,7 +94,7 @@ forever: assemble system prompt and tool schemas snapshot the derived messages (the reconstruction boundary) 'step/start' - agent/request (config only) -> prepare reasoning/default under turn signal -> log request/header -> llm/stream (frozen, registration-bound) + agent/request (config only) -> prepare adapter defaults/provenance under turn signal -> log request/header -> llm/stream (frozen, registration-bound) 'assistant/chunk' 'assistant/message' schedule tool calls by ctx.tools.executionMode: @@ -143,7 +143,7 @@ idle inject: 会话日志是权威依据。`deriveMessages()` 投影出模型历史;原始 `assistant/chunk` 事件保证回放和 UI 保真。fork、恢复、transcript(文本记录)渲染、遥测和持久化均派生自该事件流。 -**模型可见 ⟺ 已记录**:`step/start` 时的消息与折叠后的 `request/header` 可以重建每个请求;该包的 `dsh-agent-loop/invariant` 可通过 `ctx.invariants` 断言这一点([可重建性](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md))。 +**模型可见 ⟺ 已记录**:`step/start` 时的消息与折叠后的 `request/header` 可以重建每个请求;该 header 还会标记适配器填入的默认值,使下一次提议可以丢弃这些值并解析所选路由,同时不丢失显式对话设置。该包的 `dsh-agent-loop/invariant` 可通过 `ctx.invariants` 断言可重建性([可重建性](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md))。 持久性由插件负责。后端会尽快排空同步的 `session/event` 通知。`session/flush` 屏障位于每次请求与顶层工具分发之前,并在 `turn/end` 之后、处理另一个已排队轮次或观察到空闲状态之前执行。`SessionPersistence` 直接存储 `SessionEvent`,并将元数据存入 `SessionHeader`;JSONL 默认采用带校验和的 Zstandard,SQLite 遵循同一契约([决策](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md))。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 97500f2747..a2cb4913f1 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -820,7 +820,7 @@ stream(options: GenerateOptions): AsyncIterable Types: [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [LlmResolvedModelInfo](../core-data-structures/core.md) · [PreparedLlmCall](../core-data-structures/llm-streaming.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:191`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:193`](../../packages/llm/llm/src/index.ts) ## `ctx.permission` — `PermissionService` @@ -1590,7 +1590,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:713`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:738`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index 74bac3a63c..369bfe36dd 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/core.md -core.md: 782d51b5178276ca1cb313607165b17b8e5d6a02 -core.zh.md: d1c06447d9a0675d87eb263d7486b64cd4677373 +core.md: 418d4102f9d39109069aa9ae6eb4a33c2f440b46 +core.zh.md: 09a1790651b02cb4977135ba9d656443eb1c52d3 diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 782d51b517..418d4102f9 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -340,9 +340,9 @@ The model-facing `ToolSchema` is the wire shape; the registered `ToolDefinition` ### The request envelope: `LlmCallConfig` and the logged header -The loop builds each request from logged state. `EpochHeader` records call config, rendered prompt, and authoritative returned tool order (configured by `toolOrder`, or lexicographic when unset) through full `request/header` snapshots. Together with derived history, this makes the request reconstructable from the session log. See [session.md](session.md#the-request-header-event-requestheader) and the [reconstructability Agent Note](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md). +The loop builds each request from logged state. `EpochHeader` records call config, adapter-default provenance, rendered prompt, and authoritative returned tool order (configured by `toolOrder`, or lexicographic when unset) through full `request/header` snapshots. Together with derived history, this makes the request reconstructable from the session log. See [session.md](session.md#the-request-header-event-requestheader) and the [reconstructability Agent Note](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md). -`agent/request` receives a frozen call-config seed and may return a replacement to switch provider, model, reasoning effort, or sampling. After the waterfall, the loop prepares the exact model capability under the turn signal, rejects unsupported explicit effort ids without clamping, materializes an adapter-configured default, and logs the effective value. The prepared call keeps one adapter registration through dispatch. Requests reaching `llm/stream` are deep-frozen, so mutation throws, and carry a process-local loop identity so observers do not confuse separately logged frozen auxiliary calls with conversation requests. +`agent/request` receives a frozen call-config seed and may return a replacement to switch provider, model, reasoning effort, or sampling. Before the waterfall, the loop removes values marked as adapter defaults so exact-model preparation materializes the selected route's current values; unmarked explicit settings remain in the proposal. After the waterfall, preparation rejects unsupported explicit effort ids without clamping and logs the effective config plus provenance under the turn signal. The prepared call keeps one adapter registration through dispatch. Requests reaching `llm/stream` are deep-frozen, so mutation throws, and carry a process-local loop identity so observers do not confuse separately logged frozen auxiliary calls with conversation requests. On the wire, a loop-built request reads the `system` slot (the rendered prompt assembly) followed by the derived history — the boundary snapshot, whose tail is the newest `user/message` on a turn's first step and the previous step's tool results on later steps. The dev invariant recomputes exactly this equation against every loop-built request. @@ -365,6 +365,17 @@ interface LlmCallConfig { } ``` +```ts type-equiv +/** + * Effective config fields supplied by exact-model adapter resolution rather + * than by the caller's request proposal. + */ +interface LlmCallConfigAdapterDefaults { + reasoningEffort?: true + maxTokens?: true +} +``` + ## Sessions A `Session` is an **append-only log** of typed `SessionEvent`s — the single source of truth. The LLM message history is *derived* from the log (`deriveMessages()`), not stored separately. The event vocabulary derives from `SessionEventMap`: diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index d1c06447d9..09a1790651 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -346,9 +346,9 @@ interface ToolSchema { ### 请求信封:`LlmCallConfig` 与记录的 header -循环从已记录状态构建每个请求。`EpochHeader` 通过完整的 `request/header` 快照记录调用配置、渲染后的提示词以及权威返回工具顺序(由 `toolOrder` 配置;未配置时按字典序)。结合派生历史,请求便可由会话日志重建。见 [session.md](session.md#the-request-header-event-requestheader) 与[可重建性 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)。 +循环从已记录状态构建每个请求。`EpochHeader` 通过完整的 `request/header` 快照记录调用配置、适配器默认值来源、渲染后的提示词以及权威返回工具顺序(由 `toolOrder` 配置;未配置时按字典序)。结合派生历史,请求便可由会话日志重建。见 [session.md](session.md#the-request-header-event-requestheader) 与[可重建性 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)。 -`agent/request` 接收冻结的调用配置种子,并可返回替代值以切换提供方、模型、推理强度或采样参数。waterfall 结束后,循环会在轮次信号控制下完成确切模型的能力准备,拒绝显式指定但不受支持的推理强度 ID(不自动调整),填入适配器配置的默认值,并记录最终生效值。准备完成的调用直至分派完成始终持有同一项适配器注册。到达 `llm/stream` 的请求会被深度冻结,因此变更会抛异常;请求还携带进程本地循环标识,使观察者不会把单独记录的冻结辅助调用误认成对话请求。 +`agent/request` 接收冻结的调用配置种子,并可返回替代值以切换提供方、模型、推理强度或采样参数。waterfall 开始前,循环会移除标记为适配器默认值的值,使确切模型准备过程填入所选路由的当前值;未带标记的显式设置仍保留在提议中。waterfall 结束后,准备过程会在轮次信号控制下拒绝显式指定但不受支持的推理强度 ID(不自动调整),并记录生效配置及其来源。准备完成的调用直至分派完成始终持有同一项适配器注册。到达 `llm/stream` 的请求会被深度冻结,因此变更会抛异常;请求还携带进程本地循环标识,使观察者不会把单独记录的冻结辅助调用误认成对话请求。 在协议格式上,循环构建的请求先读取 `system` 槽位(渲染后的提示词组装),再读取派生历史——边界快照,其尾部在轮次首步是最新的 `user/message`,在后续步骤是上一步的工具结果。开发不变式针对每个循环构建的请求精确重算此等式。 @@ -371,6 +371,17 @@ interface LlmCallConfig { } ``` +```ts type-equiv +/** + * Effective config fields supplied by exact-model adapter resolution rather + * than by the caller's request proposal. + */ +interface LlmCallConfigAdapterDefaults { + reasoningEffort?: true + maxTokens?: true +} +``` + ## 会话 `Session` 是一份类型化 `SessionEvent` 的**仅追加日志**——唯一的真源。LLM(大语言模型)消息历史从日志*派生*(`deriveMessages()`),而非单独存储。事件词汇从 `SessionEventMap` 派生: diff --git a/docs/core-data-structures/llm-streaming.i18n.yaml b/docs/core-data-structures/llm-streaming.i18n.yaml index 61594c9066..7e1ba8b7bb 100644 --- a/docs/core-data-structures/llm-streaming.i18n.yaml +++ b/docs/core-data-structures/llm-streaming.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/llm-streaming.md -llm-streaming.md: d9c1772e2fb56de2f240b4e62fdc2e1aa12ae787 -llm-streaming.zh.md: 824b7c46186f2e330ad2a3aafa7a046759ab9a89 +llm-streaming.md: e7500a7985ea1916e206c41e05855701b48fcf00 +llm-streaming.zh.md: 2b61815f2730afdfb93bc06b8ee8925d2f4cac25 diff --git a/docs/core-data-structures/llm-streaming.md b/docs/core-data-structures/llm-streaming.md index d9c1772e2f..e7500a7985 100644 --- a/docs/core-data-structures/llm-streaming.md +++ b/docs/core-data-structures/llm-streaming.md @@ -169,6 +169,8 @@ declare class BlockAssembler { interface PreparedLlmCall { /** Detached, deep-frozen config with any adapter-owned default materialized. */ readonly config: LlmCallConfig + /** Config fields materialized by the captured adapter rather than proposed by the caller. */ + readonly adapterDefaults: LlmCallConfigAdapterDefaults /** * Dispatch this call once through the registration captured during * preparation. The request's call-config fields must match {@link config}; diff --git a/docs/core-data-structures/llm-streaming.zh.md b/docs/core-data-structures/llm-streaming.zh.md index 824b7c4618..2b61815f27 100644 --- a/docs/core-data-structures/llm-streaming.zh.md +++ b/docs/core-data-structures/llm-streaming.zh.md @@ -169,6 +169,8 @@ declare class BlockAssembler { interface PreparedLlmCall { /** Detached, deep-frozen config with any adapter-owned default materialized. */ readonly config: LlmCallConfig + /** Config fields materialized by the captured adapter rather than proposed by the caller. */ + readonly adapterDefaults: LlmCallConfigAdapterDefaults /** * Dispatch this call once through the registration captured during * preparation. The request's call-config fields must match {@link config}; diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml index 7c99825d0a..8297d3779a 100644 --- a/docs/core-data-structures/session.i18n.yaml +++ b/docs/core-data-structures/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/session.md -session.md: 769d5db301e3e81664c732ab1685c859a00cceb2 -session.zh.md: 7af459949eda1b939596c20adf1c9e55f6d2b2b4 +session.md: d7cd2c216c35a89024a312805759929b26edb53d +session.zh.md: 1a163f96872ba18f230d6aa395e86c0f4c56240d diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 769d5db301..d7cd2c216c 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -144,7 +144,7 @@ interface TodoItem { ### The request header event: `request/header` -The request envelope — the `EpochHeader` (call config + rendered system prompt + assembled tool schemas) — is logged session state, so every conversation request is a pure function of the log (the reconstructability Agent Note). A full `request/header` snapshot with reason `'initial'` or `'resume'` records each loop-instance boundary; a later changed request records another full snapshot with reason `'change'`. `foldRequestHeader(events)` reconstructs the header by selecting the latest snapshot. The event is not a `SurfaceEventType`: it produces no LLM message. +The request envelope — the `EpochHeader` (call config + adapter-default provenance + rendered system prompt + assembled tool schemas) — is logged session state, so every conversation request is a pure function of the log (the reconstructability Agent Note). A full `request/header` snapshot with reason `'initial'` or `'resume'` records each loop-instance boundary; a later changed request records another full snapshot with reason `'change'`. `foldRequestHeader(events)` reconstructs the header by selecting the latest snapshot. The event is not a `SurfaceEventType`: it produces no LLM message. ```ts type-equiv /** @@ -155,6 +155,8 @@ The request envelope — the `EpochHeader` (call config + rendered system prompt interface EpochHeader { /** The conversation's call configuration (provider, model, reasoning effort, and sampling scalars). */ config: LlmCallConfig + /** Effective config fields materialized from the exact adapter rather than proposed by a caller. */ + adapterDefaults?: LlmCallConfigAdapterDefaults /** Rendered system prompt text; absent for a system-less request. */ system?: string /** Assembled tool schemas; absent for a tool-less request. */ diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md index 7af459949e..1a163f9687 100644 --- a/docs/core-data-structures/session.zh.md +++ b/docs/core-data-structures/session.zh.md @@ -146,7 +146,7 @@ interface TodoItem { ### 请求头事件:`request/header` -请求信封(即 `EpochHeader`:调用配置 + 渲染后的系统提示词 + 已组装的工具 schema)会作为会话状态写入日志,因此每个对话请求都是日志的纯函数(见可重建性 Agent Note)。带有 reason `'initial'` 或 `'resume'` 的完整 `request/header` 快照记录每个 agent loop 实例的边界;之后请求发生变化时,系统会以 reason `'change'` 记录另一份完整快照。`foldRequestHeader(events)` 通过选择最新快照重建请求头。该事件不是 `SurfaceEventType`,不产生 LLM 消息。 +请求信封(即 `EpochHeader`:调用配置 + 适配器默认值来源 + 渲染后的系统提示词 + 已组装的工具 schema)会作为会话状态写入日志,因此每个对话请求都是日志的纯函数(见可重建性 Agent Note)。带有 reason `'initial'` 或 `'resume'` 的完整 `request/header` 快照记录每个 agent loop 实例的边界;之后请求发生变化时,系统会以 reason `'change'` 记录另一份完整快照。`foldRequestHeader(events)` 通过选择最新快照重建请求头。该事件不是 `SurfaceEventType`,不产生 LLM 消息。 ```ts type-equiv /** @@ -157,6 +157,8 @@ interface TodoItem { interface EpochHeader { /** The conversation's call configuration (provider, model, reasoning effort, and sampling scalars). */ config: LlmCallConfig + /** Effective config fields materialized from the exact adapter rather than proposed by a caller. */ + adapterDefaults?: LlmCallConfigAdapterDefaults /** Rendered system prompt text; absent for a system-less request. */ system?: string /** Assembled tool schemas; absent for a tool-less request. */ diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 524ba6ffd7..4d4faa42e3 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -78,7 +78,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:279`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:286`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:315`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:347`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:282`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:318`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:350`](../packages/core/session/src/types.ts) ## Events @@ -154,7 +154,7 @@ Source: [`packages/ui/user-approval/src/index.ts:67`](../packages/ui/user-approv Types: [StreamChunk](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:212`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:215`](../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -170,7 +170,7 @@ Source: [`packages/core/session/src/types.ts:212`](../packages/core/session/src/ Types: [TokenUsage](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:219`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:222`](../packages/core/session/src/types.ts) ### `command/*` @@ -379,7 +379,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:51`](../packages/plan/plan-mode/s 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:252`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:255`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -432,7 +432,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s 'session/end-seed': Record ``` -Source: [`packages/core/session/src/types.ts:275`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:278`](../packages/core/session/src/types.ts) #### `session/title` — log-only @@ -468,7 +468,7 @@ Source: [`packages/session-title/session-title-llm/src/index.ts:43`](../packages 'steering/message': { turn: number; message: UserMessage } ``` -Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:248`](../packages/core/session/src/types.ts) ### `step/*` @@ -479,7 +479,7 @@ Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/ 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:201`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:204`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -488,7 +488,7 @@ Source: [`packages/core/session/src/types.ts:201`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:199`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:202`](../packages/core/session/src/types.ts) ### `todo/*` @@ -501,7 +501,7 @@ Source: [`packages/core/session/src/types.ts:199`](../packages/core/session/src/ Types: [TodoItem](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:247`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:250`](../packages/core/session/src/types.ts) ### `tool/*` @@ -518,7 +518,7 @@ Source: [`packages/core/session/src/types.ts:247`](../packages/core/session/src/ Types: [CallId](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:225`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:228`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -591,7 +591,7 @@ Source: [`packages/core/tools/src/code-mode.ts:33`](../packages/core/tools/src/c } ``` -Source: [`packages/core/session/src/types.ts:237`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:240`](../packages/core/session/src/types.ts) ### `turn/*` @@ -609,7 +609,7 @@ Source: [`packages/core/session/src/types.ts:237`](../packages/core/session/src/ Types: [TurnEndReason](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:197`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:200`](../packages/core/session/src/types.ts) #### `turn/start` — log-only @@ -622,7 +622,7 @@ Source: [`packages/core/session/src/types.ts:197`](../packages/core/session/src/ Types: [TurnTrigger](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:190`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:193`](../packages/core/session/src/types.ts) ### `user/*` @@ -640,4 +640,4 @@ Source: [`packages/core/session/src/types.ts:190`](../packages/core/session/src/ 'user/message': UserMessage ``` -Source: [`packages/core/session/src/types.ts:210`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:213`](../packages/core/session/src/types.ts) diff --git a/examples/headless-agent/cordis.yml b/examples/headless-agent/cordis.yml index 038a2da7f6..335add389c 100644 --- a/examples/headless-agent/cordis.yml +++ b/examples/headless-agent/cordis.yml @@ -5,7 +5,7 @@ # The DeepSeek adapter. Swap to '@deepseek-ai/dsh-llm-pi-ai' for the pi-ai-backed # twin (same config shape; `reasoning: high` replaces thinking/reasoningEffort). # Shipped default: full thinking at max effort on every request. Exact-model -# resolution materializes the effort before the request header is logged. +# resolution materializes request defaults before the request header is logged. - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index 511d41e6b2..e51b2ab70f 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -270,7 +270,7 @@ describe('headless stream-json snapshots', () => { expect(result.stderr).toBe('') expect(server.requests).toHaveLength(1) expect(server.requests[0]?.max_tokens).toBe(256_000) - const config = parseJsonl(result.stdout) + const header = (parseJsonl(result.stdout) .map(record => record.event) .find((event): event is JsonObject => ( event !== null @@ -278,8 +278,8 @@ describe('headless stream-json snapshots', () => { && !Array.isArray(event) && 'type' in event && event.type === 'request/header' - ))?.data as JsonObject | undefined - expect((config?.header as JsonObject | undefined)?.config).toMatchInlineSnapshot(` + ))?.data as JsonObject | undefined)?.header as JsonObject | undefined + expect(header?.config).toMatchInlineSnapshot(` { "maxTokens": 256000, "model": "deepseek-v4-flash", @@ -287,6 +287,10 @@ describe('headless stream-json snapshots', () => { "reasoningEffort": "off", } `) + expect(header?.adapterDefaults).toEqual({ + maxTokens: true, + reasoningEffort: true, + }) } finally { await server.close() } diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index c12905ba85..f4aa70420c 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1757,7 +1757,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'EpochHeader', - declaration: 'export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n}', + declaration: 'export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n}', }, { name: 'FileDiff', @@ -1911,6 +1911,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'LlmCallConfig', declaration: 'export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n}', }, + { + name: 'LlmCallConfigAdapterDefaults', + declaration: 'export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n}', + }, { name: 'LlmFailure', declaration: 'export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n}', @@ -1969,7 +1973,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PreparedLlmCall', - declaration: 'export interface PreparedLlmCall {\n readonly config: LlmCallConfig;\n stream(options: GenerateOptions): AsyncIterable;\n}', + declaration: 'export interface PreparedLlmCall {\n readonly config: LlmCallConfig;\n readonly adapterDefaults: LlmCallConfigAdapterDefaults;\n stream(options: GenerateOptions): AsyncIterable;\n}', }, { name: 'PreparedReferencedMessage', diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 3a82c693ca..2649d4fde0 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md -README.md: a1617a1ef871f61157e0d70a06d055168170dced -README.zh.md: 6ba945a41e700331929dabb557802c14256921fb +README.md: afc00f1ecdd225f22da46b95827259a50c719766 +README.zh.md: 63aaab3b5af32b9bad70d74fbd57c8bd62c2ef7d diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index a1617a1ef8..afc00f1ecd 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -65,7 +65,7 @@ The driver owns one agent for its lifetime and runs inside `ctx.agents.withIniti Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. The anchor records the assembled content as-is, retains exact chunk provenance (`[]` for a stream with no chunks), and includes usage when available; empty content stays out of derived message history. -After `agent/request` returns a provider/model call config, the loop asks `ctx.llm.prepareCall()` to validate any adapter-owned reasoning effort and materialize its configured default under the active turn signal. The prepared call retains the exact adapter registration across this asynchronous resolution, `request/header` logging, and terminal dispatch, so HMR cannot mix one adapter's capability result with another adapter's request. The effective config is logged before dispatch, so a listener can change effort between steps without hidden request drift. A route with no registered adapter preserves the proposed config so an `llm/stream` listener can own and short-circuit it; unhandled terminal dispatch still fails with `NO_ADAPTER`. A new loop instance restores the last effort only when its initial provider/model route exactly matches the logged route; a route change discards that opaque model-owned ID and resolves the new model independently. +After `agent/request` returns a provider/model call config, the loop asks `ctx.llm.prepareCall()` to validate adapter-owned fields and materialize configured reasoning-effort and output-token defaults under the active turn signal. The prepared call retains the exact adapter registration across this asynchronous resolution, `request/header` logging, and terminal dispatch, so HMR cannot mix one adapter's capability result with another adapter's request. The header records the effective config and which fields came from the adapter. Before the next waterfall, the loop removes those marked fields from the proposal so the current exact route rematerializes its own defaults; unmarked explicit settings persist across steps and route changes. A route with no registered adapter preserves the proposed config so an `llm/stream` listener can own and short-circuit it; unhandled terminal dispatch still fails with `NO_ADAPTER`. A new loop instance applies the same provenance rule when resuming. Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and other extension failures close directly. Recovery receives the exact live error, immutable provider facts, immutable prior failures, the immutable retry policy of the adapter registration that served the request, and the turn signal after the failed step closes; the policy is absent if no final adapter served it. A handling listener returns `{ kind: 'retry' }`; the loop closes the failed turn with its error and opens one numbered retry turn without an intervening idle notification. Success clears the consecutive history, and an unhandled failure is terminal. AgentLoop owns one cancellation signal for the current admission or turn. An effective `cancel(cause)` clears pending work unless `keepInbox` is set and cooperatively aborts that signal; idle cancellation is a no-op. Durable `turn/end` records `aborted` for `user` and `parent`, while disposal records `disposed`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. The cancellation cause changes reporting, not how result context finalized after cancellation is handled. Disposal waits for signal-ignoring work before registry removal. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract. diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index 6ba945a41e..63aaab3b5a 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -65,7 +65,7 @@ interface Config { 每次提供方调用成功结束时,都会恰好追加一个 `assistant/message` 完成锚点,包括无内容调用和以 `max-tokens` 结束的调用。该锚点原样记录组装后的内容,保留确切的分片溯源(流没有分片时为 `[]`),并在用量可用时包含用量;空内容不会进入派生消息历史。 -在 `agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`,在活跃轮次信号的控制下校验由适配器持有的推理(reasoning)强度,并填入其配置默认值。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR(热模块替换)不会把某个适配器的能力解析结果与另一适配器的请求混用。生效配置会在分派前写入日志,因此监听器可以在步骤之间更改推理强度,而不会产生未记录的请求变化。没有已注册适配器的路由会保留原定配置,使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例仅在初始提供方/模型路由与日志路由完全一致时恢复上次的推理强度;路由变化会丢弃由前一模型持有的不透明 ID,并单独解析新模型。 +在 `agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`,在活跃轮次信号的控制下校验由适配器持有的字段,并填入配置的推理(reasoning)强度和输出 token 默认值。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR(热模块替换)不会把某个适配器的能力解析结果与另一适配器的请求混用。请求 header 会记录生效配置以及哪些字段来自适配器。下一次 waterfall(瀑布式事件)前,循环会从提议中移除这些带标记字段,使当前精确路由重新填入自身默认值;未带标记的显式设置会跨步骤和路由变化保留。没有已注册适配器的路由会保留原定配置,使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例在恢复时会应用同一来源规则。 插件失败会结束当前轮次,而不是结束循环。只有最终适配器分发/迭代失败以及带内的终止错误或中止结束才进入 `agent/request-error`;中间件、结果处理、工具及其他扩展失败会直接关闭轮次。失败步骤关闭后,恢复逻辑会接收确切的实时错误、不可变的提供方事实、不可变的先前失败、为请求提供服务的适配器注册所对应的不可变重试策略,以及轮次信号;如果没有最终适配器为其提供服务,则该策略缺失。处理失败的监听器返回 `{ kind: 'retry' }`;循环用其错误关闭失败轮次,并在不插入空闲通知的情况下开启一个编号重试轮次。成功会清除连续失败历史;未被处理的失败是终态。AgentLoop 为当前接纳或轮次拥有一个取消信号。有效的 `cancel(cause)` 在未设置 `keepInbox` 时清除待处理工作,并以协作方式中止该信号;空闲取消是空操作。持久 `turn/end` 为 `user` 和 `parent` 记录 `aborted`,dispose(资源释放)则记录 `disposed`;未分发的模型工具调用会收到合成的 `tool/call` 与 `ABORTED_BEFORE_DISPATCH` 结果对。取消原因只改变报告方式,不改变对取消后已定案结果上下文的处理。dispose 会等待忽略信号的工作完成,然后才从注册表移除。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)规定生命周期与竞态契约。 @@ -92,7 +92,7 @@ interface Config { #### Token 影响 -每个步骤都会再次计入系统文本与 schema。逐 agent 作用域决定贡献,而权威组装 waterfall(瀑布式事件)可以改变最终请求,并使其监听器负责保持协议连贯。 +每个步骤都会再次计入系统文本与 schema。逐 agent 作用域决定贡献,而权威组装 waterfall 可以改变最终请求,并使其监听器负责保持协议连贯。 #### KV Cache 影响 diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 1a317aa342..460deded9f 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -44,7 +44,7 @@ import { } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmCallConfig, LlmFailure, Message, PreparedLlmCall, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session' -import type { AssistantMessage, Session, SessionId, TurnEndReason, TurnTrigger, UserMessage } from '@deepseek-ai/dsh-session' +import type { AssistantMessage, EpochHeader, Session, SessionId, TurnEndReason, TurnTrigger, UserMessage } from '@deepseek-ai/dsh-session' import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' import { executeToolCalls } from './tool-calls.ts' @@ -54,6 +54,15 @@ type StepOutcome = | { kind: 'completed'; continueTurn: boolean; concluded: boolean; maxTokens: boolean } | { kind: 'request-failed'; error: RequestError; failure: LlmFailure; retryPolicy: ResolvedRetryPolicy | undefined } +/** Remove adapter-derived values before plugins propose the next request config. */ +function requestProposal(header: EpochHeader): LlmCallConfig { + if (header.adapterDefaults === undefined) return header.config + const proposal = { ...header.config } + if (header.adapterDefaults.reasoningEffort === true) delete proposal.reasoningEffort + if (header.adapterDefaults.maxTokens === true) delete proposal.maxTokens + return proposal +} + /** * The concrete {@link Agent}: each `run()` owns one turn and repeats model * steps while tools or steering require another request. @@ -615,19 +624,21 @@ export class ReactLoopAgent implements Agent { ): Promise<{ request: GenerateOptions; preparedCall?: PreparedLlmCall }> { const { session } = this - // A loop instance starts from its declared route, restoring only an opaque - // effort owned by that exact model. Later steps fold the config it logged. - const persistedConfig = session.requestHeader()?.config + // A loop instance starts from its declared route, restoring only an explicit + // effort owned by that exact model. Later steps re-resolve marked defaults. + const persistedHeader = session.requestHeader() + const persistedConfig = persistedHeader?.config const route = { provider: this.options.provider ?? '', model: this.options.model ?? '' } const reasoningEffort = persistedConfig?.provider === route.provider && persistedConfig.model === route.model + && persistedHeader?.adapterDefaults?.reasoningEffort !== true ? persistedConfig.reasoningEffort : undefined const maxTokens = this.options.maxTokens const seedConfig = deepFreeze(structuredClone( this.requestHeaderLogged // oxlint-disable-next-line typescript/no-non-null-assertion -- the instance logged the header it now folds - ? persistedConfig! + ? requestProposal(persistedHeader!) : { ...route, ...reasoningEffort === undefined ? {} : { reasoningEffort }, @@ -657,6 +668,7 @@ export class ReactLoopAgent implements Agent { const header = canonicalHeader({ config, + ...preparedCall === undefined ? {} : { adapterDefaults: preparedCall.adapterDefaults }, ...system ? { system } : {}, ...tools.length > 0 ? { tools } : {}, }) diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index f27aeabe7b..2fc80c32c1 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -18,6 +18,13 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' async function harness(adapter: MockAdapter, persona = 'stable base') { + return harnessRoutes([['mock', adapter]], persona) +} + +async function harnessRoutes( + adapters: readonly (readonly [provider: string, adapter: MockAdapter])[], + persona = 'stable base', +) { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(SessionStore) @@ -25,7 +32,7 @@ async function harness(adapter: MockAdapter, persona = 'stable base') { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) - ctx.llm.registerAdapter(['mock'], adapter) + for (const [provider, adapter] of adapters) ctx.llm.registerAdapter([provider], adapter) return ctx } @@ -134,6 +141,10 @@ describe('request stability across the loop', () => { ReasoningEffortId('high'), ReasoningEffortId('max'), ]) + expect(headers.map(event => event.data.header.adapterDefaults)).toEqual([ + { reasoningEffort: true }, + undefined, + ]) expect(headers.map(event => event.data.reason)).toEqual(['initial', 'change']) for (const [model, effort] of [ @@ -172,6 +183,72 @@ describe('request stability across the loop', () => { expect(adapter.requests[0]?.maxTokens).toBe(256_000) const header = agent.session.events.find(event => event.type === 'request/header') expect(header?.type === 'request/header' && header.data.header.config.maxTokens).toBe(256_000) + expect(header?.type === 'request/header' && header.data.header.adapterDefaults) + .toEqual({ maxTokens: true }) + }) + + it('rematerializes the selected adapter maxTokens default after a provider switch', async () => { + const deepseek = new MockAdapter([textResponse('deepseek')], undefined, 256_000) + const other = new MockAdapter([textResponse('other')], undefined, 8_192) + const ctx = await harnessRoutes([ + ['deepseek', deepseek], + ['other', other], + ]) + const agent = ctx.agentLoop.create(SessionId('adapter-max-tokens-switch'), { + provider: 'deepseek', + model: 'deepseek-model', + }) + ctx.on('agent/request', async (_agent, turn, _step, _signal, next) => { + const config = await next() + return turn === 2 + ? { ...config, provider: 'other', model: 'other-model' } + : config + }) + + send(agent, 'first') + await waitForIdle(ctx, agent) + send(agent, 'second') + await waitForIdle(ctx, agent) + + expect(deepseek.requests[0]?.maxTokens).toBe(256_000) + expect(other.requests[0]?.maxTokens).toBe(8_192) + const headers = agent.session.events.filter(event => event.type === 'request/header') + expect(headers.map(event => event.data.header.config.maxTokens)).toEqual([256_000, 8_192]) + expect(headers.map(event => event.data.header.adapterDefaults)).toEqual([ + { maxTokens: true }, + { maxTokens: true }, + ]) + }) + + it('preserves an explicit agent maxTokens cap across a provider switch', async () => { + const deepseek = new MockAdapter([textResponse('deepseek')], undefined, 256_000) + const other = new MockAdapter([textResponse('other')], undefined, 8_192) + const ctx = await harnessRoutes([ + ['deepseek', deepseek], + ['other', other], + ]) + const agent = ctx.agentLoop.create(SessionId('explicit-max-tokens-switch'), { + provider: 'deepseek', + model: 'deepseek-model', + maxTokens: 4_096, + }) + ctx.on('agent/request', async (_agent, turn, _step, _signal, next) => { + const config = await next() + return turn === 2 + ? { ...config, provider: 'other', model: 'other-model' } + : config + }) + + send(agent, 'first') + await waitForIdle(ctx, agent) + send(agent, 'second') + await waitForIdle(ctx, agent) + + expect(deepseek.requests[0]?.maxTokens).toBe(4_096) + expect(other.requests[0]?.maxTokens).toBe(4_096) + const headers = agent.session.events.filter(event => event.type === 'request/header') + expect(headers.map(event => event.data.header.config.maxTokens)).toEqual([4_096, 4_096]) + expect(headers.map(event => event.data.header.adapterDefaults)).toEqual([undefined, undefined]) }) it('keeps exact-model resolution, request logging, and dispatch on one adapter registration', async () => { diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index 9fb097d9fb..73ba153907 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/session/README.md -README.md: a9b6905dcf2b8ef1f75595e567273f7a3150a412 -README.zh.md: f1a5e97e32d1ad1abcd6ad96e6c621af9972e989 +README.md: a4944fd974fda5ee1bd6ecadf29ef5fa322e9dc3 +README.zh.md: 3578ea3aa48382609e075518b0f8a7f8851761e4 diff --git a/packages/core/session/README.md b/packages/core/session/README.md index a9b6905dcf..a4944fd974 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -63,7 +63,7 @@ Providers stream token-sized deltas, so a raw log stores hundreds of `assistant/ ### Request-header reconstruction (`request-header.ts`) -`request/header` records a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, or `change`. `foldRequestHeader()` selects the latest snapshot; legacy delta events and the removed `fallback` reason are rejected. See the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md). +`request/header` records a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, or `change`. Its optional `adapterDefaults` map marks effective `reasoningEffort` or `maxTokens` values materialized by exact-model resolution, allowing the next request proposal to distinguish them from explicit conversation settings. `foldRequestHeader()` selects the latest snapshot; legacy delta events and the removed `fallback` reason are rejected. See the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md). A `user/message` stores the complete `UserMessage` directly, including the identity created before routing or prompt admission. It renders its `content` verbatim whether it is a direct human prompt, a synthetic injection, or an admitted goal round; its typed `source` is the only channel that tells them apart and carries any domain-specific durable facts. `assistant/message`, `tool/result`, and `steering/message` likewise store complete message values. Turn execution remains enclosed by `turn/start` and `turn/end`, while an idle injection may append and flush a `user/message` between turns without running the model. diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index f1a5e97e32..3578ea3aa4 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -63,7 +63,7 @@ ### 请求头重建(`request-header.ts`) -`request/header` 记录非历史请求封装的完整规范快照,其原因为 `initial`、`resume` 或 `change`。`foldRequestHeader()` 选择最新快照;旧版增量事件和已移除的 `fallback` 原因会被拒绝。详见[可重建请求 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)。 +`request/header` 记录非历史请求封装的完整规范快照,其原因为 `initial`、`resume` 或 `change`。其可选 `adapterDefaults` 映射会标记由精确模型解析填入的生效 `reasoningEffort` 或 `maxTokens` 值,使下一次请求提议能够将它们与显式对话设置区分开。`foldRequestHeader()` 选择最新快照;旧版增量事件和已移除的 `fallback` 原因会被拒绝。详见[可重建请求 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)。 `user/message` 会直接存储完整的 `UserMessage`,其中包括路由或提示词准入前创建的标识。无论它是直接人类提示词、合成注入,还是已准入的 Goal Round,都会原样呈现其 `content`;带类型的 `source` 是区分三者的唯一通道,并携带各领域专有的持久事实。`assistant/message`、`tool/result` 和 steering(中途引导)对应的 `steering/message` 也会存储完整的消息值。轮次执行仍由 `turn/start` 与 `turn/end` 包围,而空闲注入可以在轮次之间追加并刷新一条 `user/message`,无需运行模型。 diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index a403e11df1..3dfb83a890 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -201,13 +201,18 @@ function assertCurrentLlmShape(event: Record, index: number): v : undefined if (event['type'] === 'request/header') { const header = record?.['header'] - const config = typeof header === 'object' && header !== null ? (header as Record)['config'] : undefined + const headerRecord = typeof header === 'object' && header !== null && !Array.isArray(header) + ? header as Record + : undefined + const config = headerRecord?.['config'] if (!hasProviderModel(config)) throw new Error(`seed request/header at index ${index} lacks provider/model`) - const reasoningEffort = (config as Record)['reasoningEffort'] + const configRecord = config as Record + const reasoningEffort = configRecord['reasoningEffort'] if (reasoningEffort !== undefined && (typeof reasoningEffort !== 'string' || reasoningEffort.length === 0)) { throw new Error(`seed request/header at index ${index} has an invalid reasoningEffort`) } + assertAdapterDefaults(headerRecord?.['adapterDefaults'], configRecord, index) } const type = event['type'] if (type !== 'user/message' && type !== 'assistant/message' @@ -215,6 +220,26 @@ function assertCurrentLlmShape(event: Record, index: number): v assertMessageEventShape(event, `seed ${type} at index ${index}`) } +/** Validate adapter-default provenance imported from a durable request header. */ +function assertAdapterDefaults( + value: unknown, + config: Record, + index: number, +): void { + if (value === undefined) return + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error(`seed request/header at index ${index} has invalid adapterDefaults`) + } + const defaults = value as Record + const allowed = new Set(['reasoningEffort', 'maxTokens']) + if (Object.keys(defaults).some(key => !allowed.has(key)) + || Object.values(defaults).some(marker => marker !== true) + || defaults['reasoningEffort'] === true && config['reasoningEffort'] === undefined + || defaults['maxTokens'] === true && config['maxTokens'] === undefined) { + throw new Error(`seed request/header at index ${index} has invalid adapterDefaults`) + } +} + /** Validate only the event-specific invariants needed to safely replay a message. */ function assertMessageEventShape(event: Record, subject: string): void { const type = event['type'] diff --git a/packages/core/session/src/request-header.ts b/packages/core/session/src/request-header.ts index ad2b61faed..ef67569139 100644 --- a/packages/core/session/src/request-header.ts +++ b/packages/core/session/src/request-header.ts @@ -19,8 +19,12 @@ import type { EpochHeader, SessionEvent } from './types.ts' * @returns the canonical header. */ export function canonicalHeader(header: EpochHeader): EpochHeader { + const adapterDefaults = header.adapterDefaults return { config: header.config, + ...adapterDefaults?.reasoningEffort === true || adapterDefaults?.maxTokens === true + ? { adapterDefaults } + : {}, ...header.system !== undefined && header.system.length > 0 ? { system: header.system } : {}, ...header.tools !== undefined && header.tools.length > 0 ? { tools: header.tools } : {}, } @@ -38,7 +42,12 @@ function sameSchema(a: ToolSchema, b: ToolSchema): boolean { * @returns whether config, system, and tools all match. */ export function headerEquals(a: EpochHeader, b: EpochHeader): boolean { - if (!callConfigEquals(a.config, b.config) || a.system !== b.system) return false + if ( + !callConfigEquals(a.config, b.config) + || a.adapterDefaults?.reasoningEffort !== b.adapterDefaults?.reasoningEffort + || a.adapterDefaults?.maxTokens !== b.adapterDefaults?.maxTokens + || a.system !== b.system + ) return false const at = a.tools ?? [] const bt = b.tools ?? [] return at.length === bt.length && at.every((tool, i) => sameSchema(tool, bt[i] as ToolSchema)) diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index ebebb95c7c..1f7a8583d6 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -3,6 +3,7 @@ import type { AssistantMessage, CallId, LlmCallConfig, + LlmCallConfigAdapterDefaults, LlmFailure, MessageSource, StreamChunk, @@ -163,6 +164,8 @@ export interface TodoItem { export interface EpochHeader { /** The conversation's call configuration (provider, model, reasoning effort, and sampling scalars). */ config: LlmCallConfig + /** Effective config fields materialized from the exact adapter rather than proposed by a caller. */ + adapterDefaults?: LlmCallConfigAdapterDefaults /** Rendered system prompt text; absent for a system-less request. */ system?: string /** Assembled tool schemas; absent for a tool-less request. */ diff --git a/packages/core/session/tests/request-header.spec.ts b/packages/core/session/tests/request-header.spec.ts index 53c76a5298..373fb2a127 100644 --- a/packages/core/session/tests/request-header.spec.ts +++ b/packages/core/session/tests/request-header.spec.ts @@ -14,9 +14,24 @@ function tool(name: string, description = 'd'): ToolSchema { describe('canonicalHeader', () => { it('normalizes empty optional fields to absence and preserves populated fields', () => { - expect(canonicalHeader({ config: CONFIG, system: '', tools: [] })).toEqual({ config: CONFIG }) - const full = canonicalHeader({ config: CONFIG, system: 's', tools: [tool('a')] }) - expect(full).toEqual({ config: CONFIG, system: 's', tools: [tool('a')] }) + expect(canonicalHeader({ + config: CONFIG, + adapterDefaults: {}, + system: '', + tools: [], + })).toEqual({ config: CONFIG }) + const full = canonicalHeader({ + config: { ...CONFIG, maxTokens: 256_000 }, + adapterDefaults: { maxTokens: true }, + system: 's', + tools: [tool('a')], + }) + expect(full).toEqual({ + config: { ...CONFIG, maxTokens: 256_000 }, + adapterDefaults: { maxTokens: true }, + system: 's', + tools: [tool('a')], + }) }) }) @@ -30,6 +45,14 @@ describe('headerEquals', () => { ...base, config: { ...base.config, reasoningEffort: ReasoningEffortId('high') }, })).toBe(false) + expect(headerEquals( + { ...base, config: { ...base.config, maxTokens: 256_000 } }, + { + ...base, + config: { ...base.config, maxTokens: 256_000 }, + adapterDefaults: { maxTokens: true }, + }, + )).toBe(false) expect(headerEquals(base, { ...base, system: 'other' })).toBe(false) expect(headerEquals(base, { ...base, tools: [] })).toBe(false) expect(headerEquals(base, { ...base, tools: [tool('a', 'changed')] })).toBe(false) diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index 7b7e2fae28..0d8663ea6c 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -403,6 +403,40 @@ describe('Session', () => { } }) + it('round-trips adapter-default provenance and rejects invalid durable values', () => { + const valid = { + type: 'request/header', + seq: 0, + time: 1, + data: { + header: { + config: { + provider: 'mock', + model: 'model', + maxTokens: 256_000, + }, + adapterDefaults: { maxTokens: true }, + }, + reason: 'initial', + }, + } as const + expect(new Session(SessionId('adapter-defaults'), [valid]).events[0]).toEqual(valid) + + for (const adapterDefaults of [ + null, + [], + { unknown: true }, + { maxTokens: false }, + { reasoningEffort: true }, + ]) { + const invalid = structuredClone(valid) as unknown as SessionEvent + if (invalid.type !== 'request/header') throw new Error('test fixture must be a request header') + invalid.data.header.adapterDefaults = adapterDefaults as never + expect(() => new Session(SessionId('invalid-adapter-defaults'), [invalid])) + .toThrow('seed request/header at index 0 has invalid adapterDefaults') + } + }) + it('isolates the log from mutation through a derived message (append-only contract)', () => { const session = new Session(SessionId('s4')) session.append('user/message', createUserMessage({ diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 73e131f0e4..8212490acc 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md -README.md: 5a22689d0b15ae5de1b82e37cf8c2c283c14af97 -README.zh.md: 0fa8b16eee72eee741b07d27b1ed61297f5420c2 +README.md: 19bc84146c9b03a6ed039a7bbe9e60ebecf50838 +README.zh.md: 80772d4c06a426318fe5ddff5c997fc9f2e75129 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 5a22689d0b..19bc84146c 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -32,14 +32,14 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire name: DeepSeek-V4-Flash - id: private-reasoner description: Company-hosted reasoning model - contextWindow: 64000 + contextWindow: 512000 ``` The plugin registers the single provider route `deepseek` together with its resolved `retryPolicy`. A request selects it with `provider: deepseek`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` as `DeepSeek-V4-Flash` and `deepseek-v4-pro` as `DeepSeek-V4-Pro`, each with a 1,000,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek')` for clients such as ACP editors and the Web selector, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id. `contextWindow` is optional per configured model and is not exposed through the advisory catalog. `ctx.llm.resolveModelInfo('deepseek', model).context` returns an exact model value first, then `defaultContextWindow` for an entry without capacity or an unlisted pass-through id. The adapter default is 1,000,000; pressure-sensitive plugins therefore get deployment-owned capacity without treating the model selector as authoritative. Registering another adapter for `deepseek` throws `LlmError('DUPLICATE_ADAPTER')`. -`maxTokens` is the adapter-configured output cap for conversation requests and defaults to 256,000. Exact-model resolution exposes it as `defaultMaxTokens`; `LlmService` materializes that value into `GenerateOptions.maxTokens` before the agent loop writes `request/header`, so the wire request remains reconstructable. An explicit request or `AgentOptions.maxTokens` value wins and is serialized as `max_tokens`. +`maxTokens` is the adapter-configured output cap for conversation requests and defaults to 256,000. Exact-model resolution exposes it as `defaultMaxTokens`; `LlmService` materializes that value into `GenerateOptions.maxTokens` before the agent loop writes `request/header`, so the wire request remains reconstructable. An explicit request or `AgentOptions.maxTokens` value wins and is serialized as `max_tokens`. The adapter does not clamp this request budget against `contextWindow`; deployments with a smaller context or provider output limit must configure a compatible `maxTokens`. The same exact-model result exposes ordered `off`, `high`, and `max` efforts under `reasoning` for every pass-through model when deployment policy permits thinking. `reasoningEffort` selects the deployment default and falls back to `high` when omitted. `agent/request` can replace it on each conversation step; the resolved value is logged in `request/header`. `high` and `max` enable thinking and serialize as the official top-level `reasoning_effort`; adapter-owned `off` instead serializes `thinking.type: disabled` and omits `reasoning_effort`. An unsupported value fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 0fa8b16eee..80772d4c06 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -32,14 +32,14 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: name: DeepSeek-V4-Flash - id: private-reasoner description: Company-hosted reasoning model - contextWindow: 64000 + contextWindow: 512000 ``` 该插件注册唯一提供方路由 `deepseek`,同时注册解析后的 `retryPolicy`。请求使用 `provider: deepseek` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash`(名称为 `DeepSeek-V4-Flash`)和 `deepseek-v4-pro`(名称为 `DeepSeek-V4-Pro`),两者的上下文窗口均为 1,000,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。Catalog 配置项通过 `ctx.llm.listModels('deepseek')` 公开给 ACP(Agent Client Protocol)编辑器和 Web 选择器等客户端,但仍只提供建议:未列出模型 id 仍原样传递。省略配置项 name 默认为其 id。 `contextWindow` 对每个已配置模型都可选,不会通过建议 catalog 公开。`ctx.llm.resolveModelInfo('deepseek', model).context` 先返回精确模型值,再对不含容量的配置项或未列出原样传递 id 返回 `defaultContextWindow`。适配器默认值为 1,000,000;因此,压力敏感插件可以获得由部署决定的容量,不会将模型 selector 视为权威。为 `deepseek` 注册另一个适配器会抛出 `LlmError('DUPLICATE_ADAPTER')`。 -`maxTokens` 是适配器为对话请求配置的输出上限,默认值为 256,000。确切模型解析会将其公开为 `defaultMaxTokens`;`LlmService` 会在 agent loop(智能体循环)写入 `request/header` 前,将该值填入 `GenerateOptions.maxTokens`,从而仍可根据持久记录重建协议请求。显式的请求值或 `AgentOptions.maxTokens` 值优先,并会序列化为 `max_tokens`。 +`maxTokens` 是适配器为对话请求配置的输出上限,默认值为 256,000。确切模型解析会将其公开为 `defaultMaxTokens`;`LlmService` 会在 agent loop(智能体循环)写入 `request/header` 前,将该值填入 `GenerateOptions.maxTokens`,从而仍可根据持久记录重建协议请求。显式的请求值或 `AgentOptions.maxTokens` 值优先,并会序列化为 `max_tokens`。适配器不会根据 `contextWindow` 自动调低该请求预算;上下文或提供方输出上限较小的部署必须配置与其相容的 `maxTokens`。 同一确切模型结果会在部署策略允许思考时,为每个原样传递模型在 `reasoning` 下公开有序的 `off`、`high` 和 `max` 推理(reasoning)强度。`reasoningEffort` 选择部署默认值,省略时回退为 `high`。`agent/request` 可以在每个会话步骤替换它;解析后的值会记录在 `request/header`。`high` 和 `max` 会启用思考,并序列化为官方顶层 `reasoning_effort`;适配器持有的 `off` 则序列化为 `thinking.type: disabled`,且省略 `reasoning_effort`。不支持的值会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败。 diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index f2a083d55b..705cc7ba9c 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -244,7 +244,7 @@ export class DeepSeekAdapter extends LlmAdapter { } private async * request(options: GenerateOptions, signal: AbortSignal): AsyncIterable { - const body = serializeRequest(options, this.options.defaults, this.maxTokens) + const body = serializeRequest(options, this.options.defaults) // Prepared outside the try so the TRANSPORT label below covers exactly the // transport boundary, never a serialization failure. const payload = JSON.stringify(body) diff --git a/packages/llm/llm-deepseek/src/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts index d0f0081fae..bb6443425e 100644 --- a/packages/llm/llm-deepseek/src/serialize.ts +++ b/packages/llm/llm-deepseek/src/serialize.ts @@ -137,13 +137,11 @@ export function serializeMessages(messages: Message[]): WireMessage[] { * provider defaults apply. * @param options - the harness request (model, history, system, tools, sampling). * @param defaults - adapter-level thinking defaults; undefined fields put nothing on the wire. - * @param defaultMaxTokens - adapter output default used only when the request omits a cap. * @returns the chat-completions request body. */ export function serializeRequest( options: GenerateOptions, defaults: RequestDefaults = {}, - defaultMaxTokens?: number, ): WireRequest { const messages: WireMessage[] = [] if (options.system !== undefined) { @@ -162,7 +160,6 @@ export function serializeRequest( // A short title budget must produce visible text; conversation and // compaction calls continue to inherit the adapter's thinking defaults. const resolvedThinking = resolveThinking(options, defaults) - const maxTokens = options.maxTokens ?? defaultMaxTokens return { model: options.model, @@ -175,7 +172,7 @@ export function serializeRequest( : {}, ...tools !== undefined && tools.length > 0 ? { tools } : {}, ...options.temperature !== undefined ? { temperature: options.temperature } : {}, - ...maxTokens === undefined ? {} : { max_tokens: maxTokens }, + ...options.maxTokens === undefined ? {} : { max_tokens: options.maxTokens }, ...options.stop !== undefined ? { stop: options.stop } : {}, } } diff --git a/packages/llm/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts index 9a296ea8cb..539dec3258 100644 --- a/packages/llm/llm-deepseek/tests/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts @@ -170,13 +170,6 @@ describe('serializeRequest', () => { expect(wire.stop).toEqual(['END']) }) - it('uses the adapter maxTokens default only when the request omits a cap', () => { - expect(serializeRequest(request({ messages: history }), {}, 256_000).max_tokens) - .toBe(256_000) - expect(serializeRequest(request({ messages: history, maxTokens: 8_192 }), {}, 256_000).max_tokens) - .toBe(8_192) - }) - it('maps tools to the wire function shape', () => { const wire = serializeRequest(request({ messages: history, diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 4d637c49cc..033b415d40 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm/README.md -README.md: 3ffcfb59fa7d3e3077a59d0b0c8ebd9afa590e7e -README.zh.md: df44b2bad1d5fa5c03dff86de584bfff63dbf297 +README.md: 2dbd530ca17ef34787cb4195c04ca85d768980b7 +README.zh.md: 9928113f5cbfc49887980fde57ad4ee9f37dbd22 diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 3ffcfb59fa..2dbd530ca1 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -25,7 +25,7 @@ Provider and model metadata is a discovery surface, not a routing whitelist. `re Exact-model metadata is a separate correctness query, not a catalog decoration or global LLM setting. `resolveModelInfo()` asks the adapter that owns the exact provider/model route once; an adapter can describe an unlisted dynamic model, and absent `context`, `defaultMaxTokens`, or `reasoning` fields preserve unknown capacity, provider-owned output defaults, or unavailable reasoning capability. Invalid identity, context, output default, or reasoning metadata fails with `INVALID_MODEL_INFO`, `INVALID_MODEL_CONTEXT`, `INVALID_MODEL_MAX_TOKENS`, or `INVALID_MODEL_REASONING`. -`defaultMaxTokens` is an adapter-configured per-request output cap, not a model hard limit. `resolveCallConfig()` materializes it only when the request omits `maxTokens`; an explicit cap wins. Reasoning identifiers are opaque adapter-owned strings rather than a core enum: the same resolution accepts only an exact advertised identifier, materializes `defaultEffort` when present, and otherwise preserves the provider default. Asynchronous model resolvers receive the caller's signal and must settle promptly after cancellation. `prepareCall()` additionally retains the exact adapter registration through header logging and terminal dispatch, so HMR cannot combine one adapter's capability result with another adapter's request; reusing its one-shot handle or changing its call-config fields fails with `INVALID_PREPARED_CALL`. An unsupported explicit or configured effort fails with `UNSUPPORTED_REASONING_EFFORT` before provider I/O. +`defaultMaxTokens` is an adapter-configured per-request output cap, not a model hard limit. `resolveCallConfig()` materializes it only when the request omits `maxTokens`; an explicit cap wins. Reasoning identifiers are opaque adapter-owned strings rather than a core enum: the same resolution accepts only an exact advertised identifier, materializes `defaultEffort` when present, and otherwise preserves the provider default. Asynchronous model resolvers receive the caller's signal and must settle promptly after cancellation. `prepareCall()` additionally reports which `maxTokens` and `reasoningEffort` fields it materialized in `adapterDefaults` and retains the exact adapter registration through header logging and terminal dispatch, so HMR cannot combine one adapter's capability result with another adapter's request; reusing its one-shot handle or changing its call-config fields fails with `INVALID_PREPARED_CALL`. An unsupported explicit or configured effort fails with `UNSUPPORTED_REASONING_EFFORT` before provider I/O. ### Events @@ -48,7 +48,7 @@ Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta ### Call configuration (`call-config.ts`) -`LlmCallConfig` is the provider, model, optional adapter-owned reasoning effort, and sampling scalars of one conversation's requests (`provider`, `model`, `reasoningEffort`, `temperature`, `maxTokens`, `stop` — each mapping 1:1 onto the same-named `GenerateOptions` field). It is per-conversation state recorded in the session log as part of the request header (see the dsh-session `request/header` events), never a silently-adjustable per-call knob: the `agent/request` waterfall proposes a replacement, `prepareCall()` validates it and materializes adapter defaults under the turn signal, and the loop logs the effective value before using the prepared call's registration-bound stream. `callConfigEquals(a, b)` is the field-wise real-change detector; `deepFreeze(value)` is the ownership helper the loop applies to every built request before dispatch (`llm/stream` listeners and adapters read, never rewrite). `markAgentLoopRequest()` gives that exact object process-local loop provenance, and `isAgentLoopRequest()` lets observers distinguish it from independently logged auxiliary calls that may also be frozen and session-associated. `GenerateOptions.purpose` classifies logged auxiliary compaction and session-title calls so adapters can apply purpose-specific transport policy without changing ordinary conversation requests. +`LlmCallConfig` is the provider, model, optional adapter-owned reasoning effort, and sampling scalars of one conversation's requests (`provider`, `model`, `reasoningEffort`, `temperature`, `maxTokens`, `stop` — each mapping 1:1 onto the same-named `GenerateOptions` field). It is per-conversation state recorded in the session log as part of the request header (see the dsh-session `request/header` events), never a silently-adjustable per-call knob: the `agent/request` waterfall proposes a replacement, `prepareCall()` validates it and materializes adapter defaults under the turn signal, and the loop logs the effective value plus adapter-default provenance before using the prepared call's registration-bound stream. The next proposal omits marked defaults so a changed route resolves its own values; unmarked explicit fields persist. `callConfigEquals(a, b)` is the field-wise real-change detector; `deepFreeze(value)` is the ownership helper the loop applies to every built request before dispatch (`llm/stream` listeners and adapters read, never rewrite). `markAgentLoopRequest()` gives that exact object process-local loop provenance, and `isAgentLoopRequest()` lets observers distinguish it from independently logged auxiliary calls that may also be frozen and session-associated. `GenerateOptions.purpose` classifies logged auxiliary compaction and session-title calls so adapters can apply purpose-specific transport policy without changing ordinary conversation requests. ### App attribution (`attribution.ts`) diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index df44b2bad1..9928113f5c 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -25,7 +25,7 @@ 确切模型元数据是独立的正确性查询,不是 catalog 装饰或全局 LLM 设置。`resolveModelInfo()` 会向拥有精确提供方/模型路由的适配器查询一次;适配器可以描述未列出的动态模型,缺少 `context`、`defaultMaxTokens` 或 `reasoning` 字段会分别保留未知容量、提供方持有的输出默认值或不可用的推理能力。无效的身份、上下文、输出默认值或推理元数据会以 `INVALID_MODEL_INFO`、`INVALID_MODEL_CONTEXT`、`INVALID_MODEL_MAX_TOKENS` 或 `INVALID_MODEL_REASONING` 失败。 -`defaultMaxTokens` 是适配器配置的单次请求输出上限,不是模型硬上限。仅当请求省略 `maxTokens` 时,`resolveCallConfig()` 才会填入该值;显式上限优先。推理标识符是由适配器持有的不透明字符串,而非核心枚举:同一次解析只接受与已公布标识符完全一致的值,在存在 `defaultEffort` 时填入它,否则保留提供方默认值。异步模型解析器会接收调用方的 signal,并且必须在取消后迅速结束。`prepareCall()` 还会让精确适配器注册跨越请求头记录和最终分派,因此 HMR(热模块替换)不会将一个适配器的能力结果与另一个适配器的请求混用;复用其一次性句柄或更改调用配置字段会以 `INVALID_PREPARED_CALL` 失败。不支持的显式或配置推理强度会在提供方 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败。 +`defaultMaxTokens` 是适配器配置的单次请求输出上限,不是模型硬上限。仅当请求省略 `maxTokens` 时,`resolveCallConfig()` 才会填入该值;显式上限优先。推理标识符是由适配器持有的不透明字符串,而非核心枚举:同一次解析只接受与已公布标识符完全一致的值,在存在 `defaultEffort` 时填入它,否则保留提供方默认值。异步模型解析器会接收调用方的 signal,并且必须在取消后迅速结束。`prepareCall()` 还会通过 `adapterDefaults` 报告它填入了哪些 `maxTokens` 和 `reasoningEffort` 字段,并让精确适配器注册跨越请求头记录和最终分派,因此 HMR(热模块替换)不会将一个适配器的能力结果与另一个适配器的请求混用;复用其一次性句柄或更改调用配置字段会以 `INVALID_PREPARED_CALL` 失败。不支持的显式或配置推理强度会在提供方 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败。 ### 事件 @@ -48,7 +48,7 @@ ### 调用配置(`call-config.ts`) -`LlmCallConfig` 是一个会话中各次请求的提供方、模型、可选的适配器持有推理强度和采样标量(`provider`、`model`、`reasoningEffort`、`temperature`、`maxTokens`、`stop`,每个都与同名 `GenerateOptions` 字段 1:1 映射)。它是作为请求标头一部分记录在会话日志中的每会话状态(见 dsh-session `request/header` 事件),绝不是可静默调整的每次调用旋钮:`agent/request` waterfall 会提议替换,`prepareCall()` 在轮次 signal 控制下校验它并填入适配器默认值,loop 随后记录生效值,再使用已准备调用中与注册绑定的流。`callConfigEquals(a, b)` 是逐字段真实变更检测器;`deepFreeze(value)` 是 loop 在 dispatch 前对每个已构建请求应用的所有权 helper(`llm/stream` listener 与适配器只读,绝不改写)。`markAgentLoopRequest()` 为该精确对象添加进程本地 loop 溯源,`isAgentLoopRequest()` 让观测方可以将其与同样可能冻结并关联会话、但独立记录的辅助调用区分。`GenerateOptions.purpose` 对已记录辅助压缩与会话标题调用分类,让适配器可以应用目的特定传输策略,而不改变普通会话请求。 +`LlmCallConfig` 是一个会话中各次请求的提供方、模型、可选的适配器持有推理强度和采样标量(`provider`、`model`、`reasoningEffort`、`temperature`、`maxTokens`、`stop`,每个都与同名 `GenerateOptions` 字段 1:1 映射)。它是作为请求标头一部分记录在会话日志中的每会话状态(见 dsh-session `request/header` 事件),绝不是可静默调整的每次调用旋钮:`agent/request` waterfall 会提议替换,`prepareCall()` 在轮次 signal 控制下校验它并填入适配器默认值,loop 随后记录生效值及适配器默认值来源,再使用已准备调用中与注册绑定的流。下一次提议会省略带标记的默认值,使变更后的路由解析自身的值;未带标记的显式字段会保留。`callConfigEquals(a, b)` 是逐字段真实变更检测器;`deepFreeze(value)` 是 loop 在 dispatch 前对每个已构建请求应用的所有权 helper(`llm/stream` listener 与适配器只读,绝不改写)。`markAgentLoopRequest()` 为该精确对象添加进程本地 loop 溯源,`isAgentLoopRequest()` 让观测方可以将其与同样可能冻结并关联会话、但独立记录的辅助调用区分。`GenerateOptions.purpose` 对已记录辅助压缩与会话标题调用分类,让适配器可以应用目的特定传输策略,而不改变普通会话请求。 ### 应用归因(`attribution.ts`) diff --git a/packages/llm/llm/src/call-config.ts b/packages/llm/llm/src/call-config.ts index c5247af143..2daa6d1a4c 100644 --- a/packages/llm/llm/src/call-config.ts +++ b/packages/llm/llm/src/call-config.ts @@ -27,6 +27,15 @@ export interface LlmCallConfig { stop?: string[] } +/** + * Effective config fields supplied by exact-model adapter resolution rather + * than by the caller's request proposal. + */ +export interface LlmCallConfigAdapterDefaults { + reasoningEffort?: true + maxTokens?: true +} + /** * Field-wise equality over {@link LlmCallConfig} — the comparison a caller * runs to decide whether a proposed configuration is a real change (worth a diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 0d0fea78ca..d6d1257fce 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -20,7 +20,7 @@ import { resolveRetryPolicy } from './retry-policy.ts' import type { ResolvedRetryPolicy } from './retry-policy.ts' import type { ProviderRequestId } from './brand.ts' import { callConfigEquals, deepFreeze } from './call-config.ts' -import type { LlmCallConfig } from './call-config.ts' +import type { LlmCallConfig, LlmCallConfigAdapterDefaults } from './call-config.ts' import { HarnessError } from './error.ts' import { bindAdapterFailureScope, markLlmAdapterFailure } from './adapter-failure.ts' import type { AdapterFailureScope } from './adapter-failure.ts' @@ -34,7 +34,7 @@ export * from './message.ts' export * from './retry-policy.ts' export { BlockAssembler } from './assembler.ts' export { callConfigEquals, deepFreeze, isAgentLoopRequest, markAgentLoopRequest } from './call-config.ts' -export type { LlmCallConfig } from './call-config.ts' +export type { LlmCallConfig, LlmCallConfigAdapterDefaults } from './call-config.ts' export { isLlmAdapterFailure, llmFailureOf, llmRetryPolicyOf } from './adapter-failure.ts' declare module 'cordis' { @@ -113,6 +113,8 @@ export class LlmError extends HarnessError { export interface PreparedLlmCall { /** Detached, deep-frozen config with any adapter-owned default materialized. */ readonly config: LlmCallConfig + /** Config fields materialized by the captured adapter rather than proposed by the caller. */ + readonly adapterDefaults: LlmCallConfigAdapterDefaults /** * Dispatch this call once through the registration captured during * preparation. The request's call-config fields must match {@link config}; @@ -447,12 +449,20 @@ export class LlmService extends Service { */ async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise { const registration = this.registration(config.provider) - const resolvedConfig = deepFreeze(structuredClone( - await this.resolveCallConfigFor(registration, config, signal), - )) + const resolved = await this.resolveCallConfigFor(registration, config, signal) + const resolvedConfig = deepFreeze(structuredClone(resolved)) + const adapterDefaults = deepFreeze({ + ...config.reasoningEffort === undefined && resolved.reasoningEffort !== undefined + ? { reasoningEffort: true } + : {}, + ...config.maxTokens === undefined && resolved.maxTokens !== undefined + ? { maxTokens: true } + : {}, + }) let dispatched = false return Object.freeze({ config: resolvedConfig, + adapterDefaults, stream: (options: GenerateOptions): AsyncIterable => { if (dispatched) { throw new LlmError('a prepared LLM call can only be dispatched once', 'INVALID_PREPARED_CALL') diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index 1140d720ed..986b0965b9 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -906,7 +906,12 @@ describe('LlmService', () => { { id: 'route', name: 'Route' }, [], {}, - { model: source }, + { + model: source, + providerDefault: { + efforts: [{ id: ReasoningEffortId('standard'), name: 'Standard' }], + }, + }, )) const resolved = await ctx.llm.resolveModelInfo('route', 'model') @@ -920,6 +925,8 @@ describe('LlmService', () => { }) const explicit = { provider: 'route', model: 'model', reasoningEffort: ReasoningEffortId('ultra') } await expect(ctx.llm.resolveCallConfig(explicit)).resolves.toBe(explicit) + const providerDefault = { provider: 'route', model: 'providerDefault' } + await expect(ctx.llm.resolveCallConfig(providerDefault)).resolves.toBe(providerDefault) }) it('materializes an adapter-owned maxTokens default while preserving an explicit cap', async () => { @@ -941,8 +948,12 @@ describe('LlmService', () => { model: 'model', maxTokens: 256_000, }) + const preparedDefault = await ctx.llm.prepareCall({ provider: 'route', model: 'model' }) + expect(preparedDefault.adapterDefaults).toEqual({ maxTokens: true }) const explicit = { provider: 'route', model: 'model', maxTokens: 8_192 } await expect(ctx.llm.resolveCallConfig(explicit)).resolves.toBe(explicit) + const preparedExplicit = await ctx.llm.prepareCall(explicit) + expect(preparedExplicit.adapterDefaults).toEqual({}) }) it.each([0, 1.5, Number.MAX_SAFE_INTEGER + 1])( @@ -1106,6 +1117,8 @@ describe('LlmService', () => { ctx.llm.registerAdapter(['route'], adapter) const prepared = await ctx.llm.prepareCall({ provider: 'route', model: 'model' }) expect(Object.isFrozen(prepared.config)).toBe(true) + expect(Object.isFrozen(prepared.adapterDefaults)).toBe(true) + expect(prepared.adapterDefaults).toEqual({ reasoningEffort: true }) const stream = prepared.stream({ ...prepared.config, model: 'other', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index d886b33df2..c52671968d 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -81,6 +81,11 @@ "symbol": "LlmCallConfig", "source": "packages/llm/llm/src/call-config.ts" }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "LlmCallConfigAdapterDefaults", + "source": "packages/llm/llm/src/call-config.ts" + }, { "doc": "docs/core-data-structures/core.md", "symbol": "SessionEvent",