Merge remote-tracking branch 'origin/master' into worktree/web-session-model-selector

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md
#	.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.zh.md
#	packages/llm/llm-deepseek/README.i18n.yaml
#	packages/llm/llm-deepseek/tests/adapter.spec.ts
This commit is contained in:
Yichen Jiang
2026-07-27 15:49:09 +08:00
106 changed files with 2594 additions and 491 deletions

View File

@@ -272,7 +272,7 @@ export class BasicCompactService extends CompactService {
return this.compactRegion(range.start, range.end, agent, signal)
}
const context = await this.ctx.llm.resolveModelContext(target.provider, target.model)
const context = (await this.ctx.llm.resolveModelInfo(target.provider, target.model, signal)).context
const targetKey = `${target.provider}/${target.model}`
if (context === undefined) {
throw new TargetPressureConfigError(

View File

@@ -16,7 +16,7 @@ import type {
ContentBlock,
GenerateOptions,
LlmFailure,
LlmModelContext,
LlmResolvedModelInfo,
Message,
StreamChunk,
} from '@deepseek-ai/dsh-llm'
@@ -33,8 +33,13 @@ class ContextAdapter extends LlmAdapter {
super()
}
override resolveModelContext(): Promise<LlmModelContext> {
return Promise.resolve({ contextWindow: this.contextWindow })
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
return Promise.resolve({
provider,
id: model,
name: model,
context: { contextWindow: this.contextWindow },
})
}
override async * stream(): AsyncIterable<StreamChunk> {
@@ -47,9 +52,14 @@ class RoutedContextAdapter extends LlmAdapter {
super()
}
override resolveModelContext(provider: string): Promise<LlmModelContext | undefined> {
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
const contextWindow = this.windows[provider]
return Promise.resolve(contextWindow === undefined ? undefined : { contextWindow })
return Promise.resolve({
provider,
id: model,
name: model,
...contextWindow === undefined ? {} : { context: { contextWindow } },
})
}
override async * stream(): AsyncIterable<StreamChunk> {
@@ -453,6 +463,18 @@ describe('pressure measurement and retention', () => {
.resolves.not.toBeNull()
})
it('forwards turn cancellation to proactive model metadata resolution', async () => {
const ctx = createContext()
const resolveModelInfo = vi.spyOn(ctx.llm, 'resolveModelInfo')
const compact = service(compactConfig, ctx)
const session = conversation()
const signal = new AbortController().signal
await expect(compact.compactIfNeeded(agent(session, MODEL), 'pressure', signal))
.resolves.not.toBeNull()
expect(resolveModelInfo).toHaveBeenCalledWith(MODEL, MODEL, signal)
})
it('re-resolves capacity after a same-model-id provider switch in one session', async () => {
const ctx = new Context()
void new LlmService(ctx)
@@ -485,7 +507,11 @@ describe('pressure measurement and retention', () => {
void new LlmService(ctx)
void new TokenMeterService(ctx)
ctx.llm.registerAdapter(['unknown-context'], new ContextAdapter(1_000))
vi.spyOn(ctx.llm, 'resolveModelContext').mockResolvedValue(undefined)
vi.spyOn(ctx.llm, 'resolveModelInfo').mockImplementation((provider, model) => Promise.resolve({
provider,
id: model,
name: model,
}))
const compact = service(compactConfig, ctx)
const session = conversation(4)
session.append('request/header', {
@@ -1337,7 +1363,11 @@ describe('automatic listener and loader composition', () => {
const ctx = createContext()
const warnings: string[] = []
ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn
vi.spyOn(ctx.llm, 'resolveModelContext').mockResolvedValue(undefined)
vi.spyOn(ctx.llm, 'resolveModelInfo').mockImplementation((provider, model) => Promise.resolve({
provider,
id: model,
name: model,
}))
void new TestCompactService(ctx, {
thresholdRatio: 0.5,
retainTokens: 180,

View File

@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact'
import { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import type { Agent } from '@deepseek-ai/dsh-agent'
@@ -41,8 +41,13 @@ class StepwiseToolAdapter extends LlmAdapter {
super()
}
override resolveModelContext(): Promise<{ contextWindow: number }> {
return Promise.resolve({ contextWindow: 400 })
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
return Promise.resolve({
provider,
id: model,
name: model,
context: { contextWindow: 400 },
})
}
async * stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
@@ -76,8 +81,13 @@ class OverflowRecoveryAdapter extends LlmAdapter {
super()
}
override resolveModelContext(): Promise<{ contextWindow: number }> {
return Promise.resolve({ contextWindow: 128 })
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
return Promise.resolve({
provider,
id: model,
name: model,
context: { contextWindow: 128 },
})
}
override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {

View File

@@ -381,12 +381,20 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
jsDoc: '/**\n * Discover models advertised by one registered provider. Catalog membership\n * is advisory and never changes routing or request validation.\n * @param provider - registered provider route to inspect.\n * @returns detached model metadata in adapter-preferred order.\n */',
},
{
signature: 'async resolveModelContext( provider: string, model: string, ): Promise<LlmModelContext | undefined>',
jsDoc: '/**\n * Resolve context capacity from the adapter that owns one exact route.\n * This query is independent of the advisory model catalog: an unlisted model\n * may return metadata, while `undefined` never rejects later routing.\n * @param provider - registered provider route to inspect.\n * @param model - exact model id passed to the adapter.\n * @returns detached context metadata, or `undefined` when the adapter has none.\n */',
signature: 'async resolveModelInfo( provider: string, model: string, signal?: AbortSignal, ): Promise<LlmResolvedModelInfo>',
jsDoc: '/**\n * Resolve and validate all metadata from the adapter that owns one exact\n * route. The result is detached from adapter-owned objects; catalog\n * membership remains advisory and does not control request routing.\n * @param provider - registered provider route to inspect.\n * @param model - exact model id passed to the adapter.\n * @param signal - optional cancellation for adapter-owned asynchronous lookup.\n * @returns exact model identity plus available context and reasoning metadata.\n */',
},
{
signature: 'async resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise<LlmCallConfig>',
jsDoc: '/**\n * Validate a conversation call config against its exact model capability and\n * materialize an adapter-configured default. Unsupported explicit efforts\n * reject before provider I/O; no clamping or aliasing is performed. This\n * standalone query does not bind a later dispatch; use {@link prepareCall}\n * when logging and streaming must share one adapter registration.\n * @param config - provider/model route and optional request controls.\n * @param signal - optional cancellation for adapter-owned capability lookup.\n * @returns a detached config only when a default must be materialized.\n */',
},
{
signature: 'async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall>',
jsDoc: '/**\n * Resolve one call under its current adapter registration. The returned\n * one-shot handle keeps that registration across header logging and dispatch,\n * so HMR cannot combine one adapter\'s capability result with another adapter.\n * @param config - provider/model route and optional request controls.\n * @param signal - optional cancellation for adapter-owned capability lookup.\n * @returns a prepared config and its registration-bound stream entry point.\n */',
},
{
signature: 'stream(options: GenerateOptions): AsyncIterable<StreamChunk>',
jsDoc: '/**\n * Stream one model call as raw chunks (token-level deltas). Throws\n * `LlmError` with code `NO_ADAPTER` if no adapter is registered for\n * `options.provider`. Replay state is retained only when the same adapter\n * instance owns its historical provider and the target provider. Final\n * adapter selection, dispatch, and iteration failures retain their original\n * Error identity and are tagged in a call-local scope for narrow agent-loop\n * request recovery; middleware and nested-call failures remain untagged for\n * the outer call.\n * @param options - the full request; `options.provider` selects the adapter.\n * @returns the chunk stream, possibly wrapped by `llm/stream` listeners.\n */',
jsDoc: '/**\n * Stream one model call as raw chunks (token-level deltas). Throws\n * `LlmError` with code `NO_ADAPTER` if no adapter is registered for\n * `options.provider`. Replay state is retained only when the same adapter\n * instance owns its historical provider and the target provider. Final\n * adapter selection remains fixed through asynchronous exact-model resolution\n * and dispatch. Selection, dispatch, and iteration failures retain their\n * original Error identity and are tagged in a call-local scope for narrow\n * agent-loop request recovery; middleware and nested-call failures remain\n * untagged for the outer call.\n * @param options - the full request; `options.provider` selects the adapter.\n * @returns the chunk stream, possibly wrapped by `llm/stream` listeners.\n */',
},
],
},
@@ -1653,7 +1661,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'GenerateOptions',
declaration: 'export interface GenerateOptions {\n provider: string;\n model: string;\n messages: Message[];\n system?: string;\n tools?: ToolSchema[];\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n signal?: AbortSignal;\n sessionId?: Branded<\'SessionId\'>;\n purpose?: \'compaction\' | \'session-title\';\n}',
declaration: 'export interface GenerateOptions {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n messages: Message[];\n system?: string;\n tools?: ToolSchema[];\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n signal?: AbortSignal;\n sessionId?: Branded<\'SessionId\'>;\n purpose?: \'compaction\' | \'session-title\';\n}',
},
{
name: 'GenericCallView',
@@ -1733,11 +1741,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'LlmAdapter',
declaration: 'export abstract class LlmAdapter {\n providerInfo(provider: string): LlmProviderInfo;\n listModels(_provider: string): Promise<readonly LlmModelInfo[]>;\n resolveModelContext(_provider: string, _model: string): Promise<LlmModelContext | undefined>;\n abstract stream(options: GenerateOptions): AsyncIterable<StreamChunk>;\n}',
declaration: 'export abstract class LlmAdapter {\n providerInfo(provider: string): LlmProviderInfo;\n listModels(_provider: string): Promise<readonly LlmModelInfo[]>;\n resolveModel(provider: string, model: string, _signal?: AbortSignal): Promise<LlmResolvedModelInfo>;\n abstract stream(options: GenerateOptions): AsyncIterable<StreamChunk>;\n}',
},
{
name: 'LlmCallConfig',
declaration: 'export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n}',
declaration: 'export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n}',
},
{
name: 'LlmFailure',
@@ -1751,10 +1759,22 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'LlmModelInfo',
declaration: 'export interface LlmModelInfo {\n provider: string;\n id: string;\n name: string;\n description?: string;\n}',
},
{
name: 'LlmModelReasoningInfo',
declaration: 'export interface LlmModelReasoningInfo {\n efforts: readonly LlmReasoningEffortInfo[];\n defaultEffort?: ReasoningEffortId;\n}',
},
{
name: 'LlmProviderInfo',
declaration: 'export interface LlmProviderInfo {\n id: string;\n name: string;\n}',
},
{
name: 'LlmReasoningEffortInfo',
declaration: 'export interface LlmReasoningEffortInfo {\n id: ReasoningEffortId;\n name: string;\n description?: string;\n}',
},
{
name: 'LlmResolvedModelInfo',
declaration: 'export interface LlmResolvedModelInfo extends LlmModelInfo {\n context?: LlmModelContext;\n reasoning?: LlmModelReasoningInfo;\n}',
},
{
name: 'Message',
declaration: 'export interface Message {\n role: \'system\' | \'user\' | \'assistant\';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n}',
@@ -1779,6 +1799,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'OutOfBandSessionEventType',
declaration: 'export type OutOfBandSessionEventType = Exclude<Extract<SessionEventType, keyof OutOfBandSessionEventMap>, SurfaceEventType>;',
},
{
name: 'PreparedLlmCall',
declaration: 'export interface PreparedLlmCall {\n readonly config: LlmCallConfig;\n stream(options: GenerateOptions): AsyncIterable<StreamChunk>;\n}',
},
{
name: 'PreparedReferencedMessage',
declaration: 'export interface PreparedReferencedMessage {\n content: ContentBlock[];\n contexts: HookContext[];\n}',
@@ -1899,6 +1923,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'ReasoningBlock',
declaration: 'export interface ReasoningBlock {\n type: \'reasoning\';\n text: string;\n}',
},
{
name: 'ReasoningEffortId',
declaration: 'export type ReasoningEffortId = Branded<\'ReasoningEffortId\'>;',
},
{
name: 'RequestHeaderReason',
declaration: 'export type RequestHeaderReason = \'initial\' | \'resume\' | \'change\';',

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
README.md: 3acf4d3828291d5f318306f2652e0d920c695675
README.zh.md: 11ff8318813b1abd096e1a3549d389cbba88f12b
# pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md
README.md: 6ad982ffff7b73e16ee39f9c29da787e37547de4
README.zh.md: c72df198774f0f8009cc5ab43932e69187757745

View File

@@ -62,6 +62,8 @@ 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. A successful `agent/step-result` stores its transformed content; a rejected result records empty content before the original failure continues. The anchor retains exact chunk provenance (`[]` for a stream with no chunks) and usage when available, while 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.
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 `agent/post-step` remain ordinary turn failures. Recovery receives the exact live error, immutable provider facts, and immutable prior failures after the failed step closes. A retry rebuilds from the durable log in a new numbered step, success clears the consecutive history, and exhaustion records the structured failure once on `turn/end`. AgentLoop privately owns one cancellation holder whose explicit signal spans prompt policy, assembly, every step, model and tool work, recovery, continuation, and terminal stop; it retires the holder immediately before publishing `turn/end`, while the driver may remain `running` through the durability flush. An effective `cancel()` emits the typed runtime-only `user | parent` cause before clearing pending work and cooperatively aborting the holder; notification failures cannot veto cancellation, work queued by a notification observer is cleared, work queued by a later abort observer belongs to the next turn, and idle cancellation emits nothing. Durable `turn/end` remains coarse `aborted`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. Disposal wins terminal classification, and work that ignores the signal must settle before quiescence. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract. Terminal continuation stops remain authoritative through turn close and durability flush.
Within a step, exclusive calls form barriers; parallel-safe calls use a bounded rolling pool and are reclassified before start. Only dispatch/body overlaps. Policy, durable results, and result context remain model-ordered. Abort stops new calls, drains started results, then drains accepted batch context before the turn closes through the normal abort path.

View File

@@ -62,6 +62,8 @@ interface Config {
每次提供方调用成功结束时,都会恰好追加一个 `assistant/message` 完成锚点,包括无内容调用和以 `max-tokens` 结束的调用。成功的 `agent/step-result` 存储其转换后内容;被拒绝的结果会先记录空内容,再继续抛出原始失败。该锚点保留确切的 chunk 溯源(流没有 chunk 时为 `[]`),并在用量可用时保留用量;空内容不会进入派生消息历史。
`agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`在活跃轮次信号的控制下校验由适配器持有的推理reasoning强度并填入其配置默认值。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR热模块替换不会把某个适配器的能力解析结果与另一适配器的请求混用。生效配置会在分派前写入日志因此监听器可以在步骤之间更改推理强度而不会产生未记录的请求变化。没有已注册适配器的路由会保留原定配置使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例仅在初始提供方/模型路由与日志路由完全一致时恢复上次的推理强度;路由变化会丢弃由前一模型持有的不透明 ID并单独解析新模型。
插件失败会结束当前轮次,而不是结束循环。只有最终适配器分发/迭代失败,以及带内的终止错误或中止结束原因,才进入 `agent/request-error`;中间件、结果处理、工具和 `agent/post-step` 仍属于普通轮次失败。失败步骤关闭后,恢复逻辑会接收确切的实时错误、不可变的提供方事实和不可变的先前失败。重试会在新的编号步骤中根据持久日志重建;成功会清除连续失败历史;耗尽后只在 `turn/end` 上记录一次结构化失败。AgentLoop 私下拥有一个取消持有者其显式信号覆盖提示词策略、组装、每个步骤、模型与工具工作、恢复、continuation 和终止停止;它会在发布 `turn/end` 前立即退役该持有者,而驱动器可以在持久性 flush 期间继续保持 `running`。有效的 `cancel()` 会先发出仅存在于运行时的类型化 `user | parent` 原因,再清除待处理工作,并以协作方式中止该持有者;通知失败无法 veto 取消,通知观察方排队的工作会被清除,之后由中止观察方排队的工作属于下一轮次,空闲取消则不发出任何内容。持久 `turn/end` 仍使用粗粒度的 `aborted`;未分发的模型工具调用会收到合成的 `tool/call``ABORTED_BEFORE_DISPATCH` 结果对。释放会在终止分类中胜出;忽略信号的工作必须先结算,系统才能完全停稳。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)规定生命周期与竞态契约。终止 continuation 的停止决定在轮次关闭和持久性 flush 期间始终具有权威性。
在步骤内独占调用形成屏障并行安全调用使用有界滚动池并在启动前重新分类。只有分发主体会重叠。策略、持久结果和结果上下文仍保持模型顺序。中止会停止新调用、drain 已启动的结果然后在轮次通过普通中止路径关闭前drain 已接纳的批次上下文。

View File

@@ -7,7 +7,7 @@
import { randomUUID } from 'node:crypto'
import type { Context } from 'cordis'
import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, LlmFailure, Message } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, LlmFailure, Message, PreparedLlmCall } from '@deepseek-ai/dsh-llm'
import { isDeepStrictEqual } from 'node:util'
import { BlockAssembler, HarnessError, LlmError, assertNever, deepFreeze, errorChain, llmFailureOf, markAgentLoopRequest } from '@deepseek-ai/dsh-llm'
import { agentEvents, agentInterruptReasonOf, assembleContextFor, AgentMessageId } from '@deepseek-ai/dsh-agent'
@@ -661,19 +661,45 @@ async function runStep(
// Seed the first request from agent options and later requests from the logged header;
// detach and freeze so listeners must return an attributable replacement.
const seedConfig: LlmCallConfig = deepFreeze(structuredClone(transmission.loggedHeader
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- loggedHeader ⟹ a snapshot is in the log
? session.requestHeader()!.config
: { provider: options.provider ?? '', model: options.model ?? '' }))
const loggedConfig = session.requestHeader()?.config
const initialProvider = options.provider ?? ''
const initialModel = options.model ?? ''
const initialConfig: LlmCallConfig = {
provider: initialProvider,
model: initialModel,
...loggedConfig?.provider === initialProvider
&& loggedConfig.model === initialModel
&& loggedConfig.reasoningEffort !== undefined
? { reasoningEffort: loggedConfig.reasoningEffort }
: {},
}
const seedConfig: LlmCallConfig = deepFreeze(structuredClone(
transmission.loggedHeader
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- loggedHeader ⟹ a snapshot is in the log
? session.requestHeader()!.config
: initialConfig,
))
// Listener replacements are recorded in the request header before dispatch.
const config = await events.waterfall(
const proposedConfig = await events.waterfall(
'agent/request', turn, step, seedConfig, signal, () => Promise.resolve(seedConfig),
)
interruptionCheckpoint(signal)
if (!config.provider || !config.model) {
if (!proposedConfig.provider || !proposedConfig.model) {
throw new Error(`agent "${agent.id}" has no provider/model: set AgentOptions.provider and AgentOptions.model or supply both via the agent/request waterfall`)
}
let config: LlmCallConfig
let preparedCall: PreparedLlmCall | undefined
try {
preparedCall = await ctx.llm.prepareCall(proposedConfig, signal)
config = preparedCall.config
} catch (error: unknown) {
// A waterfall listener may own and short-circuit a route with no adapter.
// Terminal dispatch still raises NO_ADAPTER when no listener handles it.
if (!(error instanceof LlmError) || error.code !== 'NO_ADAPTER') throw error
config = proposedConfig
}
interruptionCheckpoint(signal)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- runTurn composes the prefix before every runStep call
const sessionPrefix = transmission.sessionPrefix!
@@ -691,6 +717,9 @@ async function runStep(
const request: GenerateOptions = markAgentLoopRequest(deepFreeze({
provider: header.config.provider,
model: header.config.model,
...header.config.reasoningEffort !== undefined
? { reasoningEffort: header.config.reasoningEffort }
: {},
messages: [...header.messagePrefix ?? [], ...boundaryMessages],
...header.system !== undefined ? { system: header.system } : {},
...header.tools !== undefined ? { tools: header.tools } : {},
@@ -704,7 +733,7 @@ async function runStep(
// --- Model call (streaming-first; raw chunks are the replay record) ---
const assembler = new BlockAssembler()
const chunkSeqs: number[] = []
const stream = ctx.llm.stream(request)
const stream = preparedCall?.stream(request) ?? ctx.llm.stream(request)
try {
for await (const chunk of stream) {
interruptionCheckpoint(signal)

View File

@@ -1,4 +1,4 @@
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, LlmModelReasoningInfo, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
/** Helpers to write scripted responses tersely. */
@@ -64,10 +64,25 @@ export function toolCallResponse(rawCallId: string, name: string, args: object,
export class MockAdapter extends LlmAdapter {
requests: GenerateOptions[] = []
constructor(private script: (StreamChunk[] | ((options: GenerateOptions) => StreamChunk[]) | 'hang')[]) {
constructor(
private script: (StreamChunk[] | ((options: GenerateOptions) => StreamChunk[]) | 'hang')[],
private readonly reasoning?: LlmModelReasoningInfo,
) {
super()
}
override resolveModel(
provider: string,
model: string,
): Promise<LlmResolvedModelInfo> {
return Promise.resolve({
provider,
id: model,
name: model,
...this.reasoning === undefined ? {} : { reasoning: this.reasoning },
})
}
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
this.requests.push(options)
const entry = this.script.shift()

View File

@@ -7,8 +7,8 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
import LlmService, { LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, LlmModelReasoningInfo, LlmResolvedModelInfo } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
@@ -104,6 +104,168 @@ describe('request stability across the loop', () => {
expectPrefixExtension(adapter.requests[0]!, adapter.requests[1]!)
})
it('logs adapter defaults, supports per-turn effort changes, and restores the effective value', async () => {
const reasoning = {
efforts: [
{ id: ReasoningEffortId('high'), name: 'High' },
{ id: ReasoningEffortId('max'), name: 'Max' },
],
defaultEffort: ReasoningEffortId('high'),
}
const adapter = new MockAdapter([textResponse('one'), textResponse('two')], reasoning)
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('effort'), { provider: 'mock', model: 'mock' })
ctx.on('agent/request', async (_agent, turn, _step, _config, _signal, next) => {
const config = await next()
return turn === 2 ? { ...config, reasoningEffort: ReasoningEffortId('max') } : config
})
send(agent, 'first')
await waitForIdle(ctx, agent)
send(agent, 'second')
await waitForIdle(ctx, agent)
expect(adapter.requests.map(request => request.reasoningEffort)).toEqual([
ReasoningEffortId('high'),
ReasoningEffortId('max'),
])
const headers = agent.session.events.filter(event => event.type === 'request/header')
expect(headers.map(event => event.data.header.config.reasoningEffort)).toEqual([
ReasoningEffortId('high'),
ReasoningEffortId('max'),
])
expect(headers.map(event => event.data.reason)).toEqual(['initial', 'change'])
const resumedAdapter = new MockAdapter([textResponse('three')], reasoning)
const resumedCtx = await harness(resumedAdapter)
const resumedHandle = await resumedCtx.agents.create({
sessionId: SessionId('effort-resumed'),
seed: structuredClone(agent.session.events),
agentOptions: { provider: 'mock', model: 'mock' },
})
send(resumedHandle.agent, 'third')
await waitForIdle(resumedCtx, resumedHandle.agent)
expect(resumedAdapter.requests[0]?.reasoningEffort).toBe(ReasoningEffortId('max'))
const resumedHeaders = resumedHandle.agent.session.events.filter(event => event.type === 'request/header')
expect(resumedHeaders.at(-1)?.data.header.config.reasoningEffort).toBe(ReasoningEffortId('max'))
expect(resumedHeaders.at(-1)?.data.reason).toBe('resume')
})
it('keeps exact-model resolution, request logging, and dispatch on one adapter registration', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: 'stable base' })
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
const started = Promise.withResolvers<undefined>()
const reasoning = Promise.withResolvers<LlmModelReasoningInfo>()
const first = new class extends MockAdapter {
override async resolveModel(
provider: string,
model: string,
_signal?: AbortSignal,
): Promise<LlmResolvedModelInfo> {
started.resolve(undefined)
return {
provider,
id: model,
name: model,
reasoning: await reasoning.promise,
}
}
}([textResponse('first')])
const second = new MockAdapter([textResponse('second')], {
efforts: [{ id: ReasoningEffortId('max'), name: 'Max' }],
defaultEffort: ReasoningEffortId('max'),
})
const disposeFirst = ctx.llm.registerAdapter(['mock'], first)
const agent = ctx.agentLoop.create(SessionId('effort-hmr'), { provider: 'mock', model: 'mock' })
send(agent, 'go')
await started.promise
disposeFirst()
ctx.llm.registerAdapter(['mock'], second)
reasoning.resolve({
efforts: [{ id: ReasoningEffortId('high'), name: 'High' }],
defaultEffort: ReasoningEffortId('high'),
})
await waitForIdle(ctx, agent)
expect(first.requests.map(request => request.reasoningEffort)).toEqual([
ReasoningEffortId('high'),
])
expect(second.requests).toHaveLength(0)
const headers = agent.session.events.filter(event => event.type === 'request/header')
expect(headers.at(-1)?.data.header.config.reasoningEffort).toBe(ReasoningEffortId('high'))
})
it('aborts a blocked reasoning lookup before quiescent disposal completes', async () => {
const started = Promise.withResolvers<AbortSignal>()
const adapter = new class extends MockAdapter {
override resolveModel(
_provider: string,
_model: string,
signal?: AbortSignal,
): Promise<never> {
if (signal === undefined) return Promise.reject(new Error('missing reasoning signal'))
started.resolve(signal)
return new Promise((_resolve, reject) => {
if (signal.aborted) {
reject(signal.reason instanceof Error ? signal.reason : new Error('reasoning aborted'))
return
}
signal.addEventListener('abort', () => {
reject(signal.reason instanceof Error ? signal.reason : new Error('reasoning aborted'))
}, { once: true })
})
}
}([])
const ctx = await harness(adapter)
const handle = await ctx.agents.create({
sessionId: SessionId('reasoning-dispose'),
agentOptions: { provider: 'mock', model: 'mock' },
})
send(handle.agent, 'go')
const signal = await started.promise
await handle.dispose()
expect(signal.aborted).toBe(true)
expect(handle.agent.status).toBe('disposed')
expect(adapter.requests).toHaveLength(0)
expect(handle.agent.session.events.some(event => event.type === 'request/header')).toBe(false)
})
it.each(['plain error', 'LLM error'] as const)(
'does not swallow a %s from exact-model resolution',
async (kind) => {
const failure = kind === 'plain error'
? new Error('reasoning metadata failed')
: new LlmError('unsupported effort', 'UNSUPPORTED_REASONING_EFFORT')
const adapter = new class extends MockAdapter {
override resolveModel(): Promise<never> {
return Promise.reject(failure)
}
}([])
const ctx = await harness(adapter)
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
const agent = ctx.agentLoop.create(SessionId(`reasoning-${kind}`), {
provider: 'mock',
model: 'mock',
})
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(errors).toContain(failure)
expect(adapter.requests).toHaveLength(0)
},
)
it('a compaction replace rewrites the resend, and the log explains it', async () => {
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
const ctx = await harness(adapter)
@@ -301,6 +463,7 @@ describe('request stability across the loop', () => {
const firstChunk = events.find(e => e.type === 'assistant/chunk' && e.seq > stepStart.seq)!
const header = foldRequestHeader(events.slice(0, firstChunk.seq))!
expect(request.model).toBe(header.config.model)
expect(request.reasoningEffort).toBe(header.config.reasoningEffort)
expect(request.system).toEqual(header.system)
expect(structuredClone(request.tools ?? [])).toEqual(structuredClone(header.tools ?? []))
expect(request.temperature).toBe(header.config.temperature)

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
README.md: bbae91ff2f497208f1ce620e162c27968d666ac3
README.zh.md: 95367b35a546c68491b9623daf45a54cd63f2731
# pnpm run verify-translation-pairing --write packages/core/agent/README.md
README.md: a65c53b3e4edf2031f286d7d172e73357c66ff1e
README.zh.md: 05da8a0a0d3ad2ed879b72e20eae1c4efaf711c8

View File

@@ -12,7 +12,7 @@ Tracks live agents and carries the initiating Agent through asynchronous driver
### Public API
The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `installAgentLlmTarget(agentCtx, target)` snapshots a mutable provider/model selection during prompt assembly and applies that pair to both prompt variables and request routing for one step. `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves.
The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `installAgentLlmTarget(agentCtx, target)` snapshots a mutable provider/model/reasoning-effort selection during prompt assembly, applies the route to prompt variables, and applies the complete target to request routing for one step; an absent selected effort clears an inherited effort so the target uses adapter/provider defaults. `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves.
- `ctx.agents.register(agent: Agent): () => void` — record an **already-constructed** agent. Disposed with the calling fiber.
- Advanced ordered lifecycle: `enter(agent, owner): () => void` enforces `agent.id === agent.session.id`, performs the authoritative ID collision check, and inserts without announcing; `owner` explicitly records the live creator-agent relation (or `undefined` for a root), independently of durable session lineage. `announce(agent)` emits `agent/created` exactly once. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, and every detach checks the captured entry object, so a stale capability cannot delete a later same-ID replacement. The async factory uses this split; ordinary plugins use `register()`.

View File

@@ -12,7 +12,7 @@ Agent 接口、注册表、进程本地发起方作用域,以及 `agent/*` 事
### 公开 API
带作用域的注册表层:`Agent.ctx` 是 agent 的作用域上下文(`dsh-scope`,键 = 该 agent。通过它注册工具变量监听器只对该 agent 生效,并在释放时全部撤销。`agentEvents(ctx, agent)` 是普通 agent 主体操作的融合分发器(一次完成载体 + 注入主体);其通知 mode 会调用每个监听器,并同时收容同步抛出和返回 Promise 的拒绝。注册表生命周期对复用一个稳定路由载体。`assembleContextFor(agent)` 构建按 agent 的组装上下文(同时包含 `agent` + `scope`)。`installAgentLlmTarget(agentCtx, target)` 在提示词组装期间快照可变的提供方/模型选择,并将该对同时应用到一个步骤的提示词变量与请求路由`CreateAgentOptions.setup(agentCtx)``ResumeAgentOptions.setup(agentCtx)` 在新建或恢复的 agent 尚未发布时组合其带作用域的世界。Setup 是受信任、仅用于组合的同进程代码:只有创建完成后才能驱动 agent。
带作用域的注册表层:`Agent.ctx` 是 agent 的作用域上下文(`dsh-scope`,键 = 该 agent。通过它注册工具变量监听器只对该 agent 生效,并在释放时全部撤销。`agentEvents(ctx, agent)` 是普通 agent 主体操作的融合分发器(一次完成载体 + 注入主体);其通知 mode 会调用每个监听器,并同时收容同步抛出和返回 Promise 的拒绝。注册表生命周期对复用一个稳定路由载体。`assembleContextFor(agent)` 构建按 agent 的组装上下文(同时包含 `agent` + `scope`)。`installAgentLlmTarget(agentCtx, target)` 在提示词组装期间快照可变的提供方/模型推理reasoning强度选择将路由应用到提示词变量并将完整目标应用到一个步骤的请求路由如果没有选定推理强度则会清除继承的推理强度使该目标使用适配器提供方默认值`CreateAgentOptions.setup(agentCtx)``ResumeAgentOptions.setup(agentCtx)` 在新建或恢复的 agent 尚未发布时组合其带作用域的世界。Setup 是受信任、仅用于组合的同进程代码:只有创建完成后才能驱动 agent。
- `ctx.agents.register(agent: Agent): () => void`:记录一个 **已经构造完成** 的 agent。随调用 fiber 释放。
- 高级有序生命周期:`enter(agent, owner): () => void` 强制 `agent.id === agent.session.id`,执行权威 ID 冲突检查,并在不通知的情况下插入;`owner` 显式记录实时创建方 agent 关系(根 agent 为 `undefined`),与持久会话谱系无关。`announce(agent)` 恰好发出一次 `agent/created`。创建监听器同步请求的 detach 会延后到该次分发结束;每次 detach 都会检查捕获的条目对象,因此陈旧能力无法删除后续使用同一 ID 的替代项。异步工厂使用这一拆分;普通插件使用 `register()`

View File

@@ -1,17 +1,19 @@
/**
* Agent-scoped provider/model target snapshot shared by interactive front doors.
* Agent-scoped LLM target snapshot shared by interactive front doors.
* @module @deepseek-ai/dsh-agent/llm-target
*/
import type { Context } from 'cordis'
import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
import type { LlmCallConfig, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
/** Complete provider/model route selected for one live agent. */
/** Complete provider/model route and optional reasoning effort selected for one live agent. */
export interface AgentLlmTarget {
/** Registered provider route. */
provider: string
/** Provider-owned model id. */
model: string
/** Adapter-owned reasoning effort, or provider/default behavior when absent. */
reasoningEffort?: ReasoningEffortId
}
/** Mutable selection plus the target captured for the current step. */
@@ -24,9 +26,11 @@ export interface AgentLlmTargetRef {
/**
* Couple one mutable target to agent-scoped prompt assembly and request routing.
* Prompt assembly snapshots the selected pair before delegating, then applies
* both prompt variables and request config to that snapshot so a concurrent
* switch takes effect on a later step instead of splitting the two surfaces.
* Prompt assembly snapshots the selected target before delegating, then applies
* its route to prompt variables and its route/effort to request config so a
* concurrent switch takes effect on a later step instead of splitting the two
* surfaces. An absent selected effort clears any inherited effort so a model
* switch can restore that target's provider/default behavior.
*
* @param agentCtx - The target agent's scoped context.
* @param target - Mutable selection owned by the calling front door.
@@ -52,10 +56,15 @@ export function installAgentLlmTarget(agentCtx: Context, target: AgentLlmTargetR
async (_agent, _turn, _step, _config, _signal, next): Promise<LlmCallConfig> => {
const resolved = await next()
const selected = target.assembled
return selected === undefined ? resolved : {
...resolved,
if (selected === undefined) return resolved
const { reasoningEffort: _inheritedEffort, ...withoutInheritedEffort } = resolved
return {
...withoutInheritedEffort,
provider: selected.provider,
model: selected.model,
...selected.reasoningEffort === undefined
? {}
: { reasoningEffort: selected.reasoningEffort },
}
},
)

View File

@@ -7,7 +7,7 @@ import {
type Agent,
type AgentLlmTargetRef,
} from '../src/index.ts'
import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
import { ReasoningEffortId, type LlmCallConfig } from '@deepseek-ai/dsh-llm'
describe('installAgentLlmTarget()', () => {
it('snapshots prompt variables and request routing together, then disposes both listeners', async () => {
@@ -24,16 +24,31 @@ describe('installAgentLlmTarget()', () => {
'agent/request', 1, 0, seed, signal, () => Promise.resolve(seed),
)).resolves.toBe(seed)
target.current = { provider: 'alpha', model: 'a1' }
target.current = {
provider: 'alpha',
model: 'a1',
reasoningEffort: ReasoningEffortId('high'),
}
expect((await ctx.systemPrompt.assemble()).variables).toMatchObject({ provider: 'alpha', model: 'a1' })
target.current = { provider: 'beta', model: 'b1' }
await expect(agentEvents(ctx, agent).waterfall(
'agent/request', 1, 0, seed, signal, () => Promise.resolve(seed),
)).resolves.toEqual({ provider: 'alpha', model: 'a1', temperature: 0.2 })
)).resolves.toEqual({
provider: 'alpha',
model: 'a1',
reasoningEffort: ReasoningEffortId('high'),
temperature: 0.2,
})
expect((await ctx.systemPrompt.assemble()).variables).toMatchObject({ provider: 'beta', model: 'b1' })
const inherited: LlmCallConfig = {
provider: 'alpha',
model: 'a1',
reasoningEffort: ReasoningEffortId('max'),
temperature: 0.2,
}
await expect(agentEvents(ctx, agent).waterfall(
'agent/request', 1, 1, seed, signal, () => Promise.resolve(seed),
'agent/request', 1, 1, inherited, signal, () => Promise.resolve(inherited),
)).resolves.toEqual({ provider: 'beta', model: 'b1', temperature: 0.2 })
dispose()

View File

@@ -183,6 +183,11 @@ function assertCurrentLlmShape(event: Record<string, unknown>, index: number): v
const header = record['header']
const config = typeof header === 'object' && header !== null ? (header as Record<string, unknown>)['config'] : undefined
if (!hasProviderModel(config)) throw new Error(`seed request/header at index ${index} lacks provider/model`)
const reasoningEffort = (config as Record<string, unknown>)['reasoningEffort']
if (reasoningEffort !== undefined
&& (typeof reasoningEffort !== 'string' || reasoningEffort.length === 0)) {
throw new Error(`seed request/header at index ${index} has an invalid reasoningEffort`)
}
}
if (event['type'] === 'assistant/message' && !hasProviderModel(record['provenance'])) {
throw new Error(`seed assistant/message at index ${index} lacks provider/model provenance`)

View File

@@ -156,7 +156,7 @@ export interface TodoItem {
* canonical empty optional fields are absent.
*/
export 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

@@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'
import { Session, SessionId, canonicalHeader, foldRequestHeader, headerEquals } from '@deepseek-ai/dsh-session'
import type { EpochHeader, SessionEvent } from '@deepseek-ai/dsh-session'
import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm'
import { ReasoningEffortId } from '@deepseek-ai/dsh-llm'
const CONFIG = { provider: 'mock', model: 'm' }
@@ -29,6 +30,10 @@ describe('headerEquals', () => {
it('compares every canonical field and preserves tool order', () => {
expect(headerEquals(base, structuredClone(base))).toBe(true)
expect(headerEquals(base, { ...base, config: { provider: 'mock', model: 'other' } })).toBe(false)
expect(headerEquals(base, {
...base,
config: { ...base.config, reasoningEffort: ReasoningEffortId('high') },
})).toBe(false)
expect(headerEquals(base, { ...base, system: 'other' })).toBe(false)
expect(headerEquals(base, { ...base, messagePrefix: [msg('other')] })).toBe(false)
expect(headerEquals(base, { ...base, tools: [] })).toBe(false)

View File

@@ -1,6 +1,6 @@
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import { CallId, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import SessionStore, {
displayPromptContent,
findLastMessageTurnEnd,
@@ -118,6 +118,11 @@ describe('Session', () => {
})
it('renders injected-context and steering messages as plain user content', () => {
expect(displayPromptContent({
content: [{ type: 'text', text: 'plain prompt' }],
source: { kind: 'user' },
})).toEqual([{ type: 'text', text: 'plain prompt' }])
const session = new Session(SessionId('s2'))
session.append('user/message', {
content: [{ type: 'text', text: 'file changed: a.ts' }],
@@ -228,6 +233,35 @@ describe('Session', () => {
.toEqual([unrelatedPrimitiveData])
})
it('round-trips a non-empty reasoning effort and rejects invalid durable values', () => {
const valid = {
type: 'request/header',
seq: 0,
time: 1,
data: {
header: {
config: {
provider: 'mock',
model: 'model',
reasoningEffort: ReasoningEffortId('adapter-owned'),
},
},
reason: 'initial',
},
} as const
expect(new Session(SessionId('reasoning-effort'), [valid]).events[0])
.toEqual(valid)
for (const reasoningEffort of ['', 1]) {
const invalid = structuredClone(valid) as unknown as SessionEvent
if (invalid.type !== 'request/header') throw new Error('test fixture must be a request header')
const config = invalid.data.header.config as unknown as Record<string, unknown>
config.reasoningEffort = reasoningEffort
expect(() => new Session(SessionId('invalid-reasoning-effort'), [invalid]))
.toThrow('seed request/header at index 0 has an invalid reasoningEffort')
}
})
it('isolates the log from mutation through a derived message (append-only contract)', () => {
const session = new Session(SessionId('s4'))
session.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } }, { surfaceOp: 'append' })

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 packages/llm/llm-deepseek/README.md
README.md: f7d44be3ee9e0d44cf338dcc0dc7210ef16309dd
README.zh.md: ed21fef1aa9f635f627a8c2b58a5917dd321a0d1
README.md: 75b02f54ce25198c6aa41e510c9a5698d85e3d60
README.zh.md: 7c156f14de3c19f732b57df809ce2e07798f67ba

View File

@@ -17,7 +17,7 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire
apiKey: !!js process.env.DEEPSEEK_API_KEY # or rely on the env fallback
baseURL: !!js process.env.DEEPSEEK_BASE_URL # default: https://api.deepseek.com
thinking: enabled # optional; provider default is enabled
reasoningEffort: high # optional; high | max — omitted ⇒ not sent
reasoningEffort: high # optional; off | high | max — omitted ⇒ high
streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default
defaultContextWindow: 256000 # optional positive-integer fallback for models without an exact value
models: # optional; defaults to V4 Flash and V4 Pro
@@ -30,11 +30,11 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire
The plugin registers the single provider route `deepseek`. 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 256,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.resolveModelContext('deepseek', model)` returns an exact model value first, then `defaultContextWindow` for an entry without capacity or an unlisted pass-through id. When neither value exists it returns `undefined` without invalidating routing. Pressure-sensitive plugins therefore get deployment-owned capacity without treating the model selector as authoritative. Registering another adapter for `deepseek` throws `LlmError('DUPLICATE_ADAPTER')`.
`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. When neither value exists, `context` is absent without invalidating routing. Pressure-sensitive plugins therefore get deployment-owned capacity without treating the model selector as authoritative. Registering another adapter for `deepseek` throws `LlmError('DUPLICATE_ADAPTER')`.
`reasoningEffort` is **omitted by default** — when unset, the `reasoning_effort` wire field is not sent and the server applies its own default for the model. The only accepted values are `high` and `max` (DeepSeek's official effort levels). It is meaningful only with thinking enabled (the provider default).
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.
`thinking`/`reasoningEffort` are adapter-level request defaults serialized as the official top-level `thinking: {type}` / `reasoning_effort` wire fields. They live in adapter config (not `GenerateOptions`) to keep the core vocabulary provider-neutral. A request with `GenerateOptions.purpose: 'session-title'` forces thinking disabled and omits `reasoning_effort`, reserving its bounded output for visible title text without changing conversation or compaction defaults.
`thinking: disabled` is a deployment lock that publishes only `off` with `off` as its default. Omitting `reasoningEffort` or configuring it as `off` is valid; configuring `high` or `max` fails plugin loading, and a direct per-request attempt to enable thinking fails before network I/O. A request with `GenerateOptions.purpose: 'session-title'` also forces thinking disabled and omits the already-resolved effort, reserving its bounded output for visible title text without changing conversation or compaction defaults.
`streamIdleTimeoutMs` bounds each outstanding provider read, including the initial `fetch`, without counting time the consumer spends between chunks. One stable abort signal reaches the request and body reader for the whole call; expiry stops the transport and throws `LlmError('TIMEOUT')`, while an earlier caller abort throws `LlmError('ABORTED')`. The adapter makes exactly one provider request per `stream()` call; agent-level retry is a separate plugin policy.
@@ -45,6 +45,7 @@ Every request carries the shared attribution header from dsh-llm's `attributionH
## Wire-format notes (verified live + against the official docs)
- Streaming only (`stream_options.include_usage` always on). `usage` may arrive attached to the finish chunk or as a trailing usage-only chunk — the translator defers both to `[DONE]`, so `usage` always precedes `finish` and nothing follows `finish`.
- The adapter-owned `off` effort maps to `thinking: {type: 'disabled'}` and never crosses the wire as `reasoning_effort: 'off'`.
- The first thinking-mode chunk carries `reasoning_content: ""` — handled (no spurious reasoning block).
- **Reasoning passback rule**: on assistant turns that carried tool calls, `reasoning_content` is serialized back in history (required by the API in thinking mode); on tool-call-free turns it is dropped (ignored anyway — saves tokens).
- Cache accounting: `cacheReadTokens``prompt_cache_hit_tokens` / `prompt_tokens_details.cached_tokens`; DeepSeek reports no cache-write metric.
@@ -55,7 +56,7 @@ Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA`
## Testing
Unit suites run against a local `node:http` mock SSE server (no network), including structured HTTP facts, malformed/truncated streams, caller abort, connection failure, and proof that idle timeout aborts the actual body. Real-API coverage lives in `tests/adapter.e2e.ts` (`pnpm run test:e2e`, key-gated): V4 Flash + V4 Pro across thinking enabled/disabled and both official effort levels, including the thinking+tools round trip with reasoning passback.
Unit suites run against a local `node:http` mock SSE server (no network), including dynamic `high`/`off`/`max` selection, structured HTTP facts, malformed/truncated streams, caller abort, connection failure, and proof that idle timeout aborts the actual body. Real-API coverage lives in `tests/adapter.e2e.ts` (`pnpm run test:e2e`, key-gated): V4 Flash + V4 Pro across thinking enabled/disabled and both official effort levels, including the thinking+tools round trip with reasoning passback.
## Model Experience
@@ -81,7 +82,7 @@ Reasoning, text, and raw-string tool arguments are translated into harness chunk
#### Token effect
Generated tokens follow provider thinking and effort settings plus the request's `maxTokens`; only loop-retained blocks affect later input.
Generated tokens follow the request's logged reasoning effort and `maxTokens`; only loop-retained blocks affect later input.
#### KV Cache effect

View File

@@ -17,7 +17,7 @@ harness LLM seam 的 DeepSeek chat-completions 适配器:手写 `fetch` + SSE
apiKey: !!js process.env.DEEPSEEK_API_KEY # or rely on the env fallback
baseURL: !!js process.env.DEEPSEEK_BASE_URL # default: https://api.deepseek.com
thinking: enabled # optional; provider default is enabled
reasoningEffort: high # optional; high | max — omitted ⇒ not sent
reasoningEffort: high # optional; off | high | max — omitted ⇒ high
streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default
defaultContextWindow: 256000 # optional positive-integer fallback for models without an exact value
models: # optional; defaults to V4 Flash and V4 Pro
@@ -30,11 +30,11 @@ harness LLM seam 的 DeepSeek chat-completions 适配器:手写 `fetch` + SSE
该插件注册唯一提供方路由 `deepseek`。请求使用 `provider: deepseek` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash`(名称为 `DeepSeek-V4-Flash`)和 `deepseek-v4-pro`(名称为 `DeepSeek-V4-Pro`),两者的上下文窗口均为 256,000 token显式列表会替换这些默认值`models: []` 则不公布任何模型。Catalog 配置项通过 `ctx.llm.listModels('deepseek')` 公开给 ACPAgent Client Protocol编辑器和 Web 选择器等客户端,但仍只提供建议:未列出模型 id 仍原样传递。省略配置项 name 默认为其 id。
`contextWindow` 对每个已配置模型都可选,不会通过建议 catalog 公开。`ctx.llm.resolveModelContext('deepseek', model)` 先返回精确模型值,再对不含容量的配置项或未列出原样传递 id 返回 `defaultContextWindow`。两者都不存在时返回 `undefined`不会使路由失效。因此,压力敏感插件可以获得部署拥有的容量,不会将模型 selector 视为权威。为 `deepseek` 注册另一个适配器会抛出 `LlmError('DUPLICATE_ADAPTER')`
`contextWindow` 对每个已配置模型都可选,不会通过建议 catalog 公开。`ctx.llm.resolveModelInfo('deepseek', model).context` 先返回精确模型值,再对不含容量的配置项或未列出原样传递 id 返回 `defaultContextWindow`。两者都不存在时`context` 字段缺失但不会使路由失效。因此,压力敏感插件可以获得部署拥有的容量,不会将模型 selector 视为权威。为 `deepseek` 注册另一个适配器会抛出 `LlmError('DUPLICATE_ADAPTER')`
`reasoningEffort` 默认**省略**:未设置时,不发送 `reasoning_effort` 协议字段,服务器会为模型应用自身默认值。只接受 `high``max`DeepSeek 官方 effort 级别)。只有在启用 thinking 时才有意义(提供方默认启用)
同一确切模型结果会在部署策略允许思考时,为每个原样传递模型在 `reasoning` 下公开有序的 `off``high``max` 推理强度。`reasoningEffort` 选择部署默认值,省略时回退为 `high``agent/request` 可以在每个会话步骤替换它;解析后的值会记录在 `request/header``high``max` 会启用思考,并序列化为官方顶层 `reasoning_effort`;适配器持有的 `off` 则序列化为 `thinking.type: disabled`,且省略 `reasoning_effort`。不支持的值会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败
`thinking``reasoningEffort` 是适配器级请求默认值,序列化为官方顶层 `thinking: {type}``reasoning_effort` 协议字段。它们位于适配器配置中(而非 `GenerateOptions`),以保持核心词汇与提供方无关。携带 `GenerateOptions.purpose: 'session-title'` 的请求会强制禁用 thinking 并省略 `reasoning_effort`,将有界输出保留给可见标题文本,不改变会话或压缩默认值。
`thinking: disabled` 是部署锁定:它只公布 `off`,并以 `off` 为默认值。省略 `reasoningEffort` 或将其配置为 `off` 均有效;配置 `high``max` 会使插件加载失败,直接按请求启用思考也会在网络 I/O 前失败。携带 `GenerateOptions.purpose: 'session-title'` 的请求会强制禁用思考并省略已解析的推理强度,将有界输出保留给可见标题文本,不改变会话或压缩默认值。
`streamIdleTimeoutMs` 会限制每次未完成提供方读取,包括初始 `fetch`,但不计入消费方在 chunk 间花费的时间。一个稳定 abort 信号会在整个调用中达到请求与 body reader过期会停止传输并抛出 `LlmError('TIMEOUT')`,较早的调用方 abort 则抛出 `LlmError('ABORTED')`。适配器每次 `stream()` 调用精确发起一次提供方请求agent 级重试是独立插件策略。
@@ -45,6 +45,7 @@ harness LLM seam 的 DeepSeek chat-completions 适配器:手写 `fetch` + SSE
## 协议格式说明(已通过实时请求与官方文档验证)
- 只支持流式输出(`stream_options.include_usage` 始终开启)。`usage` 可能附着在 finish chunk 上,也可能作为尾随仅 usage chunk 到达;转换器会将两者都延迟到 `[DONE]`,因此 `usage` 始终位于 `finish` 之前,`finish` 之后不会出现任何内容。
- 适配器持有的 `off` 推理强度映射为 `thinking: {type: 'disabled'}`,绝不会以 `reasoning_effort: 'off'` 跨越协议。
- 第一个 thinking 模式 chunk 携带 `reasoning_content: ""`,系统会处理它(不会产生多余 reasoning 块)。
- **Reasoning 回传规则**:对携带工具调用的 assistant 轮次,会将 `reasoning_content` 序列化回历史thinking 模式 API 必需);对不含工具调用的轮次,它会被丢弃(不会使用,可节省 token
- Cache 计量:`cacheReadTokens``prompt_cache_hit_tokens` / `prompt_tokens_details.cached_tokens`DeepSeek 不报告 cache-write 指标。
@@ -55,7 +56,7 @@ harness LLM seam 的 DeepSeek chat-completions 适配器:手写 `fetch` + SSE
## 测试
单元套件使用本地 `node:http` mock SSE 服务器(无网络),覆盖结构化 HTTP 事实、格式错误/截断流、调用方 abort、连接失败以及 idle 超时确实会 abort 实际 body 的证明。真实 API 覆盖位于 `tests/adapter.e2e.ts``pnpm run test:e2e`,由 key 调节V4 Flash + V4 Pro覆盖 thinking 启用/禁用与两种官方 effort 级别,包括 thinking + 工具往返与 reasoning 回传。
单元套件使用本地 `node:http` mock SSE 服务器(无网络),覆盖动态 `high``off``max` 选择、结构化 HTTP 事实、格式错误/截断流、调用方 abort、连接失败以及 idle 超时确实会 abort 实际 body 的证明。真实 API 覆盖位于 `tests/adapter.e2e.ts``pnpm run test:e2e`,由 key 调节V4 Flash + V4 Pro覆盖 thinking 启用/禁用与两种官方 effort 级别,包括 thinking + 工具往返与 reasoning 回传。
## 模型体验
@@ -81,7 +82,7 @@ Reasoning、文本与原始字符串工具参数会转换为 harness chunk
#### Token 影响
生成 token 遵循提供方 thinking 与 effort 设置及请求的 `maxTokens`;只有 loop 保留的块会影响后续输入。
生成 token 遵循请求中已记录的推理强度和 `maxTokens`;只有 loop 保留的块会影响后续输入。
#### KV Cache 影响

View File

@@ -5,12 +5,12 @@
* @module dsh-llm-deepseek/adapter
*/
import { attributionHeaders, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm'
import { attributionHeaders, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type {
GenerateOptions,
LlmModelContext,
LlmModelInfo,
LlmProviderInfo,
LlmResolvedModelInfo,
StreamChunk,
} from '@deepseek-ai/dsh-llm'
import { idleWatchdog, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout'
@@ -51,6 +51,26 @@ export interface DeepSeekAdapterOptions {
/** Default maximum idle interval while an adapter stream read is outstanding. */
export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000
const STREAM_IDLE_TIMEOUT_CODE = 'LLM_STREAM_IDLE_TIMEOUT'
const OFF_REASONING_EFFORT = ReasoningEffortId('off')
const HIGH_REASONING_EFFORT = ReasoningEffortId('high')
const MAX_REASONING_EFFORT = ReasoningEffortId('max')
const REASONING_EFFORTS = [
{ id: OFF_REASONING_EFFORT, name: 'Off' },
{ id: HIGH_REASONING_EFFORT, name: 'High' },
{ id: MAX_REASONING_EFFORT, name: 'Max' },
] as const
const OFF_ONLY_REASONING_EFFORTS = [
{ id: OFF_REASONING_EFFORT, name: 'Off' },
] as const
function modelInfo(provider: string, model: DeepSeekCatalogModel): LlmModelInfo {
return {
provider,
id: model.id,
name: model.name ?? model.id,
...model.description === undefined ? {} : { description: model.description },
}
}
function providerRetryAfterMs(value: string | null): number | undefined {
if (value === null) return undefined
@@ -98,6 +118,11 @@ export class DeepSeekAdapter extends LlmAdapter {
constructor(private readonly options: DeepSeekAdapterOptions) {
super()
if (options.defaults?.thinking === 'disabled'
&& options.defaults.reasoningEffort !== undefined
&& options.defaults.reasoningEffort !== 'off') {
throw new Error('llm-deepseek: only reasoningEffort "off" can be configured when thinking is disabled')
}
if (options.defaultContextWindow !== undefined
&& (!Number.isInteger(options.defaultContextWindow) || options.defaultContextWindow <= 0)) {
throw new Error('llm-deepseek: defaultContextWindow must be a positive integer')
@@ -117,21 +142,40 @@ export class DeepSeekAdapter extends LlmAdapter {
}
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
return Promise.resolve((this.options.models ?? []).map(model => ({
provider,
id: model.id,
name: model.name ?? model.id,
...model.description === undefined ? {} : { description: model.description },
})))
return Promise.resolve((this.options.models ?? []).map(model => modelInfo(provider, model)))
}
override resolveModelContext(
_provider: string,
override resolveModel(
provider: string,
model: string,
): Promise<LlmModelContext | undefined> {
const contextWindow = this.options.models?.find(entry => entry.id === model)?.contextWindow
_signal?: AbortSignal,
): Promise<LlmResolvedModelInfo> {
const configured = this.options.models?.find(entry => entry.id === model)
const contextWindow = configured?.contextWindow
?? this.options.defaultContextWindow
return Promise.resolve(contextWindow === undefined ? undefined : { contextWindow })
return Promise.resolve({
...configured === undefined
? { provider, id: model, name: model }
: modelInfo(provider, configured),
...contextWindow === undefined ? {} : { context: { contextWindow } },
...this.options.defaults?.thinking === 'disabled'
? {
reasoning: {
efforts: OFF_ONLY_REASONING_EFFORTS,
defaultEffort: OFF_REASONING_EFFORT,
},
}
: {
reasoning: {
efforts: REASONING_EFFORTS,
defaultEffort: this.options.defaults?.reasoningEffort === 'off'
? OFF_REASONING_EFFORT
: this.options.defaults?.reasoningEffort === 'max'
? MAX_REASONING_EFFORT
: HIGH_REASONING_EFFORT,
},
},
})
}
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {

View File

@@ -28,18 +28,19 @@ const DEFAULT_MODELS: DeepSeekCatalogModel[] = [
/**
* 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. */
apiKey?: string
/** Endpoint base; falls back to $DEEPSEEK_BASE_URL, then the public API. */
baseURL?: string
/** Thinking-mode default for every request (provider default: enabled). */
/** Deployment thinking policy; `disabled` limits every conversation request to `off`. */
thinking?: 'enabled' | 'disabled'
/** Thinking effort (only meaningful with thinking enabled). */
reasoningEffort?: 'high' | 'max'
/** Default thinking effort (default `high`); `off` disables thinking per request. */
reasoningEffort?: 'off' | 'high' | 'max'
/** Positive context capacity used when the selected model has no exact value. */
defaultContextWindow?: number
/** Advisory models shown by discovery consumers; defaults to V4 Flash and V4 Pro. */
@@ -59,7 +60,7 @@ export const Config: z<Config> = z.object({
apiKey: z.string(),
baseURL: z.string(),
thinking: z.union(['enabled', 'disabled']),
reasoningEffort: z.union(['high', 'max']),
reasoningEffort: z.union(['off', 'high', 'max']),
defaultContextWindow: z.number().step(1).min(1),
models: z.array(catalogModel).default(DEFAULT_MODELS),
streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS),
@@ -94,6 +95,11 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee
}
export function apply(ctx: Context, config: Config): void {
if (config.thinking === 'disabled'
&& config.reasoningEffort !== undefined
&& config.reasoningEffort !== 'off') {
throw new Error('llm-deepseek: only reasoningEffort "off" can be configured when thinking is disabled')
}
const apiKey = config.apiKey ?? process.env.DEEPSEEK_API_KEY
if (apiKey === undefined || apiKey.length === 0) {
throw new Error('llm-deepseek: an API key is required (Config.apiKey or $DEEPSEEK_API_KEY)')

View File

@@ -6,13 +6,49 @@
* @module dsh-llm-deepseek/serialize
*/
import { LlmError } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
import type { WireMessage, WireRequest, WireTool } from './types.ts'
/** Adapter-level request defaults (from plugin config). */
export interface RequestDefaults {
thinking?: 'enabled' | 'disabled' | undefined
reasoningEffort?: 'high' | 'max' | undefined
reasoningEffort?: 'off' | 'high' | 'max' | undefined
}
interface ResolvedThinking {
thinking?: 'enabled' | 'disabled'
reasoningEffort?: 'high' | 'max'
}
/** Validate the adapter-owned effort before resolving its DeepSeek wire fields. */
function reasoningEffort(effort: NonNullable<GenerateOptions['reasoningEffort']>): 'off' | 'high' | 'max' {
if (effort === 'off' || effort === 'high' || effort === 'max') {
return effort as 'off' | 'high' | 'max'
}
throw new LlmError(
`DeepSeek does not support reasoning effort "${effort}"`,
'UNSUPPORTED_REASONING_EFFORT',
)
}
/** Resolve one legal thinking/effort pair without exposing `off` as a wire effort. */
function resolveThinking(options: GenerateOptions, defaults: RequestDefaults): ResolvedThinking {
if (options.purpose === 'session-title') return { thinking: 'disabled' }
const effort = options.reasoningEffort === undefined
? defaults.reasoningEffort
: reasoningEffort(options.reasoningEffort)
if (defaults.thinking === 'disabled' && effort !== undefined && effort !== 'off') {
throw new LlmError(
`DeepSeek deployment does not support reasoning effort "${effort}"`,
'UNSUPPORTED_REASONING_EFFORT',
)
}
if (effort === 'off') return { thinking: 'disabled' }
if (effort === 'high' || effort === 'max') {
return { thinking: 'enabled', reasoningEffort: effort }
}
return defaults.thinking === undefined ? {} : { thinking: defaults.thinking }
}
/** Join the text blocks of a message (used for user/tool-result content). */
@@ -120,16 +156,17 @@ export function serializeRequest(options: GenerateOptions, defaults: RequestDefa
}))
// A short title budget must produce visible text; conversation and
// compaction calls continue to inherit the adapter's thinking defaults.
const thinking = options.purpose === 'session-title' ? 'disabled' : defaults.thinking
const reasoningEffort = options.purpose === 'session-title' ? undefined : defaults.reasoningEffort
const resolvedThinking = resolveThinking(options, defaults)
return {
model: options.model,
messages,
stream: true,
stream_options: { include_usage: true },
...thinking !== undefined ? { thinking: { type: thinking } } : {},
...reasoningEffort !== undefined ? { reasoning_effort: reasoningEffort } : {},
...resolvedThinking.thinking !== undefined ? { thinking: { type: resolvedThinking.thinking } } : {},
...resolvedThinking.reasoningEffort !== undefined
? { reasoning_effort: resolvedThinking.reasoningEffort }
: {},
...tools !== undefined && tools.length > 0 ? { tools } : {},
...options.temperature !== undefined ? { temperature: options.temperature } : {},
...options.maxTokens !== undefined ? { max_tokens: options.maxTokens } : {},

View File

@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { CallId } from '@deepseek-ai/dsh-llm'
import LlmService, { CallId, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import type { Config } from '@deepseek-ai/dsh-llm-deepseek'
@@ -50,41 +50,40 @@ const weatherTool: ToolSchema = {
}
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () => {
it('flash + thinking disabled: plain text generation', async () => {
const ctx = await harness(FLASH, { thinking: 'disabled' })
const result = await assemble(ctx,{
it('flash dynamically switches from off to high', async () => {
const ctx = await harness(FLASH, { reasoningEffort: 'off' })
const withoutThinking = await assemble(ctx,{
model: FLASH,
messages: ask('Reply with exactly the word: pong'),
maxTokens: 50,
})
expect(result.finish.kind).toBe('stop')
expect(textOf(result).toLowerCase()).toContain('pong')
expect(result.message.content.some(block => block.type === 'reasoning')).toBe(false)
expect(result.usage?.inputTokens).toBeGreaterThan(0)
expect(result.usage?.outputTokens).toBeGreaterThan(0)
})
expect(withoutThinking.finish.kind).toBe('stop')
expect(textOf(withoutThinking).toLowerCase()).toContain('pong')
expect(withoutThinking.message.content.some(block => block.type === 'reasoning')).toBe(false)
expect(withoutThinking.usage?.inputTokens).toBeGreaterThan(0)
expect(withoutThinking.usage?.outputTokens).toBeGreaterThan(0)
it('flash + thinking enabled (effort high): reasoning blocks + reasoning tokens', async () => {
const ctx = await harness(FLASH, { thinking: 'enabled', reasoningEffort: 'high' })
const result = await assemble(ctx,{
const withThinking = await assemble(ctx,{
model: FLASH,
reasoningEffort: ReasoningEffortId('high'),
messages: ask('Which is larger, 9.11 or 9.8? Answer with just the number.'),
maxTokens: 2000,
})
expect(result.finish.kind).toBe('stop')
expect(result.message.content.some(block => block.type === 'reasoning')).toBe(true)
expect(textOf(result)).toContain('9.8')
expect(result.usage?.reasoningTokens).toBeGreaterThan(0)
expect(withThinking.finish.kind).toBe('stop')
expect(withThinking.message.content.some(block => block.type === 'reasoning')).toBe(true)
expect(textOf(withThinking)).toContain('9.8')
expect(withThinking.usage?.reasoningTokens).toBeGreaterThan(0)
})
it.each(['high', 'max'] as const)(
'pro + thinking enabled (effort %s): tool-call round trip with reasoning passback',
async (effort) => {
const ctx = await harness(PRO, { thinking: 'enabled', reasoningEffort: effort })
const ctx = await harness(PRO, { thinking: 'enabled' })
// Turn 1: the model must call the tool (and think before it).
const first = await assemble(ctx,{
model: PRO,
reasoningEffort: ReasoningEffortId(effort),
messages: ask('What is the weather in Paris right now? Use the get_weather tool.'),
tools: [weatherTool],
maxTokens: 2000,
@@ -99,6 +98,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', ()
// block in history (the official thinking+tools passback rule).
const second = await assemble(ctx,{
model: PRO,
reasoningEffort: ReasoningEffortId(effort),
messages: [
...ask('What is the weather in Paris right now? Use the get_weather tool.'),
{ role: 'assistant', content: first.message.content },

View File

@@ -8,6 +8,7 @@ import LlmService, {
LlmError,
ProviderRequestId,
QUOTA_EXCEEDED_CODE,
ReasoningEffortId,
userAgent,
} from '@deepseek-ai/dsh-llm'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
@@ -120,6 +121,7 @@ describe('DeepSeekAdapter against a mock server', () => {
// The wire request carried the auth header contents we configured.
expect(server.requests[0]).toMatchObject({
model: 'deepseek-v4-flash',
reasoning_effort: 'high',
stream: true,
stream_options: { include_usage: true },
})
@@ -173,9 +175,45 @@ describe('DeepSeekAdapter against a mock server', () => {
expect(server.headers[0]?.['x-deepseek-harness-compact']).toBe('1')
})
it('forwards thinking config onto the wire', async () => {
it('switches dynamically from the configured high default through off to max', async () => {
const server = await mockServer([
{ kind: 'sse', events: textEvents },
{ kind: 'sse', events: textEvents },
{ kind: 'sse', events: textEvents },
])
const ctx = await harness(server.url, { thinking: 'enabled', reasoningEffort: 'high' })
await assemble(ctx,{
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
})
await assemble(ctx,{
model: 'deepseek-v4-flash',
reasoningEffort: ReasoningEffortId('off'),
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi again' }] }],
})
await assemble(ctx,{
model: 'deepseek-v4-flash',
reasoningEffort: ReasoningEffortId('max'),
messages: [{ role: 'user', content: [{ type: 'text', text: 'one more time' }] }],
})
expect(server.requests[0]).toMatchObject({
thinking: { type: 'enabled' },
reasoning_effort: 'high',
})
expect(server.requests[1]).toMatchObject({
thinking: { type: 'disabled' },
})
expect(server.requests[1]).not.toHaveProperty('reasoning_effort')
expect(server.requests[2]).toMatchObject({
thinking: { type: 'enabled' },
reasoning_effort: 'max',
})
})
it('publishes only off and omits the wire effort when thinking is disabled', async () => {
const server = await mockServer([{ kind: 'sse', events: textEvents }])
const ctx = await harness(server.url, { thinking: 'disabled', reasoningEffort: 'high' })
const ctx = await harness(server.url, { thinking: 'disabled' })
await assemble(ctx,{
model: 'deepseek-v4-flash',
@@ -183,10 +221,52 @@ describe('DeepSeekAdapter against a mock server', () => {
})
expect(server.requests[0]).toMatchObject({
thinking: { type: 'disabled' },
reasoning_effort: 'high',
})
expect(server.requests[0]).not.toHaveProperty('reasoning_effort')
await expect(ctx.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash'))
.resolves.toMatchObject({
reasoning: {
efforts: [{ id: ReasoningEffortId('off'), name: 'Off' }],
defaultEffort: ReasoningEffortId('off'),
},
})
})
it('rejects a per-request effort before I/O when thinking is disabled', async () => {
const server = await mockServer([])
const ctx = await harness(server.url, { thinking: 'disabled' })
await expect(assemble(ctx, {
model: 'deepseek-v4-flash',
reasoningEffort: ReasoningEffortId('high'),
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
})).rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' })
expect(server.requests).toHaveLength(0)
})
it.each(['high', 'max'])(
'rejects direct adapter effort %s before I/O when thinking is disabled',
async (effort) => {
const server = await mockServer([])
const adapter = new DeepSeekAdapter({
apiKey: 'test-key',
baseURL: server.url,
defaults: { thinking: 'disabled' },
})
const stream = adapter.stream({
provider: 'deepseek',
model: 'deepseek-v4-flash',
reasoningEffort: ReasoningEffortId(effort),
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
})
await expect(async () => {
for await (const _chunk of stream) { /* drain */ }
}).rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' })
expect(server.requests).toHaveLength(0)
},
)
it.each([
[401, 'AUTH'],
[403, 'AUTH'],
@@ -531,8 +611,100 @@ describe('plugin registration and config', () => {
{ provider: 'deepseek', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash' },
{ provider: 'deepseek', id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro' },
])
await expect(ctx.llm.resolveModelContext('deepseek', 'deepseek-v4-flash'))
.resolves.toEqual({ contextWindow: 256_000 })
await expect(ctx.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash'))
.resolves.toMatchObject({
provider: 'deepseek',
id: 'deepseek-v4-flash',
name: 'DeepSeek-V4-Flash',
context: { contextWindow: 256_000 },
reasoning: {
efforts: [
{ id: ReasoningEffortId('off'), name: 'Off' },
{ id: ReasoningEffortId('high'), name: 'High' },
{ id: ReasoningEffortId('max'), name: 'Max' },
],
defaultEffort: ReasoningEffortId('high'),
},
})
})
it.each(['off', 'max'] as const)('uses the configured %s reasoning default', async (effort) => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, {
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
reasoningEffort: effort,
})
await expect(ctx.llm.resolveModelInfo('deepseek', 'unlisted-pass-through'))
.resolves.toMatchObject({
reasoning: {
efforts: [
{ id: ReasoningEffortId('off'), name: 'Off' },
{ id: ReasoningEffortId('high'), name: 'High' },
{ id: ReasoningEffortId('max'), name: 'Max' },
],
defaultEffort: ReasoningEffortId(effort),
},
})
})
it('accepts off as the default when thinking is deployment-disabled', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, {
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
thinking: 'disabled',
reasoningEffort: 'off',
})
await expect(ctx.llm.resolveModelInfo('deepseek', 'unlisted-pass-through'))
.resolves.toMatchObject({
reasoning: {
efforts: [{ id: ReasoningEffortId('off'), name: 'Off' }],
defaultEffort: ReasoningEffortId('off'),
},
})
})
it.each(['high', 'max'] as const)(
'rejects configured reasoning effort %s when thinking is disabled',
async (reasoningEffort) => {
const ctx = new Context()
await ctx.plugin(LlmService)
await expect(ctx.plugin(LlmDeepSeek, {
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
thinking: 'disabled',
reasoningEffort,
})).rejects.toThrow(/only reasoningEffort "off"/)
expect(ctx.llm.listProviders()).toEqual([])
},
)
it.each(['high', 'max'] as const)(
'rejects disabled-thinking effort %s at the direct constructor boundary',
(reasoningEffort) => {
expect(() => new DeepSeekAdapter({
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
defaults: { thinking: 'disabled', reasoningEffort },
})).toThrow(/only reasoningEffort "off"/)
},
)
it('accepts disabled thinking with off at the direct constructor boundary', async () => {
const adapter = new DeepSeekAdapter({
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
defaults: { thinking: 'disabled', reasoningEffort: 'off' },
})
await expect(adapter.resolveModel('deepseek', 'pass-through')).resolves.toMatchObject({
reasoning: {
efforts: [{ id: ReasoningEffortId('off'), name: 'Off' }],
defaultEffort: ReasoningEffortId('off'),
},
})
})
it('uses the default model catalog when apply is called directly', async () => {
@@ -565,10 +737,15 @@ describe('plugin registration and config', () => {
{ provider: 'deepseek', id: 'private-fast', name: 'private-fast' },
{ provider: 'deepseek', id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget' },
])
await expect(ctx.llm.resolveModelContext('deepseek', 'private-fast'))
.resolves.toEqual({ contextWindow: 32_000 })
await expect(ctx.llm.resolveModelContext('deepseek', 'arbitrary-unlisted'))
.resolves.toBeUndefined()
await expect(ctx.llm.resolveModelInfo('deepseek', 'private-fast'))
.resolves.toMatchObject({ context: { contextWindow: 32_000 } })
await expect(ctx.llm.resolveModelInfo('deepseek', 'private-reasoner'))
.resolves.toMatchObject({
name: 'Private Reasoner',
description: 'Higher reasoning budget',
})
await expect(ctx.llm.resolveModelInfo('deepseek', 'arbitrary-unlisted'))
.resolves.not.toHaveProperty('context')
})
it('uses exact model capacity before the adapter-wide default', async () => {
@@ -584,12 +761,12 @@ describe('plugin registration and config', () => {
],
})
await expect(ctx.llm.resolveModelContext('deepseek', 'inherits-default'))
.resolves.toEqual({ contextWindow: 256_000 })
await expect(ctx.llm.resolveModelContext('deepseek', 'exact-override'))
.resolves.toEqual({ contextWindow: 64_000 })
await expect(ctx.llm.resolveModelContext('deepseek', 'unlisted-pass-through'))
.resolves.toEqual({ contextWindow: 256_000 })
await expect(ctx.llm.resolveModelInfo('deepseek', 'inherits-default'))
.resolves.toMatchObject({ context: { contextWindow: 256_000 } })
await expect(ctx.llm.resolveModelInfo('deepseek', 'exact-override'))
.resolves.toMatchObject({ context: { contextWindow: 64_000 } })
await expect(ctx.llm.resolveModelInfo('deepseek', 'unlisted-pass-through'))
.resolves.toMatchObject({ context: { contextWindow: 256_000 } })
})
it('allows an explicit empty model catalog', async () => {

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { CallId } from '@deepseek-ai/dsh-llm'
import { CallId, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
import { serializeMessages, serializeRequest } from '../src/serialize.ts'
@@ -174,15 +174,47 @@ describe('serializeRequest', () => {
expect(wire.tools).toBeUndefined()
})
it('applies adapter defaults for thinking and effort', () => {
const wire = serializeRequest(request({ messages: history }), { thinking: 'enabled', reasoningEffort: 'max' })
it('maps adapter-default thinking and the request reasoning effort', () => {
const wire = serializeRequest(
request({ messages: history, reasoningEffort: ReasoningEffortId('max') }),
{ thinking: 'enabled', reasoningEffort: 'high' },
)
expect(wire.thinking).toEqual({ type: 'enabled' })
expect(wire.reasoning_effort).toBe('max')
})
it('maps off to disabled thinking without a wire reasoning effort', () => {
const wire = serializeRequest(
request({ messages: history, reasoningEffort: ReasoningEffortId('off') }),
{ thinking: 'enabled', reasoningEffort: 'max' },
)
expect(wire.thinking).toEqual({ type: 'disabled' })
expect(wire.reasoning_effort).toBeUndefined()
})
it('re-enables thinking when max overrides an off default', () => {
const wire = serializeRequest(
request({ messages: history, reasoningEffort: ReasoningEffortId('max') }),
{ reasoningEffort: 'off' },
)
expect(wire.thinking).toEqual({ type: 'enabled' })
expect(wire.reasoning_effort).toBe('max')
})
it('rejects enabling thinking when the deployment is locked to disabled', () => {
expect(() => serializeRequest(
request({ messages: history, reasoningEffort: ReasoningEffortId('high') }),
{ thinking: 'disabled' },
)).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_REASONING_EFFORT' }))
})
it('disables thinking for session-title requests without changing adapter defaults', () => {
const wire = serializeRequest(
request({ messages: history, purpose: 'session-title' }),
request({
messages: history,
purpose: 'session-title',
reasoningEffort: ReasoningEffortId('max'),
}),
{ thinking: 'enabled', reasoningEffort: 'max' },
)
expect(wire.thinking).toEqual({ type: 'disabled' })
@@ -194,6 +226,19 @@ describe('serializeRequest', () => {
expect(wire.thinking).toBeUndefined()
expect(wire.reasoning_effort).toBeUndefined()
})
it('preserves an explicit enabled default without inventing a wire effort', () => {
const wire = serializeRequest(request({ messages: history }), { thinking: 'enabled' })
expect(wire.thinking).toEqual({ type: 'enabled' })
expect(wire.reasoning_effort).toBeUndefined()
})
it('rejects an effort outside the DeepSeek capability', () => {
expect(() => serializeRequest(request({
messages: history,
reasoningEffort: ReasoningEffortId('medium'),
}))).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_REASONING_EFFORT' }))
})
})
describe('review fixes: assistant content shapes', () => {

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
README.md: 8a5c955edf9418352a6916e17766b3c06b62c9ba
README.zh.md: 2b953e2aa30da29b7aa307abe3f92c14fb0906d6
# pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md
README.md: 24a4762342f1ce8e71d1a5b1733fe02257823cb8
README.zh.md: 557dc892c2eac10edc4e2fe4a0a142024942b1a9

View File

@@ -30,7 +30,9 @@ Configure credentials and deployment-specific transport settings per provider. O
Each provider name must exist in pi-ai's installed catalog and may appear only once in this plugin instance. Registration with `ctx.llm` is atomic: a collision with any provider route already owned by another adapter fails plugin loading without registering the remaining routes. Model ids are not lifecycle config; an unknown model fails before any provider request with `LlmError('UNKNOWN_MODEL')`.
The adapter exposes each configured provider's installed pi-ai models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata derived from `getModels(provider)`; request-time resolution still performs the authoritative catalog lookup, so discovery does not create a second model registry. `ctx.llm.resolveModelContext(provider, model)` performs the same exact descriptor lookup and returns its context window, keeping capacity metadata on the route-owning adapter rather than a consuming plugin.
The adapter exposes each configured provider's installed pi-ai models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata derived from `getModels(provider)`; request-time resolution still performs the authoritative catalog lookup, so discovery does not create a second model registry. `ctx.llm.resolveModelInfo(provider, model)` performs that exact descriptor lookup once and returns its identity, context window, and selectable thinking levels, keeping authoritative metadata on the route-owning adapter rather than its consumers.
The `reasoning.efforts` list is pi-ai's ordered `getSupportedThinkingLevels(model)` result without filtering or normalization, including `off` and the model-specific availability of `xhigh` or `max`. The Harness exposes each canonical pi-ai level as an opaque ID; provider/model wire spellings remain inside pi-ai's `thinkingLevelMap`. A non-reasoning model therefore exposes pi-ai's `off` choice. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and any explicit value absent from the exact model capability fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. pi-ai's common stream options represent `off` by omitting `reasoning`.
Supported profile fields are `provider`, `apiKey`, `baseURL`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, and `streamIdleTimeoutMs`. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name.
@@ -49,6 +51,7 @@ If a listener rewrites assembled assistant content, the loop drops replay state
- pi-ai tool-call arguments are parsed objects; the harness stores raw JSON strings. The adapter parses input and re-stringifies output.
- pi-ai reports failures as in-stream error events; these map to `finish {kind:'error'|'aborted', failure}` chunks. Provider-specific error text distinguishes terminal `QUOTA` from transient `RATE_LIMIT`, while text and usage signals evaluated against the resolved model's context window normalize overflow to `CONTEXT_WINDOW_EXCEEDED`. A terminal `stop` whose message carries no content blocks maps to a `finish {kind:'error'}` with code `EMPTY_RESPONSE` (retried by default policy) instead of a successful empty message.
- pi-ai folds reasoning tokens into output usage; there is no separate reasoning count to map.
- pi-ai's `off` thinking level crosses the Harness capability seam unchanged and becomes an omitted pi-ai common `reasoning` option at dispatch.
- `GenerateOptions.stop` is rejected with `UNSUPPORTED_OPTION` because pi-ai's common streaming surface cannot guarantee it across providers.
## App attribution

View File

@@ -30,7 +30,9 @@
每个提供方名称必须存在于 pi-ai 已安装 catalog 中,且在此插件实例中最多出现一次。向 `ctx.llm` 注册具有原子性:如果与另一适配器已拥有的任何提供方路由冲突,插件会加载失败,不注册剩余路由。模型 id 不是生命周期配置;未知模型会在发起任何提供方请求前以 `LlmError('UNKNOWN_MODEL')` 失败。
适配器通过 `ctx.llm.listModels(provider)` 公开每个已配置提供方已安装的 pi-ai 模型。这是从 `getModels(provider)` 派生的提供方无关 selector 元数据;请求时解析仍会执行权威 catalog 查找,因此发现不会创建第二个模型注册表。`ctx.llm.resolveModelContext(provider, model)` 执行相同的精确 descriptor 查找并返回其上下文窗口,让容量元数据保留在拥有路由的适配器上,而非消费插件上
适配器通过 `ctx.llm.listModels(provider)` 公开每个已配置提供方已安装的 pi-ai 模型。这是从 `getModels(provider)` 派生的提供方无关 selector 元数据;请求时解析仍会执行权威 catalog 查找,因此发现不会创建第二个模型注册表。`ctx.llm.resolveModelInfo(provider, model)` 执行一次精确 descriptor 查找并返回其身份、上下文窗口和可选思考级别,让权威元数据保留在拥有路由的适配器上,而非消费
`reasoning.efforts` 列表是 pi-ai 有序的 `getSupportedThinkingLevels(model)` 结果,不经筛选或规范化,其中包括 `off`,以及模型对 `xhigh``max` 的特定支持。Harness 将每个规范 pi-ai 级别公开为不透明 ID提供方模型协议拼写仍保留在 pi-ai 的 `thinkingLevelMap` 中。因此不具备推理reasoning能力的模型也会公开 pi-ai 的 `off` 选项。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;任何未出现在确切模型能力中的显式值都会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败而不会被自动调整。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`
受支持的 profile 字段是 `provider``apiKey``baseURL``headers``reasoning``thinkingBudgets``cacheRetention``transport``timeoutMs``websocketConnectTimeoutMs``streamIdleTimeoutMs`。流 idle 间隔必须是正的有限 Node 定时器延迟默认为五分钟且只覆盖未完成提供方读取不包括消费方思考时间。Harness 应用归因会胜过名称冲突的已配置标头。
@@ -49,6 +51,7 @@
- pi-ai 工具调用参数是已解析对象harness 存储原始 JSON 字符串。适配器会解析输入,并将输出重新字符串化。
- pi-ai 将失败报告为流内错误事件;它们会映射到 `finish {kind:'error'|'aborted', failure}` chunk。提供方特定错误文本会区分终端 `QUOTA` 与短暂 `RATE_LIMIT`,针对已解析模型上下文窗口评估的文本与 usage 信号则将溢出规范化为 `CONTEXT_WINDOW_EXCEEDED`。携带零个内容块消息的终止 `stop` 会映射为 `finish {kind:'error'}`code 为 `EMPTY_RESPONSE`(默认策略会重试),而非成功空消息。
- pi-ai 将 reasoning token 折叠到输出 usage 中;没有可映射的独立 reasoning 计数。
- pi-ai 的 `off` thinking 级别会原样穿过 Harness 能力 seam并在分派时变为被省略的 pi-ai 通用 `reasoning` 选项。
- `GenerateOptions.stop` 会以 `UNSUPPORTED_OPTION` 被拒绝,因为 pi-ai 的通用流式输出表层无法保证所有提供方都支持它。
## 应用归因

View File

@@ -7,13 +7,27 @@
import { streamSimple } from '@earendil-works/pi-ai/compat'
import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all'
import type { BuiltinProvider } from '@earendil-works/pi-ai/providers/all'
import { getSupportedThinkingLevels } from '@earendil-works/pi-ai'
import type {
Api,
Model,
ModelThinkingLevel,
SimpleStreamOptions,
ThinkingLevel,
} from '@earendil-works/pi-ai'
import { attributionHeaders, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, LlmModelContext, LlmModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
import {
attributionHeaders,
LlmAdapter,
LlmError,
ReasoningEffortId,
} from '@deepseek-ai/dsh-llm'
import type {
GenerateOptions,
LlmModelInfo,
LlmResolvedModelInfo,
ReasoningEffortId as ReasoningEffortIdType,
StreamChunk,
} from '@deepseek-ai/dsh-llm'
import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { resolveProfiles } from './config.ts'
import type { PiAiProviderProfile, ResolvedPiAiProviderProfile } from './config.ts'
@@ -30,7 +44,7 @@ export interface PiAiAdapterOptions {
* Resolve a catalog model dynamically and apply only the configured endpoint
* override, preserving the catalog's API/capability/compatibility metadata.
*/
function resolveModel(profile: PiAiProviderProfile, modelId: string): Model<Api> {
function resolvePiModel(profile: PiAiProviderProfile, modelId: string): Model<Api> {
const model = getBuiltinModels(profile.provider as BuiltinProvider).find(candidate => candidate.id === modelId) as Model<Api> | undefined
if (model === undefined) {
throw new LlmError(`pi-ai provider "${profile.provider}" has no catalog model "${modelId}"`, 'UNKNOWN_MODEL')
@@ -39,10 +53,14 @@ function resolveModel(profile: PiAiProviderProfile, modelId: string): Model<Api>
}
/** Copy profile stream knobs into pi-ai's common option vocabulary. */
function profileOptions(profile: PiAiProviderProfile): SimpleStreamOptions {
function profileOptions(
profile: PiAiProviderProfile,
reasoning: ModelThinkingLevel | undefined,
): SimpleStreamOptions {
const enabledReasoning: ThinkingLevel | undefined = reasoning === 'off' ? undefined : reasoning
return {
...profile.apiKey === undefined ? {} : { apiKey: profile.apiKey },
...profile.reasoning === undefined ? {} : { reasoning: profile.reasoning },
...enabledReasoning === undefined ? {} : { reasoning: enabledReasoning },
...profile.thinkingBudgets === undefined ? {} : { thinkingBudgets: profile.thinkingBudgets },
...profile.cacheRetention === undefined ? {} : { cacheRetention: profile.cacheRetention },
...profile.transport === undefined ? {} : { transport: profile.transport },
@@ -53,6 +71,20 @@ function profileOptions(profile: PiAiProviderProfile): SimpleStreamOptions {
}
}
/** Validate an explicit Harness/profile effort without invoking pi-ai's clamp. */
function resolveReasoningLevel(
model: Model<Api>,
effort: ReasoningEffortIdType | ModelThinkingLevel | undefined,
): ModelThinkingLevel | undefined {
if (effort === undefined) return undefined
const supported = getSupportedThinkingLevels(model)
if (supported.some(level => level === effort)) return effort as ModelThinkingLevel
throw new LlmError(
`pi-ai provider "${model.provider}" model "${model.id}" does not support reasoning effort "${effort}"`,
'UNSUPPORTED_REASONING_EFFORT',
)
}
/** Merge deployment headers while removing case-insensitive attribution collisions. */
function requestHeaders(headers: Readonly<Record<string, string>> | undefined): Record<string, string> {
const attribution = attributionHeaders()
@@ -87,10 +119,11 @@ export class PiAiAdapter extends LlmAdapter {
})))
}
override resolveModelContext(
override resolveModel(
provider: string,
model: string,
): Promise<LlmModelContext | undefined> {
_signal?: AbortSignal,
): Promise<LlmResolvedModelInfo> {
const profile = this.profiles.get(provider)
if (profile === undefined) {
return Promise.reject(new LlmError(
@@ -98,9 +131,26 @@ export class PiAiAdapter extends LlmAdapter {
'NO_ADAPTER',
))
}
return Promise.resolve().then(() => ({
contextWindow: resolveModel(profile, model).contextWindow,
}))
return Promise.resolve().then(() => {
const resolvedModel = resolvePiModel(profile, model)
const levels = getSupportedThinkingLevels(resolvedModel)
const defaultLevel = resolveReasoningLevel(resolvedModel, profile.reasoning)
return {
provider,
id: model,
name: resolvedModel.name,
context: { contextWindow: resolvedModel.contextWindow },
reasoning: {
efforts: levels.map(level => ({
id: ReasoningEffortId(level),
name: `${level.charAt(0).toUpperCase()}${level.slice(1)}`,
})),
...defaultLevel === undefined
? {}
: { defaultEffort: ReasoningEffortId(defaultLevel) },
},
}
})
}
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
@@ -111,7 +161,11 @@ export class PiAiAdapter extends LlmAdapter {
if (profile === undefined) {
throw new LlmError(`pi-ai adapter does not own provider "${options.provider}"`, 'NO_ADAPTER')
}
const model = resolveModel(profile, options.model)
const model = resolvePiModel(profile, options.model)
const reasoning = resolveReasoningLevel(
model,
options.reasoningEffort ?? profile.reasoning,
)
const consumer = new AbortController()
const upstream = options.signal === undefined
@@ -122,7 +176,7 @@ export class PiAiAdapter extends LlmAdapter {
try {
const events = streamSimple(model, toPiContext(options), {
...profileOptions(profile),
...profileOptions(profile, reasoning),
...options.temperature === undefined ? {} : { temperature: options.temperature },
...options.maxTokens === undefined ? {} : { maxTokens: options.maxTokens },
...options.sessionId === undefined ? {} : { sessionId: String(options.sessionId) },

View File

@@ -5,7 +5,7 @@
*/
import { getBuiltinProviders } from '@earendil-works/pi-ai/providers/all'
import type { CacheRetention, ThinkingBudgets, ThinkingLevel, Transport } from '@earendil-works/pi-ai'
import type { CacheRetention, ModelThinkingLevel, ThinkingBudgets, Transport } from '@earendil-works/pi-ai'
import z from 'schemastery'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
@@ -23,7 +23,7 @@ export interface PiAiProviderProfile {
/** Provider request headers; Harness attribution wins reserved names. */
headers?: Record<string, string>
/** Provider-neutral pi-ai reasoning level. */
reasoning?: ThinkingLevel
reasoning?: ModelThinkingLevel
/** Token budgets used by reasoning providers that support them. */
thinkingBudgets?: ThinkingBudgets
/** Prompt-cache retention preference. */
@@ -62,7 +62,7 @@ const profile = z.object({
apiKey: z.string(),
baseURL: z.string(),
headers: z.dict(z.string()),
reasoning: z.union(['minimal', 'low', 'medium', 'high', 'xhigh', 'max']),
reasoning: z.union(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']),
thinkingBudgets,
cacheRetention: z.union(['none', 'short', 'long']),
transport: z.union(['sse', 'websocket', 'websocket-cached', 'auto']),

View File

@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { CallId } from '@deepseek-ai/dsh-llm'
import LlmService, { CallId, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm'
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
import type { PiAiProviderProfile } from '@deepseek-ai/dsh-llm-pi-ai'
@@ -9,7 +9,7 @@ import { assemble, type AssembledResult } from './assemble.ts'
/**
* Real-API e2e for the pi-ai-backed adapter: V4 Flash + V4 Pro with provider
* defaults and representative high/xhigh reasoning. Mirrors the native
* defaults and representative off/high/max reasoning. Mirrors the native
* adapter's StreamChunk contract and exercises a replayed tool follow-up.
* Key-gated.
*/
@@ -74,10 +74,24 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-pi-ai e2e (real API)', () =>
expect(textOf(result).toLowerCase()).toContain('pong')
})
it('flash + reasoning off: plain text without reasoning blocks', async () => {
const ctx = await harness(FLASH)
const result = await assemble(ctx,{
model: FLASH,
reasoningEffort: ReasoningEffortId('off'),
messages: ask('Reply with exactly the word: pong'),
maxTokens: 50,
})
expect(result.finish.kind).toBe('stop')
expect(result.message.content.some(block => block.type === 'reasoning')).toBe(false)
expect(textOf(result).toLowerCase()).toContain('pong')
})
it.each([FLASH, PRO])('%s + reasoning high: reasoning blocks present', async (model) => {
const ctx = await harness(model, { reasoning: 'high' })
const ctx = await harness(model)
const result = await assemble(ctx,{
model,
reasoningEffort: ReasoningEffortId('high'),
messages: ask('Which is larger, 9.11 or 9.8? Answer with just the number.'),
maxTokens: 2000,
})
@@ -86,11 +100,12 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-pi-ai e2e (real API)', () =>
expect(textOf(result)).toContain('9.8')
})
it('pro + reasoning xhigh (wire max): tool-call round trip', async () => {
const ctx = await harness(PRO, { reasoning: 'xhigh' })
it('pro + reasoning max: tool-call round trip', async () => {
const ctx = await harness(PRO)
const first = await assemble(ctx,{
model: PRO,
reasoningEffort: ReasoningEffortId('max'),
messages: ask('What is the weather in Paris right now? Use the get_weather tool.'),
tools: [weatherTool],
maxTokens: 2000,
@@ -103,6 +118,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-pi-ai e2e (real API)', () =>
const second = await assemble(ctx,{
model: PRO,
reasoningEffort: ReasoningEffortId('max'),
messages: [
...ask('What is the weather in Paris right now? Use the get_weather tool.'),
first.message,

View File

@@ -2,7 +2,7 @@ import { createServer } from 'node:http'
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import LlmService, { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, userAgent } from '@deepseek-ai/dsh-llm'
import LlmService, { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, ReasoningEffortId, userAgent } from '@deepseek-ai/dsh-llm'
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
@@ -124,7 +124,7 @@ describe('PiAiAdapter provider routing', () => {
it('forwards common stream options and profile reasoning', async () => {
const server = await mockServer([{ events: textEvents }])
const ctx = await harness(server.url, {
reasoning: 'xhigh',
reasoning: 'max',
cacheRetention: 'none',
transport: 'sse',
timeoutMs: 5000,
@@ -148,6 +148,33 @@ describe('PiAiAdapter provider routing', () => {
})
})
it('uses a dynamic request effort and rejects unsupported efforts before network I/O', async () => {
const server = await mockServer([{ events: textEvents }, { events: textEvents }])
const ctx = await harness(server.url, { reasoning: 'max' })
await assemble(ctx, {
model: 'deepseek-v4-flash',
reasoningEffort: ReasoningEffortId('high'),
messages: [],
})
expect(server.requests[0]).toMatchObject({ reasoning_effort: 'high' })
await assemble(ctx, {
model: 'deepseek-v4-flash',
reasoningEffort: ReasoningEffortId('off'),
messages: [],
})
expect(server.requests[1]).toMatchObject({ thinking: { type: 'disabled' } })
expect(server.requests[1]).not.toHaveProperty('reasoning_effort')
await expect(assemble(ctx, {
model: 'deepseek-v4-flash',
reasoningEffort: ReasoningEffortId('xhigh'),
messages: [],
})).rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' })
expect(server.requests).toHaveLength(2)
})
it('preserves omitted profile options when constructing the adapter directly', async () => {
const server = await mockServer([{ events: textEvents }])
const ctx = new Context()
@@ -322,9 +349,69 @@ describe('provider profile lifecycle', () => {
provider: 'openai', id: 'gpt-4.1', name: 'GPT-4.1',
})
expect(models.every(model => model.provider === 'openai')).toBe(true)
const context = await ctx.llm.resolveModelContext('openai', 'gpt-4.1')
expect(context).toBeDefined()
expect(typeof context?.contextWindow).toBe('number')
const info = await ctx.llm.resolveModelInfo('openai', 'gpt-4.1')
expect(typeof info.context?.contextWindow).toBe('number')
})
it('exposes pi-ai model thinking levels verbatim without inventing a provider default', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmPiAi, {
providers: [{ provider: 'deepseek' }, { provider: 'openai' }],
})
await expect(ctx.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash'))
.resolves.toMatchObject({
reasoning: {
efforts: [
{ id: ReasoningEffortId('off'), name: 'Off' },
{ id: ReasoningEffortId('high'), name: 'High' },
{ id: ReasoningEffortId('max'), name: 'Max' },
],
},
})
const extended = await ctx.llm.resolveModelInfo('openai', 'gpt-5.6-sol')
expect(extended.reasoning?.efforts.map(effort => effort.id)).toEqual([
ReasoningEffortId('off'),
ReasoningEffortId('minimal'),
ReasoningEffortId('low'),
ReasoningEffortId('medium'),
ReasoningEffortId('high'),
ReasoningEffortId('xhigh'),
ReasoningEffortId('max'),
])
await expect(ctx.llm.resolveModelInfo('openai', 'gpt-4.1'))
.resolves.toMatchObject({
reasoning: {
efforts: [{ id: ReasoningEffortId('off'), name: 'Off' }],
},
})
})
it('uses a supported profile reasoning value as the model default and rejects an unsupported one', async () => {
const supported = new Context()
await supported.plugin(LlmService)
await supported.plugin(LlmPiAi, {
providers: [{ provider: 'deepseek', reasoning: 'max' }],
})
await expect(supported.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash'))
.resolves.toMatchObject({ reasoning: { defaultEffort: ReasoningEffortId('max') } })
const unsupported = new Context()
await unsupported.plugin(LlmService)
await unsupported.plugin(LlmPiAi, {
providers: [{ provider: 'deepseek', reasoning: 'medium' }],
})
await expect(unsupported.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash'))
.rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' })
const disabled = new Context()
await disabled.plugin(LlmService)
await disabled.plugin(LlmPiAi, {
providers: [{ provider: 'deepseek', reasoning: 'off' }],
})
await expect(disabled.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash'))
.resolves.toMatchObject({ reasoning: { defaultEffort: ReasoningEffortId('off') } })
})
it('accepts absent credentials for pi-ai ambient authentication', async () => {
@@ -376,9 +463,9 @@ describe('provider profile lifecycle', () => {
it('constructs the adapter directly and rejects routes it does not own', async () => {
const adapter = new PiAiAdapter({ profiles: [{ provider: 'openai' }] })
await expect(adapter.listModels('anthropic')).rejects.toMatchObject({ code: 'NO_ADAPTER' })
await expect(adapter.resolveModelContext('anthropic', 'claude-sonnet-4'))
await expect(adapter.resolveModel('anthropic', 'claude-sonnet-4'))
.rejects.toMatchObject({ code: 'NO_ADAPTER' })
await expect(adapter.resolveModelContext('openai', 'not-a-catalog-model'))
await expect(adapter.resolveModel('openai', 'not-a-catalog-model'))
.rejects.toMatchObject({ code: 'UNKNOWN_MODEL' })
await expect((async () => {
for await (const _chunk of adapter.stream({ provider: 'anthropic', model: 'claude-sonnet-4', messages: [] })) { /* drain */ }

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
README.md: 981f7d58802d2ff18633b09b5a4ec8a7b1bf3383
README.zh.md: 0a6535f41adb8ec90d02dbb56aec257853a19083
# pnpm run verify-translation-pairing --write packages/llm/llm/README.md
README.md: e11b997cddaacaea7fec00f73b7f5b47c60a943d
README.zh.md: e074d7f764c130b4ea3df343f566b27ef3a5dd3d

View File

@@ -13,14 +13,18 @@ An adapter registry plus a single streaming call surface, interceptable via a wa
- `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): () => void` Register one adapter instance for the given provider routes. Registration is all-or-nothing, and is disposed with the calling fiber.
- `ctx.llm.listProviders(): LlmProviderInfo[]` Describe registered provider routes in registration order.
- `ctx.llm.listModels(provider: string): Promise<LlmModelInfo[]>` Discover the models one registered provider currently advertises.
- `ctx.llm.resolveModelContext(provider: string, model: string): Promise<LlmModelContext | undefined>` Resolve authoritative context capacity for one exact route from its owning adapter.
- `ctx.llm.resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise<LlmResolvedModelInfo>` Resolve validated exact-model identity plus available context and reasoning metadata from the owning adapter, with optional cancellation for asynchronous adapters.
- `ctx.llm.resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise<LlmCallConfig>` Validate an explicit effort and materialize an adapter-configured default without clamping.
- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall>` Resolve a config and capture its current adapter registration as one cancellable, one-shot call.
- `ctx.llm.stream(options: GenerateOptions): AsyncIterable<StreamChunk>` Stream one model call as raw chunks (token-level deltas). Consumers assemble the chunks into blocks/messages with `BlockAssembler`.
`LlmService` preserves errors from final adapter selection, synchronous dispatch, iterator construction, and iteration, and binds their provenance to the exact stream handle returned for that model call. `isLlmAdapterFailure(stream, value)` reports only errors from that call's final adapter boundary; `llmFailureOf(stream, value)` returns the adjacent immutable `LlmFailure`. Nested model calls, `llm/stream` middleware, and downstream consumer failures remain unclassified for the outer call. Classification never replaces or mutates the adapter's original coded `Error`.
Provider and model metadata is a discovery surface, not a routing whitelist. `registerAdapter()` still owns provider exclusivity, while an adapter may accept model ids absent from `listModels()`; consumers must not reject a request because its model is unlisted. Returned metadata is detached and invalid or duplicate adapter entries fail with `INVALID_ADAPTER` or `INVALID_CATALOG`.
Context capacity is a separate correctness query, not a catalog decoration or global LLM setting. `resolveModelContext()` asks the adapter that owns the exact provider/model route; an adapter can describe an unlisted dynamic model, and `undefined` means only that capacity is unavailable. Invalid returned capacity fails with `INVALID_MODEL_CONTEXT`.
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` or `reasoning` fields mean only that those capabilities are unavailable. Invalid identity, context, or reasoning metadata fails with `INVALID_MODEL_INFO`, `INVALID_MODEL_CONTEXT`, or `INVALID_MODEL_REASONING`.
Reasoning identifiers are opaque adapter-owned strings rather than a core enum. An adapter publishes its ordered selectable list, including an `off` id when that model's capability API exposes one. `resolveCallConfig()` 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.
### Events
@@ -30,7 +34,7 @@ Context capacity is a separate correctness query, not a catalog decoration or gl
### Extension points
- Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(providers, adapter)` to add one or more provider routes. `GenerateOptions.provider` selects the adapter; `GenerateOptions.model` is adapter-owned and may be resolved dynamically. Override `providerInfo()` and asynchronous `listModels()` to expose selector metadata, and `resolveModelContext()` when exact capacity is known; the defaults use the route id as its name, advertise no models, and return no capacity.
- Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(providers, adapter)` to add one or more provider routes. `GenerateOptions.provider` selects the adapter; `GenerateOptions.model` is adapter-owned and may be resolved dynamically. Override `providerInfo()` and asynchronous `listModels()` to expose selector metadata, then implement `resolveModel()` when exact identity, capacity, or selectable reasoning efforts are available; an asynchronous resolver must honor its optional cancellation signal. The defaults use the route and model ids as names, advertise no models, and return no capacity or reasoning metadata.
- Wrap `llm/stream` via `ctx.on()` waterfall listeners for caching, logging, or routing. A wrapper that retries after emitting a chunk has no durable attempt boundary; shipped agent retry policy therefore uses `agent/request-error` instead.
### Content-block vocabulary (`types.ts`)
@@ -41,7 +45,7 @@ Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta
### Call configuration (`call-config.ts`)
`LlmCallConfig` is the provider + model + sampling scalars of one conversation's requests (`provider`, `model`, `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 and the loop logs a real change. `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 and defaults it 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.
### App attribution (`attribution.ts`)
@@ -64,7 +68,7 @@ Two adapters implement `LlmAdapter` on different internals: [`@deepseek-ai/dsh-l
## Model Experience
None, as this adapter registry forwards an already assembled request without adding or changing any model-bound text, schema, or message.
None, as the service adds no model-bound text, schema, or message; it only materializes and logs an adapter-configured reasoning effort.
#### KV Cache effect

View File

@@ -13,14 +13,18 @@
- `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): () => void` 为给定提供方路由注册一个适配器实例。注册要么全部成功,要么全部不生效,并且会随调用 fiber dispose。
- `ctx.llm.listProviders(): LlmProviderInfo[]` 按注册顺序描述已注册提供方路由。
- `ctx.llm.listModels(provider: string): Promise<LlmModelInfo[]>` 发现某个已注册提供方当前公布的模型。
- `ctx.llm.resolveModelContext(provider: string, model: string): Promise<LlmModelContext | undefined>` 从拥有精确路由的适配器解析权威上下文容量
- `ctx.llm.resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise<LlmResolvedModelInfo>` 从拥有精确路由的适配器解析经校验的确切模型身份、可用上下文和推理reasoning元数据异步适配器可选地支持取消
- `ctx.llm.resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise<LlmCallConfig>` 校验显式推理强度,并填入适配器配置的默认值,但不自动调整。
- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall>` 解析配置并将其当前适配器注册捕获为一次可取消、一次性调用。
- `ctx.llm.stream(options: GenerateOptions): AsyncIterable<StreamChunk>` 将一次模型调用流式输出为原始 chunktoken 级 delta。消费方使用 `BlockAssembler` 将 chunk 组装为块/消息。
`LlmService` 保留来自最终适配器选择、同步 dispatch、iterator 构造与迭代的错误,并将其溯源绑定到该次模型调用返回的精确流句柄。`isLlmAdapterFailure(stream, value)` 只报告该调用最终适配器边界的错误;`llmFailureOf(stream, value)` 返回相邻的不可变 `LlmFailure`。嵌套模型调用、`llm/stream` middleware 和下游消费方失败对外层调用仍未分类。分类绝不替换或更改适配器的原始编码 `Error`
提供方与模型元数据是发现表层,不是路由白名单。`registerAdapter()` 仍拥有提供方排他性,适配器则可以接受 `listModels()` 中不存在的模型 id消费方禁止因模型未列出而拒绝请求。返回的元数据与输入脱离无效或重复适配器配置项会以 `INVALID_ADAPTER``INVALID_CATALOG` 失败。
上下文容量是独立的正确性查询,不是 catalog 装饰或全局 LLM 设置。`resolveModelContext()`询问拥有精确提供方/模型路由的适配器;适配器可以描述未列出的动态模型,`undefined` 只表示容量不可用。无效的返回容量`INVALID_MODEL_CONTEXT` 失败。
确切模型元数据是独立的正确性查询,不是 catalog 装饰或全局 LLM 设置。`resolveModelInfo()`拥有精确提供方/模型路由的适配器查询一次;适配器可以描述未列出的动态模型,缺少 `context``reasoning` 字段只表示相应能力不可用。无效的身份、上下文或推理元数据会`INVALID_MODEL_INFO``INVALID_MODEL_CONTEXT``INVALID_MODEL_REASONING` 失败。
推理标识符是由适配器持有的不透明字符串,而非核心枚举。适配器会公布有序可选列表;模型能力 API 提供 `off` id 时,列表也会包含它。`resolveCallConfig()` 只接受与已公布标识符完全一致的值,在存在 `defaultEffort` 时填入它,否则保留提供方默认值。异步模型解析器会接收调用方的 signal并且必须在取消后迅速完成结算。`prepareCall()` 还会让精确适配器注册跨越请求头记录和最终分派,因此 HMR热模块替换不会将一个适配器的能力结果与另一个适配器的请求混用复用其一次性句柄或更改调用配置字段会以 `INVALID_PREPARED_CALL` 失败。不支持的显式或配置推理强度会在提供方 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败。
### 事件
@@ -30,7 +34,7 @@
### 扩展点
- 继承 `LlmAdapter` 并调用 `ctx.llm.registerAdapter(providers, adapter)`,添加一条或多条提供方路由。`GenerateOptions.provider` 选择适配器;`GenerateOptions.model` 属于适配器,可以动态解析。覆盖 `providerInfo()` 和异步 `listModels()` 以公开 selector 元数据,在已知精确容量时覆盖 `resolveModelContext()`默认实现将路由 id 用作名称,不公布模型,也不返回容量。
- 继承 `LlmAdapter` 并调用 `ctx.llm.registerAdapter(providers, adapter)`,添加一条或多条提供方路由。`GenerateOptions.provider` 选择适配器;`GenerateOptions.model` 属于适配器,可以动态解析。覆盖 `providerInfo()` 和异步 `listModels()` 以公开 selector 元数据;精确身份、容量或可选推理强度可用时,实现 `resolveModel()`;异步解析器必须响应其可选的取消 signal。默认实现将路由和模型 id 用作名称,不公布模型,也不返回容量或推理元数据
- 包装 `llm/stream` 时,通过 `ctx.on()` waterfall listener 实现缓存、日志或路由。发出 chunk 后重试的包装层没有持久尝试边界;因此已发布 agent 重试策略改用 `agent/request-error`
### 内容块词汇(`types.ts`
@@ -41,7 +45,7 @@
### 调用配置(`call-config.ts`
`LlmCallConfig` 是一个会话请求的提供方 + 模型 + 采样标量(`provider``model``temperature``maxTokens``stop`,每个都与同名 `GenerateOptions` 字段 1:1 映射)。它是作为请求标头一部分记录在会话日志中的每会话状态(见 dsh-session `request/header` 事件),绝不是可静默调整的每次调用旋钮:`agent/request` waterfall 会提议替换,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`
@@ -64,7 +68,7 @@
## 模型体验
无。该适配器注册表转发已组装的请求,不添加或更改任何模型边界文本、schema 或消息。
无。服务不添加或更改任何模型边界文本、schema 或消息;它只会填入并记录适配器配置的推理强度
#### KV Cache 影响

View File

@@ -38,3 +38,15 @@ export type ProviderRequestId = Branded<'ProviderRequestId'>
export function ProviderRequestId(id: string): ProviderRequestId {
return id as ProviderRequestId
}
/** Adapter-owned identifier for one model's selectable reasoning effort. */
export type ReasoningEffortId = Branded<'ReasoningEffortId'>
/**
* Brand an adapter-owned reasoning-effort identifier.
* @param id - the opaque identifier exposed by one model capability.
* @returns the same string, branded; no validation is performed.
*/
export function ReasoningEffortId(id: string): ReasoningEffortId {
return id as ReasoningEffortId
}

View File

@@ -1,24 +1,27 @@
/**
* Conversation call configuration and freeze utilities. Provider routing,
* model, and sampling values are request-header state that can affect cache
* reuse; request waterfalls replace them and the loop logs changed snapshots
* instead of allowing silent per-call drift.
* model, reasoning effort, and sampling values are request-header state that
* can affect cache reuse; request waterfalls replace them and the loop logs
* changed snapshots instead of allowing silent per-call drift.
* @module dsh-llm/call-config
*/
import type { GenerateOptions } from './types.ts'
import type { ReasoningEffortId } from './brand.ts'
/** Process-local identities of request objects assembled by dsh-agent-loop. */
const AGENT_LOOP_REQUESTS = new WeakSet<GenerateOptions>()
/**
* 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.
*/
export interface LlmCallConfig {
provider: string
model: string
reasoningEffort?: ReasoningEffortId
temperature?: number
maxTokens?: number
stop?: string[]
@@ -33,7 +36,13 @@ export interface LlmCallConfig {
* @returns whether every field (including the `stop` list, element-wise) matches.
*/
export function callConfigEquals(a: LlmCallConfig, b: LlmCallConfig): boolean {
if (a.provider !== b.provider || a.model !== b.model || a.temperature !== b.temperature || a.maxTokens !== b.maxTokens) return false
if (
a.provider !== b.provider
|| a.model !== b.model
|| a.reasoningEffort !== b.reasoningEffort
|| a.temperature !== b.temperature
|| a.maxTokens !== b.maxTokens
) return false
if (a.stop === undefined || b.stop === undefined) return a.stop === b.stop
return a.stop.length === b.stop.length && a.stop.every((s, i) => s === b.stop?.[i])
}

View File

@@ -10,14 +10,15 @@ import { Context, Service } from 'cordis'
import type {
GenerateOptions,
LlmFailure,
LlmModelContext,
LlmModelInfo,
LlmResolvedModelInfo,
LlmProviderInfo,
Message,
StreamChunk,
} from './types.ts'
import type { ProviderRequestId } from './brand.ts'
import { deepFreeze } from './call-config.ts'
import { callConfigEquals, deepFreeze } from './call-config.ts'
import type { LlmCallConfig } from './call-config.ts'
import { HarnessError } from './error.ts'
import { bindAdapterFailureScope, markLlmAdapterFailure } from './adapter-failure.ts'
import type { AdapterFailureScope } from './adapter-failure.ts'
@@ -103,6 +104,20 @@ export class LlmError extends HarnessError {
}
}
/** One model call whose config and adapter registration were resolved together. */
export interface PreparedLlmCall {
/** Detached, deep-frozen config with any adapter-owned default materialized. */
readonly config: LlmCallConfig
/**
* Dispatch this call once through the registration captured during
* preparation. The request's call-config fields must match {@link config};
* reuse or mismatch fails with `INVALID_PREPARED_CALL`.
* @param options - fully assembled request carrying the prepared config.
* @returns the chunk stream, including the `llm/stream` waterfall.
*/
stream(options: GenerateOptions): AsyncIterable<StreamChunk>
}
/**
* Provider-wire adapter for the harness message and stream vocabulary. Register implementations
* with `ctx.llm.registerAdapter(providers, adapter)`. Every provider HTTP request must include
@@ -131,17 +146,20 @@ export abstract class LlmAdapter {
}
/**
* Resolve context capacity for one model accepted by this adapter. Absence
* means the adapter does not know the capacity, not that routing is invalid.
* @param _provider - one provider route owned by this adapter.
* @param _model - exact model id passed to {@link GenerateOptions.model}.
* @returns provider-owned context metadata, or `undefined` when unavailable.
* Resolve all metadata available for one exact model. This query is
* independent of the advisory catalog and does not validate request routing.
* @param provider - one provider route owned by this adapter.
* @param model - exact model id passed to {@link GenerateOptions.model}.
* @param _signal - cancellation for this exact-model lookup; asynchronous
* implementations must settle promptly after it aborts.
* @returns provider/model identity plus any context and reasoning metadata.
*/
resolveModelContext(
_provider: string,
_model: string,
): Promise<LlmModelContext | undefined> {
return Promise.resolve(undefined)
resolveModel(
provider: string,
model: string,
_signal?: AbortSignal,
): Promise<LlmResolvedModelInfo> {
return Promise.resolve({ provider, id: model, name: model })
}
/**
@@ -157,7 +175,7 @@ export abstract class LlmAdapter {
* surface, interceptable via the `llm/stream` waterfall.
*/
export class LlmService extends Service {
private adapters = new Map<string, { adapter: LlmAdapter; provider: LlmProviderInfo }>()
private adapters = new Map<string, AdapterRegistration>()
constructor(ctx: Context) {
super(ctx, 'llm')
@@ -175,7 +193,7 @@ export class LlmService extends Service {
const dispose = this.ctx.effect(function* (this: LlmService) {
if (providers.length === 0) throw new LlmError('an adapter must register at least one provider', 'INVALID_ADAPTER')
const unique = new Set<string>()
const registrations: { adapter: LlmAdapter; provider: LlmProviderInfo }[] = []
const registrations: AdapterRegistration[] = []
for (const provider of providers) {
if (provider.length === 0) throw new LlmError('adapter provider names must be non-empty', 'INVALID_ADAPTER')
if (unique.has(provider) || this.adapters.has(provider)) {
@@ -240,29 +258,170 @@ export class LlmService extends Service {
}
/**
* Resolve context capacity from the adapter that owns one exact route.
* This query is independent of the advisory model catalog: an unlisted model
* may return metadata, while `undefined` never rejects later routing.
* Resolve and validate all metadata from the adapter that owns one exact
* route. The result is detached from adapter-owned objects; catalog
* membership remains advisory and does not control request routing.
* @param provider - registered provider route to inspect.
* @param model - exact model id passed to the adapter.
* @returns detached context metadata, or `undefined` when the adapter has none.
* @param signal - optional cancellation for adapter-owned asynchronous lookup.
* @returns exact model identity plus available context and reasoning metadata.
*/
async resolveModelContext(
async resolveModelInfo(
provider: string,
model: string,
): Promise<LlmModelContext | undefined> {
const context = await this.registration(provider).adapter.resolveModelContext(provider, model)
if (context === undefined) return undefined
if (!Number.isInteger(context.contextWindow) || context.contextWindow <= 0) {
signal?: AbortSignal,
): Promise<LlmResolvedModelInfo> {
return this.resolveModelInfoFor(this.registration(provider), model, signal)
}
private async resolveModelInfoFor(
registration: AdapterRegistration,
model: string,
signal?: AbortSignal,
): Promise<LlmResolvedModelInfo> {
const provider = registration.provider.id
const resolved = await registration.adapter.resolveModel(provider, model, signal)
if (
typeof resolved.provider !== 'string'
|| resolved.provider !== provider
|| typeof resolved.id !== 'string'
|| resolved.id !== model
|| typeof resolved.name !== 'string'
|| resolved.name.length === 0
|| (resolved.description !== undefined && typeof resolved.description !== 'string')
) {
throw new LlmError(
`adapter returned invalid exact model metadata for provider "${provider}" model "${model}"`,
'INVALID_MODEL_INFO',
)
}
const context = resolved.context
if (context !== undefined && (!Number.isInteger(context.contextWindow) || context.contextWindow <= 0)) {
throw new LlmError(
`adapter returned invalid context metadata for provider "${provider}" model "${model}"`,
'INVALID_MODEL_CONTEXT',
)
}
return { contextWindow: context.contextWindow }
const info: LlmResolvedModelInfo = {
provider,
id: model,
name: resolved.name,
...resolved.description === undefined ? {} : { description: resolved.description },
...context === undefined ? {} : { context: { contextWindow: context.contextWindow } },
}
const reasoning = resolved.reasoning
if (reasoning === undefined) return info
if (reasoning.efforts.length === 0) {
throw new LlmError(
`adapter returned invalid reasoning metadata for provider "${provider}" model "${model}"`,
'INVALID_MODEL_REASONING',
)
}
const seen = new Set<string>()
const efforts = reasoning.efforts.map((effort) => {
if (
typeof effort.id !== 'string'
|| effort.id.length === 0
|| typeof effort.name !== 'string'
|| effort.name.length === 0
|| (effort.description !== undefined && typeof effort.description !== 'string')
|| seen.has(effort.id)
) {
throw new LlmError(
`adapter returned invalid or duplicate reasoning effort metadata for provider "${provider}" model "${model}"`,
'INVALID_MODEL_REASONING',
)
}
seen.add(effort.id)
return {
id: effort.id,
name: effort.name,
...effort.description === undefined ? {} : { description: effort.description },
}
})
if (reasoning.defaultEffort !== undefined && !seen.has(reasoning.defaultEffort)) {
throw new LlmError(
`adapter returned an unknown default reasoning effort for provider "${provider}" model "${model}"`,
'INVALID_MODEL_REASONING',
)
}
return {
...info,
reasoning: {
efforts,
...reasoning.defaultEffort === undefined ? {} : { defaultEffort: reasoning.defaultEffort },
},
}
}
private registration(provider: string): { adapter: LlmAdapter; provider: LlmProviderInfo } {
/**
* 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. This
* standalone query does not bind a later dispatch; use {@link prepareCall}
* when logging and streaming must share one adapter registration.
* @param config - provider/model route and optional request controls.
* @param signal - optional cancellation for adapter-owned capability lookup.
* @returns a detached config only when a default must be materialized.
*/
async resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise<LlmCallConfig> {
return this.resolveCallConfigFor(this.registration(config.provider), config, signal)
}
private async resolveCallConfigFor(
registration: AdapterRegistration,
config: LlmCallConfig,
signal?: AbortSignal,
): Promise<LlmCallConfig> {
const reasoning = (await this.resolveModelInfoFor(registration, config.model, signal)).reasoning
const requested = config.reasoningEffort
if (reasoning === undefined) {
if (requested !== undefined) {
throw new LlmError(
`provider "${config.provider}" model "${config.model}" does not support reasoning effort "${requested}"`,
'UNSUPPORTED_REASONING_EFFORT',
)
}
return config
}
const effective = requested ?? reasoning.defaultEffort
if (effective === undefined) return config
if (!reasoning.efforts.some(effort => effort.id === effective)) {
throw new LlmError(
`provider "${config.provider}" model "${config.model}" does not support reasoning effort "${effective}"`,
'UNSUPPORTED_REASONING_EFFORT',
)
}
return requested === effective ? config : { ...config, reasoningEffort: effective }
}
/**
* Resolve one call under its current adapter registration. The returned
* one-shot handle keeps that registration across header logging and dispatch,
* so HMR cannot combine one adapter's capability result with another adapter.
* @param config - provider/model route and optional request controls.
* @param signal - optional cancellation for adapter-owned capability lookup.
* @returns a prepared config and its registration-bound stream entry point.
*/
async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall> {
const registration = this.registration(config.provider)
const resolvedConfig = deepFreeze(structuredClone(
await this.resolveCallConfigFor(registration, config, signal),
))
let dispatched = false
return Object.freeze({
config: resolvedConfig,
stream: (options: GenerateOptions): AsyncIterable<StreamChunk> => {
if (dispatched) {
throw new LlmError('a prepared LLM call can only be dispatched once', 'INVALID_PREPARED_CALL')
}
dispatched = true
return this.streamWithRegistration(options, { registration, config: resolvedConfig })
},
})
}
private registration(provider: string): AdapterRegistration {
const registration = this.adapters.get(provider)
if (!registration) throw new LlmError(`no adapter registered for provider "${provider}"`, 'NO_ADAPTER')
return registration
@@ -295,11 +454,27 @@ export class LlmService extends Service {
private async * adapterStream(
options: GenerateOptions,
failures: AdapterFailureScope,
prepared?: { registration: AdapterRegistration; config: LlmCallConfig },
): AsyncGenerator<StreamChunk> {
let iterator: AsyncIterator<StreamChunk>
try {
const adapter = this.registration(options.provider).adapter
const stream = adapter.stream(this.forAdapter(options, adapter))
const registration = prepared?.registration ?? this.registration(options.provider)
const resolvedConfig = prepared === undefined
? await this.resolveCallConfigFor(registration, options, options.signal)
: prepared.config
if (prepared !== undefined && !callConfigEquals(options, resolvedConfig)) {
throw new LlmError(
'prepared LLM call config changed before adapter dispatch',
'INVALID_PREPARED_CALL',
)
}
const resolvedOptions = prepared !== undefined || callConfigEquals(options, resolvedConfig)
? options
: Object.isFrozen(options)
? deepFreeze({ ...options, ...resolvedConfig })
: { ...options, ...resolvedConfig }
const adapter = registration.adapter
const stream = adapter.stream(this.forAdapter(resolvedOptions, adapter))
iterator = stream[Symbol.asyncIterator]()
} catch (error: unknown) {
throw markLlmAdapterFailure(failures, error)
@@ -339,18 +514,36 @@ export class LlmService extends Service {
* `LlmError` with code `NO_ADAPTER` if no adapter is registered for
* `options.provider`. Replay state is retained only when the same adapter
* instance owns its historical provider and the target provider. Final
* adapter selection, dispatch, and iteration failures retain their original
* Error identity and are tagged in a call-local scope for narrow agent-loop
* request recovery; middleware and nested-call failures remain untagged for
* the outer call.
* adapter selection remains fixed through asynchronous exact-model resolution
* and dispatch. Selection, dispatch, and iteration failures retain their
* original Error identity and are tagged in a call-local scope for narrow
* agent-loop request recovery; middleware and nested-call failures remain
* untagged for the outer call.
* @param options - the full request; `options.provider` selects the adapter.
* @returns the chunk stream, possibly wrapped by `llm/stream` listeners.
*/
stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
return this.streamWithRegistration(options)
}
private streamWithRegistration(
options: GenerateOptions,
prepared?: { registration: AdapterRegistration; config: LlmCallConfig },
): AsyncIterable<StreamChunk> {
const failures: AdapterFailureScope = new WeakMap<Error, LlmFailure>()
const stream = this.ctx.waterfall(this, 'llm/stream', options, () => this.adapterStream(options, failures))
const stream = this.ctx.waterfall(
this,
'llm/stream',
options,
() => this.adapterStream(options, failures, prepared),
)
return bindAdapterFailureScope(stream, failures)
}
}
interface AdapterRegistration {
readonly adapter: LlmAdapter
readonly provider: LlmProviderInfo
}
export default LlmService

View File

@@ -5,7 +5,7 @@
*/
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { CallId, ProviderRequestId } from './brand.ts'
import type { CallId, ProviderRequestId, ReasoningEffortId } from './brand.ts'
/** Serializable provider-boundary facts; policy decides whether they are retryable. */
export interface LlmFailure {
@@ -161,6 +161,35 @@ export interface LlmModelContext {
contextWindow: number
}
/** Display metadata for one adapter-owned reasoning effort. */
export 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
}
/** Selectable reasoning efforts for one exact provider/model route. */
export 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
}
/** Exact-route model metadata resolved by its owning adapter. */
export interface LlmResolvedModelInfo extends LlmModelInfo {
/** Provider-owned context capacity when known. */
context?: LlmModelContext
/** Adapter-owned selectable reasoning levels when exposed. */
reasoning?: LlmModelReasoningInfo
}
/**
* Raw streaming protocol emitted by adapters.
* Block indexes correlate interleaved deltas, and `block-end` carries the
@@ -201,6 +230,8 @@ export 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

View File

@@ -6,6 +6,7 @@
import { describe, expect, it } from 'vitest'
import { callConfigEquals, deepFreeze, isAgentLoopRequest, markAgentLoopRequest } from '../src/call-config.ts'
import { ReasoningEffortId } from '../src/brand.ts'
import type { GenerateOptions } from '../src/types.ts'
describe('callConfigEquals', () => {
@@ -14,6 +15,11 @@ describe('callConfigEquals', () => {
expect(callConfigEquals(base, base)).toBe(true)
expect(callConfigEquals(base, { provider: 'x', model: 'm' })).toBe(false)
expect(callConfigEquals(base, { provider: 'p', model: 'x' })).toBe(false)
expect(callConfigEquals({ ...base, reasoningEffort: ReasoningEffortId('high') }, base)).toBe(false)
expect(callConfigEquals(
{ ...base, reasoningEffort: ReasoningEffortId('high') },
{ ...base, reasoningEffort: ReasoningEffortId('high') },
)).toBe(true)
expect(callConfigEquals({ ...base, temperature: 0.5 }, base)).toBe(false)
expect(callConfigEquals({ ...base, maxTokens: 1 }, { ...base, maxTokens: 2 })).toBe(false)
expect(callConfigEquals({ ...base, stop: ['a'] }, base)).toBe(false)

View File

@@ -11,9 +11,16 @@ import LlmService, {
LlmError,
llmFailureOf,
ProviderRequestId,
ReasoningEffortId,
StreamChunk,
} from '@deepseek-ai/dsh-llm'
import type { LlmModelContext, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm'
import type {
LlmModelContext,
LlmModelInfo,
LlmModelReasoningInfo,
LlmProviderInfo,
LlmResolvedModelInfo,
} from '@deepseek-ai/dsh-llm'
class ScriptedAdapter extends LlmAdapter {
constructor(private script: StreamChunk[]) {
@@ -49,6 +56,7 @@ class CatalogAdapter extends ScriptedAdapter {
private readonly provider: LlmProviderInfo,
private readonly models: readonly LlmModelInfo[],
private readonly contexts: Readonly<Record<string, LlmModelContext>> = {},
private readonly reasoning: Readonly<Record<string, LlmModelReasoningInfo>> = {},
) {
super(SCRIPT)
}
@@ -61,11 +69,17 @@ class CatalogAdapter extends ScriptedAdapter {
return Promise.resolve(this.models)
}
override resolveModelContext(
_provider: string,
override resolveModel(
provider: string,
model: string,
): Promise<LlmModelContext | undefined> {
return Promise.resolve(this.contexts[model])
): Promise<LlmResolvedModelInfo> {
return Promise.resolve({
provider,
id: model,
name: model,
...this.contexts[model] === undefined ? {} : { context: this.contexts[model] },
...this.reasoning[model] === undefined ? {} : { reasoning: this.reasoning[model] },
})
}
}
@@ -739,8 +753,32 @@ describe('LlmService', () => {
expect(ctx.llm.listProviders()).toEqual([{ id: 'plain', name: 'plain' }])
await expect(ctx.llm.listModels('plain')).resolves.toEqual([])
await expect(ctx.llm.listModels('missing')).rejects.toMatchObject({ code: 'NO_ADAPTER' })
await expect(ctx.llm.resolveModelContext('plain', 'unlisted')).resolves.toBeUndefined()
await expect(ctx.llm.resolveModelContext('missing', 'm')).rejects.toMatchObject({ code: 'NO_ADAPTER' })
await expect(ctx.llm.resolveModelInfo('plain', 'unlisted')).resolves.toEqual({
provider: 'plain', id: 'unlisted', name: 'unlisted',
})
await expect(ctx.llm.resolveModelInfo('missing', 'm')).rejects.toMatchObject({ code: 'NO_ADAPTER' })
})
it.each([
[{ provider: 1, id: 'model', name: 'Model' }, 'non-string provider'],
[{ provider: 'other', id: 'model', name: 'Model' }, 'mismatched provider'],
[{ provider: 'route', id: 1, name: 'Model' }, 'non-string id'],
[{ provider: 'route', id: 'other', name: 'Model' }, 'mismatched id'],
[{ provider: 'route', id: 'model', name: 1 }, 'non-string name'],
[{ provider: 'route', id: 'model', name: '' }, 'empty name'],
[{ provider: 'route', id: 'model', name: 'Model', description: 1 }, 'non-string description'],
] as const)('rejects invalid exact model metadata (%s: %s)', async (metadata, _label) => {
const ctx = new Context()
await ctx.plugin(LlmService)
const adapter = new class extends ScriptedAdapter {
override resolveModel(): Promise<LlmResolvedModelInfo> {
return Promise.resolve(metadata as unknown as LlmResolvedModelInfo)
}
}(SCRIPT)
ctx.llm.registerAdapter(['route'], adapter)
await expect(ctx.llm.resolveModelInfo('route', 'model'))
.rejects.toMatchObject({ code: 'INVALID_MODEL_INFO' })
})
it('resolves detached model context independently of advisory catalog membership', async () => {
@@ -753,11 +791,241 @@ describe('LlmService', () => {
{ unlisted: source },
))
const resolved = await ctx.llm.resolveModelContext('route', 'unlisted')
expect(resolved).toEqual({ contextWindow: 32_000 })
const resolved = await ctx.llm.resolveModelInfo('route', 'unlisted')
expect(resolved.context).toEqual({ contextWindow: 32_000 })
source.contextWindow = 64_000
expect(resolved).toEqual({ contextWindow: 32_000 })
await expect(ctx.llm.resolveModelContext('route', 'other')).resolves.toBeUndefined()
expect(resolved.context).toEqual({ contextWindow: 32_000 })
await expect(ctx.llm.resolveModelInfo('route', 'other')).resolves.toEqual({
provider: 'route', id: 'other', name: 'other',
})
})
it('resolves detached adapter-owned reasoning metadata and materializes its default', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
const source = {
efforts: [
{ id: ReasoningEffortId('standard'), name: 'Standard' },
{ id: ReasoningEffortId('ultra'), name: 'Ultra', description: 'Largest budget' },
],
defaultEffort: ReasoningEffortId('standard'),
}
ctx.llm.registerAdapter(['route'], new CatalogAdapter(
{ id: 'route', name: 'Route' },
[],
{},
{ model: source },
))
const resolved = await ctx.llm.resolveModelInfo('route', 'model')
expect(resolved.reasoning).toEqual(source)
source.efforts[0]!.name = 'mutated'
expect(resolved.reasoning?.efforts[0]?.name).toBe('Standard')
await expect(ctx.llm.resolveCallConfig({ provider: 'route', model: 'model' })).resolves.toEqual({
provider: 'route',
model: 'model',
reasoningEffort: ReasoningEffortId('standard'),
})
const explicit = { provider: 'route', model: 'model', reasoningEffort: ReasoningEffortId('ultra') }
await expect(ctx.llm.resolveCallConfig(explicit)).resolves.toBe(explicit)
})
it.each([
[{ efforts: [] }, 'empty effort list'],
[{ efforts: [{ id: '', name: 'Empty' }] }, 'empty id'],
[{ efforts: [{ id: 'valid', name: '' }] }, 'empty name'],
[{ efforts: [{ id: 'valid', name: 'Valid', description: 1 }] }, 'non-string description'],
[{ efforts: [{ id: 'same', name: 'One' }, { id: 'same', name: 'Two' }] }, 'duplicate id'],
[{ efforts: [{ id: 'valid', name: 'Valid' }], defaultEffort: 'other' }, 'unknown default'],
] as const)('rejects invalid model reasoning metadata (%s: %s)', async (metadata, _label) => {
const ctx = new Context()
await ctx.plugin(LlmService)
ctx.llm.registerAdapter(['route'], new CatalogAdapter(
{ id: 'route', name: 'Route' },
[],
{},
{ model: metadata as unknown as LlmModelReasoningInfo },
))
await expect(ctx.llm.resolveModelInfo('route', 'model'))
.rejects.toMatchObject({ code: 'INVALID_MODEL_REASONING' })
})
it('rejects unsupported reasoning efforts without clamping', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
ctx.llm.registerAdapter(['route'], new CatalogAdapter(
{ id: 'route', name: 'Route' },
[],
{},
{ model: { efforts: [{ id: ReasoningEffortId('ultra'), name: 'Ultra' }] } },
))
await expect(ctx.llm.resolveCallConfig({
provider: 'route',
model: 'model',
reasoningEffort: ReasoningEffortId('standard'),
})).rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' })
await expect(ctx.llm.resolveCallConfig({
provider: 'route',
model: 'plain',
reasoningEffort: ReasoningEffortId('standard'),
})).rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' })
})
it('resolves reasoning defaults at the final adapter boundary after routing middleware', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
const adapter = new class extends RecordingAdapter {
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
const reasoning: LlmModelReasoningInfo = {
efforts: [{ id: ReasoningEffortId('standard'), name: 'Standard' }],
defaultEffort: ReasoningEffortId('standard'),
}
return Promise.resolve({
provider,
id: model,
name: model,
reasoning,
})
}
}(SCRIPT)
ctx.llm.registerAdapter(['routed'], adapter)
const disposeRouting = ctx.on('llm/stream', (options, next) => {
options.provider = 'routed'
return next()
})
for await (const _chunk of ctx.llm.stream({
provider: 'initial',
model: 'model',
messages: [],
})) { /* drain */ }
expect(adapter.lastOptions?.reasoningEffort).toBe(ReasoningEffortId('standard'))
disposeRouting()
const frozenRequest: GenerateOptions = Object.freeze({
provider: 'routed',
model: 'model',
messages: [],
})
for await (const _chunk of ctx.llm.stream(frozenRequest)) { /* drain */ }
expect(adapter.lastOptions?.reasoningEffort).toBe(ReasoningEffortId('standard'))
expect(Object.isFrozen(adapter.lastOptions)).toBe(true)
})
it('pins one adapter registration across asynchronous exact-model resolution and dispatch', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
const started = Promise.withResolvers<undefined>()
const reasoning = Promise.withResolvers<LlmModelReasoningInfo>()
const first = new class extends RecordingAdapter {
override async resolveModel(
provider: string,
model: string,
_signal?: AbortSignal,
): Promise<LlmResolvedModelInfo> {
started.resolve(undefined)
return {
provider,
id: model,
name: model,
reasoning: await reasoning.promise,
}
}
}(SCRIPT)
const disposeFirst = ctx.llm.registerAdapter(['route'], first)
const draining = (async () => {
for await (const _chunk of ctx.llm.stream({
provider: 'route',
model: 'model',
messages: [],
})) { /* drain */ }
})()
await started.promise
disposeFirst()
const second = new RecordingAdapter(SCRIPT)
ctx.llm.registerAdapter(['route'], second)
reasoning.resolve({
efforts: [{ id: ReasoningEffortId('high'), name: 'High' }],
defaultEffort: ReasoningEffortId('high'),
})
await draining
expect(first.lastOptions?.reasoningEffort).toBe(ReasoningEffortId('high'))
expect(second.lastOptions).toBeUndefined()
})
it('prepares a one-shot registration-bound call and rejects config drift', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
const adapter = new CatalogAdapter(
{ id: 'route', name: 'Route' },
[],
{},
{
model: {
efforts: [{ id: ReasoningEffortId('high'), name: 'High' }],
defaultEffort: ReasoningEffortId('high'),
},
},
)
ctx.llm.registerAdapter(['route'], adapter)
const prepared = await ctx.llm.prepareCall({ provider: 'route', model: 'model' })
expect(Object.isFrozen(prepared.config)).toBe(true)
const stream = prepared.stream({
...prepared.config,
model: 'other',
messages: [],
})
await expect((async () => {
for await (const _chunk of stream) { /* drain */ }
})()).rejects.toMatchObject({ code: 'INVALID_PREPARED_CALL' })
expect(() => prepared.stream({
...prepared.config,
messages: [],
})).toThrow(expect.objectContaining({ code: 'INVALID_PREPARED_CALL' }))
})
it('passes cancellation through exact-model resolution', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
const started = Promise.withResolvers<undefined>()
const adapter = new class extends ScriptedAdapter {
override resolveModel(
_provider: string,
_model: string,
signal?: AbortSignal,
): Promise<LlmResolvedModelInfo> {
started.resolve(undefined)
return new Promise<LlmResolvedModelInfo>((_resolve, reject) => {
if (signal === undefined) {
reject(new Error('missing reasoning signal'))
return
}
if (signal.aborted) {
reject(signal.reason instanceof Error ? signal.reason : new Error('reasoning aborted'))
return
}
signal.addEventListener('abort', () => {
reject(signal.reason instanceof Error ? signal.reason : new Error('reasoning aborted'))
}, { once: true })
})
}
}(SCRIPT)
ctx.llm.registerAdapter(['route'], adapter)
const controller = new AbortController()
const resolving = ctx.llm.resolveCallConfig(
{ provider: 'route', model: 'model' },
controller.signal,
)
await started.promise
const reason = new Error('cancel reasoning')
controller.abort(reason)
await expect(resolving).rejects.toBe(reason)
})
it.each([0, -1, 1.5, Number.NaN])(
@@ -770,7 +1038,7 @@ describe('LlmService', () => {
[],
{ model: { contextWindow } },
))
await expect(ctx.llm.resolveModelContext('route', 'model'))
await expect(ctx.llm.resolveModelInfo('route', 'model'))
.rejects.toMatchObject({ code: 'INVALID_MODEL_CONTEXT' })
},
)

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
README.md: ccb18d725feaa397520f5ee17e2900355e7d08c2
README.zh.md: 6ab48b0f5a704fa85e4bceffd886490462287f6a
# pnpm run verify-translation-pairing --write packages/llm/token-meter/README.md
README.md: 578728ded9cf51a12abcd70d541404e995028f26
README.zh.md: 51518e98c43e740822970c1a39154f550ea962dc

View File

@@ -6,7 +6,7 @@ Replay-aware token measurement through the singleton `ctx.tokenMeter` service. I
## Configuration
The estimator has no settings. It intentionally uses one fixed heuristic: four characters per token plus structural overhead for roles, blocks, and request-envelope fields. Any key is rejected, including the obsolete global `contextWindow`; model capacity belongs to the adapter that owns an exact provider/model route and is available through `ctx.llm.resolveModelContext()`.
The estimator has no settings. It intentionally uses one fixed heuristic: four characters per token plus structural overhead for roles, blocks, and request-envelope fields. Any key is rejected, including the obsolete global `contextWindow`; model capacity belongs to the adapter that owns an exact provider/model route and is available through `ctx.llm.resolveModelInfo().context`.
## Measurement contract

View File

@@ -6,7 +6,7 @@
## 配置
估算器没有设置。它有意使用一项固定启发式规则:每个 token 按四个字符估算,再加上角色、块与请求 envelope 字段的结构开销。任何 key 都会被拒绝,包括已废弃的全局 `contextWindow`;模型容量属于拥有精确提供方/模型路由的适配器,可通过 `ctx.llm.resolveModelContext()` 获取。
估算器没有设置。它有意使用一项固定启发式规则:每个 token 按四个字符估算,再加上角色、块与请求 envelope 字段的结构开销。任何 key 都会被拒绝,包括已废弃的全局 `contextWindow`;模型容量属于拥有精确提供方/模型路由的适配器,可通过 `ctx.llm.resolveModelInfo().context` 获取。
## 测量契约

View File

@@ -11,7 +11,7 @@ import { delimiter as pathDelimiter } from 'node:path'
import type { Context } from 'cordis'
import { decodeStorageRecord } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { GenerateOptions, LlmModelContext, LlmModelInfo, LlmProviderInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
import { LlmAdapter, LlmError, assertNever } from '@deepseek-ai/dsh-llm'
/**
@@ -426,12 +426,20 @@ class ReplayAdapter extends LlmAdapter {
})))
}
override resolveModelContext(provider: string, model: string): Promise<LlmModelContext | undefined> {
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
const configured = this.providers.get(provider)
/* v8 ignore next -- LlmService only asks about routes registered from this same map. */
if (configured === undefined) return Promise.resolve(undefined)
const contextWindow = configured.models?.find(candidate => candidate.id === model)?.contextWindow
return Promise.resolve(contextWindow === undefined ? undefined : { contextWindow })
if (configured === undefined) return Promise.resolve({ provider, id: model, name: model })
const configuredModel = configured.models?.find(candidate => candidate.id === model)
return Promise.resolve({
provider,
id: model,
name: configuredModel?.name ?? model,
...configuredModel?.description === undefined ? {} : { description: configuredModel.description },
...configuredModel?.contextWindow === undefined
? {}
: { context: { contextWindow: configuredModel.contextWindow } },
})
}
override stream(options: GenerateOptions): AsyncIterable<StreamChunk> {

View File

@@ -338,10 +338,12 @@ describe('installLlmReplay (through the real LlmService)', () => {
{ provider: 'deepseek', id: 'pro', name: 'Pro', description: 'Larger model' },
])
await expect(ctx.llm.listModels('empty')).resolves.toEqual([])
await expect(ctx.llm.resolveModelContext('deepseek', 'flash')).resolves.toEqual({ contextWindow: 128_000 })
await expect(ctx.llm.resolveModelContext('deepseek', 'pro')).resolves.toBeUndefined()
await expect(ctx.llm.resolveModelContext('deepseek', 'unlisted')).resolves.toBeUndefined()
await expect(ctx.llm.resolveModelContext('empty', 'unlisted')).resolves.toBeUndefined()
await expect(ctx.llm.resolveModelInfo('deepseek', 'flash')).resolves.toMatchObject({
context: { contextWindow: 128_000 },
})
await expect(ctx.llm.resolveModelInfo('deepseek', 'pro')).resolves.not.toHaveProperty('context')
await expect(ctx.llm.resolveModelInfo('deepseek', 'unlisted')).resolves.not.toHaveProperty('context')
await expect(ctx.llm.resolveModelInfo('empty', 'unlisted')).resolves.not.toHaveProperty('context')
expect(await drain(ctx.llm.stream({ provider: 'deepseek', model: 'pro', messages: [] }))).toEqual(TEXT_CHUNKS)
dispose()

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
README.md: 3d64de9f0703838cad10f8e04665ec4b985d00dc
README.zh.md: 5cf41dd76c7c28cd2d605466c7c10cfe1c1dd958
# pnpm run verify-translation-pairing --write packages/ui/tui/README.md
README.md: 84ed3de78469ab778118ee5599acfd8476b0ecd1
README.zh.md: 771b89a91077db7543713b4ce1b5fce0c30c2a16

View File

@@ -12,7 +12,7 @@ This package owns interactive terminal presentation and input only. It injects `
After terminal startup succeeds, the package provides the terminal-local `ctx.tui` extension service. A plugin that injects it can call `openOverlay()` with a component factory and constrained layout options; the host exposes the viewport, semantic theme, display-text escaping, redraw, close, and a lifetime signal, but not the pi-tui tree, terminal, focus controller, or overlay handle. Plugin overlays, the model selector, and user questions share one FIFO modal queue. Each request is an effect of the calling plugin fiber, so unload removes queued work or closes visible work before cleanup settles; terminal shutdown unloads dependents before stopping pi-tui. Overlay state is not logged or replayed. Component code is trusted and may render ANSI styling, but must pass untrusted text through `host.display()`. The [interactive-extension Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md) owns the boundary and rejected alternatives.
The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes `<session title> — <configured title>`. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelContext()` for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode and the current model with reasoning state; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. Surface replacement events rebuild the transcript so compacted history does not reappear.
The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes `<session title> — <configured title>`. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode plus the current model and any explicitly selected reasoning effort; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. Surface replacement events rebuild the transcript so compacted history does not reappear.
An embedding may provide `TuiRuntime.formatCwd` when its logical workspace label differs from the session's host directory. The override changes only the footer label; tools continue to use the session `cwd`.
@@ -24,13 +24,13 @@ When optional `ctx.sessionReferences` is mounted, the same `@` menu also offers
While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/reasoning`, `/tools`, `/redraw`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool cards collapse long bodies into a configurable head/tail preview; Ctrl+O toggles every card between its preview and full output. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle.
`/model` opens the advisory `ctx.llm` catalog as a keyboard selector: Up/Down moves, Enter selects, and Escape closes it. `/model <model>` still selects an unambiguous model id directly, while `/model <provider>/<model>` selects an exact target. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same pair through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local.
`/model` opens the advisory `ctx.llm` catalog as a keyboard selector: Up/Down moves, Shift+Tab cycles the focused model's adapter-advertised reasoning efforts in display order, Enter selects the model and effort, and Escape closes it. When an adapter does not advertise a default effort, the cycle also includes `provider default`, which clears an explicit selection; models without selectable effort metadata ignore Shift+Tab. The selector renders the exact advertised effort list—including `off` when present—and does not synthesize, clamp, or transfer an effort between models. `/model <model>` still selects an unambiguous model id directly, while `/model <provider>/<model>` selects an exact target and uses its adapter default when one exists. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same provider/model/reasoning-effort target through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local.
`/reload` (EXPERIMENTAL, dev-only) re-reads every file-backed loader config tree and applies the diff to the running app — the HMR watcher's config path, invoked manually; it needs the cordis Loader in the context and degrades to a warning without one, runs only while the agent is idle, and refuses re-entry while a reload is in flight. Module-source hot reload remains watcher-owned. When a `skills` service is mounted, `/skill:<name> [instructions]` loads that skill's instructions into the conversation as a user turn; autocomplete lists the model-invocable skills, and any skill (including a model-disabled one) is loadable by its exact name.
The footer sums the session's reported usage as `↑<uncached input> ↓<output>`, followed by `cache <rate>%` once any input has been billed — the share of billed prompt tokens (uncached input plus cache reads and writes) served from the provider cache, rounded to a percent. It also compares token-meter pressure with `ctx.llm.resolveModelContext()` for the current route (omitting the context share when the adapter has no capacity metadata) and shows the current model and tool-card mode; the right side clips first when the footer is narrow.
The footer sums the session's reported usage as `↑<uncached input> ↓<output>`, followed by `cache <rate>%` once any input has been billed — the share of billed prompt tokens (uncached input plus cache reads and writes) served from the provider cache, rounded to a percent. It also compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route (omitting the context share when the adapter has no capacity metadata) and shows the current model and tool-card mode; the right side clips first when the footer is narrow.
`/status` adds a point-in-time diagnostics card to the transcript and remains available while the agent runs. It reports the session id, title, working directory, selected provider/model, reasoning-block visibility, agent state, event/turn/step/tool-call counts, exact input/output/cache token buckets, KV-cache hit rate, token-meter context use and capacity, creation time, and latest event time. Missing titles, models, cache input, or context capacity are labeled instead of inferred. The card is terminal-only and does not duplicate the compact footer.
`/status` adds a point-in-time diagnostics card to the transcript and remains available while the agent runs. It reports the session id, title, working directory, selected provider/model, selected reasoning effort or default behavior, reasoning-block visibility, agent state, event/turn/step/tool-call counts, exact input/output/cache token buckets, KV-cache hit rate, token-meter context use and capacity, creation time, and latest event time. Missing titles, models, cache input, or context capacity are labeled instead of inferred. The card is terminal-only and does not duplicate the compact footer.
`/resume` opens a full-viewport keyboard selector over the current workspace instead of a centered dialog. Its focused search field starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored inside the field. Candidates are sorted by last logged activity and searchable by log-backed title or session id; each row reports current/live/persisted state, last turn outcome, recent provider/model, and durable goal phase when present. Up/Down and Page Up/Page Down navigate, Enter resumes, Escape clears a non-empty search before a second Escape cancels, and Ctrl+C cancels directly. The current session, a session already live in this runtime, an unreadable log, a mismatched cwd, or a session whose logged provider has no current adapter remains visible but disabled. Selection repeats those checks and requires the current agent to be idle before flushing the current session. The TUI then stops the terminal UI and calls the optional host-owned `TuiRuntime.handoffResume`; where `process.execve` is available, the shipped `dsh` host disposes the app and replaces its process. Resume restores the same `SessionId`, transcript, title, todos, and durable goal; goal activation remains disarmed and the TUI asks for human confirmation or `/goal resume`.
@@ -49,7 +49,7 @@ The footer sums the session's reported usage as `↑<uncached input> ↓<output>
| `maxResumeOptions` | `8` | Visible sessions in the resume selector |
| `questionDialogWidth` | `200` | Question-panel width in columns, clamped to the terminal |
| `questionDialogMaxHeight` | `20` | Question-panel maximum rows |
| `modelDialogWidth` | `72` | Model-selector width in columns |
| `modelDialogWidth` | `76` | Model-selector width in columns |
| `modelDialogMaxHeight` | `20` | Model-selector maximum rows |
| `fileSearchMaxResults` | `20` | Maximum file and directory candidates shown for one `@` query |
| `fileSearchMaxEntries` | `10000` | Maximum paths retained in the bounded workspace index used by bare fuzzy queries |
@@ -116,7 +116,7 @@ The fixed instruction is part of the stable system-prompt prefix and is reusable
#### What the model sees
The `/model` command text and keyboard-selector input are not logged or sent. New steps receive the selected provider/model pair in both prompt variables and request routing.
The `/model` command text and keyboard-selector input are not logged or sent. New steps receive the selected provider/model route in prompt variables and the selected provider/model/reasoning-effort target in request routing.
#### Token effect

View File

@@ -12,7 +12,7 @@ DeepSeek Harness agent智能体的交互式终端入口基于 [`@earend
终端成功启动后,本包会提供终端本地的 `ctx.tui` 扩展服务。注入该服务的插件可以使用组件工厂和受限布局选项调用 `openOverlay()`;宿主会公开 viewport、语义化主题、显示文本转义、重绘、关闭和生命周期信号但不公开 pi-tui 树、终端、焦点控制器或 overlay 句柄。插件 overlay、模型选择器和用户问题共用一个 FIFO 模态队列。每个请求都是调用方插件 fiber 的 effect因此卸载会移除排队工作或在清理结算前关闭可见工作终端关闭会先卸载依赖项再停止 pi-tui。Overlay 状态不会记录或回放。组件代码受信任,可以渲染 ANSI 样式,但必须通过 `host.display()` 处理不受信任文本。[交互式扩展 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md)持有该边界和未采用的替代方案。
TUI 从活跃会话表层重建已恢复历史,渲染 Markdown 响应与 reasoning将每个工具的 `presentCall` / `presentResult` 意图应用到终端、diff 或通用卡片,把最新的 `todo/write` 计划保留在编辑器上方,并在左下方宽键盘面板中展示 `ctx.userInteraction` 问题,包含进度、编号选项和对齐说明。最新记录的会话标题成为 header 副标题;标题不存在时使用 `welcome`,终端窗口标题则变为 `<session title> — <configured title>`。持久 `llm/retry` 事件会撤回失败步骤的实时 chunk并在 transcript文本记录中渲染计划重试次数、延迟和失败成功、耗尽与取消随后通过普通会话事件结算。Footer 会对每个已记录模型步骤的用量只计一次,包括失败尝试;对于没有用量 chunk 的日志,以已提交消息的用量回退。其空闲视图会比较 token-meter 压力与当前路由的 `ctx.llm.resolveModelContext()`;适配器没有容量元数据时显示 `context unknown`,并显示工具卡片模式、当前模型和 reasoning 状态。Agent 运行时,这些摘要会替换为已经过工作时间指示器和 `esc interrupt`。表层替换事件会重建 transcript使经过压缩compaction的历史不会再次出现。
TUI 从活跃会话表层重建已恢复历史,渲染 Markdown 响应与 reasoning将每个工具的 `presentCall` / `presentResult` 意图应用到终端、diff 或通用卡片,把最新的 `todo/write` 计划保留在编辑器上方,并在左下方宽键盘面板中展示 `ctx.userInteraction` 问题,包含进度、编号选项和对齐说明。最新记录的会话标题成为 header 副标题;标题不存在时使用 `welcome`,终端窗口标题则变为 `<session title> — <configured title>`。持久 `llm/retry` 事件会撤回失败步骤的实时 chunk并在 transcript文本记录中渲染计划重试次数、延迟和失败成功、耗尽与取消随后通过普通会话事件结算。Footer 会对每个已记录模型步骤的用量只计一次,包括失败尝试;对于没有用量 chunk 的日志,以已提交消息的用量回退。其空闲视图会 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较;适配器没有容量元数据时显示 `context unknown`,并显示工具卡片模式、当前模型,以及任何显式选择的推理强度。Agent 运行时,这些摘要会替换为已经过工作时间指示器和 `esc interrupt`。表层替换事件会重建 transcript使经过压缩compaction的历史不会再次出现。
如果逻辑工作区标签与会话宿主目录不同,嵌入方可以提供 `TuiRuntime.formatCwd`。该覆盖只改变 footer 标签;工具仍使用会话 `cwd`
@@ -24,13 +24,13 @@ TUI 从活跃会话表层重建已恢复历史,渲染 Markdown 响应与 reaso
Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help``/model``/clear``/reasoning``/tools``/redraw``/reload``/resume``/status``/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help``/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具它显示该阶段已经过时间和运行中的步骤总数每秒刷新并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标每条消息排空后随即清除。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片把长主体折叠为可配置的头尾预览Ctrl+O 在预览与完整输出之间切换所有卡片。Ctrl+R 切换 reasoningCtrl+L 重绘Ctrl+D 在空闲时退出。
`/model` 将建议性的 `ctx.llm` catalog 打开为键盘选择器Up/Down 移动Enter 选择Escape 关闭。`/model <model>` 仍可直接选择无歧义的模型 id`/model <provider>/<model>` 则选择精确目标。已配置目标或最新记录的请求 header 会初始化选择器;由于 catalog 仅提供建议,未列出的当前模型仍会显示。选择仅对本 TUI 会话有效。提示词组装会为一个步骤建立目标快照,替换 `{{provider}}``{{model}}`,并通过 `agent/request` 应用同一组值;因此组装期间的切换会从后续步骤开始生效。请求 header 会持久记录真正到达模型的目标,未使用的选择则只存在于进程本地。
`/model` 将建议性的 `ctx.llm` catalog 打开为键盘选择器Up/Down 移动,Shift+Tab 按显示顺序循环切换适配器为焦点模型公布的推理强度,Enter 选择模型和推理强度Escape 关闭。适配器未公布默认推理强度时,循环还会包含 `provider default`,该项会清除显式选择;没有可选推理强度元数据的模型会忽略 Shift+Tab。选择器会原样呈现公布的推理强度列表包括存在时的 `off`),不会合成、自动调整或在模型之间转移推理强度。`/model <model>` 仍可直接选择无歧义的模型 id`/model <provider>/<model>` 则选择精确目标,并在存在时使用其适配器默认值。已配置目标或最新记录的请求 header 会初始化选择器;由于 catalog 仅提供建议,未列出的当前模型仍会显示。选择仅对本 TUI 会话有效。提示词组装会为一个步骤建立目标快照,替换 `{{provider}}``{{model}}`,并通过 `agent/request` 应用同一个提供方/模型/推理强度目标;因此组装期间的切换会从后续步骤开始生效。请求 header 会持久记录真正到达模型的目标,未使用的选择则只存在于进程本地。
`/reload`(实验性,仅开发环境)会重新读取所有基于文件的 loader 配置树,并把 diff 应用到运行中 app它手动调用 HMR热模块替换watcher 的配置路径;上下文中必须有 cordis Loader否则退化为警告。它只在 agent 空闲时运行,并拒绝 reload 进行期间的再次进入。模块源代码热重载仍由 watcher 持有。挂载 `skills` 服务后,`/skill:<name> [instructions]` 会把该 skill 的指令作为一个 user 轮次加载到会话中;自动补全列出模型可调用的 skill任何 skill包括模型禁用的 skill都可通过精确名称加载。
Footer 将会话报告的用量汇总为 `↑<uncached input> ↓<output>`;任何输入计费后,后面会显示 `cache <rate>%`,表示提供方缓存服务的已计费提示词 token 占比(未缓存输入加缓存读写),并四舍五入为百分比。它还会比较 token-meter 压力与当前路由的 `ctx.llm.resolveModelContext()`适配器没有容量元数据时省略上下文占比并显示当前模型和工具卡片模式footer 过窄时,右侧会优先裁剪。
Footer 将会话报告的用量汇总为 `↑<uncached input> ↓<output>`;任何输入计费后,后面会显示 `cache <rate>%`,表示提供方缓存服务的已计费提示词 token 占比(未缓存输入加缓存读写),并四舍五入为百分比。它还会 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较适配器没有容量元数据时省略上下文占比并显示当前模型和工具卡片模式footer 过窄时,右侧会优先裁剪。
`/status` 会向 transcript 添加一张时间点诊断卡片,并在 agent 运行时保持可用。它报告会话 id、标题、工作目录、所选提供方模型、reasoning 块可见性、agent 状态、事件/轮次/步骤/工具调用计数、精确输入/输出/缓存 token bucket、KV-cache 命中率、token-meter 上下文用量与容量、创建时间和最新事件时间。缺失标题、模型、缓存输入或上下文容量时会明确标记,而非推断。该卡片只存在于终端,不会重复紧凑 footer。
`/status` 会向 transcript 添加一张时间点诊断卡片,并在 agent 运行时保持可用。它报告会话 id、标题、工作目录、所选提供方模型、所选推理强度或默认行为、reasoning 块可见性、agent 状态、事件/轮次/步骤/工具调用计数、精确输入/输出/缓存 token bucket、KV-cache 命中率、token-meter 上下文用量与容量、创建时间和最新事件时间。缺失标题、模型、缓存输入或上下文容量时会明确标记,而非推断。该卡片只存在于终端,不会重复紧凑 footer。
`/resume` 会针对当前工作区打开全 viewport 键盘选择器,而非居中对话框。获得焦点的搜索字段紧跟搜索 glyph 开始,并发出 pi-tui 的 cursor marker使终端 IME 组合保持锚定在字段内。候选项按最近记录的活动排序,可按日志支持的标题或会话 id 搜索;每行报告 current/live/persisted 状态、上一轮次结果、近期提供方模型以及存在时的持久目标阶段。Up/Down 与 Page Up/Page Down 导航Enter 恢复Escape 会先清除非空搜索再次按下才取消Ctrl+C 则直接取消。当前会话、已在本运行时中活跃的会话、不可读日志、cwd 不匹配或日志所记提供方没有当前适配器的会话仍会显示,但不可选择。选择时会重复这些检查,并要求当前 agent 空闲,随后 flush 当前会话。TUI 接着停止终端 UI并调用由宿主持有的可选 `TuiRuntime.handoffResume`;存在 `process.execve` 时,发布的 `dsh` 宿主会对 app 执行 dispose资源释放并替换自身进程。恢复操作保留相同的 `SessionId`、transcript、标题、todo 和持久目标目标激活仍保持解除TUI 会要求用户确认或执行 `/goal resume`
@@ -49,7 +49,7 @@ Footer 将会话报告的用量汇总为 `↑<uncached input> ↓<output>`;任
| `maxResumeOptions` | `8` | 恢复选择器中可见的会话数 |
| `questionDialogWidth` | `200` | 问题面板宽度(列数),以终端宽度为上限 |
| `questionDialogMaxHeight` | `20` | 问题面板最大行数 |
| `modelDialogWidth` | `72` | 模型选择器宽度(列数) |
| `modelDialogWidth` | `76` | 模型选择器宽度(列数) |
| `modelDialogMaxHeight` | `20` | 模型选择器最大行数 |
| `fileSearchMaxResults` | `20` | 一次 `@` 查询显示的最大文件和目录候选数 |
| `fileSearchMaxEntries` | `10000` | 无路径模糊查询使用的有界工作区索引最多保留的路径数 |
@@ -116,7 +116,7 @@ Paths prefixed with @ are files explicitly referenced by the user. Use the read
#### 模型看到的内容
`/model` 命令文本和键盘选择器输入均不会记录或发送。新步骤会在提示词变量请求路由中同时收到所选提供方/模型
`/model` 命令文本和键盘选择器输入均不会记录或发送。新步骤会在提示词变量中收到所选提供方/模型路由,并在请求路由中收到所选提供方/模型/推理强度目标
#### Token 影响

View File

@@ -31,6 +31,7 @@ import {
type EditorTheme,
type Focusable,
type MarkdownTheme,
type SelectItem,
type SelectListTheme,
type SlashCommand,
type Terminal,
@@ -53,6 +54,8 @@ import { assertNever, errorChain } from '@deepseek-ai/dsh-llm'
import type {
ContentBlock,
LlmModelInfo,
LlmModelReasoningInfo,
ReasoningEffortId,
StreamChunk,
TokenUsage,
} from '@deepseek-ai/dsh-llm'
@@ -233,7 +236,7 @@ const maxModelOptionsSchema = z.number().step(1).min(1).default(8)
const maxResumeOptionsSchema = z.number().step(1).min(1).default(8)
const questionDialogWidthSchema = z.number().step(1).min(20).default(200)
const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20)
const modelDialogWidthSchema = z.number().step(1).min(20).default(72)
const modelDialogWidthSchema = z.number().step(1).min(20).default(76)
const modelDialogMaxHeightSchema = z.number().step(1).min(6).default(20)
const fileSearchMaxResultsSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_RESULTS)
const fileSearchMaxEntriesSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_ENTRIES)
@@ -356,7 +359,7 @@ export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConf
maxResumeOptions: config?.maxResumeOptions ?? 8,
questionDialogWidth: config?.questionDialogWidth ?? 200,
questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20,
modelDialogWidth: config?.modelDialogWidth ?? 72,
modelDialogWidth: config?.modelDialogWidth ?? 76,
modelDialogMaxHeight: config?.modelDialogMaxHeight ?? 20,
fileSearchMaxResults: config?.fileSearchMaxResults ?? DEFAULT_FILE_SEARCH_MAX_RESULTS,
fileSearchMaxEntries: config?.fileSearchMaxEntries ?? DEFAULT_FILE_SEARCH_MAX_ENTRIES,
@@ -580,15 +583,39 @@ function textBlocks(content: readonly ContentBlock[], type: 'text' | 'reasoning'
interface ModelChoice extends AgentLlmTarget {
modelName: string
description?: string
reasoning?: LlmModelReasoningInfo
}
interface ModelDialogSelection {
choice: ModelChoice
reasoningEffort: ReasoningEffortId | undefined
}
function targetLabel(target: AgentLlmTarget): string {
return `${target.provider}/${target.model}`
}
function compactTargetLabel(target: AgentLlmTarget): string {
return `${target.model}${target.reasoningEffort === undefined ? '' : ` ${target.reasoningEffort}`}`
}
function targetReasoningLabel(choice: ModelChoice, effort: ReasoningEffortId | undefined): string | undefined {
if (effort === undefined) return choice.reasoning === undefined ? undefined : 'provider default'
return choice.reasoning?.efforts.find(candidate => candidate.id === effort)?.name ?? effort
}
function initialTarget(agent: Agent): AgentLlmTarget | undefined {
const logged = agent.session.requestHeader()?.config
if (logged !== undefined) return { provider: logged.provider, model: logged.model }
if (logged !== undefined) {
if (logged.reasoningEffort === undefined) {
return { provider: logged.provider, model: logged.model }
}
return {
provider: logged.provider,
model: logged.model,
reasoningEffort: logged.reasoningEffort,
}
}
if (agent.options.provider === undefined || agent.options.model === undefined) return undefined
return { provider: agent.options.provider, model: agent.options.model }
}
@@ -607,11 +634,15 @@ async function readModelChoices(
) {
models.push({ provider: provider.id, id: current.model, name: current.model })
}
return models.map((model): ModelChoice => ({
provider: provider.id,
model: model.id,
modelName: model.name,
...model.description === undefined ? {} : { description: model.description },
return Promise.all(models.map(async (model): Promise<ModelChoice> => {
const reasoning = (await ctx.llm.resolveModelInfo(provider.id, model.id)).reasoning
return {
provider: provider.id,
model: model.id,
modelName: model.name,
...model.description === undefined ? {} : { description: model.description },
...reasoning === undefined ? {} : { reasoning },
}
}))
}))
return groups.flat()
@@ -1226,24 +1257,40 @@ function renderDialog(
class ModelDialog implements Component {
private readonly list: SelectList
private readonly items: Map<string, SelectItem>
private readonly choices: Map<string, ModelChoice>
private readonly efforts: Map<string, ReasoningEffortId | undefined>
private readonly currentValue: string | undefined
constructor(
choices: readonly ModelChoice[],
current: AgentLlmTarget | undefined,
maxVisible: number,
private readonly palette: Palette,
done: (choice: ModelChoice) => void,
done: (selection: ModelDialogSelection) => void,
cancel: () => void,
) {
this.list = new SelectList(choices.map(choice => ({
value: targetLabel(choice),
label: displayText(targetLabel(choice)),
description: [
displayText(choice.modelName),
...choice.description === undefined ? [] : [displayText(choice.description)],
...current?.provider === choice.provider && current.model === choice.model ? ['current'] : [],
].join(' — '),
})), maxVisible, dialogSelectTheme(palette))
this.items = new Map()
this.choices = new Map()
this.efforts = new Map()
this.currentValue = current === undefined ? undefined : targetLabel(current)
for (const choice of choices) {
const value = targetLabel(choice)
const isCurrent = current?.provider === choice.provider && current.model === choice.model
this.choices.set(value, choice)
this.efforts.set(
value,
isCurrent
? current.reasoningEffort ?? choice.reasoning?.defaultEffort
: choice.reasoning?.defaultEffort,
)
this.items.set(value, {
value,
label: displayText(value),
description: this.describeChoice(choice, isCurrent),
})
}
this.list = new SelectList([...this.items.values()], maxVisible, dialogSelectTheme(palette))
const currentIndex = current === undefined
? 0
: choices.findIndex(choice => choice.provider === current.provider && choice.model === current.model)
@@ -1252,17 +1299,58 @@ class ModelDialog implements Component {
const selected = choices.find(choice => targetLabel(choice) === item.value)
/* v8 ignore next -- SelectList only returns values built from `choices`. */
if (selected === undefined) return
done(selected)
done({
choice: selected,
reasoningEffort: this.efforts.get(item.value),
})
}
this.list.onCancel = cancel
}
private describeChoice(choice: ModelChoice, isCurrent: boolean): string {
const selectedEffort = this.efforts.get(targetLabel(choice))
const effort = choice.reasoning?.efforts.find(candidate => candidate.id === selectedEffort)
const effortLabel = selectedEffort === undefined
? choice.reasoning === undefined ? undefined : 'provider default'
: effort?.name ?? selectedEffort
return [
displayText(choice.modelName),
...choice.description === undefined ? [] : [displayText(choice.description)],
...effortLabel === undefined ? [] : [displayText(effortLabel)],
...isCurrent ? ['current'] : [],
].join(' — ')
}
private cycleReasoningEffort(): void {
const selectedItem = this.list.getSelectedItem()
/* v8 ignore next -- the dialog is opened only for a non-empty catalog. */
if (selectedItem === null) return
const choice = this.choices.get(selectedItem.value)
if (choice?.reasoning === undefined) return
const current = this.efforts.get(selectedItem.value)
const efforts: Array<ReasoningEffortId | undefined> = [
...choice.reasoning.defaultEffort === undefined ? [undefined] : [],
...choice.reasoning.efforts.map(effort => effort.id),
]
const currentIndex = efforts.indexOf(current)
const next = efforts[(currentIndex + 1) % efforts.length]
this.efforts.set(selectedItem.value, next)
const item = this.items.get(selectedItem.value)
/* v8 ignore next -- items and choices are constructed from the same values. */
if (item === undefined) return
item.description = this.describeChoice(choice, selectedItem.value === this.currentValue)
}
invalidate(): void {
this.list.invalidate()
}
handleInput(data: string): void {
this.list.handleInput(data)
if (matchesKey(data, Key.shift(Key.tab))) {
this.cycleReasoningEffort()
} else {
this.list.handleInput(data)
}
this.invalidate()
}
@@ -1271,7 +1359,7 @@ class ModelDialog implements Component {
return renderDialog('Select model', [
...this.list.render(innerWidth),
'',
this.palette.dim('↑/↓ navigate • Enter select • Esc cancel'),
this.palette.dim('↑/↓ navigate • Shift+Tab reasoning • Enter select • Esc cancel'),
], width, this.palette)
}
}
@@ -1938,7 +2026,7 @@ export function createTuiChat(
() => sessionTitle ?? config.welcome,
palette,
resolved.color && resolved.truecolor,
() => target.current?.model,
() => target.current === undefined ? undefined : compactTargetLabel(target.current),
)
const footer = new FooterComponent(
agent,
@@ -1946,7 +2034,7 @@ export function createTuiChat(
() => toolsExpanded,
() => tokens,
runtime.formatCwd,
() => target.current?.model,
() => target.current === undefined ? undefined : compactTargetLabel(target.current),
() => contextWindow === undefined
? undefined
: Math.min(100, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens / contextWindow * 100)),
@@ -2019,8 +2107,8 @@ export function createTuiChat(
contextWindow = undefined
const resolution = selected === undefined
? Promise.resolve({ kind: 'resolved', contextWindow: undefined } as const)
: ctx.llm.resolveModelContext(selected.provider, selected.model).then(
context => ({ kind: 'resolved', contextWindow: context?.contextWindow } as const),
: ctx.llm.resolveModelInfo(selected.provider, selected.model).then(
info => ({ kind: 'resolved', contextWindow: info.context?.contextWindow } as const),
(error: unknown) => ({ kind: 'error', error } as const),
)
contextResolution = resolution
@@ -2036,14 +2124,31 @@ export function createTuiChat(
}
resolveContextWindow(target.current)
const selectModel = (selected: ModelChoice): void => {
if (target.current?.provider === selected.provider && target.current.model === selected.model) {
appendNotice(`Model is already ${targetLabel(selected)}.`)
const selectModel = (
selected: ModelChoice,
explicitReasoning?: { effort: ReasoningEffortId | undefined },
): void => {
const sameRoute = target.current?.provider === selected.provider && target.current.model === selected.model
const reasoningEffort = explicitReasoning === undefined
? (sameRoute ? target.current?.reasoningEffort ?? selected.reasoning?.defaultEffort : selected.reasoning?.defaultEffort)
: explicitReasoning.effort
if (sameRoute && target.current?.reasoningEffort === reasoningEffort) {
const reasoning = targetReasoningLabel(selected, reasoningEffort)
appendNotice(`Model is already ${targetLabel(selected)}${reasoning === undefined ? '' : ` with reasoning effort ${displayText(reasoning)}`}.`)
return
}
target.current = { provider: selected.provider, model: selected.model }
target.current = {
provider: selected.provider,
model: selected.model,
...reasoningEffort === undefined ? {} : { reasoningEffort },
}
resolveContextWindow(target.current)
appendNotice(`Model selected: ${targetLabel(selected)}. New steps will use it.`)
const reasoning = targetReasoningLabel(selected, reasoningEffort)
appendNotice([
`Model selected: ${targetLabel(selected)}.`,
...reasoning === undefined ? [] : [`Reasoning effort: ${displayText(reasoning)}.`],
'New steps will use it.',
].join(' '))
}
const showModelSelector = (choices: readonly ModelChoice[]): void => {
@@ -2059,9 +2164,9 @@ export function createTuiChat(
target.current,
resolved.maxModelOptions,
palette,
(selected) => {
(selection) => {
void session.close()
selectModel(selected)
selectModel(selection.choice, { effort: selection.reasoningEffort })
},
() => { void session.close() },
),
@@ -2633,12 +2738,17 @@ export function createTuiChat(
const steps = events.filter(event => event.type === 'step/start').length
const toolCalls = events.filter(event => event.type === 'tool/call').length
const model = target.current === undefined ? 'unset' : displayText(targetLabel(target.current))
const effort = target.current === undefined
? 'unset'
: target.current.reasoningEffort === undefined
? 'default'
: displayText(target.current.reasoningEffort)
const groups: readonly (readonly StatusCardRow[])[] = [
[
['Session', displayText(agent.session.id)],
['Title', displayText(sessionTitle ?? 'untitled')],
['Directory', displayText(cwd)],
['Model', `${model} ${palette.dim(`(reasoning ${showReasoning ? 'shown' : 'hidden'})`)}`],
['Model', `${model} ${palette.dim(`(effort ${effort}; reasoning blocks ${showReasoning ? 'shown' : 'hidden'})`)}`],
],
[
['Agent', [

View File

@@ -8,7 +8,12 @@ import AgentRegistry, {
type AgentStatus,
type SendOptions,
} from '@deepseek-ai/dsh-agent'
import type { ContentBlock, LlmModelContext, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm'
import type {
ContentBlock,
LlmModelInfo,
LlmProviderInfo,
LlmResolvedModelInfo,
} from '@deepseek-ai/dsh-llm'
import CommandService from '@deepseek-ai/dsh-commands'
import SessionStore, { SessionId, type Session, type SessionHeader } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
@@ -47,7 +52,10 @@ export interface TuiHarnessOptions {
providers: LlmProviderInfo[]
models: LlmModelInfo[]
listModels?: (provider: string) => Promise<LlmModelInfo[]>
resolveModelContext?: (provider: string, model: string) => Promise<LlmModelContext | undefined>
resolveModelInfo?: (
provider: string,
model: string,
) => Promise<Pick<LlmResolvedModelInfo, 'context' | 'reasoning'>>
}
/** Provide a fake `sessionPersistence` service so resume surfaces can list sessions. */
sessionPersistence?: {
@@ -118,9 +126,20 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
return catalog.listModels?.(provider)
?? Promise.resolve(catalog.models.filter(model => model.provider === provider).map(model => ({ ...model })))
},
resolveModelContext(provider: string, model: string) {
return catalog.resolveModelContext?.(provider, model)
?? Promise.resolve({ contextWindow: options.contextWindow ?? 128_000 })
async resolveModelInfo(provider: string, model: string) {
const advertised = catalog.models.find(candidate =>
candidate.provider === provider && candidate.id === model)
const capabilities = await (catalog.resolveModelInfo?.(provider, model)
?? Promise.resolve({
context: { contextWindow: options.contextWindow ?? 128_000 },
}))
return {
provider,
id: model,
name: advertised?.name ?? model,
...advertised?.description === undefined ? {} : { description: advertised.description },
...capabilities,
}
},
} as never)
}

View File

@@ -0,0 +1,42 @@
terminal 92x32 buffer=normal length=32 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=92 viewportRow=15 bufferRow=15
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
3| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
4| " "
style 1-1 inverse
5| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
6| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 65-91 dim
7-12| <blank>
13| " ╭ Select model ────────────────────────────────────────────────────────────╮ "
style 8-83 fg=bright-blue
14| " │ deepseek/deepseek-v4-flash DeepSeek V4 Flash — High — current │ "
style 8-8 fg=bright-blue
style 38-77 fg=bright-black
style 83-83 fg=bright-blue
15| " │ → deepseek/deepseek-v4-pro DeepSeek V4 Pro — provider default │ "
style 8-8 fg=bright-blue
style 10-77 fg=bright-blue inverse
style 83-83 fg=bright-blue
16| " │ │ "
style 8-8 fg=bright-blue
style 83-83 fg=bright-blue
17| " │ ↑/↓ navigate • Shift+Tab reasoning • Enter select • Esc cancel │ "
style 8-8 fg=bright-blue
style 10-71 dim
style 83-83 fg=bright-blue
18| " ╰──────────────────────────────────────────────────────────────────────────╯ "
style 8-83 fg=bright-blue
19-31| <blank>

View File

@@ -20,23 +20,23 @@ buffer
style 0-43 dim
style 65-91 dim
7-12| <blank>
13| " ╭ Select model ────────────────────────────────────────────────────────╮ "
style 10-81 fg=bright-blue
14| " │ → deepseek/deepseek-v4-flash DeepSeek V4 Flash — current "
style 10-10 fg=bright-blue
style 12-72 fg=bright-blue inverse
style 81-81 fg=bright-blue
15| " │ deepseek/deepseek-v4-pro DeepSeek V4 Pro "
style 10-10 fg=bright-blue
style 38-60 fg=bright-black
style 81-81 fg=bright-blue
16| " "
style 10-10 fg=bright-blue
style 81-81 fg=bright-blue
17| " │ ↑/↓ navigate • Enter select • Esc cancel "
style 10-10 fg=bright-blue
style 12-51 dim
style 81-81 fg=bright-blue
18| " ╰──────────────────────────────────────────────────────────────────────╯ "
style 10-81 fg=bright-blue
13| " ╭ Select model ────────────────────────────────────────────────────────────╮ "
style 8-83 fg=bright-blue
14| " │ → deepseek/deepseek-v4-flash DeepSeek V4 Flash — High — current "
style 8-8 fg=bright-blue
style 10-77 fg=bright-blue inverse
style 83-83 fg=bright-blue
15| " │ deepseek/deepseek-v4-pro DeepSeek V4 Pro — provider default │ "
style 8-8 fg=bright-blue
style 36-77 fg=bright-black
style 83-83 fg=bright-blue
16| " │ "
style 8-8 fg=bright-blue
style 83-83 fg=bright-blue
17| " │ ↑/↓ navigate • Shift+Tab reasoning • Enter select • Esc cancel "
style 8-8 fg=bright-blue
style 10-71 dim
style 83-83 fg=bright-blue
18| " ╰──────────────────────────────────────────────────────────────────────────╯ "
style 8-83 fg=bright-blue
19-31| <blank>

View File

@@ -1,7 +1,7 @@
terminal 92x32 buffer=normal length=32 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=6 bufferRow=6
cursor hidden column=1 viewportRow=7 bufferRow=7
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
@@ -11,15 +11,17 @@ buffer
2| " deepseek-v4-pro • main-session"
style 1-32 dim
3| <blank>
4| " Model selected: deepseek/deepseek-v4-pro. New steps will use it. "
style 1-64 fg=bright-black
5| "────────────────────────────────────────────────────────────────────────────────────────────"
4| " Model selected: deepseek/deepseek-v4-pro. Reasoning effort: provider default. New steps "
style 1-91 fg=bright-black
5| " will use it. "
style 1-12 fg=bright-black
6| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
6| " "
7| " "
style 1-1 inverse
7| "────────────────────────────────────────────────────────────────────────────────────────────"
8| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
8| "deepseek-v4-pro /workspace/project ↑0 ↓0 0% context tools:collapsed"
9| "deepseek-v4-pro /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-41 dim
style 65-91 dim
9-31| <blank>
10-31| <blank>

View File

@@ -41,13 +41,13 @@ buffer
style 0-0 dim
style 3-12 fg=bright-black
style 55-55 dim
16| "│ Model: deepseek/deepseek-v4-pro (reasoning │"
16| "│ Model: deepseek/deepseek-v4-pro (effort │"
style 0-0 dim
style 3-12 fg=bright-black
style 40-55 dim
17| "│ shown) │"
17| "│ default; reasoning blocks shown) │"
style 0-0 dim
style 15-20 dim
style 15-46 dim
style 55-55 dim
18| "│ │"
style 0-0 dim

View File

@@ -25,68 +25,68 @@ buffer
style 1-9 fg=bright-magenta bold
10| " Session inspected. "
11| <blank>
12| "╭─ Session status ─────────────────────────────────────────────────╮"
12| "╭─ Session status ───────────────────────────────────────────────────────────────╮"
style 0-2 dim
style 3-16 fg=bright-blue bold
style 17-67 dim
13| "│ Session: main-session │"
style 17-81 dim
13| "│ Session: main-session │"
style 0-0 dim
style 3-12 fg=bright-black
style 67-67 dim
14| "│ Title: Inspect session diagnostics │"
style 81-81 dim
14| "│ Title: Inspect session diagnostics │"
style 0-0 dim
style 3-12 fg=bright-black
style 67-67 dim
15| "│ Directory: /workspace/project │"
style 81-81 dim
15| "│ Directory: /workspace/project │"
style 0-0 dim
style 3-12 fg=bright-black
style 67-67 dim
16| "│ Model: deepseek/deepseek-v4-pro (reasoning shown) │"
style 81-81 dim
16| "│ Model: deepseek/deepseek-v4-pro (effort default; reasoning blocks shown) │"
style 0-0 dim
style 3-12 fg=bright-black
style 40-56 dim
style 67-67 dim
17| "│ │"
style 40-79 dim
style 81-81 dim
17| "│ │"
style 0-0 dim
style 67-67 dim
18| "│ Agent: idle · 6 events · 1 turn · 1 step · 1 tool call │"
style 81-81 dim
18| "│ Agent: idle · 6 events · 1 turn · 1 step · 1 tool call │"
style 0-0 dim
style 3-12 fg=bright-black
style 67-67 dim
19| "│ │"
style 81-81 dim
19| "│ │"
style 0-0 dim
style 67-67 dim
20| "│ Tokens: 1,250 input + 340 output │"
style 81-81 dim
20| "│ Tokens: 1,250 input + 340 output │"
style 0-0 dim
style 3-12 fg=bright-black
style 67-67 dim
21| "│ KV cache: [███████████░░░░░] 67% hit (3,000 read + 250 write) │"
style 81-81 dim
21| "│ KV cache: [███████████░░░░░] 67% hit (3,000 read + 250 write) │"
style 0-0 dim
style 3-12 fg=bright-black
style 15-15 dim
style 16-26 fg=bright-blue
style 27-32 dim
style 67-67 dim
22| "│ Context: [█████░░░░░░░░░░░] 33% used (42,000 / 128,000) │"
style 81-81 dim
22| "│ Context: [█████░░░░░░░░░░░] 33% used (42,000 / 128,000) │"
style 0-0 dim
style 3-12 fg=bright-black
style 15-15 dim
style 16-20 fg=bright-blue
style 21-32 dim
style 67-67 dim
23| "│ │"
style 81-81 dim
23| "│ │"
style 0-0 dim
style 67-67 dim
24| "│ Created: 2026-07-22 09:10:11 UTC │"
style 81-81 dim
24| "│ Created: 2026-07-22 09:10:11 UTC │"
style 0-0 dim
style 3-12 fg=bright-black
style 67-67 dim
25| "│ Active: 2026-07-22 09:10:11 UTC │"
style 81-81 dim
25| "│ Active: 2026-07-22 09:10:11 UTC │"
style 0-0 dim
style 3-12 fg=bright-black
style 67-67 dim
26| "╰──────────────────────────────────────────────────────────────────╯"
style 0-67 dim
style 81-81 dim
26| "╰────────────────────────────────────────────────────────────────────────────────╯"
style 0-81 dim
27| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
28| " "

View File

@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url'
import { afterAll, describe, expect, it, vi } from 'vitest'
import type { Context } from 'cordis'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import { CallId, type ContentBlock } from '@deepseek-ai/dsh-llm'
import { CallId, ReasoningEffortId, type ContentBlock } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-llm-retry'
import { SessionId, type JsonValue, type Session } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
@@ -45,6 +45,7 @@ const CHECKPOINTS = [
'surface-after-compaction-narrow',
'surface-after-compaction-wide',
'model-selector',
'model-effort-switching',
'model-switching',
'errors-and-help',
'disposed-terminal',
@@ -632,8 +633,29 @@ describe('TUI terminal-state snapshots', () => {
await harness.terminal.dispose()
})
it('pins the model selector and selection notice', async () => {
const harness = await setupSnapshot({}, { columns: 92, rows: 32 })
it('pins the model selector, effort cycling, and provider-default selection', async () => {
const harness = await setupSnapshot({
catalog: {
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
models: [
{ provider: 'deepseek', id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' },
{ provider: 'deepseek', id: 'deepseek-v4-pro', name: 'DeepSeek V4 Pro' },
],
resolveModelInfo: (_provider, model) => Promise.resolve({
context: { contextWindow: 128_000 },
reasoning: {
efforts: [
{ id: ReasoningEffortId('off'), name: 'Off' },
{ id: ReasoningEffortId('high'), name: 'High' },
{ id: ReasoningEffortId('max'), name: 'Max' },
],
...model === 'deepseek-v4-flash'
? { defaultEffort: ReasoningEffortId('high') }
: {},
},
}),
},
}, { columns: 92, rows: 32 })
await renderAfter(harness, () => {
harness.terminal.send('/model')
harness.terminal.send('\r')
@@ -641,6 +663,13 @@ describe('TUI terminal-state snapshots', () => {
await checkpoint('model-selector', harness.terminal, { includeScrollback: true })
await renderAfter(harness, () => {
harness.terminal.send('\x1b[B')
harness.terminal.send('\x1b[Z')
harness.terminal.send('\x1b[Z')
harness.terminal.send('\x1b[Z')
harness.terminal.send('\x1b[Z')
})
await checkpoint('model-effort-switching', harness.terminal, { includeScrollback: true })
await renderAfter(harness, () => {
harness.terminal.send('\r')
})
await checkpoint('model-switching', harness.terminal, { includeScrollback: true })

View File

@@ -5,7 +5,11 @@ import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { CombinedAutocompleteProvider, type Terminal } from '@earendil-works/pi-tui'
import AgentRegistry, { agentEvents, assembleContextFor, AgentMessageId, type Agent } from '@deepseek-ai/dsh-agent'
import { type LlmCallConfig } from '@deepseek-ai/dsh-llm'
import {
ReasoningEffortId,
type LlmCallConfig,
type LlmModelReasoningInfo,
} from '@deepseek-ai/dsh-llm'
import { GOAL_CHANGE_VERSION, GoalId, renderGoalChange, type GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal'
import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands'
import SessionStore, { SessionId, type JsonValue, type SessionEvent, type SessionHeader, type TurnEndReason } from '@deepseek-ai/dsh-session'
@@ -143,7 +147,11 @@ function provideLlmCatalog(ctx: Context): void {
ctx.provide('llm', {
listProviders: () => [],
listModels: () => Promise.resolve([]),
resolveModelContext: () => Promise.resolve(undefined),
resolveModelInfo: (provider: string, model: string) => Promise.resolve({
provider,
id: model,
name: model,
}),
} as never)
}
@@ -157,7 +165,7 @@ describe('TUI config', () => {
maxResumeOptions: 8,
questionDialogWidth: 200,
questionDialogMaxHeight: 20,
modelDialogWidth: 72,
modelDialogWidth: 76,
modelDialogMaxHeight: 20,
fileSearchMaxResults: 20,
fileSearchMaxEntries: 10_000,
@@ -1157,7 +1165,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.terminal.output).toContain('restored answer')
expect(result.terminal.output).toContain('write tests')
expect(result.terminal.output).toContain('↑1.3k ↓42')
// Context resolution is async (resolveModelContext); settle before reading.
// Exact model resolution is async; settle before reading.
await tick()
expect(result.terminal.output).toContain('42% context tools:collapsed')
// Narrow terminals clip the right-hand context/tools segment first; the
@@ -1714,7 +1722,8 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.terminal.output).toContain('main-session')
expect(result.terminal.output).toContain('Inspect status \\x1b]2;unsafe\\x07')
expect(result.terminal.output).toContain('/workspace/status')
expect(result.terminal.output).toContain('deepseek/deepseek-v4-pro (reasoning hidden)')
expect(result.terminal.output).toContain('deepseek/deepseek-v4-pro (effort default; reasoning blocks')
expect(result.terminal.output).toContain('hidden)')
expect(result.terminal.output).toContain('running · 6 events · 1 turn · 1 step · 2 tool calls')
expect(result.terminal.output).toContain('1,250 input + 340 output')
expect(result.terminal.output).toContain('[███████████░░░░░] 67% hit (3,000 read + 250 write)')
@@ -1742,7 +1751,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
catalog: {
providers: [],
models: [],
resolveModelContext: () => Promise.resolve(undefined),
resolveModelInfo: () => Promise.resolve({}),
},
})
result.terminal.send('/status')
@@ -1750,7 +1759,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
await tick()
expect(result.terminal.output).toContain('untitled')
expect(result.terminal.output).toContain('unset (reasoning shown)')
expect(result.terminal.output).toContain('unset (effort unset; reasoning blocks shown)')
expect(result.terminal.output).toContain('idle · 0 events · 0 turns · 0 steps · 0 tool calls')
expect(result.terminal.output).toContain('n/a (0 read + 0 write)')
expect(result.terminal.output).toContain('7 used · capacity unknown')
@@ -2289,6 +2298,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
it('opens a keyboard selector and switches the session model without sending slash text to the agent', async () => {
const initialContext = Promise.withResolvers<{ contextWindow: number }>()
let deferInitialContext = true
const result = await setup({
agentOptions: { provider: 'alpha', model: 'a1' },
contextTokens: 50,
@@ -2300,9 +2310,42 @@ describe('pi-tui chat lifecycle and transcript', () => {
{ provider: 'beta', id: 'b1', name: 'Beta One' },
{ provider: 'beta', id: 'shared', name: 'Beta Shared' },
],
resolveModelContext: (provider, model) => provider === 'alpha' && model === 'a1'
? initialContext.promise
: Promise.resolve({ contextWindow: 200 }),
async resolveModelInfo(provider, model) {
const shouldDeferContext = provider === 'alpha' && model === 'a1' && deferInitialContext
if (shouldDeferContext) deferInitialContext = false
const context = shouldDeferContext
? await initialContext.promise
: { contextWindow: 200 }
let reasoning: LlmModelReasoningInfo | undefined
if (model === 'a1') {
reasoning = {
efforts: [
{ id: ReasoningEffortId('low'), name: 'Low' },
{ id: ReasoningEffortId('high'), name: 'High' },
],
defaultEffort: ReasoningEffortId('low'),
}
} else if (model === 'b1') {
reasoning = {
efforts: [
{ id: ReasoningEffortId('high'), name: 'High' },
{ id: ReasoningEffortId('max'), name: 'Max' },
],
defaultEffort: ReasoningEffortId('high'),
}
} else if (provider === 'alpha' && model === 'shared') {
reasoning = {
efforts: [
{ id: ReasoningEffortId('standard'), name: 'Standard' },
{ id: ReasoningEffortId('ultra'), name: 'Ultra' },
],
}
}
return {
context,
...reasoning === undefined ? {} : { reasoning },
}
},
},
})
@@ -2327,6 +2370,92 @@ describe('pi-tui chat lifecycle and transcript', () => {
result.terminal.send('\x1b')
await tick()
const providerDefaultOutput = result.terminal.output.length
result.terminal.send('/model alpha/shared')
result.terminal.send('\r')
await vi.waitFor(() => {
expect(result.terminal.output.slice(providerDefaultOutput)).toContain('Reasoning effort: provider default.')
})
result.terminal.send('/model')
result.terminal.send('\r')
await vi.waitFor(() => {
expect(result.terminal.output.slice(providerDefaultOutput)).toContain('Select model')
})
expect(result.terminal.output.slice(providerDefaultOutput)).toContain('Alpha Shared — provider default')
result.terminal.send('\x1b[Z')
await tick()
expect(result.terminal.output.slice(providerDefaultOutput)).toContain('Alpha Shared — Standard')
result.terminal.send('\r')
await tick()
expect(result.terminal.output.slice(providerDefaultOutput)).toContain('Reasoning effort: Standard.')
const resetDefaultOutput = result.terminal.output.length
result.terminal.send('/model')
result.terminal.send('\r')
await vi.waitFor(() => {
expect(result.terminal.output.slice(resetDefaultOutput)).toContain('Alpha Shared — Standard — current')
})
result.terminal.send('\x1b[Z')
await tick()
expect(result.terminal.output.slice(resetDefaultOutput)).toContain('Alpha Shared — Ultra — current')
result.terminal.send('\x1b[Z')
await tick()
expect(result.terminal.output.slice(resetDefaultOutput)).toContain('Alpha Shared — provider default')
result.terminal.send('\r')
await tick()
expect(result.terminal.output.slice(resetDefaultOutput)).toContain('Reasoning effort: provider default.')
const explicitResetSeed: LlmCallConfig = {
provider: 'beta',
model: 'b1',
reasoningEffort: ReasoningEffortId('max'),
}
await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent))
await expect(agentEvents(result.ctx, result.agent).waterfall(
'agent/request',
0,
0,
explicitResetSeed,
new AbortController().signal,
() => Promise.resolve(explicitResetSeed),
)).resolves.toEqual({ provider: 'alpha', model: 'shared' })
const nonReasoningOutput = result.terminal.output.length
result.terminal.send('/model')
result.terminal.send('\r')
await vi.waitFor(() => {
expect(result.terminal.output.slice(nonReasoningOutput)).toContain('Select model')
})
result.terminal.send('\x1b[B')
result.terminal.send('\x1b[B')
result.terminal.send('\x1b[Z')
result.terminal.send('\r')
await tick()
expect(result.terminal.output.slice(nonReasoningOutput)).toContain('Model selected: beta/shared.')
result.terminal.send('/model beta/shared')
result.terminal.send('\r')
await vi.waitFor(() => {
expect(result.terminal.output.slice(nonReasoningOutput)).toContain('Model is already beta/shared.')
})
await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent))
result.terminal.send('/model alpha/a1')
result.terminal.send('\r')
await vi.waitFor(() => {
expect(result.terminal.output.slice(nonReasoningOutput)).toContain('Reasoning effort: Low.')
})
const inheritedEffort: LlmCallConfig = {
provider: 'alpha',
model: 'a1',
reasoningEffort: ReasoningEffortId('max'),
}
await expect(agentEvents(result.ctx, result.agent).waterfall(
'agent/request',
0,
0,
inheritedEffort,
new AbortController().signal,
() => Promise.resolve(inheritedEffort),
)).resolves.toEqual({ provider: 'beta', model: 'shared' })
result.agent.status = 'running'
const runningSelectorOutput = result.terminal.output.length
result.terminal.send('/model')
@@ -2335,13 +2464,18 @@ describe('pi-tui chat lifecycle and transcript', () => {
const output = result.terminal.output.slice(runningSelectorOutput)
expect(output).toContain('Select model')
expect(output).toContain('alpha/a1')
expect(output).toContain('Alpha One — Fast — current')
expect(output).toContain('Alpha One — Fast — Low — current')
expect(output).toContain('Beta One — High')
})
result.terminal.send('\x1b[B')
result.terminal.send('\x1b[B')
result.terminal.send('\x1b[Z')
await tick()
expect(result.terminal.output).toContain('Beta One — Max')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('Model selected: beta/b1')
expect(result.terminal.output).toContain('Reasoning effort: Max.')
expect(result.agent.sent).toEqual([])
expect(result.agent.steered).toEqual([])
initialContext.resolve({ contextWindow: 100 })
@@ -2360,8 +2494,12 @@ describe('pi-tui chat lifecycle and transcript', () => {
result.agent.status = 'idle'
result.ctx.emit('agent/status', result.agent, 'idle')
await tick()
expect(result.terminal.output).toContain('b1 ')
expect(result.terminal.output).toContain('b1 max ')
expect(result.terminal.output).toContain('25% context tools:collapsed')
result.terminal.send('/status')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('beta/b1 (effort max; reasoning blocks shown)')
const assembly = await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent))
expect(assembly.variables).toMatchObject({ provider: 'beta', model: 'b1' })
@@ -2369,7 +2507,12 @@ describe('pi-tui chat lifecycle and transcript', () => {
const request = await agentEvents(result.ctx, result.agent).waterfall(
'agent/request', 1, 0, seed, new AbortController().signal, () => Promise.resolve(seed),
)
expect(request).toEqual({ provider: 'beta', model: 'b1', temperature: 0.2 })
expect(request).toEqual({
provider: 'beta',
model: 'b1',
reasoningEffort: ReasoningEffortId('max'),
temperature: 0.2,
})
await dispose(result)
})
@@ -2379,7 +2522,13 @@ describe('pi-tui chat lifecycle and transcript', () => {
catalog: { providers: [{ id: 'beta', name: 'Beta' }], models: [] },
beforeMount(session) {
session.append('request/header', {
header: { config: { provider: 'beta', model: 'private' } },
header: {
config: {
provider: 'beta',
model: 'private',
reasoningEffort: ReasoningEffortId('ultra'),
},
},
reason: 'initial',
})
},
@@ -2389,15 +2538,36 @@ describe('pi-tui chat lifecycle and transcript', () => {
await tick()
expect(resumed.terminal.output).toContain('Select model')
expect(resumed.terminal.output).toContain('beta/private')
expect(resumed.terminal.output).toContain('private — current')
expect(resumed.terminal.output).toContain('private — ultra — current')
resumed.terminal.send('\x1b')
await tick()
resumed.terminal.send('/model beta/private')
resumed.terminal.send('\r')
await tick()
expect(resumed.terminal.output).toContain('with reasoning effort ultra')
await dispose(resumed)
const resumedDefault = await setup({
catalog: {
providers: [{ id: 'alpha', name: 'Alpha' }],
models: [{ provider: 'alpha', id: 'default', name: 'Default Model' }],
},
beforeMount(session) {
session.append('request/header', {
header: { config: { provider: 'alpha', model: 'default' } },
reason: 'initial',
})
},
})
expect(resumedDefault.terminal.output).toContain('default • main-session')
await dispose(resumedDefault)
const unset = await setup({
agentOptions: {},
catalog: {
providers: [{ id: 'alpha', name: 'Alpha' }],
models: [{ provider: 'alpha', id: 'a1', name: 'Alpha One' }],
resolveModelContext: () => Promise.resolve(undefined),
resolveModelInfo: () => Promise.resolve({}),
},
})
unset.terminal.send('/model')
@@ -2429,7 +2599,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
models: [],
listModels: () => Promise.reject(new Error('catalog offline')),
resolveModelContext: () => Promise.reject(new Error('capacity offline')),
resolveModelInfo: () => Promise.reject(new Error('capacity offline')),
},
})
failed.terminal.send('/model')
@@ -2439,6 +2609,20 @@ describe('pi-tui chat lifecycle and transcript', () => {
})
expect(failed.terminal.output).toContain('Could not resolve model context: capacity offline')
await dispose(failed)
const reasoningFailed = await setup({
catalog: {
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
models: [{ provider: 'deepseek', id: 'model-1', name: 'Model One' }],
resolveModelInfo: () => Promise.reject(new Error('reasoning metadata offline')),
},
})
reasoningFailed.terminal.send('/model')
reasoningFailed.terminal.send('\r')
await vi.waitFor(() => {
expect(reasoningFailed.terminal.output).toContain('Could not read the model catalog: reasoning metadata offline')
})
await dispose(reasoningFailed)
})
it('does not render a model catalog that resolves after TUI disposal', async () => {
@@ -2480,7 +2664,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
catalog: {
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
models: [],
resolveModelContext: () => context.promise,
resolveModelInfo: () => context.promise.then(value => ({ context: value })),
},
})
await contextResult.controller.dispose()