apache / apache/maka

refactor(runtime): one owner for provider failure semantics

Open
#4,947 3 comments 0 reactions 1 assignee Claimed by @Astro-Han View on GitHub
Dominant language
TypeScript
Stars
5.4k
Forks
502
Avg merge
1d 2h
Merged PRs (30d)
715

Description

## 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 (#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

- #3802 — the same question scoped to finish reasons; subsumed here.
- #4502 — the runtime half (keep the provider's text on every failure class, carry it to the durable Turn) lands here; what remains there is the Desktop presentation of that text, which this refactor does not touch.
- #4599 — a concrete failure whose class this refactor must be able to name.
- #4876 — "measure once, carry it".

简体中文

一次 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.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.