diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 19f81be3e..f5dbd49fd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,7 +64,8 @@ record unresolved low-ROI cases with evidence and impact. Never defer a safety o data-consistency blocker while claiming the affected workflow passed. Session creation requires initial input for `none`, and for streaming creation -outside `self_hosted`. Check these conditions before creation retry lookup or +outside `self_hosted`. Report inline agent protocol errors first, then check these +conditions before creation retry lookup or resource resolution. The parser remains shared with subsequent message admission; non-streaming hosted and self-hosted requests may omit input. Do not retain an idle-none creation compatibility exception. Valid requests retain their documented @@ -87,7 +88,10 @@ observed upstream server failures as compatibility behavior. See Report validation failures with official evidence through the typed field error, which emits `invalid_request_error` with the observed param and message; keep -other local codes until their official fields are sampled. A malformed path +other local codes until their official fields are sampled. Agent configuration +(saved create/update and the inline Session agent) uses one path-tracking +validator of the pinned shapes before its parsers and harness admission, which +keep their local codes; do not grow it into a JSON Schema engine. A malformed path identifier must produce exactly the response of a well-formed missing one on that route, including invalid bodies, queries and storage availability: resolve it to the never-assigned maximum UUID and let the missing path run, or reject it @@ -2594,8 +2598,8 @@ adapter. The maintained native harness owns dynamic model/provider eligibility; its SDK exposes no reliable pre-input receipt proving effective deferral after a policy change. Do not represent tool inventory or an operator allowlist as that proof. Record exact real model/provider evidence and this detection gap separately. -Search-only, missing-search, duplicate-search, workspace, MCP and Subagent combinations -remain unqualified. See [the operation coverage](contracts/agents-api/tool-search.md). +Search-only, missing-search, workspace, MCP and Subagent combinations remain +unqualified; a repeated `tool_search` is a protocol error. See [the operation coverage](contracts/agents-api/tool-search.md). ### Structured output execution @@ -2618,8 +2622,9 @@ function tools and text results. The workspace uses its existing preparation and native sandbox with only the SDK's configured `StructuredOutput` tool added to inventory and permission checks. Frozen schemas reach preparation before the input handoff; Start cannot replace them. Skills, Plugins, capability directories, -HTTP MCP, Subagent/tool-discovery combinations and non-object root schemas remain -unqualified. Check resolved template contents as well as inline configuration; +HTTP MCP, Subagent/tool-discovery combinations and schemas without an explicit +object root remain unqualified; an explicit non-object root type is a protocol +error for every harness. Check resolved template contents as well as inline configuration; ordinary text requests retain their existing qualifications. The SDK uses binary64 JSON numbers: reject execution schemas whose numeric values would change during that conversion, without narrowing saved Agent storage. diff --git a/contracts/agents-api/README.md b/contracts/agents-api/README.md index 9adb0a19e..d13c0dd38 100644 --- a/contracts/agents-api/README.md +++ b/contracts/agents-api/README.md @@ -496,7 +496,10 @@ validate and resolve the upstream schema before persistence, and report only supported options. For example, upstream metadata is limited to 16 pairs with 64-character keys and 512-character values; a storage byte limit is not a replacement for that public validation. Violations return `invalid_request_error` -with the official `metadata` or `metadata.` param. U+0000 in stored strings +with the official `metadata` or `metadata.` param, and Agent configuration +protocol errors report their JSON path; see the +[configuration validation batch](official-semantics-alignment.md#agent-configuration-validation--september-23). +U+0000 in stored strings is a local PostgreSQL limit and returns 400 without writing; see the [validation error batch](official-semantics-alignment.md#validation-error-fields--september-23). @@ -702,8 +705,9 @@ null and empty tool lists resolve to an empty list. The resolved tools are part the immutable Session configuration and creation retry identity. Saved-Agent inheritance uses the same resolved tools. The bounded [deferred discovery path](tool-search.md) adds type-only `tool_search` for its qualified profile. Other discovery combinations, other tool kinds, -the native 64-definition cap and unique nonblank names of at most 512 bytes remain -compatibility gaps. Claude SDK additionally requires object-root schemas. It accepts text and +the native 64-definition cap and nonblank names of at most 512 bytes remain +compatibility gaps; repeated names and explicit non-object root types reject as +officially. Claude SDK additionally requires an explicit object root. It accepts text and successful inline PNG/JPEG function results on `none` and Docker `openai_hosted`; failed images, unqualified placements and remote references remain gaps. See [function image coverage](function-result-images.md). Codex internal Goal/Skills/user-input/discovery semantics need diff --git a/contracts/agents-api/execution-tools.md b/contracts/agents-api/execution-tools.md index 272ce56d6..45c2c7a07 100644 --- a/contracts/agents-api/execution-tools.md +++ b/contracts/agents-api/execution-tools.md @@ -21,13 +21,13 @@ and Environment Plugin MCP have separate inventories and qualification. | --- | --- | --- | | Initial message input, `sessions.create` | String input and ordered user-message arrays share atomic admission. Codex/Claude text and inline image execution: M1/M2; ordinary MiniMax text: M1/P1. [Input contract](message-input.md), [initial parser](../../services/agents-api/internal/api/session_initial_input.go). | Empty-message behavior and local size-limit parity need upstream evidence. Images are only qualified for inline PNG/JPEG on `none` and Core-managed Docker `openai_hosted`; MiniMax, self-hosted images and remote URLs remain gaps. | | Prepared and active messages, `sessions.events.create` | Ordered `input_text`/`input_image` arrays retain original content and distinct public user Items. HTTP 202 confirms persistence; native receipts establish application. Initial/prepared/active Docker PNG/JPEG: M2; active PNG on `none`: M1. [Shared admission](../../services/agents-api/internal/api/inputs.go). | Codex flattens native messages with blank-line separators. Claude can fold or queue native turns; it does not promise Codex's same-native-turn behavior. Public durability does not prove native consumption. | -| Structured output, `agent.text.format` | Save/inherit/freeze `{type:json_schema,schema:...}`. Claude SDK object-root, single Agent, medium verbosity, ordinary functions returning text: S1 (`none`) and S2 (Docker, including prepared/active input and Files/Artifacts). Native final text is retained unchanged. [Contract](structured-output.md), [profile](../../services/agents-api/internal/engine/claude.go). | Codex/MiniMax, other schema roots, schema numbers changed by binary64, self-hosted, Skills/Plugins, MCP, Subagent and discovery combinations reject execution. No output repair, coercion or extra model loop. Arbitrary schema dialects are unverified. | -| Function configuration, saved/inline Agents | Required name/description/schema; `defer_loading` defaults false. Saved references resolve into an immutable Session snapshot. Codex/Claude real calls: F1/F2/M2/S1/S2. [Parser](../../services/agents-api/internal/api/function_configuration.go), [saved tools](../../services/agents-api/internal/api/saved_tools.go). | MiniMax public functions reject. Claude requires object-root schemas. Local unique/nonblank name and 64-definition bounds are compatibility gaps. Saving configuration alone does not qualify execution. | +| Structured output, `agent.text.format` | Save/inherit/freeze `{type:json_schema,schema:...}`. Claude SDK object-root, single Agent, medium verbosity, ordinary functions returning text: S1 (`none`) and S2 (Docker, including prepared/active input and Files/Artifacts). Native final text is retained unchanged. [Contract](structured-output.md), [profile](../../services/agents-api/internal/engine/claude.go). | An explicit non-object root type is a protocol error for every harness ([validation](official-semantics-alignment.md#agent-configuration-validation--september-23)). Codex/MiniMax, schemas without an object root, schema numbers changed by binary64, self-hosted, Skills/Plugins, MCP, Subagent and discovery combinations reject execution. No output repair, coercion or extra model loop. Arbitrary schema dialects are unverified. | +| Function configuration, saved/inline Agents | Required name/description/schema; `defer_loading` defaults false. Protocol errors, repeated names and explicit non-object root types reject with the official fields ([validation](official-semantics-alignment.md#agent-configuration-validation--september-23)). Saved references resolve into an immutable Session snapshot. Codex/Claude real calls: F1/F2/M2/S1/S2. [Parser](../../services/agents-api/internal/api/function_configuration.go), [saved tools](../../services/agents-api/internal/api/saved_tools.go). | MiniMax public functions reject. Claude requires an explicit object root. Local nonblank/512-byte name and 64-definition Session bounds are compatibility gaps. Saving configuration alone does not qualify execution. | | Function-result admission, `events.create` | Required `turn_id`, `call_id`, `success`; optional nullable `error` and `output`. Output is string or ordered text/image content. Scoped atomic batches retain field presence, original content and retry identity. Same result retries are accepted; changed results conflict, including after terminal state. F1/F2/M2 plus [controlled SDK/raw checks](../../services/agents-api/tests/official_function_inputs.py). [Parser](../../services/agents-api/internal/api/function_inputs.go), [Store](../../services/agents-api/internal/store/function_results.go). | Admission is separate from application and public Item publication. Invalid or unqualified content cannot consume a pending call. Exact hosted errors, defaults and publication timing remain unverified. | | Function text results and native application | Codex waits for a matching live root dynamic-tool completion; Claude waits for a matching live root native tool result. Text/error results, retry/conflict, cancellation and cold continuation: F1/F2. [Receipt contract](function-result-images.md). | Transport writes alone do not confirm application. Confirmation is not provider consumption, crash recovery or exactly-once external effects. No automatic result replay. | | Function image results | Successful ordered inline PNG/JPEG with text, large PNG and image-only JPEG: Codex/Claude `none` F1/F2; Docker M2. Public Items retain submitted bytes. Codex receipt regression: F2. | Claude rejects failed images and remote references before persistence; native resizing may change its image bytes. MiniMax functions and self-hosted image workflows are unqualified. Other Codex image/error/reference combinations cannot be inferred from successful-inline evidence. | -| Deferred function discovery | Type-only `tool_search` plus mixed eager/deferred functions: Claude SDK 0.3.269/native 2.1.269, Kimi K3, single Agent, medium, `none`, text results; text and PNG input D1. Native provider observations establish lazy schema loading. [Contract](tool-search.md). | Codex/MiniMax discovery, search-only/missing-search/duplicate-search, workspace, MCP, structured-output and Subagent combinations remain gaps. Opaque native policy changes lack a reliable pre-input deferral signal. Saved tools include `tool_search`; the pinned Session response union excludes it. Exact hosted projection is unverified. | -| Explicit disabled search/PTC | Saved and inline `web_search.mode=disabled` and `programmatic_tool_calling.enabled=false`; shared native controls on initial and cold execution. All three harnesses on `none`: P1, including native inventory/control evidence. [Contract](tool-policy.md), [parser](../../services/agents-api/internal/api/disabled_tools.go). | Unsupported explicit enablement rejects. Omission retains approved native behavior, which does not establish official default-on PTC parity. Enabled search and default/error parity remain gaps; unrelated native utilities are not implicitly removed. | +| Deferred function discovery | Type-only `tool_search` plus mixed eager/deferred functions: Claude SDK 0.3.269/native 2.1.269, Kimi K3, single Agent, medium, `none`, text results; text and PNG input D1. Native provider observations establish lazy schema loading. [Contract](tool-search.md). | A repeated `tool_search` is a protocol error. Codex/MiniMax discovery, search-only/missing-search, workspace, MCP, structured-output and Subagent combinations remain gaps. Opaque native policy changes lack a reliable pre-input deferral signal. Saved tools include `tool_search`; the pinned Session response union excludes it. Exact hosted projection is unverified. | +| Explicit disabled search/PTC | Saved and inline `web_search.mode=disabled` and `programmatic_tool_calling.enabled=false`; shared native controls on initial and cold execution. All three harnesses on `none`: P1, including native inventory/control evidence. [Contract](tool-policy.md), [parser](../../services/agents-api/internal/api/disabled_tools.go). | Unsupported explicit enablement rejects; a repeated `web_search` is a protocol error, while saved `live`/`cached`/omitted modes keep the local rejection (TV-05). Omission retains approved native behavior, which does not establish official default-on PTC parity. Enabled search and default/error parity remain gaps; unrelated native utilities are not implicitly removed. | | Agent service-origin MCP | Implemented Codex/Claude HTTP `none` profiles, anonymous/static bearer, scoped Vault selection and native `mcp_call` Items. [Configuration and qualification limits](../../services/agents-api/README.md#http-mcp-execution), [profiles](../../services/agents-api/internal/engine/profile.go). | This closure batch does not requalify MCP/provider combinations. MiniMax, self-hosted/hosted service-origin MCP, OAuth, nonempty inline headers/metadata and other transports/origins reject. Claude requires a static connected inventory; original MCP-envelope fidelity and continuing server health are unverified. | | Environment Plugin MCP/native tools | Separate [Docker Plugin transport matrix](environment-templates.md#environment-origin-mcp-plugins) and [V1 deployment evidence](user-managed-runtime-v1.md). Native tools stay within the existing colocated Runtime. | Plugin MCP is not Agent service-origin MCP or a public function action. No new optional cross-product is qualified here. Native utility inventories need not be identical. | | Required action: `function_call` | Session GET/list and Session SSE expose persisted `{arguments,call_id,name,turn_id,type}`. Actions remain pending until native application or cancellation/terminal settlement. [Projection](../../services/agents-api/internal/store/function_state.go), [confirmation](../../services/agents-api/internal/store/function_results.go); real handling F1/F2/M2/S1/S2/D1; explicit client disconnect/query/reconnect: F3. | Function-call history is not pending-state authority. Client reconnect does not replay events or redo external application effects. | diff --git a/contracts/agents-api/official-semantics-alignment.md b/contracts/agents-api/official-semantics-alignment.md index 2cbbb3627..4f36f2286 100644 --- a/contracts/agents-api/official-semantics-alignment.md +++ b/contracts/agents-api/official-semantics-alignment.md @@ -352,3 +352,99 @@ a Worker test cancels a waiting Turn through the daemon protocol before deleting Handler, pinned-SDK, TypeScript client and Web unit tests cover the error fields and the cancel-then-delete flow. Real Core, daemon and model acceptance is recorded separately by the coordinator. + +## Agent configuration validation — September 23 + +This batch moves protocol validation of Agent configuration into Core with the +official error fields: saved Agent create and update bodies and the inline +`agent` on Session create. Evidence comes from the campaign scan at main +`beb18fd`, recorded privately in +`~/.parsar/remediation/20260923/campaign-scan-3/subagents-tools/findings.json` +(TV-01..07) with raw official records in `official/validation-{B1,B2A,B2B,B3}.json` +and the Core replay under `core/`. The official probe used one owned Agent and 44 +requests without a model: Agent updates, two Agent creates and nine Session +creates without input on `none`, so no Session or Turn could start. The Agent was +deleted and a read confirmed 404. + +| Row | Case | Core behavior | +| --- | --- | --- | +| C1 | A missing required member, an unknown member, a wrong JSON type or an unsupported enum value in `tools[]`, `text`, `reasoning`, `service_tier`, `multi_agent`, `model`, `name` or `instructions`, including the unpinned `tool_choice` (TV-01) | 400 with type and code `invalid_request_error`, param set to the JSON path (`tools[0].parameters`; `agent.tools[0].parameters` on Session create) and the observed messages: `Missing required parameter: ''.`, `Unknown parameter: ''.`, `Invalid type for '': expected , but got instead.`, `Invalid value: ''. Supported values are: ...` with the pinned literals, and `Invalid '': integer below minimum value. Expected a value >= 1, but got instead.` | +| C2 | Repeated function name, more than one `web_search` or more than one `tool_search` (TV-02) | 400 `invalid_request_error`, param null: `duplicate function tool name: `, `duplicate web_search tool`, `duplicate tool_search tool`. | +| C3 | Function `parameters` or `text.format` json_schema with an explicit string root `type` other than `object` (TV-03) | 400 `invalid_request_error`, param null: `Invalid schema for function '': schema must be a JSON Schema of 'type: "object"', got 'type: ""'.` and `agent.text.format.schema must have top-level type "object"; got ""`, for every harness and before harness admission. | +| C4 | Session create on `none` without input and with an invalid inline agent (TV-04) | The configuration error first. Valid configurations, including enabled `web_search` or programmatic tool calling, still receive the input requirement. | +| K1 | Function names with any characters or over 64 characters, programmatic tool calling enabled on a saved Agent, reasoning effort `max`, service tier `flex` (TV-07) | Unchanged: saved and echoed. | +| K2 | Harness and execution admission limits: enabled `web_search` or programmatic tool calling, structured output on an unqualified harness, explicit reasoning or a non-`auto` service tier on Session create (TV-06) | Unchanged: `unsupported_or_invalid_configuration` with the existing messages, after protocol validation. | +| K3 | Saved `web_search` with mode `live`, `cached`, null or omitted (TV-05) | Unchanged: `unsupported_or_invalid_configuration`, "Only disabled web_search is qualified for execution." | + +Decisions: + +- A compact validator walks the raw JSON along the pinned shapes + (`PersistedAgentToolParam`/`AgentToolParam`, `AgentTextParam`, + `AgentReasoningParam`, `MultiAgentConfigParam` and the `service_tier` literal) + and reports the first violation through the typed field error from the + validation error batch. It runs before the existing parsers, which keep Core's + local limits and codes, and before harness admission. It is not a JSON Schema + engine: function and output schemas, `request_metadata` values, MCP `transport` + members, `metadata` and `x_agents_core` stay with their existing parsers. +- In each object, a union's `type` is checked first. Unknown and repeated members + are then reported in document order, followed by member values in document + order and missing required members in the pinned order. The whole object is + checked before the C2/C3 conflicts, and tools before `text`. The official order + between several errors in one body was not observed. +- Member names match exactly, so a name that differs from a member only by case, + such as `reasoning.Effort`, is an unknown parameter. A member repeated anywhere + in the checked tree returns 400 `invalid_request_error` with its path as param + and the local message `Duplicate parameter: ''.`; the official response + is unobserved. Both are needed because encoding/json matches names + case-insensitively and merges repeated objects into the decoded structs: + checking only the last copy let `"text"` given twice store an array-root schema, + and `{"reasoning":{"Effort":"high"},"reasoning":{}}` store `effort: high`. + Members left to their parsers are checked on the decoded values, so they cannot + differ from what is stored. +- Observed expected-kind phrases are `an object`, `a boolean` and + `an object with string keys and unknown value values`. At unsampled positions + Core uses `a string` (also for enum members), `an integer` and `an array`, and + reports a missing Agent create `model` and a non-object Session `agent` in the + same forms. +- Caller-supplied member names, enum values, function names and schema root types + are repeated only when they are at most 256 bytes of printable UTF-8, the + Environment Files rule. Otherwise the error keeps its code and path param, or + a null param for an unknown member, and drops the value: `Unknown parameter.`, + `Invalid value. Supported values are: ...`, `duplicate function tool name`, or + the schema message without the name or `got` clause. +- C3 rejects only an explicit string root type. Schemas without a root type, or + with a non-string `type` such as an array, are unchanged; neither was sampled. + The output schema message names `agent.text.format.schema` on Agent requests as + well, as observed on Agent update; Agent create was not sampled. +- Session admission also applies C2 and C3 to the resolved saved configuration, so + Agents saved before this batch cannot execute with such tools or schemas; + replacing the field in the Session override admits them. An invalid inline + override is reported before the saved-Agent lookup, so owned, foreign and missing + Agents give the same response. Otherwise the lookup order (SES-33) is unchanged. +- Validation of the update body precedes the Agent lookup, so owned, foreign, + missing and malformed Agent IDs give the same response. +- Inline agent validation runs before same-key creation recovery. A same-key + retry of an inline Session created before this batch therefore returns the new + 400 if its original configuration is now invalid, instead of the original + Session. Saved-Agent retries still recover, because the resolved-configuration + checks run after recovery. Core is pre-release, so the order is not changed for + such retries. + +Deferred and unchanged: TV-05, saving `web_search` with mode `live`, `cached` or +omitted (officially saved, omitted stored as `live`), needs a separate decision +about saved-but-unqualified settings. Duplicate `programmatic_tool_calling` +declarations and MCP server labels were not sampled: saved Agents accept them and +Session admission keeps "Execution requires distinct tool controls." and +"Execution requires distinct MCP server labels.". A missing model without +`agent_id`, unknown top-level Session members, `max_concurrent_subagents` above +4294967295, nonblank and 512-byte function names and the 64-function Session bound +keep their local codes. + +Go handler tests cover every C and K row on Agent create and update and on Session +create with and without input, the echo bounds and saved records from before this +batch. A real-PostgreSQL test replays the TV-01..03 rows on Agent create by two +tenants, on updates of owned, foreign, missing and malformed Agents, and on inline +and saved-override Session creates, with a database digest proving no writes; it +then saves and reads back the K1 values and checks tenant isolation. The +pinned-SDK acceptance scripts assert the new codes, params and messages. Real +Core, daemon and model acceptance is recorded separately by the coordinator. diff --git a/contracts/agents-api/openapi.yaml b/contracts/agents-api/openapi.yaml index d3fda78ce..5837aeac7 100644 --- a/contracts/agents-api/openapi.yaml +++ b/contracts/agents-api/openapi.yaml @@ -2795,8 +2795,13 @@ paths: description: 'Persists configuration independently of execution. Names over 128 characters and metadata outside 16 string pairs with 64-character keys and 512-character values return invalid_request_error with the official param; - U+0000 in stored strings is rejected as a local storage limit. Supports model/name/instructions/metadata, - explicit reasoning and service tiers, multi_agent, text/json_schema, function/tool_search/programmatic_tool_calling + U+0000 in stored strings is rejected as a local storage limit. Missing, unknown, + repeated, wrongly typed or unsupported enum members of the pinned configuration + shapes (tools, text, reasoning, service_tier, multi_agent) return invalid_request_error + with the JSON path as param; duplicate function names, repeated web_search + or tool_search and non-object schema root types return it with a null param. + Supports model/name/instructions/metadata, explicit reasoning and service + tiers, multi_agent, text/json_schema, function/tool_search/programmatic_tool_calling and HTTP MCP with nullable credential_id and explicit service origin and boolean required defaulting to false. Saving credential_id grants no access: Session admission checks attached Vault ownership and destination. MCP allowed_tools @@ -2942,9 +2947,10 @@ paths: - application/json description: Preserves omitted fields and replaces supplied fields using shared saved-configuration validation. Null name/instructions clear; null or empty - metadata clears all pairs. Name and metadata validation errors return invalid_request_error - with the official param. Existing Session snapshots are unchanged. Empty updates - advance updated_at without changing saved fields. Nested replacement/null + metadata clears all pairs. Name, metadata and configuration validation errors + return invalid_request_error with the official param, using the Agent create + rules before the Agent lookup. Existing Session snapshots are unchanged. Empty + updates advance updated_at without changing saved fields. Nested replacement/null defaults, model-derived reasoning and exact hosted error behavior remain incompletely verified. parameters: @@ -3688,7 +3694,11 @@ paths: keys or failed credential lookup/decryption never fall back to anonymous execution. Omitted stream defaults to false; stream and agent_id cannot be null. Metadata may be null; non-string values and limit violations return invalid_request_error - with a metadata or metadata. param. Hosted network policy rejections + with a metadata or metadata. param. The inline agent uses the Agent create + configuration validation with agent.-prefixed params, reported before the + input requirement and saved-Agent lookup; saved configurations with conflicting + tools or schema roots reject admission with the same errors, and execution + limits keep unsupported_or_invalid_configuration. Hosted network policy rejections return invalid_request_error with a null param. Initial input accepts a string or ordered user-message array. Codex and Claude SDK on none and qualified openai_hosted also accept inline PNG/JPEG image content; other image combinations diff --git a/contracts/agents-api/operation-evidence.md b/contracts/agents-api/operation-evidence.md index 5f51fcb19..3afe84213 100644 --- a/contracts/agents-api/operation-evidence.md +++ b/contracts/agents-api/operation-evidence.md @@ -1,6 +1,6 @@ # Pinned operation evidence inventory — 2026-09-23 -Baseline inventory of main `b5715912f09333e2b4449ec6f0eecaabce44c9b7`. The Session admission batch below updates creation and metadata validation, the list query tolerance batch (L) updates list and resource query handling, the validation error batch (X) updates field error codes/params, malformed path IDs and U+0000 handling, the Environment Files wire batch (G) updates Files.create/list status, envelope, query, path and empty-page behavior, and the creation stream settlement batch (J) updates creation SSE lifetime/snapshot, terminal Turn usage and Turn start order, and the Session deletion batch (Z) updates the deletion lifecycle; historical evidence retains its original revision and scope. This inventory guides repeated qualification and does not assert complete compatibility. +Baseline inventory of main `b5715912f09333e2b4449ec6f0eecaabce44c9b7`. The Session admission batch below updates creation and metadata validation, the list query tolerance batch (L) updates list and resource query handling, the validation error batch (X) updates field error codes/params, malformed path IDs and U+0000 handling, the Environment Files wire batch (G) updates Files.create/list status, envelope, query, path and empty-page behavior, and the creation stream settlement batch (J) updates creation SSE lifetime/snapshot, terminal Turn usage and Turn start order, and the Session deletion batch (Z) updates the deletion lifecycle, and the Agent configuration validation batch (M) updates saved and inline Agent configuration errors; historical evidence retains its original revision and scope. This inventory guides repeated qualification and does not assert complete compatibility. Baseline: `contracts/agents-api/upstream.json`, SDK **3.13.0**, upstream commit **d7c41efee1b0802b79f3f88a678ef2052b06e9ce**, `OpenAI-Beta: agents=v1`. AGENTS.md and relevant CONTRIBUTING.md compatibility, ownership and evidence rules govern this inventory. @@ -40,6 +40,7 @@ Repository paths below are relative to the inspected worktree; private evidence | J | [Creation stream settlement](history-events-usage.md#creation-stream-settlement-2026-09-23); private `~/.parsar/remediation/20260923/campaign-scan-2/events-tools/findings.json` EVT-01..04 with raw frames under `official/`: four owned official `none` Sessions (text, structured output, function result, cancellation), 1091 events validated against the pinned types. Core-side changes carry API/store/contract DB tests; live acceptance is recorded with the batch. EVT-05..13 and EVT-19 stay deferred or record-only. | | L | [List query tolerance](list-query-semantics.md#list-query-tolerance--september-23-2026); private `~/.parsar/remediation/20260923/campaign-scan-1/{vaults-agents,sessions,skills-files-templates}/findings.json`: owned-collection unknown/repeated keys, limit bounds, Vault status union and Files empty purpose, plus unknown keys on a deleted Vault read and Agent delete. Rows A1–D2 of that section; no model execution. | | G | [Environment Files wire alignment](environment-files.md#wire-alignment--september-23-2026); private `~/.parsar/remediation/20260923/campaign-scan-2/hosted-env/findings.json` HE-10, 16, 18, 32, 34–39 with raw records under `official/` (labels `fc01`–`fc17`, `fl01`–`fl16`, `files-*-pending`) and `run1/`: three owned hosted Sessions, all deleted; first official Environment Files observations. Rows F1–F9 of that section. Go handler, real-PostgreSQL Worker, gateway/daemon and Rust helper tests without a model; live acceptance is recorded with the batch. | +| M | [Agent configuration validation](official-semantics-alignment.md#agent-configuration-validation--september-23); private `~/.parsar/remediation/20260923/campaign-scan-3/subagents-tools/findings.json` TV-01..07 with raw records under `official/validation-{B1,B2A,B2B,B3}.json`: one owned Agent (deleted) and 44 requests without a model, Session creates without input on `none`. Rows C1–C4 and K1–K3 of that section. Go handler, real-PostgreSQL no-write/tenant replay and pinned-SDK tests without a model; live acceptance is recorded with the batch. | | Y | [Artifact capture and listing](official-semantics-alignment.md#artifact-capture-and-listing--september-23); private `~/.parsar/remediation/20260923/campaign-scan-2/hosted-env/findings.json` HE-50..62 with raw records under `official/` (labels `al01`–`al09`, `ar01`–`ar04`, `ac01`–`ac05`, `ad01`/`ad02`): three owned Sessions and two tiny Turns, all deleted; first official Artifact observations. Rows A1–A4 of that section: symlink skip, republication, list envelope and malformed filter. Rust link tests, real-PostgreSQL store/HTTP and pinned-SDK tests without a model; live acceptance is recorded with the batch. | | Z | [Session deletion lifecycle](official-semantics-alignment.md#session-deletion-lifecycle--september-23); private `~/.parsar/remediation/20260923/campaign-scan-1/sessions/findings.json` SES-29/30 with raw records under `official/` (`q5-delete-repeat`, `q5b-delete-while-in-progress`, `q5-delete-never-existed`, `q5-delete-while-running`, `q5-get-after-delete`). Rows D1–D5 of that section. Handler, real-PostgreSQL HTTP/store/lock-race, Worker, pinned-SDK, TypeScript client and Web tests without a model; live acceptance is recorded with the batch. | @@ -49,12 +50,12 @@ Paths in the appendix include `/v1`. SDK names here omit `client.`. `P` means pa | # | SDK operation | Implemented behavior | Official wire observation | Core validation | Known difference / unverified semantics | | --- | --- | --- | --- | --- | --- | -| 1 | beta.agents.create | P: saved configuration, 201; metadata/name errors use official code and param | R `agent-create-supported`; initial unsupported model case 400; X VA-07/08/09 | C DB create; T saved-Agent execution references; X DB field errors and U+0000 no-write | Model-derived reasoning defaults and unsupported configurations; complete default/null/errors unknown | +| 1 | beta.agents.create | P: saved configuration, 201; metadata/name errors use official code and param; configuration protocol errors use official code, JSON-path param and message; repeated tools and non-object schema roots reject | R `agent-create-supported`; initial unsupported model case 400; X VA-07/08/09; M TV-01/02 `AC01`/`AC02` | C DB create; T saved-Agent execution references; X DB field errors and U+0000 no-write; M DB configuration errors, no-write and tenant replay | Model-derived reasoning defaults and unsupported configurations; saved `web_search` live/omitted still rejects locally (TV-05); multi-error order, unsampled kind phrases and complete default/null/errors unknown | | 2 | beta.agents.retrieve | P: tenant-owned saved read | R `agent-read`, `agent-read-deleted` | C DB own/foreign/deleted read | Full field defaults and inline-vs-saved lifetime | -| 3 | beta.agents.update | P: atomic replacements; empty body touches timestamp; metadata/name errors use official code and param | R `agent-patch-metadata`, `agent-null-fields`, `agent-noop`, `agent-nested-reasoning`, rejection labels; X VA-07/08/09/10 | C DB no-op/unchanged snapshot; resource implementation-validation.md actual PostgreSQL SDK update tests | Model-dependent default recomputation; uncommon nested/null/error variants | +| 3 | beta.agents.update | P: atomic replacements; empty body touches timestamp; metadata/name errors use official code and param; configuration errors as for create, before the Agent lookup | R `agent-patch-metadata`, `agent-null-fields`, `agent-noop`, `agent-nested-reasoning`, rejection labels; X VA-07/08/09/10; M TV-01..03 and TV-07 update labels (`F01`–`X01`, `W04`–`M02`) | C DB no-op/unchanged snapshot; resource implementation-validation.md actual PostgreSQL SDK update tests; M DB owned/foreign/missing/malformed-ID replay, K1 values and isolation | Model-dependent default recomputation; uncommon nested/null/error variants; TV-05 saved `web_search` modes | | 4 | beta.agents.list | P: scoped cursor list; unknown keys ignored, limit 0/above 100 clamp | R `agent-list-empty-scoped`, `agent-list-limit101`; L VA-01/02/03/04/18 | L DB `official_list_query.py` tenant A/B | Core page capacity 100; no inferred official cap. Overflowing limits unsampled | | 5 | beta.agents.delete | P: resource deletion | R `cleanup-agent`, subsequent 404 | C DB delete/post-delete | Referenced/in-flight/repeated-delete exact parity | -| 6 | beta.agents.sessions.create | P: JSON/live SSE 201, saved/inline frozen config, initial messages, native profiles; fresh creation SSE sends the JSON 201 projection, then ends right after the first idle recorded when a Turn ends or an input reservation stops being pending, or any failed, never sending later events; nothing admitted ends after `created`; a silent settlement ends after events up to the cursor read with a settled projection in one snapshot. A same-key stream retry returns 201, sends no events and ends at once | S `create-1/2.json`, `omitted-input.json`, `null-input.json`, `empty-array-input.json`, `retry-status-original/repeat.json`; H stream; J creation streams closed after idle, open through requires_action | N Live none admission and retry; C Live three hosted profiles; D/T/K/I recorded additional workflows; J DB creation-stream lifetime/snapshot/retry | Session admission batch removes idle `none` creation; local idempotent create still differs from two official IDs. Stream retry, self-hosted, hosted and no-input stream lifetimes are local; work drained before a silent-settlement read can still be sent. Many input/tool/environment combinations restricted | +| 6 | beta.agents.sessions.create | P: JSON/live SSE 201, saved/inline frozen config, initial messages, native profiles; inline agent configuration errors use official fields with `agent.` params before the input requirement, and saved records with repeated tools or non-object schema roots reject admission; fresh creation SSE sends the JSON 201 projection, then ends right after the first idle recorded when a Turn ends or an input reservation stops being pending, or any failed, never sending later events; nothing admitted ends after `created`; a silent settlement ends after events up to the cursor read with a settled projection in one snapshot. A same-key stream retry returns 201, sends no events and ends at once | S `create-1/2.json`, `omitted-input.json`, `null-input.json`, `empty-array-input.json`, `retry-status-original/repeat.json`; H stream; J creation streams closed after idle, open through requires_action; M TV-01..04 `SC01`–`SC11` | N Live none admission and retry; C Live three hosted profiles; D/T/K/I recorded additional workflows; J DB creation-stream lifetime/snapshot/retry; M DB inline and saved-override configuration errors without writes | Session admission batch removes idle `none` creation; local idempotent create still differs from two official IDs. Stream retry, self-hosted, hosted and no-input stream lifetimes are local; work drained before a silent-settlement read can still be sent. Many input/tool/environment combinations restricted; harness admission limits keep the local code (TV-06) | | 7 | beta.agents.sessions.retrieve | P: persisted state, required actions, usage; malformed ID equals missing | S `retrieve-1.json`, `session-after-1.json`; H recovered state; X SES-28 | C Live history; T pending actions; H Core acceptance recorded | Complete statuses/actions/lifecycle timing; Claude/MiniMax public usage remains null | | 8 | beta.agents.sessions.update | P: metadata-only replacement/clear; metadata errors use official code and `metadata`/`metadata.` param | S `metadata-replace/null/empty/omit/invalid-value.json`; `update-agent.json` uses newer unpinned field | N Live completed Session metadata rejection/clear/isolation; recorded active controlled metadata coverage | Session admission batch changes empty update to observed official 400; Session agent update belongs to baseline upgrade, not fixed-pin operation gap | | 9 | beta.agents.sessions.list | P: Agent filter, full envelope, cursor paging; unknown keys ignored, limit 0/above 100 clamp | S `list-filter.json`, `list-empty-after.json`, `list-owned-cross-filter-cursor.json`, limit/order/unknown-query samples; L SES-10/11/15/16/17 | C Live order/cursors/empty/tenant checks; L DB tenant A/B | Core page capacity 100; official cap unknown. Eventual visibility sample is not a required delay | diff --git a/contracts/agents-api/structured-output.md b/contracts/agents-api/structured-output.md index 74572f7ad..e15e6a642 100644 --- a/contracts/agents-api/structured-output.md +++ b/contracts/agents-api/structured-output.md @@ -14,8 +14,11 @@ workspace tools, preparation, Files and Artifacts. The native SDK remains responsible for its model/tool loop and schema validation. Codex and MiniMax structured-output execution, self-hosted/E2B execution, Skills/Plugins/capability directories (including inherited template contents), HTTP MCP, Subagent/tool-search -combinations and other root types are unqualified and explicitly rejected. These -are implementation gaps, not a redefinition of the official protocol. +combinations and schemas without an explicit object root are unqualified and +explicitly rejected. These are implementation gaps, not a redefinition of the +official protocol. An explicit non-object root type is an official protocol error +on save and Session creation for every harness +([validation](official-semantics-alignment.md#agent-configuration-validation--september-23)). The Claude SDK consumes JSON numbers as binary64. Session admission rejects schema numbers whose values cannot survive that conversion; saved Agent resources still diff --git a/contracts/agents-api/tool-policy.md b/contracts/agents-api/tool-policy.md index 05fbd07f0..f2d2a752e 100644 --- a/contracts/agents-api/tool-policy.md +++ b/contracts/agents-api/tool-policy.md @@ -18,8 +18,13 @@ Saved references use the same parser and immutable Session snapshot. Disabled search retains optional settings as resource data: omitted/null context size resolves to `medium`; domain and location omission resolves to null; an empty domain list remains empty. These settings cannot enable execution while disabled. -Web-search mode omission/null, enabled search and exact hosted errors remain -unqualified. Explicit enabled programmatic execution rejects at Session admission; +Web-search mode omission/null and enabled search remain unqualified. Protocol +errors in these declarations, such as an unsupported `mode` or `context_size`, a +non-boolean `enabled` or a repeated `web_search`, use the official fields +([validation](official-semantics-alignment.md#agent-configuration-validation--september-23)); +saved modes other than `disabled`, including omission, keep the local +`unsupported_or_invalid_configuration` rejection, which the official service does +not apply (TV-05, deferred). Explicit enabled programmatic execution rejects at Session admission; saving that intent remains separate from execution qualification. Omitting programmatic configuration preserves each harness's native behavior. diff --git a/contracts/agents-api/tool-search.md b/contracts/agents-api/tool-search.md index 1df4ea1d8..1acad6fc4 100644 --- a/contracts/agents-api/tool-search.md +++ b/contracts/agents-api/tool-search.md @@ -26,8 +26,9 @@ false and the adapter explicitly enables native ToolSearch. Only declared callba and ToolSearch are allowed. No search index, callback protocol, provider proxy or model loop is added to production. -Search-only, missing-search, duplicate-search, workspace, HTTP MCP, structured-output -and Subagent combinations remain unqualified. They are implementation/verification +Search-only, missing-search, workspace, HTTP MCP, structured-output and Subagent +combinations remain unqualified. A repeated `tool_search` is an official protocol +error on saved and inline configuration. They are implementation/verification gaps, not claimed upstream restrictions. Codex and MiniMax discovery remain gaps. Unknown public combinations reject before execution; an actual Runtime must also advertise the operation. An advertisement alone cannot qualify a public profile. diff --git a/services/agents-api/internal/api/agents.go b/services/agents-api/internal/api/agents.go index 851e45e6c..63c4e7de8 100644 --- a/services/agents-api/internal/api/agents.go +++ b/services/agents-api/internal/api/agents.go @@ -20,7 +20,7 @@ type AgentStore interface { } // @Summary Create a reusable Agent -// @Description Persists configuration independently of execution. Names over 128 characters and metadata outside 16 string pairs with 64-character keys and 512-character values return invalid_request_error with the official param; U+0000 in stored strings is rejected as a local storage limit. Supports model/name/instructions/metadata, explicit reasoning and service tiers, multi_agent, text/json_schema, function/tool_search/programmatic_tool_calling and HTTP MCP with nullable credential_id and explicit service origin and boolean required defaulting to false. Saving credential_id grants no access: Session admission checks attached Vault ownership and destination. MCP allowed_tools preserves null versus empty; saved HTTP transport includes empty headers. Model-derived reasoning defaults, other MCP variants, enabled web_search and public retry conformance remain incomplete. Explicit disabled web_search can be saved; Session execution also accepts explicit disabled programmatic_tool_calling through qualified Runtime controls. Session execution admits only its supported configuration subset. +// @Description Persists configuration independently of execution. Names over 128 characters and metadata outside 16 string pairs with 64-character keys and 512-character values return invalid_request_error with the official param; U+0000 in stored strings is rejected as a local storage limit. Missing, unknown, repeated, wrongly typed or unsupported enum members of the pinned configuration shapes (tools, text, reasoning, service_tier, multi_agent) return invalid_request_error with the JSON path as param; duplicate function names, repeated web_search or tool_search and non-object schema root types return it with a null param. Supports model/name/instructions/metadata, explicit reasoning and service tiers, multi_agent, text/json_schema, function/tool_search/programmatic_tool_calling and HTTP MCP with nullable credential_id and explicit service origin and boolean required defaulting to false. Saving credential_id grants no access: Session admission checks attached Vault ownership and destination. MCP allowed_tools preserves null versus empty; saved HTTP transport includes empty headers. Model-derived reasoning defaults, other MCP variants, enabled web_search and public retry conformance remain incomplete. Explicit disabled web_search can be saved; Session execution also accepts explicit disabled programmatic_tool_calling through qualified Runtime controls. Session execution admits only its supported configuration subset. // @Tags Agents // @Accept json // @Produce json @@ -35,7 +35,7 @@ func (h *Handler) createAgent(w http.ResponseWriter, r *http.Request) { if !ok { return } - if writeFieldError(w, metadataTypeError(raw)) { + if writeFieldError(w, metadataTypeError(raw)) || writeFieldError(w, validateSavedAgentBody(raw, savedAgentCreate)) { return } var request v1.CreateAgentRequest diff --git a/services/agents-api/internal/api/agents_update.go b/services/agents-api/internal/api/agents_update.go index 04ca88d2d..a3cf80bb5 100644 --- a/services/agents-api/internal/api/agents_update.go +++ b/services/agents-api/internal/api/agents_update.go @@ -11,7 +11,7 @@ import ( ) // @Summary Update a reusable Agent -// @Description Preserves omitted fields and replaces supplied fields using shared saved-configuration validation. Null name/instructions clear; null or empty metadata clears all pairs. Name and metadata validation errors return invalid_request_error with the official param. Existing Session snapshots are unchanged. Empty updates advance updated_at without changing saved fields. Nested replacement/null defaults, model-derived reasoning and exact hosted error behavior remain incompletely verified. +// @Description Preserves omitted fields and replaces supplied fields using shared saved-configuration validation. Null name/instructions clear; null or empty metadata clears all pairs. Name, metadata and configuration validation errors return invalid_request_error with the official param, using the Agent create rules before the Agent lookup. Existing Session snapshots are unchanged. Empty updates advance updated_at without changing saved fields. Nested replacement/null defaults, model-derived reasoning and exact hosted error behavior remain incompletely verified. // @Tags Agents // @Accept json // @Produce json @@ -51,6 +51,9 @@ func resolveAgentUpdate(raw []byte) (store.UpdateAgentInput, error) { if err := metadataTypeError(raw); err != nil { return store.UpdateAgentInput{}, err } + if err := validateSavedAgentBody(raw, savedAgentUpdate); err != nil { + return store.UpdateAgentInput{}, err + } var request v1.UpdateAgentRequest if decodeInputObject(raw, &request, "model", "name", "instructions", "metadata", "multi_agent", "reasoning", "service_tier", "text", "tools", "x_agents_core") != nil { return store.UpdateAgentInput{}, errors.New("Request must be a JSON object containing supported fields.") diff --git a/services/agents-api/internal/api/configuration_validation.go b/services/agents-api/internal/api/configuration_validation.go new file mode 100644 index 000000000..5f922ae19 --- /dev/null +++ b/services/agents-api/internal/api/configuration_validation.go @@ -0,0 +1,427 @@ +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "strconv" + "strings" +) + +// Agent configuration protocol validation is owned by Core. It covers saved +// Agent create and update bodies and the inline Session agent, and runs before +// the configuration parsers (which keep Core's local limits and codes) and +// before harness admission. It walks the raw JSON along the pinned SDK shapes +// (PersistedAgentToolParam/AgentToolParam, AgentTextParam, AgentReasoningParam, +// MultiAgentConfigParam and the service_tier literal) and reports the first +// violation as a fieldError with the JSON path as param and the official +// message forms. Values the pinned types leave open, such as JSON Schemas, +// request metadata, MCP transport members, metadata and x_agents_core, are +// left to their existing parsers. + +type valueKind uint8 + +const ( + anyValue valueKind = iota // validated by the existing parsers + stringValue // str + booleanValue // bool + integerValue // int with an inclusive minimum + enumValue // Literal[...] of strings + arrayValue // Iterable/SequenceNotStr of items + mapValue // Dict[str, object] + openObject // an object whose members are validated elsewhere + objectValue // TypedDict members + unionValue // TypedDicts selected by their "type" member +) + +type shape struct { + kind valueKind + required bool + nullable bool + minimum int64 + values []string // enum values, or union types in the pinned order + members []member // objectValue members + variants map[string][]member // unionValue members other than "type" + items *shape // arrayValue items +} + +type member struct { + name string + shape +} + +var ( + requiredString = shape{kind: stringValue, required: true} + nullableString = shape{kind: stringValue, nullable: true} + stringList = shape{kind: arrayValue, nullable: true, items: &shape{kind: stringValue}} + + agentTools = shape{kind: arrayValue, nullable: true, items: &shape{kind: unionValue, + values: []string{"function", "tool_search", "programmatic_tool_calling", "mcp", "web_search"}, + variants: map[string][]member{ + "function": { + {"description", requiredString}, {"name", requiredString}, + {"parameters", shape{kind: mapValue, required: true}}, {"defer_loading", shape{kind: booleanValue}}, + }, + "tool_search": nil, + "programmatic_tool_calling": {{"enabled", shape{kind: booleanValue}}}, + "mcp": { + {"server_label", requiredString}, {"transport", shape{kind: openObject, required: true}}, + {"allowed_tools", stringList}, + {"connection_origin", shape{kind: enumValue, nullable: true, values: []string{"service", "environment"}}}, + {"credential_id", nullableString}, {"request_metadata", shape{kind: mapValue, nullable: true}}, + {"required", shape{kind: booleanValue}}, + }, + "web_search": { + {"allowed_domains", stringList}, + {"context_size", shape{kind: enumValue, nullable: true, values: []string{"low", "medium", "high"}}}, + {"location", shape{kind: objectValue, nullable: true, members: []member{ + {"city", nullableString}, {"country", nullableString}, {"region", nullableString}, {"timezone", nullableString}, + }}}, + {"mode", shape{kind: enumValue, nullable: true, values: []string{"disabled", "cached", "live"}}}, + }, + }}} + agentText = shape{kind: objectValue, nullable: true, members: []member{ + {"format", shape{kind: unionValue, nullable: true, values: []string{"text", "json_schema"}, + variants: map[string][]member{"text": nil, "json_schema": {{"schema", shape{kind: mapValue, required: true}}}}}}, + {"verbosity", shape{kind: enumValue, nullable: true, values: []string{"low", "medium", "high"}}}, + }} + agentReasoning = shape{kind: objectValue, nullable: true, members: []member{ + {"effort", shape{kind: enumValue, nullable: true, values: []string{"none", "minimal", "low", "medium", "high", "xhigh", "max"}}}, + {"summary", shape{kind: enumValue, nullable: true, values: []string{"concise", "detailed", "auto"}}}, + }} + agentMultiAgent = shape{kind: objectValue, nullable: true, members: []member{ + {"enabled", shape{kind: booleanValue, required: true}}, + {"max_concurrent_subagents", shape{kind: integerValue, minimum: 1}}, + }} + + savedAgentCreate = agentShape(true, true) + savedAgentUpdate = agentShape(true, false) + sessionAgent = agentShape(false, false) +) + +// agentShape returns AgentCreateParams, AgentUpdateParams or the Session Agent. +// Saved Agents additionally carry name and metadata; only creation requires a model. +func agentShape(saved, create bool) shape { + members := []member{ + {"model", shape{kind: stringValue, required: create}}, + {"instructions", nullableString}, + {"multi_agent", agentMultiAgent}, + {"reasoning", agentReasoning}, + {"service_tier", shape{kind: enumValue, nullable: true, values: []string{"auto", "default", "flex", "priority", "fast"}}}, + {"text", agentText}, + {"tools", agentTools}, + {"x_agents_core", shape{}}, + } + if saved { + members = append(members, member{"name", nullableString}, member{"metadata", shape{}}) + } + return shape{kind: objectValue, members: members} +} + +// validateSavedAgentBody checks a saved Agent create or update body. Malformed +// and non-object bodies keep the existing whole-body error. +func validateSavedAgentBody(raw []byte, root shape) error { + if !json.Valid(raw) || jsonValueKind(raw) != "an object" { + return nil + } + return validateAgentConfiguration("", raw, root) +} + +// validateSessionAgent checks the inline Session agent, whose paths start with agent. +func validateSessionAgent(raw json.RawMessage) error { + return validateAgentConfiguration("agent", raw, sessionAgent) +} + +func validateAgentConfiguration(path string, raw json.RawMessage, root shape) error { + if err := checkValue(path, raw, root); err != nil { + return err + } + _, fields := orderedMembers(raw) + var tools []json.RawMessage + if value := fields["tools"]; jsonValueKind(value) == "an array" { + _ = json.Unmarshal(value, &tools) + } + var text struct { + Format *struct { + Type string `json:"type"` + Schema json.RawMessage `json:"schema"` + } `json:"format"` + } + if value := fields["text"]; jsonValueKind(value) == "an object" { + _ = json.Unmarshal(value, &text) + } + if text.Format == nil { + return configurationConflict(tools, "", nil) + } + return configurationConflict(tools, text.Format.Type, text.Format.Schema) +} + +func checkValue(path string, raw json.RawMessage, s shape) error { + got := jsonValueKind(raw) + if s.kind == anyValue || got == "null" && s.nullable { + return nil + } + switch s.kind { + case stringValue, enumValue: + if got != "a string" { + return invalidType(path, "a string", got) + } + if s.kind == enumValue { + var value string + if json.Unmarshal(raw, &value) != nil || !containsString(s.values, value) { + return invalidValue(path, value, s.values) + } + } + case booleanValue: + if got != "a boolean" { + return invalidType(path, "a boolean", got) + } + case integerValue: + if got != "an integer" { + return invalidType(path, "an integer", got) + } + return checkMinimum(path, string(bytes.TrimSpace(raw)), s.minimum) + case arrayValue: + if got != "an array" { + return invalidType(path, "an array", got) + } + var items []json.RawMessage + if err := json.Unmarshal(raw, &items); err != nil { + return err + } + for i, item := range items { + if err := checkValue(fmt.Sprintf("%s[%d]", path, i), item, *s.items); err != nil { + return err + } + } + case mapValue: + if got != "an object" { + return invalidType(path, "an object with string keys and unknown value values", got) + } + case openObject: + if got != "an object" { + return invalidType(path, "an object", got) + } + case objectValue: + if got != "an object" { + return invalidType(path, "an object", got) + } + return checkMembers(path, raw, s.members) + case unionValue: + if got != "an object" { + return invalidType(path, "an object", got) + } + _, fields := orderedMembers(raw) + kind, supplied := fields["type"] + if !supplied { + return missingParameter(joinPath(path, "type")) + } + if got := jsonValueKind(kind); got != "a string" { + return invalidType(joinPath(path, "type"), "a string", got) + } + var value string + _ = json.Unmarshal(kind, &value) + variant, known := s.variants[value] + if !known { + return invalidValue(joinPath(path, "type"), value, s.values) + } + return checkMembers(path, raw, append([]member{{"type", shape{}}}, variant...)) + } + return nil +} + +// checkMembers rejects unknown and repeated members in document order, then +// validates the values in document order and the required members in the pinned +// order. Names match exactly, so a name that differs from a member only by case +// is unknown. encoding/json matches names case-insensitively and merges repeated +// objects into pointer structs, so only an object whose members each appear once +// with their exact names decodes to the checked values. +func checkMembers(path string, raw json.RawMessage, members []member) error { + keys, fields := orderedMembers(raw) + seen := make(map[string]bool, len(keys)) + for _, key := range keys { + if _, known := findMember(members, key); !known { + if !echoableField(key) { + return errUnknownParameter + } + return &fieldError{param: joinPath(path, key), message: fmt.Sprintf("Unknown parameter: '%s'.", joinPath(path, key))} + } + if seen[key] { + // A local message: the official response to a repeated member is unobserved. + return &fieldError{param: joinPath(path, key), message: fmt.Sprintf("Duplicate parameter: '%s'.", joinPath(path, key))} + } + seen[key] = true + } + for _, key := range keys { + spec, _ := findMember(members, key) + if err := checkValue(joinPath(path, key), fields[key], spec.shape); err != nil { + return err + } + } + for _, spec := range members { + if _, supplied := fields[spec.name]; spec.required && !supplied { + return missingParameter(joinPath(path, spec.name)) + } + } + return nil +} + +// configurationConflict reports the official semantic rejections of a +// well-formed configuration: a non-object schema root, a repeated function name +// and a repeated web_search or tool_search declaration. Schemas without a string +// root type are unchanged. Session admission also applies it to the resolved +// saved configuration, so records saved before these checks cannot execute. +func configurationConflict(tools []json.RawMessage, formatType string, schema json.RawMessage) error { + names, declared := map[string]bool{}, map[string]bool{} + for _, raw := range tools { + var tool struct { + Type string `json:"type"` + Name string `json:"name"` + Parameters json.RawMessage `json:"parameters"` + } + if json.Unmarshal(raw, &tool) != nil { + continue + } + switch tool.Type { + case "function": + if root, typed := schemaRootType(tool.Parameters); typed && root != "object" { + return functionSchemaError(tool.Name, root) + } + if names[tool.Name] { + if !echoableField(tool.Name) { + return &fieldError{message: "duplicate function tool name"} + } + return &fieldError{message: "duplicate function tool name: " + tool.Name} + } + names[tool.Name] = true + case "web_search", "tool_search": + if declared[tool.Type] { + return &fieldError{message: "duplicate " + tool.Type + " tool"} + } + declared[tool.Type] = true + } + } + if root, typed := schemaRootType(schema); formatType == "json_schema" && typed && root != "object" { + // The official message names the Session path on saved Agents too. + if !echoableField(root) { + return &fieldError{message: `agent.text.format.schema must have top-level type "object"`} + } + return &fieldError{message: fmt.Sprintf(`agent.text.format.schema must have top-level type "object"; got "%s"`, root)} + } + return nil +} + +func functionSchemaError(name, root string) error { + subject, got := "function", "" + if echoableField(name) { + subject = fmt.Sprintf("function '%s'", name) + } + if echoableField(root) { + got = fmt.Sprintf(`, got 'type: "%s"'`, root) + } + return &fieldError{message: fmt.Sprintf(`Invalid schema for %s: schema must be a JSON Schema of 'type: "object"'%s.`, subject, got)} +} + +// schemaRootType returns a schema object's top-level type when it is a string. +// A map keeps the key match exact; struct decoding would also match "Type". +func schemaRootType(schema json.RawMessage) (string, bool) { + var root map[string]json.RawMessage + var value string + if json.Unmarshal(schema, &root) != nil || jsonValueKind(root["type"]) != "a string" || json.Unmarshal(root["type"], &value) != nil { + return "", false + } + return value, true +} + +// errUnknownParameter keeps the official code for an unknown member whose name +// is not echoed (the Environment Files rule). +var errUnknownParameter = &fieldError{message: "Unknown parameter."} + +func missingParameter(path string) error { + return &fieldError{param: path, message: fmt.Sprintf("Missing required parameter: '%s'.", path)} +} + +func invalidType(path, expected, got string) error { + return &fieldError{param: path, message: fmt.Sprintf("Invalid type for '%s': expected %s, but got %s instead.", path, expected, got)} +} + +// invalidValue repeats a short printable value only, like unknown member names. +func invalidValue(path, value string, supported []string) error { + quoted := make([]string, len(supported)) + for i, value := range supported { + quoted[i] = "'" + value + "'" + } + list := strings.Join(quoted, ", ") + if len(quoted) == 2 { + list = quoted[0] + " and " + quoted[1] + } else if len(quoted) > 2 { + list = strings.Join(quoted[:len(quoted)-1], ", ") + ", and " + quoted[len(quoted)-1] + } + if !echoableField(value) { + return &fieldError{param: path, message: "Invalid value. Supported values are: " + list + "."} + } + return &fieldError{param: path, message: fmt.Sprintf("Invalid value: '%s'. Supported values are: %s.", value, list)} +} + +// checkMinimum receives an integer literal; values outside int64 are only below +// the minimum when negative, and are then not repeated. +func checkMinimum(path, literal string, minimum int64) error { + value, err := strconv.ParseInt(literal, 10, 64) + if err == nil && value >= minimum || err != nil && !strings.HasPrefix(literal, "-") { + return nil + } + message := fmt.Sprintf("Invalid '%s': integer below minimum value. Expected a value >= %d", path, minimum) + if err == nil { + message += fmt.Sprintf(", but got %d instead.", value) + } else { + message += "." + } + return &fieldError{param: path, message: message} +} + +func joinPath(path, name string) string { + if path == "" { + return name + } + return path + "." + name +} + +func findMember(members []member, name string) (member, bool) { + for _, candidate := range members { + if candidate.name == name { + return candidate, true + } + } + return member{}, false +} + +func containsString(values []string, value string) bool { + for _, candidate := range values { + if candidate == value { + return true + } + } + return false +} + +// orderedMembers returns every key of an object in document order, including +// repeated keys, and each key's last value. The input is valid JSON. +func orderedMembers(raw json.RawMessage) ([]string, map[string]json.RawMessage) { + decoder := json.NewDecoder(bytes.NewReader(raw)) + fields := map[string]json.RawMessage{} + if token, err := decoder.Token(); err != nil || token != json.Delim('{') { + return nil, fields + } + var keys []string + for decoder.More() { + token, err := decoder.Token() + key, isKey := token.(string) + var value json.RawMessage + if err != nil || !isKey || decoder.Decode(&value) != nil { + return keys, fields + } + keys = append(keys, key) + fields[key] = value + } + return keys, fields +} diff --git a/services/agents-api/internal/api/configuration_validation_test.go b/services/agents-api/internal/api/configuration_validation_test.go new file mode 100644 index 000000000..d1b6bf457 --- /dev/null +++ b/services/agents-api/internal/api/configuration_validation_test.go @@ -0,0 +1,363 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +// savedConfigurationStore serves fixed saved Agent configurations, including +// records saved before the protocol checks existed. +type savedConfigurationStore struct { + *validationStore + agents map[string]string +} + +func (s *savedConfigurationStore) GetAgent(_ context.Context, tenant, id string) (store.SavedAgent, error) { + configuration, ok := s.agents[id] + if !ok { + return store.SavedAgent{}, store.ErrNotFound + } + return store.SavedAgent{ID: id, TenantID: tenant, Configuration: json.RawMessage(configuration), Metadata: map[string]string{}}, nil +} + +func configurationHandler(t *testing.T, agents map[string]string) (http.Handler, *savedConfigurationStore) { + t.Helper() + s := &savedConfigurationStore{validationStore: &validationStore{}, agents: agents} + h, recording, _ := testHandler(t, WithExecution(&inputRecorder{ResourceStore: s})) + recording.ResourceStore = s + return h, s +} + +type configurationOperation struct { + name, path, prefix string + body func(fields string) string +} + +func configurationOperations() []configurationOperation { + return []configurationOperation{ + {"agent create", "/v1/agents", "", func(f string) string { return `{"model":"validation-model",` + f + `}` }}, + {"agent update", "/v1/agents/" + uuid.NewString(), "", func(f string) string { return `{` + f + `}` }}, + {"session create", "/v1/agents/sessions", "agent.", func(f string) string { + return `{"agent":{"model":"validation-model",` + f + `},"environment":{"type":"none"},"input":"Validate."}` + }}, + // C4: configuration errors precede the none input requirement. + {"session create without input", "/v1/agents/sessions", "agent.", func(f string) string { + return `{"agent":{"model":"validation-model",` + f + `},"environment":{"type":"none"}}` + }}, + } +} + +func lookupTool(name, fields string) string { + return `{"type":"function","name":"` + name + `","description":"Look up a value.","parameters":{"type":"object","properties":{"q":{"type":"string"}},"required":["q"]}` + fields + `}` +} + +func assertConfigurationError(t *testing.T, w *httptest.ResponseRecorder, code string, param *string, message string) { + t.Helper() + got, gotParam, gotMessage := errorFields(t, w) + if w.Code != http.StatusBadRequest || got != code || (param == nil) != (gotParam == nil) || param != nil && *param != *gotParam || gotMessage != message { + t.Fatalf("want %s %v %q, got %d %s", code, param, message, w.Code, w.Body) + } +} + +func TestAgentConfigurationProtocolErrorsUseOfficialFields(t *testing.T) { + const tools = `, 'tool_search', 'programmatic_tool_calling', 'mcp', and 'web_search'.` + cases := []struct{ name, fields, param, message string }{ + // C1, observed (TV-01). {p} is the Session "agent." prefix. + {"F02", `"tools":[{"type":"function","name":"lookup","description":"Look up a value.","parameters":[]}]`, "{p}tools[0].parameters", "Invalid type for '{p}tools[0].parameters': expected an object with string keys and unknown value values, but got an array instead."}, + {"F03", `"tools":[{"type":"function","name":"lookup","description":"Look up a value."}]`, "{p}tools[0].parameters", "Missing required parameter: '{p}tools[0].parameters'."}, + {"F04", `"tools":[` + lookupTool("lookup", `,"strict":true`) + `]`, "{p}tools[0].strict", "Unknown parameter: '{p}tools[0].strict'."}, + {"F05", `"tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}]`, "{p}tools[0].description", "Missing required parameter: '{p}tools[0].description'."}, + {"U01", `"tools":[{"type":"code_interpreter"}]`, "{p}tools[0].type", "Invalid value: 'code_interpreter'. Supported values are: 'function'" + tools}, + {"U02", `"tools":[{"type":"bogus_tool"}]`, "{p}tools[0].type", "Invalid value: 'bogus_tool'. Supported values are: 'function'" + tools}, + {"W03", `"tools":[{"type":"web_search","mode":"bogus"}]`, "{p}tools[0].mode", "Invalid value: 'bogus'. Supported values are: 'disabled', 'cached', and 'live'."}, + {"W04", `"tools":[{"type":"web_search","mode":"disabled","context_size":"huge"}]`, "{p}tools[0].context_size", "Invalid value: 'huge'. Supported values are: 'low', 'medium', and 'high'."}, + {"P02", `"tools":[{"type":"programmatic_tool_calling","enabled":"yes"}]`, "{p}tools[0].enabled", "Invalid type for '{p}tools[0].enabled': expected a boolean, but got a string instead."}, + {"T01", `"text":{"format":{"type":"json_schema"}}`, "{p}text.format.schema", "Missing required parameter: '{p}text.format.schema'."}, + {"T02", `"text":{"format":{"type":"json_schema","name":"out","strict":true,"schema":{"type":"object","properties":{}}}}`, "{p}text.format.name", "Unknown parameter: '{p}text.format.name'."}, + {"T03", `"text":{"format":{"type":"json_object"}}`, "{p}text.format.type", "Invalid value: 'json_object'. Supported values are: 'text' and 'json_schema'."}, + {"R01", `"reasoning":{"effort":"extreme"}`, "{p}reasoning.effort", "Invalid value: 'extreme'. Supported values are: 'none', 'minimal', 'low', 'medium', 'high', 'xhigh', and 'max'."}, + {"R02", `"reasoning":{"summary":"verbose"}`, "{p}reasoning.summary", "Invalid value: 'verbose'. Supported values are: 'concise', 'detailed', and 'auto'."}, + {"S01", `"service_tier":"turbo"`, "{p}service_tier", "Invalid value: 'turbo'. Supported values are: 'auto', 'default', 'flex', 'priority', and 'fast'."}, + {"X01", `"tool_choice":"auto"`, "{p}tool_choice", "Unknown parameter: '{p}tool_choice'."}, + {"TS1", `"tools":[{"type":"tool_search","max_results":3}]`, "{p}tools[0].max_results", "Unknown parameter: '{p}tools[0].max_results'."}, + {"F09", `"tools":["lookup"]`, "{p}tools[0]", "Invalid type for '{p}tools[0]': expected an object, but got a string instead."}, + {"F10", `"tools":[` + lookupTool("lookup", `,"defer_loading":"yes"`) + `]`, "{p}tools[0].defer_loading", "Invalid type for '{p}tools[0].defer_loading': expected a boolean, but got a string instead."}, + {"M01", `"multi_agent":{}`, "{p}multi_agent.enabled", "Missing required parameter: '{p}multi_agent.enabled'."}, + {"M02", `"multi_agent":{"enabled":true,"max_concurrent_subagents":0}`, "{p}multi_agent.max_concurrent_subagents", "Invalid '{p}multi_agent.max_concurrent_subagents': integer below minimum value. Expected a value >= 1, but got 0 instead."}, + // C1, the same forms at unsampled positions of the pinned shapes. + {"null tool", `"tools":[null]`, "{p}tools[0]", "Invalid type for '{p}tools[0]': expected an object, but got null instead."}, + {"missing tool type", `"tools":[{}]`, "{p}tools[0].type", "Missing required parameter: '{p}tools[0].type'."}, + {"null tool type", `"tools":[{"type":null}]`, "{p}tools[0].type", "Invalid type for '{p}tools[0].type': expected a string, but got null instead."}, + {"tools object", `"tools":{}`, "{p}tools", "Invalid type for '{p}tools': expected an array, but got an object instead."}, + {"second tool", `"tools":[{"type":"tool_search"},{"type":"web_search","mode":"disabled","location":{"extra":true}}]`, "{p}tools[1].location.extra", "Unknown parameter: '{p}tools[1].location.extra'."}, + {"null domain", `"tools":[{"type":"web_search","mode":"disabled","allowed_domains":[null]}]`, "{p}tools[0].allowed_domains[0]", "Invalid type for '{p}tools[0].allowed_domains[0]': expected a string, but got null instead."}, + {"null enabled", `"tools":[{"type":"programmatic_tool_calling","enabled":null}]`, "{p}tools[0].enabled", "Invalid type for '{p}tools[0].enabled': expected a boolean, but got null instead."}, + {"mcp origin", `"tools":[{"type":"mcp","server_label":"x","transport":{"type":"http","server_url":"https://example.invalid"},"connection_origin":"bogus"}]`, "{p}tools[0].connection_origin", "Invalid value: 'bogus'. Supported values are: 'service' and 'environment'."}, + {"mcp transport", `"tools":[{"type":"mcp","server_label":"x"}]`, "{p}tools[0].transport", "Missing required parameter: '{p}tools[0].transport'."}, + {"verbosity", `"text":{"verbosity":"verbose"}`, "{p}text.verbosity", "Invalid value: 'verbose'. Supported values are: 'low', 'medium', and 'high'."}, + {"text member", `"text":{"unknown":true}`, "{p}text.unknown", "Unknown parameter: '{p}text.unknown'."}, + {"format type", `"text":{"format":{}}`, "{p}text.format.type", "Missing required parameter: '{p}text.format.type'."}, + {"text schema", `"text":{"format":{"type":"text","schema":{}}}`, "{p}text.format.schema", "Unknown parameter: '{p}text.format.schema'."}, + {"schema array", `"text":{"format":{"type":"json_schema","schema":[]}}`, "{p}text.format.schema", "Invalid type for '{p}text.format.schema': expected an object with string keys and unknown value values, but got an array instead."}, + {"reasoning array", `"reasoning":[]`, "{p}reasoning", "Invalid type for '{p}reasoning': expected an object, but got an array instead."}, + {"case variant", `"reasoning":{"Effort":"high"}`, "{p}reasoning.Effort", "Unknown parameter: '{p}reasoning.Effort'."}, + // encoding/json merges repeated objects and matches names case-insensitively, + // so repeated members and case variants reject anywhere in the tree (local message). + {"merged text", `"text":{"format":{"type":"json_schema","schema":{"type":"array"}}},"text":{"verbosity":"low"}`, "{p}text", "Duplicate parameter: '{p}text'."}, + {"merged reasoning", `"reasoning":{"Effort":"high"},"reasoning":{}`, "{p}reasoning", "Duplicate parameter: '{p}reasoning'."}, + {"merged location", `"tools":[{"type":"web_search","mode":"disabled","location":{"city":"Paris"},"location":{"country":null}}]`, "{p}tools[0].location", "Duplicate parameter: '{p}tools[0].location'."}, + {"case variant member", `"reasoning":{"effort":"high","EFFORT":"max"}`, "{p}reasoning.EFFORT", "Unknown parameter: '{p}reasoning.EFFORT'."}, + {"repeated scalar", `"reasoning":{"effort":"high","effort":"bogus"}`, "{p}reasoning.effort", "Duplicate parameter: '{p}reasoning.effort'."}, + {"repeated root", `"service_tier":"auto","service_tier":"flex"`, "{p}service_tier", "Duplicate parameter: '{p}service_tier'."}, + {"repeated tool type", `"tools":[{"type":"function","type":"function","name":"f","description":"","parameters":{}}]`, "{p}tools[0].type", "Duplicate parameter: '{p}tools[0].type'."}, + {"repeated nested", `"multi_agent":{"enabled":true,"enabled":false}`, "{p}multi_agent.enabled", "Duplicate parameter: '{p}multi_agent.enabled'."}, + {"case variant root", `"Text":{}`, "{p}Text", "Unknown parameter: '{p}Text'."}, + {"case variant nested", `"text":{"Verbosity":"low"}`, "{p}text.Verbosity", "Unknown parameter: '{p}text.Verbosity'."}, + {"case variant tool", `"tools":[{"type":"web_search","mode":"disabled","Location":{}}]`, "{p}tools[0].Location", "Unknown parameter: '{p}tools[0].Location'."}, + {"unknown first", `"tool_choice":"auto","text":{},"text":{}`, "{p}tool_choice", "Unknown parameter: '{p}tool_choice'."}, + {"repeat first", `"text":{},"text":{},"tool_choice":"auto"`, "{p}text", "Duplicate parameter: '{p}text'."}, + {"repeat before values", `"reasoning":{"effort":"bogus"},"text":{},"text":{}`, "{p}text", "Duplicate parameter: '{p}text'."}, + {"enabled null", `"multi_agent":{"enabled":null}`, "{p}multi_agent.enabled", "Invalid type for '{p}multi_agent.enabled': expected a boolean, but got null instead."}, + {"maximum number", `"multi_agent":{"enabled":true,"max_concurrent_subagents":1.5}`, "{p}multi_agent.max_concurrent_subagents", "Invalid type for '{p}multi_agent.max_concurrent_subagents': expected an integer, but got a number instead."}, + {"maximum negative", `"multi_agent":{"enabled":true,"max_concurrent_subagents":-99999999999999999999}`, "{p}multi_agent.max_concurrent_subagents", "Invalid '{p}multi_agent.max_concurrent_subagents': integer below minimum value. Expected a value >= 1."}, + {"instructions type", `"instructions":false`, "{p}instructions", "Invalid type for '{p}instructions': expected a string, but got a boolean instead."}, + // C2 (TV-02) and C3 (TV-03): param null. + {"F08", `"tools":[` + lookupTool("lookup", "") + `,{"type":"function","name":"lookup","description":"Second.","parameters":{"type":"object"}}]`, "", "duplicate function tool name: lookup"}, + {"W05", `"tools":[{"type":"web_search","mode":"disabled"},{"type":"web_search","mode":"disabled"}]`, "", "duplicate web_search tool"}, + {"TS2", `"tools":[{"type":"tool_search"},{"type":"tool_search"}]`, "", "duplicate tool_search tool"}, + {"F01", `"tools":[{"type":"function","name":"lookup","description":"Look up a value.","parameters":{"type":"string"}}]`, "", `Invalid schema for function 'lookup': schema must be a JSON Schema of 'type: "object"', got 'type: "string"'.`}, + {"T04", `"text":{"format":{"type":"json_schema","schema":{"type":"array","items":{"type":"string"}}}}`, "", `agent.text.format.schema must have top-level type "object"; got "array"`}, + {"root after duplicates", `"tools":[{"type":"tool_search"},` + lookupTool("a", "") + `,{"type":"function","name":"b","description":"","parameters":{"type":"null"}}]`, "", `Invalid schema for function 'b': schema must be a JSON Schema of 'type: "object"', got 'type: "null"'.`}, + // Structure is checked before conflicts. + {"structure first", `"tools":[{"type":"tool_search"},{"type":"tool_search"},{"type":"bogus"}]`, "{p}tools[2].type", "Invalid value: 'bogus'. Supported values are: 'function'" + tools}, + } + for _, op := range configurationOperations() { + t.Run(op.name, func(t *testing.T) { + h, s := configurationHandler(t, nil) + for _, tc := range cases { + w := credentialRequest(h, http.MethodPost, op.path, op.body(tc.fields)) + var param *string + if tc.param != "" { + value := strings.ReplaceAll(tc.param, "{p}", op.prefix) + param = &value + } + t.Run(tc.name, func(t *testing.T) { + assertConfigurationError(t, w, "invalid_request_error", param, strings.ReplaceAll(tc.message, "{p}", op.prefix)) + }) + } + if s.writes != 0 { + t.Fatalf("rejected configuration reached storage: %d writes", s.writes) + } + }) + } +} + +func TestSessionAgentProtocolErrors(t *testing.T) { + h, s := configurationHandler(t, nil) + param := func(value string) *string { return &value } + for _, tc := range []struct { + body, param, message string + }{ + {`{"agent":null,"environment":{"type":"none"},"input":"x"}`, "agent", "Invalid type for 'agent': expected an object, but got null instead."}, + {`{"agent":[],"environment":{"type":"none"}}`, "agent", "Invalid type for 'agent': expected an object, but got an array instead."}, + {`{"agent":{"model":"m","name":"x"},"environment":{"type":"none"}}`, "agent.name", "Unknown parameter: 'agent.name'."}, + {`{"agent":{"model":"m","metadata":{}},"environment":{"type":"none"}}`, "agent.metadata", "Unknown parameter: 'agent.metadata'."}, + {`{"agent":{"model":null},"environment":{"type":"none"}}`, "agent.model", "Invalid type for 'agent.model': expected a string, but got null instead."}, + {`{"agent":{"model":4},"environment":{"type":"none"}}`, "agent.model", "Invalid type for 'agent.model': expected a string, but got an integer instead."}, + // The Session body keeps its decoder, which replaces a repeated agent whole. + {`{"agent":{"model":"m"},"agent":{"model":"m","model":"n"},"environment":{"type":"none"}}`, "agent.model", "Duplicate parameter: 'agent.model'."}, + } { + assertConfigurationError(t, credentialRequest(h, http.MethodPost, "/v1/agents/sessions", tc.body), "invalid_request_error", param(tc.param), tc.message) + } + for _, path := range []string{"/v1/agents", "/v1/agents/" + uuid.NewString()} { + assertConfigurationError(t, credentialRequest(h, http.MethodPost, path, `{"model":4}`), "invalid_request_error", param("model"), "Invalid type for 'model': expected a string, but got an integer instead.") + assertConfigurationError(t, credentialRequest(h, http.MethodPost, path, `{"model":"m","model":"n"}`), "invalid_request_error", param("model"), "Duplicate parameter: 'model'.") + } + // Saved Agent creation requires a model; updates and Session overrides do not. + assertConfigurationError(t, credentialRequest(h, http.MethodPost, "/v1/agents", `{"name":"x"}`), "invalid_request_error", param("model"), "Missing required parameter: 'model'.") + if s.writes != 0 { + t.Fatal("rejected configuration reached storage") + } +} + +// K1 and other values the official service accepts stay accepted, and schemas +// without a string root type are unchanged. +func TestAgentConfigurationAcceptedValuesUnchanged(t *testing.T) { + long := strings.Repeat("n", 65) + common := []string{ + `"tools":[` + lookupTool("bad name!", "") + `]`, + `"tools":[` + lookupTool(long, "") + `]`, + `"tools":[` + lookupTool("a", "") + `,` + lookupTool("b", `,"defer_loading":false`) + `]`, + `"tools":[{"type":"function","name":"x","description":"","parameters":{}}]`, + `"tools":[{"type":"function","name":"x","description":"","parameters":{"type":["object","null"]}}]`, + `"tools":[{"type":"function","name":"x","description":"","parameters":{"type":"object","Type":"string"}}]`, + `"tools":[{"type":"web_search","mode":"disabled","context_size":null,"allowed_domains":[],"location":{"city":null}},{"type":"programmatic_tool_calling","enabled":false}]`, + `"tools":null`, `"text":{"format":null,"verbosity":null}`, `"reasoning":null`, `"multi_agent":{"enabled":false,"max_concurrent_subagents":4}`, + `"service_tier":"auto"`, `"instructions":null`, + } + saved := append([]string{ + `"tools":[{"type":"programmatic_tool_calling","enabled":true}]`, + `"reasoning":{"effort":"max","summary":"auto"}`, `"service_tier":"flex"`, + `"text":{"format":{"type":"json_schema","schema":{"type":"object"}}}`, + `"text":{"format":{"type":"json_schema","schema":{"properties":{}}}}`, + `"tools":[{"type":"tool_search"},` + lookupTool("lookup", `,"defer_loading":true`) + `]`, + `"multi_agent":{"enabled":true,"max_concurrent_subagents":4294967295}`, + `"tools":[{"type":"mcp","server_label":"x","transport":{"type":"http","server_url":"https://example.invalid/mcp"},"connection_origin":"service","allowed_tools":null,"credential_id":null,"request_metadata":{},"required":false}]`, + }, common...) + for _, op := range configurationOperations()[:3] { + h, s := configurationHandler(t, nil) + accepted := common + if op.prefix == "" { + accepted = saved + } + for _, fields := range accepted { + before := s.writes + if w := credentialRequest(h, http.MethodPost, op.path, op.body(fields)); w.Code >= 300 || s.writes != before+1 { + t.Fatalf("%s rejected %s: %d %s", op.name, fields, w.Code, w.Body) + } + } + } +} + +// K2 and K3: execution admission and saved web_search limits keep their codes. +func TestAgentConfigurationLocalLimitsKeepCodes(t *testing.T) { + h, s := configurationHandler(t, nil) + session := configurationOperations()[2] + for _, tc := range []struct{ fields, message string }{ + {`"tools":[{"type":"web_search","mode":"live"}]`, "Only disabled web_search is qualified for execution."}, + {`"tools":[{"type":"web_search"}]`, "Only disabled web_search is qualified for execution."}, + {`"tools":[{"type":"programmatic_tool_calling","enabled":true}]`, "Programmatic tool calling is not qualified for execution."}, + {`"tools":[{"type":"programmatic_tool_calling","enabled":false},{"type":"programmatic_tool_calling","enabled":false}]`, "Execution requires distinct tool controls."}, + {`"text":{"format":{"type":"json_schema","schema":{"type":"object"}}}`, "Harness codex does not support the requested Agent/environment configuration: Structured output is not qualified for this engine."}, + {`"reasoning":{"effort":"max"}`, "Explicit reasoning execution options are not supported by this service yet."}, + {`"service_tier":"flex"`, "Execution currently supports service_tier=auto only."}, + {`"tools":[{"type":"function","name":"","description":"","parameters":{}}]`, "Function names must be nonempty, unique and at most 512 bytes."}, + } { + assertConfigurationError(t, credentialRequest(h, http.MethodPost, session.path, session.body(tc.fields)), "unsupported_or_invalid_configuration", nil, tc.message) + } + for _, op := range configurationOperations()[:2] { + for _, fields := range []string{`"tools":[{"type":"web_search","mode":"live"}]`, `"tools":[{"type":"web_search"}]`, `"tools":[{"type":"web_search","mode":"cached"}]`, `"tools":[{"type":"web_search","mode":null}]`} { + assertConfigurationError(t, credentialRequest(h, http.MethodPost, op.path, op.body(fields)), "unsupported_or_invalid_configuration", nil, "Only disabled web_search is qualified for execution.") + } + assertConfigurationError(t, credentialRequest(h, http.MethodPost, op.path, op.body(`"multi_agent":{"enabled":true,"max_concurrent_subagents":4294967296}`)), "unsupported_or_invalid_configuration", nil, "max_concurrent_subagents must be an integer from 1 to 4294967295.") + } + if s.writes != 0 { + t.Fatal("rejected configuration reached storage") + } +} + +// C4 and SES-33: only configuration errors precede the input requirement; +// accepted configurations and saved-Agent lookup keep their order. +func TestSessionConfigurationValidationOrder(t *testing.T) { + legacy := uuid.NewString() + h, s := configurationHandler(t, map[string]string{legacy: `{"model":"m","tools":[{"type":"web_search","mode":"disabled"}]}`}) + const input = "conversation-only sessions currently require initial input" + for _, body := range []string{ + `{"agent":{"model":"m","tools":[{"type":"web_search","mode":"live"}]},"environment":{"type":"none"}}`, + `{"agent":{"model":"m","tools":[{"type":"programmatic_tool_calling","enabled":true}]},"environment":{"type":"none"}}`, + `{"agent":{"model":"m","reasoning":{"effort":"max"}},"environment":{"type":"none"}}`, + `{"agent":{"instructions":"x"},"environment":{"type":"none"}}`, + `{"agent_id":"` + uuid.NewString() + `","environment":{"type":"none"}}`, + `{"agent_id":"` + legacy + `","agent":{"tools":[{"type":"tool_search"}]},"environment":{"type":"none"}}`, + } { + assertConfigurationError(t, credentialRequest(h, http.MethodPost, "/v1/agents/sessions", body), "invalid_request_error", nil, input) + } + missing := uuid.NewString() + if w := credentialRequest(h, http.MethodPost, "/v1/agents/sessions", `{"agent_id":"`+missing+`","agent":{"tools":[]},"environment":{"type":"none"},"input":"x"}`); w.Code != http.StatusNotFound { + t.Fatal(w.Code, w.Body) + } + // An invalid inline override is reported before the saved-Agent lookup. + for _, id := range []string{missing, legacy} { + for _, suffix := range []string{``, `,"input":"x"`} { + w := credentialRequest(h, http.MethodPost, "/v1/agents/sessions", `{"agent_id":"`+id+`","agent":{"tools":[{"type":"tool_search"},{"type":"tool_search"}]},"environment":{"type":"none"}`+suffix+`}`) + assertConfigurationError(t, w, "invalid_request_error", nil, "duplicate tool_search tool") + } + } + if s.writes != 0 { + t.Fatal("rejected configuration reached storage") + } +} + +// Saved records that predate the conflict checks cannot execute. +func TestSessionAdmissionRejectsSavedConfigurationConflicts(t *testing.T) { + saved := func(fields string) string { + return `{"model":"m","multi_agent":{"enabled":false,"max_concurrent_subagents":null},"reasoning":{},"service_tier":"auto","text":{"format":{"type":"text"},"verbosity":"medium"},` + fields + `}` + } + records := map[string]struct{ configuration, message string }{ + uuid.NewString(): {saved(`"tools":[{"type":"function","name":"lookup","description":"","parameters":{"type":"string"},"defer_loading":false}]`), `Invalid schema for function 'lookup': schema must be a JSON Schema of 'type: "object"', got 'type: "string"'.`}, + uuid.NewString(): {saved(`"tools":[{"type":"function","name":"f","description":"","parameters":{},"defer_loading":false},{"type":"function","name":"f","description":"","parameters":{},"defer_loading":false}]`), "duplicate function tool name: f"}, + uuid.NewString(): {saved(`"tools":[{"type":"tool_search"},{"type":"tool_search"}]`), "duplicate tool_search tool"}, + uuid.NewString(): {saved(`"tools":[],"text":{"format":{"type":"json_schema","schema":{"type":"array"}},"verbosity":"medium"}`), `agent.text.format.schema must have top-level type "object"; got "array"`}, + } + agents := map[string]string{} + for id, record := range records { + agents[id] = record.configuration + } + valid := uuid.NewString() + agents[valid] = saved(`"tools":[{"type":"function","name":"f","description":"","parameters":{"type":"object"},"defer_loading":false}]`) + h, s := configurationHandler(t, agents) + for id, record := range records { + w := credentialRequest(h, http.MethodPost, "/v1/agents/sessions", `{"agent_id":"`+id+`","environment":{"type":"none"},"input":"x"}`) + assertConfigurationError(t, w, "invalid_request_error", nil, record.message) + } + if s.writes != 0 { + t.Fatal("rejected configuration reached storage") + } + // A replacement of the conflicting field admits the saved record. + for id, record := range records { + field := `"tools":[]` + if strings.Contains(record.configuration, "json_schema") { + field = `"text":{"format":{"type":"text"}}` + } + if w := credentialRequest(h, http.MethodPost, "/v1/agents/sessions", `{"agent_id":"`+id+`","agent":{`+field+`},"environment":{"type":"none"},"input":"x"}`); w.Code != http.StatusCreated { + t.Fatal(id, w.Code, w.Body) + } + } + if w := credentialRequest(h, http.MethodPost, "/v1/agents/sessions", `{"agent_id":"`+valid+`","environment":{"type":"none"},"input":"x"}`); w.Code != http.StatusCreated { + t.Fatal(w.Code, w.Body) + } +} + +// Caller-supplied names and values are repeated only when short and printable +// (the Environment Files rule), so every response stays small. +func TestAgentConfigurationErrorsBoundEchoedInput(t *testing.T) { + long := strings.Repeat("<", 257) + param := func(value string) *string { return &value } + for _, op := range configurationOperations() { + h, s := configurationHandler(t, nil) + p := op.prefix + for _, tc := range []struct { + fields string + param *string + message string + }{ + {`"` + long + `":1`, nil, "Unknown parameter."}, + {`"tools":[{"type":"tool_search","tab\tkey":1}]`, nil, "Unknown parameter."}, + {`"tools":[{"type":"tool_search","\ufffd":1}]`, nil, "Unknown parameter."}, + {`"tools":[{"type":"tool_search","` + strings.Repeat("<>", 64) + `":1}]`, param(p + "tools[0]." + strings.Repeat("<>", 64)), "Unknown parameter: '" + p + "tools[0]." + strings.Repeat("<>", 64) + "'."}, + {`"reasoning":{"` + strings.Repeat("a", 4<<10) + `":1}`, nil, "Unknown parameter."}, + {`"service_tier":"` + long + `"`, param(p + "service_tier"), "Invalid value. Supported values are: 'auto', 'default', 'flex', 'priority', and 'fast'."}, + {`"tools":[{"type":"line\u2028break"}]`, param(p + "tools[0].type"), "Invalid value. Supported values are: 'function', 'tool_search', 'programmatic_tool_calling', 'mcp', and 'web_search'."}, + {`"tools":[` + lookupTool(long, "") + `,` + lookupTool(long, "") + `]`, nil, "duplicate function tool name"}, + {`"tools":[{"type":"function","name":"` + long + `","description":"","parameters":{"type":"string"}}]`, nil, `Invalid schema for function: schema must be a JSON Schema of 'type: "object"', got 'type: "string"'.`}, + {`"tools":[{"type":"function","name":"lookup","description":"","parameters":{"type":"` + long + `"}}]`, nil, `Invalid schema for function 'lookup': schema must be a JSON Schema of 'type: "object"'.`}, + {`"text":{"format":{"type":"json_schema","schema":{"type":"` + long + `"}}}`, nil, `agent.text.format.schema must have top-level type "object"`}, + } { + w := credentialRequest(h, http.MethodPost, op.path, op.body(tc.fields)) + if w.Body.Len() > 4096 { + t.Fatalf("%s: unbounded response of %d bytes", op.name, w.Body.Len()) + } + assertConfigurationError(t, w, "invalid_request_error", tc.param, tc.message) + } + if s.writes != 0 { + t.Fatal("rejected configuration reached storage") + } + } +} diff --git a/services/agents-api/internal/api/handler.go b/services/agents-api/internal/api/handler.go index f5b2de16f..02b6367da 100644 --- a/services/agents-api/internal/api/handler.go +++ b/services/agents-api/internal/api/handler.go @@ -145,7 +145,7 @@ func NewHandler(s ResourceStore, auth *Authenticator, engine string, options ... // createSession atomically reserves or admits initial text with the Session. // @Summary Create an execution Session -// @Description Supports inline configuration or a tenant-owned saved agent_id with per-Session field replacements. Execution supports model/instructions, text verbosity, non-deferred function tools, adapter-qualified multi_agent with persisted Subagent reads, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with explicit service origin, native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Ambiguous selection rejects creation. Frozen private selections never populate an omitted public credential_id; missing decryption configuration fails dispatch without anonymous fallback. Required initialization uses native startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and OAuth remain unsupported. The self_hosted profile requires Codex, an absolute workspace_directory and empty capability_directories, with optional non-deferred function tools and HTTP MCP using explicit service origin, optionally authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication each require separately advertised combination support; old peers cannot receive unsupported work. Omitted/null capability_directories use the empty-list default; self_hosted requires configured execution plus executor registry. Claude SDK currently requires medium verbosity and object-root function schemas. It supports anonymous or attached static-bearer service-origin HTTP MCP on none with boolean required and separately advertised MCP/bearer/required runtime support. Required servers must be connected before the first native input is released; pending or failed startup rejects execution. The shared Vault selection and immutable binding rules apply; unsupported native labels/tool names reject before persistence. An attached Vault with no matching credential may remain anonymous; missing keys or failed credential lookup/decryption never fall back to anonymous execution. Omitted stream defaults to false; stream and agent_id cannot be null. Metadata may be null; non-string values and limit violations return invalid_request_error with a metadata or metadata. param. Hosted network policy rejections return invalid_request_error with a null param. Initial input accepts a string or ordered user-message array. Codex and Claude SDK on none and qualified openai_hosted also accept inline PNG/JPEG image content; other image combinations and remote URLs are unsupported. None initial input atomically starts a Turn; self_hosted initial input is reserved while returning its Environment connection target, with execution deferred to native readiness and Session failure on initial timeout. Initial input is required for none and for streamed creation outside self_hosted. Omitted/null input remains valid for non-streaming hosted and self_hosted creation. With stream=true, returns live Session events starting with the committed creation snapshot and closes right after the first agent.session.idle recorded when a Turn ends or an input reservation stops being pending, or any agent.session.failed, without sending later events. A creation that admitted nothing closes after the snapshot; a settlement that records no event closes after events up to the cursor read with a settled Session projection. Required actions keep it open; disconnect does not cancel execution. The GET events stream remains live-only. New Sessions retain their authenticated creator; all creation retries require the same typed subject, including across key rotation. Saved-Agent retries and inline requests using Vault attachments or credential references retain caller intent independently of later resource changes; unrelated inline retries preserve resolved/default equivalences. Unknown historical creators reject retries; known creators without recorded intent retain resolved-snapshot retry rules. These conflict policies are local and not verified hosted parity. A same-key stream=true retry of an existing creation returns 201 with no events and closes at once; retry with stream=false or use the GET events stream to recover. Claude SDK on none and Core-managed Docker openai_hosted supports qualified object-root json_schema output with medium verbosity, single-Agent execution and ordinary functions. Hosted execution reuses native workspace tools and Files/Artifacts; Skills, Plugins, capability directories, HTTP MCP, Subagent and tool_search combinations remain unqualified, including inherited template contents. Other non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile supports non-deferred function tools with text or successful inline PNG/JPEG results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled and restricted exact ASCII hostnames are supported. Restricted policy requires 1–100 allowed domains. Unsupported hostname forms and startup installations are rejected. Confidential env, system/npm/Python packages and ordered setup commands use the shared initialization lifecycle; requested network applies after setup. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the common Core lifecycle before native execution or live Files access. With a template reference, omitted/null files, env, packages and setup_commands inherit. Non-null files and command lists replace; env overlays by key; each package manager inherits on omission/null and otherwise replaces its list. Empty lists clear their selected field. Tenant-owned environment_template_id references inherit omitted/null network and allow only narrowing overrides. Inline hosted network:null retains the enabled default; updating a Template with network:null resets its saved policy to enabled. Core freezes effective configuration; template updates/deletion do not alter Session snapshots or same-intent creation retries. Inline or tenant-owned skill_reference Skills share initialization. Templates preserve default/latest/explicit selectors; Session creation freezes concrete metadata and encrypted content atomically. Skill, Plugin and capability-directory list omission/null inherit; a non-null list replaces, including empty-list clearing. Omitted/null Skill version selectors resolve the default version. Source deletion/default updates cannot change committed Session Skill contents. Deferred function discovery uses type-only tool_search and per-function defer_loading in the qualified single-agent Claude environment:none function profile, including qualified inline image messages and text results. Explicit web_search mode disabled and programmatic_tool_calling enabled false use frozen common Runtime controls. Enabled forms remain unqualified. Omitted programmatic configuration preserves native behavior, a documented difference from the official default-on behavior. Other combinations remain unqualified; see the operation coverage. +// @Description Supports inline configuration or a tenant-owned saved agent_id with per-Session field replacements. Execution supports model/instructions, text verbosity, non-deferred function tools, adapter-qualified multi_agent with persisted Subagent reads, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with explicit service origin, native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Ambiguous selection rejects creation. Frozen private selections never populate an omitted public credential_id; missing decryption configuration fails dispatch without anonymous fallback. Required initialization uses native startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and OAuth remain unsupported. The self_hosted profile requires Codex, an absolute workspace_directory and empty capability_directories, with optional non-deferred function tools and HTTP MCP using explicit service origin, optionally authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication each require separately advertised combination support; old peers cannot receive unsupported work. Omitted/null capability_directories use the empty-list default; self_hosted requires configured execution plus executor registry. Claude SDK currently requires medium verbosity and object-root function schemas. It supports anonymous or attached static-bearer service-origin HTTP MCP on none with boolean required and separately advertised MCP/bearer/required runtime support. Required servers must be connected before the first native input is released; pending or failed startup rejects execution. The shared Vault selection and immutable binding rules apply; unsupported native labels/tool names reject before persistence. An attached Vault with no matching credential may remain anonymous; missing keys or failed credential lookup/decryption never fall back to anonymous execution. Omitted stream defaults to false; stream and agent_id cannot be null. Metadata may be null; non-string values and limit violations return invalid_request_error with a metadata or metadata. param. The inline agent uses the Agent create configuration validation with agent.-prefixed params, reported before the input requirement and saved-Agent lookup; saved configurations with conflicting tools or schema roots reject admission with the same errors, and execution limits keep unsupported_or_invalid_configuration. Hosted network policy rejections return invalid_request_error with a null param. Initial input accepts a string or ordered user-message array. Codex and Claude SDK on none and qualified openai_hosted also accept inline PNG/JPEG image content; other image combinations and remote URLs are unsupported. None initial input atomically starts a Turn; self_hosted initial input is reserved while returning its Environment connection target, with execution deferred to native readiness and Session failure on initial timeout. Initial input is required for none and for streamed creation outside self_hosted. Omitted/null input remains valid for non-streaming hosted and self_hosted creation. With stream=true, returns live Session events starting with the committed creation snapshot and closes right after the first agent.session.idle recorded when a Turn ends or an input reservation stops being pending, or any agent.session.failed, without sending later events. A creation that admitted nothing closes after the snapshot; a settlement that records no event closes after events up to the cursor read with a settled Session projection. Required actions keep it open; disconnect does not cancel execution. The GET events stream remains live-only. New Sessions retain their authenticated creator; all creation retries require the same typed subject, including across key rotation. Saved-Agent retries and inline requests using Vault attachments or credential references retain caller intent independently of later resource changes; unrelated inline retries preserve resolved/default equivalences. Unknown historical creators reject retries; known creators without recorded intent retain resolved-snapshot retry rules. These conflict policies are local and not verified hosted parity. A same-key stream=true retry of an existing creation returns 201 with no events and closes at once; retry with stream=false or use the GET events stream to recover. Claude SDK on none and Core-managed Docker openai_hosted supports qualified object-root json_schema output with medium verbosity, single-Agent execution and ordinary functions. Hosted execution reuses native workspace tools and Files/Artifacts; Skills, Plugins, capability directories, HTTP MCP, Subagent and tool_search combinations remain unqualified, including inherited template contents. Other non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile supports non-deferred function tools with text or successful inline PNG/JPEG results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled and restricted exact ASCII hostnames are supported. Restricted policy requires 1–100 allowed domains. Unsupported hostname forms and startup installations are rejected. Confidential env, system/npm/Python packages and ordered setup commands use the shared initialization lifecycle; requested network applies after setup. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the common Core lifecycle before native execution or live Files access. With a template reference, omitted/null files, env, packages and setup_commands inherit. Non-null files and command lists replace; env overlays by key; each package manager inherits on omission/null and otherwise replaces its list. Empty lists clear their selected field. Tenant-owned environment_template_id references inherit omitted/null network and allow only narrowing overrides. Inline hosted network:null retains the enabled default; updating a Template with network:null resets its saved policy to enabled. Core freezes effective configuration; template updates/deletion do not alter Session snapshots or same-intent creation retries. Inline or tenant-owned skill_reference Skills share initialization. Templates preserve default/latest/explicit selectors; Session creation freezes concrete metadata and encrypted content atomically. Skill, Plugin and capability-directory list omission/null inherit; a non-null list replaces, including empty-list clearing. Omitted/null Skill version selectors resolve the default version. Source deletion/default updates cannot change committed Session Skill contents. Deferred function discovery uses type-only tool_search and per-function defer_loading in the qualified single-agent Claude environment:none function profile, including qualified inline image messages and text results. Explicit web_search mode disabled and programmatic_tool_calling enabled false use frozen common Runtime controls. Enabled forms remain unqualified. Omitted programmatic configuration preserves native behavior, a documented difference from the official default-on behavior. Other combinations remain unqualified; see the operation coverage. // @Tags Sessions // @Accept json // @Produce json,text/event-stream diff --git a/services/agents-api/internal/api/session_agent.go b/services/agents-api/internal/api/session_agent.go index 0f7a9ffae..08e15faca 100644 --- a/services/agents-api/internal/api/session_agent.go +++ b/services/agents-api/internal/api/session_agent.go @@ -61,6 +61,10 @@ func resolveSessionAgent(input sessionRequest, saved *v1.SavedAgent) (v1.Agent, } func admitSessionAgent(cfg v1.SavedAgentConfiguration) (v1.Agent, error) { + // Protocol conflicts precede execution limits, including in saved records. + if err := configurationConflict(cfg.Tools, cfg.Text.Format.Type, cfg.Text.Format.Schema); err != nil { + return v1.Agent{}, err + } if strings.TrimSpace(cfg.Model) == "" { return v1.Agent{}, errors.New("Execution currently requires a nonempty model.") } diff --git a/services/agents-api/internal/api/session_request.go b/services/agents-api/internal/api/session_request.go index 50ad12750..33fea6d29 100644 --- a/services/agents-api/internal/api/session_request.go +++ b/services/agents-api/internal/api/session_request.go @@ -64,6 +64,10 @@ func (request decodedSessionRequest) validated() (sessionRequest, error) { return input, err } if len(request.Agent) > 0 { + // Protocol errors in the inline agent precede the input requirement. + if err := validateSessionAgent(request.Agent); err != nil { + return input, err + } if decodeInputObject(request.Agent, &input.Agent, "model", "instructions", "multi_agent", "reasoning", "service_tier", "text", "tools", "x_agents_core") != nil { return input, store.ErrInvalidInput } diff --git a/services/agents-api/internal/store/configuration_validation_public_test.go b/services/agents-api/internal/store/configuration_validation_public_test.go new file mode 100644 index 000000000..9205d741a --- /dev/null +++ b/services/agents-api/internal/store/configuration_validation_public_test.go @@ -0,0 +1,173 @@ +package store_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +// Agent configuration protocol errors (TV-01..04) and repeated members reject +// before any write on every configuration path, with responses independent of +// resource ownership. +func TestAgentConfigurationValidationRejectsWithoutWritesPostgres(t *testing.T) { + // An isolated database keeps the no-write digest independent of other tests. + _, pool := store.NewManagedTestStore(t) + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{63}, 32)) + if err != nil { + t.Fatal(err) + } + s := store.NewWithCredentialCipher(pool, cipher) + owner, foreign, ownerTenant := uuid.NewString(), uuid.NewString(), uuid.NewString() + auth, err := api.NewAuthenticator([]api.APIKey{ + {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "config-owner", TokenSHA256: device.HashCredential(owner), TenantID: ownerTenant}, + {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "config-foreign", TokenSHA256: device.HashCredential(foreign), TenantID: uuid.NewString()}, + }) + if err != nil { + t.Fatal(err) + } + h, err := api.NewHandler(s, auth, "codex", api.WithExecution(s)) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(h) + defer server.Close() + client := pathIDClient{t: t, server: server} + const lookup = `{"type":"function","name":"lookup","description":"Look up a value.","parameters":{"type":"object","properties":{"q":{"type":"string"}},"required":["q"]}}` + agent := client.created(owner, "/v1/agents", `{"model":"config-model","tools":[`+lookup+`]}`) + // A record saved before the protocol checks existed. + saved := func(tools string) string { + return `{"model":"config-model","name":null,"instructions":null,"multi_agent":{"enabled":false,"max_concurrent_subagents":null},"reasoning":{},"service_tier":"auto","text":{"format":{"type":"text"},"verbosity":"medium"},"tools":` + tools + `}` + } + legacy, err := s.CreateAgent(t.Context(), ownerTenant, store.CreateAgentInput{Metadata: map[string]string{}, Configuration: json.RawMessage(saved(`[{"type":"function","name":"lookup","description":"","parameters":{"type":"string"},"defer_loading":false}]`))}) + if err != nil { + t.Fatal(err) + } + before := databaseDigest(t, pool) + + const tools = `'function', 'tool_search', 'programmatic_tool_calling', 'mcp', and 'web_search'.` + cases := []struct{ name, fields, param, message string }{ + {"F01", `"tools":[{"type":"function","name":"lookup","description":"Look up a value.","parameters":{"type":"string"}}]`, "", `Invalid schema for function 'lookup': schema must be a JSON Schema of 'type: "object"', got 'type: "string"'.`}, + {"F02", `"tools":[{"type":"function","name":"lookup","description":"Look up a value.","parameters":[]}]`, "{p}tools[0].parameters", "Invalid type for '{p}tools[0].parameters': expected an object with string keys and unknown value values, but got an array instead."}, + {"F03", `"tools":[{"type":"function","name":"lookup","description":"Look up a value."}]`, "{p}tools[0].parameters", "Missing required parameter: '{p}tools[0].parameters'."}, + {"F04", `"tools":[{"type":"function","name":"lookup","description":"Look up a value.","parameters":{"type":"object"},"strict":true}]`, "{p}tools[0].strict", "Unknown parameter: '{p}tools[0].strict'."}, + {"F05", `"tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}]`, "{p}tools[0].description", "Missing required parameter: '{p}tools[0].description'."}, + {"F08", `"tools":[` + lookup + `,{"type":"function","name":"lookup","description":"Second.","parameters":{"type":"object"}}]`, "", "duplicate function tool name: lookup"}, + {"F09", `"tools":["lookup"]`, "{p}tools[0]", "Invalid type for '{p}tools[0]': expected an object, but got a string instead."}, + {"F10", `"tools":[{"type":"function","name":"lookup","description":"","parameters":{},"defer_loading":"yes"}]`, "{p}tools[0].defer_loading", "Invalid type for '{p}tools[0].defer_loading': expected a boolean, but got a string instead."}, + {"U01", `"tools":[{"type":"code_interpreter"}]`, "{p}tools[0].type", "Invalid value: 'code_interpreter'. Supported values are: " + tools}, + {"U02", `"tools":[{"type":"bogus_tool"}]`, "{p}tools[0].type", "Invalid value: 'bogus_tool'. Supported values are: " + tools}, + {"W03", `"tools":[{"type":"web_search","mode":"bogus"}]`, "{p}tools[0].mode", "Invalid value: 'bogus'. Supported values are: 'disabled', 'cached', and 'live'."}, + {"W04", `"tools":[{"type":"web_search","mode":"disabled","context_size":"huge"}]`, "{p}tools[0].context_size", "Invalid value: 'huge'. Supported values are: 'low', 'medium', and 'high'."}, + {"W05", `"tools":[{"type":"web_search","mode":"disabled"},{"type":"web_search","mode":"disabled"}]`, "", "duplicate web_search tool"}, + {"P02", `"tools":[{"type":"programmatic_tool_calling","enabled":"yes"}]`, "{p}tools[0].enabled", "Invalid type for '{p}tools[0].enabled': expected a boolean, but got a string instead."}, + {"TS1", `"tools":[{"type":"tool_search","max_results":3}]`, "{p}tools[0].max_results", "Unknown parameter: '{p}tools[0].max_results'."}, + {"TS2", `"tools":[{"type":"tool_search"},{"type":"tool_search"}]`, "", "duplicate tool_search tool"}, + {"T01", `"text":{"format":{"type":"json_schema"}}`, "{p}text.format.schema", "Missing required parameter: '{p}text.format.schema'."}, + {"T02", `"text":{"format":{"type":"json_schema","name":"out","strict":true,"schema":{"type":"object","properties":{}}}}`, "{p}text.format.name", "Unknown parameter: '{p}text.format.name'."}, + {"T03", `"text":{"format":{"type":"json_object"}}`, "{p}text.format.type", "Invalid value: 'json_object'. Supported values are: 'text' and 'json_schema'."}, + {"T04", `"text":{"format":{"type":"json_schema","schema":{"type":"array","items":{"type":"string"}}}}`, "", `agent.text.format.schema must have top-level type "object"; got "array"`}, + {"R01", `"reasoning":{"effort":"extreme"}`, "{p}reasoning.effort", "Invalid value: 'extreme'. Supported values are: 'none', 'minimal', 'low', 'medium', 'high', 'xhigh', and 'max'."}, + {"R02", `"reasoning":{"summary":"verbose"}`, "{p}reasoning.summary", "Invalid value: 'verbose'. Supported values are: 'concise', 'detailed', and 'auto'."}, + {"S01", `"service_tier":"turbo"`, "{p}service_tier", "Invalid value: 'turbo'. Supported values are: 'auto', 'default', 'flex', 'priority', and 'fast'."}, + {"X01", `"tool_choice":"auto"`, "{p}tool_choice", "Unknown parameter: '{p}tool_choice'."}, + {"M01", `"multi_agent":{}`, "{p}multi_agent.enabled", "Missing required parameter: '{p}multi_agent.enabled'."}, + {"M02", `"multi_agent":{"enabled":true,"max_concurrent_subagents":0}`, "{p}multi_agent.max_concurrent_subagents", "Invalid '{p}multi_agent.max_concurrent_subagents': integer below minimum value. Expected a value >= 1, but got 0 instead."}, + // Repeated members would merge when decoded, and names match case-insensitively (local message). + {"merged text", `"text":{"format":{"type":"json_schema","schema":{"type":"array"}}},"text":{"verbosity":"low"}`, "{p}text", "Duplicate parameter: '{p}text'."}, + {"merged reasoning", `"reasoning":{"Effort":"high"},"reasoning":{}`, "{p}reasoning", "Duplicate parameter: '{p}reasoning'."}, + {"merged location", `"tools":[{"type":"web_search","mode":"disabled","location":{"city":"Paris"},"location":{"country":null}}]`, "{p}tools[0].location", "Duplicate parameter: '{p}tools[0].location'."}, + {"case variant", `"reasoning":{"effort":"high","EFFORT":"max"}`, "{p}reasoning.EFFORT", "Unknown parameter: '{p}reasoning.EFFORT'."}, + } + expect := func(tc struct{ name, fields, param, message string }, prefix string) string { + param := "null" + if tc.param != "" { + encoded, _ := json.Marshal(strings.ReplaceAll(tc.param, "{p}", prefix)) + param = string(encoded) + } + message, _ := json.Marshal(strings.ReplaceAll(tc.message, "{p}", prefix)) + return `{"error":{"message":` + string(message) + `,"type":"invalid_request_error","code":"invalid_request_error","param":` + param + `}}` + "\n" + } + for _, tc := range cases { + saved, session := expect(tc, ""), expect(tc, "agent.") + for _, request := range []struct{ token, path, body, want string }{ + {owner, "/v1/agents", `{"model":"m",` + tc.fields + `}`, saved}, + {foreign, "/v1/agents", `{"model":"m",` + tc.fields + `}`, saved}, + // Validation precedes the Agent lookup: owned, foreign, missing and malformed IDs agree. + {owner, "/v1/agents/" + agent, `{` + tc.fields + `}`, saved}, + {foreign, "/v1/agents/" + agent, `{` + tc.fields + `}`, saved}, + {owner, "/v1/agents/" + uuid.NewString(), `{` + tc.fields + `}`, saved}, + {owner, "/v1/agents/not-an-agent", `{` + tc.fields + `}`, saved}, + {owner, "/v1/agents/sessions", `{"agent":{"model":"m",` + tc.fields + `},"environment":{"type":"none"},"input":"hi"}`, session}, + // C4: the inline configuration error precedes the input requirement. + {owner, "/v1/agents/sessions", `{"agent":{"model":"m",` + tc.fields + `},"environment":{"type":"none"}}`, session}, + // An inline override is validated before the saved-Agent lookup. + {owner, "/v1/agents/sessions", `{"agent_id":"` + agent + `","agent":{` + tc.fields + `},"environment":{"type":"none"},"input":"hi"}`, session}, + {foreign, "/v1/agents/sessions", `{"agent_id":"` + agent + `","agent":{` + tc.fields + `},"environment":{"type":"none"},"input":"hi"}`, session}, + } { + if status, body := client.do(request.token, http.MethodPost, request.path, "application/json", []byte(request.body)); status != http.StatusBadRequest || body != request.want { + t.Errorf("%s %s: %d %s", tc.name, request.path, status, body) + } + } + } + // A saved record from before the checks cannot execute; another tenant cannot see it. + schema := `{"error":{"message":"Invalid schema for function 'lookup': schema must be a JSON Schema of 'type: \"object\"', got 'type: \"string\"'.","type":"invalid_request_error","code":"invalid_request_error","param":null}}` + "\n" + if status, body := client.do(owner, http.MethodPost, "/v1/agents/sessions", "application/json", []byte(`{"agent_id":"`+legacy.ID+`","environment":{"type":"none"},"input":"hi"}`)); status != http.StatusBadRequest || body != schema { + t.Errorf("legacy saved Agent: %d %s", status, body) + } + if status, body := client.do(foreign, http.MethodPost, "/v1/agents/sessions", "application/json", []byte(`{"agent_id":"`+legacy.ID+`","environment":{"type":"none"},"input":"hi"}`)); status != http.StatusNotFound { + t.Errorf("foreign legacy saved Agent: %d %s", status, body) + } + // K2 and K3 keep the local configuration code. + for _, request := range []struct{ path, body, message string }{ + {"/v1/agents", `{"model":"m","tools":[{"type":"web_search","mode":"live"}]}`, "Only disabled web_search is qualified for execution."}, + {"/v1/agents/" + agent, `{"tools":[{"type":"web_search"}]}`, "Only disabled web_search is qualified for execution."}, + {"/v1/agents/sessions", `{"agent":{"model":"m","tools":[{"type":"programmatic_tool_calling","enabled":true}]},"environment":{"type":"none"},"input":"hi"}`, "Programmatic tool calling is not qualified for execution."}, + {"/v1/agents/sessions", `{"agent":{"model":"m","text":{"format":{"type":"json_schema","schema":{"type":"object"}}}},"environment":{"type":"none"},"input":"hi"}`, "Harness codex does not support the requested Agent/environment configuration: Structured output is not qualified for this engine."}, + } { + encoded, _ := json.Marshal(request.message) + want := `{"error":{"message":` + string(encoded) + `,"type":"invalid_request_error","code":"unsupported_or_invalid_configuration","param":null}}` + "\n" + if status, body := client.do(owner, http.MethodPost, request.path, "application/json", []byte(request.body)); status != http.StatusBadRequest || body != want { + t.Errorf("%s: %d %s", request.path, status, body) + } + } + if after := databaseDigest(t, pool); !mapsEqual(before, after) { + t.Fatal("rejected configuration changed persisted state") + } + + // K1: values the official service accepts are saved and echoed unchanged. + long := strings.Repeat("n", 65) + update := `{"tools":[{"type":"function","name":"bad name!","description":"","parameters":{"type":"object"}},{"type":"function","name":"` + long + `","description":"","parameters":{}},{"type":"programmatic_tool_calling","enabled":true}],"reasoning":{"effort":"max"},"service_tier":"flex"}` + status, body := client.do(owner, http.MethodPost, "/v1/agents/"+agent, "application/json", []byte(update)) + var updated struct { + Tools []map[string]any + Reasoning struct{ Effort string } + ServiceTier string `json:"service_tier"` + } + if status != http.StatusOK || json.Unmarshal([]byte(body), &updated) != nil || len(updated.Tools) != 3 || updated.Tools[0]["name"] != "bad name!" || updated.Tools[1]["name"] != long || + updated.Tools[2]["enabled"] != true || updated.Reasoning.Effort != "max" || updated.ServiceTier != "flex" { + t.Fatalf("K1 update: %d %s", status, body) + } + if status, retrieved := client.do(owner, http.MethodGet, "/v1/agents/"+agent, "", nil); status != http.StatusOK || retrieved != body { + t.Fatalf("K1 retrieve: %d %s", status, retrieved) + } + // Another tenant can neither read nor change the Agent with a valid body. + for _, method := range []string{http.MethodGet, http.MethodPost} { + if status, _ := client.do(foreign, method, "/v1/agents/"+agent, "application/json", []byte(`{"service_tier":"auto"}`)); status != http.StatusNotFound { + t.Fatalf("foreign %s: %d", method, status) + } + } + if status, retrieved := client.do(owner, http.MethodGet, "/v1/agents/"+agent, "", nil); status != http.StatusOK || retrieved != body { + t.Fatalf("foreign update changed the Agent: %d %s", status, retrieved) + } + // An inline Session with K1 function names executes on the none profile. + client.created(owner, "/v1/agents/sessions", `{"agent":{"model":"m","tools":[{"type":"function","name":"bad name!","description":"","parameters":{"type":"object"}},{"type":"function","name":"`+long+`","description":"","parameters":{}}]},"environment":{"type":"none"},"input":"hi"}`) +} diff --git a/services/agents-api/tests/official_agent_update.py b/services/agents-api/tests/official_agent_update.py index e1958b5df..5da7fead3 100644 --- a/services/agents-api/tests/official_agent_update.py +++ b/services/agents-api/tests/official_agent_update.py @@ -54,6 +54,23 @@ def main(): json=body if body is not None else None) assert response.status_code == 400, (body, response.status_code, response.text) assert agents.retrieve(original.id) == updated + # Configuration protocol errors (TV-01..03) use the official fields and precede + # the Agent lookup, so owned, foreign and missing Agents get the same response. + for body, param, message in [ + ({"reasoning": {"effort": "extreme"}}, "reasoning.effort", "Invalid value: 'extreme'. Supported values are: 'none', 'minimal', 'low', 'medium', 'high', 'xhigh', and 'max'."), + ({"service_tier": "turbo"}, "service_tier", "Invalid value: 'turbo'. Supported values are: 'auto', 'default', 'flex', 'priority', and 'fast'."), + ({"text": {"format": {"type": "json_schema"}}}, "text.format.schema", "Missing required parameter: 'text.format.schema'."), + ({"tools": [{**tool, "strict": True}]}, "tools[0].strict", "Unknown parameter: 'tools[0].strict'."), + ({"tools": ["lookup"]}, "tools[0]", "Invalid type for 'tools[0]': expected an object, but got a string instead."), + ({"tools": [{"type": "web_search", "mode": "disabled"}] * 2}, None, "duplicate web_search tool"), + ({"text": {"format": {"type": "json_schema", "schema": {"type": "array"}}}}, None, 'agent.text.format.schema must have top-level type "object"; got "array"'), + ]: + expected = {"type": "invalid_request_error", "code": "invalid_request_error", "param": param, "message": message} + for target, auth in ((original.id, headers), (original.id, headers | {"Authorization": "Bearer " + foreign}), + (str(uuid.uuid4()), headers)): + response = http.post(base + "/v1/agents/" + target, headers=auth, json=body) + assert response.status_code == 400 and response.json()["error"] == expected, (body, response.text) + assert agents.retrieve(original.id) == updated for target in (original.id, str(uuid.uuid4()), "invalid", str(uuid.UUID(int=0))): response = http.post(base + "/v1/agents/" + target, headers=headers | {"Authorization": "Bearer " + foreign}, json={"name": "foreign"}) diff --git a/services/agents-api/tests/official_agents.py b/services/agents-api/tests/official_agents.py index 308918405..388d8ac92 100644 --- a/services/agents-api/tests/official_agents.py +++ b/services/agents-api/tests/official_agents.py @@ -50,7 +50,10 @@ def verify_agents(client, other, invalid, expect_error): assert raw.get(base + "/" + agent.id, headers=headers).json() == body assert agents.retrieve(agent.id) == agent saved.append(agent) + # Function names are not restricted, as officially (TV-07). for fields in [ + {"tools": [{"type": "function", "name": name, "description": "", "parameters": {"type": "object"}} + for name in ("bad name!", "n" * 65)]}, {"model": "", "name": "🧪" * 128, "instructions": "", "metadata": {}}, {"multi_agent": {"enabled": True, "max_concurrent_subagents": 4294967295}}, {"multi_agent": {"enabled": False, "max_concurrent_subagents": 4}}, @@ -101,6 +104,7 @@ def verify_agents(client, other, invalid, expect_error): assert response.json()["error"]["type"] == "invalid_request_error" expect_error(BadRequestError, lambda: agents.create(model="resource-model", extra_body=fields)) # Rejections with official evidence report its code, param and message. + lookup = {"type": "function", "name": "lookup", "description": "Look up a value.", "parameters": {"type": "object"}} field_errors = [ ({"name": "x" * 129}, "name", "Invalid 'name': string too long. Expected a string with maximum length 128, but got a string with length 129 instead."), ({"metadata": {"k": 1}}, "metadata.k", "Invalid type for 'metadata.k': expected a string, but got an integer instead."), @@ -109,6 +113,18 @@ def verify_agents(client, other, invalid, expect_error): ({"metadata": {"x" * 65: "v"}}, "metadata." + "x" * 65, "Invalid property name in 'metadata': '" + "x" * 65 + "' is too long. Expected a string with maximum length 64, but got a string with length 65 instead."), ({"metadata": {"k": "v" * 513}}, "metadata.k", "Invalid 'metadata.k': string too long. Expected a string with maximum length 512, but got a string with length 513 instead."), ({"metadata": {"k": "a\x00b"}}, "metadata.k", "Invalid 'metadata.k': string contains U+0000, which this service cannot store."), + # Configuration protocol errors (TV-01..03); conflicts have a null param. + ({"tools": [{**lookup, "parameters": []}]}, "tools[0].parameters", "Invalid type for 'tools[0].parameters': expected an object with string keys and unknown value values, but got an array instead."), + ({"tools": [{k: v for k, v in lookup.items() if k != "parameters"}]}, "tools[0].parameters", "Missing required parameter: 'tools[0].parameters'."), + ({"tools": [{"type": "tool_search", "max_results": 3}]}, "tools[0].max_results", "Unknown parameter: 'tools[0].max_results'."), + ({"tools": [{"type": "bogus_tool"}]}, "tools[0].type", "Invalid value: 'bogus_tool'. Supported values are: 'function', 'tool_search', 'programmatic_tool_calling', 'mcp', and 'web_search'."), + ({"tool_choice": "auto"}, "tool_choice", "Unknown parameter: 'tool_choice'."), + ({"text": {"format": {"type": "json_object"}}}, "text.format.type", "Invalid value: 'json_object'. Supported values are: 'text' and 'json_schema'."), + ({"multi_agent": {"enabled": True, "max_concurrent_subagents": 0}}, "multi_agent.max_concurrent_subagents", "Invalid 'multi_agent.max_concurrent_subagents': integer below minimum value. Expected a value >= 1, but got 0 instead."), + ({"tools": [lookup, {**lookup, "description": "Second."}]}, None, "duplicate function tool name: lookup"), + ({"tools": [{"type": "tool_search"}, {"type": "tool_search"}]}, None, "duplicate tool_search tool"), + ({"tools": [{**lookup, "parameters": {"type": "string"}}]}, None, "Invalid schema for function 'lookup': schema must be a JSON Schema of 'type: \"object\"', got 'type: \"string\"'."), + ({"text": {"format": {"type": "json_schema", "schema": {"type": "array"}}}}, None, 'agent.text.format.schema must have top-level type "object"; got "array"'), ] count = len(list(agents.list())) for fields, param, message in field_errors: diff --git a/services/agents-api/tests/official_session_requests.py b/services/agents-api/tests/official_session_requests.py index 54519155a..e82d4c78d 100644 --- a/services/agents-api/tests/official_session_requests.py +++ b/services/agents-api/tests/official_session_requests.py @@ -33,6 +33,30 @@ def verify_session_create_requests(client, spec): assert error.body["code"] == code and error.body["param"] == param else: raise AssertionError(f"Official client accepted invalid fields: {fields}") + # Inline agent configuration errors use the official fields with the agent. + # prefix, and precede the none input requirement (TV-01..04). + lookup = {"type": "function", "name": "lookup", "description": "Look up a value.", "parameters": {"type": "object"}} + agent_errors = [ + ({"tools": [{"type": "bogus_tool"}]}, "agent.tools[0].type", "Invalid value: 'bogus_tool'. Supported values are: 'function', 'tool_search', 'programmatic_tool_calling', 'mcp', and 'web_search'."), + ({"tools": [{k: v for k, v in lookup.items() if k != "parameters"}]}, "agent.tools[0].parameters", "Missing required parameter: 'agent.tools[0].parameters'."), + ({"reasoning": {"effort": "extreme"}}, "agent.reasoning.effort", "Invalid value: 'extreme'. Supported values are: 'none', 'minimal', 'low', 'medium', 'high', 'xhigh', and 'max'."), + ({"tool_choice": "auto"}, "agent.tool_choice", "Unknown parameter: 'agent.tool_choice'."), + ({"tools": [lookup, {**lookup, "description": "Second."}]}, None, "duplicate function tool name: lookup"), + ({"tools": [{**lookup, "parameters": {"type": "string"}}]}, None, "Invalid schema for function 'lookup': schema must be a JSON Schema of 'type: \"object\"', got 'type: \"string\"'."), + ({"text": {"format": {"type": "json_schema", "schema": {"type": "array", "items": {"type": "string"}}}}}, None, 'agent.text.format.schema must have top-level type "object"; got "array"'), + ] + for fields, param, message in agent_errors: + expected = {"type": "invalid_request_error", "code": "invalid_request_error", "param": param, "message": message} + request = {**spec, "agent": {**spec["agent"], **fields}} + for body in (request, {k: v for k, v in request.items() if k != "input"}): + response = raw.post(str(client.base_url).rstrip("/") + "/agents/sessions", headers=headers, json=body) + assert response.status_code == 400 and response.json()["error"] == expected, (fields, response.text) + try: + sessions.create(**request) + except BadRequestError as error: + assert error.body == expected, error.body + else: + raise AssertionError(f"Official client accepted invalid agent: {fields}") assert {session.id for session in sessions.list()} == before key = {"Idempotency-Key": str(uuid.uuid4())} @@ -48,5 +72,5 @@ def verify_session_create_requests(client, spec): metadata = {"empty": "", "label": "中文🧪"} preserved = sessions.create(**spec, metadata=metadata) assert sessions.retrieve(preserved.id).metadata == metadata - print("Session create requests: raw HTTP and pinned SDK null rejection, no writes on rejection, default equivalence, idempotency and exact metadata passed.") + print("Session create requests: raw HTTP and pinned SDK null and inline agent configuration rejection, no writes on rejection, default equivalence, idempotency and exact metadata passed.") return [first, preserved]