diff --git a/contracts/agents-api/execution-tools.md b/contracts/agents-api/execution-tools.md index 1b52e7a65..36ace1a5c 100644 --- a/contracts/agents-api/execution-tools.md +++ b/contracts/agents-api/execution-tools.md @@ -83,7 +83,7 @@ Execution admits only `mode: "disabled"` and `enabled: false`. Enabled or omitte } ``` -- `server_label` is nonempty and unique within the Session. Only the `http` transport is accepted; `server_url` is an absolute HTTP or HTTPS URL without credentials, query or fragment. Nonempty `headers` and `request_metadata` are rejected. +- `server_label` is nonempty and unique within the Session. Only the `http` transport is accepted; `server_url` is an absolute HTTP or HTTPS URL without credentials, query or fragment. Nonempty `headers` and `request_metadata` and an inline `authorization` are rejected. - [Public MCP connection origin](./environments.md#public-mcp-connection-origin) owns origin defaults, placement and credential authority; [Harness capabilities](./harness-capabilities.md#tools) owns per-Harness support. - Omitted or null `allowed_tools` permits every server tool; `[]` permits none. - `required: true` makes native thread creation and cold resume wait for the server to initialize; a failure stops execution without replacing retained history. It needs the Runtime's `mcp_http_required` capability. Public work can be accepted or queued during the wait. diff --git a/contracts/agents-api/go-bindings.json b/contracts/agents-api/go-bindings.json index 5b5d9253e..3e2178e08 100644 --- a/contracts/agents-api/go-bindings.json +++ b/contracts/agents-api/go-bindings.json @@ -159,10 +159,7 @@ "FunctionToolInput": { "sources": ["#/components/schemas/AgentToolConfigParamFunction"], "fields": { - "name": {"type": "*string"}, - "description": {"type": "*string"}, - "parameters": {"type": "json.RawMessage"}, - "defer_loading": {"type": "json.RawMessage"} + "parameters": {"type": "json.RawMessage"} } }, "InlineAgent": { @@ -233,16 +230,7 @@ "order": ["type", "server_label", "transport", "allowed_tools", "connection_origin", "credential_id", "request_metadata", "required"] }, "MCPToolInput": { - "sources": ["#/components/schemas/AgentToolConfigParamMcp"], - "fields": { - "server_label": {"type": "*string"}, - "allowed_tools": {"type": "json.RawMessage", "omit": false}, - "connection_origin": {"omit": false}, - "credential_id": {"omit": false}, - "request_metadata": {"type": "json.RawMessage", "omit": false}, - "required": {"type": "json.RawMessage", "omit": false} - }, - "order": ["type", "server_label", "transport", "allowed_tools", "connection_origin", "credential_id", "request_metadata", "required"] + "sources": ["#/components/schemas/AgentToolConfigParamMcp"] }, "MultiAgentConfig": { "sources": ["#/components/schemas/MultiAgentConfigResource"], diff --git a/contracts/agents-api/index.md b/contracts/agents-api/index.md index 5b2f933fc..d0d27dc8d 100644 --- a/contracts/agents-api/index.md +++ b/contracts/agents-api/index.md @@ -14,9 +14,9 @@ Core targets the complete OpenAI Agents API as pinned below ([public API rule](h | [openapi.yaml](./openapi.yaml) | The official public contract with Core's `x_agents_core` extension on Agent and Session request/response objects | | [go-bindings.json](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/contracts/agents-api/go-bindings.json) | Go names, field representations, encoding order and stored projections; it does not define official field membership, enums or constraints | -Run `make openapi` to regenerate the public Go types, route inventory and all three OpenAPI documents. `scripts/generate-public-api.py` reads the checked-in, checksum-verified official source without network access. It selects Agents, Vaults, Files and Skills and follows their schema references, preserving union types, nullability, required fields and constraints. Core's extension types in `v1/` remain authored in Go and are added to the public schema during generation. The internal `/core/v1` and `/api/v1` documents come from handler annotations. `make check-openapi` checks freshness and the generator; it also runs through `make check-go`. +Run `make openapi` to regenerate the public Go types, the Agent request shapes, the route inventory and all three OpenAPI documents. `scripts/generate-public-api.py` reads the checked-in, checksum-verified official source without network access. It selects Agents, Vaults, Files and Skills and follows their schema references, preserving union types, nullability, required fields and constraints. The request shapes in `services/core/internal/api/official_shapes.gen.go` project `CreateAgentParams`, `UpdateAgentParams` and `SessionAgentConfigParam`; Core checks request bodies against them before it reads an Agent configuration. Core's extension types in `v1/` remain authored in Go and are added to the public schema during generation. The internal `/core/v1` and `/api/v1` documents come from handler annotations. `make check-openapi` checks freshness and the generator; it also runs through `make check-go`. -The public contract is the official API plus Core extensions. Standard fields are generated into `v1/official.gen.go`; `go-bindings.json` lists types consumed by Core and overrides only the Go representation or field order that existing storage or custom JSON encoding requires. Unspecified fields follow the official schema; shared shapes use one Go type. Selected discriminated unions also generate JSON serializers to retain required nullable fields for each variant. Other union serializers, request admission and state transitions remain implementation code. Contract tests verify that the public schema preserves the official definitions, extensions remain in `x_agents_core`, and all documents match registered routes. Official-client and raw HTTP tests verify behavior. Schema generation does not qualify an unimplemented feature; the gaps below still apply. Upstream upgrades update the OpenAPI and SDK pins together after comparison and compatibility tests. +The public contract is the official API plus Core extensions. Standard fields are generated into `v1/official.gen.go`; `go-bindings.json` lists types consumed by Core and overrides only the Go representation or field order that existing storage or custom JSON encoding requires. Unspecified fields follow the official schema; shared shapes use one Go type. Selected discriminated unions also generate JSON serializers to retain required nullable fields for each variant. Other union serializers, Core's local limits, execution admission and state transitions remain implementation code. Contract tests verify that the public schema preserves the official definitions, extensions remain in `x_agents_core`, and all documents match registered routes. Official-client and raw HTTP tests verify behavior. Schema generation does not qualify an unimplemented feature; the gaps below still apply. Upstream upgrades update the OpenAPI and SDK pins together after comparison and compatibility tests. The official source and existing service have these recorded differences: Agents authentication errors can return a null `code`; empty Files pages return null `first_id` and `last_id`; File resources can return null `expires_at` and `status_details`. The source declares those fields non-null. The official-client response validator allows null only for these named fields and otherwise validates OpenAPI 3.1 response schemas. Files and Skills operations omit error responses in the source, so those error bodies use the upstream shared `ErrorResponse` schema. [Wire semantics](./wire-semantics.md) and raw HTTP tests qualify service behavior; the published schema retains the official definitions. @@ -111,6 +111,7 @@ Each item is Core's deliberate or native behavior where the official service beh - Explicit reasoning effort or summary, service tiers other than `auto`, enabled `web_search` and enabled programmatic tool calling are saved but rejected at Session admission. - Harness support for tools, structured output, deferred discovery, subagents and MCP differs by Harness and placement; see the [Harness capabilities](./harness-capabilities.md). MiniMax Code has no public functions, no service-origin MCP and no image input. - Model-derived reasoning defaults are not resolved. +- MCP tools support the `http` transport only; `stdio` is rejected, and so is an inline `authorization` on a Session MCP transport ([HTTP MCP](./execution-tools.md#http-mcp)). **Execution and history** diff --git a/contracts/agents-api/v1/official.gen.go b/contracts/agents-api/v1/official.gen.go index 1cc7b4653..d74771d9c 100644 --- a/contracts/agents-api/v1/official.gen.go +++ b/contracts/agents-api/v1/official.gen.go @@ -273,10 +273,10 @@ type FunctionCallAction struct { // FunctionToolInput projects AgentToolConfigParamFunction. type FunctionToolInput struct { Type string `json:"type" binding:"required" enums:"function"` - Name *string `json:"name" binding:"required"` - Description *string `json:"description" binding:"required"` + Name string `json:"name" binding:"required"` + Description string `json:"description" binding:"required"` Parameters json.RawMessage `json:"parameters" binding:"required" swaggertype:"object"` - DeferLoading json.RawMessage `json:"defer_loading,omitempty" swaggertype:"object"` + DeferLoading *bool `json:"defer_loading,omitempty"` } // InlineAgent projects SessionAgentConfigParam. @@ -377,14 +377,14 @@ type MCPTool struct { // MCPToolInput projects AgentToolConfigParamMcp. type MCPToolInput struct { - Type string `json:"type" binding:"required" enums:"mcp"` - ServerLabel *string `json:"server_label" binding:"required"` - Transport json.RawMessage `json:"transport" binding:"required" swaggertype:"object"` - AllowedTools json.RawMessage `json:"allowed_tools" extensions:"x-nullable" swaggertype:"object"` - ConnectionOrigin *string `json:"connection_origin" extensions:"x-nullable" enums:"service,environment"` - CredentialID *string `json:"credential_id" extensions:"x-nullable"` - RequestMetadata json.RawMessage `json:"request_metadata" extensions:"x-nullable" swaggertype:"object"` - Required json.RawMessage `json:"required" swaggertype:"object"` + Type string `json:"type" binding:"required" enums:"mcp"` + ServerLabel string `json:"server_label" binding:"required"` + CredentialID *string `json:"credential_id,omitempty" extensions:"x-nullable"` + Transport json.RawMessage `json:"transport" binding:"required" swaggertype:"object"` + RequestMetadata map[string]json.RawMessage `json:"request_metadata,omitempty" extensions:"x-nullable" swaggertype:"object"` + AllowedTools []string `json:"allowed_tools,omitempty" extensions:"x-nullable"` + Required *bool `json:"required,omitempty"` + ConnectionOrigin *string `json:"connection_origin,omitempty" extensions:"x-nullable" enums:"service,environment"` } // MultiAgentConfig projects MultiAgentConfigResource. diff --git a/contracts/agents-api/wire-semantics.md b/contracts/agents-api/wire-semantics.md index 75d9136f2..2a62ed66b 100644 --- a/contracts/agents-api/wire-semantics.md +++ b/contracts/agents-api/wire-semantics.md @@ -162,7 +162,7 @@ Saving a value does not make it executable. Session creation admits a smaller se ### Configuration validation -Agent create and update bodies and the inline `agent` of Session create are checked against the pinned shapes of `tools`, `text`, `reasoning`, `service_tier`, `multi_agent`, `model`, `name` and `instructions`, before their parsers and before Harness admission. Failures return 400 with type and code `invalid_request_error`: +Agent create and update bodies and the inline `agent` of Session create are checked against the pinned shapes of `tools`, `text`, `reasoning`, `service_tier`, `multi_agent`, `model`, `name`, `instructions` and `metadata`, before their parsers and before Harness admission. Failures return 400 with type and code `invalid_request_error`: | Case | Param | Message | | --- | --- | --- | @@ -175,7 +175,7 @@ Agent create and update bodies and the inline `agent` of Session create are chec | Function `parameters` with a string root `type` other than `object` | null | `Invalid schema for function '': schema must be a JSON Schema of 'type: "object"', got 'type: ""'.` | | `text.format` JSON schema with a string root `type` other than `object` | null | `agent.text.format.schema must have top-level type "object"; got ""`, also on Agent requests | -Within one object Core reports a union's `type` first, then unknown members, then member values in document order, then missing members; tools before `text`, and the whole object before the duplicate and schema-root checks. Schemas without a string root `type` are not checked. Function and output schemas, MCP `transport`, `request_metadata`, `metadata` and `x_agents_core` keep their own parsers. Update bodies and the inline Session agent are validated before the Agent lookup, so owned, foreign, missing and malformed Agent IDs give the same response. +Within one object Core reports a union's `type` first, then unknown members, then member values in document order, then missing members in the pinned schema's order; tools before `text`, and the whole object before the duplicate and schema-root checks. Schemas without a string root `type` are not checked. Function and output schemas, `request_metadata` and `x_agents_core` keep their own parsers. Update bodies and the inline Session agent are validated before the Agent lookup, so owned, foreign, missing and malformed Agent IDs give the same response. Core saves values the pinned shapes allow even when it cannot run them: function names of any length, enabled programmatic tool calling, reasoning effort `max` and service tier `flex`. diff --git a/contracts/agents-api/zh/execution-tools.md b/contracts/agents-api/zh/execution-tools.md index aaab6a7c9..022a42ed5 100644 --- a/contracts/agents-api/zh/execution-tools.md +++ b/contracts/agents-api/zh/execution-tools.md @@ -1,7 +1,7 @@ --- title: "执行工具" source: contracts/agents-api/execution-tools.md -source_hash: 5e0b9ab1938ccd0ee7b22b1482a365bd54285c8c09fc4cb1ca172e34ab122ce2 +source_hash: eacb0d566f9d787f393c1d462351023a1ca6fe97ffb32773c8f23139833f5762 --- Agent 在 `tools` 中声明应用函数、控制项和 MCP 服务器,并可在 `text.format` 中声明输出 schema。本契约说明 Core 如何验证声明、哪些内容跨越 Runtime 边界,以及调用方如何恢复待执行操作。[Harness 能力](harness-capabilities.md)列出各 Harness 在不同部署位置支持的操作。原生工作区工具和 Environment Plugin MCP 属于 [Environment](environments.md#skills-plugins-and-environment-mcp)。 @@ -85,7 +85,7 @@ Core 发送 `PromptRequestPayload.ToolSearch` 和每个 `FunctionTool.DeferLoadi } ``` -- `server_label` 非空且在 Session 中唯一。仅接受 `http` 传输;`server_url` 为不带凭据、查询或片段的绝对 HTTP 或 HTTPS URL。非空 `headers` 和 `request_metadata` 被拒绝。 +- `server_label` 非空且在 Session 中唯一。仅接受 `http` 传输;`server_url` 为不带凭据、查询或片段的绝对 HTTP 或 HTTPS URL。非空 `headers`、`request_metadata` 和内联 `authorization` 被拒绝。 - [公开 MCP 连接来源](environments.md#public-mcp-connection-origin)定义来源默认值、部署位置和凭据权限;[Harness 能力](harness-capabilities.md#tools)定义各 Harness 支持范围。 - 省略或 null 的 `allowed_tools` 允许所有服务器工具;`[]` 不允许任何工具。 - `required: true` 使原生线程创建和冷恢复等待服务器初始化;失败会停止执行,不替换保留历史。它要求 Runtime 的 `mcp_http_required` 能力。等待期间公开工作可被接受或排队。 diff --git a/contracts/agents-api/zh/index.md b/contracts/agents-api/zh/index.md index f84d692b7..fda6127ab 100644 --- a/contracts/agents-api/zh/index.md +++ b/contracts/agents-api/zh/index.md @@ -1,7 +1,7 @@ --- title: "Agents API 覆盖台账" source: contracts/agents-api/index.md -source_hash: 61cd594bb0bd475b019c916e099b6e8de935b4e5cb1cde7a39f620818097900b +source_hash: c155876a94aeda51fb97ec292629505142cfb6f76785dfaf2a783acab87226ae --- Core 旨在以下方固定版本为准支持完整的 OpenAI Agents API([public API rule](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/AGENTS.md#public-api))。本台账记录 Core 对各项资源实现了哪些内容、哪些契约保存其详细信息,并列出相对于 OpenAI 服务的所有已知差异和所有未解决缺口。[API namespaces and credentials](../../../docs/zh/api/index.md) 说明谁调用哪些 API;[Agents API guide](../../../docs/zh/api/public-agent-api.md) 介绍使用方法。 @@ -16,9 +16,9 @@ Core 旨在以下方固定版本为准支持完整的 OpenAI Agents API([publi | [openapi.yaml](../openapi.yaml) | 官方公共契约,并在 Agent 和 Session 请求及响应对象上加入 Core 的 `x_agents_core` 扩展 | | [go-bindings.json](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/contracts/agents-api/go-bindings.json) | Go 名称、字段表示、编码顺序和存储投影;不定义官方字段集合、枚举或约束 | -运行 `make openapi` 重新生成公共 Go 类型、路由清单和三个 OpenAPI 文档。`scripts/generate-public-api.py` 读取仓库内经过校验和验证的官方源文件,无需网络。它选择 Agents、Vaults、Files 和 Skills,并跟随 schema 引用,保留联合类型、可空性、必填字段和约束。Core 在 `v1/` 中的扩展类型继续由 Go 定义,在生成时加入公共 schema。内部 `/core/v1` 和 `/api/v1` 文档由处理函数注解生成。`make check-openapi` 检查生成结果是否最新并测试生成器;`make check-go` 也会运行此检查。 +运行 `make openapi` 重新生成公共 Go 类型、Agent 请求结构、路由清单和三个 OpenAPI 文档。`scripts/generate-public-api.py` 读取仓库内经过校验和验证的官方源文件,无需网络。它选择 Agents、Vaults、Files 和 Skills,并跟随 schema 引用,保留联合类型、可空性、必填字段和约束。`services/core/internal/api/official_shapes.gen.go` 中的请求结构投影 `CreateAgentParams`、`UpdateAgentParams` 和 `SessionAgentConfigParam`;Core 在读取 Agent 配置前先按它们检查请求体。Core 在 `v1/` 中的扩展类型继续由 Go 定义,在生成时加入公共 schema。内部 `/core/v1` 和 `/api/v1` 文档由处理函数注解生成。`make check-openapi` 检查生成结果是否最新并测试生成器;`make check-go` 也会运行此检查。 -公共契约是官方 API 加上 Core 扩展。标准字段生成到 `v1/official.gen.go`;`go-bindings.json` 只列出 Core 使用的类型,仅在已有存储或自定义 JSON 编码需要时覆盖 Go 表示或字段顺序。未覆盖的字段遵循官方 schema,相同结构复用同一个 Go 类型。部分带判别字段的联合类型也从 schema 生成 JSON 序列化代码,保留每个分支必需的可空字段。其他联合类型序列化、请求准入和状态转换仍由实现代码负责。契约测试验证公共 schema 保留官方定义、扩展位于 `x_agents_core` 中,且所有文档与注册路由一致。官方客户端和原始 HTTP 测试验证行为。生成 schema 不代表某个尚未实现的功能已经得到验证;下方缺口仍然适用。升级上游时,在比对和兼容性测试后一起更新 OpenAPI 和 SDK 固定版本。 +公共契约是官方 API 加上 Core 扩展。标准字段生成到 `v1/official.gen.go`;`go-bindings.json` 只列出 Core 使用的类型,仅在已有存储或自定义 JSON 编码需要时覆盖 Go 表示或字段顺序。未覆盖的字段遵循官方 schema,相同结构复用同一个 Go 类型。部分带判别字段的联合类型也从 schema 生成 JSON 序列化代码,保留每个分支必需的可空字段。其他联合类型序列化、Core 的本地限制、执行准入和状态转换仍由实现代码负责。契约测试验证公共 schema 保留官方定义、扩展位于 `x_agents_core` 中,且所有文档与注册路由一致。官方客户端和原始 HTTP 测试验证行为。生成 schema 不代表某个尚未实现的功能已经得到验证;下方缺口仍然适用。升级上游时,在比对和兼容性测试后一起更新 OpenAPI 和 SDK 固定版本。 官方源文件与已有服务存在以下已记录的差异:Agents 鉴权错误的 `code` 可以为 null;Files 空页的 `first_id` 和 `last_id` 为 null;File 资源的 `expires_at` 和 `status_details` 可以为 null。源文件将这些字段声明为非空。官方客户端响应验证器只对这些指定字段允许 null,其余部分按 OpenAPI 3.1 响应 schema 验证。源文件中的 Files 和 Skills 操作未声明错误响应,因此这些错误体使用上游共享的 `ErrorResponse` schema。[传输语义](wire-semantics.md)和原始 HTTP 测试验证服务行为;发布的 schema 保留官方定义。 @@ -114,6 +114,7 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh - 显式指定推理强度或摘要、使用 `auto` 之外的服务层级、启用 `web_search` 或启用程序化工具调用,这些设置都会被保存,但在 Session 准入时会被拒绝。 - Harness 对工具、结构化输出、延迟发现、subagents 和 MCP 的支持因 Harness 和部署位置而异;请参阅 [Harness capabilities](harness-capabilities.md)。MiniMax Code 不提供公共 functions、没有服务源 MCP,也不支持图像输入。 - 由模型推导出的推理默认值不会被解析确定。 +- MCP 工具仅支持 `http` 传输,`stdio` 会被拒绝,Session MCP 传输中的内联 `authorization` 也会被拒绝([HTTP MCP](execution-tools.md#http-mcp))。 **执行和历史** diff --git a/contracts/agents-api/zh/wire-semantics.md b/contracts/agents-api/zh/wire-semantics.md index 840c3a8f7..d688f9444 100644 --- a/contracts/agents-api/zh/wire-semantics.md +++ b/contracts/agents-api/zh/wire-semantics.md @@ -1,7 +1,7 @@ --- title: "Core 协议行为" source: contracts/agents-api/wire-semantics.md -source_hash: 5524db9b90aaf51026746316d6305da396033f50e88ed50792a8f52cf5aac9db +source_hash: bed64f05b6e52ab5774063f649173b4a42c0a2da4038bc273f595f81c5dbd128 --- 已锁定版本的 OpenAI Python SDK([upstream.json](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/contracts/agents-api/upstream.json))定义了 `/v1` 路由、字段和类型。本页面说明这些类型未作规定之处 Core 的行为,例如状态码、错误字段、默认值和列表边界,以及 Core 与官方服务存在差异的地方。[coverage ledger](index.md) 列出了这些差异和尚存缺口;[Sessions, events and history](sessions-events.md)、[message content](message-content.md)、[Vaults](vaults.md)、[source Files and Skills](source-files.md) 和 [Environment files and Artifacts](environment-files.md) 分别负责各自资源的规则。 @@ -164,7 +164,7 @@ Agent 创建要求提供 `model`。对于省略的字段,Core 会保存并返 ### 配置验证 {#configuration-validation} -Agent 创建和更新正文以及 Session 创建中的内联 `agent`,会在其解析器和 Harness 准入之前,根据已锁定的 `tools`、`text`、`reasoning`、`service_tier`、`multi_agent`、`model`、`name` 和 `instructions` 形状进行检查。失败时返回 400,`type` 和 `code` 均为 `invalid_request_error`: +Agent 创建和更新正文以及 Session 创建中的内联 `agent`,会在其解析器和 Harness 准入之前,根据已锁定的 `tools`、`text`、`reasoning`、`service_tier`、`multi_agent`、`model`、`name`、`instructions` 和 `metadata` 形状进行检查。失败时返回 400,`type` 和 `code` 均为 `invalid_request_error`: | 情况 | Param | 消息 | | --- | --- | --- | @@ -177,7 +177,7 @@ Agent 创建和更新正文以及 Session 创建中的内联 `agent`,会在其 | Function `parameters` 的字符串根 `type` 不是 `object` | null | `Invalid schema for function '': schema must be a JSON Schema of 'type: "object"', got 'type: ""'.` | | `text.format` JSON schema 的字符串根 `type` 不是 `object` | null | `agent.text.format.schema must have top-level type "object"; got ""`,Agent 请求上也会返回此消息 | -在同一个对象内,Core 会先报告联合类型的 `type`,然后报告未知成员,再按文档顺序报告成员值,最后报告缺失成员;先检查 tools,再检查 `text`,并在重复项和 schema 根检查之前检查整个对象。没有字符串根 `type` 的 schema 不会被检查。Function 和 output schema、MCP `transport`、`request_metadata`、`metadata` 和 `x_agents_core` 使用各自的解析器。更新正文和 Session 内联 Agent 会在查找 Agent 之前进行验证,因此属于当前租户、属于外部租户、缺失和格式错误的 Agent ID 会得到相同的响应。 +在同一个对象内,Core 会先报告联合类型的 `type`,然后报告未知成员,再按文档顺序报告成员值,最后按固定 schema 的顺序报告缺失成员;先检查 tools,再检查 `text`,并在重复项和 schema 根检查之前检查整个对象。没有字符串根 `type` 的 schema 不会被检查。Function 和 output schema、`request_metadata` 和 `x_agents_core` 使用各自的解析器。更新正文和 Session 内联 Agent 会在查找 Agent 之前进行验证,因此属于当前租户、属于外部租户、缺失和格式错误的 Agent ID 会得到相同的响应。 即使无法执行,Core 也会保存已锁定形状允许的值:任意长度的函数名称、已启用的 programmatic tool calling、reasoning effort `max` 和 service tier `flex`。 diff --git a/scripts/generate-public-api.py b/scripts/generate-public-api.py index 69081f3e8..f469e0f9b 100644 --- a/scripts/generate-public-api.py +++ b/scripts/generate-public-api.py @@ -10,6 +10,9 @@ ROOT = Path(__file__).resolve().parents[1] CONTRACT = ROOT / 'contracts/agents-api' +SHAPES = ROOT / 'services/core/internal/api/official_shapes.gen.go' +# Agent configuration request bodies checked by Core's shape walker. +REQUEST_SHAPES = ('CreateAgentParams', 'UpdateAgentParams', 'SessionAgentConfigParam') HTTP_METHODS = {'get', 'post', 'put', 'patch', 'delete', 'head', 'options'} @@ -173,9 +176,71 @@ def go_types(document, bindings): lines.append('}\n') if binding.get('marshal_union'): lines.extend(union_marshaler(document, name, binding, field_types)) + return gofmt(lines) + + +def gofmt(lines): return subprocess.run(['gofmt'], input='\n'.join(lines).encode(), stdout=subprocess.PIPE, check=True).stdout +def go_shape(document, schema, required=False): + """Project one request value onto a shape of services/core/internal/api/configuration_validation.go.""" + value, = [v for v in dereference(document, schema).get('anyOf', [schema]) if v.get('type') != 'null'] + value = dereference(document, value) + kind = value.get('type') + if isinstance(kind, list): + kind, = set(kind) - {'null'} + if 'oneOf' in value: + if value['discriminator']['propertyName'] != 'type': + raise ValueError(f'Unsupported union discriminator: {value["discriminator"]}') + variants = {} + for option in value['oneOf']: + option = dereference(document, option) + tag, = option['properties']['type']['enum'] + variants[tag] = go_members(document, option, 'type') + fields = ['kind: unionValue', 'values: []string{' + ', '.join(json.dumps(tag) for tag in variants) + '}', + 'variants: map[string][]member{\n' + ''.join(f'{json.dumps(tag)}: {members.removeprefix("[]member")},\n' for tag, members in variants.items()) + '}'] + elif 'enum' in value: + fields = ['kind: enumValue', 'values: []string{' + ', '.join(json.dumps(v) for v in value['enum']) + '}'] + elif kind == 'object' and 'properties' in value: + fields = ['kind: objectValue', 'members: ' + go_members(document, value)] + elif kind == 'object': + values = value['additionalProperties'] + if values and values.get('type') != 'string': + raise ValueError(f'Unsupported map values: {values}') + fields = ['kind: mapValue'] + (['items: &shape{kind: stringValue}'] if values else []) + elif kind == 'array': + fields = ['kind: arrayValue', 'items: &' + go_shape(document, value['items'])] + elif kind == 'integer': + fields = ['kind: integerValue', f'minimum: {value["minimum"]}'] + else: + fields = [{'string': 'kind: stringValue', 'boolean': 'kind: booleanValue'}[kind]] + if required: + fields.append('required: true') + if nullable(document, schema): + fields.append('nullable: true') + return 'shape{' + ', '.join(fields) + '}' + + +def go_members(document, schema, skip=None): + # The walker rejects unknown members, so only closed objects project. + if schema.get('additionalProperties') is not False: + raise ValueError(f'Unsupported open object: {sorted(schema["properties"])}') + required = schema.get('required', []) + return '[]member{\n' + ''.join(f'{{{json.dumps(name)}, {go_shape(document, value, name in required)}}},\n' for name, value in schema['properties'].items() if name != skip) + '}' + + +def request_shapes(document, owners): + lines = ['// Code generated by scripts/generate-public-api.py; DO NOT EDIT.', 'package api', '', + '// Pinned request shapes; x_agents_core is checked by its own parser.', 'var ('] + for name in REQUEST_SHAPES: + members = go_members(document, resolve(document, '#/components/schemas/' + name)) + if name in owners: + members = members[:-1] + '{"x_agents_core", shape{}},\n}' + lines.append(f'{name[0].lower() + name[1:]} = shape{{kind: objectValue, members: {members}}}') + return gofmt(lines + [')']) + + def prune_components(document): needed = set() def walk(value): @@ -263,6 +328,7 @@ def main(): owners = extension_owners(bindings) if args.swag_roots: write(CONTRACT / 'v1/official.gen.go', go_types(source, bindings), args.check) + write(SHAPES, request_shapes(source, owners), args.check) roots = 'package extensions\n\n' + '\n'.join(f'// @Success 200 {{object}} v1.{name}' for name in sorted(set(owners.values()))) + '\nfunc extensions() {}\n' args.swag_roots.write_text(roots) else: diff --git a/scripts/generate-public-api.test.py b/scripts/generate-public-api.test.py index 531d205f8..ad47f433f 100644 --- a/scripts/generate-public-api.test.py +++ b/scripts/generate-public-api.test.py @@ -51,6 +51,25 @@ def test_new_official_fields_reach_go_without_a_second_field_list(self): self.assertIn('FutureField *string', generated) self.assertIn('json:"future_field,omitempty"', generated) + def test_generated_request_shapes_are_current(self): + owners = generator.extension_owners(self.bindings) + self.assertEqual(generator.request_shapes(self.source, owners), generator.SHAPES.read_bytes()) + + def test_new_official_members_and_values_reach_request_shapes(self): + source = copy.deepcopy(self.source) + schemas = source['components']['schemas'] + schemas['McpTransportConfigParamStdio']['properties']['future_field'] = {'type': 'string'} + schemas['ServiceTierParam']['enum'].append('future_tier') + generated = generator.request_shapes(source, generator.extension_owners(self.bindings)).decode() + self.assertIn('{"future_field", shape{kind: stringValue}}', generated) + self.assertEqual(generated.count('"future_tier"'), 3) + + def test_open_request_objects_are_rejected(self): + source = copy.deepcopy(self.source) + del source['components']['schemas']['McpTransportConfigParamStdio']['additionalProperties'] + with self.assertRaisesRegex(ValueError, 'Unsupported open object'): + generator.request_shapes(source, generator.extension_owners(self.bindings)) + def test_stale_binding_cannot_add_a_public_field(self): bindings = copy.deepcopy(self.bindings) bindings['Vault']['fields'] = {'private_data': {'type': 'string'}} diff --git a/services/core/internal/api/agents.go b/services/core/internal/api/agents.go index 0aa2a0607..0e652877f 100644 --- a/services/core/internal/api/agents.go +++ b/services/core/internal/api/agents.go @@ -30,7 +30,7 @@ func (h *Handler) createAgent(w http.ResponseWriter, r *http.Request) { if !ok { return } - if writeFieldError(w, metadataTypeError(raw)) || writeFieldError(w, validateSavedAgentBody(raw, savedAgentCreate)) { + if writeFieldError(w, validateSavedAgentBody(raw, createAgentParams)) { return } if err := validateSavedCoreInput(raw); err != nil { @@ -38,13 +38,14 @@ func (h *Handler) createAgent(w http.ResponseWriter, r *http.Request) { return } var request v1.CreateAgentRequest - if decodeInputObject(raw, &request, "model", "name", "instructions", "metadata", "multi_agent", "reasoning", "service_tier", "text", "tools", "x_agents_core") != nil { + // The walks bound members and types, not integer ranges. + if json.Unmarshal(raw, &request) != nil { writeError(w, http.StatusBadRequest, "invalid_request", "Request must be a JSON object containing supported fields.") return } command, err := resolveSavedAgent(request) if err != nil { - if !writeFieldError(w, err) { + if !writeStoredDataError(w, r, err) && !writeFieldError(w, err) { writeError(w, http.StatusBadRequest, "unsupported_or_invalid_configuration", err.Error()) } return @@ -83,7 +84,7 @@ func (h *Handler) respondAgentStatus(w http.ResponseWriter, r *http.Request, age func agentResponse(agent agents.Agent) (v1.SavedAgent, error) { var response v1.SavedAgent if err := json.Unmarshal(agent.Configuration, &response.SavedAgentConfiguration); err != nil { - return response, err + return response, &storedDataError{err} } response.ID, response.Object = agent.ID, "agent" response.Metadata = agent.Metadata diff --git a/services/core/internal/api/agents_update.go b/services/core/internal/api/agents_update.go index ab7f9d268..b72a4424e 100644 --- a/services/core/internal/api/agents_update.go +++ b/services/core/internal/api/agents_update.go @@ -17,7 +17,7 @@ func (h *Handler) updateAgent(w http.ResponseWriter, r *http.Request) { } command, err := resolveAgentUpdate(raw) if err != nil { - if !writeFieldError(w, err) { + if !writeStoredDataError(w, r, err) && !writeFieldError(w, err) { writeError(w, http.StatusBadRequest, "unsupported_or_invalid_configuration", err.Error()) } return @@ -33,33 +33,25 @@ func (h *Handler) updateAgent(w http.ResponseWriter, r *http.Request) { // resolveAgentUpdate returns the command without its tenant and Agent. func resolveAgentUpdate(raw []byte) (agents.UpdateCommand, error) { - if err := metadataTypeError(raw); err != nil { - return agents.UpdateCommand{}, err - } - if err := validateSavedAgentBody(raw, savedAgentUpdate); err != nil { + if err := validateSavedAgentBody(raw, updateAgentParams); err != nil { return agents.UpdateCommand{}, err } if err := validateSavedCoreInput(raw); err != nil { return agents.UpdateCommand{}, err } var request v1.UpdateAgentRequest - if decodeInputObject(raw, &request, "model", "name", "instructions", "metadata", "multi_agent", "reasoning", "service_tier", "text", "tools", "x_agents_core") != nil { + // The walks bound members and types, not integer ranges. + if json.Unmarshal(raw, &request) != nil { return agents.UpdateCommand{}, errors.New("Request must be a JSON object containing supported fields.") } - var fields map[string]json.RawMessage - if err := json.Unmarshal(raw, &fields); err != nil { - return agents.UpdateCommand{}, err - } - if _, supplied := fields["model"]; supplied && request.Model == nil { - return agents.UpdateCommand{}, errors.New("model must be a string when supplied.") - } + _, fields := orderedMembers(raw) normalized, err := resolveSavedFields(v1.CreateAgentRequest(request)) if err != nil { return agents.UpdateCommand{}, err } var patch map[string]json.RawMessage if err := json.Unmarshal(normalized.Configuration, &patch); err != nil { - return agents.UpdateCommand{}, err + return agents.UpdateCommand{}, &storedDataError{err} } if _, supplied := fields["x_agents_core"]; supplied && request.XAgentsCore == nil { patch["x_agents_core"] = json.RawMessage(`null`) diff --git a/services/core/internal/api/configuration_validation.go b/services/core/internal/api/configuration_validation.go index 2c768821e..577621a26 100644 --- a/services/core/internal/api/configuration_validation.go +++ b/services/core/internal/api/configuration_validation.go @@ -11,24 +11,22 @@ import ( // 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. +// before harness admission. It walks the raw JSON along the request shapes that +// scripts/generate-public-api.py projects from the pinned schema +// (official_shapes.gen.go) and reports the first violation as a fieldError with +// the JSON path as param and the official message forms. x_agents_core keeps +// its own parser. type valueKind uint8 const ( - anyValue valueKind = iota // validated by the existing parsers + anyValue valueKind = iota // validated by its own parser stringValue // str booleanValue // bool integerValue // int with an inclusive minimum enumValue // Literal[...] of strings arrayValue // Iterable/SequenceNotStr of items - mapValue // Dict[str, object] + mapValue // Dict[str, object], or Dict[str, str] with items openObject // an object whose members are validated elsewhere objectValue // TypedDict members unionValue // TypedDicts selected by their "type" member @@ -42,7 +40,7 @@ type shape struct { 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 + items *shape // arrayValue items, or mapValue string values } type member struct { @@ -50,73 +48,7 @@ type member struct { 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} -} +var requiredString = shape{kind: stringValue, required: true} // validateSavedAgentBody checks a saved Agent create or update body. Malformed // and non-object bodies keep the existing whole-body error. @@ -129,7 +61,7 @@ func validateSavedAgentBody(raw []byte, root shape) error { // validateSessionAgent checks the inline Session agent, whose paths start with agent. func validateSessionAgent(raw json.RawMessage) error { - return validateAgentConfiguration("agent", raw, sessionAgent) + return validateAgentConfiguration("agent", raw, sessionAgentConfigParam) } func validateAgentConfiguration(path string, raw json.RawMessage, root shape) error { @@ -196,7 +128,19 @@ func checkValue(path string, raw json.RawMessage, s shape) error { } case mapValue: if got != "an object" { - return invalidType(path, "an object with string keys and unknown value values", got) + values := "unknown value" + if s.items != nil { + values = "string" + } + return invalidType(path, "an object with string keys and "+values+" values", got) + } + if s.items != nil { + keys, fields := orderedMembers(raw) + for _, key := range keys { + if err := checkValue(joinPath(path, key), fields[key], *s.items); err != nil { + return err + } + } } case openObject: if got != "an object" { diff --git a/services/core/internal/api/configuration_validation_test.go b/services/core/internal/api/configuration_validation_test.go index 2297b7b32..ed4f7fa6c 100644 --- a/services/core/internal/api/configuration_validation_test.go +++ b/services/core/internal/api/configuration_validation_test.go @@ -83,6 +83,7 @@ func TestAgentConfigurationProtocolErrorsUseOfficialFields(t *testing.T) { {"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'."}, + {"function order", `"tools":[{"type":"function","parameters":{"type":"object"}}]`, "{p}tools[0].name", "Missing required parameter: '{p}tools[0].name'."}, {"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'."}, @@ -110,6 +111,10 @@ func TestAgentConfigurationProtocolErrorsUseOfficialFields(t *testing.T) { {"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'."}, + {"stdio cwd", `"tools":[{"type":"mcp","server_label":"x","transport":{"type":"stdio","command":"run"}}]`, "{p}tools[0].transport.cwd", "Missing required parameter: '{p}tools[0].transport.cwd'."}, + {"http url", `"tools":[{"type":"mcp","server_label":"x","transport":{"type":"http"}}]`, "{p}tools[0].transport.server_url", "Missing required parameter: '{p}tools[0].transport.server_url'."}, + {"transport type", `"tools":[{"type":"mcp","server_label":"x","transport":{"type":"ws"}}]`, "{p}tools[0].transport.type", "Invalid value: 'ws'. Supported values are: 'http' and 'stdio'."}, + {"header value", `"tools":[{"type":"mcp","server_label":"x","transport":{"type":"http","server_url":"https://example.invalid","headers":{"h":1}}}]`, "{p}tools[0].transport.headers.h", "Invalid type for '{p}tools[0].transport.headers.h': expected a string, but got an integer instead."}, {"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'."}, @@ -193,6 +198,9 @@ func TestSessionAgentProtocolErrors(t *testing.T) { } 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","metadata":5}`), "invalid_request_error", param("metadata"), "Invalid type for 'metadata': expected an object with string keys and string values, but got an integer instead.") + // Inline authorization is a Session-only transport member. + assertConfigurationError(t, credentialRequest(h, http.MethodPost, path, `{"model":"m","tools":[{"type":"mcp","server_label":"x","transport":{"type":"http","server_url":"https://example.invalid","authorization":"x"}}]}`), "invalid_request_error", param("tools[0].transport.authorization"), "Unknown parameter: 'tools[0].transport.authorization'.") assertConfigurationError(t, credentialRequest(h, http.MethodPost, path, `{"model":"m","model":"n"}`), "invalid_request_error", nil, "Invalid body: duplicate JSON key 'model' at 'model'. Duplicate JSON keys are not supported.") } // Saved Agent creation requires a model; updates and Session overrides do not. @@ -267,6 +275,9 @@ func TestAgentConfigurationLocalLimitsKeepCodes(t *testing.T) { for _, op := range configurationOperations()[:2] { 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.") } + for _, op := range configurationOperations()[:3] { + assertConfigurationError(t, credentialRequest(h, http.MethodPost, op.path, op.body(`"tools":[{"type":"mcp","server_label":"x","transport":{"type":"stdio","command":"run","cwd":"/"}}]`)), "unsupported_or_invalid_configuration", nil, "MCP currently supports HTTP transport only.") + } if s.writes != 0 { t.Fatal("rejected configuration reached storage") } diff --git a/services/core/internal/api/disabled_tools.go b/services/core/internal/api/disabled_tools.go index b52f3d9a3..884f8092d 100644 --- a/services/core/internal/api/disabled_tools.go +++ b/services/core/internal/api/disabled_tools.go @@ -5,31 +5,28 @@ import ( "errors" ) -func resolveProgrammaticTool(raw json.RawMessage) (json.RawMessage, error) { - var input struct { - Type string `json:"type"` - Enabled json.RawMessage `json:"enabled"` - } - if decodeInputObject(raw, &input, "type", "enabled") != nil { - return nil, errors.New("Invalid programmatic_tool_calling fields.") - } - enabled, err := optionalBoolean(input.Enabled, true) - if err != nil { - return nil, errors.New("programmatic_tool_calling.enabled must be a boolean.") - } - return json.Marshal(struct { +// Tool resolvers read tools whose pinned shape was checked at the /v1 boundary: +// request tools, or a saved Agent's tools that resolveSavedTools stored. A +// decode failure is Core's own fault and is reported as a service error. +func resolveProgrammaticTool(raw json.RawMessage) (json.RawMessage, bool, error) { + input := struct { Type string `json:"type"` Enabled bool `json:"enabled"` - }{input.Type, enabled}) + }{Enabled: true} + if err := json.Unmarshal(raw, &input); err != nil { + return nil, false, &storedDataError{err} + } + value, err := json.Marshal(input) + return value, input.Enabled, err } // webSearchTool is the resolved web_search projection. A present location // projects all four keys; null and empty allowed_domains stay distinct. type webSearchTool struct { - Type string `json:"type"` - Mode *string `json:"mode"` - ContextSize *string `json:"context_size"` - AllowedDomains []*string `json:"allowed_domains"` + Type string `json:"type"` + Mode *string `json:"mode"` + ContextSize *string `json:"context_size"` + AllowedDomains []string `json:"allowed_domains"` Location *struct { City *string `json:"city"` Country *string `json:"country"` @@ -38,27 +35,12 @@ type webSearchTool struct { } `json:"location"` } -func decodeWebSearch(raw json.RawMessage) (webSearchTool, bool) { - var tool webSearchTool - return tool, decodeInputObject(raw, &tool, "type", "mode", "context_size", "allowed_domains", "location") == nil -} - // Optional settings are resource data in every mode; they never enable execution. func (tool webSearchTool) resolveSettings() (json.RawMessage, error) { if tool.ContextSize == nil { value := "medium" tool.ContextSize = &value } - for _, domain := range tool.AllowedDomains { - if domain == nil { - return nil, errors.New("web_search.allowed_domains must contain strings.") - } - } - switch *tool.ContextSize { - case "low", "medium", "high": - default: - return nil, errors.New("Invalid web_search.context_size.") - } return json.Marshal(tool) } @@ -66,26 +48,24 @@ func (tool webSearchTool) resolveSettings() (json.RawMessage, error) { // or null mode is saved as live. Session admission still qualifies only disabled // search (resolveDisabledWebSearch), so saving never enables execution. func resolveSavedWebSearch(raw json.RawMessage) (json.RawMessage, error) { - tool, ok := decodeWebSearch(raw) - if !ok { - return nil, errors.New("Invalid web_search fields.") + var tool webSearchTool + if err := json.Unmarshal(raw, &tool); err != nil { + return nil, &storedDataError{err} } if tool.Mode == nil { value := "live" tool.Mode = &value } - switch *tool.Mode { - case "disabled", "cached", "live": - default: - return nil, errors.New("web_search.mode must be disabled, cached or live.") - } return tool.resolveSettings() } // Only disabled search is qualified for execution. func resolveDisabledWebSearch(raw json.RawMessage) (json.RawMessage, error) { - tool, ok := decodeWebSearch(raw) - if !ok || tool.Mode == nil || *tool.Mode != "disabled" { + var tool webSearchTool + if err := json.Unmarshal(raw, &tool); err != nil { + return nil, &storedDataError{err} + } + if tool.Mode == nil || *tool.Mode != "disabled" { return nil, errors.New("Only disabled web_search is qualified for execution.") } return tool.resolveSettings() diff --git a/services/core/internal/api/errors.go b/services/core/internal/api/errors.go index 676d51ee0..2e92da06d 100644 --- a/services/core/internal/api/errors.go +++ b/services/core/internal/api/errors.go @@ -105,6 +105,28 @@ func writeInternalError(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "internal_error", "The operation could not be completed.") } +// storedDataError carries a failure of Core's own stored or resolved +// configuration on the Agent and Session paths: a saved configuration or tool +// that Core resolved or stored and that does not decode, or a storage or +// decryption failure while reading the deployment default model provider. It +// is never the client's input, so it is never echoed in a response. +type storedDataError struct{ err error } + +func (e *storedDataError) Error() string { return e.err.Error() } +func (e *storedDataError) Unwrap() error { return e.err } + +// writeStoredDataError reports a storedDataError as a service error and returns +// false for any other error. The underlying failure is logged for diagnosis. +func writeStoredDataError(w http.ResponseWriter, r *http.Request, err error) bool { + var stored *storedDataError + if !errors.As(err, &stored) { + return false + } + log.Ctx(r.Context()).Error("oac-core stored data failed", "error", stored.err) + writeModelConfigurationError(w, r, stored.err) + return true +} + // writeTextValueError reports request text that PostgreSQL cannot store and // returns false for any other error. It is a documented local limit: text and // jsonb cannot store U+0000, and text parameters, including query filters, diff --git a/services/core/internal/api/errors_agents.go b/services/core/internal/api/errors_agents.go index 1e891cdc3..3fc3a8df4 100644 --- a/services/core/internal/api/errors_agents.go +++ b/services/core/internal/api/errors_agents.go @@ -10,7 +10,7 @@ import ( // writeAgentsError reports an error of the Agent operations. func writeAgentsError(w http.ResponseWriter, r *http.Request, err error) { - if writeTextValueError(w, r, err) || writeAuditSourceError(w, r, err) || writeCredentialUnavailableError(w, r, err) { + if writeStoredDataError(w, r, err) || writeTextValueError(w, r, err) || writeAuditSourceError(w, r, err) || writeCredentialUnavailableError(w, r, err) { return } switch { diff --git a/services/core/internal/api/function_configuration.go b/services/core/internal/api/function_configuration.go index 6d013317c..0674a43f5 100644 --- a/services/core/internal/api/function_configuration.go +++ b/services/core/internal/api/function_configuration.go @@ -1,7 +1,6 @@ package api import ( - "bytes" "encoding/json" "errors" "strings" @@ -16,51 +15,28 @@ func resolveFunctions(input []v1.FunctionToolInput) ([]json.RawMessage, error) { } names := make(map[string]bool, len(input)) for _, tool := range input { - value, _, err := resolveFunction(tool) + if strings.TrimSpace(tool.Name) == "" || len(tool.Name) > 512 || names[tool.Name] { + return nil, errors.New("Function names must be nonempty, unique and at most 512 bytes.") + } + value, err := resolveFunction(tool) if err != nil { return nil, err } - if strings.TrimSpace(*tool.Name) == "" || len(*tool.Name) > 512 || names[*tool.Name] { - return nil, errors.New("Function names must be nonempty, unique and at most 512 bytes.") - } - - names[*tool.Name] = true + names[tool.Name] = true tools = append(tools, value) } return tools, nil } -// resolveFunction validates the persisted wire shape. Execution admission may -// impose additional restrictions, without narrowing the reusable resource. -func resolveFunction(tool v1.FunctionToolInput) (json.RawMessage, bool, error) { - if tool.Type != "function" || tool.Name == nil || tool.Description == nil { - return nil, false, errors.New("Function tools require type=function, name and description.") - } - var schema map[string]json.RawMessage - if json.Unmarshal(tool.Parameters, &schema) != nil || schema == nil { - return nil, false, errors.New("Function parameters must be a JSON Schema object.") - } - deferred, err := optionalBoolean(tool.DeferLoading, false) - if err != nil { - return nil, false, errors.New("defer_loading must be a boolean when supplied.") - } - value, err := json.Marshal(struct { +// resolveFunction canonicalizes a function whose pinned shape was checked at +// the /v1 boundary. Execution admission may impose additional restrictions, +// without narrowing the reusable resource. +func resolveFunction(tool v1.FunctionToolInput) (json.RawMessage, error) { + return json.Marshal(struct { Type string `json:"type"` Name string `json:"name"` Description string `json:"description"` Parameters json.RawMessage `json:"parameters"` DeferLoading bool `json:"defer_loading"` - }{"function", *tool.Name, *tool.Description, tool.Parameters, deferred}) - return value, deferred, err -} - -func optionalBoolean(raw json.RawMessage, fallback bool) (bool, error) { - if len(raw) == 0 { - return fallback, nil - } - var value bool - if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) || json.Unmarshal(raw, &value) != nil { - return false, errors.New("Expected a boolean.") - } - return value, nil + }{"function", tool.Name, tool.Description, tool.Parameters, tool.DeferLoading != nil && *tool.DeferLoading}) } diff --git a/services/core/internal/api/handler.go b/services/core/internal/api/handler.go index 2e358785e..33d76be58 100644 --- a/services/core/internal/api/handler.go +++ b/services/core/internal/api/handler.go @@ -232,12 +232,10 @@ func (h *Handler) createSession(w http.ResponseWriter, r *http.Request) { return } var required *modelProviderRequiredError - var defaults *modelProviderDefaultsError switch { case errors.As(err, &required): writeError(w, http.StatusBadRequest, "model_provider_required", required.message, "x_agents_core.model_provider") - case errors.As(err, &defaults): - writeModelConfigurationError(w, r, defaults.err) + case writeStoredDataError(w, r, err): case !writeFieldError(w, err): writeError(w, http.StatusBadRequest, "unsupported_or_invalid_configuration", err.Error()) } diff --git a/services/core/internal/api/mcp_configuration.go b/services/core/internal/api/mcp_configuration.go index e856bdb55..6af603b52 100644 --- a/services/core/internal/api/mcp_configuration.go +++ b/services/core/internal/api/mcp_configuration.go @@ -11,92 +11,63 @@ import ( const mcpHTTPOnly = "MCP currently supports HTTP transport only." +// resolveMCPTool canonicalizes a declaration whose pinned shape was checked at +// decode. Core executes HTTP servers only and keeps its local limits. func resolveMCPTool(raw json.RawMessage, saved bool) (json.RawMessage, error) { var input v1.MCPToolInput - if decodeInputObject(raw, &input, "type", "server_label", "transport", "allowed_tools", "connection_origin", "credential_id", "request_metadata", "required") != nil { - return nil, errors.New("Invalid MCP tool fields.") + var transport struct { + Type string `json:"type"` + ServerURL string `json:"server_url"` + Authorization *string `json:"authorization"` + Headers map[string]string `json:"headers"` + } + if err := errors.Join(json.Unmarshal(raw, &input), json.Unmarshal(input.Transport, &transport)); err != nil { + return nil, &storedDataError{err} } - if input.Type != "mcp" || input.ServerLabel == nil || strings.TrimSpace(*input.ServerLabel) == "" { + if strings.TrimSpace(input.ServerLabel) == "" { return nil, errors.New("MCP tools require type=mcp and a nonempty server_label.") } + if transport.Type != "http" { + return nil, errors.New(mcpHTTPOnly) + } // The official service saves an omitted or null origin on an HTTP server as // "service" (MV-01). Defaulting it here makes the stored and frozen - // configuration identical to an explicit declaration. Other transports are - // unsupported with any origin. - if input.ConnectionOrigin == nil { - if !mcpHTTPTransport(input.Transport) { - return nil, errors.New(mcpHTTPOnly) - } - service := "service" - input.ConnectionOrigin = &service - } - if *input.ConnectionOrigin != "service" && *input.ConnectionOrigin != "environment" { - return nil, errors.New("MCP connection_origin must be service or environment.") + // configuration identical to an explicit declaration. + origin := "service" + if input.ConnectionOrigin != nil { + origin = *input.ConnectionOrigin } if input.CredentialID != nil && *input.CredentialID == "" { return nil, errors.New("MCP credential_id must be null or a nonempty string.") } - required, err := optionalBoolean(input.Required, false) - if err != nil { - return nil, errors.New("MCP required must be a boolean.") - } - if !emptyMCPObject(input.RequestMetadata) { + if len(input.RequestMetadata) != 0 { return nil, errors.New("Nonempty MCP request_metadata is not supported yet.") } - var transport struct { - Type string `json:"type"` - ServerURL *string `json:"server_url"` - Headers json.RawMessage `json:"headers"` - } - if decodeInputObject(input.Transport, &transport, "type", "server_url", "headers") != nil || transport.Type != "http" || transport.ServerURL == nil { - return nil, errors.New(mcpHTTPOnly) - } - u, err := url.Parse(*transport.ServerURL) + u, err := url.Parse(transport.ServerURL) if err != nil || u.Hostname() == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.Fragment != "" || u.RawQuery != "" || u.ForceQuery { return nil, errors.New("MCP server_url must be an absolute HTTP(S) URL without credentials, query or fragment.") } - if !emptyMCPObject(transport.Headers) { + if transport.Authorization != nil { + return nil, errors.New("Inline MCP authorization is not supported yet.") + } + if len(transport.Headers) != 0 { return nil, errors.New("Nonempty MCP headers are not supported yet.") } var allowed *[]string - if len(input.AllowedTools) != 0 { - var values *[]*string - if json.Unmarshal(input.AllowedTools, &values) != nil { - return nil, errors.New("MCP allowed_tools must be null or an array of tool names.") - } - if values != nil { - names := make([]string, 0, len(*values)) - for _, name := range *values { - if name == nil || *name == "" { - return nil, errors.New("MCP allowed_tools requires nonempty string names.") - } - names = append(names, *name) + if input.AllowedTools != nil { + for _, name := range input.AllowedTools { + if name == "" { + return nil, errors.New("MCP allowed_tools requires nonempty string names.") } - allowed = &names } + allowed = &input.AllowedTools } - tool := v1.MCPTool{Type: "mcp", ServerLabel: *input.ServerLabel, - Transport: v1.MCPHTTPTransport{Type: "http", ServerURL: *transport.ServerURL}, - AllowedTools: allowed, Required: required, ConnectionOrigin: *input.ConnectionOrigin, CredentialID: input.CredentialID, RequestMetadata: map[string]json.RawMessage{}} + tool := v1.MCPTool{Type: "mcp", ServerLabel: input.ServerLabel, + Transport: v1.MCPHTTPTransport{Type: "http", ServerURL: transport.ServerURL}, + AllowedTools: allowed, Required: input.Required != nil && *input.Required, ConnectionOrigin: origin, CredentialID: input.CredentialID, RequestMetadata: map[string]json.RawMessage{}} if saved { headers := map[string]string{} tool.Transport.Headers = &headers } return json.Marshal(tool) } - -// mcpHTTPTransport reports a transport object whose exact "type" member is -// "http". The complete transport is validated afterwards. -func mcpHTTPTransport(raw json.RawMessage) bool { - var fields map[string]json.RawMessage - var kind string - return json.Unmarshal(raw, &fields) == nil && json.Unmarshal(fields["type"], &kind) == nil && kind == "http" -} - -func emptyMCPObject(raw json.RawMessage) bool { - if len(raw) == 0 { - return true - } - var value map[string]json.RawMessage - return json.Unmarshal(raw, &value) == nil && len(value) == 0 -} diff --git a/services/core/internal/api/mcp_configuration_test.go b/services/core/internal/api/mcp_configuration_test.go index 49ecc87e7..f0013cafb 100644 --- a/services/core/internal/api/mcp_configuration_test.go +++ b/services/core/internal/api/mcp_configuration_test.go @@ -70,7 +70,7 @@ func TestMCPOmittedOriginIsService(t *testing.T) { } // Other transports report the transport restriction with any origin. for _, origin := range []string{"", `,"connection_origin":null`, `,"connection_origin":"service"`} { - input := `{"type":"mcp","server_label":"records"` + origin + `,"transport":{"type":"stdio","command":"run"}}` + input := `{"type":"mcp","server_label":"records"` + origin + `,"transport":{"type":"stdio","command":"run","cwd":"/"}}` if _, err := resolveMCPTool(json.RawMessage(input), true); err == nil || err.Error() != "MCP currently supports HTTP transport only." { t.Fatalf("stdio origin %q: %v", origin, err) } @@ -85,6 +85,7 @@ func TestMCPAllowedToolsAndOptionalFields(t *testing.T) { } input["allowed_tools"] = json.RawMessage(allowed) input["credential_id"], input["request_metadata"], input["required"] = json.RawMessage("null"), json.RawMessage("null"), json.RawMessage("false") + input["transport"] = json.RawMessage(`{"type":"http","server_url":"https://mcp.example.test/tools","authorization":null,"headers":null}`) encoded, _ := json.Marshal(input) resolved, err := resolveMCPTool(encoded, false) if err != nil { @@ -97,24 +98,18 @@ func TestMCPAllowedToolsAndOptionalFields(t *testing.T) { } } +// The pinned shape is checked at decode; these inputs match it. func TestMCPUnsupportedInputsAreSecretSafe(t *testing.T) { for name, replacement := range map[string]map[string]json.RawMessage{ - "unknown origin": {"connection_origin": json.RawMessage(`"unknown"`)}, - "stdio origin missing": {"connection_origin": nil, "transport": json.RawMessage(`{"type":"stdio","command":"private-marker"}`)}, - "stdio origin null": {"connection_origin": json.RawMessage("null"), "transport": json.RawMessage(`{"type":"stdio","command":"private-marker"}`)}, - "origin missing, case": {"connection_origin": nil, "transport": json.RawMessage(`{"Type":"http","server_url":"https://mcp.example.test"}`)}, - "required type": {"required": json.RawMessage(`"true"`)}, - "required null": {"required": json.RawMessage("null")}, + "stdio origin missing": {"connection_origin": nil, "transport": json.RawMessage(`{"type":"stdio","command":"private-marker","cwd":"/"}`)}, + "stdio origin null": {"connection_origin": json.RawMessage("null"), "transport": json.RawMessage(`{"type":"stdio","command":"private-marker","cwd":"/"}`)}, "empty credential": {"credential_id": json.RawMessage(`""`)}, - "credential type": {"credential_id": json.RawMessage(`3`)}, "metadata": {"request_metadata": json.RawMessage(`{"private-marker":"value"}`)}, - "null tool name": {"allowed_tools": json.RawMessage(`[null]`)}, - "wrong allow-list": {"allowed_tools": json.RawMessage(`"lookup"`)}, "inline authorization": {"transport": json.RawMessage(`{"type":"http","server_url":"https://mcp.example.test","authorization":"private-marker"}`)}, "headers": {"transport": json.RawMessage(`{"type":"http","server_url":"https://mcp.example.test","headers":{"Authorization":"private-marker"}}`)}, "URL credentials": {"transport": json.RawMessage(`{"type":"http","server_url":"https://private-marker@mcp.example.test"}`)}, "URL query": {"transport": json.RawMessage(`{"type":"http","server_url":"https://mcp.example.test/?token=private-marker"}`)}, - "stdio": {"transport": json.RawMessage(`{"type":"stdio","command":"private-marker"}`)}, + "stdio": {"transport": json.RawMessage(`{"type":"stdio","command":"private-marker","cwd":"/"}`)}, } { t.Run(name, func(t *testing.T) { var input map[string]json.RawMessage diff --git a/services/core/internal/api/official_shapes.gen.go b/services/core/internal/api/official_shapes.gen.go new file mode 100644 index 000000000..b4ba45f34 --- /dev/null +++ b/services/core/internal/api/official_shapes.gen.go @@ -0,0 +1,210 @@ +// Code generated by scripts/generate-public-api.py; DO NOT EDIT. +package api + +// Pinned request shapes; x_agents_core is checked by its own parser. +var ( + createAgentParams = shape{kind: objectValue, members: []member{ + {"metadata", shape{kind: mapValue, items: &shape{kind: stringValue}, nullable: true}}, + {"name", shape{kind: stringValue, nullable: true}}, + {"model", shape{kind: stringValue, required: true}}, + {"reasoning", shape{kind: objectValue, members: []member{ + {"effort", shape{kind: enumValue, values: []string{"none", "minimal", "low", "medium", "high", "xhigh", "max"}, nullable: true}}, + {"summary", shape{kind: enumValue, values: []string{"concise", "detailed", "auto"}, nullable: true}}, + }, nullable: true}}, + {"text", shape{kind: objectValue, members: []member{ + {"format", shape{kind: unionValue, values: []string{"text", "json_schema"}, variants: map[string][]member{ + "text": {}, + "json_schema": { + {"schema", shape{kind: mapValue, required: true}}, + }, + }, nullable: true}}, + {"verbosity", shape{kind: enumValue, values: []string{"low", "medium", "high"}, nullable: true}}, + }, nullable: true}}, + {"service_tier", shape{kind: enumValue, values: []string{"auto", "default", "flex", "priority", "fast"}, nullable: true}}, + {"instructions", shape{kind: stringValue, nullable: true}}, + {"tools", shape{kind: arrayValue, items: &shape{kind: unionValue, values: []string{"function", "tool_search", "programmatic_tool_calling", "mcp", "web_search"}, variants: map[string][]member{ + "function": { + {"name", shape{kind: stringValue, required: true}}, + {"description", shape{kind: stringValue, required: true}}, + {"parameters", shape{kind: mapValue, required: true}}, + {"defer_loading", shape{kind: booleanValue}}, + }, + "tool_search": {}, + "programmatic_tool_calling": { + {"enabled", shape{kind: booleanValue}}, + }, + "mcp": { + {"server_label", shape{kind: stringValue, required: true}}, + {"credential_id", shape{kind: stringValue, nullable: true}}, + {"transport", shape{kind: unionValue, values: []string{"http", "stdio"}, variants: map[string][]member{ + "http": { + {"server_url", shape{kind: stringValue, required: true}}, + {"headers", shape{kind: mapValue, items: &shape{kind: stringValue}, nullable: true}}, + }, + "stdio": { + {"command", shape{kind: stringValue, required: true}}, + {"args", shape{kind: arrayValue, items: &shape{kind: stringValue}, nullable: true}}, + {"cwd", shape{kind: stringValue, required: true}}, + {"env_vars", shape{kind: arrayValue, items: &shape{kind: stringValue}, nullable: true}}, + }, + }, required: true}}, + {"request_metadata", shape{kind: mapValue, nullable: true}}, + {"allowed_tools", shape{kind: arrayValue, items: &shape{kind: stringValue}, nullable: true}}, + {"required", shape{kind: booleanValue}}, + {"connection_origin", shape{kind: enumValue, values: []string{"service", "environment"}, nullable: true}}, + }, + "web_search": { + {"mode", shape{kind: enumValue, values: []string{"disabled", "cached", "live"}, nullable: true}}, + {"context_size", shape{kind: enumValue, values: []string{"low", "medium", "high"}, nullable: true}}, + {"allowed_domains", shape{kind: arrayValue, items: &shape{kind: stringValue}, nullable: true}}, + {"location", shape{kind: objectValue, members: []member{ + {"country", shape{kind: stringValue, nullable: true}}, + {"region", shape{kind: stringValue, nullable: true}}, + {"city", shape{kind: stringValue, nullable: true}}, + {"timezone", shape{kind: stringValue, nullable: true}}, + }, nullable: true}}, + }, + }}, nullable: true}}, + {"multi_agent", shape{kind: objectValue, members: []member{ + {"enabled", shape{kind: booleanValue, required: true}}, + {"max_concurrent_subagents", shape{kind: integerValue, minimum: 1}}, + }, nullable: true}}, + {"x_agents_core", shape{}}, + }} + updateAgentParams = shape{kind: objectValue, members: []member{ + {"model", shape{kind: stringValue}}, + {"reasoning", shape{kind: objectValue, members: []member{ + {"effort", shape{kind: enumValue, values: []string{"none", "minimal", "low", "medium", "high", "xhigh", "max"}, nullable: true}}, + {"summary", shape{kind: enumValue, values: []string{"concise", "detailed", "auto"}, nullable: true}}, + }, nullable: true}}, + {"text", shape{kind: objectValue, members: []member{ + {"format", shape{kind: unionValue, values: []string{"text", "json_schema"}, variants: map[string][]member{ + "text": {}, + "json_schema": { + {"schema", shape{kind: mapValue, required: true}}, + }, + }, nullable: true}}, + {"verbosity", shape{kind: enumValue, values: []string{"low", "medium", "high"}, nullable: true}}, + }, nullable: true}}, + {"service_tier", shape{kind: enumValue, values: []string{"auto", "default", "flex", "priority", "fast"}, nullable: true}}, + {"instructions", shape{kind: stringValue, nullable: true}}, + {"multi_agent", shape{kind: objectValue, members: []member{ + {"enabled", shape{kind: booleanValue, required: true}}, + {"max_concurrent_subagents", shape{kind: integerValue, minimum: 1}}, + }, nullable: true}}, + {"metadata", shape{kind: mapValue, items: &shape{kind: stringValue}, nullable: true}}, + {"name", shape{kind: stringValue, nullable: true}}, + {"tools", shape{kind: arrayValue, items: &shape{kind: unionValue, values: []string{"function", "tool_search", "programmatic_tool_calling", "mcp", "web_search"}, variants: map[string][]member{ + "function": { + {"name", shape{kind: stringValue, required: true}}, + {"description", shape{kind: stringValue, required: true}}, + {"parameters", shape{kind: mapValue, required: true}}, + {"defer_loading", shape{kind: booleanValue}}, + }, + "tool_search": {}, + "programmatic_tool_calling": { + {"enabled", shape{kind: booleanValue}}, + }, + "mcp": { + {"server_label", shape{kind: stringValue, required: true}}, + {"credential_id", shape{kind: stringValue, nullable: true}}, + {"transport", shape{kind: unionValue, values: []string{"http", "stdio"}, variants: map[string][]member{ + "http": { + {"server_url", shape{kind: stringValue, required: true}}, + {"headers", shape{kind: mapValue, items: &shape{kind: stringValue}, nullable: true}}, + }, + "stdio": { + {"command", shape{kind: stringValue, required: true}}, + {"args", shape{kind: arrayValue, items: &shape{kind: stringValue}, nullable: true}}, + {"cwd", shape{kind: stringValue, required: true}}, + {"env_vars", shape{kind: arrayValue, items: &shape{kind: stringValue}, nullable: true}}, + }, + }, required: true}}, + {"request_metadata", shape{kind: mapValue, nullable: true}}, + {"allowed_tools", shape{kind: arrayValue, items: &shape{kind: stringValue}, nullable: true}}, + {"required", shape{kind: booleanValue}}, + {"connection_origin", shape{kind: enumValue, values: []string{"service", "environment"}, nullable: true}}, + }, + "web_search": { + {"mode", shape{kind: enumValue, values: []string{"disabled", "cached", "live"}, nullable: true}}, + {"context_size", shape{kind: enumValue, values: []string{"low", "medium", "high"}, nullable: true}}, + {"allowed_domains", shape{kind: arrayValue, items: &shape{kind: stringValue}, nullable: true}}, + {"location", shape{kind: objectValue, members: []member{ + {"country", shape{kind: stringValue, nullable: true}}, + {"region", shape{kind: stringValue, nullable: true}}, + {"city", shape{kind: stringValue, nullable: true}}, + {"timezone", shape{kind: stringValue, nullable: true}}, + }, nullable: true}}, + }, + }}, nullable: true}}, + {"x_agents_core", shape{}}, + }} + sessionAgentConfigParam = shape{kind: objectValue, members: []member{ + {"model", shape{kind: stringValue}}, + {"reasoning", shape{kind: objectValue, members: []member{ + {"effort", shape{kind: enumValue, values: []string{"none", "minimal", "low", "medium", "high", "xhigh", "max"}, nullable: true}}, + {"summary", shape{kind: enumValue, values: []string{"concise", "detailed", "auto"}, nullable: true}}, + }, nullable: true}}, + {"text", shape{kind: objectValue, members: []member{ + {"format", shape{kind: unionValue, values: []string{"text", "json_schema"}, variants: map[string][]member{ + "text": {}, + "json_schema": { + {"schema", shape{kind: mapValue, required: true}}, + }, + }, nullable: true}}, + {"verbosity", shape{kind: enumValue, values: []string{"low", "medium", "high"}, nullable: true}}, + }, nullable: true}}, + {"service_tier", shape{kind: enumValue, values: []string{"auto", "default", "flex", "priority", "fast"}, nullable: true}}, + {"instructions", shape{kind: stringValue, nullable: true}}, + {"multi_agent", shape{kind: objectValue, members: []member{ + {"enabled", shape{kind: booleanValue, required: true}}, + {"max_concurrent_subagents", shape{kind: integerValue, minimum: 1}}, + }, nullable: true}}, + {"tools", shape{kind: arrayValue, items: &shape{kind: unionValue, values: []string{"function", "tool_search", "programmatic_tool_calling", "mcp", "web_search"}, variants: map[string][]member{ + "function": { + {"name", shape{kind: stringValue, required: true}}, + {"description", shape{kind: stringValue, required: true}}, + {"parameters", shape{kind: mapValue, required: true}}, + {"defer_loading", shape{kind: booleanValue}}, + }, + "tool_search": {}, + "programmatic_tool_calling": { + {"enabled", shape{kind: booleanValue}}, + }, + "mcp": { + {"server_label", shape{kind: stringValue, required: true}}, + {"credential_id", shape{kind: stringValue, nullable: true}}, + {"transport", shape{kind: unionValue, values: []string{"http", "stdio"}, variants: map[string][]member{ + "http": { + {"server_url", shape{kind: stringValue, required: true}}, + {"authorization", shape{kind: stringValue, nullable: true}}, + {"headers", shape{kind: mapValue, items: &shape{kind: stringValue}, nullable: true}}, + }, + "stdio": { + {"command", shape{kind: stringValue, required: true}}, + {"args", shape{kind: arrayValue, items: &shape{kind: stringValue}, nullable: true}}, + {"cwd", shape{kind: stringValue, required: true}}, + {"env", shape{kind: mapValue, items: &shape{kind: stringValue}, nullable: true}}, + {"env_vars", shape{kind: arrayValue, items: &shape{kind: stringValue}, nullable: true}}, + }, + }, required: true}}, + {"request_metadata", shape{kind: mapValue, nullable: true}}, + {"allowed_tools", shape{kind: arrayValue, items: &shape{kind: stringValue}, nullable: true}}, + {"required", shape{kind: booleanValue}}, + {"connection_origin", shape{kind: enumValue, values: []string{"service", "environment"}, nullable: true}}, + }, + "web_search": { + {"mode", shape{kind: enumValue, values: []string{"disabled", "cached", "live"}, nullable: true}}, + {"context_size", shape{kind: enumValue, values: []string{"low", "medium", "high"}, nullable: true}}, + {"allowed_domains", shape{kind: arrayValue, items: &shape{kind: stringValue}, nullable: true}}, + {"location", shape{kind: objectValue, members: []member{ + {"country", shape{kind: stringValue, nullable: true}}, + {"region", shape{kind: stringValue, nullable: true}}, + {"city", shape{kind: stringValue, nullable: true}}, + {"timezone", shape{kind: stringValue, nullable: true}}, + }, nullable: true}}, + }, + }}, nullable: true}}, + {"x_agents_core", shape{}}, + }} +) diff --git a/services/core/internal/api/saved_configuration.go b/services/core/internal/api/saved_configuration.go index 37463d1bc..167e04753 100644 --- a/services/core/internal/api/saved_configuration.go +++ b/services/core/internal/api/saved_configuration.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "fmt" - "slices" "unicode/utf8" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" @@ -47,19 +46,10 @@ func resolveSavedFields(input v1.CreateAgentRequest) (agents.CreateCommand, erro return agents.CreateCommand{}, err } if input.ServiceTier != nil { - if !slices.Contains([]string{"auto", "default", "flex", "priority", "fast"}, *input.ServiceTier) { - return agents.CreateCommand{}, errors.New("service_tier must be auto, default, flex, priority or fast.") - } cfg.ServiceTier = *input.ServiceTier } if input.Reasoning != nil { cfg.Reasoning = *input.Reasoning - if cfg.Reasoning.Effort != nil && !slices.Contains([]string{"none", "minimal", "low", "medium", "high", "xhigh", "max"}, *cfg.Reasoning.Effort) { - return agents.CreateCommand{}, errors.New("reasoning.effort is not a supported protocol value.") - } - if cfg.Reasoning.Summary != nil && !slices.Contains([]string{"concise", "detailed", "auto"}, *cfg.Reasoning.Summary) { - return agents.CreateCommand{}, errors.New("reasoning.summary must be concise, detailed or auto.") - } } // Model-derived effort resolution is a recorded gap. Do not manufacture a // default from the operator's execution engine or another model's catalog. @@ -79,25 +69,23 @@ func resolveSavedFields(input v1.CreateAgentRequest) (agents.CreateCommand, erro return result, err } +// resolveSavedMultiAgent and resolveText read values whose pinned shape was +// checked at decode; only the uint32 bound of max_concurrent_subagents remains. func resolveSavedMultiAgent(raw json.RawMessage) (v1.MultiAgentConfig, error) { result := v1.MultiAgentConfig{} if len(raw) == 0 || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { return result, nil } - var input struct { - Enabled *bool `json:"enabled"` - Max json.RawMessage `json:"max_concurrent_subagents"` - } - if decodeInputObject(raw, &input, "enabled", "max_concurrent_subagents") != nil || input.Enabled == nil { - return result, errors.New("multi_agent requires enabled as a boolean.") - } - maximum := uint32(6) - if len(input.Max) > 0 && (bytes.Equal(bytes.TrimSpace(input.Max), []byte("null")) || json.Unmarshal(input.Max, &maximum) != nil || maximum == 0) { + input := struct { + Enabled bool `json:"enabled"` + Max uint32 `json:"max_concurrent_subagents"` + }{Max: 6} + if json.Unmarshal(raw, &input) != nil { return result, errors.New("max_concurrent_subagents must be an integer from 1 to 4294967295.") } - result.Enabled = *input.Enabled + result.Enabled = input.Enabled if result.Enabled { - value := int(maximum) + value := int(input.Max) result.MaxConcurrentSubagents = &value } return result, nil @@ -109,37 +97,13 @@ func resolveText(input *v1.TextConfigInput) (v1.TextConfig, error) { return result, nil } if input.Verbosity != nil { - if err := validateTextVerbosity(*input.Verbosity); err != nil { - return result, err - } result.Verbosity = *input.Verbosity } if len(input.Format) == 0 || bytes.Equal(bytes.TrimSpace(input.Format), []byte("null")) { return result, nil } - result.Format = v1.TextFormat{} - if decodeInputObject(input.Format, &result.Format, "type", "schema") != nil { - return result, errors.New("text.format must be a supported format object.") - } - switch result.Format.Type { - case "text": - if len(result.Format.Schema) > 0 { - return result, errors.New("text format does not accept schema.") - } - case "json_schema": - var schema map[string]json.RawMessage - if json.Unmarshal(result.Format.Schema, &schema) != nil || schema == nil { - return result, errors.New("json_schema format requires a schema object.") - } - default: - return result, errors.New("text.format.type must be text or json_schema.") + if err := json.Unmarshal(input.Format, &result.Format); err != nil { + return result, &storedDataError{err} } return result, nil } - -func validateTextVerbosity(value string) error { - if !slices.Contains([]string{"low", "medium", "high"}, value) { - return errors.New("text.verbosity must be low, medium or high.") - } - return nil -} diff --git a/services/core/internal/api/saved_tools.go b/services/core/internal/api/saved_tools.go index 707e23253..e15a41c7e 100644 --- a/services/core/internal/api/saved_tools.go +++ b/services/core/internal/api/saved_tools.go @@ -7,34 +7,34 @@ import ( v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" ) +// resolveSavedTools canonicalizes tools whose pinned shape was checked at the +// /v1 boundary. The default case guards a pinned tool type that Core does not +// resolve yet. func resolveSavedTools(input []json.RawMessage) ([]json.RawMessage, error) { tools := make([]json.RawMessage, 0, len(input)) for _, raw := range input { var kind struct { Type string `json:"type"` } - if json.Unmarshal(raw, &kind) != nil { - return nil, errors.New("tools must contain tool objects.") + if err := json.Unmarshal(raw, &kind); err != nil { + return nil, &storedDataError{err} } var value json.RawMessage switch kind.Type { case "function": var function v1.FunctionToolInput - if decodeInputObject(raw, &function, "type", "name", "description", "parameters", "defer_loading") != nil { - return nil, errors.New("Invalid function tool fields.") + if err := json.Unmarshal(raw, &function); err != nil { + return nil, &storedDataError{err} } - resolved, _, err := resolveFunction(function) + resolved, err := resolveFunction(function) if err != nil { return nil, err } value = resolved case "tool_search": - if decodeInputObject(raw, &kind, "type") != nil { - return nil, errors.New("tool_search only accepts type.") - } value, _ = json.Marshal(kind) case "programmatic_tool_calling": - resolved, err := resolveProgrammaticTool(raw) + resolved, _, err := resolveProgrammaticTool(raw) if err != nil { return nil, err } diff --git a/services/core/internal/api/session_agent.go b/services/core/internal/api/session_agent.go index bd95dfa65..279a25894 100644 --- a/services/core/internal/api/session_agent.go +++ b/services/core/internal/api/session_agent.go @@ -36,7 +36,7 @@ func resolveSessionAgent(input sessionRequest, saved *v1.SavedAgent) (v1.Agent, } var override v1.SavedAgentConfiguration if err := json.Unmarshal(resolved.Configuration, &override); err != nil { - return v1.Agent{}, err + return v1.Agent{}, &storedDataError{err} } cfg := override if saved != nil { @@ -84,9 +84,6 @@ func admitSessionAgent(cfg v1.SavedAgentConfiguration) (v1.Agent, error) { if cfg.ServiceTier != "auto" { return v1.Agent{}, errors.New("Execution currently supports service_tier=auto only.") } - if err := validateTextVerbosity(cfg.Text.Verbosity); err != nil { - return v1.Agent{}, err - } tools, err := resolveSessionTools(cfg.Tools) if err != nil { return v1.Agent{}, err diff --git a/services/core/internal/api/session_model_configuration.go b/services/core/internal/api/session_model_configuration.go index 4cde53a67..5134a4528 100644 --- a/services/core/internal/api/session_model_configuration.go +++ b/services/core/internal/api/session_model_configuration.go @@ -41,7 +41,7 @@ func (h *Handler) prepareSessionModelConfiguration(ctx context.Context, input *s if errors.As(err, &configurationError) { return configurationError } - return &modelProviderDefaultsError{err} + return &storedDataError{err} } } input.modelSource = "session" diff --git a/services/core/internal/api/session_model_defaults.go b/services/core/internal/api/session_model_defaults.go index 93bdffd7d..20409c8e1 100644 --- a/services/core/internal/api/session_model_defaults.go +++ b/services/core/internal/api/session_model_defaults.go @@ -30,7 +30,7 @@ func (h *Handler) sessionAgentDefaults(ctx context.Context, tenant string, input } saved := &v1.SavedAgent{ID: resource.ID} if err := json.Unmarshal(resource.Configuration, &saved.SavedAgentConfiguration); err != nil { - return nil, nil, err + return nil, nil, &storedDataError{err} } if inherit && saved.XAgentsCore != nil && saved.XAgentsCore.ModelProvider != nil && provider == nil { return nil, nil, errors.New("saved agent model provider bundle is missing") @@ -44,13 +44,6 @@ type modelProviderRequiredError struct{ message string } func (e *modelProviderRequiredError) Error() string { return e.message } -// modelProviderDefaultsError carries a storage or decryption failure while -// reading the deployment default; it is reported as a service error. -type modelProviderDefaultsError struct{ err error } - -func (e *modelProviderDefaultsError) Error() string { return e.err.Error() } -func (e *modelProviderDefaultsError) Unwrap() error { return e.err } - func modelProviderRequired(environment, engine string) error { if environment == "self_hosted" { return &modelProviderRequiredError{"self_hosted Sessions need a model provider for harness " + engine + ": pass x_agents_core.model_provider or use an Agent that has one saved. Deployment default model providers apply to openai_hosted and none Sessions, never to self_hosted."} diff --git a/services/core/internal/api/session_request.go b/services/core/internal/api/session_request.go index d84edd4dc..d8971677d 100644 --- a/services/core/internal/api/session_request.go +++ b/services/core/internal/api/session_request.go @@ -93,9 +93,6 @@ func (request decodedSessionRequest) validated() (sessionRequest, error) { if err := json.Unmarshal(request.Agent, &input.agentFields); err != nil { return input, sessions.ErrInvalidInput } - if _, supplied := input.agentFields["model"]; supplied && input.Agent.Model == nil { - return input, sessions.ErrInvalidInput - } } if len(request.AgentID) > 0 { var id string diff --git a/services/core/internal/api/session_tools.go b/services/core/internal/api/session_tools.go index 16e9f0b49..c66888e99 100644 --- a/services/core/internal/api/session_tools.go +++ b/services/core/internal/api/session_tools.go @@ -18,8 +18,8 @@ func resolveSessionTools(input []json.RawMessage) ([]json.RawMessage, error) { var kind struct { Type string `json:"type"` } - if json.Unmarshal(raw, &kind) != nil { - return nil, errors.New("Invalid execution tool configuration.") + if err := json.Unmarshal(raw, &kind); err != nil { + return nil, &storedDataError{err} } switch kind.Type { case "programmatic_tool_calling", "web_search": @@ -32,15 +32,10 @@ func resolveSessionTools(input []json.RawMessage) ([]json.RawMessage, error) { if kind.Type == "web_search" { resolved, err = resolveDisabledWebSearch(raw) } else { - resolved, err = resolveProgrammaticTool(raw) - var value struct { - Enabled bool `json:"enabled"` - } - if err == nil { - _ = json.Unmarshal(resolved, &value) - if value.Enabled { - err = errors.New("Programmatic tool calling is not qualified for execution.") - } + var enabled bool + resolved, enabled, err = resolveProgrammaticTool(raw) + if err == nil && enabled { + err = errors.New("Programmatic tool calling is not qualified for execution.") } } if err != nil { @@ -48,8 +43,8 @@ func resolveSessionTools(input []json.RawMessage) ([]json.RawMessage, error) { } tools[i] = resolved case "tool_search": - if search || decodeInputObject(raw, &kind, "type") != nil { - return nil, errors.New("Execution requires one type-only tool_search declaration.") + if search { + return nil, errors.New("Execution requires one tool_search declaration.") } search = true tools[i], _ = json.Marshal(kind) @@ -59,15 +54,18 @@ func resolveSessionTools(input []json.RawMessage) ([]json.RawMessage, error) { return nil, err } var tool v1.MCPTool - if json.Unmarshal(resolved, &tool) != nil || servers[tool.ServerLabel] { + if err := json.Unmarshal(resolved, &tool); err != nil { + return nil, &storedDataError{err} + } + if servers[tool.ServerLabel] { return nil, errors.New("Execution requires distinct MCP server labels.") } servers[tool.ServerLabel] = true tools[i] = resolved case "function": var function v1.FunctionToolInput - if decodeInputObject(raw, &function, "type", "name", "description", "parameters", "defer_loading") != nil { - return nil, errors.New("Invalid execution function fields.") + if err := json.Unmarshal(raw, &function); err != nil { + return nil, &storedDataError{err} } functions = append(functions, function) positions = append(positions, i) diff --git a/services/core/internal/api/validation_errors_test.go b/services/core/internal/api/validation_errors_test.go index fc9b45aac..e0ec582f7 100644 --- a/services/core/internal/api/validation_errors_test.go +++ b/services/core/internal/api/validation_errors_test.go @@ -167,10 +167,15 @@ func TestMetadataValidationUsesOfficialFields(t *testing.T) { if want := map[bool]int{true: 0, false: 3}[op.limited]; s.writes != want { t.Fatalf("rejected metadata reached storage: %d writes", s.writes) } - // Type errors precede the generic whole-body error. + // Type errors precede the generic whole-body error. Agent bodies follow + // the pinned shape walk, which reports unknown members first. body := strings.Replace(fmt.Sprintf(op.body, `{"k":1}`), "{", `{"unsupported_field":true,`, 1) + want := "metadata.k" + if strings.HasPrefix(op.name, "agent") { + want = "unsupported_field" + } w := credentialRequest(h, op.method, op.path, body) - if code, param, _ := errorFields(t, w); w.Code != http.StatusBadRequest || code != "invalid_request_error" || param == nil || *param != "metadata.k" { + if code, param, _ := errorFields(t, w); w.Code != http.StatusBadRequest || code != "invalid_request_error" || param == nil || *param != want { t.Fatalf("metadata type did not precede generic error: %d %s", w.Code, w.Body) } for _, tc := range accepted { diff --git a/services/core/tests/official_mcp.py b/services/core/tests/official_mcp.py index 716073371..b41d95ffb 100644 --- a/services/core/tests/official_mcp.py +++ b/services/core/tests/official_mcp.py @@ -74,19 +74,22 @@ def verify_mcp_configuration(client, other, expect_error): {"authorization": "synthetic-private"}, {"server_url": "https://mcp.example.invalid/mcp?token=synthetic-private"}): invalid.append({**tool, "transport": {**transport, **changes}}) - # Transports other than HTTP stay unsupported with or without an origin. - stdio = {"type": "stdio", "command": "synthetic-private"} - invalid += [{**minimal, "transport": stdio}, {**tool, "transport": stdio}] + # A pinned stdio transport stays unsupported with or without an origin; a + # malformed one gets the official field error first. + stdio, malformed = {"type": "stdio", "command": "synthetic-private", "cwd": "/"}, {"type": "stdio", "command": "synthetic-private"} + invalid += [{**minimal, "transport": stdio}, {**tool, "transport": stdio}, {**tool, "transport": malformed}] for declaration in invalid: - for operation in ( - lambda: agents.create(model="requested-model", tools=[declaration]), - lambda: sessions.create(agent={"model": "requested-model", "tools": [declaration]}, - input="Verify mcp fixture admission.", environment={"type": "none"}), + for prefix, operation in ( + ("", lambda: agents.create(model="requested-model", tools=[declaration])), + ("agent.", lambda: sessions.create(agent={"model": "requested-model", "tools": [declaration]}, + input="Verify mcp fixture admission.", environment={"type": "none"})), ): error = expect_error(BadRequestError, operation) assert "synthetic-private" not in str(error.body) if declaration["transport"] is stdio: assert error.body["message"] == "MCP currently supports HTTP transport only." + if declaration["transport"] is malformed: + assert error.body["message"] == f"Missing required parameter: '{prefix}tools[0].transport.cwd'." assert {item.id for item in sessions.list()} == before assert {item.id for item in agents.list()} == saved_before print("HTTP MCP: pinned saved/Session projections, omitted/null origins, null/empty allowlists, immutable snapshots and rejected writes passed; no native execution claimed.")