Skip to content

refactor(runtime): one owner for provider failure semantics #4947

Description

@Astro-Han

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

  1. Merge the two extractors onto the deeper one.
  2. Close the class union; delete errorPresentationFromClass's default fallback and the renderer's substring matching.
  3. Collapse the 'Auth' / 'auth' round-trip (modelFailureKind / errorClassFromFailureKind).
  4. Carry the retry decision and its reason to the transcript.
  5. Stop the fixed per-class sentence from overwriting the provider's own message; carry the bounded summary on every class, not only unknown.
  6. 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 失败,界面只给出「出错了,原因不明。重新发消息重试。」和「这一轮有工具执行出错,先看它的结果,再决定要不要重发。」——两句话在给相反的建议,而第一句可能是错的。落库的证据里,messageResponse stream ended without a finish reason.failureClassunknownreasoncode 都不存在。

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions