Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion contracts/agents-api/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@ Each item is Core's deliberate or native behavior where the official service beh
- A function call the native Harness abandons, such as a Subagent call dropped when the root closes or interrupts the child or a MiniMax Code call past its 120 s timeout, is withdrawn: it stops being a required action and a later result for it returns 409 `conflict_error`. The official API has no withdrawal.
- Attaching to the stream mid-Turn sends no catch-up Item snapshots.
- The Items list includes in-progress and incomplete output Items, and keeps a failed function result's submitted `output`.
- Error messages omit the call and executor IDs that official messages include.
- Error messages omit the call and executor IDs that official messages include, except that the unknown tool call message names its `call_id`.
- A failed Turn reports the model failure categories under their official names and `sandbox_error` for Runtime or sandbox failures in the execution environment. Every other Core failure, such as a missing model provider, a rejected input or a storage failure, reports `internal_error`. The message is a fixed sentence per code and never carries native text. Subagent Turns always report `internal_error`.
- An empty text part beside other text is accepted and stored.
- Empty input returns 400 `invalid_request` with a generic message and a null param; the official response is `invalid_request_error` with param `input`.
- Session usage is available as soon as every root Turn has settled; official reads lag by seconds.
Expand Down
2 changes: 1 addition & 1 deletion contracts/agents-api/sessions-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ Checks run in this order: request validation, Session lookup, retry lookup, the
| Input the Turn cannot accept in its state, such as a result after cancellation or after its Turn ended without a stored result | 409 `conflict_error` | "The Turn cannot accept this input in its current state." |
| A result that differs from the call's stored result, before or after its Turn ends | 409 `conflict_error` | "The tool call already has a different result." |
| The same `Idempotency-Key` with a different batch | 409 `idempotency_conflict` | "This idempotency key was used with different input." |
| A result whose `call_id` names no function call of this Session | 400 `invalid_request_error`, param null | "Unknown pending tool call." |
| A result whose `call_id` names no function call of this Session | 400 `invalid_request_error`, param null | "Unknown pending tool call: {call_id}" |
| A result for a call of this Session whose `turn_id` names another Turn, an unknown Turn ID or a value that is not a Turn ID | 400 `invalid_request_error`, param null | "The tool call belongs to a different Turn." |
| A missing, malformed or foreign Session | 404 `not_found_error` | "Resource not found." |
| A reserved batch whose five-minute deadline passed before a Turn started | 409 `environment_input_expired` | "The environment input deadline elapsed before admission." |
Expand Down
5 changes: 3 additions & 2 deletions contracts/agents-api/zh/index.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: "OpenAI 兼容性"
source: contracts/agents-api/index.md
source_hash: 4431b17e74c51ece10dc606c8c2078aeb5e974259cf954868f79b797c91d63a7
source_hash: fd489e7a0eb5b5847ba9b1a0ac8ad4bbc73b315e012adb9c9d7a8facbfa79c7f
---

Core 实现下方固定版本的 OpenAI Agents API。本台账列出各项资源的覆盖情况、相对于官方服务的所有差异以及已知缺口。
Expand Down Expand Up @@ -92,7 +92,8 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh
- 原生 Harness 放弃的函数调用(例如根关闭或中断子级时被丢弃的 Subagent 调用,或超过 120 秒超时的 MiniMax Code 调用)会被撤回:它不再是待执行操作,之后为它提交结果会返回 409 `conflict_error`。官方 API 没有撤回机制。
- 在 Turn 中途接入流时,不会发送补发 Item 快照。
- Items 列表会包含进行中和未完成的输出 Items,并保留失败函数结果中已提交的 `output`。
- 错误消息会省略官方消息包含的 call 和 executor ID。
- 错误消息会省略官方消息包含的 call 和 executor ID,但未知工具调用的消息会带上其 `call_id`。
- 失败的 Turn 以官方名称报告模型失败类别,Runtime 或沙箱在执行环境中的失败报告为 `sandbox_error`。Core 的其他失败,例如缺少模型提供方、输入被拒绝或存储失败,一律报告 `internal_error`。消息是每个 code 对应的固定句子,不含原生文本。Subagent Turn 始终报告 `internal_error`。
- 与其他文本并存的空文本部分可被接受并存储。
- 空输入会返回 400 `invalid_request`,附带通用消息和值为 null 的 param;官方响应为 `invalid_request_error`,param 为 `input`。
- 一旦所有根级 Turn 均已进入终态,Session 用量即可用;官方读取会滞后数秒。
Expand Down
4 changes: 2 additions & 2 deletions contracts/agents-api/zh/sessions-events.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: "Session、事件和历史"
source: contracts/agents-api/sessions-events.md
source_hash: 9e02f5fc7ace233f2a17cd7d8fa53447794b95430bf7bf219b66ece33f96ca35
source_hash: ef11e7900a0dec4acceb06c60a0f1275c3e0231081a929c3f1a074e454fb785b
---

本契约涵盖 Session 内部发生的事情:发送输入、实时事件流,以及读取 Turn、Item 和使用量的持久化历史。Session 资源本身(创建配置、重试标识、更新、列出和删除)见 [API 行为](wire-semantics.md)。消息和函数结果内容见[消息内容](message-content.md)。[Agents API 指南](../../../docs/zh/api/public-agent-api.md)展示了使用 SDK 和 HTTP 的调用方式。
Expand Down Expand Up @@ -71,7 +71,7 @@ Turn 失败后 Session 仍可使用:新输入会启动一个新 Turn。后来
| Turn 在当前状态下无法接受的输入,例如取消后提交的结果,或 Turn 已结束且未存储结果后提交的结果 | 409 `conflict_error` | "The Turn cannot accept this input in its current state." |
| 与该调用已存储结果不同的结果,无论其 Turn 结束之前还是之后 | 409 `conflict_error` | "The tool call already has a different result." |
| 相同 `Idempotency-Key` 搭配不同批次 | 409 `idempotency_conflict` | "This idempotency key was used with different input." |
| `call_id` 未标识该 Session 的任何函数调用的结果 | 400 `invalid_request_error`, param null | "Unknown pending tool call." |
| `call_id` 未标识该 Session 的任何函数调用的结果 | 400 `invalid_request_error`, param null | "Unknown pending tool call: {call_id}" |
| 为该 Session 的某个调用提交结果,但 `turn_id` 标识另一个 Turn、未知 Turn ID,或不是 Turn ID 的值 | 400 `invalid_request_error`, param null | "The tool call belongs to a different Turn." |
| 缺失、格式错误或来自其他作用域的 Session | 404 `not_found_error` | "Resource not found." |
| 预留的批次在 Turn 启动前超过五分钟截止时间 | 409 `environment_input_expired` | "The environment input deadline elapsed before admission." |
Expand Down
2 changes: 1 addition & 1 deletion packages/agents-client/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2271,7 +2271,7 @@ describe("OpenAIAgentsClient", () => {
for (const [status, type, code, message] of [
[409, "conflict_error", "conflict_error", "The tool call already has a different result."],
[409, "conflict_error", "conflict_error", "The Turn cannot accept this input in its current state."],
[400, "invalid_request_error", "invalid_request_error", "Unknown pending tool call."],
[400, "invalid_request_error", "invalid_request_error", "Unknown pending tool call: call"],
[400, "invalid_request_error", "invalid_request_error", "The tool call belongs to a different Turn."],
] as const) {
const calls: FetchCall[] = [];
Expand Down
6 changes: 4 additions & 2 deletions services/core/internal/api/errors_sessions.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ func writeInputError(w http.ResponseWriter, r *http.Request, err error) {
// and Environment configuration checks they perform.
func writeSessionsError(w http.ResponseWriter, r *http.Request, err error) {
var cursor *sessions.CursorError
var unknownCall *sessions.UnknownFunctionCallError
switch {
case errors.Is(err, projects.ErrArchived):
// Executor credential management checks the Project in its own
Expand Down Expand Up @@ -61,8 +62,9 @@ func writeSessionsError(w http.ResponseWriter, r *http.Request, err error) {
case errors.Is(err, sessions.ErrNotIdle):
// Observed official status, type, code, null param and message.
writeError(w, http.StatusConflict, "conflict_error", "session must be durably idle or failed without required actions before deletion")
case errors.Is(err, sessions.ErrUnknownFunctionCall):
writeError(w, http.StatusBadRequest, "invalid_request_error", "Unknown pending tool call.")
case errors.As(err, &unknownCall):
// The official SDK recognizes a lost race by this exact message.
writeError(w, http.StatusBadRequest, "invalid_request_error", "Unknown pending tool call: "+unknownCall.CallID)
case errors.Is(err, sessions.ErrFunctionCallTurnMismatch):
writeError(w, http.StatusBadRequest, "invalid_request_error", "The tool call belongs to a different Turn.")
case errors.Is(err, sessions.ErrFunctionResultConflict):
Expand Down
4 changes: 2 additions & 2 deletions services/core/internal/api/function_inputs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,8 @@ func TestPublicInputAdmissionErrorFields(t *testing.T) {
`{"error":{"message":"Earlier input to this Session is still pending.","type":"conflict_error","code":"conflict_error","param":null}}`},
{"changed_result", fmt.Errorf("submit turn inputs: %w", sessions.ErrFunctionResultConflict), 409,
`{"error":{"message":"The tool call already has a different result.","type":"conflict_error","code":"conflict_error","param":null}}`},
{"unknown_call", fmt.Errorf("submit turn inputs: %w", sessions.ErrUnknownFunctionCall), 400,
`{"error":{"message":"Unknown pending tool call.","type":"invalid_request_error","code":"invalid_request_error","param":null}}`},
{"unknown_call", fmt.Errorf("submit turn inputs: %w", &sessions.UnknownFunctionCallError{CallID: "call_1"}), 400,
`{"error":{"message":"Unknown pending tool call: call_1","type":"invalid_request_error","code":"invalid_request_error","param":null}}`},
{"other_turn", fmt.Errorf("submit turn inputs: %w", sessions.ErrFunctionCallTurnMismatch), 400,
`{"error":{"message":"The tool call belongs to a different Turn.","type":"invalid_request_error","code":"invalid_request_error","param":null}}`},
{"missing_session", fmt.Errorf("submit turn inputs: %w", sessions.ErrNotFound), 404,
Expand Down
17 changes: 17 additions & 0 deletions services/core/internal/api/mcp_configuration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
"reflect"
"strings"
"testing"

"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions"
)

const publicMCP = `{"type":"mcp","server_label":"records","connection_origin":"service","transport":{"type":"http","server_url":"https://mcp.example.test/tools"}}`
Expand Down Expand Up @@ -195,6 +197,21 @@ func TestSessionMCPKeepsToolOrderAndStripsSavedHeaders(t *testing.T) {
}
}

func TestSessionReadOmitsMCPHeaders(t *testing.T) {
stored, err := resolveMCPTool(json.RawMessage(`{"type":"mcp","server_label":"records","transport":{"type":"http","server_url":"https://mcp.example.test","headers":{"X-Tenant":"a"}}}`), false)
if err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(configuration{Agent: v1.Agent{ID: "agent", Model: "fixture", Tools: []json.RawMessage{stored}}, Environment: v1.Environment{Type: "none"}})
response, err := sessionResponse(sessions.Session{Configuration: raw}, "")
if err != nil || !strings.Contains(string(stored), "X-Tenant") {
t.Fatal(err)
}
if got := string(response.Agent.Tools[0]); strings.Contains(got, "headers") || !strings.Contains(got, `"transport":{"type":"http","server_url":"https://mcp.example.test"}`) {
t.Fatalf("Session read: %s", got)
}
}

func TestPublicEnvironmentMCPPreservesDeclaration(t *testing.T) {
for _, saved := range []bool{false, true} {
for _, allowed := range []string{"null", "[]", `["prove"]`} {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,22 @@ func TestNativeClassificationPostgresRoundTripAndPublicPrivacy(t *testing.T) {
if strings.Contains(string(eventBytes), "canary") || strings.Contains(string(eventBytes), "engine_error") || strings.Contains(string(eventBytes), "http_status") {
t.Fatal("private metadata entered SSE")
}
// Remove only additive private fields. All public response bytes must match.
// The Turn read alone publishes the category: official names stay, anything else is internal_error.
public := code
if code == "secret-canary" {
public = "internal_error"
}
if !strings.Contains(string(before[1]), `"error":{"code":"`+public+`"`) {
t.Fatal("Turn error code", before[1])
}
// Remove only the private fields; every other public response must keep its bytes.
if _, err = pool.Exec(t.Context(), `UPDATE turns SET outcome=outcome-'engine_error_code'-'engine_http_status' WHERE id=$1`, receipt.TurnID); err != nil {
t.Fatal(err)
}
for i, path := range paths {
if i == 1 {
continue
}
w := diagnosticRequest(h, path, "Bearer caller")
if w.Code != 200 || !bytes.Equal(before[i], w.Body.Bytes()) {
t.Fatal("private classification changed public response", path, w.Body)
Expand Down
18 changes: 17 additions & 1 deletion services/core/internal/api/session_response.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ func sessionResponse(session sessions.Session, executorURL string) (v1.Session,
return v1.Session{}, errors.New("unsupported stored tool configuration")
}
if tool.Type != "tool_search" {
tools = append(tools, projectedMCPCredential(raw, cfg))
tools = append(tools, projectedMCPCredential(withoutMCPHeaders(raw), cfg))
}
}
cfg.Agent.Tools = tools
Expand Down Expand Up @@ -106,6 +106,22 @@ func sessionResponse(session sessions.Session, executorURL string) (v1.Session,
return response, nil
}

// withoutMCPHeaders drops the stored HTTP headers of an MCP tool. The pinned
// Session read shape carries only type and server_url; dispatch keeps reading
// the headers from the stored configuration.
func withoutMCPHeaders(raw json.RawMessage) json.RawMessage {
var tool v1.MCPTool
if json.Unmarshal(raw, &tool) != nil || tool.Type != "mcp" || tool.Transport.Headers == nil {
return raw
}
tool.Transport.Headers = nil
projected, err := json.Marshal(tool)
if err != nil {
return raw
}
return projected
}

func sessionEnvironment(session sessions.Session, kind, executorURL string) (v1.SessionEnvironment, error) {
if kind == "none" {
return v1.SessionEnvironment{Type: "none"}, nil
Expand Down
32 changes: 32 additions & 0 deletions services/core/internal/api/turn_diagnostic_failure.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package api
import (
"encoding/json"

v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1"
"github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions"
)
Expand Down Expand Up @@ -56,3 +57,34 @@ func turnDiagnosticFailure(turn sessions.Turn) *DiagnosticFailure {
}
return &DiagnosticFailure{Code: code, Params: params, FailedAt: diagnosticTime(turn.CompletedAt)}
}

// publicTurnErrors maps the diagnostics code to the official
// SessionTurnErrorCodeResource with a fixed customer-safe sentence: native
// model categories keep their names and Runtime or sandbox failures in the
// execution environment are sandbox_error. Native text never reaches it.
var publicTurnErrors = map[string]string{
"context_length_exceeded": "The request exceeds the model's context window.",
"usage_limit_exceeded": "The organization has reached a usage, plan, or billing limit.",
"rate_limit_exceeded": "The request exceeds the available rate limit.",
"server_overloaded": "The model service is temporarily overloaded.",
"cyber_policy": "The request was rejected by a safety policy.",
"connection_failed": "The request could not connect to the model service.",
"server_error": "The model service encountered an unexpected error.",
"authentication_error": "The API credentials are invalid or lack the required access.",
"invalid_request": "The request contains invalid input or configuration.",
"resource_not_found": "The requested model or resource is unavailable.",
"request_timeout": "The request timed out before the model service responded.",
"sandbox_error": "The request could not complete in its execution environment.",
}

func publicTurnError(diagnosticCode string) *v1.TurnError {
code := diagnosticCode
switch diagnosticCode {
case "runtime_unavailable", "runtime_disconnected", "runtime_preparation_failed":
code = "sandbox_error"
}
if message, ok := publicTurnErrors[code]; ok {
return &v1.TurnError{Code: code, Message: message}
}
return &v1.TurnError{Code: "internal_error", Message: "The execution could not complete."}
}
18 changes: 18 additions & 0 deletions services/core/internal/api/turn_diagnostic_failure_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,21 @@ func TestDiagnosticNativeClassification(t *testing.T) {
}
}
}

func TestPublicTurnErrorFollowsDiagnosticClassification(t *testing.T) {
for outcome, want := range map[string]string{
`{"error_code":"engine_failed","engine_error_code":"rate_limit_exceeded","error":"raw-secret-canary"}`: "rate_limit_exceeded",
`{"error_code":"engine_failed","engine_error_code":"connection_failed","engine_http_status":502}`: "connection_failed",
`{"error_code":"engine_failed","engine_error_code":"native-only"}`: "internal_error",
`{"error_code":"device_disconnected"}`: "sandbox_error",
`{"error_code":"preparation_start_failed"}`: "sandbox_error",
`{"error_code":"model_provider_required"}`: "internal_error",
`{"error_code":"event_persistence_failed"}`: "internal_error",
``: "internal_error",
} {
got := publicTurnError(turnDiagnosticFailure(sessions.Turn{Status: sessions.TurnFailed, Outcome: json.RawMessage(outcome)}).Code)
if got.Code != want || got.Message == "" || strings.Contains(got.Message, "canary") {
t.Fatalf("%s: %+v", outcome, got)
}
}
}
3 changes: 1 addition & 2 deletions services/core/internal/api/turns.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,7 @@ func turnResponse(session sessions.Session, turn sessions.Turn) (v1.Turn, error)
// Session Turns are root Turns, so subagent_id is always null here.
response := v1.Turn{Usage: tokenUsage(turn.Usage), ID: turn.ID, SessionID: turn.SessionID, AgentID: cfg.Agent.ID, Object: "agent.session.turn", Status: turn.Status, CreatedAt: turn.CreatedAt.Unix(), StartedAt: unixTime(turn.StartedAt), CompletedAt: unixTime(turn.CompletedAt)}
if turn.Status == sessions.TurnFailed {
// Native errors can contain secrets; publish a stable category without raw diagnostics.
response.Error = &v1.TurnError{Code: "internal_error", Message: "The execution could not complete."}
response.Error = publicTurnError(turnDiagnosticFailure(turn).Code)
}
return response, nil
}
Expand Down
9 changes: 9 additions & 0 deletions services/core/internal/sessions/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,12 @@ var (
// Session Artifacts.
ErrArtifactCursor = &CursorError{Message: "after is not a valid artifact ID"}
)

// UnknownFunctionCallError is ErrUnknownFunctionCall with the rejected call_id.
type UnknownFunctionCallError struct{ CallID string }

func (e *UnknownFunctionCallError) Error() string {
return ErrUnknownFunctionCall.Error() + ": " + e.CallID
}

func (e *UnknownFunctionCallError) Unwrap() error { return ErrUnknownFunctionCall }
2 changes: 1 addition & 1 deletion services/core/internal/sessions/functions.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ func AdmitFunctionResult(ctx context.Context, tx FunctionResultTx, input Functio
if elsewhere {
return Turn{}, ErrFunctionCallTurnMismatch
}
return Turn{}, ErrUnknownFunctionCall
return Turn{}, &UnknownFunctionCallError{CallID: input.CallID}
}

// PendingFunctionCallsTx reads a Turn's function calls that wait for a result
Expand Down
Loading
Loading