refactor(agent-loop): simplify message machine

This commit is contained in:
_Kerman
2026-07-30 13:49:57 +08:00
parent d554ae3019
commit f2e20c1ef0
212 changed files with 1326 additions and 2382 deletions

View File

@@ -1,5 +1,5 @@
/**
* Provider-routed model-request retry policy on the agent loop's closed-step
* Provider-routed model-request retry policy on the agent loop's request
* recovery seam. Each scheduled retry is durable before its cancellable wait.
*
* @module @deepseek-ai/dsh-llm-retry
@@ -7,14 +7,13 @@
import type { Context } from 'cordis'
import z from 'schemastery'
import type { Agent, RequestError, RequestErrorAction } from '@deepseek-ai/dsh-agent'
import type { Agent, RequestErrorAction, RequestFailureContext } from '@deepseek-ai/dsh-agent'
import type { LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { providerForClosedStep } from './history.ts'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/** Durable, non-surface record of one provider-routed retry scheduled after a closed failed step. */
/** Durable, non-surface record of one provider-routed retry scheduled after a failed request attempt. */
'llm/retry': {
turn: number
step: number
@@ -172,24 +171,12 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna
async function recover(
agent: Agent,
turn: number,
step: number,
_error: RequestError,
failure: LlmFailure,
priorFailures: readonly LlmFailure[],
policy: ResolvedRetryPolicy | undefined,
context: RequestFailureContext,
signal: AbortSignal,
next: () => Promise<RequestErrorAction>,
): Promise<RequestErrorAction> {
const { turn, step, provider, failure, retryPolicy: policy } = context
if (policy === undefined) return next()
// The call-local policy belongs to the registration that served this
// failure. Recover only the durable provider identity from the header;
// downstream recovery may append later state before an always fallback.
const provider = providerForClosedStep(agent.session.events, turn, step)
/* v8 ignore next 3 -- agent-loop closes only steps whose request header was recorded */
if (provider === undefined) {
throw new Error(`llm-retry: no request provider for closed turn ${turn}/step ${step}`)
}
if (policy.mode === 'always') {
if (signal.aborted || lifetime.signal.aborted) return
const fusedSignal = AbortSignal.any([signal, lifetime.signal])
@@ -211,11 +198,10 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna
}
const policyKey = retryPolicyKey(policy)
const firstPriorTurn = turn - priorFailures.length
const priorPolicyRetry = agent.session.events.findLast((event): event is SessionEvent<'llm/retry'> =>
event.type === 'llm/retry'
&& event.data.turn >= firstPriorTurn
&& event.data.turn < turn
&& event.data.turn === turn
&& event.data.step === step
&& event.data.provider === provider
&& event.data.policyKey === policyKey,
)
@@ -241,12 +227,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna
const disposeListener = ctx.on('agent/request-error', (
agent: Agent,
turn: number,
step: number,
error: RequestError,
failure: LlmFailure,
priorFailures: readonly LlmFailure[],
policy: ResolvedRetryPolicy | undefined,
context: RequestFailureContext,
signal: AbortSignal,
next: () => Promise<RequestErrorAction>,
) => {
@@ -254,7 +235,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna
// removed. Lifetime cancellation must prevent that stale callback from
// entering a downstream policy after disposal.
if (lifetime.signal.aborted) return Promise.resolve<RequestErrorAction>(undefined)
return track(recover(agent, turn, step, error, failure, priorFailures, policy, signal, next))
return track(recover(agent, context, signal, next))
})
ctx.effect(() => async () => {

View File

@@ -41,34 +41,6 @@ function validateFailure(value: unknown, fail: InvariantFailure): asserts value
}
}
/** Find the first turn in the structured-failure retry chain containing `turn`. */
function retryChainStart(history: readonly SessionEvent[], turn: number): number {
let startIndex = history.findLastIndex(
event => event.type === 'turn/start' && event.data.turn === turn,
)
while (startIndex >= 0) {
const start = history[startIndex]
if (start?.type !== 'turn/start' || start.data.trigger.kind !== 'retry') break
let endIndex = startIndex - 1
while (endIndex >= 0 && history[endIndex]?.type !== 'turn/end') endIndex -= 1
const end = history[endIndex]
if (end?.type !== 'turn/end'
|| end.data.reason.kind !== 'error'
|| end.data.reason.failure === undefined) break
const previousStart = history.findLastIndex(
(event, index) =>
index < endIndex
&& event.type === 'turn/start'
&& event.data.turn === end.data.turn,
)
if (previousStart < 0) break
startIndex = previousStart
}
return startIndex
}
/** Validate one retry record against the open turn and most recently closed step. */
function validateRetry(
history: readonly SessionEvent[],
@@ -139,7 +111,9 @@ function validateRetry(
fail(`llm/retry provider ${provider} does not match the failed request provider ${String(routedProvider)}`)
}
const chainStart = retryChainStart(history, turn)
const chainStart = history.findLastIndex(
prior => prior.type === 'turn/start' && prior.data.turn === turn,
)
const chain = history.slice(Math.max(chainStart, 0))
const lastSuccess = chain.findLastIndex(prior => prior.type === 'assistant/message')
const chainRetries = chain.slice(lastSuccess + 1)

View File

@@ -17,7 +17,7 @@ async function setup(): Promise<Context> {
function closeStep(ctx: Context, id: string, turn = 1, step = 1) {
const session = ctx.sessions.create(SessionId(id))
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn })
session.append('step/start', { turn, step })
session.append('request/header', {
header: { config: { provider: 'mock', model: 'mock' } },
@@ -28,7 +28,7 @@ function closeStep(ctx: Context, id: string, turn = 1, step = 1) {
}
function appendRetryTurn(session: Session, turn: number) {
session.append('turn/start', { turn, trigger: { kind: 'retry' } })
session.append('turn/start', { turn })
session.append('step/start', { turn, step: 1 })
session.append('request/header', {
header: { config: { provider: 'mock', model: 'mock' } },
@@ -73,7 +73,7 @@ describe('llm-retry invariants', () => {
expect(() => {
session.append('llm/retry', { turn: 1, step: 1, ...normal })
session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, failure } })
session.append('turn/start', { turn: 2, trigger: { kind: 'retry' } })
session.append('turn/start', { turn: 2 })
session.append('step/start', { turn: 2, step: 1 })
session.append('step/end', { turn: 2, step: 1 })
session.append('llm/retry', {
@@ -169,14 +169,14 @@ describe('llm-retry invariants', () => {
}).toThrow(/open turn is 1/)
const openStep = ctx.sessions.create(SessionId('retry-invariant-open-step'))
openStep.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
openStep.append('turn/start', { turn: 1 })
openStep.append('step/start', { turn: 1, step: 1 })
expect(() => {
openStep.append('llm/retry', { turn: 1, step: 1, ...normal })
}).toThrow(/step 1 is still open/)
const noStep = ctx.sessions.create(SessionId('retry-invariant-no-step'))
noStep.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
noStep.append('turn/start', { turn: 1 })
expect(() => {
noStep.append('llm/retry', { turn: 1, step: 1, ...normal })
}).toThrow(/latest closed step is undefined/)
@@ -208,7 +208,7 @@ describe('llm-retry invariants', () => {
const mismatch = closeStep(ctx, 'retry-invariant-numbering')
mismatch.append('llm/retry', { turn: 1, step: 1, ...normal })
mismatch.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, failure } })
mismatch.append('turn/start', { turn: 2, trigger: { kind: 'retry' } })
mismatch.append('turn/start', { turn: 2 })
mismatch.append('step/start', { turn: 2, step: 1 })
mismatch.append('step/end', { turn: 2, step: 1 })
expect(() => {
@@ -218,7 +218,7 @@ describe('llm-retry invariants', () => {
const reset = closeStep(ctx, 'retry-invariant-reset')
reset.append('llm/retry', { turn: 1, step: 1, ...normal })
reset.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, failure } })
reset.append('turn/start', { turn: 2, trigger: { kind: 'retry' } })
reset.append('turn/start', { turn: 2 })
reset.append('step/start', { turn: 2, step: 1 })
reset.append('assistant/message', {
turn: 2,
@@ -234,7 +234,7 @@ describe('llm-retry invariants', () => {
}, { surfaceOp: 'append' })
reset.append('step/end', { turn: 2, step: 1 })
reset.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
reset.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } })
reset.append('turn/start', { turn: 3 })
reset.append('step/start', { turn: 3, step: 1 })
reset.append('step/end', { turn: 3, step: 1 })
expect(() => {

View File

@@ -32,7 +32,7 @@ describe.each(['jsonl', 'sqlite'] as const)('%s retry-event persistence', (kind)
const ctx = await backend(kind)
try {
const session = ctx.sessions.create(SessionId(`retry-${kind}`))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('step/start', { turn: 1, step: 1 })
session.append('request/header', {
header: { config: { provider: 'mock', model: 'mock' } },

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/README.md
README.md: d343449d1530bf70a3a8c57f883894e29c42d18f
README.zh.md: 6ac57b1e6010b58c45b516f13ec6361d47ca8d12
README.md: dc7499a6854fe9a45c1297aa2a1a67aea92eaf6f
README.zh.md: 1f5850b6e73c067aa554636d33bfd9194a4410f3

View File

@@ -16,10 +16,10 @@ An adapter registry plus a single streaming call surface, interceptable via a wa
- `ctx.llm.listModels(provider: string): Promise<LlmModelInfo[]>` Discover the models one registered provider currently advertises.
- `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.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall>` Resolve a config and capture its current adapter registration plus immutable retry policy 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`; `llmRetryPolicyOf(stream)` returns the immutable policy of the exact registration selected at that boundary, even if the route is later disposed or replaced. A call that never reaches a final adapter has no serving policy. 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`.
`LlmService` normalizes failures from final adapter selection, synchronous dispatch, iterator construction, and iteration into the stream protocol's single terminal form: `finish { kind: 'error' | 'aborted', failure }`. A failure after partial deltas may leave content blocks open; consumers discard that incomplete output. Errors from `llm/stream` middleware, nested calls, adapter cleanup, and downstream consumers remain thrown because they are plugin or consumer failures rather than model-request outcomes. A prepared call exposes the immutable retry policy captured with its exact adapter registration; a route handled entirely by middleware has no serving policy.
Provider and model metadata is a discovery surface, not a routing whitelist. `registerAdapter()` still owns provider exclusivity and captures the adapter's retry policy for each route, while an adapter may accept model ids absent from `listModels()`; consumers must not reject a request because its model is unlisted. Returned selector metadata is detached and invalid or duplicate adapter entries fail with `INVALID_ADAPTER` or `INVALID_CATALOG`.
@@ -44,7 +44,7 @@ Reasoning identifiers are opaque adapter-owned strings rather than a core enum.
Message content is an array of typed blocks: `text`, `reasoning`, `tool-call`, `tool-result`. The union is derived from the merge-extensible `ContentBlockMap`, so plugins can add block types via declaration merging. Assistant messages use a model source carrying provider/model provenance and optional adapter-private replay state. Before dispatch, `LlmService` retains that state only when the historical provider route and target provider route are currently owned by the exact same adapter instance; the adapter then decides whether it can restore or convert the state across models/providers. The core block set is limited to blocks every shipping path honors — multimodal content (images, audio, …) has no core block type; a feature that needs one adds it via the map together with the adapter/UI/compaction support that honors it.
Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`). `BlockAssembler` is the single shared implementation that assembles chunks into blocks/messages.
Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`). Every adapter outcome reaches consumers as one terminal `finish`; operational failure uses its `error` or `aborted` reason rather than throwing across the stream API. `BlockAssembler` is the single shared implementation that assembles chunks into blocks/messages.
### Call configuration (`call-config.ts`)
@@ -67,7 +67,7 @@ Every product adapter sends application identity on provider HTTP requests. `att
### Real adapters
Two adapters implement `LlmAdapter` on different internals: [`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) uses direct fetch with `eventsource-parser` SSE framing for the `deepseek` route, while [`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) dynamically resolves configured provider/model pairs through `@earendil-works/pi-ai`. Both follow the `StreamChunk` conventions in `types.ts`: usage precedes finish, tool arguments remain raw strings, and errors take one of two sanctioned paths. See [the twin LLM adapters](../../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the design rationale.
Two adapters implement `LlmAdapter` on different internals: [`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) uses direct fetch with `eventsource-parser` SSE framing for the `deepseek` route, while [`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) dynamically resolves configured provider/model pairs through `@earendil-works/pi-ai`. Both follow the `StreamChunk` conventions in `types.ts`: usage precedes finish and tool arguments remain raw strings. Adapter implementations may throw or emit a failure finish internally; `LlmService` exposes both as a terminal failure finish. See [the twin LLM adapters](../../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the adapter rationale and [the terminal-failure decision](../../../.agents/notes/implemented/architecture/2026-07-29-terminal-llm-stream-failures.md) for the service boundary.
## Model Experience

View File

@@ -16,10 +16,10 @@
- `ctx.llm.listModels(provider: string): Promise<LlmModelInfo[]>` 发现某个已注册提供方当前公布的模型。
- `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.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``llmRetryPolicyOf(stream)` 返回在该边界选中的确切注册所对应的不可变策略,即使之后释放或替换路由也不变。未到达最终适配器的调用没有服务策略。嵌套模型调用、`llm/stream` middleware 和下游消费方失败对外层调用仍未分类。分类绝不替换或更改适配器的原始编码 `Error`
`LlmService` 会把最终适配器选择、同步 dispatch、iterator 构造与迭代产生的失败规范化为流协议的单一终止形式:`finish { kind: 'error' | 'aborted', failure }`。部分 delta 之后的失败可能留下未关闭内容块;消费方会丢弃这部分不完整输出。`llm/stream` middleware、嵌套调用、适配器清理和下游消费方的错误仍会抛出因为它们属于插件或消费方失败而非模型请求结果。准备完成的调用会公开随其确切适配器注册捕获的不可变重试策略完全由 middleware 处理的路由没有服务策略
提供方与模型元数据是发现表层,不是路由白名单。`registerAdapter()` 仍拥有提供方排他性,并为每条路由捕获适配器的重试策略;适配器则可以接受 `listModels()` 中不存在的模型 id消费方禁止因模型未列出而拒绝请求。返回的 selector 元数据与输入脱离,无效或重复适配器配置项会以 `INVALID_ADAPTER``INVALID_CATALOG` 失败。
@@ -44,7 +44,7 @@
消息内容是类型化内容块数组:`text``reasoning``tool-call``tool-result`。联合从可合并扩展的 `ContentBlockMap` 派生,因此插件可以通过 declaration merging 添加块类型。assistant 消息使用模型来源其中携带提供方模型溯源与可选适配器私有回放状态。dispatch 前,`LlmService` 只在历史提供方路由与目标提供方路由当前由完全相同的适配器实例拥有时才保留该状态;随后由适配器判定能否在模型/提供方间恢复或转换该状态。核心块集只包含每条已发布路径都支持的块。多模态内容(图像、音频等)没有核心块类型;需要它的功能会通过 map 添加并一并添加支持它的适配器UI压缩实现。
流式输出是原始 chunk 协议(`block-start``text-delta``reasoning-delta``tool-call-delta``block-end``usage``finish`)。`BlockAssembler` 是将 chunk 组装为块/消息的唯一共享实现。
流式输出是原始 chunk 协议(`block-start``text-delta``reasoning-delta``tool-call-delta``block-end``usage``finish`)。每个适配器结果都以一个终止 `finish` 抵达消费方;运行失败使用其中的 `error``aborted` reason不再跨 stream API 抛出。`BlockAssembler` 是将 chunk 组装为块/消息的唯一共享实现。
### 调用配置(`call-config.ts`
@@ -67,7 +67,7 @@
### 真实适配器
两个适配器使用不同内部机制实现 `LlmAdapter`[`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) 针对 `deepseek` 路由使用直接 fetch 加 `eventsource-parser` SSE 分帧,[`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) 则通过 `@earendil-works/pi-ai` 动态解析已配置提供方/模型对。两者都遵循 `StreamChunk` 约定,定义见 `types.ts`usage 先于 finish工具参数保持原始字符串错误使用两种已批准路径之一。设计理由见 [双 LLM 适配器](../../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md)。
两个适配器使用不同内部机制实现 `LlmAdapter`[`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) 针对 `deepseek` 路由使用直接 fetch 加 `eventsource-parser` SSE 分帧,[`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) 则通过 `@earendil-works/pi-ai` 动态解析已配置提供方/模型对。两者都遵循 `types.ts` 中的 `StreamChunk` 约定usage 先于 finish工具参数保持原始字符串。适配器实现内部可以抛出或发出失败 finish`LlmService` 会将两者都作为终止失败 finish 暴露。适配器设计理由见[双 LLM 适配器](../../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md),服务边界见[终止失败决策](../../../.agents/notes/implemented/architecture/2026-07-29-terminal-llm-stream-failures.md)
## 模型体验

View File

@@ -1,67 +1,40 @@
/**
* Private provider-failure tagging shared by `LlmService` and its consumers.
* Normalization for values thrown by a final LLM adapter boundary.
*
* @module @deepseek-ai/dsh-llm/adapter-failure
*/
import { HarnessError } from './error.ts'
import type { LlmFailure, StreamChunk } from './types.ts'
import type { ResolvedRetryPolicy } from './retry-policy.ts'
/** Call-local facts captured when one model call enters its final adapter boundary. */
export interface AdapterFailureScope {
/** Errors and normalized facts proven to originate in this call's final adapter boundary. */
readonly failures: WeakMap<Error, LlmFailure>
/** Immutable policy of the exact adapter registration selected for this call. */
retryPolicy?: ResolvedRetryPolicy
}
/** Call-local failure scopes keyed by the exact stream handle returned to a consumer. */
const adapterFailureScopes = new WeakMap<AsyncIterable<StreamChunk>, AdapterFailureScope>()
import type { LlmFailure } from './types.ts'
/**
* Bind one call's adapter-failure scope to a unique returned stream handle.
* @param stream - the waterfall-selected stream for this call.
* @param failures - errors tagged by this call's final adapter boundary.
* @returns a unique stream handle that delegates iteration to `stream`.
* Detach serializable provider facts from a value thrown by an adapter.
* @param value - arbitrary value thrown during adapter dispatch or iteration.
* @returns immutable provider-neutral facts suitable for a terminal finish chunk.
* @internal
*/
export function bindAdapterFailureScope(
stream: AsyncIterable<StreamChunk>,
failures: AdapterFailureScope,
): AsyncIterable<StreamChunk> {
const call = {
[Symbol.asyncIterator](): AsyncIterator<StreamChunk> {
return stream[Symbol.asyncIterator]()
},
}
adapterFailureScopes.set(call, failures)
return call
}
/**
* Preserve an adapter's Error identity while tagging its provider origin.
* @param failures - the call-local final-adapter failure scope.
* @param value - arbitrary value thrown by adapter dispatch or iteration.
* @returns the original Error, or a coded Error wrapping a non-Error throw.
* @internal
*/
export function markLlmAdapterFailure(
failures: AdapterFailureScope,
value: unknown,
): Error & { code?: string } {
export function normalizeLlmFailure(value: unknown): LlmFailure {
const error = value instanceof Error
? value as Error & { code?: string }
: new HarnessError(String(value), 'UNKNOWN', { cause: value })
? value
: new HarnessError(thrownMessage(value), 'UNKNOWN', { cause: value })
// Cross-package copies preserve own data but not class identity. Trust the
// carried facts only when both own properties agree after validation.
const carried = ownFailureSnapshot(error)
const failure = carried !== undefined && carried.code === ownErrorCode(error) ? carried : Object.freeze({
if (carried !== undefined && carried.code === ownErrorCode(error)) return carried
return Object.freeze({
message: errorMessage(error),
code: harnessErrorCode(error),
})
failures.failures.set(error, failure)
return error
}
/** Render a non-Error throw without letting hostile coercion escape normalization. */
function thrownMessage(value: unknown): string {
try {
const message = String(value)
return message.length > 0 ? message : 'LLM adapter failed'
} catch (_hostileThrownValue) {
return 'LLM adapter failed'
}
}
/** Read a foreign error's own data-backed `code` without invoking accessors. */
@@ -129,46 +102,3 @@ function errorMessage(error: Error): string {
function harnessErrorCode(error: Error): string {
return error instanceof HarnessError ? error.code : 'UNKNOWN'
}
/**
* Whether a failure came from final adapter dispatch, iterator construction,
* or iteration for the call represented by the exact returned stream handle.
* @param stream - the exact stream returned by the model call being classified.
* @param value - arbitrary failure caught by a model-call consumer.
* @returns true only for errors tagged at that call's final adapter boundary.
*/
export function isLlmAdapterFailure(
stream: AsyncIterable<StreamChunk>,
value: unknown,
): value is Error & { code?: string } {
const failures = adapterFailureScopes.get(stream)
return value instanceof Error && failures !== undefined && failures.failures.has(value)
}
/**
* Retrieve normalized provider facts only for an Error tagged by this exact
* model call's final adapter boundary.
* @param stream - the exact stream returned to the consumer.
* @param value - the caught failure.
* @returns the immutable facts for that call, or `undefined` for middleware, nested, or consumer failures.
*/
export function llmFailureOf(
stream: AsyncIterable<StreamChunk>,
value: unknown,
): LlmFailure | undefined {
const failures = adapterFailureScopes.get(stream)
return value instanceof Error ? failures?.failures.get(value) : undefined
}
/**
* Read the retry policy of the exact registration selected at this call's
* final adapter boundary. The policy remains available after that registration
* is disposed or replaced; absence means no final adapter served the call.
* @param stream - the exact stream returned by the model call.
* @returns the immutable serving-registration policy, or `undefined`.
*/
export function llmRetryPolicyOf(
stream: AsyncIterable<StreamChunk>,
): ResolvedRetryPolicy | undefined {
return adapterFailureScopes.get(stream)?.retryPolicy
}

View File

@@ -127,11 +127,15 @@ export class BlockAssembler {
/**
* Assemble all blocks seen so far, in stream order.
* @returns one block per seen index; an open block assembles from its
* accumulated deltas (an unknown block type never closed by `block-end` throws).
* @returns one block per seen index, except that max-token truncation drops
* tool calls that cannot be executed safely; an open block assembles from
* its accumulated deltas (an unknown block type never closed by `block-end` throws).
*/
blocks(): ContentBlock[] {
return this.order.map(index => this.assemble(this.mustGet(index), index))
const blocks = this.order.map(index => this.assemble(this.mustGet(index), index))
return this.finish.kind === 'max-tokens'
? blocks.filter(block => block.type !== 'tool-call')
: blocks
}
/** Usage from the `usage` chunk; undefined until one arrives. */

View File

@@ -22,8 +22,7 @@ import type { ProviderRequestId } from './brand.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'
import { normalizeLlmFailure } from './adapter-failure.ts'
export * from './attribution.ts'
export * from './brand.ts'
@@ -35,7 +34,6 @@ export * from './retry-policy.ts'
export { BlockAssembler } from './assembler.ts'
export { callConfigEquals, deepFreeze, isAgentLoopRequest, markAgentLoopRequest } from './call-config.ts'
export type { LlmCallConfig } from './call-config.ts'
export { isLlmAdapterFailure, llmFailureOf, llmRetryPolicyOf } from './adapter-failure.ts'
declare module 'cordis' {
interface Context {
@@ -113,6 +111,8 @@ export class LlmError extends HarnessError {
export interface PreparedLlmCall {
/** Detached, deep-frozen config with any adapter-owned default materialized. */
readonly config: LlmCallConfig
/** Immutable retry policy captured with the adapter registration. */
readonly retryPolicy: ResolvedRetryPolicy
/**
* Dispatch this call once through the registration captured during
* preparation. The request's call-config fields must match {@link config};
@@ -440,10 +440,17 @@ export class LlmService extends Service {
let dispatched = false
return Object.freeze({
config: resolvedConfig,
retryPolicy: registration.retryPolicy,
stream: (options: GenerateOptions): AsyncIterable<StreamChunk> => {
if (dispatched) {
throw new LlmError('a prepared LLM call can only be dispatched once', 'INVALID_PREPARED_CALL')
}
if (!callConfigEquals(options, resolvedConfig)) {
throw new LlmError(
'prepared LLM call config changed before adapter dispatch',
'INVALID_PREPARED_CALL',
)
}
dispatched = true
return this.streamWithRegistration(options, { registration, config: resolvedConfig })
},
@@ -473,31 +480,20 @@ export class LlmService extends Service {
}
/**
* Final adapter boundary. It tags only failures from adapter selection,
* synchronous dispatch, iterator construction, or iteration while preserving
* the original Error object. Middleware outside this generator remains
* distinguishable as plugin work. An iteration failure skips adapter cleanup
* so it cannot suppress the primary provider error. A downstream close awaits
* adapter cleanup, whose failures remain ordinary untagged work.
* Final adapter boundary. Adapter selection, dispatch, iterator construction,
* and iteration failures become one terminal failure chunk. Middleware and
* downstream consumer failures remain thrown plugin or consumer errors.
*/
private async * adapterStream(
options: GenerateOptions,
failures: AdapterFailureScope,
prepared?: { registration: AdapterRegistration; config: LlmCallConfig },
): AsyncGenerator<StreamChunk> {
let iterator: AsyncIterator<StreamChunk>
try {
const registration = prepared?.registration ?? this.registration(options.provider)
failures.retryPolicy = registration.retryPolicy
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)
@@ -507,32 +503,31 @@ export class LlmService extends Service {
const stream = adapter.stream(this.forAdapter(resolvedOptions, adapter))
iterator = stream[Symbol.asyncIterator]()
} catch (error: unknown) {
throw markLlmAdapterFailure(failures, error)
yield adapterFailureChunk(error, options.signal)
return
}
let completed = false
let iterationFailed = false
try {
while (true) {
let value: StreamChunk
let item: IteratorResult<StreamChunk>
try {
const item = await iterator.next()
if (item.done) {
completed = true
return
}
value = item.value
item = await iterator.next()
} catch (error: unknown) {
iterationFailed = true
throw markLlmAdapterFailure(failures, error)
completed = true
yield adapterFailureChunk(error, options.signal)
return
}
if (item.done) {
completed = true
return
}
// End the adapter-owned try before yielding: consumer/middleware
// failures resumed into this generator must remain untagged.
yield value
// failures resumed into this generator must remain thrown.
yield item.value
}
} finally {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the iteration catch sets its latch before entering finally.
if (!completed && !iterationFailed) {
if (!completed) {
const close = iterator.return?.bind(iterator)
if (close) await close()
}
@@ -540,15 +535,13 @@ export class LlmService extends Service {
}
/**
* Stream one model call as raw chunks (token-level deltas). Throws
* `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 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.
* Stream one model call as raw chunks (token-level deltas). Replay state is
* retained only when the same adapter instance owns its historical provider
* and the target provider. Final adapter selection remains fixed through
* asynchronous exact-model resolution and dispatch. Adapter selection,
* dispatch, and iteration failures become terminal `error` or `aborted`
* finish chunks; middleware, nested-call, cleanup, and consumer failures
* remain thrown.
* @param options - the full request; `options.provider` selects the adapter.
* @returns the chunk stream, possibly wrapped by `llm/stream` listeners.
*/
@@ -560,14 +553,23 @@ export class LlmService extends Service {
options: GenerateOptions,
prepared?: { registration: AdapterRegistration; config: LlmCallConfig },
): AsyncIterable<StreamChunk> {
const failures: AdapterFailureScope = { failures: new WeakMap<Error, LlmFailure>() }
const stream = this.ctx.waterfall(
return this.ctx.waterfall(
this,
'llm/stream',
options,
() => this.adapterStream(options, failures, prepared),
() => this.adapterStream(options, prepared),
)
return bindAdapterFailureScope(stream, failures)
}
}
/** Convert one adapter throw into the stream protocol's terminal outcome. */
function adapterFailureChunk(error: unknown, signal?: AbortSignal): StreamChunk {
const failure = normalizeLlmFailure(error)
return {
type: 'finish',
reason: signal?.aborted || failure.code === 'ABORTED'
? { kind: 'aborted', failure }
: { kind: 'error', failure },
}
}

View File

@@ -72,7 +72,9 @@ async function* validateStream(
usageSeen = true
break
case 'finish':
if (open.size > 0) fail(`LLM stream finished with ${open.size} open block(s)`)
if (open.size > 0 && chunk.reason.kind !== 'error' && chunk.reason.kind !== 'aborted') {
fail(`LLM stream finished with ${open.size} open block(s)`)
}
finished = true
break
}

View File

@@ -170,8 +170,9 @@ export interface LlmResolvedModelInfo extends LlmModelInfo {
* Raw streaming protocol emitted by adapters.
* Block indexes correlate interleaved deltas, and `block-end` carries the
* assembled block. Adapters emit usage before the terminal finish and nothing
* afterward; tool arguments remain raw JSON strings. Failures either throw or
* end with `error`/`aborted`, and consumers must handle both paths.
* afterward; tool arguments remain raw JSON strings. An adapter implementation
* may throw, but `LlmService.stream()` normalizes that failure to a terminal
* `error` or `aborted` finish before exposing it to consumers.
*/
export type StreamChunk =
| { type: 'block-start'; index: number; blockType: ContentBlockType }

View File

@@ -670,7 +670,7 @@ describe('malformed replay and listener lifecycle', () => {
type: 'turn/start',
seq: 0,
time: 1,
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
data: { turn: 1 },
}] })
activeMeter.measure(session)
session.append('user/message', createUserMessage({