diff --git a/contracts/agents-api/sessions-events.md b/contracts/agents-api/sessions-events.md index 1f5fda736..2d4d4323f 100644 --- a/contracts/agents-api/sessions-events.md +++ b/contracts/agents-api/sessions-events.md @@ -48,7 +48,7 @@ A Session stays usable after a Turn fails: new input starts a new Turn. Later re - **Limits.** The request body is at most 1 MiB, and the stored input of one request at most 512 KiB. - **Null batch.** An explicit `null` for `events` is invalid. - **Empty batch.** `{"events": []}` checks that the Session exists and returns 202. It creates no Turn, Item or retry identity. -- **Retries.** An `Idempotency-Key` of up to 128 bytes identifies the whole ordered batch. The same key and batch return 202 again without admitting anything twice; the same key with another batch returns 409 `idempotency_conflict`. A request without a key is always new. +- **Retries.** An `Idempotency-Key` of 1–256 bytes identifies the whole ordered batch; an empty or longer key returns 400. The same key and batch return 202 again without admitting anything twice; the same key with another batch returns 409 `idempotency_conflict`. A request without a key is always new. - **Messages.** On an idle Session a message batch starts a queued Turn. While a Turn runs, messages join it (steering); they never start a parallel Turn. Each message stays its own user Item, even when the Harness receives several as one prompt. - **Cancellation.** A queued Turn is cancelled without a live Runtime. A running Turn is cancelled when the Runtime confirms it; completion can win that race. The Turn has stopped when it reads `cancelled`, not when the request returns. A Turn that waits for a function result keeps the Session at `requires_action` with its `required_actions` until then, and the request emits no Session event. A cancellation on an idle Session with no pending input is accepted and has no effect; while an input reservation is pending, it returns 409. - **Function results.** `turn_id`, `call_id` and `success` are required; `output` and `error` are optional and nullable ([content rules](./message-content.md#function-results)). An identical repeated result returns 202 without another application or event. The result Item appears when the Harness applies the result; a result that cancellation prevents from being applied stays stored but produces no Item. diff --git a/contracts/agents-api/wire-semantics.md b/contracts/agents-api/wire-semantics.md index cf7147995..e18303bd9 100644 --- a/contracts/agents-api/wire-semantics.md +++ b/contracts/agents-api/wire-semantics.md @@ -239,7 +239,7 @@ Creation validates the body and metadata types, the request fields and initial i ### Creation retries -Send an `Idempotency-Key` of 1–128 bytes that is not only whitespace; a longer or whitespace-only key returns 400 `invalid_request`. An empty header counts as no key. Without a key, every request creates a new Session. The official service creates a new Session for each request even with the same key; Core returns the original one. +Send an `Idempotency-Key` of 1–256 bytes; a longer key returns 400 `invalid_request`. An empty header counts as no key. Without a key, every request creates a new Session. The official service creates a new Session for each request even with the same key; Core returns the original one. | Case | Response | | --- | --- | diff --git a/contracts/agents-api/zh/sessions-events.md b/contracts/agents-api/zh/sessions-events.md index ff512094e..00d3637cc 100644 --- a/contracts/agents-api/zh/sessions-events.md +++ b/contracts/agents-api/zh/sessions-events.md @@ -1,7 +1,7 @@ --- title: "Session、事件和历史" source: contracts/agents-api/sessions-events.md -source_hash: 180e08dbdd9c85ae85005cc202f73316f2b1b04828cca67df13b804206f49f02 +source_hash: 1c6e57bbd212f7155906d68bbbcb2e976170512a2aca907e024104667419d720 --- 本契约涵盖 Session 内部发生的事情:发送输入、实时事件流,以及读取 Turn、Item 和使用量的持久化历史。Session 资源本身(创建配置、重试标识、更新、列出和删除)见 [API 行为](wire-semantics.md)。消息和函数结果内容见[消息内容](message-content.md)。[Agents API 指南](../../../docs/zh/api/public-agent-api.md)展示了使用 SDK 和 HTTP 的调用方式。 @@ -50,7 +50,7 @@ Turn 失败后 Session 仍可使用:新输入会启动一个新 Turn。后来 - **限制。** 请求正文最多为 1 MiB,单次请求存储的输入最多为 512 KiB。 - **空值批次。** 将 `events` 显式设为 `null` 无效。 - **空批次。** `{"events": []}` 会检查 Session 是否存在并返回 202。它不会创建 Turn、Item 或重试标识。 -- **重试。** 最长 128 字节的 `Idempotency-Key` 标识整个有序批次。相同密钥和批次会再次返回 202,而不会重复接纳任何内容;相同密钥搭配另一个批次会返回 409 `idempotency_conflict`。不带密钥的请求始终是新请求。 +- **重试。** 1–256 字节的 `Idempotency-Key` 标识整个有序批次;空键或更长的键返回 400。相同密钥和批次会再次返回 202,而不会重复接纳任何内容;相同密钥搭配另一个批次会返回 409 `idempotency_conflict`。不带密钥的请求始终是新请求。 - **消息。** 在空闲 Session 上,消息批次会启动一个排队的 Turn。Turn 运行时,消息会加入其中(引导);它们绝不会启动并行 Turn。每条消息都保留为独立的用户 Item,即使 Harness 将多条消息作为同一个提示接收。 - **取消。** 排队的 Turn 无需活动 Runtime 即可取消。正在运行的 Turn 只有在 Runtime 确认后才会取消;完成操作可能赢得该竞争。读取到 `cancelled` 时才表示该 Turn 已停止,而不是请求返回时。正在等待函数结果的 Turn 在此之前会让 Session 保持 `requires_action` 及其 `required_actions`,且该请求不会发出 Session 事件。在没有待处理输入的情况下,对空闲 Session 执行的取消会被接受且不产生任何效果;而在输入预留待处理期间,取消会返回 409。 - **函数结果。** `turn_id`、`call_id` 和 `success` 为必填项;`output` 和 `error` 为可选项且可为空([内容规则](message-content.md#function-results))。重复提交完全相同的结果会返回 202,不会再次应用或发出事件。Harness 应用结果时才会出现结果 Item;如果取消操作导致结果无法应用,结果仍会存储,但不会产生 Item。 diff --git a/contracts/agents-api/zh/wire-semantics.md b/contracts/agents-api/zh/wire-semantics.md index 0de791bb5..ad653897c 100644 --- a/contracts/agents-api/zh/wire-semantics.md +++ b/contracts/agents-api/zh/wire-semantics.md @@ -1,7 +1,7 @@ --- title: "API 行为" source: contracts/agents-api/wire-semantics.md -source_hash: 29a928dc41487b9e5a11cdeff7305d2f006040822caf66b299e504cb8eab9662 +source_hash: abb76393a7bc5669970b2fd7156f6293143b12949f0517fc8061737f51c73d32 --- 已锁定版本的 OpenAI Python SDK([upstream.json](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/contracts/agents-api/upstream.json))定义了 `/v1` 路由、字段和类型。本页面说明这些类型未作规定之处 Core 的行为,例如状态码、错误字段、默认值和列表边界,以及 Core 与官方服务存在差异的地方。[OpenAI 兼容性](index.md) 列出了这些差异和尚存缺口;[Sessions, events and history](sessions-events.md)、[message content](message-content.md)、[Vaults](vaults.md)、[source Files and Skills](source-files.md) 和 [Environment files and Artifacts](environment-files.md) 分别负责各自资源的规则。 @@ -241,7 +241,7 @@ Session 创建会将 Agent 的有效配置复制到不可变快照中。使用 ` ### 创建重试 {#creation-retries} -发送一个 1–128 字节且不只包含空白字符的 `Idempotency-Key`;更长或仅包含空白字符的键会返回 400 `invalid_request`。空标头视为未提供键。没有键时,每个请求都会创建一个新的 Session。官方服务即使收到相同的键,也会为每个请求创建新的 Session;Core 则返回原有 Session。 +发送一个 1–256 字节的 `Idempotency-Key`;更长的键会返回 400 `invalid_request`。空标头视为未提供键。没有键时,每个请求都会创建一个新的 Session。官方服务即使收到相同的键,也会为每个请求创建新的 Session;Core 则返回原有 Session。 | 情况 | 响应 | | --- | --- | diff --git a/docs/api/public-agent-api.md b/docs/api/public-agent-api.md index fa7764420..6fe6be650 100644 --- a/docs/api/public-agent-api.md +++ b/docs/api/public-agent-api.md @@ -109,7 +109,7 @@ Two lists differ: `GET /files` returns up to 10,000 files at once, and workspace ### Idempotency -Send an `Idempotency-Key` (up to 128 bytes) when creating a Session or sending input. A retry with the same key and body returns the original result instead of doing the work twice. The same key with a different body fails with 409 `idempotency_conflict`. +Send an `Idempotency-Key` (1–256 bytes) when creating a Session or sending input. A retry with the same key and body returns the original result instead of doing the work twice. The same key with a different body fails with 409 `idempotency_conflict`. ```python import uuid diff --git a/docs/zh/api/public-agent-api.md b/docs/zh/api/public-agent-api.md index 73e3f4ecd..4d850bb19 100644 --- a/docs/zh/api/public-agent-api.md +++ b/docs/zh/api/public-agent-api.md @@ -1,7 +1,7 @@ --- title: "Agents API 指南" source: docs/api/public-agent-api.md -source_hash: 1db2045ded2e2bf523dd94a3db08c5ac6078bf76489f7cb09de092164e89aa8c +source_hash: bd942f9e71b26493c6d1c3b0dc6fd57e6781a34a6d8597d8559acfdc6c7b8a2a --- Core 在 `/v1` 提供 OpenAI Agents API。本指南用官方 OpenAI SDK 和普通 HTTP 两种方式演示每项常见任务,并说明 Core 与 OpenAI 的差异。如果尚未启动过 Session,请先阅读[运行第一个 Session](../getting-started/quickstart.md)。 @@ -111,7 +111,7 @@ agents "/agents/sessions?limit=100&after=$LAST_ID" ### 幂等性 {#idempotency} -创建 Session 或发送输入时,请发送 `Idempotency-Key`(最长 128 字节)。使用相同密钥和正文重试时,会返回原始结果,而不会重复执行工作。相同密钥搭配不同正文会失败,并返回 409 `idempotency_conflict`。 +创建 Session 或发送输入时,请发送 `Idempotency-Key`(1–256 字节)。使用相同密钥和正文重试时,会返回原始结果,而不会重复执行工作。相同密钥搭配不同正文会失败,并返回 409 `idempotency_conflict`。 ```python import uuid diff --git a/services/core/internal/api/inputs.go b/services/core/internal/api/inputs.go index b17bd5d38..ef552cb7e 100644 --- a/services/core/internal/api/inputs.go +++ b/services/core/internal/api/inputs.go @@ -29,10 +29,10 @@ func (h *Handler) createEvents(w http.ResponseWriter, r *http.Request) { } _ = json.Unmarshal(raw, &request) key := r.Header.Get("Idempotency-Key") - if key == "" { + if len(r.Header.Values("Idempotency-Key")) == 0 { key = uuid.NewString() } - if err := sessions.ValidateInputKey(key); err != nil { + if err := sessions.ValidateIdempotencyKey(key); err != nil { writeSessionsError(w, r, err) return } diff --git a/services/core/internal/api/session_semantics_test.go b/services/core/internal/api/session_semantics_test.go index 2cb5b1ceb..2e0562386 100644 --- a/services/core/internal/api/session_semantics_test.go +++ b/services/core/internal/api/session_semantics_test.go @@ -60,7 +60,7 @@ func TestEmptyEventBatchAuthorizesWithoutExecutionEffects(t *testing.T) { } } reads := sessions.reads - for _, key := range []string{" ", strings.Repeat("x", 129)} { + for _, key := range []string{"", strings.Repeat("x", 257)} { if w := request("owned", "test-api-key", key); w.Code != http.StatusBadRequest || sessions.reads != reads { t.Fatalf("invalid identity reached resource: %d %s", w.Code, w.Body) } diff --git a/services/core/internal/sessions/creation.go b/services/core/internal/sessions/creation.go index bea48eddd..1bd27eaa1 100644 --- a/services/core/internal/sessions/creation.go +++ b/services/core/internal/sessions/creation.go @@ -332,7 +332,7 @@ func prepareCreation(input CreateSession, fingerprint func(string) (string, erro return NewSession{}, nil, nil, fmt.Errorf("%w: %v", ErrInvalidInput, err) } engine := strings.TrimSpace(input.Engine) - if !ValidEngine(engine) || !validCreationKey(input.IdempotencyKey) { + if !ValidEngine(engine) || ValidateIdempotencyKey(input.IdempotencyKey) != nil { return NewSession{}, nil, nil, fmt.Errorf("%w: engine and idempotency key are required", ErrInvalidInput) } labels := input.Metadata @@ -417,10 +417,6 @@ func prepareCreation(input CreateSession, fingerprint func(string) (string, erro }, batch, encodedInput, nil } -func validCreationKey(key string) bool { - return strings.TrimSpace(key) != "" && len(key) <= 128 -} - // fingerprintedProvider replaces a bundle's key with its keyed fingerprint. func fingerprintedProvider(provider *v1.ModelProviderInput, fingerprint func(string) (string, error)) (*v1.ModelProviderInput, error) { if provider == nil { @@ -505,8 +501,8 @@ func (s *Service) FindSessionCreation(ctx context.Context, tenant, key string, r if err := creator.Validate(); err != nil { return Creation{}, fmt.Errorf("%w: %v", ErrInvalidInput, err) } - if !validCreationKey(key) { - return Creation{}, ErrInvalidInput + if err := ValidateIdempotencyKey(key); err != nil { + return Creation{}, err } hash, err := intentHash(request, s.storage.FingerprintProviderKey) if err != nil { diff --git a/services/core/internal/sessions/creation_test.go b/services/core/internal/sessions/creation_test.go index 48b2bb70a..10e21d25d 100644 --- a/services/core/internal/sessions/creation_test.go +++ b/services/core/internal/sessions/creation_test.go @@ -218,8 +218,8 @@ func TestPrepareCreation(t *testing.T) { }{ "invalid creator": {func(input *CreateSession) { input.Creator = identity.Subject{} }, fingerprints, ErrInvalidInput}, "invalid engine": {func(input *CreateSession) { input.Engine = "Codex" }, fingerprints, ErrInvalidInput}, - "blank key": {func(input *CreateSession) { input.IdempotencyKey = " " }, fingerprints, ErrInvalidInput}, - "long key": {func(input *CreateSession) { input.IdempotencyKey = strings.Repeat("k", 129) }, fingerprints, ErrInvalidInput}, + "empty key": {func(input *CreateSession) { input.IdempotencyKey = "" }, fingerprints, ErrInvalidInput}, + "long key": {func(input *CreateSession) { input.IdempotencyKey = strings.Repeat("k", 257) }, fingerprints, ErrInvalidInput}, "configuration over 512K": {func(input *CreateSession) { input.Configuration = json.RawMessage(`"` + strings.Repeat("x", 512*1024) + `"`) }, fingerprints, ErrInvalidInput}, @@ -604,7 +604,7 @@ func TestFindSessionCreation(t *testing.T) { {"missing creation", "create", request, creator, fingerprints, func() (CreationRecord, error) { return CreationRecord{}, ErrNotFound }, ErrNotFound, []string{"FindCreation tenant create"}}, {"another creator", "create", request, identity.Subject{Kind: "user", ID: "stranger"}, fingerprints, found, ErrIdempotencyConflict, []string{"FindCreation tenant create"}}, {"no intent", "create", nil, creator, nil, nil, ErrInvalidInput, nil}, - {"invalid key", " ", request, creator, nil, nil, ErrInvalidInput, nil}, + {"invalid key", "", request, creator, nil, nil, ErrInvalidInput, nil}, {"invalid creator", "create", request, identity.Subject{}, nil, nil, ErrInvalidInput, nil}, } { t.Run(test.name, func(t *testing.T) { diff --git a/services/core/internal/sessions/environment_creation.go b/services/core/internal/sessions/environment_creation.go index 0023c0c66..7e056a701 100644 --- a/services/core/internal/sessions/environment_creation.go +++ b/services/core/internal/sessions/environment_creation.go @@ -153,8 +153,8 @@ func (s *Service) environmentIdentity(key string, creator identity.Subject, requ if err := creator.Validate(); err != nil { return EnvironmentIdentity{}, fmt.Errorf("%w: %v", ErrInvalidInput, err) } - if key == "" || len(key) > 256 { - return EnvironmentIdentity{}, ErrInvalidInput + if err := ValidateIdempotencyKey(key); err != nil { + return EnvironmentIdentity{}, err } hash, err := intentHash(request, s.storage.FingerprintProviderKey) if err != nil { diff --git a/services/core/internal/sessions/environment_inputs.go b/services/core/internal/sessions/environment_inputs.go index 12842af11..2619963cd 100644 --- a/services/core/internal/sessions/environment_inputs.go +++ b/services/core/internal/sessions/environment_inputs.go @@ -37,7 +37,7 @@ func joinsActiveTurn(turn Turn, active bool) bool { // passed, or the receipts the batch was admitted with. The checks of // SubmitInputs apply, and a failed or expired Environment rejects new input. func (s *Service) ReserveEnvironmentInput(ctx context.Context, tenant, session, key string, inputs []Input) (EnvironmentInputReservation, error) { - if err := ValidateInputKey(key); err != nil { + if err := ValidateIdempotencyKey(key); err != nil { return EnvironmentInputReservation{}, err } batch, encoded, err := validateMessageInputs(inputs) diff --git a/services/core/internal/sessions/inputs.go b/services/core/internal/sessions/inputs.go index 458d78230..47dcf7dd8 100644 --- a/services/core/internal/sessions/inputs.go +++ b/services/core/internal/sessions/inputs.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "slices" - "strings" "time" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/jsonobject" @@ -31,10 +30,11 @@ type TurnInput struct { CreatedAt time.Time } -// ValidateInputKey enforces the shared request identity limit, including no-op requests. -func ValidateInputKey(key string) error { - if strings.TrimSpace(key) == "" || len(key) > 128 { - return fmt.Errorf("%w: idempotency key is required and limited to 128 bytes", ErrInvalidInput) +// ValidateIdempotencyKey enforces the pinned Idempotency-Key bound of 1 to 256 +// bytes on every request identity, including no-op requests. +func ValidateIdempotencyKey(key string) error { + if key == "" || len(key) > 256 { + return fmt.Errorf("%w: idempotency key must be 1 to 256 bytes", ErrInvalidInput) } return nil } @@ -331,7 +331,7 @@ type InputReader interface { // (ErrInputPending). Internal receipts are not the response body of the // public events endpoint. func (s *Service) SubmitInputs(ctx context.Context, tenant, session, key string, inputs []Input) ([]InputReceipt, error) { - if err := ValidateInputKey(key); err != nil { + if err := ValidateIdempotencyKey(key); err != nil { return nil, err } batch, encoded, err := ValidateInputs(inputs) diff --git a/services/core/internal/sessions/inputs_test.go b/services/core/internal/sessions/inputs_test.go index 7006692bc..ffbf958cc 100644 --- a/services/core/internal/sessions/inputs_test.go +++ b/services/core/internal/sessions/inputs_test.go @@ -326,7 +326,7 @@ func TestSubmitInputs(t *testing.T) { }) t.Run("an invalid request touches no storage", func(t *testing.T) { service := deviceService(t, &fakeStorage{t: t}) - if _, err := service.SubmitInputs(t.Context(), testTenant, testSession, " ", inputs); !errors.Is(err, ErrInvalidInput) { + if _, err := service.SubmitInputs(t.Context(), testTenant, testSession, "", inputs); !errors.Is(err, ErrInvalidInput) { t.Fatal(err) } if _, err := service.SubmitInputs(t.Context(), testTenant, testSession, inputKey, nil); !errors.Is(err, ErrInvalidInput) { @@ -334,3 +334,11 @@ func TestSubmitInputs(t *testing.T) { } }) } + +func TestIdempotencyKeyIsOneTo256Bytes(t *testing.T) { + for key, valid := range map[string]bool{"": false, "k": true, strings.Repeat("k", 256): true, strings.Repeat("k", 257): false} { + if (ValidateIdempotencyKey(key) == nil) != valid { + t.Error("key length", len(key)) + } + } +}