feat(llm): add model-specific reasoning effort controls

This commit is contained in:
Yichen Jiang
2026-07-25 07:47:51 +08:00
parent 65d29da8a1
commit 8372340f9c
50 changed files with 1046 additions and 88 deletions

View File

@@ -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
architecture.md: d1051eecf51d8d1c7f8c235b0c5dd80478b43316
architecture.zh.md: 502c0248a9d2c62af165ce07eb19489b76c5f6ee
architecture.md: 0bc015afcf583224b7162540ae53f2bb19952cd6
architecture.zh.md: 20ed3229e0a8d5997cc982ae5c82fb90f900c948

View File

@@ -91,7 +91,7 @@ forever:
agent/pre-step
snapshot the derived messages (the reconstruction boundary)
'step/start'
agent/request (config only) -> log request/header -> checkpoint -> llm/stream (frozen)
agent/request -> resolve reasoning/default -> log request/header -> checkpoint -> llm/stream (frozen)
on final adapter-path or terminal in-band failure:
'step/end'
agent/request-error(original error, failure facts, immutable prior failures, signal)

View File

@@ -91,7 +91,7 @@ forever:
agent/pre-step
snapshot the derived messages (the reconstruction boundary)
'step/start'
agent/request (config only) -> log request/header -> checkpoint -> llm/stream (frozen)
agent/request -> resolve reasoning/default -> log request/header -> checkpoint -> llm/stream (frozen)
on final adapter-path or terminal in-band failure:
'step/end'
agent/request-error(original error, failure facts, immutable prior failures, signal)

View File

@@ -520,8 +520,9 @@ Requires: `llm`
/**
* Plugin config, validated by the same-named schemastery schema. Every field
* is optional in yml: credentials/endpoint fall back to the environment (a
* missing API key fails plugin load, not the first call), and omitted
* thinking fields send nothing on the wire, so the provider default applies.
* missing API key fails plugin load, not the first call), omitted thinking
* mode uses the provider default, and omitted reasoning effort resolves to
* `high`.
*/
export interface Config {
/** API key; falls back to $DEEPSEEK_API_KEY. Required one way or the other. */
@@ -530,7 +531,7 @@ export interface Config {
baseURL?: string
/** Thinking-mode default for every request (provider default: enabled). */
thinking?: 'enabled' | 'disabled'
/** Thinking effort (only meaningful with thinking enabled). */
/** Default thinking effort when thinking is enabled (default `high`). */
reasoningEffort?: 'high' | 'max'
/** Positive context capacity used when the selected model has no exact value. */
defaultContextWindow?: number
@@ -553,7 +554,7 @@ export interface DeepSeekCatalogModel {
}
```
Source: [`packages/llm/llm-deepseek/src/index.ts:34`](../packages/llm/llm-deepseek/src/index.ts)
Source: [`packages/llm/llm-deepseek/src/index.ts:35`](../packages/llm/llm-deepseek/src/index.ts)
## `@deepseek-ai/dsh-llm-pi-ai`

View File

@@ -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
adding-an-llm-adapter.md: f20442b8c2ce823452a3ea13409f202185d12d04
adding-an-llm-adapter.zh.md: 2864dd1e18742c7449e24f22504a5a38976ab450
adding-an-llm-adapter.md: 3adae89f360cd81e264912d74bd86f5a06cd42f8
adding-an-llm-adapter.zh.md: 80329da5e7c89bfb5ee2e2aa5b9931a3ef0e83f6

View File

@@ -32,7 +32,7 @@ Registration is effect-based (HMR-safe); one adapter per provider route — dupl
- A `GenerateOptions` field your provider cannot honor (e.g. a `stop` list on a provider without stop sequences): throw `LlmError(..., 'UNSUPPORTED')` rather than silently dropping it.
- If the provider requires response ids, signatures, or other native metadata on follow-up calls, emit the minimal lossless-JSON projection as `finish.replayState`. Validate it when rebuilding history. `LlmService` passes it only when the historical provider route and target provider route are currently owned by the exact same adapter instance; your adapter decides whether same-model, cross-model, or cross-provider restoration is legal. Never infer native replay from provider/model names alone when state is absent.
Provider-specific request knobs (thinking modes, effort levels) belong in the ADAPTER's Config, not in `GenerateOptions` the core vocabulary stays provider-neutral.
Provider-specific thinking-mode toggles remain in the adapter's Config. Selectable reasoning strength uses the provider-neutral capability seam: return ordered opaque ids from `resolveModelReasoning()`, declare a configured `defaultEffort` only when one exists, and map `GenerateOptions.reasoningEffort` to the provider wire value. Do not expose provider wire spellings, clamp unsupported values, or include an `off` mode as an effort.
## Structure that worked

View File

@@ -32,7 +32,7 @@ export function apply(ctx: Context, config: Config) {
- 如果 `GenerateOptions` 中某个字段你的提供方无法支持(例如提供方不支持 stop sequences 时收到 `stop` 列表):抛出 `LlmError(..., 'UNSUPPORTED')`,而非静默丢弃。
- 如果提供方在后续调用中需要响应 ID、签名或其他原生元数据请将其最小无损 JSON 投影作为 `finish.replayState` 发出。重建历史时验证该状态。只有历史提供方路由和目标提供方路由当前由完全相同的适配器实例拥有时,`LlmService` 才会传递该状态;由适配器决定同模型、跨模型或跨提供方恢复是否合法。状态缺失时,切勿仅根据提供方/模型名称推断原生回放。
提供方特有的请求旋钮(thinking 模式、effort 级别)放在**适配器**的 Config 中,而非 `GenerateOptions` 中——核心词汇保持提供方无关
提供方特有的 thinking 模式开关仍放在适配器的 Config 中。可选的推理强度使用提供方无关的能力 seam`resolveModelReasoning()` 返回有序的不透明 ID仅当存在配置指定的默认值时才声明 `defaultEffort`;并将 `GenerateOptions.reasoningEffort` 映射为提供方协议值。不得暴露提供方协议值的具体拼写、自动调整不支持的值,也不得把 `off` 模式列为推理强度
## 经验证有效的结构

View File

@@ -547,7 +547,7 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t
Types: [GenerateOptions](../core-data-structures/core.md) · [LlmService](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md)
Source: [`packages/llm/llm/src/index.ts:52`](../../packages/llm/llm/src/index.ts)
Source: [`packages/llm/llm/src/index.ts:54`](../../packages/llm/llm/src/index.ts)
## `session/*`

View File

@@ -671,6 +671,25 @@ async listModels(provider: string): Promise<LlmModelInfo[]>
*/
async resolveModelContext( provider: string, model: string, ): Promise<LlmModelContext | undefined>
/**
* Resolve selectable reasoning efforts from the adapter that owns one exact
* route. Metadata is validated and detached; an absent result means an
* effort selector is unsupported for that model.
* @param provider - registered provider route to inspect.
* @param model - exact model id passed to the adapter.
* @returns detached reasoning metadata, or `undefined` when unsupported.
*/
async resolveModelReasoning( provider: string, model: string, ): Promise<LlmModelReasoningInfo | undefined>
/**
* Validate a conversation call config against its exact model capability and
* materialize an adapter-configured default. Unsupported explicit efforts
* reject before provider I/O; no clamping or aliasing is performed.
* @param config - provider/model route and optional request controls.
* @returns a detached config only when a default must be materialized.
*/
async resolveCallConfig(config: LlmCallConfig): Promise<LlmCallConfig>
/**
* Stream one model call as raw chunks (token-level deltas). Throws
* `LlmError` with code `NO_ADAPTER` if no adapter is registered for
@@ -686,9 +705,9 @@ async resolveModelContext( provider: string, model: string, ): Promise<LlmModelC
stream(options: GenerateOptions): AsyncIterable<StreamChunk>
```
Types: [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmModelContext](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md)
Types: [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmModelContext](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmModelReasoningInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md)
Source: [`packages/llm/llm/src/index.ts:159`](../../packages/llm/llm/src/index.ts)
Source: [`packages/llm/llm/src/index.ts:175`](../../packages/llm/llm/src/index.ts)
## `ctx.permission` — `PermissionService`
@@ -1238,7 +1257,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId):
Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [OutOfBandSessionEventType](../core-data-structures/session.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) · [SessionEventMap](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) · [TurnTrigger](../core-data-structures/session.md)
Source: [`packages/core/session/src/index.ts:605`](../../packages/core/session/src/index.ts)
Source: [`packages/core/session/src/index.ts:610`](../../packages/core/session/src/index.ts)
## `ctx.sessionTitle` — `SessionTitleService`

View File

@@ -205,12 +205,46 @@ interface LlmModelContext {
}
```
Reasoning effort is another exact-route capability. The core brands identifiers but does not enumerate their values; each adapter owns the ordered set, display names, and optional deployment default.
```ts type-equiv
/** Adapter-owned identifier for one model's selectable reasoning effort. */
type ReasoningEffortId = Branded<'ReasoningEffortId'>
```
```ts type-equiv
/** Display metadata for one adapter-owned reasoning effort. */
interface LlmReasoningEffortInfo {
/** Opaque stable value accepted by {@link GenerateOptions.reasoningEffort}. */
id: ReasoningEffortId
/** Human-readable effort name for selectors and diagnostics. */
name: string
/** Optional user-facing distinction from otherwise similar efforts. */
description?: string
}
```
```ts type-equiv
/** Selectable reasoning efforts for one exact provider/model route. */
interface LlmModelReasoningInfo {
/** Supported efforts in adapter-preferred display order. */
efforts: readonly LlmReasoningEffortInfo[]
/**
* Adapter-configured default materialized into requests when callers omit
* an effort. Absence preserves the provider's own default.
*/
defaultEffort?: ReasoningEffortId
}
```
```ts type-equiv
/** A single model request, fully assembled. */
interface GenerateOptions {
/** Registered provider route selecting the adapter instance. */
provider: string
model: string
/** Adapter-owned reasoning effort selected for this exact model. */
reasoningEffort?: ReasoningEffortId
/**
* Ordered conversation messages, exactly as the provider sees them (after
* the `system` slot). A loop-built request assembles them as
@@ -287,21 +321,23 @@ The model-facing `ToolSchema` is the wire shape; the registered `ToolDefinition`
The loop builds each request from logged state. `EpochHeader` records call config, rendered prompt, authoritative returned tool order (configured by `toolOrder`, or lexicographic when unset), and session prefix 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, or sampling. `agent/session-prefix` composes request-only prefix messages once per loop instance, and the header records the exact result used. 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. The loop resolves the exact model capability after the waterfall, rejects unsupported explicit effort ids without clamping, materializes an adapter-configured default, and logs the effective value. `agent/session-prefix` composes request-only prefix messages once per loop instance, and the header records the exact result used. 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 in this order: the `system` slot (the rendered prompt assembly) → `messagePrefix` (the frozen session prefix) → 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 prefix never enters the derived history; its durable record is the header events, and the dev invariant recomputes exactly this equation against every loop-built request.
FIXME(call-config-shape): revisit the exact definition of this type — which fields are genuinely epoch-level for cache purposes (`model` certainly; the sampling scalars sit here out of caution), and where provider-specific extras (reasoning options, extra body params) belong when an adapter needs them.
FIXME(call-config-shape): revisit which remaining fields are genuinely epoch-level for cache purposes (`model` and the model-owned reasoning effort are explicit; the sampling scalars sit here out of caution).
```ts type-equiv
/**
* Provider + model + sampling scalars of one conversation's requests. Every field maps
* 1:1 onto the same-named `GenerateOptions` field; the loop builds requests
* from the logged header rather than accepting these per call.
* Provider, model, reasoning effort, and sampling scalars of one conversation's
* requests. Every field maps 1:1 onto the same-named `GenerateOptions` field;
* the loop builds requests from the logged header rather than accepting these
* per call.
*/
interface LlmCallConfig {
provider: string
model: string
reasoningEffort?: ReasoningEffortId
temperature?: number
maxTokens?: number
stop?: string[]

View File

@@ -154,7 +154,7 @@ declare class BlockAssembler {
## The seam
`LlmAdapter` is the provider seam: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerInfo()` and asynchronous `listModels()` methods feed `LlmService.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. The separate `resolveModelContext()` query exposes correctness-sensitive capacity for an exact route without making catalog membership authoritative; absence means unknown metadata, not invalid routing. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. The consumer surface (`ctx.llm.stream()`) and the `llm/stream` waterfall are described in [architecture.md § Content blocks and streaming](../architecture.md#content-blocks-and-streaming-dsh-llm).
`LlmAdapter` is the provider seam: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerInfo()` and asynchronous `listModels()` methods feed `LlmService.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. The separate `resolveModelContext()` query exposes correctness-sensitive capacity, while `resolveModelReasoning()` exposes ordered model-owned effort ids and an optional deployment default; absence from either query means unavailable metadata or capability, not invalid catalog membership. The service validates and materializes reasoning through `resolveCallConfig()` at the final adapter boundary, so direct calls cannot bypass unsupported-effort rejection. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. The consumer surface (`ctx.llm.stream()`) and the `llm/stream` waterfall are described in [architecture.md § Content blocks and streaming](../architecture.md#content-blocks-and-streaming-dsh-llm).
```ts public-api
/**
@@ -189,6 +189,17 @@ declare abstract class LlmAdapter {
_provider: string,
_model: string,
): Promise<LlmModelContext | undefined>;
/**
* Resolve selectable reasoning efforts for one exact model. Absence means
* the model has no selectable reasoning-effort capability.
* @param _provider - one provider route owned by this adapter.
* @param _model - exact model id passed to {@link GenerateOptions.model}.
* @returns adapter-owned effort metadata, or `undefined` when unsupported.
*/
resolveModelReasoning(
_provider: string,
_model: string,
): Promise<LlmModelReasoningInfo | undefined>;
/**
* Stream one model call as raw chunks. The only required method.
* @param options - the fully-assembled request; implementations must honor `options.signal`.

View File

@@ -167,7 +167,7 @@ The request envelope — the `EpochHeader` (call config + rendered system prompt
* canonical empty optional fields are absent.
*/
interface EpochHeader {
/** The conversation's call configuration (provider, model, and sampling scalars). */
/** The conversation's call configuration (provider, model, reasoning effort, and sampling scalars). */
config: LlmCallConfig
/** Rendered system prompt text; absent for a system-less request. */
system?: string

View File

@@ -30,7 +30,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) |
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
| `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:167`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:52`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:54`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:89`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `runtime`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |

View File

@@ -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
llm-adapter.md: 3e83289b8072ef231f83c0fa3cfe3260547b42fa
llm-adapter.zh.md: 92fcf9b22f4bb356ada4c46f9a03ef0cc2d159da
llm-adapter.md: f133a6ea748fd7d9bfa6a7adaa35b1cccbe40c8e
llm-adapter.zh.md: 3a4fd9a516126d1f9fa8675fdc87be10c85f3489

View File

@@ -110,7 +110,9 @@ async function* exampleChunks(): AsyncIterable<StreamChunk> {
## GenerateOptions
`stream()` receives the exported `GenerateOptions` type. It includes the model, conversation history, system prompt, tool schemas, generation parameters, stop sequences, and abort signal; treat the TypeScript type exported by `@deepseek-ai/dsh-llm` as authoritative. Map supported fields to the provider API. If the provider cannot honor a field, throw `LlmError` with a stable code instead of silently dropping it.
`stream()` receives the exported `GenerateOptions` type. It includes the model, adapter-owned reasoning-effort id, conversation history, system prompt, tool schemas, generation parameters, stop sequences, and abort signal; treat the TypeScript type exported by `@deepseek-ai/dsh-llm` as authoritative. Map supported fields to the provider API. If the provider cannot honor a field, throw `LlmError` with a stable code instead of silently dropping it.
Override `resolveModelReasoning(provider, model)` when an exact model exposes selectable reasoning strengths. Return ordered opaque ids and display names plus an optional configured default; do not promote provider names into a core enum. The service validates the metadata and rejects unsupported explicit values before `stream()`. Returning `undefined` means that model has no selectable reasoning-effort capability.
## Register an adapter

View File

@@ -110,7 +110,9 @@ async function* exampleChunks(): AsyncIterable<StreamChunk> {
## GenerateOptions
`stream()` 接收仓库导出的 `GenerateOptions`。它包含模型名、对话历史、系统提示词、tool schema、生成参数、停止序列和中止信号完整字段以 `@deepseek-ai/dsh-llm` 导出的 TypeScript 类型为准。适配器必须将支持的字段映射到具体 API无法支持的字段应抛出带稳定 code 的 `LlmError`,不能静默丢弃。
`stream()` 接收仓库导出的 `GenerateOptions`。它包含模型名、由适配器持有的推理强度 ID、对话历史、系统提示词、tool schema、生成参数、停止序列和中止信号完整字段以 `@deepseek-ai/dsh-llm` 导出的 TypeScript 类型为准。适配器必须将支持的字段映射到具体 API无法支持的字段应抛出带稳定 code 的 `LlmError`,不能静默丢弃。
当某个具体模型提供可选推理强度时,请覆写 `resolveModelReasoning(provider, model)`。返回有序的不透明 ID、展示名称以及可选的配置默认值不要将提供方使用的等级名称提升为核心枚举。服务会校验元数据并在调用 `stream()` 前拒绝显式指定但不受支持的值。返回 `undefined` 表示该模型没有可选的推理强度能力。
## 注册适配器