Problem
A turn fails. The transcript says:
出错了,原因不明。重新发消息重试。
这一轮有工具执行出错,先看它的结果,再决定要不要重发。
Two sentences giving opposite advice, and the first one may be wrong. The durable evidence for that turn:
content.message "Response stream ended without a finish reason."
stateDelta.failureClass "unknown"
reason / code absent
At that moment the runtime knew three things: the stream was truncated, the attempt had already run tools, and auto-retry was therefore declined to avoid repeating side effects. None of the three reaches the user. The message is sitting in runtime_events.payload_json; the reasoning is a discarded local variable.
This is not a missing string. Provider failure semantics have no owner.
Evidence
Two evidence extractors, different depth. classifyError() and providerRetryMetadata() normalize with normalizeProviderError(), which reads only the top-level error's statusCode / structuredCodes. providerFailureDiagnostic() normalizes with providerFailureDiagnosticFacts(), which walks the cause chain up to 4 levels. Same input, different facts.
The second extractor arrived in #3100, after #1214 introduced the first. The new requirement got a deeper extractor; the original path was never brought along.
A measurable contradiction. providerRetryMetadata is called with two input depths — model-adapter.ts:1273 with the raw error, which decides whether a retry actually happens, and provider-error-classification.ts:449 with the post-walk target, which is written to telemetry. A wrapped 5xx is recorded as retryable: true while the execution path, blind to the status code, declines to retry. The ledger and the behaviour disagree.
Six tables re-deriving from the same facts. classifyProviderFacts, durableProviderErrorClass, providerRetryMetadata, errorPresentationFromClass, modelFailureKind / errorClassFromFailureKind, and the renderer's substring matching in describeTurnErrorClass. They disagree on vocabulary ('Auth' vs 'auth'), on membership (only the durable table knows RequestRejected), and on whether the set is closed at all — classifyProviderFacts falls through to Error.name, which is why the renderer resorts to lower.includes('tool')-style guessing and why an unrecognised class silently becomes "unknown".
A table that overwrites the truth. errorPresentationFromClass returns a fixed sentence per class, and normalizeProviderFailure applies the provider's own summary only when failure.kind === 'unknown' (model-adapter.ts:1295). Every classified failure therefore replaces the provider's wording with ours: a 429 becomes Rate limit exceeded and its actual reason is gone. ErrorEvent.details is never populated by the adapter.
The same disease one layer down. #3802 describes finishReason semantics decided in two places with two vocabularies, and asks which layer should own the provider-spelling map. That is this question, scoped to finish reasons. One decision should cover both.
The decision this needs
Which layer owns the mapping from provider evidence to Maka failure semantics, such that supporting a new provider behaviour is a one-line change in one table.
Target
extractProviderErrorFacts() one extractor, walks the cause chain
↓
classifyProviderError() one closed union
↓
ProviderFailure class + provider text + retry decision *and its reason*
↓
├── retryPolicy() decides, and records why
├── presentation() exhaustive switch; a missing entry is a compile error
└── diagnostic() telemetry row
ProviderFailure carries retry: { decision: 'declined', because: 'side_effects' } — the fact the opening example lost.
Scope
- Merge the two extractors onto the deeper one.
- Close the class union; delete
errorPresentationFromClass's default fallback and the renderer's substring matching.
- Collapse the
'Auth' / 'auth' round-trip (modelFailureKind / errorClassFromFailureKind).
- Carry the retry decision and its reason to the transcript.
- Stop the fixed per-class sentence from overwriting the provider's own message; carry the bounded summary on every class, not only
unknown.
- Persist
http_status, provider_code, provider_request_id on usage_model_call_attempts — computed today by providerFailureDiagnostic, discarded at write time ([DISCUSS] Storage and protocol: four published rules, each enforced in exactly one path #4876's "measure once, carry it").
Non-goals
- Retry conditions are unchanged. An incomplete stream is retried only when the attempt produced no observable output. Relaxing that to resume a partial step is a Runtime Host execution-semantics change and needs its own decision; this issue only makes the existing decision legible.
- Desktop presentation is unchanged. Rendering the provider text as an expandable detail is separate work.
- No backfill of
errorClass already written; existing turns keep reading "unknown".
Related
简体中文
一次 turn 失败,界面只给出「出错了,原因不明。重新发消息重试。」和「这一轮有工具执行出错,先看它的结果,再决定要不要重发。」——两句话在给相反的建议,而第一句可能是错的。落库的证据里,message 是 Response stream ended without a finish reason.,failureClass 是 unknown,reason 和 code 都不存在。
runtime 当时知道三件事:流截断了、这一轮已经执行过工具、因此拒绝自动重试以免重复副作用。三件事没有一件到得了界面。message 躺在 runtime_events.payload_json 里,拒绝重试的理由是个被丢弃的局部变量。
这不是少了一句文案。provider 失败语义没有权威。
证据:证据提取有深浅两个版本(normalizeProviderError 只看顶层,providerFailureDiagnosticFacts 沿 cause 链挖四层),分别在 #1214 和 #3100 引入,后者没有回头收编前者;providerRetryMetadata 在两个调用点收到不同深度的输入,导致遥测记 retryable: true 而执行路径不重试;六张表各自从同一份事实重推一遍,词表不一致、成员不一致、集合甚至不封闭;errorPresentationFromClass 的固定文案还会覆盖 provider 原话,只有落到 unknown 的才侥幸保住。
要定的是:provider 证据到 Maka 失败语义的映射由哪一层拥有,使得支持一种新的供应商行为只需要在一张表里改一行。#3802 问的是同一个问题,只是限定在 finish reason,这里一并收编。
不做的事:不改重试触发条件(那是 Runtime Host 的执行语义,要单独定);不做 Desktop 的展开式详情(留给 #4502);不回溯已落库的 errorClass。
Drafted with Claude Code.
Problem
A turn fails. The transcript says:
Two sentences giving opposite advice, and the first one may be wrong. The durable evidence for that turn:
At that moment the runtime knew three things: the stream was truncated, the attempt had already run tools, and auto-retry was therefore declined to avoid repeating side effects. None of the three reaches the user. The message is sitting in
runtime_events.payload_json; the reasoning is a discarded local variable.This is not a missing string. Provider failure semantics have no owner.
Evidence
Two evidence extractors, different depth.
classifyError()andproviderRetryMetadata()normalize withnormalizeProviderError(), which reads only the top-level error'sstatusCode/structuredCodes.providerFailureDiagnostic()normalizes withproviderFailureDiagnosticFacts(), which walks thecausechain up to 4 levels. Same input, different facts.The second extractor arrived in #3100, after #1214 introduced the first. The new requirement got a deeper extractor; the original path was never brought along.
A measurable contradiction.
providerRetryMetadatais called with two input depths —model-adapter.ts:1273with the raw error, which decides whether a retry actually happens, andprovider-error-classification.ts:449with the post-walk target, which is written to telemetry. A wrapped 5xx is recorded asretryable: truewhile the execution path, blind to the status code, declines to retry. The ledger and the behaviour disagree.Six tables re-deriving from the same facts.
classifyProviderFacts,durableProviderErrorClass,providerRetryMetadata,errorPresentationFromClass,modelFailureKind/errorClassFromFailureKind, and the renderer's substring matching indescribeTurnErrorClass. They disagree on vocabulary ('Auth'vs'auth'), on membership (only the durable table knowsRequestRejected), and on whether the set is closed at all —classifyProviderFactsfalls through toError.name, which is why the renderer resorts tolower.includes('tool')-style guessing and why an unrecognised class silently becomes "unknown".A table that overwrites the truth.
errorPresentationFromClassreturns a fixed sentence per class, andnormalizeProviderFailureapplies the provider's own summary only whenfailure.kind === 'unknown'(model-adapter.ts:1295). Every classified failure therefore replaces the provider's wording with ours: a 429 becomesRate limit exceededand its actual reason is gone.ErrorEvent.detailsis never populated by the adapter.The same disease one layer down. #3802 describes
finishReasonsemantics decided in two places with two vocabularies, and asks which layer should own the provider-spelling map. That is this question, scoped to finish reasons. One decision should cover both.The decision this needs
Which layer owns the mapping from provider evidence to Maka failure semantics, such that supporting a new provider behaviour is a one-line change in one table.
Target
ProviderFailurecarriesretry: { decision: 'declined', because: 'side_effects' }— the fact the opening example lost.Scope
errorPresentationFromClass'sdefaultfallback and the renderer's substring matching.'Auth'/'auth'round-trip (modelFailureKind/errorClassFromFailureKind).unknown.http_status,provider_code,provider_request_idonusage_model_call_attempts— computed today byproviderFailureDiagnostic, discarded at write time ([DISCUSS] Storage and protocol: four published rules, each enforced in exactly one path #4876's "measure once, carry it").Non-goals
errorClassalready written; existing turns keep reading "unknown".Related
简体中文
一次 turn 失败,界面只给出「出错了,原因不明。重新发消息重试。」和「这一轮有工具执行出错,先看它的结果,再决定要不要重发。」——两句话在给相反的建议,而第一句可能是错的。落库的证据里,
message是Response stream ended without a finish reason.,failureClass是unknown,reason和code都不存在。runtime 当时知道三件事:流截断了、这一轮已经执行过工具、因此拒绝自动重试以免重复副作用。三件事没有一件到得了界面。message 躺在
runtime_events.payload_json里,拒绝重试的理由是个被丢弃的局部变量。这不是少了一句文案。provider 失败语义没有权威。
证据:证据提取有深浅两个版本(
normalizeProviderError只看顶层,providerFailureDiagnosticFacts沿 cause 链挖四层),分别在 #1214 和 #3100 引入,后者没有回头收编前者;providerRetryMetadata在两个调用点收到不同深度的输入,导致遥测记retryable: true而执行路径不重试;六张表各自从同一份事实重推一遍,词表不一致、成员不一致、集合甚至不封闭;errorPresentationFromClass的固定文案还会覆盖 provider 原话,只有落到unknown的才侥幸保住。要定的是:provider 证据到 Maka 失败语义的映射由哪一层拥有,使得支持一种新的供应商行为只需要在一张表里改一行。#3802 问的是同一个问题,只是限定在 finish reason,这里一并收编。
不做的事:不改重试触发条件(那是 Runtime Host 的执行语义,要单独定);不做 Desktop 的展开式详情(留给 #4502);不回溯已落库的
errorClass。Drafted with Claude Code.