From 49ecd11f978ea0bc87c409b0aa2db6f23cf4481f Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Wed, 14 Oct 2026 05:40:00 +0000 Subject: [PATCH 1/2] Classify model provider rejections --- .../agent/codex/error_classification.go | 16 ++++++++++ .../agent/codex/error_classification_test.go | 30 +++++++++++++++++++ apps/daemon/internal/agent/codex/session.go | 11 +++---- services/core/deploy/codex/README.md | 3 +- services/core/deploy/mcode/README.md | 2 +- 5 files changed, 55 insertions(+), 7 deletions(-) diff --git a/apps/daemon/internal/agent/codex/error_classification.go b/apps/daemon/internal/agent/codex/error_classification.go index 14670ec16..cfff19f1a 100644 --- a/apps/daemon/internal/agent/codex/error_classification.go +++ b/apps/daemon/internal/agent/codex/error_classification.go @@ -32,6 +32,10 @@ func classifyTurnError(failure *TurnError) proto.ErrorPayload { code = "invalid_request" case "cyberPolicy": code = "cyber_policy" + case "other": + if isProviderErrorBody(failure.Message) { + code = "invalid_request" + } } return proto.ErrorPayload{Code: code} } @@ -58,3 +62,15 @@ func classifyTurnError(failure *TurnError) proto.ErrorPayload { } return proto.ErrorPayload{} } + +// Codex reports an HTTP 400 from the model provider as "other" and forwards the +// response body verbatim; other "other" failures carry Codex's own text. A body +// that is a JSON object with an object "error" member is the provider's +// rejection. A plain-text 400 body stays unclassified. +func isProviderErrorBody(message string) bool { + var body struct { + Error json.RawMessage `json:"error"` + } + var object map[string]json.RawMessage + return json.Unmarshal([]byte(message), &body) == nil && json.Unmarshal(body.Error, &object) == nil && object != nil +} diff --git a/apps/daemon/internal/agent/codex/error_classification_test.go b/apps/daemon/internal/agent/codex/error_classification_test.go index 6a5a04014..a99185074 100644 --- a/apps/daemon/internal/agent/codex/error_classification_test.go +++ b/apps/daemon/internal/agent/codex/error_classification_test.go @@ -93,3 +93,33 @@ func TestClassificationOnlyFromRootTerminalError(t *testing.T) { }) } } + +// Codex 0.153 reports a provider HTTP 400 as "other" with the verbatim body and +// repeats it on the error notification stream. +func TestProviderRejectionClassifiedOnce(t *testing.T) { + body := `{"error":{"message":"invalid params, unknown model","code":"invalid_prompt"}}` + for message, want := range map[string]string{body: "invalid_request", "plain text bad request": "", "unexpected status 404 Not Found: The model bogus does not exist": "", `{"error":"bad"}`: ""} { + if got := classifyTurnError(&TurnError{Message: message, CodexErrorInfo: json.RawMessage(`"other"`)}); got.Code != want { + t.Fatal(message, got) + } + } + out := make(chan proto.Envelope, 16) + s := &Session{runID: "run", out: out, cancelCtx: context.Background(), cfg: defaultSessionConfig(testStateRoot(t)), rpc: NewJSONRPCClient(JSONRPCConfig{})} + s.registerHandlers() + s.setThreadID("root") + turnError, _ := json.Marshal(TurnError{Message: body, CodexErrorInfo: json.RawMessage(`"other"`)}) + scopeNotification(t, s, "turn/started", `{"threadId":"root","turn":{"id":"turn"}}`) + scopeNotification(t, s, "error", `{"threadId":"root","turnId":"turn","error":`+string(turnError)+`,"willRetry":false}`) + scopeNotification(t, s, "turn/completed", `{"threadId":"root","turn":{"id":"turn","status":"failed","error":`+string(turnError)+`}}`) + for env := range out { + if env.Type == proto.TypeError { + var p proto.ErrorPayload + _ = env.DecodePayload(&p) + if p.Code != "invalid_request" || p.Error != "invalid_prompt: invalid params, unknown model" { + t.Fatalf("%+v", p) + } + return + } + } + t.Fatal("terminal error lost") +} diff --git a/apps/daemon/internal/agent/codex/session.go b/apps/daemon/internal/agent/codex/session.go index 044c66ae0..71f6a1f7d 100644 --- a/apps/daemon/internal/agent/codex/session.go +++ b/apps/daemon/internal/agent/codex/session.go @@ -210,12 +210,13 @@ func (s *Session) onTurnCompleted(raw json.RawMessage) { // operators can't see why a key was rejected. // 3. buffered text from the "error" notification stream // (sandbox warnings, late stream packets) — last because - // it's noisier than turn.error. + // it's noisier than turn.error. Codex also reports the + // terminal error on that stream, so identical text is + // kept once. body := finalText - if turnErrMsg := turnErrorMessage(p.Turn.Error); turnErrMsg != "" { - body = appendOnNewline(body, turnErrMsg) - } - if errText != "" { + turnErrMsg := turnErrorMessage(p.Turn.Error) + body = appendOnNewline(body, turnErrMsg) + if errText != turnErrMsg { body = appendOnNewline(body, errText) } s.emitTerminalFailure(body, true, classifyTurnError(p.Turn.Error)) diff --git a/services/core/deploy/codex/README.md b/services/core/deploy/codex/README.md index 645908565..976be0798 100644 --- a/services/core/deploy/codex/README.md +++ b/services/core/deploy/codex/README.md @@ -52,10 +52,11 @@ The adapter classifies a failed Turn only from the exact root terminal Turn's `c | `internalServerError` | `server_error` | | `badRequest` | `invalid_request` | | `cyberPolicy` | `cyber_policy` | +| `other` whose message is a JSON object with an object `error` member | `invalid_request` | | `httpConnectionFailed`, `responseStreamConnectionFailed`, `responseStreamDisconnected` | `connection_failed`, with the upstream `httpStatusCode` | | `responseTooManyFailedAttempts` | `rate_limit_exceeded` when `httpStatusCode` is 429, otherwise `connection_failed` | -Every other variant stays unclassified. +Codex `0.153.4` reports a provider HTTP 400 as `other` and puts the response body in the message, so a JSON error body is the provider's rejection, such as an unknown model name. Every other variant stays unclassified, and so does `other` with any other message, including a plain-text 400 body and the `unexpected status` text Codex writes for provider 401 and 404 responses. ## Runtime image diff --git a/services/core/deploy/mcode/README.md b/services/core/deploy/mcode/README.md index 9272bd8a3..f993eb85d 100644 --- a/services/core/deploy/mcode/README.md +++ b/services/core/deploy/mcode/README.md @@ -36,7 +36,7 @@ In the workspace profile, the frozen installation's Skills are linked into the S - Continuation requires the exact recorded native Session ID and the same working directory. Recovery without a recorded ID is rejected. - MiniMax Code reports no usage: ACP context occupancy and cumulative cost are not per-Turn usage. - Workspace `workspace_bash` calls become command observations with the command text, output text and status; exit code and duration stay unknown. Native task and Skill utilities produce no public Items. -- The adapter attaches no [native failure classification](../../../../docs/runtime-protocol.md#native-failure-classification) to its errors. +- The adapter attaches no [native failure classification](../../../../docs/runtime-protocol.md#native-failure-classification) to its errors: MiniMax Code `0.4.12` reports a model provider rejection over ACP as JSON-RPC internal error `-32603` whose only detail is prose, so these failures stay unclassified. ## Runtime image From 2c85b09cd1bdf4ae67c034002ef5115136ab9c07 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Wed, 14 Oct 2026 06:45:56 +0000 Subject: [PATCH 2/2] Classify provider rejections by their declared code --- .../agent/codex/error_classification.go | 34 +++++++++++++------ .../agent/codex/error_classification_test.go | 21 +++++++----- docs/runtime-protocol.md | 2 +- docs/zh/runtime-protocol.md | 4 +-- services/core/deploy/codex/README.md | 4 +-- 5 files changed, 41 insertions(+), 24 deletions(-) diff --git a/apps/daemon/internal/agent/codex/error_classification.go b/apps/daemon/internal/agent/codex/error_classification.go index cfff19f1a..06585abe3 100644 --- a/apps/daemon/internal/agent/codex/error_classification.go +++ b/apps/daemon/internal/agent/codex/error_classification.go @@ -33,9 +33,7 @@ func classifyTurnError(failure *TurnError) proto.ErrorPayload { case "cyberPolicy": code = "cyber_policy" case "other": - if isProviderErrorBody(failure.Message) { - code = "invalid_request" - } + code = providerRejectionCode(failure.Message) } return proto.ErrorPayload{Code: code} } @@ -63,14 +61,28 @@ func classifyTurnError(failure *TurnError) proto.ErrorPayload { return proto.ErrorPayload{} } -// Codex reports an HTTP 400 from the model provider as "other" and forwards the -// response body verbatim; other "other" failures carry Codex's own text. A body -// that is a JSON object with an object "error" member is the provider's -// rejection. A plain-text 400 body stays unclassified. -func isProviderErrorBody(message string) bool { +// Codex 0.153.4 reports a provider HTTP 400 as "other" and forwards the +// response body verbatim; its own "other" failures are prose. A JSON object with +// an object "error" member is the provider's rejection: a declared code in its +// own error.code or error.type wins, otherwise it is invalid_request. Prose, such +// as a plain-text 400 body, stays unclassified. +func providerRejectionCode(message string) string { var body struct { - Error json.RawMessage `json:"error"` + Error *struct { + Code json.RawMessage `json:"code"` + Type json.RawMessage `json:"type"` + } `json:"error"` } - var object map[string]json.RawMessage - return json.Unmarshal([]byte(message), &body) == nil && json.Unmarshal(body.Error, &object) == nil && object != nil + if json.Unmarshal([]byte(message), &body) != nil || body.Error == nil { + return "" + } + for _, raw := range []json.RawMessage{body.Error.Code, body.Error.Type} { + var name string + if json.Unmarshal(raw, &name) == nil { + if code, _ := proto.NormalizeEngineFailure(name, nil); code != "" { + return code + } + } + } + return "invalid_request" } diff --git a/apps/daemon/internal/agent/codex/error_classification_test.go b/apps/daemon/internal/agent/codex/error_classification_test.go index a99185074..024a09873 100644 --- a/apps/daemon/internal/agent/codex/error_classification_test.go +++ b/apps/daemon/internal/agent/codex/error_classification_test.go @@ -94,11 +94,16 @@ func TestClassificationOnlyFromRootTerminalError(t *testing.T) { } } -// Codex 0.153 reports a provider HTTP 400 as "other" with the verbatim body and +// Codex 0.153.4 reports a provider HTTP 400 as "other" with the verbatim body and // repeats it on the error notification stream. func TestProviderRejectionClassifiedOnce(t *testing.T) { body := `{"error":{"message":"invalid params, unknown model","code":"invalid_prompt"}}` - for message, want := range map[string]string{body: "invalid_request", "plain text bad request": "", "unexpected status 404 Not Found: The model bogus does not exist": "", `{"error":"bad"}`: ""} { + for message, want := range map[string]string{ + body: "invalid_request", + `{"error":{"message":"too long","code":"context_length_exceeded"}}`: "context_length_exceeded", + `{"error":{"message":"bad key","type":"authentication_error"}}`: "authentication_error", + "plain text bad request": "", "unexpected status 404 Not Found: The model bogus does not exist": "", `{"error":"bad"}`: "", + } { if got := classifyTurnError(&TurnError{Message: message, CodexErrorInfo: json.RawMessage(`"other"`)}); got.Code != want { t.Fatal(message, got) } @@ -111,15 +116,15 @@ func TestProviderRejectionClassifiedOnce(t *testing.T) { scopeNotification(t, s, "turn/started", `{"threadId":"root","turn":{"id":"turn"}}`) scopeNotification(t, s, "error", `{"threadId":"root","turnId":"turn","error":`+string(turnError)+`,"willRetry":false}`) scopeNotification(t, s, "turn/completed", `{"threadId":"root","turn":{"id":"turn","status":"failed","error":`+string(turnError)+`}}`) - for env := range out { + var failures []proto.ErrorPayload + for _, env := range collectObserved(t, out) { if env.Type == proto.TypeError { var p proto.ErrorPayload _ = env.DecodePayload(&p) - if p.Code != "invalid_request" || p.Error != "invalid_prompt: invalid params, unknown model" { - t.Fatalf("%+v", p) - } - return + failures = append(failures, p) } } - t.Fatal("terminal error lost") + if len(failures) != 1 || failures[0].Code != "invalid_request" || failures[0].Error != "invalid_prompt: invalid params, unknown model" { + t.Fatalf("%+v", failures) + } } diff --git a/docs/runtime-protocol.md b/docs/runtime-protocol.md index d87df0a68..728658980 100644 --- a/docs/runtime-protocol.md +++ b/docs/runtime-protocol.md @@ -182,7 +182,7 @@ An adapter may add `code` and `http_status` to a Run's `error` frame. They are o The accepted codes are `authentication_error`, `rate_limit_exceeded`, `usage_limit_exceeded`, `server_overloaded`, `server_error`, `invalid_request`, `resource_not_found`, `request_timeout`, `context_length_exceeded`, `cyber_policy` and `connection_failed` ([`engine_failure.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/engine_failure.go)). Only `connection_failed` keeps `http_status`, and only an integer from 100 to 599; every other status is discarded. A missing, malformed or unknown value leaves the error unclassified without discarding Usage or `done`. -Core stores accepted values in the Turn outcome as `engine_error_code` and `engine_http_status`. The classification is subordinate to the terminal status and Core's `error_code`: it cannot turn a completed or cancelled Turn into a failure, hide an incomplete event stream, or override a persistence or cancellation-receipt failure. Normal delivery and terminal journal draining use the same extraction. [Session diagnostics](../contracts/agents-api/session-diagnostics.md) expose the category only for a failed Turn whose Core error is `engine_failed`. The [Codex](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/codex/README.md) and [Claude Code](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/claude/README.md) adapter guides give each Harness's mapping; an adapter never classifies error prose. +Core stores accepted values in the Turn outcome as `engine_error_code` and `engine_http_status`. The classification is subordinate to the terminal status and Core's `error_code`: it cannot turn a completed or cancelled Turn into a failure, hide an incomplete event stream, or override a persistence or cancellation-receipt failure. Normal delivery and terminal journal draining use the same extraction. [Session diagnostics](../contracts/agents-api/session-diagnostics.md) expose the category only for a failed Turn whose Core error is `engine_failed`. The [Codex](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/codex/README.md) and [Claude Code](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/claude/README.md) adapter guides give each Harness's mapping. An adapter classifies only from structured error data: the Harness's own error variant or status, or a model provider's JSON error body that the Harness passes through, whose own `error.code` or `error.type` may name an accepted code. It never classifies from error prose. ## Workspace operations diff --git a/docs/zh/runtime-protocol.md b/docs/zh/runtime-protocol.md index 3500b87df..2071b0741 100644 --- a/docs/zh/runtime-protocol.md +++ b/docs/zh/runtime-protocol.md @@ -1,7 +1,7 @@ --- title: "Core–Runtime 协议" source: docs/runtime-protocol.md -source_hash: 8349aca995e66669b7941b6aad5f28e6ae3259516d923a0494938a61fb3e711e +source_hash: 47121442f49eb3b7d6590fa0769d1531fc49fc397169cdc219f9bf9eb05f80f2 --- 此协议在 Runtime daemon 获取机器凭据后连接 Core 与 daemon,定义 daemon 连接上消息的含义和顺序。wire 类型、限制和验证器仅在 [`internal/agentdaemon/proto`](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/internal/agentdaemon/proto) 中定义一次;Core 的 [gateway](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/services/core/internal/runtimegateway) 与参考 Runtime 的 [dispatcher](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/apps/daemon/internal/dispatch) 都使用它们,因此无需同步第二套 payload schema。签发凭据和打开连接的 HTTP 路由见[机器连接 API](../../contracts/agents-api/zh/machine-api.md)。 @@ -185,7 +185,7 @@ adapter 可以给 Run 的 `error` frame 添加 `code` 和 `http_status`。它们 接受的 code 为 `authentication_error`、`rate_limit_exceeded`、`usage_limit_exceeded`、`server_overloaded`、`server_error`、`invalid_request`、`resource_not_found`、`request_timeout`、`context_length_exceeded`、`cyber_policy` 和 `connection_failed`([`engine_failure.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/engine_failure.go))。仅 `connection_failed` 保留 `http_status`,且只能是 100 到 599 的整数;其他 status 都丢弃。值缺失、格式错误或未知时保持未分类错误,不丢弃 Usage 或 `done`。 -Core 在 Turn outcome 中将接受的值保存为 `engine_error_code` 和 `engine_http_status`。分类从属于终结状态和 Core 的 `error_code`:不能将已完成或已取消 Turn 变成失败、隐藏不完整事件流,或覆盖持久化或取消回执失败。正常 delivery 和 terminal journal draining 使用同一提取逻辑。[Session 诊断](../../contracts/agents-api/zh/session-diagnostics.md)仅为 Core error 为 `engine_failed` 的失败 Turn 暴露类别。[Codex](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/codex/README.md) 和 [Claude Code](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/claude/README.md) adapter 指南给出各 Harness 的映射;adapter 不依据错误文字分类。 +Core 在 Turn outcome 中将接受的值保存为 `engine_error_code` 和 `engine_http_status`。分类从属于终结状态和 Core 的 `error_code`:不能将已完成或已取消 Turn 变成失败、隐藏不完整事件流,或覆盖持久化或取消回执失败。正常 delivery 和 terminal journal draining 使用同一提取逻辑。[Session 诊断](../../contracts/agents-api/zh/session-diagnostics.md)仅为 Core error 为 `engine_failed` 的失败 Turn 暴露类别。[Codex](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/codex/README.md) 和 [Claude Code](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/claude/README.md) adapter 指南给出各 Harness 的映射。adapter 只依据结构化错误数据分类:Harness 自身的错误类型或状态,或 Harness 原样转交的模型服务 JSON 错误 body,其 `error.code` 或 `error.type` 可以给出接受的 code。adapter 不依据错误文字分类。 ## 工作区操作 {#workspace-operations} diff --git a/services/core/deploy/codex/README.md b/services/core/deploy/codex/README.md index 976be0798..aeaddef93 100644 --- a/services/core/deploy/codex/README.md +++ b/services/core/deploy/codex/README.md @@ -52,11 +52,11 @@ The adapter classifies a failed Turn only from the exact root terminal Turn's `c | `internalServerError` | `server_error` | | `badRequest` | `invalid_request` | | `cyberPolicy` | `cyber_policy` | -| `other` whose message is a JSON object with an object `error` member | `invalid_request` | +| `other` whose message is a JSON object with an object `error` member | that member's `code` or `type` when it is an accepted code, otherwise `invalid_request` | | `httpConnectionFailed`, `responseStreamConnectionFailed`, `responseStreamDisconnected` | `connection_failed`, with the upstream `httpStatusCode` | | `responseTooManyFailedAttempts` | `rate_limit_exceeded` when `httpStatusCode` is 429, otherwise `connection_failed` | -Codex `0.153.4` reports a provider HTTP 400 as `other` and puts the response body in the message, so a JSON error body is the provider's rejection, such as an unknown model name. Every other variant stays unclassified, and so does `other` with any other message, including a plain-text 400 body and the `unexpected status` text Codex writes for provider 401 and 404 responses. +Codex `0.153.4` reports a provider HTTP 400 as `other` and puts the provider's error body in the message, such as the rejection of an unknown model name. Every other variant stays unclassified, and so does `other` with any other message, including a plain-text 400 body and the `unexpected status` text Codex writes for provider 401 and 404 responses. ## Runtime image