diff --git a/contracts/agents-api/admin-api.md b/contracts/agents-api/admin-api.md index 6da616c59..9a3f7ac49 100644 --- a/contracts/agents-api/admin-api.md +++ b/contracts/agents-api/admin-api.md @@ -133,7 +133,7 @@ After an uncertain `POST` response, `GET` the archive before writing again. Repe ## Execution configuration -`GET /projects/{project_id}/sessions/{session_id}/execution-configuration` reports the model, Harness, native parameters and model provider a Session froze at creation. It reads only stored configuration: it never contacts a provider, starts a Turn or wakes a sandbox. Responses carry `Cache-Control: no-store`. +`GET /projects/{project_id}/sessions/{session_id}/execution-configuration` reports the Session's current model, and the Harness, native parameters and model provider it froze at creation. A model changed by a Session update reads with source `session`. It reads only stored configuration: it never contacts a provider, starts a Turn or wakes a sandbox. Responses carry `Cache-Control: no-store`. ```json { diff --git a/contracts/agents-api/harness-capabilities.md b/contracts/agents-api/harness-capabilities.md index 61b9a53ac..974c48b8d 100644 --- a/contracts/agents-api/harness-capabilities.md +++ b/contracts/agents-api/harness-capabilities.md @@ -27,6 +27,8 @@ Placements are `none` (no Environment), hosted (`openai_hosted`) and self-hosted | `reasoning.summary` | `concise`, `detailed`, `auto`: admitted | `auto`: admitted | Rejected | | `service_tier` other than `auto` | `default`, `flex`, `priority`, `fast`: admitted; `ultrafast` rejected | Rejected | Rejected | | `text.verbosity` other than `medium` | Admitted; the native model decides | Rejected | Rejected | +| Session update of `agent.model`, `reasoning.effort` (including null), `service_tier` and `review: auto_review` | Rejected | Rejected | Rejected | +| Session update of `agent.tools` | Rejected | Rejected | Rejected | | `compaction.async: true`, `experimental.time_awareness: true` and the `automations` and `computer_use` tools | Rejected | Rejected | Rejected | | [Public token usage](./sessions-events.md) | Measured counters | Null | Null | diff --git a/contracts/agents-api/harness-onboarding.md b/contracts/agents-api/harness-onboarding.md index fdac84fed..86743f53d 100644 --- a/contracts/agents-api/harness-onboarding.md +++ b/contracts/agents-api/harness-onboarding.md @@ -184,7 +184,8 @@ The declaration holds: - the model `Configuration` ([native model configuration](#native-model-configuration)); - `Capabilities`, a `proto.AgentKindCapabilities` that states the most the Harness can do on a capable host, with every field `Supported` or `Unsupported` and the zero value invalid ([Capability declarations](../../docs/runtime-protocol.md#capability-declarations)); - the supported `Placements` and public `MCPOrigins`, whether whitespace-only text is accepted, and `MaxInstructionsBytes`, the native byte limit on an Agent's instructions (zero declares none), which admission rejects above; -- `Execution`, the official execution option values the adapter maps to native settings: web search modes and whether search settings are accepted, reasoning efforts and summaries, service tiers, programmatic-tool-calling values and text verbosities. Core admission and the Runtime reject an undeclared value with the same check ([tool policy](./execution-tools.md#web-search-and-programmatic-tool-calling)). Core always sends the official default text verbosity, so `TextVerbosities` must include `medium`; +- `Execution`, the official execution option values the adapter maps to native settings: web search modes and whether search settings are accepted, reasoning efforts and summaries, service tiers, programmatic-tool-calling values and text verbosities. Core admission and the Runtime reject an undeclared value with the same check ([tool policy](./execution-tools.md#web-search-and-programmatic-tool-calling)). Core always sends the official default text verbosity, so `TextVerbosities` must include `medium`. `Reviews` lists the supported `review` values other than `none` (only `auto_review`); +- `SessionUpdates`, whether the adapter applies a Session update natively between root Turns of a retained Executor: `Model`, `ReasoningEffort`, `ReasoningEffortReset` (null effort) and `ServiceTier`, each `Supported` or `Unsupported`. Core rejects an update that changes an unsupported setting, and a change to a supported one reaches the adapter in the next Turn's settings; - optional pure restrictions, `ValidateConfiguration`, `ValidateTools`, `ValidateMessageInput` and `ValidateFunctionResult`, that add native limits to the common checks. `ValidateMessageInput` runs only for messages that carry images, after the `MessageImages` capability check and before anything is stored, and decides which `image_url` values the Harness accepts; Core checks only that the reference is not blank. A nil validator means the common checks suffice. They cannot query business data, decrypt credentials or control native processes. Configuration restrictions run first, and a tool-decoding error precedes `ValidateTools`. `harnessconfig.NewRegistry` validates every declaration and panics on an invalid or duplicate one: kinds and labels must be nonempty, placements must list at least one supported placement, MCP origins must be a non-nil list, unknown or duplicate choices and origins without a corresponding placement are rejected, bearer support requires an MCP origin, and `Execution` must declare text verbosity `medium`. Shared dispatch checks capability combinations, never engine names. diff --git a/contracts/agents-api/index.md b/contracts/agents-api/index.md index 292e6f296..e38f51edf 100644 --- a/contracts/agents-api/index.md +++ b/contracts/agents-api/index.md @@ -27,7 +27,7 @@ Evidence for a status comes from the pinned official SDK and raw HTTP against th | Resource | Operations | Status | Contract | | --- | --- | --- | --- | | Agents | create, retrieve, update, list, delete | Implemented. Every pinned setting is saved; Session admission runs a subset | [Agents](./wire-semantics.md#agents) | -| Sessions | create (JSON or stream), retrieve, update, list, delete | Implemented. Update takes `metadata` only and `agent` is rejected; list filters on `metadata[key]=value`; deletion requires an idle or failed Session | [Sessions](./wire-semantics.md#sessions), [creation streaming](./sessions-events.md#creation-streaming) | +| Sessions | create (JSON or stream), retrieve, update, list, delete | Implemented. Update takes `metadata`, `agent.model`, `agent.reasoning`, `agent.service_tier` and `review`, and rejects `agent.tools`; list filters on `metadata[key]=value`; deletion requires an idle or failed Session | [Sessions](./wire-semantics.md#sessions), [creation streaming](./sessions-events.md#creation-streaming) | | Session events | create, stream | Partial: messages with text and images, cancellation, function results; the stream is live only | [Sessions, events and history](./sessions-events.md), [message content](./message-content.md) | | Turns | retrieve, list; Turn Items | Implemented; Session Turn routes hold root Turns only | [Turns and Items](./sessions-events.md#turns-and-items) | | Items | list | Partial: messages, commands, MCP calls, functions, web search, reasoning and Subagent coordination Items; other native variants are not projected | [Turns and Items](./sessions-events.md#turns-and-items) | @@ -146,6 +146,7 @@ Each item is Core's deliberate or native behavior where the official service beh - `compaction.async: true`, `experimental.time_awareness: true` and the `automations` and `computer_use` tools are saved but rejected at Session admission for every Harness, because none runs them natively ([Session admission](./wire-semantics.md#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 image input and accepts at most 32 KiB of `instructions`. - Model-derived reasoning defaults are not resolved. +- A Session update that changes `agent.model`, `agent.reasoning.effort`, `agent.service_tier` or `review` is rejected when the Harness cannot apply it natively between Turns ([Harness capabilities](./harness-capabilities.md#execution-and-input)). **Execution and history** @@ -185,12 +186,15 @@ Each item is Core's deliberate or native behavior where the official service beh **Sessions** +- A Session update rejects `agent.tools`, because no Harness changes its tools after it starts. - The pinned SDK sends `spend_control` on Session create and update, but the pinned OpenAPI schema does not define it; Core rejects it as an unknown parameter. - Session deletion does not purge the stored history physically. - Stream lifetimes for `self_hosted`, hosted and no-input creation, and the creation-stream retry, are Core's own choices. ### Unverified against the official service +- When a Session update binds to a queued Turn: Core binds each root Turn's settings when it accepts the input that creates the Turn, so a Turn queued before an update keeps the earlier values. +- The meaning of "automatic review is not enabled" in a Session response: Core omits `review` unless it is `auto_review`. - The order of errors when one request has several faults, and error, default and payload-limit parity in general. - A Subagent's function call: Core makes it a required action of the root Turn that runs the child, with `turn_id` set to that Turn, and publishes the child's `function_call` Item once as `agent.session.turn.item.added` with the child Turn ID and a null `output_index`; a result may name either Turn. The Turn is `waiting` and the Session `requires_action` while root output may continue, and the `call_id` equals the `call_id` of the child's `function_call` Item. Claude Code publishes the child's Items when the root Turn settles, after the required action. - Whether the official service emits Environment `pending` and when: Core emits it when a hosted Environment's initialization starts, and the one official sample showed only `ready`, about 29 s after creation. diff --git a/contracts/agents-api/model-execution.md b/contracts/agents-api/model-execution.md index 24b57b1e0..34bca4f09 100644 --- a/contracts/agents-api/model-execution.md +++ b/contracts/agents-api/model-execution.md @@ -117,7 +117,7 @@ Claude `thinking` accepts `display` (`summarized` or `omitted`) for `adaptive` a A supplied object replaces the whole object; `{}` clears it and null is invalid. A Session that selects a model or provider without supplying native parameters uses `{}` instead of inheriting another model's parameters. Otherwise a saved Agent supplies its object, and an inline Session using the deployment model uses the deployment object. Changing the Harness clears inherited parameters, and so does an Agent update of model, provider or Harness that does not supply `harness_config`. There is no deep merge. An inline extension that sets only native parameters keeps the saved Harness. -Core validates the resolved configuration before writing the Session and freezes it in the Session's Agent configuration; the administrator execution-configuration read shows its value and source. Native parameters are not confidential; provider keys stay in the separate encrypted bundle. Reconnection uses the frozen configuration and cannot change the provider, protocol or parameters; an incompatible frozen configuration fails. Protocol support does not qualify structured output, tool discovery, web search, verbosity or image input by itself, and a supported connection does not prove that the remote model accepts a parameter. +Core validates the resolved configuration before writing the Session and freezes it in the Session's Agent configuration; the administrator execution-configuration read shows its value and source. Native parameters are not confidential; provider keys stay in the separate encrypted bundle. A Session update can change the model for later Turns; the provider, protocol and native parameters stay frozen. Reconnection uses the frozen configuration and cannot change the provider, protocol or parameters; an incompatible frozen configuration fails. Protocol support does not qualify structured output, tool discovery, web search, verbosity or image input by itself, and a supported connection does not prove that the remote model accepts a parameter. ## Deployment defaults diff --git a/contracts/agents-api/wire-semantics.md b/contracts/agents-api/wire-semantics.md index 9e0718890..b3795b5d7 100644 --- a/contracts/agents-api/wire-semantics.md +++ b/contracts/agents-api/wire-semantics.md @@ -195,8 +195,7 @@ These official fields have no Core meaning. A request that sets one returns 400 | Field | Param | | --- | --- | -| Session update `agent` with any member; `{}` changes nothing | `agent` | -| Session `review: auto_review` on create or update; `none` is accepted and responses omit `review` | `review` | +| Session update `agent.tools` | `agent.tools` | | `environment.environment_id` | `environment_id` | | `environment.container_size`; responses carry `container_size: null` | `container_size` | | `desktop.enabled: true`; responses carry `desktop: {"enabled": false}` | `desktop.enabled` | @@ -212,6 +211,7 @@ A Session's effective configuration must also pass execution admission, which ap | --- | --- | | A `reasoning.effort`, `reasoning.summary`, `service_tier` (including `ultrafast`), `web_search` mode or setting, `programmatic_tool_calling` value or `text.verbosity` the Harness does not declare | A message naming the field, such as `reasoning.effort "max" is not supported by this Harness.`; see [tool policy](./execution-tools.md#web-search-and-programmatic-tool-calling) and [Harness capabilities](./harness-capabilities.md#execution-and-input) | | `compaction.async: true`, `experimental.time_awareness: true`, or an `automations` or `computer_use` tool, on every Harness, since none runs them natively | A message naming the field, such as `compaction.async=true is not supported.` | +| A `review: auto_review` the Harness does not declare, on create or update; `none` is always accepted and responses omit it | A message naming the field, such as `review "auto_review" is not supported by this Harness.` | | A function name that is blank or repeated | "Function names must be nonempty and unique." | | A Harness that does not declare `retained_native_history`, on a sandbox provider that keeps every Environment on workspace storage ([Harness capabilities](./harness-capabilities.md)) | "This Session's harness does not retain its native history, which the sandbox provider's workspace storage requires. Use a harness that supports retained_native_history, or a sandbox provider with its own disk." | @@ -231,7 +231,7 @@ Omitted, null and explicit `medium` text verbosity give the same Session configu ### Configuration snapshot -Session creation copies the effective Agent configuration into an immutable snapshot. With `agent_id`, the saved Agent is read once; fields in the inline `agent` replace the saved field whole, including arrays, and null `tools` clears the list. Omitted fields inherit; an inline `x_agents_core` that omits `harness` keeps the saved Harness. Saved Agent metadata never becomes Session metadata. Later Agent updates or deletion affect only new Sessions. +Session creation copies the effective Agent configuration into an immutable snapshot. With `agent_id`, the saved Agent is read once; fields in the inline `agent` replace the saved field whole, including arrays, and null `tools` clears the list. Omitted fields inherit; an inline `x_agents_core` that omits `harness` keeps the saved Harness. Saved Agent metadata never becomes Session metadata. Later Agent updates or deletion affect only new Sessions. The snapshot changes only through a Session update, and each root Turn takes the settings current when Core accepts the input that creates it. `stream` defaults to false. `stream` and `agent_id` cannot be null. Omitted or null `metadata` is `{}`. @@ -251,7 +251,9 @@ Keys are scoped to the Project; any key of the Project, including one issued aft ### Update and list -`POST /agents/sessions/{session_id}` changes only `metadata`: null or `{}` clears it and an object replaces all pairs. A body with only `review` or an empty `agent` changes nothing and returns the Session; a body with none of `metadata`, `agent` or `review` returns 400. An `agent` with members is rejected ([unsupported fields](#unsupported-fields)). Execution state and the creation retry identity are unchanged. +`POST /agents/sessions/{session_id}` applies only the members present, in one transaction, and returns the Session. `metadata` null or `{}` clears it and an object replaces all pairs. `agent.model`, `agent.reasoning.effort` (null selects the model's default), `agent.service_tier` (null becomes `auto`) and `review` change the snapshot for Turns whose input is accepted later; a Turn that already exists, with its Subagents, keeps its settings. `agent.tools` is rejected ([unsupported fields](#unsupported-fields)). An empty `agent` changes nothing, and a body with none of `metadata`, `agent` or `review` returns 400. + +A changed value that the Harness does not declare it applies natively between Turns, or a `review` it does not declare, returns 400 `unsupported_or_invalid_configuration` naming the field, and nothing is written. An unchanged value always passes. The creation retry identity is unchanged. `GET /agents/sessions` accepts `agent_id`, which matches the Session's immutable root Agent ID, including inline Agent IDs and Agents that were since updated or deleted. The filter applies before pagination; an empty `agent_id` is a filter, not an omission. The [`metadata` filter](#query-parameters) applies too. diff --git a/contracts/agents-api/zh/admin-api.md b/contracts/agents-api/zh/admin-api.md index 0503f9623..e4ff6ebd5 100644 --- a/contracts/agents-api/zh/admin-api.md +++ b/contracts/agents-api/zh/admin-api.md @@ -1,7 +1,7 @@ --- title: "Core 管理 API" source: contracts/agents-api/admin-api.md -source_hash: 664b5d01759bf29735e4e062739843b48c8130b302002b7a3c0e53b2306e7057 +source_hash: 32e834142d199c6ba6ec276e6cd4365c551235e9d2233d296f3f18ab90e86046 --- Core 管理 API(`/core/v1`)用于管理安装实例:项目及其 API 密钥、项目资源的读取和删除、执行器凭据、部署默认模型、沙箱部署及其节点、监控和审计。Web 的[控制台服务器](../../../docs/zh/web/console-server.md#forwarding-to-core)会为已登录的管理员调用它;运维人员则从 Core 主机上的脚本调用它([编写 Core API 脚本](../../../docs/zh/getting-started/operations.md#script-the-core-api))。生成的架构是 [core.openapi.yaml](../core.openapi.yaml),所有错误都使用 [Core 错误封装](core-errors.md)。 @@ -135,7 +135,7 @@ GET 返回所选配置和租户容量策略;尚未配置时返回 404 `workspa ## 执行配置 {#execution-configuration} -`GET /projects/{project_id}/sessions/{session_id}/execution-configuration` 报告 Session 在创建时冻结的模型、Harness、原生参数和模型提供商。它仅读取已存储的配置:绝不会联系提供商、启动 Turn 或唤醒沙箱。响应带有 `Cache-Control: no-store`。 +`GET /projects/{project_id}/sessions/{session_id}/execution-configuration` 报告 Session 当前的模型,以及它在创建时冻结的 Harness、原生参数和模型提供商。经 Session 更新改变的模型,其来源读为 `session`。它仅读取已存储的配置:绝不会联系提供商、启动 Turn 或唤醒沙箱。响应带有 `Cache-Control: no-store`。 ```json { diff --git a/contracts/agents-api/zh/harness-capabilities.md b/contracts/agents-api/zh/harness-capabilities.md index ef3cf329a..554b43e65 100644 --- a/contracts/agents-api/zh/harness-capabilities.md +++ b/contracts/agents-api/zh/harness-capabilities.md @@ -1,7 +1,7 @@ --- title: "Harness 能力" source: contracts/agents-api/harness-capabilities.md -source_hash: f83d80711825f65c897822f172f3a979f3e996eb864355f18f128751575f810d +source_hash: 97c83c3295b7150e81118d5fa302463b7781689e533d8906ce1b85baa26b54af --- 本页列出每个 Harness 在每种部署位置支持的能力。Core 根据 `internal/harnessconfig//declaration.go` 中 Harness 的声明决定准入,运行 Session 的 Runtime 也必须声明操作。所链接契约定义各操作;[Harness 接入](harness-onboarding.md#qualify-the-adapter)说明资格验证方法。 @@ -29,6 +29,8 @@ source_hash: f83d80711825f65c897822f172f3a979f3e996eb864355f18f128751575f810d | `reasoning.summary` | `concise`、`detailed`、`auto`:已准入 | `auto`:已准入 | 已拒绝 | | 非 `auto` 的 `service_tier` | `default`、`flex`、`priority`、`fast`:已准入;`ultrafast` 已拒绝 | 已拒绝 | 已拒绝 | | 非 `medium` 的 `text.verbosity` | 已准入;由原生模型决定 | 已拒绝 | 已拒绝 | +| Session 更新 `agent.model`、`reasoning.effort`(含 null)、`service_tier` 和 `review: auto_review` | 已拒绝 | 已拒绝 | 已拒绝 | +| Session 更新 `agent.tools` | 已拒绝 | 已拒绝 | 已拒绝 | | `compaction.async: true`、`experimental.time_awareness: true`,以及 `automations` 和 `computer_use` 工具 | 已拒绝 | 已拒绝 | 已拒绝 | | [公开 token 用量](sessions-events.md) | 测量计数器 | Null | Null | diff --git a/contracts/agents-api/zh/harness-onboarding.md b/contracts/agents-api/zh/harness-onboarding.md index 4beb553e3..445119f64 100644 --- a/contracts/agents-api/zh/harness-onboarding.md +++ b/contracts/agents-api/zh/harness-onboarding.md @@ -1,7 +1,7 @@ --- title: "Harness 协议" source: contracts/agents-api/harness-onboarding.md -source_hash: 0d3cfce2ec6a1ff9714c021b8ba154c1a955efbdc7f1a77b6d16616a6b16ab1e +source_hash: d2353e23d1424b76a2e8f9d171bcb06e5c5c70a7d56d49ff95b2b7a71cdc080b --- **Harness** 是一种运行模型和工具循环的原生代理引擎(Codex、Claude Code、MiniMax Code);**Harness 适配器**将 Runtime 的 Executor 和 Turn 契约转换到该引擎的 SDK 或协议。Harness 协议是 Runtime 与适配器之间的契约:适配器接口及其生命周期义务、声明、注册和验收。本文面向添加或维护 Harness 的开发者。 @@ -186,7 +186,8 @@ Runtime 统一解析[状态目录](../../../docs/zh/configuration.md#runtime-res - 模型 `Configuration`([原生模型配置](#native-model-configuration)); - `Capabilities`,即 `proto.AgentKindCapabilities`,表示 Harness 在能力完备的主机上最多能做什么;每个字段都是 `Supported` 或 `Unsupported`,零值无效([能力声明](../../../docs/zh/runtime-protocol.md#capability-declarations)); - 受支持的 `Placements` 和公共 `MCPOrigins`、是否接受仅含空白的文本,以及 `MaxInstructionsBytes`(原生对 Agent instructions 的字节上限,零表示没有上限,超出时准入会拒绝); -- `Execution`,即适配器映射到原生设置的官方执行选项取值:web search 模式及是否接受搜索设置、推理强度和摘要、服务层级、程序化工具调用取值和文本详细程度。Core 准入和 Runtime 用同一检查拒绝未声明的取值([工具策略](execution-tools.md#web-search-and-programmatic-tool-calling))。Core 总会发送官方默认文本详细程度,因此 `TextVerbosities` 必须包含 `medium`; +- `Execution`,即适配器映射到原生设置的官方执行选项取值:web search 模式及是否接受搜索设置、推理强度和摘要、服务层级、程序化工具调用取值和文本详细程度。Core 准入和 Runtime 用同一检查拒绝未声明的取值([工具策略](execution-tools.md#web-search-and-programmatic-tool-calling))。Core 总会发送官方默认文本详细程度,因此 `TextVerbosities` 必须包含 `medium`。`Reviews` 列出除 `none` 外支持的 `review` 取值(仅 `auto_review`); +- `SessionUpdates`,即适配器是否在保留的 Executor 的根 Turn 之间原生应用 Session 更新:`Model`、`ReasoningEffort`、`ReasoningEffortReset`(null 强度)和 `ServiceTier`,各自为 `Supported` 或 `Unsupported`。Core 拒绝更改不支持设置的更新,对支持设置的更改会通过下一个 Turn 的设置到达适配器; - 可选的纯限制 `ValidateConfiguration`、`ValidateTools`、`ValidateMessageInput` 和 `ValidateFunctionResult`,在通用检查之外添加原生限制。`ValidateMessageInput` 仅对携带图像的消息运行,在 `MessageImages` 能力检查之后、任何存储之前执行,决定 Harness 接受哪些 `image_url`;Core 只检查引用不为空白。验证函数为 nil 表示通用检查已足够。它们不能查询业务数据、解密凭据或控制原生进程。配置限制最先执行,工具解码错误先于 `ValidateTools`。 `harnessconfig.NewRegistry` 会验证每个声明,对无效或重复的声明触发 panic:kind 和 label 必须非空,放置位置必须至少列出一个受支持的位置,MCP 来源必须是非 nil 列表,未知或重复的选项以及没有对应放置位置的来源会被拒绝,bearer 支持要求存在 MCP 来源,`Execution` 必须声明文本详细程度 `medium`。共享调度检查能力组合,绝不检查引擎名称。 diff --git a/contracts/agents-api/zh/index.md b/contracts/agents-api/zh/index.md index e8d5c9326..ac529d86a 100644 --- a/contracts/agents-api/zh/index.md +++ b/contracts/agents-api/zh/index.md @@ -1,7 +1,7 @@ --- title: "OpenAI 兼容性" source: contracts/agents-api/index.md -source_hash: f2b074b5f3011d44555f0abb890f1426e59f676902206c18e4a79b428e37bc8a +source_hash: 198b4deb88832ac7cdb28e67a0781469e44621b1baa2cccc54b7a2b9bceea36e --- Core 实现下方固定版本的 OpenAI Agents API。本台账列出各项资源的覆盖情况、相对于官方服务的所有差异以及已知缺口。 @@ -30,7 +30,7 @@ Core 实现下方固定版本的 OpenAI Agents API。本台账列出各项资源 | 资源 | 操作 | 状态 | 契约 | | --- | --- | --- | --- | | Agents | create, retrieve, update, list, delete | 已实现。所有固定版本设置都会保存;Session 准入只会用到其中一个子集 | [Agents](wire-semantics.md#agents) | -| Sessions | create (JSON 或流式), retrieve, update, list, delete | 已实现。更新只修改 `metadata`,并拒绝 `agent`;列表支持按 `metadata[key]=value` 过滤;删除要求 Session 处于空闲或失败状态 | [Sessions](wire-semantics.md#sessions)、[creation streaming](sessions-events.md#creation-streaming) | +| Sessions | create (JSON 或流式), retrieve, update, list, delete | 已实现。更新接受 `metadata`、`agent.model`、`agent.reasoning`、`agent.service_tier` 和 `review`,并拒绝 `agent.tools`;列表支持按 `metadata[key]=value` 过滤;删除要求 Session 处于空闲或失败状态 | [Sessions](wire-semantics.md#sessions)、[creation streaming](sessions-events.md#creation-streaming) | | Session events | create, stream | 部分实现:支持包含文本和图像的消息、取消和函数结果;流仅支持实时模式 | [Sessions, events and history](sessions-events.md)、[message content](message-content.md) | | Turns | retrieve, list; Turn Items | 已实现;Session Turn 路由仅承载根级 Turns | [Turns and Items](sessions-events.md#turns-and-items) | | Items | list | 部分实现:支持消息、命令、MCP 调用、functions、web search、reasoning 和 Subagent 协调 Items;其他原生变体不会被投影 | [Turns and Items](sessions-events.md#turns-and-items) | @@ -149,6 +149,7 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh - `compaction.async: true`、`experimental.time_awareness: true` 以及 `automations` 和 `computer_use` 工具会被保存,但对所有 Harness 都在 Session 准入时被拒绝,因为没有 Harness 原生运行它们([Session 准入](wire-semantics.md#session-admission))。 - Harness 对工具、结构化输出、延迟发现、subagents 和 MCP 的支持因 Harness 和部署位置而异;请参阅 [Harness capabilities](harness-capabilities.md)。MiniMax Code 不支持图像输入,且 `instructions` 最多 32 KiB。 - 由模型推导出的推理默认值不会被解析确定。 +- 当 Harness 无法在 Turn 之间原生应用时,更改 `agent.model`、`agent.reasoning.effort`、`agent.service_tier` 或 `review` 的 Session 更新会被拒绝([Harness 能力](harness-capabilities.md#execution-and-input))。 **执行和历史** @@ -188,12 +189,15 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh **Sessions** +- Session 更新拒绝 `agent.tools`,因为没有 Harness 能在启动后更改其工具。 - 固定版本 SDK 在 Session 创建和更新中发送 `spend_control`,但固定的 OpenAPI 模式没有定义它;Core 将其作为未知参数拒绝。 - 删除 Session 不会从物理存储中清除已保存的历史记录。 - 对于 `self_hosted`、hosted 和无输入创建的流生命周期,以及创建流重试机制,均由 Core 自行决定。 ### 尚未与官方服务核实的内容 {#unverified-against-the-official-service} +- Session 更新何时绑定到排队中的 Turn:Core 在接受创建根 Turn 的输入时绑定该 Turn 的设置,因此更新之前已排队的 Turn 保持原有取值。 +- Session 响应中“automatic review is not enabled”的含义:除 `auto_review` 外,Core 省略 `review`。 - 当一个请求存在多项故障时的错误顺序,以及错误处理、默认值和载荷限制的总体一致性。 - Subagent 的函数调用:Core 将其作为运行该子级的根 Turn 的待执行操作,`turn_id` 为该 Turn,并把子级的 `function_call` Item 以 `agent.session.turn.item.added` 发布一次,`turn_id` 为子级 Turn ID,`output_index` 为 null;结果可以指定其中任一 Turn。该 Turn 为 `waiting`、Session 为 `requires_action`,根输出可继续,`call_id` 等于该子级 `function_call` Item 的 `call_id`。Claude Code 在根 Turn 收敛时才发布子级的 Item,晚于该待执行操作。 - 官方服务是否发出 Environment `pending` 以及何时发出:Core 在 hosted Environment 初始化开始时发出,而仅有的一个官方样本只显示了创建约 29 秒后的 `ready`。 diff --git a/contracts/agents-api/zh/model-execution.md b/contracts/agents-api/zh/model-execution.md index 4dd0ed457..9e2fd0de9 100644 --- a/contracts/agents-api/zh/model-execution.md +++ b/contracts/agents-api/zh/model-execution.md @@ -1,7 +1,7 @@ --- title: "模型执行" source: contracts/agents-api/model-execution.md -source_hash: 676dda3abe26188f998a8b0ff1cfa761135d6f04cff19433a5e5bd6ec23e7149 +source_hash: 8c9223d103d7c98f3eed195505b0d1feebc80e520058f4572dd51c5a5ec4fc43 --- 每个 Session 都运行一个 Harness,并使用一个模型提供商。Core 通过三个固定版本上游协议未定义的 Core 扩展来选择它们:`x_agents_core.harness` 选择 Harness,`x_agents_core.model_provider` 提供端点和密钥,`x_agents_core.harness_config` 携带原生模型参数。Core 没有提供商目录、模型别名解析或产品权限模型;除 Session 和已保存 Agent 配置包外,唯一存储的配置包是每个 Harness 的一个 [deployment default](#deployment-defaults)。本文档定义 Harness—模型提供商协议:[`internal/modelprovider/config.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/modelprovider/config.go) 负责验证冻结的提供商连接,每个 Harness 则通过 [`internal/harnessconfig/harness.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/harnessconfig/harness.go) 声明其协议和原生参数。 @@ -119,7 +119,7 @@ Core 从同一个数据库快照读取 Agent 配置和加密配置包;显式 提供对象时会替换整个对象;`{}` 会清除它,null 则无效。Session 在选择模型或提供商但未提供原生参数时,会使用 `{}`,而不会继承另一个模型的参数。否则,使用已保存 Agent 提供的对象;使用部署模型的内联 Session 使用部署对象。更改 Harness 会清除继承的参数;对模型、提供商或 Harness 的 Agent 更新如果未提供 `harness_config`,也会清除继承的参数。不进行深度合并。仅设置原生参数的内联扩展会保留已保存的 Harness。 -Core 会在写入 Session 前验证解析后的配置,并将其冻结在 Session 的 Agent 配置中;管理员的 execution-configuration 读取结果会显示其值和来源。原生参数不属于机密信息;提供商密钥保存在单独加密的配置包中。重新连接会使用冻结的配置,无法更改提供商、协议或参数;不兼容的冻结配置会导致失败。仅仅支持某种协议,并不能说明结构化输出、工具发现、Web 搜索、详细程度或图像输入是否可用;连接受支持也不能证明远程模型接受某个参数。 +Core 会在写入 Session 前验证解析后的配置,并将其冻结在 Session 的 Agent 配置中;管理员的 execution-configuration 读取结果会显示其值和来源。原生参数不属于机密信息;提供商密钥保存在单独加密的配置包中。Session 更新可以为之后的 Turn 更改模型;提供商、协议和原生参数保持冻结。重新连接会使用冻结的配置,无法更改提供商、协议或参数;不兼容的冻结配置会导致失败。仅仅支持某种协议,并不能说明结构化输出、工具发现、Web 搜索、详细程度或图像输入是否可用;连接受支持也不能证明远程模型接受某个参数。 ## 部署默认值 {#deployment-defaults} diff --git a/contracts/agents-api/zh/wire-semantics.md b/contracts/agents-api/zh/wire-semantics.md index 2902c0e53..1a0e27889 100644 --- a/contracts/agents-api/zh/wire-semantics.md +++ b/contracts/agents-api/zh/wire-semantics.md @@ -1,7 +1,7 @@ --- title: "API 行为" source: contracts/agents-api/wire-semantics.md -source_hash: b481386ff50d930d3b8698e0bed882aa512c85adf62d4dc61a42bb77d5a7abe1 +source_hash: 73dcaa859ac6e54b4b51950922b8cb781daff8e159e718dddde363a8e089168a --- 已锁定版本的 OpenAI Python SDK([upstream.json](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/contracts/agents-api/upstream.json))定义了 `/v1` 路由、字段和类型。本页面说明这些类型未作规定之处 Core 的行为,例如状态码、错误字段、默认值和列表边界,以及 Core 与官方服务存在差异的地方。[OpenAI 兼容性](index.md) 列出了这些差异和尚存缺口;[Sessions, events and history](sessions-events.md)、[message content](message-content.md)、[Vaults](vaults.md)、[source Files and Skills](source-files.md) 和 [Environment files and Artifacts](environment-files.md) 分别负责各自资源的规则。 @@ -197,8 +197,7 @@ Session 创建和更新、Vault 创建、Credential 创建和更新、Environmen | 字段 | Param | | --- | --- | -| 含任何成员的 Session 更新 `agent`;`{}` 不改变任何内容 | `agent` | -| Session 创建或更新中的 `review: auto_review`;接受 `none`,响应省略 `review` | `review` | +| Session 更新 `agent.tools` | `agent.tools` | | `environment.environment_id` | `environment_id` | | `environment.container_size`;响应携带 `container_size: null` | `container_size` | | `desktop.enabled: true`;响应携带 `desktop: {"enabled": false}` | `desktop.enabled` | @@ -214,6 +213,7 @@ Session 的有效配置还必须通过执行准入,无论配置来自保存还 | --- | --- | | Harness 未声明的 `reasoning.effort`、`reasoning.summary`、`service_tier`(含 `ultrafast`)、`web_search` 模式或设置、`programmatic_tool_calling` 取值或 `text.verbosity` | 指出字段的消息,例如 `reasoning.effort "max" is not supported by this Harness.`;见 [tool policy](execution-tools.md#web-search-and-programmatic-tool-calling) 和 [Harness 能力](harness-capabilities.md#execution-and-input) | | 任何 Harness 上的 `compaction.async: true`、`experimental.time_awareness: true`,或 `automations`、`computer_use` 工具,因为没有 Harness 原生运行它们 | 指出字段的消息,例如 `compaction.async=true is not supported.` | +| 创建或更新中 Harness 未声明的 `review: auto_review`;`none` 始终被接受,响应省略它 | 指出字段的消息,例如 `review "auto_review" is not supported by this Harness.` | | 函数名称为空或重复 | "Function names must be nonempty and unique." | | 未声明 `retained_native_history` 的 Harness,运行在把每个 Environment 都放在 workspace 存储上的 sandbox provider 上([Harness 能力](harness-capabilities.md)) | "This Session's harness does not retain its native history, which the sandbox provider's workspace storage requires. Use a harness that supports retained_native_history, or a sandbox provider with its own disk." | @@ -233,7 +233,7 @@ Session 的有效配置还必须通过执行准入,无论配置来自保存还 ### 配置快照 {#configuration-snapshot} -Session 创建会将 Agent 的有效配置复制到不可变快照中。使用 `agent_id` 时,已保存的 Agent 只会读取一次;内联 `agent` 中的字段会整体替换已保存字段,包括数组,而 null `tools` 会清空列表。省略的字段会继承保存值;如果内联 `x_agents_core` 省略 `harness`,则保留已保存的 Harness。已保存的 Agent metadata 永远不会成为 Session metadata。后续更新或删除 Agent 只会影响新的 Session。 +Session 创建会将 Agent 的有效配置复制到不可变快照中。使用 `agent_id` 时,已保存的 Agent 只会读取一次;内联 `agent` 中的字段会整体替换已保存字段,包括数组,而 null `tools` 会清空列表。省略的字段会继承保存值;如果内联 `x_agents_core` 省略 `harness`,则保留已保存的 Harness。已保存的 Agent metadata 永远不会成为 Session metadata。后续更新或删除 Agent 只会影响新的 Session。快照只会通过 Session 更新改变,每个根 Turn 采用 Core 接受创建它的输入时的当前设置。 `stream` 默认为 false。`stream` 和 `agent_id` 不能为 null。省略或 null 的 `metadata` 为 `{}`。 @@ -253,7 +253,9 @@ Session 创建会将 Agent 的有效配置复制到不可变快照中。使用 ` ### 更新和列表 {#update-and-list} -`POST /agents/sessions/{session_id}` 只修改 `metadata`:null 或 `{}` 会将其清除,对象会替换所有键值对。仅含 `review` 或空 `agent` 的正文不改变任何内容并返回该 Session;`metadata`、`agent`、`review` 都不含的正文返回 400。含成员的 `agent` 会被拒绝([不支持的字段](#unsupported-fields))。执行状态和创建重试标识保持不变。 +`POST /agents/sessions/{session_id}` 只在一个事务中应用出现的成员,并返回该 Session。`metadata` 为 null 或 `{}` 会将其清除,对象会替换所有键值对。`agent.model`、`agent.reasoning.effort`(null 选择模型的默认值)、`agent.service_tier`(null 变为 `auto`)和 `review` 会改变之后才被接受输入的 Turn 所用的快照;已存在的 Turn 及其 Subagent 保持原有设置。`agent.tools` 会被拒绝([不支持的字段](#unsupported-fields))。空 `agent` 不改变任何内容,`metadata`、`agent`、`review` 都不含的正文返回 400。 + +更改的取值若 Harness 没有声明可在 Turn 之间原生应用,或 `review` 未被声明,则返回 400 `unsupported_or_invalid_configuration` 并指出字段,且不写入任何内容。未改变的取值始终通过。创建重试标识保持不变。 `GET /agents/sessions` 接受 `agent_id`,它会匹配 Session 不可变的根 Agent ID,包括内联 Agent ID,以及此后已更新或删除的 Agent。筛选在分页之前应用;空 `agent_id` 是一个筛选条件,而不是省略该筛选条件。[`metadata` 筛选](#query-parameters)同样适用。 diff --git a/internal/harnessconfig/claudesdk/declaration.go b/internal/harnessconfig/claudesdk/declaration.go index 049d58260..032174f17 100644 --- a/internal/harnessconfig/claudesdk/declaration.go +++ b/internal/harnessconfig/claudesdk/declaration.go @@ -72,6 +72,7 @@ var Declaration = harnessconfig.Declaration{ }, Placements: []string{"none", "openai_hosted", "self_hosted"}, MCPOrigins: []string{"service", "environment"}, + SessionUpdates: harnessconfig.SessionUpdates{Model: proto.CapabilityUnsupported, ReasoningEffort: proto.CapabilityUnsupported, ReasoningEffortReset: proto.CapabilityUnsupported, ServiceTier: proto.CapabilityUnsupported}, WhitespaceOnlyText: proto.CapabilitySupported, ValidateConfiguration: validateConfiguration, ValidateTools: validateTools, diff --git a/internal/harnessconfig/codex/declaration.go b/internal/harnessconfig/codex/declaration.go index 5f3d75c45..2aeb62a0f 100644 --- a/internal/harnessconfig/codex/declaration.go +++ b/internal/harnessconfig/codex/declaration.go @@ -65,6 +65,7 @@ var Declaration = harnessconfig.Declaration{ }, Placements: []string{"none", "self_hosted", "openai_hosted"}, MCPOrigins: []string{"service", "environment"}, + SessionUpdates: harnessconfig.SessionUpdates{Model: proto.CapabilityUnsupported, ReasoningEffort: proto.CapabilityUnsupported, ReasoningEffortReset: proto.CapabilityUnsupported, ServiceTier: proto.CapabilityUnsupported}, WhitespaceOnlyText: proto.CapabilitySupported, ValidateMessageInput: rejectRemoteImages, ValidateFunctionResult: func(result proto.FunctionResultPayload) error { diff --git a/internal/harnessconfig/configuration_test.go b/internal/harnessconfig/configuration_test.go index 95affb60d..ccc1f94e7 100644 --- a/internal/harnessconfig/configuration_test.go +++ b/internal/harnessconfig/configuration_test.go @@ -8,7 +8,7 @@ import ( func testDeclaration(kind string, configuration Configuration) Declaration { return Declaration{Kind: kind, Label: kind, Configuration: configuration, Capabilities: proto.UnsupportedCapabilities(), - Placements: []string{"none"}, MCPOrigins: []string{}, WhitespaceOnlyText: proto.CapabilityUnsupported, Execution: ExecutionOptions{TextVerbosities: []string{"medium"}}} + Placements: []string{"none"}, MCPOrigins: []string{}, WhitespaceOnlyText: proto.CapabilityUnsupported, SessionUpdates: SessionUpdates{proto.CapabilityUnsupported, proto.CapabilityUnsupported, proto.CapabilityUnsupported, proto.CapabilityUnsupported}, Execution: ExecutionOptions{TextVerbosities: []string{"medium"}}} } func TestRegistryOwnsDeclarations(t *testing.T) { @@ -61,6 +61,8 @@ func TestDeclarationRequiresExplicitSupport(t *testing.T) { "label": func(d *Declaration) { d.Label = " " }, "unknown kind": func(d *Declaration) { d.Kind = " example" }, "verbosity": func(d *Declaration) { d.Execution.TextVerbosities = []string{"high"} }, + "updates": func(d *Declaration) { d.SessionUpdates.Model = proto.CapabilityUnspecified }, + "reviews": func(d *Declaration) { d.Execution.Reviews = []string{"none"} }, } { declaration := valid.Clone() change(&declaration) diff --git a/internal/harnessconfig/harness.go b/internal/harnessconfig/harness.go index 88e364ebf..1a1c063ed 100644 --- a/internal/harnessconfig/harness.go +++ b/internal/harnessconfig/harness.go @@ -93,6 +93,9 @@ type Declaration struct { // Execution declares which official execution option values the Harness // maps to a native setting. Admission rejects any other user-set value. Execution ExecutionOptions + // SessionUpdates declares which settings a Session update changes natively + // between root Turns. A change to an unsupported setting is a typed error. + SessionUpdates SessionUpdates // WhitespaceOnlyText declares messages without an image or non-whitespace // text. Unsupported Harnesses reject them at admission; Core never trims or // pads model input to fit a Harness. @@ -157,6 +160,14 @@ func (d Declaration) ValidateDeclaration() error { (d.Capabilities.MCPHTTPBearerAuth.IsSupported() && len(d.MCPOrigins) == 0) { return errors.New("harnessconfig: invalid MCP origins") } + for _, support := range []proto.CapabilitySupport{d.SessionUpdates.Model, d.SessionUpdates.ReasoningEffort, d.SessionUpdates.ReasoningEffortReset, d.SessionUpdates.ServiceTier} { + if support != proto.CapabilitySupported && support != proto.CapabilityUnsupported { + return errors.New("harnessconfig: session update support must be declared") + } + } + if !validChoices(d.Execution.Reviews, "auto_review") { + return errors.New("harnessconfig: invalid reviews") + } // Core always sends the official default text verbosity. if !slices.Contains(d.Execution.TextVerbosities, "medium") { return errors.New("harnessconfig: execution must declare text verbosity medium") @@ -374,6 +385,14 @@ type ExecutionOptions struct { ProgrammaticToolCalling []bool // TextVerbosities are the supported text verbosity levels. TextVerbosities []string + // Reviews are the supported review values other than none: auto_review. + Reviews []string +} + +// SessionUpdates declares which settings the adapter changes natively between +// root Turns. Every field is Supported or Unsupported. +type SessionUpdates struct { + Model, ReasoningEffort, ReasoningEffortReset, ServiceTier proto.CapabilitySupport } func (o ExecutionOptions) Clone() ExecutionOptions { @@ -383,6 +402,7 @@ func (o ExecutionOptions) Clone() ExecutionOptions { o.ServiceTiers = slices.Clone(o.ServiceTiers) o.ProgrammaticToolCalling = slices.Clone(o.ProgrammaticToolCalling) o.TextVerbosities = slices.Clone(o.TextVerbosities) + o.Reviews = slices.Clone(o.Reviews) return o } @@ -433,3 +453,33 @@ func (d Declaration) ValidateTurnSettings(turn proto.TurnSettings) error { } return nil } + +// ValidateReview rejects a review value the Harness does not declare. None is +// always accepted. +func (d Declaration) ValidateReview(review string) error { + if review != "" && review != "none" && !slices.Contains(d.Execution.Reviews, review) { + return fmt.Errorf("review %q is not supported by this Harness.", review) + } + return nil +} + +// ValidateSessionUpdate rejects a change of model, effort or tier that the +// Harness does not declare it applies natively between root Turns. Unchanged +// settings pass. +func (d Declaration) ValidateSessionUpdate(before, after proto.TurnSettings) error { + u := d.SessionUpdates + effort := u.ReasoningEffort + if after.ReasoningEffort == "" { + effort = u.ReasoningEffortReset + } + for _, check := range []struct { + field string + changed bool + support proto.CapabilitySupport + }{{"agent.model", before.Model != after.Model, u.Model}, {"agent.reasoning.effort", before.ReasoningEffort != after.ReasoningEffort, effort}, {"agent.service_tier", before.ServiceTier != after.ServiceTier, u.ServiceTier}} { + if check.changed && !check.support.IsSupported() { + return fmt.Errorf("%s cannot be changed on this Harness.", check.field) + } + } + return nil +} diff --git a/internal/harnessconfig/harnesstest/fixture.go b/internal/harnessconfig/harnesstest/fixture.go index 9c5ff5020..7c8e4fae9 100644 --- a/internal/harnessconfig/harnesstest/fixture.go +++ b/internal/harnessconfig/harnesstest/fixture.go @@ -17,6 +17,7 @@ func Declaration(kind string, change func(*harnessconfig.Declaration)) harnessco Execution: harnessconfig.ExecutionOptions{TextVerbosities: []string{"medium"}}, Placements: []string{"none"}, MCPOrigins: []string{}, + SessionUpdates: harnessconfig.SessionUpdates{Model: proto.CapabilityUnsupported, ReasoningEffort: proto.CapabilityUnsupported, ReasoningEffortReset: proto.CapabilityUnsupported, ServiceTier: proto.CapabilityUnsupported}, WhitespaceOnlyText: proto.CapabilityUnsupported, } if change != nil { diff --git a/internal/harnessconfig/mcode/declaration.go b/internal/harnessconfig/mcode/declaration.go index b302695dd..5e5c6acba 100644 --- a/internal/harnessconfig/mcode/declaration.go +++ b/internal/harnessconfig/mcode/declaration.go @@ -66,6 +66,7 @@ var Declaration = harnessconfig.Declaration{ }, Placements: []string{"none", "openai_hosted", "self_hosted"}, MCPOrigins: []string{"service", "environment"}, + SessionUpdates: harnessconfig.SessionUpdates{Model: proto.CapabilityUnsupported, ReasoningEffort: proto.CapabilityUnsupported, ReasoningEffortReset: proto.CapabilityUnsupported, ServiceTier: proto.CapabilityUnsupported}, WhitespaceOnlyText: proto.CapabilityUnsupported, // Native MiniMax Code ignores global instructions (AGENTS.md) over 32 KiB. MaxInstructionsBytes: 32 * 1024, diff --git a/services/core/internal/api/configuration.go b/services/core/internal/api/configuration.go index 7150241f3..4f6c2d5d2 100644 --- a/services/core/internal/api/configuration.go +++ b/services/core/internal/api/configuration.go @@ -13,6 +13,7 @@ import ( type configuration struct { Agent v1.Agent `json:"agent"` Environment v1.Environment `json:"environment"` + Review string `json:"review,omitempty"` VaultIDs []string `json:"vault_ids,omitempty"` MCPCredentials []vaults.MCPCredentialBinding `json:"mcp_credentials,omitempty"` } @@ -34,5 +35,9 @@ func resolve(input sessionRequest, tenant, key string, saved *v1.SavedAgent) (js } else { agent.ID = saved.ID } - return json.Marshal(configuration{Agent: agent, Environment: *input.Environment, VaultIDs: input.VaultIDs}) + stored := configuration{Agent: agent, Environment: *input.Environment, VaultIDs: input.VaultIDs} + if input.Review != nil && *input.Review == "auto_review" { + stored.Review = *input.Review + } + return json.Marshal(stored) } diff --git a/services/core/internal/api/fakes_test.go b/services/core/internal/api/fakes_test.go index 2f196e34d..7cf37a506 100644 --- a/services/core/internal/api/fakes_test.go +++ b/services/core/internal/api/fakes_test.go @@ -845,16 +845,16 @@ func (f *fakeSessionEvents) SessionStreamSnapshot(a0 context.Context, a1 string, type fakeSessions struct { t testing.TB - updateSessionMetadata func(context.Context, sessions.UpdateSessionMetadataCommand) (sessions.Session, error) + updateSession func(context.Context, sessions.UpdateSessionCommand) (sessions.Session, error) deleteSession func(context.Context, sessions.DeleteSessionCommand) error auditSessionOperation func(context.Context, sessions.AuditSessionOperationCommand) error } -func (f *fakeSessions) UpdateSessionMetadata(a0 context.Context, a1 sessions.UpdateSessionMetadataCommand) (sessions.Session, error) { - if f.updateSessionMetadata == nil { - unexpectedCall(f.t, "UpdateSessionMetadata") +func (f *fakeSessions) UpdateSession(a0 context.Context, a1 sessions.UpdateSessionCommand) (sessions.Session, error) { + if f.updateSession == nil { + unexpectedCall(f.t, "UpdateSession") } - return f.updateSessionMetadata(a0, a1) + return f.updateSession(a0, a1) } func (f *fakeSessions) DeleteSession(a0 context.Context, a1 sessions.DeleteSessionCommand) error { diff --git a/services/core/internal/api/handler.go b/services/core/internal/api/handler.go index 6ebf8debc..188221aff 100644 --- a/services/core/internal/api/handler.go +++ b/services/core/internal/api/handler.go @@ -34,7 +34,7 @@ type SessionAdmission interface { // Sessions updates and deletes Sessions, and records their public write audit. type Sessions interface { - UpdateSessionMetadata(context.Context, sessions.UpdateSessionMetadataCommand) (sessions.Session, error) + UpdateSession(context.Context, sessions.UpdateSessionCommand) (sessions.Session, error) DeleteSession(context.Context, sessions.DeleteSessionCommand) error AuditSessionOperation(context.Context, sessions.AuditSessionOperationCommand) error } diff --git a/services/core/internal/api/resource_query_test.go b/services/core/internal/api/resource_query_test.go index bf5dde96c..1d52f9062 100644 --- a/services/core/internal/api/resource_query_test.go +++ b/services/core/internal/api/resource_query_test.go @@ -58,7 +58,7 @@ func (s *missingResourceStore) DeleteSession(_ context.Context, command sessions return s.missing(command.TenantID) } -func (s *missingResourceStore) UpdateSessionMetadata(_ context.Context, command sessions.UpdateSessionMetadataCommand) (sessions.Session, error) { +func (s *missingResourceStore) UpdateSession(_ context.Context, command sessions.UpdateSessionCommand) (sessions.Session, error) { return sessions.Session{}, s.missing(command.TenantID) } @@ -84,7 +84,7 @@ func (s *missingResourceStore) DeleteEnvironmentTemplate(_ context.Context, comm // wire serves the Agent, Session and Environment template lookups from s. func (s *missingResourceStore) wire(_ *Dependencies, f *testFakes) { f.agentsReader.getAgent, f.agents.delete, f.agents.update = s.GetAgent, s.DeleteAgent, s.UpdateAgent - f.sessionsReader.getSession, f.sessions.deleteSession, f.sessions.updateSessionMetadata = s.GetSession, s.DeleteSession, s.UpdateSessionMetadata + f.sessionsReader.getSession, f.sessions.deleteSession, f.sessions.updateSession = s.GetSession, s.DeleteSession, s.UpdateSession f.environmentTemplatesReader.get, f.environmentTemplates.update, f.environmentTemplates.delete = s.GetEnvironmentTemplate, s.UpdateEnvironmentTemplate, s.DeleteEnvironmentTemplate } diff --git a/services/core/internal/api/session_diagnostics_public_compat_test.go b/services/core/internal/api/session_diagnostics_public_compat_test.go index 78715c1ea..24cc8d5d1 100644 --- a/services/core/internal/api/session_diagnostics_public_compat_test.go +++ b/services/core/internal/api/session_diagnostics_public_compat_test.go @@ -40,7 +40,7 @@ func TestDiagnosticPublicCompatibility(t *testing.T) { if _, err = pool.Exec(t.Context(), "UPDATE sessions SET created_at='2026-09-28T00:00:00Z' WHERE id=$1", session.ID); err != nil { t.Fatal(err) } - if _, err = pool.Exec(t.Context(), `INSERT INTO turns(id,session_id,status,created_at,started_at,completed_at,outcome) VALUES($1,$2,'failed','2026-09-28T00:00:00Z','2026-09-28T00:00:01Z','2026-09-28T00:00:02Z','{"error_code":"engine_failed","error":"private-secret-canary"}')`, turn, session.ID); err != nil { + if _, err = pool.Exec(t.Context(), `INSERT INTO turns(id,session_id,status,created_at,started_at,completed_at,outcome,settings) VALUES($1,$2,'failed','2026-09-28T00:00:00Z','2026-09-28T00:00:01Z','2026-09-28T00:00:02Z','{"error_code":"engine_failed","error":"private-secret-canary"}','{}')`, turn, session.ID); err != nil { t.Fatal(err) } payload, _ := json.Marshal(map[string]any{"id": item, "turn_id": turn, "type": "command_execution", "status": "failed", "command": "example", "exit_code": 1, "duration_ms": 5}) diff --git a/services/core/internal/api/session_environment_http_test.go b/services/core/internal/api/session_environment_http_test.go index 3811843e5..31e84d561 100644 --- a/services/core/internal/api/session_environment_http_test.go +++ b/services/core/internal/api/session_environment_http_test.go @@ -25,7 +25,7 @@ func (f *environmentHTTPFixture) ListSessions(_ context.Context, tenant, _ strin return sessions.Page{Sessions: []sessions.Session{f.session}}, nil } -func (f *environmentHTTPFixture) UpdateSessionMetadata(ctx context.Context, command sessions.UpdateSessionMetadataCommand) (sessions.Session, error) { +func (f *environmentHTTPFixture) UpdateSession(ctx context.Context, command sessions.UpdateSessionCommand) (sessions.Session, error) { value, err := f.GetSession(ctx, command.TenantID, command.SessionID) value.Metadata = command.Metadata return value, err @@ -48,7 +48,7 @@ func TestSelfHostedSessionHTTPReadListMetadataAndLiveStream(t *testing.T) { TokenSHA256: runtimedevice.HashCredential("key"), TenantID: session.TenantID, }).ResolveAPIKey fixture.serve(fakes) - fakes.sessionsReader.listSessions, fakes.sessions.updateSessionMetadata = fixture.ListSessions, fixture.UpdateSessionMetadata + fakes.sessionsReader.listSessions, fakes.sessions.updateSession = fixture.ListSessions, fixture.UpdateSession // Self-hosted Sessions report the executor URL. deps.Execution.ExecutorURL = environmentOrigin handler := newTestHandler(t, deps) diff --git a/services/core/internal/api/session_metadata.go b/services/core/internal/api/session_metadata.go index 423e1dbc1..eb7df0e41 100644 --- a/services/core/internal/api/session_metadata.go +++ b/services/core/internal/api/session_metadata.go @@ -9,6 +9,7 @@ import ( "net/http" "slices" + "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/metadata" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/go-chi/chi/v5" @@ -20,36 +21,85 @@ func (h *Handler) updateSession(w http.ResponseWriter, r *http.Request) { return } var request struct { - Agent json.RawMessage `json:"agent"` + Agent *struct { + Model *string `json:"model"` + Reasoning *struct { + Effort json.RawMessage `json:"effort"` + } `json:"reasoning"` + ServiceTier json.RawMessage `json:"service_tier"` + Tools json.RawMessage `json:"tools"` + } `json:"agent"` Review *string `json:"review"` Metadata json.RawMessage `json:"metadata"` } _ = json.Unmarshal(raw, &request) - // An agent object without members changes nothing, as in the official update. - if request.Agent != nil && !emptyObject(request.Agent) { - writeFieldError(w, &fieldError{param: "agent", message: "Updating the Session agent is not supported."}) + if request.Agent != nil && request.Agent.Tools != nil { + writeFieldError(w, &fieldError{param: "agent.tools", message: "Updating the Session tools is not supported."}) return } - if err := rejectAutoReview(request.Review); writeFieldError(w, err) { + if request.Agent == nil && request.Review == nil && request.Metadata == nil { + writeError(w, http.StatusBadRequest, "invalid_request_error", "At least one update field is required") return } - if len(request.Metadata) == 0 { - if request.Review == nil && request.Agent == nil { - writeError(w, http.StatusBadRequest, "invalid_request_error", "At least one update field is required") + command := sessions.UpdateSessionCommand{TenantID: tenantID(r), SessionID: chi.URLParam(r, "session_id")} + if request.Metadata != nil { + var values map[string]string + _ = json.Unmarshal(request.Metadata, &values) + command.Metadata = metadataOrEmpty(values) + if writeFieldError(w, metadataFieldError(metadata.Validate(command.Metadata))) { return } - h.getSession(w, r) - return } - var values map[string]string - _ = json.Unmarshal(request.Metadata, &values) - values = metadataOrEmpty(values) - if writeFieldError(w, metadataFieldError(metadata.Validate(values))) { - return + configure := func(engine string, stored json.RawMessage) (json.RawMessage, error) { + var fields, agent, reasoning map[string]json.RawMessage + _ = json.Unmarshal(stored, &fields) // The stored configuration was validated at creation. + _ = json.Unmarshal(fields["agent"], &agent) + _ = json.Unmarshal(agent["reasoning"], &reasoning) + if reasoning == nil { + reasoning = map[string]json.RawMessage{} + } + if a := request.Agent; a != nil { + if a.Model != nil { + if _, err := harnessconfig.ValidateModel(*a.Model); err != nil { + return nil, &fieldError{param: "agent.model", message: err.Error()} + } + agent["model"], _ = json.Marshal(*a.Model) + } + if a.Reasoning != nil && a.Reasoning.Effort != nil { + reasoning["effort"] = a.Reasoning.Effort + } + if a.ServiceTier != nil { + agent["service_tier"] = a.ServiceTier + if isNull(a.ServiceTier) { + agent["service_tier"] = json.RawMessage(`"auto"`) + } + } + } + if request.Review != nil { + delete(fields, "review") + if *request.Review == "auto_review" { + fields["review"], _ = json.Marshal(*request.Review) + } + } + agent["reasoning"], _ = json.Marshal(reasoning) + fields["agent"], _ = json.Marshal(agent) + updated, err := json.Marshal(fields) + if err != nil { + return nil, err + } + if err := h.Policy.ValidateSessionUpdate(engine, stored, updated); err != nil { + return nil, &fieldError{message: err.Error(), code: "unsupported_or_invalid_configuration"} + } + return updated, nil + } + if request.Agent != nil || request.Review != nil { + command.Configure = configure } - session, err := h.Sessions.UpdateSessionMetadata(r.Context(), sessions.UpdateSessionMetadataCommand{TenantID: tenantID(r), SessionID: chi.URLParam(r, "session_id"), Metadata: values}) + session, err := h.Sessions.UpdateSession(r.Context(), command) if err != nil { - writeSessionsError(w, r, err) + if !writeFieldError(w, err) { + writeSessionsError(w, r, err) + } return } h.respondSession(w, r, session) @@ -120,11 +170,3 @@ func metadataFieldError(err error) error { } return err } - -// rejectAutoReview accepts review none; automatic review has no Harness-native equivalent. -func rejectAutoReview(review *string) error { - if review != nil && *review == "auto_review" { - return &fieldError{param: "review", message: "Automatic action review is not supported. Use review none."} - } - return nil -} diff --git a/services/core/internal/api/session_request.go b/services/core/internal/api/session_request.go index 1ad729348..2a1d2a8c4 100644 --- a/services/core/internal/api/session_request.go +++ b/services/core/internal/api/session_request.go @@ -47,9 +47,6 @@ func (request decodedSessionRequest) validated() (sessionRequest, error) { _ = json.Unmarshal(request.Execution, &fields) input.modelProviderNull = bytes.Equal(bytes.TrimSpace(fields["model_provider"]), []byte("null")) } - if err := rejectAutoReview(request.Review); err != nil { - return input, err - } input.VaultIDs = append([]string{}, request.VaultIDs...) canonicalEnvironment, err := preparationEnvironmentInput(request.Environment, input.XAgentsCore) if err != nil { diff --git a/services/core/internal/api/session_response.go b/services/core/internal/api/session_response.go index 271545cb8..f0cf6122a 100644 --- a/services/core/internal/api/session_response.go +++ b/services/core/internal/api/session_response.go @@ -41,6 +41,9 @@ func sessionResponse(session sessions.Session, executorURL string) (v1.Session, Metadata: session.Metadata, Object: "agent.session", Status: "idle", RequiredActions: []v1.RequiredAction{}, VaultIDs: append([]string{}, cfg.VaultIDs...), } + if cfg.Review != "" { + response.Review = &cfg.Review + } if turn := session.LastTurn; turn != nil { active := turn.CreatedAt if turn.StartedAt.After(active) { diff --git a/services/core/internal/api/unsupported_fields_test.go b/services/core/internal/api/unsupported_fields_test.go index 678e7d772..1a12e41ae 100644 --- a/services/core/internal/api/unsupported_fields_test.go +++ b/services/core/internal/api/unsupported_fields_test.go @@ -9,9 +9,7 @@ import ( func TestUnsupportedOfficialFieldsAreRejectedByParam(t *testing.T) { h, s := validationHandler(t) for _, test := range []struct{ path, body, param string }{ - {"/v1/agents/sessions/s", `{"agent":{"model":"m"}}`, "agent"}, - {"/v1/agents/sessions/s", `{"review":"auto_review"}`, "review"}, - {"/v1/agents/sessions", `{"agent":{"model":"m"},"review":"auto_review","environment":{"type":"none"},"input":"hi"}`, "review"}, + {"/v1/agents/sessions/s", `{"agent":{"tools":[]}}`, "agent.tools"}, {"/v1/agents/sessions", `{"agent":{"model":"m"},"environment":{"type":"openai_hosted","container_size":"small"},"input":"hi"}`, "container_size"}, {"/v1/agents/sessions", `{"agent":{"model":"m"},"environment":{"type":"openai_hosted","desktop":{"enabled":true}},"input":"hi"}`, "desktop.enabled"}, {"/v1/agents/sessions", `{"agent":{"model":"m"},"environment":{"type":"openai_hosted","network":{"access":"enabled","blocked_domains":["a.example"]}},"input":"hi"}`, "network.blocked_domains"}, diff --git a/services/core/internal/api/validation_errors_test.go b/services/core/internal/api/validation_errors_test.go index 01c769bb8..065a75733 100644 --- a/services/core/internal/api/validation_errors_test.go +++ b/services/core/internal/api/validation_errors_test.go @@ -38,7 +38,7 @@ func (s *validationStore) CreateVault(_ context.Context, input vaults.CreateVaul return vaults.Vault{ID: uuid.NewString(), TenantID: input.TenantID, Name: input.Name, Metadata: input.Metadata}, nil } -func (s *validationStore) UpdateSessionMetadata(_ context.Context, command sessions.UpdateSessionMetadataCommand) (sessions.Session, error) { +func (s *validationStore) UpdateSession(_ context.Context, command sessions.UpdateSessionCommand) (sessions.Session, error) { s.writes++ return sessions.Session{ID: command.SessionID, TenantID: command.TenantID, Metadata: command.Metadata, Configuration: json.RawMessage(`{"agent":{"id":"agent_validation","model":"validation-model"},"environment":{"type":"none"}}`)}, nil } @@ -64,7 +64,7 @@ func (s *validationStore) serve(d *Dependencies, f *testFakes) { f.sessionAdmission.createSession = s.CreateSession f.agents.create, f.agents.update = s.CreateAgent, s.UpdateAgent f.vaults.createVault = s.CreateVault - f.sessionsReader.getSession, f.sessions.updateSessionMetadata = nil, s.UpdateSessionMetadata + f.sessionsReader.getSession, f.sessions.updateSession = nil, s.UpdateSession f.environmentTemplates.create, f.environmentTemplates.update = s.CreateEnvironmentTemplate, s.UpdateEnvironmentTemplate } diff --git a/services/core/internal/db/queries/environment_inputs.sql b/services/core/internal/db/queries/environment_inputs.sql index 86f622e5d..6d696fbb8 100644 --- a/services/core/internal/db/queries/environment_inputs.sql +++ b/services/core/internal/db/queries/environment_inputs.sql @@ -1,7 +1,7 @@ -- name: CreateEnvironmentInputReservation :one WITH accepted AS (SELECT clock_timestamp() AS at) -INSERT INTO environment_input_reservations(id, session_id, idempotency_key, batch, is_initial, created_at, deadline) -SELECT $1, $2, $3, $4, $5, at, at + interval '5 minutes' FROM accepted +INSERT INTO environment_input_reservations(id, session_id, idempotency_key, batch, is_initial, settings, created_at, deadline) +SELECT $1, $2, $3, $4, $5, $6, at, at + interval '5 minutes' FROM accepted RETURNING *; -- name: FindEnvironmentInputReservation :one diff --git a/services/core/internal/db/queries/session_execution_configuration.sql b/services/core/internal/db/queries/session_execution_configuration.sql index 4d798dab0..79bfc01e2 100644 --- a/services/core/internal/db/queries/session_execution_configuration.sql +++ b/services/core/internal/db/queries/session_execution_configuration.sql @@ -8,3 +8,7 @@ SELECT s.id, s.engine, s.configuration AS session_configuration, FROM sessions s LEFT JOIN session_execution_configuration p ON p.session_id = s.id WHERE s.tenant_id = @tenant_id AND s.id = @session_id AND s.deleted_at IS NULL; + +-- name: MarkSessionModelSource :exec +UPDATE session_execution_configuration SET configuration = jsonb_set(configuration, '{model,source}', '"session"') +WHERE session_id = $1; diff --git a/services/core/internal/db/queries/sessions.sql b/services/core/internal/db/queries/sessions.sql index 0c61261bc..040bc7764 100644 --- a/services/core/internal/db/queries/sessions.sql +++ b/services/core/internal/db/queries/sessions.sql @@ -31,8 +31,14 @@ ORDER BY CASE WHEN NOT sqlc.arg(ascending)::boolean THEN id END DESC LIMIT sqlc.arg(page_limit); --- name: UpdateSessionMetadata :one -UPDATE sessions SET metadata = $3 WHERE tenant_id = $1 AND id = $2 AND deleted_at IS NULL RETURNING *; +-- name: LockVisibleSession :one +SELECT * FROM sessions WHERE tenant_id = $1 AND id = $2 AND deleted_at IS NULL FOR UPDATE; + +-- name: UpdateSession :one +UPDATE sessions SET configuration = $3, metadata = $4 WHERE tenant_id = $1 AND id = $2 RETURNING *; + +-- name: GetSessionConfiguration :one +SELECT configuration FROM sessions WHERE id = $1; -- name: FindSessionCreation :one SELECT * FROM sessions diff --git a/services/core/internal/db/queries/turns.sql b/services/core/internal/db/queries/turns.sql index 4fab9fdd3..58c7aa15d 100644 --- a/services/core/internal/db/queries/turns.sql +++ b/services/core/internal/db/queries/turns.sql @@ -5,7 +5,7 @@ SELECT id, deleted_at, engine FROM sessions WHERE tenant_id = $1 AND id = $2 FOR SELECT * FROM turns WHERE session_id = $1 AND status IN ('queued', 'in_progress', 'waiting'); -- name: CreateTurn :one -INSERT INTO turns(id, session_id) VALUES ($1, $2) RETURNING *; +INSERT INTO turns(id, session_id, settings) VALUES ($1, $2, $3) RETURNING *; -- name: GetTurn :one SELECT t.* FROM turns t JOIN sessions s ON s.id = t.session_id diff --git a/services/core/internal/db/sqlc/environment_input_activity.sql.go b/services/core/internal/db/sqlc/environment_input_activity.sql.go index a9609eb7f..b313e556a 100644 --- a/services/core/internal/db/sqlc/environment_input_activity.sql.go +++ b/services/core/internal/db/sqlc/environment_input_activity.sql.go @@ -17,7 +17,7 @@ SELECT r.state, r.is_initial, r.created_at, r.settled_at, r.failure_code, e.id A FROM environments e JOIN sessions s ON s.id = e.session_id JOIN LATERAL ( - SELECT id, session_id, idempotency_key, batch, state, created_at, deadline, settled_at, is_initial, failure_code FROM environment_input_reservations + SELECT id, session_id, idempotency_key, batch, state, created_at, deadline, settled_at, is_initial, failure_code, settings FROM environment_input_reservations WHERE session_id = e.session_id ORDER BY created_at DESC, id DESC LIMIT 1 ) r ON true diff --git a/services/core/internal/db/sqlc/environment_inputs.sql.go b/services/core/internal/db/sqlc/environment_inputs.sql.go index 73621d165..b3cb15542 100644 --- a/services/core/internal/db/sqlc/environment_inputs.sql.go +++ b/services/core/internal/db/sqlc/environment_inputs.sql.go @@ -53,9 +53,9 @@ func (q *Queries) CheckEnvironmentInputGate(ctx context.Context, arg CheckEnviro const createEnvironmentInputReservation = `-- name: CreateEnvironmentInputReservation :one WITH accepted AS (SELECT clock_timestamp() AS at) -INSERT INTO environment_input_reservations(id, session_id, idempotency_key, batch, is_initial, created_at, deadline) -SELECT $1, $2, $3, $4, $5, at, at + interval '5 minutes' FROM accepted -RETURNING id, session_id, idempotency_key, batch, state, created_at, deadline, settled_at, is_initial, failure_code +INSERT INTO environment_input_reservations(id, session_id, idempotency_key, batch, is_initial, settings, created_at, deadline) +SELECT $1, $2, $3, $4, $5, $6, at, at + interval '5 minutes' FROM accepted +RETURNING id, session_id, idempotency_key, batch, state, created_at, deadline, settled_at, is_initial, failure_code, settings ` type CreateEnvironmentInputReservationParams struct { @@ -64,6 +64,7 @@ type CreateEnvironmentInputReservationParams struct { IdempotencyKey string `json:"idempotency_key"` Batch []byte `json:"batch"` IsInitial bool `json:"is_initial"` + Settings []byte `json:"settings"` } func (q *Queries) CreateEnvironmentInputReservation(ctx context.Context, arg CreateEnvironmentInputReservationParams) (EnvironmentInputReservation, error) { @@ -73,6 +74,7 @@ func (q *Queries) CreateEnvironmentInputReservation(ctx context.Context, arg Cre arg.IdempotencyKey, arg.Batch, arg.IsInitial, + arg.Settings, ) var i EnvironmentInputReservation err := row.Scan( @@ -86,6 +88,7 @@ func (q *Queries) CreateEnvironmentInputReservation(ctx context.Context, arg Cre &i.SettledAt, &i.IsInitial, &i.FailureCode, + &i.Settings, ) return i, err } @@ -136,7 +139,7 @@ func (q *Queries) FailSessionEnvironmentInput(ctx context.Context, sessionID pgt } const findEnvironmentInputReservation = `-- name: FindEnvironmentInputReservation :one -SELECT r.id, r.session_id, r.idempotency_key, r.batch, r.state, r.created_at, r.deadline, r.settled_at, r.is_initial, r.failure_code, r.batch = $1::jsonb AS matches +SELECT r.id, r.session_id, r.idempotency_key, r.batch, r.state, r.created_at, r.deadline, r.settled_at, r.is_initial, r.failure_code, r.settings, r.batch = $1::jsonb AS matches FROM environment_input_reservations r WHERE r.session_id = $2 AND r.idempotency_key = $3 ` @@ -166,13 +169,14 @@ func (q *Queries) FindEnvironmentInputReservation(ctx context.Context, arg FindE &i.EnvironmentInputReservation.SettledAt, &i.EnvironmentInputReservation.IsInitial, &i.EnvironmentInputReservation.FailureCode, + &i.EnvironmentInputReservation.Settings, &i.Matches, ) return i, err } const getEnvironmentInputReservation = `-- name: GetEnvironmentInputReservation :one -SELECT id, session_id, idempotency_key, batch, state, created_at, deadline, settled_at, is_initial, failure_code FROM environment_input_reservations +SELECT id, session_id, idempotency_key, batch, state, created_at, deadline, settled_at, is_initial, failure_code, settings FROM environment_input_reservations WHERE session_id = $1 AND id = $2 ` @@ -195,6 +199,7 @@ func (q *Queries) GetEnvironmentInputReservation(ctx context.Context, arg GetEnv &i.SettledAt, &i.IsInitial, &i.FailureCode, + &i.Settings, ) return i, err } @@ -202,7 +207,7 @@ func (q *Queries) GetEnvironmentInputReservation(ctx context.Context, arg GetEnv const settleEnvironmentInputReservation = `-- name: SettleEnvironmentInputReservation :one UPDATE environment_input_reservations SET state = $1, settled_at = clock_timestamp() WHERE session_id = $2 AND id = $3 AND state = 'pending' -RETURNING id, session_id, idempotency_key, batch, state, created_at, deadline, settled_at, is_initial, failure_code +RETURNING id, session_id, idempotency_key, batch, state, created_at, deadline, settled_at, is_initial, failure_code, settings ` type SettleEnvironmentInputReservationParams struct { @@ -225,6 +230,7 @@ func (q *Queries) SettleEnvironmentInputReservation(ctx context.Context, arg Set &i.SettledAt, &i.IsInitial, &i.FailureCode, + &i.Settings, ) return i, err } diff --git a/services/core/internal/db/sqlc/models.go b/services/core/internal/db/sqlc/models.go index df010d010..c046b4d95 100644 --- a/services/core/internal/db/sqlc/models.go +++ b/services/core/internal/db/sqlc/models.go @@ -119,6 +119,7 @@ type EnvironmentInputReservation struct { SettledAt pgtype.Timestamptz `json:"settled_at"` IsInitial bool `json:"is_initial"` FailureCode pgtype.Text `json:"failure_code"` + Settings []byte `json:"settings"` } type EnvironmentSetup struct { @@ -503,6 +504,7 @@ type Turn struct { EventBytes int64 `json:"event_bytes"` TokenUsage []byte `json:"token_usage"` ArtifactCaptureStarted bool `json:"artifact_capture_started"` + Settings []byte `json:"settings"` } type TurnEvent struct { diff --git a/services/core/internal/db/sqlc/scheduling.sql.go b/services/core/internal/db/sqlc/scheduling.sql.go index aef12d2c8..3ca5348db 100644 --- a/services/core/internal/db/sqlc/scheduling.sql.go +++ b/services/core/internal/db/sqlc/scheduling.sql.go @@ -12,7 +12,7 @@ import ( ) const getLatestSessionTurn = `-- name: GetLatestSessionTurn :one -SELECT id, session_id, status, created_at, started_at, completed_at, cancel_requested_at, outcome, event_count, event_bytes, token_usage, artifact_capture_started FROM turns WHERE session_id = $1 ORDER BY created_at DESC, id DESC LIMIT 1 +SELECT id, session_id, status, created_at, started_at, completed_at, cancel_requested_at, outcome, event_count, event_bytes, token_usage, artifact_capture_started, settings FROM turns WHERE session_id = $1 ORDER BY created_at DESC, id DESC LIMIT 1 ` func (q *Queries) GetLatestSessionTurn(ctx context.Context, sessionID pgtype.UUID) (Turn, error) { @@ -31,6 +31,7 @@ func (q *Queries) GetLatestSessionTurn(ctx context.Context, sessionID pgtype.UUI &i.EventBytes, &i.TokenUsage, &i.ArtifactCaptureStarted, + &i.Settings, ) return i, err } diff --git a/services/core/internal/db/sqlc/session_events.sql.go b/services/core/internal/db/sqlc/session_events.sql.go index 4648deed9..1bc9f060a 100644 --- a/services/core/internal/db/sqlc/session_events.sql.go +++ b/services/core/internal/db/sqlc/session_events.sql.go @@ -112,7 +112,7 @@ func (q *Queries) SessionEventCursor(ctx context.Context, arg SessionEventCursor } const sessionEventTurn = `-- name: SessionEventTurn :one -SELECT id, session_id, status, created_at, started_at, completed_at, cancel_requested_at, outcome, event_count, event_bytes, token_usage, artifact_capture_started FROM turns WHERE session_id = $1 AND id = $2 +SELECT id, session_id, status, created_at, started_at, completed_at, cancel_requested_at, outcome, event_count, event_bytes, token_usage, artifact_capture_started, settings FROM turns WHERE session_id = $1 AND id = $2 ` type SessionEventTurnParams struct { @@ -136,6 +136,7 @@ func (q *Queries) SessionEventTurn(ctx context.Context, arg SessionEventTurnPara &i.EventBytes, &i.TokenUsage, &i.ArtifactCaptureStarted, + &i.Settings, ) return i, err } diff --git a/services/core/internal/db/sqlc/session_execution_configuration.sql.go b/services/core/internal/db/sqlc/session_execution_configuration.sql.go index 548bc0226..c9ca7a553 100644 --- a/services/core/internal/db/sqlc/session_execution_configuration.sql.go +++ b/services/core/internal/db/sqlc/session_execution_configuration.sql.go @@ -43,6 +43,16 @@ func (q *Queries) GetSessionExecutionConfiguration(ctx context.Context, arg GetS return i, err } +const markSessionModelSource = `-- name: MarkSessionModelSource :exec +UPDATE session_execution_configuration SET configuration = jsonb_set(configuration, '{model,source}', '"session"') +WHERE session_id = $1 +` + +func (q *Queries) MarkSessionModelSource(ctx context.Context, sessionID pgtype.UUID) error { + _, err := q.db.Exec(ctx, markSessionModelSource, sessionID) + return err +} + const saveSessionExecutionConfiguration = `-- name: SaveSessionExecutionConfiguration :exec INSERT INTO session_execution_configuration(session_id, configuration, deployment_provider_revision) VALUES ($1, $2, $3) diff --git a/services/core/internal/db/sqlc/sessions.sql.go b/services/core/internal/db/sqlc/sessions.sql.go index bf5ff735b..7403eb0a6 100644 --- a/services/core/internal/db/sqlc/sessions.sql.go +++ b/services/core/internal/db/sqlc/sessions.sql.go @@ -130,6 +130,17 @@ func (q *Queries) GetSession(ctx context.Context, arg GetSessionParams) (Session return i, err } +const getSessionConfiguration = `-- name: GetSessionConfiguration :one +SELECT configuration FROM sessions WHERE id = $1 +` + +func (q *Queries) GetSessionConfiguration(ctx context.Context, id pgtype.UUID) ([]byte, error) { + row := q.db.QueryRow(ctx, getSessionConfiguration, id) + var configuration []byte + err := row.Scan(&configuration) + return configuration, err +} + const getSessionCursor = `-- name: GetSessionCursor :one SELECT created_at, id FROM sessions WHERE tenant_id = $1 AND id = $2 ` @@ -219,6 +230,36 @@ func (q *Queries) ListSessions(ctx context.Context, arg ListSessionsParams) ([]S return items, nil } +const lockVisibleSession = `-- name: LockVisibleSession :one +SELECT id, tenant_id, engine, metadata, idempotency_key, request_hash, created_at, configuration, event_sequence, creation_request_hash, deleted_at, creator_kind, creator_id FROM sessions WHERE tenant_id = $1 AND id = $2 AND deleted_at IS NULL FOR UPDATE +` + +type LockVisibleSessionParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) LockVisibleSession(ctx context.Context, arg LockVisibleSessionParams) (Session, error) { + row := q.db.QueryRow(ctx, lockVisibleSession, arg.TenantID, arg.ID) + var i Session + err := row.Scan( + &i.ID, + &i.TenantID, + &i.Engine, + &i.Metadata, + &i.IdempotencyKey, + &i.RequestHash, + &i.CreatedAt, + &i.Configuration, + &i.EventSequence, + &i.CreationRequestHash, + &i.DeletedAt, + &i.CreatorKind, + &i.CreatorID, + ) + return i, err +} + const markSessionDeleted = `-- name: MarkSessionDeleted :exec UPDATE sessions SET deleted_at = clock_timestamp() WHERE id = $1 AND deleted_at IS NULL ` @@ -228,18 +269,24 @@ func (q *Queries) MarkSessionDeleted(ctx context.Context, id pgtype.UUID) error return err } -const updateSessionMetadata = `-- name: UpdateSessionMetadata :one -UPDATE sessions SET metadata = $3 WHERE tenant_id = $1 AND id = $2 AND deleted_at IS NULL RETURNING id, tenant_id, engine, metadata, idempotency_key, request_hash, created_at, configuration, event_sequence, creation_request_hash, deleted_at, creator_kind, creator_id +const updateSession = `-- name: UpdateSession :one +UPDATE sessions SET configuration = $3, metadata = $4 WHERE tenant_id = $1 AND id = $2 RETURNING id, tenant_id, engine, metadata, idempotency_key, request_hash, created_at, configuration, event_sequence, creation_request_hash, deleted_at, creator_kind, creator_id ` -type UpdateSessionMetadataParams struct { - TenantID pgtype.UUID `json:"tenant_id"` - ID pgtype.UUID `json:"id"` - Metadata []byte `json:"metadata"` +type UpdateSessionParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` + Configuration []byte `json:"configuration"` + Metadata []byte `json:"metadata"` } -func (q *Queries) UpdateSessionMetadata(ctx context.Context, arg UpdateSessionMetadataParams) (Session, error) { - row := q.db.QueryRow(ctx, updateSessionMetadata, arg.TenantID, arg.ID, arg.Metadata) +func (q *Queries) UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error) { + row := q.db.QueryRow(ctx, updateSession, + arg.TenantID, + arg.ID, + arg.Configuration, + arg.Metadata, + ) var i Session err := row.Scan( &i.ID, diff --git a/services/core/internal/db/sqlc/turn_reads.sql.go b/services/core/internal/db/sqlc/turn_reads.sql.go index dae4de7eb..52385cabc 100644 --- a/services/core/internal/db/sqlc/turn_reads.sql.go +++ b/services/core/internal/db/sqlc/turn_reads.sql.go @@ -12,7 +12,7 @@ import ( ) const listRootTurns = `-- name: ListRootTurns :many -SELECT t.id, t.session_id, t.status, t.created_at, t.started_at, t.completed_at, t.cancel_requested_at, t.outcome, t.event_count, t.event_bytes, t.token_usage, t.artifact_capture_started FROM turns t JOIN sessions s ON s.id = t.session_id +SELECT t.id, t.session_id, t.status, t.created_at, t.started_at, t.completed_at, t.cancel_requested_at, t.outcome, t.event_count, t.event_bytes, t.token_usage, t.artifact_capture_started, t.settings FROM turns t JOIN sessions s ON s.id = t.session_id WHERE s.tenant_id = $1 AND t.session_id = $2 AND ($3::timestamptz IS NULL OR (NOT $4::boolean AND (t.created_at, t.id) < ($3::timestamptz, $5::uuid)) @@ -65,6 +65,7 @@ func (q *Queries) ListRootTurns(ctx context.Context, arg ListRootTurnsParams) ([ &i.EventBytes, &i.TokenUsage, &i.ArtifactCaptureStarted, + &i.Settings, ); err != nil { return nil, err } diff --git a/services/core/internal/db/sqlc/turns.sql.go b/services/core/internal/db/sqlc/turns.sql.go index 0100959ee..09359d13d 100644 --- a/services/core/internal/db/sqlc/turns.sql.go +++ b/services/core/internal/db/sqlc/turns.sql.go @@ -12,16 +12,17 @@ import ( ) const createTurn = `-- name: CreateTurn :one -INSERT INTO turns(id, session_id) VALUES ($1, $2) RETURNING id, session_id, status, created_at, started_at, completed_at, cancel_requested_at, outcome, event_count, event_bytes, token_usage, artifact_capture_started +INSERT INTO turns(id, session_id, settings) VALUES ($1, $2, $3) RETURNING id, session_id, status, created_at, started_at, completed_at, cancel_requested_at, outcome, event_count, event_bytes, token_usage, artifact_capture_started, settings ` type CreateTurnParams struct { ID pgtype.UUID `json:"id"` SessionID pgtype.UUID `json:"session_id"` + Settings []byte `json:"settings"` } func (q *Queries) CreateTurn(ctx context.Context, arg CreateTurnParams) (Turn, error) { - row := q.db.QueryRow(ctx, createTurn, arg.ID, arg.SessionID) + row := q.db.QueryRow(ctx, createTurn, arg.ID, arg.SessionID, arg.Settings) var i Turn err := row.Scan( &i.ID, @@ -36,6 +37,7 @@ func (q *Queries) CreateTurn(ctx context.Context, arg CreateTurnParams) (Turn, e &i.EventBytes, &i.TokenUsage, &i.ArtifactCaptureStarted, + &i.Settings, ) return i, err } @@ -113,7 +115,7 @@ func (q *Queries) FindInputBatch(ctx context.Context, arg FindInputBatchParams) } const getActiveTurn = `-- name: GetActiveTurn :one -SELECT id, session_id, status, created_at, started_at, completed_at, cancel_requested_at, outcome, event_count, event_bytes, token_usage, artifact_capture_started FROM turns WHERE session_id = $1 AND status IN ('queued', 'in_progress', 'waiting') +SELECT id, session_id, status, created_at, started_at, completed_at, cancel_requested_at, outcome, event_count, event_bytes, token_usage, artifact_capture_started, settings FROM turns WHERE session_id = $1 AND status IN ('queued', 'in_progress', 'waiting') ` func (q *Queries) GetActiveTurn(ctx context.Context, sessionID pgtype.UUID) (Turn, error) { @@ -132,12 +134,13 @@ func (q *Queries) GetActiveTurn(ctx context.Context, sessionID pgtype.UUID) (Tur &i.EventBytes, &i.TokenUsage, &i.ArtifactCaptureStarted, + &i.Settings, ) return i, err } const getTurn = `-- name: GetTurn :one -SELECT t.id, t.session_id, t.status, t.created_at, t.started_at, t.completed_at, t.cancel_requested_at, t.outcome, t.event_count, t.event_bytes, t.token_usage, t.artifact_capture_started FROM turns t JOIN sessions s ON s.id = t.session_id +SELECT t.id, t.session_id, t.status, t.created_at, t.started_at, t.completed_at, t.cancel_requested_at, t.outcome, t.event_count, t.event_bytes, t.token_usage, t.artifact_capture_started, t.settings FROM turns t JOIN sessions s ON s.id = t.session_id WHERE s.tenant_id = $1 AND t.session_id = $2 AND t.id = $3 ` @@ -163,6 +166,7 @@ func (q *Queries) GetTurn(ctx context.Context, arg GetTurnParams) (Turn, error) &i.EventBytes, &i.TokenUsage, &i.ArtifactCaptureStarted, + &i.Settings, ) return i, err } @@ -283,7 +287,7 @@ WHERE id = $4 AND session_id = $5 AND status = $6 AND status IN ('queued', 'in_progress', 'waiting') AND ($1::text <> 'in_progress' OR cancel_requested_at IS NULL) -RETURNING id, session_id, status, created_at, started_at, completed_at, cancel_requested_at, outcome, event_count, event_bytes, token_usage, artifact_capture_started +RETURNING id, session_id, status, created_at, started_at, completed_at, cancel_requested_at, outcome, event_count, event_bytes, token_usage, artifact_capture_started, settings ` type TransitionTurnParams struct { @@ -318,6 +322,7 @@ func (q *Queries) TransitionTurn(ctx context.Context, arg TransitionTurnParams) &i.EventBytes, &i.TokenUsage, &i.ArtifactCaptureStarted, + &i.Settings, ) return i, err } diff --git a/services/core/internal/execution/controls.go b/services/core/internal/execution/controls.go index d632b7b34..cda042253 100644 --- a/services/core/internal/execution/controls.go +++ b/services/core/internal/execution/controls.go @@ -3,6 +3,7 @@ package execution import ( v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" ) // executionControls assembles the official execution options for the Runtime. @@ -24,15 +25,15 @@ func executionControls(agent v1.Agent, tools executionToolSet) *proto.ExecutionC return controls } -// turnSettings assembles the root Turn's settings from the Session snapshot. -// Auto service tier means unset. -func turnSettings(agent v1.Agent) proto.TurnSettings { - turn := proto.TurnSettings{Model: agent.Model} - if agent.Reasoning.Effort != nil { - turn.ReasoningEffort = *agent.Reasoning.Effort +// turnSettings assembles the Runtime's settings from the settings a root Turn +// bound. Auto service tier means unset. +func turnSettings(bound sessions.TurnSettings) proto.TurnSettings { + turn := proto.TurnSettings{Model: bound.Model} + if bound.ReasoningEffort != nil { + turn.ReasoningEffort = *bound.ReasoningEffort } - if agent.ServiceTier != "auto" { - turn.ServiceTier = agent.ServiceTier + if bound.ServiceTier != "auto" { + turn.ServiceTier = bound.ServiceTier } return turn } diff --git a/services/core/internal/execution/controls_test.go b/services/core/internal/execution/controls_test.go index 40a00c17d..d13b51e8d 100644 --- a/services/core/internal/execution/controls_test.go +++ b/services/core/internal/execution/controls_test.go @@ -85,7 +85,7 @@ func TestExecutionControlsCarryOnlyOfficialValues(t *testing.T) { snapshot := Snapshot{Agent: tc.agent} before, _ := json.Marshal(snapshot) for _, nativeID := range []string{"", "native-session"} { - request, err := (&Dispatcher{}).executionRequest(t.Context(), sessions.Session{ID: "session"}, snapshot, proto.AgentKindCapabilities{}, sessions.ExecutionBinding{NativeSessionID: nativeID}) + request, err := (&Dispatcher{}).executionRequest(t.Context(), sessions.Session{ID: "session"}, snapshot, proto.AgentKindCapabilities{}, sessions.ExecutionBinding{NativeSessionID: nativeID}, sessions.TurnSettings{}) if err != nil || !reflect.DeepEqual(*request.ExecutionControls, tc.want) || request.AgentSessionID != nativeID { t.Fatalf("%+v %v", request.ExecutionControls, err) } @@ -97,14 +97,14 @@ func TestExecutionControlsCarryOnlyOfficialValues(t *testing.T) { } } -func TestTurnSettingsComeFromTheSnapshot(t *testing.T) { +func TestTurnSettingsComeFromTheBoundSettings(t *testing.T) { effort := "max" - snapshot := Snapshot{Agent: v1.Agent{Model: "m", ServiceTier: "flex", Reasoning: v1.Reasoning{Effort: &effort}}} - request, err := (&Dispatcher{}).executionRequest(t.Context(), sessions.Session{ID: "session"}, snapshot, proto.AgentKindCapabilities{}, sessions.ExecutionBinding{}) + snapshot := Snapshot{Agent: v1.Agent{Model: "later", ServiceTier: "auto"}} + request, err := (&Dispatcher{}).executionRequest(t.Context(), sessions.Session{ID: "session"}, snapshot, proto.AgentKindCapabilities{}, sessions.ExecutionBinding{}, sessions.TurnSettings{Model: "m", ServiceTier: "flex", ReasoningEffort: &effort}) if err != nil || request.Turn != (proto.TurnSettings{Model: "m", ReasoningEffort: "max", ServiceTier: "flex"}) || request.AgentOptions["model"] != nil { t.Fatalf("%+v %v", request.Turn, err) } - if turnSettings(v1.Agent{Model: "m", ServiceTier: "auto"}) != (proto.TurnSettings{Model: "m"}) { + if turnSettings(sessions.TurnSettings{Model: "m", ServiceTier: "auto"}) != (proto.TurnSettings{Model: "m"}) { t.Fatal("auto service tier was sent") } } diff --git a/services/core/internal/execution/dispatcher.go b/services/core/internal/execution/dispatcher.go index 1f0c5879a..334f36ec0 100644 --- a/services/core/internal/execution/dispatcher.go +++ b/services/core/internal/execution/dispatcher.go @@ -21,6 +21,7 @@ type Snapshot struct { ModelProviderConfigured bool `json:"model_provider_configured,omitempty"` Agent v1.Agent `json:"agent"` Environment v1.Environment `json:"environment"` + Review string `json:"review,omitempty"` VaultIDs []string `json:"vault_ids,omitempty"` MCPCredentials []vaults.MCPCredentialBinding `json:"mcp_credentials,omitempty"` } @@ -104,7 +105,11 @@ func (d *Dispatcher) Run(ctx context.Context, tenantID, sessionID, turnID string if err := d.messageInputSupport(peer, session.Engine, snapshot, text); err != nil { return sessions.Turn{}, err } - req, err := d.executionRequest(ctx, session, snapshot, caps, bound) + turn, err := d.SessionsReader.GetTurn(ctx, tenantID, sessionID, turnID) + if err != nil { + return sessions.Turn{}, err + } + req, err := d.executionRequest(ctx, session, snapshot, caps, bound, turn.Settings) if err != nil { return sessions.Turn{}, err } diff --git a/services/core/internal/execution/harness_admission.go b/services/core/internal/execution/harness_admission.go index f3341e548..afea54a0c 100644 --- a/services/core/internal/execution/harness_admission.go +++ b/services/core/internal/execution/harness_admission.go @@ -63,7 +63,11 @@ func validateDeclaredConfiguration(declaration harnessconfig.Declaration, snapsh if err := declaration.ValidateExecutionControls(executionControls(snapshot.Agent, tools)); err != nil { return err } - if err := declaration.ValidateTurnSettings(turnSettings(snapshot.Agent)); err != nil { + bound := sessions.TurnSettings{Model: snapshot.Agent.Model, ReasoningEffort: snapshot.Agent.Reasoning.Effort, ServiceTier: snapshot.Agent.ServiceTier} + if err := declaration.ValidateTurnSettings(turnSettings(bound)); err != nil { + return err + } + if err := declaration.ValidateReview(snapshot.Review); err != nil { return err } request := proto.PromptRequestPayload{ToolSearch: tools.Search, FunctionTools: tools.Functions} diff --git a/services/core/internal/execution/mcp_support_test.go b/services/core/internal/execution/mcp_support_test.go index be2ced897..311aca2a6 100644 --- a/services/core/internal/execution/mcp_support_test.go +++ b/services/core/internal/execution/mcp_support_test.go @@ -57,7 +57,7 @@ func TestMCPPublicBearerPolicyIsIndependentOfRuntimeCapabilities(t *testing.T) { } credentials := &recordingCredentials{token: "scoped-token"} session := sessions.Session{TenantID: uuid.NewString(), Engine: engine} - request, err := (&Dispatcher{Credentials: credentials}).executionRequest(t.Context(), session, snapshot, caps, sessions.ExecutionBinding{}) + request, err := (&Dispatcher{Credentials: credentials}).executionRequest(t.Context(), session, snapshot, caps, sessions.ExecutionBinding{}, sessions.TurnSettings{}) if !allowed { if err == nil || err.Error() != "The configured engine does not support this MCP connection origin." || request.MCPHTTPServers != nil || len(credentials.requests) != 0 { t.Fatal("unverified profile bypassed public policy", err) @@ -111,7 +111,7 @@ func TestMCPExecutionChecksRequireVerifiedCapabilityCombinations(t *testing.T) { } if !allowed { credentials := &recordingCredentials{token: "scoped-token"} - request, requestErr := (&Dispatcher{Credentials: credentials}).executionRequest(t.Context(), sessions.Session{Engine: "codex"}, snapshot, caps, sessions.ExecutionBinding{}) + request, requestErr := (&Dispatcher{Credentials: credentials}).executionRequest(t.Context(), sessions.Session{Engine: "codex"}, snapshot, caps, sessions.ExecutionBinding{}, sessions.TurnSettings{}) if requestErr == nil || request.MCPHTTPServers != nil || len(credentials.requests) != 0 { t.Fatal("request bypassed capability checks before credential lookup", requestErr) } @@ -143,7 +143,7 @@ func TestMCPAnonymousExecutionPreservesFrozenDecision(t *testing.T) { t.Fatal("anonymous binding validation changed", err) } credentials := &recordingCredentials{token: "scoped-token"} - request, err := (&Dispatcher{Credentials: credentials}).executionRequest(t.Context(), sessions.Session{Engine: engine}, snapshot, caps, sessions.ExecutionBinding{}) + request, err := (&Dispatcher{Credentials: credentials}).executionRequest(t.Context(), sessions.Session{Engine: engine}, snapshot, caps, sessions.ExecutionBinding{}, sessions.TurnSettings{}) if len(credentials.requests) != 0 { t.Fatal("anonymous or invalid binding reached credential lookup") } diff --git a/services/core/internal/execution/model_execution_test.go b/services/core/internal/execution/model_execution_test.go index 41912d376..6c54429f2 100644 --- a/services/core/internal/execution/model_execution_test.go +++ b/services/core/internal/execution/model_execution_test.go @@ -18,14 +18,14 @@ func TestSessionModelExecutionNeverFallsBack(t *testing.T) { reader, _ := testSessions(t, pgtest.Open(t), nil) d := Dispatcher{SessionsReader: reader} session := sessions.Session{TenantID: uuid.NewString(), ID: uuid.NewString(), Engine: "codex"} - if _, err := d.executionRequest(t.Context(), session, Snapshot{ModelProviderConfigured: true}, proto.AgentKindCapabilities{}, sessions.ExecutionBinding{}); !errors.Is(err, sessions.ErrNotFound) { + if _, err := d.executionRequest(t.Context(), session, Snapshot{ModelProviderConfigured: true}, proto.AgentKindCapabilities{}, sessions.ExecutionBinding{}, sessions.TurnSettings{}); !errors.Is(err, sessions.ErrNotFound) { t.Fatal("missing Session credentials fell back", err) } // A none device without a frozen provider uses its own provider environment: // Core sends only the Agent's model and instructions. instructions := "Keep this instruction." snapshot := Snapshot{Agent: v1.Agent{Model: "device-model", Instructions: &instructions}, Environment: v1.Environment{Type: "none"}} - request, err := d.executionRequest(t.Context(), sessions.Session{Engine: "codex"}, snapshot, proto.AgentKindCapabilities{}, sessions.ExecutionBinding{}) + request, err := d.executionRequest(t.Context(), sessions.Session{Engine: "codex"}, snapshot, proto.AgentKindCapabilities{}, sessions.ExecutionBinding{}, sessions.TurnSettings{Model: "device-model"}) if err != nil || request.Turn.Model != "device-model" || !reflect.DeepEqual(request.AgentOptions, map[string]any{"system_prompt": &instructions}) { t.Fatal("none Session received adapter options Core does not own", request.Turn, request.AgentOptions, err) } diff --git a/services/core/internal/execution/prepared_dispatch.go b/services/core/internal/execution/prepared_dispatch.go index fe0a67d90..026176f97 100644 --- a/services/core/internal/execution/prepared_dispatch.go +++ b/services/core/internal/execution/prepared_dispatch.go @@ -54,7 +54,7 @@ func (d *Dispatcher) RunEnvironmentInput(ctx context.Context, lease Ownership, t } owner, cancel := context.WithCancel(ctx) defer cancel() - req, err := d.executionRequest(owner, session, snapshot, caps, bound) + req, err := d.executionRequest(owner, session, snapshot, caps, bound, run.Reservation.Settings) if err != nil { return run, err } diff --git a/services/core/internal/execution/recovery_test.go b/services/core/internal/execution/recovery_test.go index ca99e03b8..99642d06c 100644 --- a/services/core/internal/execution/recovery_test.go +++ b/services/core/internal/execution/recovery_test.go @@ -13,7 +13,7 @@ func TestExistingSessionRecoveryRequiresVerifiedCapability(t *testing.T) { for _, nativeID := range []string{"", "native"} { for _, capable := range []bool{false, true} { wantRecovery := started && nativeID == "" - req, err := (&Dispatcher{}).executionRequest(t.Context(), sessions.Session{ID: "session", Engine: engine}, Snapshot{}, proto.AgentKindCapabilities{NativeSessionRecovery: proto.CapabilityFromBool(capable)}, sessions.ExecutionBinding{HasStartedTurn: started, NativeSessionID: nativeID}) + req, err := (&Dispatcher{}).executionRequest(t.Context(), sessions.Session{ID: "session", Engine: engine}, Snapshot{}, proto.AgentKindCapabilities{NativeSessionRecovery: proto.CapabilityFromBool(capable)}, sessions.ExecutionBinding{HasStartedTurn: started, NativeSessionID: nativeID}, sessions.TurnSettings{}) if wantRecovery && !capable { if err == nil { t.Fatal("unverified recovery admitted", engine) diff --git a/services/core/internal/execution/request.go b/services/core/internal/execution/request.go index 0762491af..476913b76 100644 --- a/services/core/internal/execution/request.go +++ b/services/core/internal/execution/request.go @@ -10,7 +10,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/vaults" ) -func (d *Dispatcher) executionRequest(ctx context.Context, session sessions.Session, snapshot Snapshot, caps proto.AgentKindCapabilities, bound sessions.ExecutionBinding) (proto.PromptRequestPayload, error) { +func (d *Dispatcher) executionRequest(ctx context.Context, session sessions.Session, snapshot Snapshot, caps proto.AgentKindCapabilities, bound sessions.ExecutionBinding, turn sessions.TurnSettings) (proto.PromptRequestPayload, error) { recoverNativeSession := bound.HasStartedTurn && bound.NativeSessionID == "" if recoverNativeSession && !caps.NativeSessionRecovery.IsSupported() { return proto.PromptRequestPayload{}, errors.New("native session recovery is unavailable") @@ -35,7 +35,7 @@ func (d *Dispatcher) executionRequest(ctx context.Context, session sessions.Sess options["harness_config"] = native } request := proto.PromptRequestPayload{AgentKind: session.Engine, FunctionTools: tools.Functions, ToolSearch: tools.Search, - Turn: turnSettings(snapshot.Agent), AgentOptions: options, ExecutionControls: executionControls(snapshot.Agent, tools), AgentStateKey: session.ID, + Turn: turnSettings(turn), AgentOptions: options, ExecutionControls: executionControls(snapshot.Agent, tools), AgentStateKey: session.ID, AgentSessionID: bound.NativeSessionID, RequireExistingNativeSession: recoverNativeSession, ObserveSubagentIdentities: snapshot.Agent.MultiAgent.Enabled, MaxConcurrentSubagents: snapshot.Agent.MultiAgent.MaxConcurrentSubagents, diff --git a/services/core/internal/execution/structured_output_test.go b/services/core/internal/execution/structured_output_test.go index 7072fb259..dbda52dad 100644 --- a/services/core/internal/execution/structured_output_test.go +++ b/services/core/internal/execution/structured_output_test.go @@ -33,7 +33,7 @@ func TestStructuredOutputRequestKeepsFrozenSchemaAndInstructions(t *testing.T) { schema := json.RawMessage(`{"type":"object","properties":{"number":{"const":9007199254740992}}}`) instructions := "Keep these original instructions." snapshot := Snapshot{Agent: v1.Agent{Model: "model", Instructions: &instructions, Text: v1.TextConfig{Format: v1.TextFormat{Type: "json_schema", Schema: schema}}}} - request, err := (&Dispatcher{}).executionRequest(context.Background(), sessions.Session{}, snapshot, proto.AgentKindCapabilities{MessageItems: proto.CapabilitySupported}, sessions.ExecutionBinding{}) + request, err := (&Dispatcher{}).executionRequest(context.Background(), sessions.Session{}, snapshot, proto.AgentKindCapabilities{MessageItems: proto.CapabilitySupported}, sessions.ExecutionBinding{}, sessions.TurnSettings{}) if err != nil || request.ExecutionControls.OutputFormat == nil { t.Fatal(err) } diff --git a/services/core/internal/execution/support.go b/services/core/internal/execution/support.go index b8fe1312c..07ce721b2 100644 --- a/services/core/internal/execution/support.go +++ b/services/core/internal/execution/support.go @@ -42,6 +42,24 @@ func (p Policy) ValidateSessionConfiguration(engine string, configuration json.R return validateDeclaredConfiguration(declaration, snapshot) } +// ValidateSessionUpdate checks the configuration an update produces and that +// the Harness applies each changed setting natively. +func (p Policy) ValidateSessionUpdate(engine string, before, after json.RawMessage) error { + if err := p.ValidateSessionConfiguration(engine, after); err != nil { + return err + } + old, err := sessions.TurnSettingsOf(before) + if err != nil { + return err + } + updated, err := sessions.TurnSettingsOf(after) + if err != nil { + return err + } + declaration, _ := p.Harnesses().Lookup(engine) + return declaration.ValidateSessionUpdate(turnSettings(old), turnSettings(updated)) +} + func (p Policy) canAdmitInputs(engine string, configuration json.RawMessage) bool { var snapshot Snapshot if json.Unmarshal(configuration, &snapshot) != nil || !environmentNone(snapshot) { diff --git a/services/core/internal/persistence/postgres/coremetricspg/store_test.go b/services/core/internal/persistence/postgres/coremetricspg/store_test.go index db5d8edba..e6fd748cd 100644 --- a/services/core/internal/persistence/postgres/coremetricspg/store_test.go +++ b/services/core/internal/persistence/postgres/coremetricspg/store_test.go @@ -35,8 +35,8 @@ func coreMetricsSession(t *testing.T, pool *pgxpool.Pool, deleted bool) string { func coreMetricsTurn(t *testing.T, pool *pgxpool.Pool, session, status, code string, created time.Time, started, completed *time.Time) { t.Helper() - _, err := pool.Exec(t.Context(), `INSERT INTO turns(id,session_id,status,created_at,started_at,completed_at,outcome) - VALUES($1,$2,$3,$4,$5,$6,jsonb_build_object('error_code',$7::text))`, uuid.NewString(), session, status, created, started, completed, code) + _, err := pool.Exec(t.Context(), `INSERT INTO turns(id,session_id,status,created_at,started_at,completed_at,outcome,settings) + VALUES($1,$2,$3,$4,$5,$6,jsonb_build_object('error_code',$7::text),'{}')`, uuid.NewString(), session, status, created, started, completed, code) if err != nil { t.Fatal(err) } diff --git a/services/core/internal/persistence/postgres/deploymentpg/suspension_pressure_test.go b/services/core/internal/persistence/postgres/deploymentpg/suspension_pressure_test.go index 898bf2559..aba54d3a3 100644 --- a/services/core/internal/persistence/postgres/deploymentpg/suspension_pressure_test.go +++ b/services/core/internal/persistence/postgres/deploymentpg/suspension_pressure_test.go @@ -295,7 +295,7 @@ func TestPressureReclamationIgnoresUnavailableNodeReceipts(t *testing.T) { f.connect(t, node.NodeID) waiting := pendingPressureDemand(t, f) // A real pending reservation supplies demand and retains its original deadline. - if _, err := f.pool.Exec(t.Context(), `INSERT INTO environment_input_reservations(id,session_id,idempotency_key,batch,created_at,deadline) SELECT $1,session_id,'pressure','[{}]',clock_timestamp(),clock_timestamp()+interval '5 minutes' FROM environments WHERE id=$2`, uuid.NewString(), waiting.EnvironmentID); err != nil { + if _, err := f.pool.Exec(t.Context(), `INSERT INTO environment_input_reservations(id,session_id,idempotency_key,batch,settings,created_at,deadline) SELECT $1,session_id,'pressure','[{}]','{}',clock_timestamp(),clock_timestamp()+interval '5 minutes' FROM environments WHERE id=$2`, uuid.NewString(), waiting.EnvironmentID); err != nil { t.Fatal(err) } owner := runningPressureOwner(t, f, changes, installation, node.NodeID, view.Generation) diff --git a/services/core/internal/persistence/postgres/modelconfigurationpg/fixture_test.go b/services/core/internal/persistence/postgres/modelconfigurationpg/fixture_test.go index 32152bb75..053da7579 100644 --- a/services/core/internal/persistence/postgres/modelconfigurationpg/fixture_test.go +++ b/services/core/internal/persistence/postgres/modelconfigurationpg/fixture_test.go @@ -128,7 +128,7 @@ func (o observed) terminal(t *testing.T, sessionID, status, errorCode, engineErr t.Helper() outcome, _ := json.Marshal(map[string]string{"error_code": errorCode, "engine_error_code": engineErrorCode}) id := uuid.NewString() - if _, err := o.pool.Exec(t.Context(), "INSERT INTO turns(id,session_id,status,outcome,completed_at) VALUES($1,$2,$3,$4,clock_timestamp())", id, sessionID, status, outcome); err != nil { + if _, err := o.pool.Exec(t.Context(), "INSERT INTO turns(id,session_id,status,outcome,completed_at,settings) VALUES($1,$2,$3,$4,clock_timestamp(),'{}')", id, sessionID, status, outcome); err != nil { t.Fatal(err) } return id diff --git a/services/core/internal/persistence/postgres/modelconfigurationpg/observation_test.go b/services/core/internal/persistence/postgres/modelconfigurationpg/observation_test.go index 80c29ba11..254d86add 100644 --- a/services/core/internal/persistence/postgres/modelconfigurationpg/observation_test.go +++ b/services/core/internal/persistence/postgres/modelconfigurationpg/observation_test.go @@ -109,7 +109,7 @@ func TestObservationExcludesOtherSourcesAndHistoricalSessions(t *testing.T) { func TestObservationIgnoresActiveAndNonRootTurns(t *testing.T) { o := newObserved(t) id := uuid.NewString() - if _, err := o.pool.Exec(t.Context(), "INSERT INTO turns(id,session_id,status) VALUES($1,$2,'queued')", id, o.session.ID); err != nil { + if _, err := o.pool.Exec(t.Context(), "INSERT INTO turns(id,session_id,status,settings) VALUES($1,$2,'queued','{}')", id, o.session.ID); err != nil { t.Fatal(err) } o.observe(t, id, 0) diff --git a/services/core/internal/persistence/postgres/sessionpg/artifacts_test.go b/services/core/internal/persistence/postgres/sessionpg/artifacts_test.go index 4e17c6e55..3ea60738e 100644 --- a/services/core/internal/persistence/postgres/sessionpg/artifacts_test.go +++ b/services/core/internal/persistence/postgres/sessionpg/artifacts_test.go @@ -24,7 +24,7 @@ func stagingTurn(t *testing.T, pool *pgxpool.Pool, kind string) (tenant, session t.Helper() tenantID, sessionID, environmentID := newEnvironment(t, pool, kind, "connected") turnID := uuid.New() - exec(t, pool, `INSERT INTO turns(id, session_id, status) VALUES ($1, $2, 'in_progress')`, turnID, sessionID) + exec(t, pool, `INSERT INTO turns(id, session_id, status, settings) VALUES ($1, $2, 'in_progress', '{}')`, turnID, sessionID) return uuidText(tenantID), uuidText(sessionID), uuidText(environmentID), turnID.String() } diff --git a/services/core/internal/persistence/postgres/sessionpg/execution_journal_test.go b/services/core/internal/persistence/postgres/sessionpg/execution_journal_test.go index 4c2e082b8..6f19f916c 100644 --- a/services/core/internal/persistence/postgres/sessionpg/execution_journal_test.go +++ b/services/core/internal/persistence/postgres/sessionpg/execution_journal_test.go @@ -145,7 +145,7 @@ func TestSubagentProjectionStoresTheSessionRows(t *testing.T) { tenant, session, turn, device := uuid.New(), uuid.New(), uuid.New(), uuid.New() exec(t, pool, `INSERT INTO sessions(id, tenant_id, engine, idempotency_key, request_hash, configuration) VALUES ($1, $2, 'codex', 'key', 'hash', '{"agent":{"id":"agent_root"}}')`, session, tenant) - exec(t, pool, `INSERT INTO turns(id, session_id, status) VALUES ($1, $2, 'in_progress')`, turn, session) + exec(t, pool, `INSERT INTO turns(id, session_id, status, settings) VALUES ($1, $2, 'in_progress', '{}')`, turn, session) exec(t, pool, `INSERT INTO devices(id, tenant_id, name, credential_hash) VALUES ($1, $2, 'device', repeat('a', 64))`, device, tenant) exec(t, pool, `INSERT INTO session_devices(session_id, device_id, native_session_id) VALUES ($1, $2, 'root')`, session, device) encode := func(kind string, value any) sessions.ExecutionEvent { diff --git a/services/core/internal/persistence/postgres/sessionpg/functions_test.go b/services/core/internal/persistence/postgres/sessionpg/functions_test.go index 1a5c1144f..9b5370ae1 100644 --- a/services/core/internal/persistence/postgres/sessionpg/functions_test.go +++ b/services/core/internal/persistence/postgres/sessionpg/functions_test.go @@ -43,8 +43,8 @@ func newFunctionTurn(t *testing.T, pool *pgxpool.Pool, status string) functionTu func addTurn(t *testing.T, pool *pgxpool.Pool, session pgtype.UUID, status string) pgtype.UUID { t.Helper() turn := uuid.New() - exec(t, pool, `INSERT INTO turns(id, session_id, status, completed_at) - VALUES ($1, $2, $3::text, CASE WHEN $3::text IN ('completed', 'failed', 'cancelled') THEN clock_timestamp() END)`, turn, session, status) + exec(t, pool, `INSERT INTO turns(id, session_id, status, completed_at, settings) + VALUES ($1, $2, $3::text, CASE WHEN $3::text IN ('completed', 'failed', 'cancelled') THEN clock_timestamp() END, '{}')`, turn, session, status) return pgID(turn) } diff --git a/services/core/internal/persistence/postgres/sessionpg/inputs.go b/services/core/internal/persistence/postgres/sessionpg/inputs.go index f1f9e7d6c..f821c7b92 100644 --- a/services/core/internal/persistence/postgres/sessionpg/inputs.go +++ b/services/core/internal/persistence/postgres/sessionpg/inputs.go @@ -43,8 +43,12 @@ func withInputs(ctx context.Context, runner pgunit.Transactor, tenantID, session }) } -func (t *SessionTx) CreateTurn(ctx context.Context) (sessions.Turn, error) { - row, err := t.q.CreateTurn(ctx, sqlc.CreateTurnParams{ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, SessionID: t.session}) +func (t *SessionTx) CreateTurn(ctx context.Context, settings sessions.TurnSettings) (sessions.Turn, error) { + encoded, err := json.Marshal(settings) + if err != nil { + return sessions.Turn{}, err + } + row, err := t.q.CreateTurn(ctx, sqlc.CreateTurnParams{ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, SessionID: t.session, Settings: encoded}) if err != nil { return sessions.Turn{}, err } @@ -130,6 +134,9 @@ func (t *SessionTx) reservationFromRow(ctx context.Context, row sqlc.Environment if err := json.Unmarshal(row.Batch, &result.Inputs); err != nil { return sessions.EnvironmentInputReservation{}, fmt.Errorf("decode Environment input: %w", err) } + if err := json.Unmarshal(row.Settings, &result.Settings); err != nil { + return sessions.EnvironmentInputReservation{}, fmt.Errorf("decode Environment input settings: %w", err) + } if row.State != sessions.EnvironmentInputAdmitted { return result, nil } @@ -144,9 +151,21 @@ func (t *SessionTx) reservationFromRow(ctx context.Context, row sqlc.Environment return result, nil } -func (t *SessionTx) CreateInputReservation(ctx context.Context, key string, batch json.RawMessage, initial bool) (sessions.EnvironmentInputReservation, error) { +func (t *SessionTx) LoadTurnSettings(ctx context.Context) (sessions.TurnSettings, error) { + configuration, err := t.q.GetSessionConfiguration(ctx, t.session) + if err != nil { + return sessions.TurnSettings{}, err + } + return sessions.TurnSettingsOf(configuration) +} + +func (t *SessionTx) CreateInputReservation(ctx context.Context, key string, batch json.RawMessage, initial bool, settings sessions.TurnSettings) (sessions.EnvironmentInputReservation, error) { + encoded, err := json.Marshal(settings) + if err != nil { + return sessions.EnvironmentInputReservation{}, err + } row, err := t.q.CreateEnvironmentInputReservation(ctx, sqlc.CreateEnvironmentInputReservationParams{ - ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, SessionID: t.session, IdempotencyKey: key, Batch: batch, IsInitial: initial, + ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, SessionID: t.session, IdempotencyKey: key, Batch: batch, IsInitial: initial, Settings: encoded, }) if err != nil { return sessions.EnvironmentInputReservation{}, storable(err) diff --git a/services/core/internal/persistence/postgres/sessionpg/rows.go b/services/core/internal/persistence/postgres/sessionpg/rows.go index 4cbe2ddf0..621864473 100644 --- a/services/core/internal/persistence/postgres/sessionpg/rows.go +++ b/services/core/internal/persistence/postgres/sessionpg/rows.go @@ -57,12 +57,15 @@ func ResourceLookup(tenantID, id string) (sqlc.GetDeviceParams, error) { // TurnFromRow maps a stored Turn. func TurnFromRow(row sqlc.Turn) sessions.Turn { - return sessions.Turn{ + turn := sessions.Turn{ ID: uuid.UUID(row.ID.Bytes).String(), SessionID: uuid.UUID(row.SessionID.Bytes).String(), Status: row.Status, CreatedAt: row.CreatedAt.Time, StartedAt: row.StartedAt.Time, CompletedAt: row.CompletedAt.Time, CancelRequestedAt: row.CancelRequestedAt.Time, Outcome: json.RawMessage(row.Outcome), Usage: json.RawMessage(row.TokenUsage), ArtifactCaptureStarted: row.ArtifactCaptureStarted, } + // The settings were written by TurnSettings and checked as an object. + _ = json.Unmarshal(row.Settings, &turn.Settings) + return turn } // sessionFromRow maps a stored Session with its creator, normalized diff --git a/services/core/internal/persistence/postgres/sessionpg/session_reads.go b/services/core/internal/persistence/postgres/sessionpg/session_reads.go index 4d76792e0..b56526dc9 100644 --- a/services/core/internal/persistence/postgres/sessionpg/session_reads.go +++ b/services/core/internal/persistence/postgres/sessionpg/session_reads.go @@ -225,6 +225,12 @@ func (s *Store) GetSessionExecutionConfiguration(ctx context.Context, tenantID, } else if err := json.Unmarshal(row.ExecutionConfiguration, &projection); err != nil { return v1.SessionExecutionConfiguration{}, errors.New("invalid stored session execution configuration") } + // The model is the Session's current one, which an update may have changed. + model, err := sessions.ExecutionModel(row.SessionConfiguration) + if err != nil { + return projection, err + } + projection.Model.Value = model sessions.NormalizeExecutionProjection(&projection, uuid.UUID(row.ID.Bytes).String()) return projection, nil } diff --git a/services/core/internal/persistence/postgres/sessionpg/session_test.go b/services/core/internal/persistence/postgres/sessionpg/session_test.go index 51d3e56e7..334f0103a 100644 --- a/services/core/internal/persistence/postgres/sessionpg/session_test.go +++ b/services/core/internal/persistence/postgres/sessionpg/session_test.go @@ -45,8 +45,8 @@ func newEnvironment(t *testing.T, pool *pgxpool.Pool, kind, status string) (pgty func reserveInput(t *testing.T, pool *pgxpool.Pool, session pgtype.UUID) { t.Helper() - exec(t, pool, `INSERT INTO environment_input_reservations(id, session_id, idempotency_key, batch, created_at, deadline) - VALUES ($1, $2, 'input', '[{}]', clock_timestamp(), clock_timestamp() + interval '1 hour')`, uuid.New(), session) + exec(t, pool, `INSERT INTO environment_input_reservations(id, session_id, idempotency_key, batch, settings, created_at, deadline) + VALUES ($1, $2, 'input', '[{}]', '{}', clock_timestamp(), clock_timestamp() + interval '1 hour')`, uuid.New(), session) } func kinds(changes []sessions.SessionChange) []string { @@ -192,7 +192,7 @@ func readEnvironmentState(t *testing.T, pool *pgxpool.Pool, session pgtype.UUID) func TestFailEnvironmentThroughTheBindingCommitsWithItsTransaction(t *testing.T) { pool := pgtest.Open(t) tenant, session, _ := newEnvironment(t, pool, "openai_hosted", "connected") - exec(t, pool, `INSERT INTO turns(id, session_id) VALUES ($1, $2)`, uuid.New(), session) + exec(t, pool, `INSERT INTO turns(id, session_id, settings) VALUES ($1, $2, '{}')`, uuid.New(), session) reserveInput(t, pool, session) const reason = "Failed to provision environment" fail := func(tx pgx.Tx) error { diff --git a/services/core/internal/persistence/postgres/sessionpg/session_writes.go b/services/core/internal/persistence/postgres/sessionpg/session_writes.go index c8cfae51f..3404e5604 100644 --- a/services/core/internal/persistence/postgres/sessionpg/session_writes.go +++ b/services/core/internal/persistence/postgres/sessionpg/session_writes.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "reflect" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/auditpg" @@ -49,7 +50,7 @@ func (t *deletionTx) RecordDeletionAudit(ctx context.Context) error { return auditpg.RecordWriteAudit(ctx, t.q, t.tenantID, writeaudit.ActionDelete, writeaudit.ResourceSession, uuid.UUID(t.session.Bytes).String(), "") } -func (s *Store) UpdateSessionMetadata(ctx context.Context, tenantID, sessionID string, encoded []byte) (sessions.Session, error) { +func (s *Store) UpdateSession(ctx context.Context, tenantID, sessionID string, encoded []byte, configure sessions.ConfigureSession) (sessions.Session, error) { tenant, err := parseID(tenantID) if err != nil { return sessions.Session{}, err @@ -57,10 +58,36 @@ func (s *Store) UpdateSessionMetadata(ctx context.Context, tenantID, sessionID s var session sessions.Session err = s.units.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { q := sqlc.New(tx) - row, err := q.UpdateSessionMetadata(ctx, sqlc.UpdateSessionMetadataParams{TenantID: tenant, ID: pgunit.PathID(sessionID), Metadata: encoded}) + current, err := q.LockVisibleSession(ctx, sqlc.LockVisibleSessionParams{TenantID: tenant, ID: pgunit.PathID(sessionID)}) if err != nil { return err } + configuration := current.Configuration + if configure != nil { + if configuration, err = configure(current.Engine, configuration); err != nil { + return err + } + } + if encoded == nil { + encoded = current.Metadata + } + row, err := q.UpdateSession(ctx, sqlc.UpdateSessionParams{TenantID: tenant, ID: current.ID, Configuration: configuration, Metadata: encoded}) + if err != nil { + return err + } + before, err := sessions.ExecutionModel(current.Configuration) + if err != nil { + return err + } + after, err := sessions.ExecutionModel(configuration) + if err != nil { + return err + } + if !reflect.DeepEqual(before, after) { + if err := q.MarkSessionModelSource(ctx, row.ID); err != nil { + return err + } + } if err := auditpg.RecordWriteAudit(ctx, q, tenantID, writeaudit.ActionUpdate, writeaudit.ResourceSession, uuid.UUID(row.ID.Bytes).String(), ""); err != nil { return err } @@ -74,7 +101,7 @@ func (s *Store) UpdateSessionMetadata(ctx context.Context, tenantID, sessionID s return sessions.Session{}, sessions.ErrNotFound } if err != nil { - return sessions.Session{}, fmt.Errorf("update session metadata: %w", err) + return sessions.Session{}, fmt.Errorf("update session: %w", err) } return session, nil } diff --git a/services/core/internal/persistence/postgres/sessionpg/sessionpg_test.go b/services/core/internal/persistence/postgres/sessionpg/sessionpg_test.go index d9e6dc7d6..ea2cbcbdc 100644 --- a/services/core/internal/persistence/postgres/sessionpg/sessionpg_test.go +++ b/services/core/internal/persistence/postgres/sessionpg/sessionpg_test.go @@ -28,8 +28,8 @@ func newTurn(t *testing.T, pool *pgxpool.Pool, status string) (pgtype.UUID, pgty if _, err := pool.Exec(t.Context(), `INSERT INTO sessions(id, tenant_id, engine, idempotency_key, request_hash) VALUES ($1, $2, 'codex', 'key', 'hash')`, session, tenant); err != nil { t.Fatal(err) } - if _, err := pool.Exec(t.Context(), `INSERT INTO turns(id, session_id, status, completed_at) - VALUES ($1, $2, $3::text, CASE WHEN $3::text IN ('completed', 'failed', 'cancelled') THEN clock_timestamp() END)`, turn, session, status); err != nil { + if _, err := pool.Exec(t.Context(), `INSERT INTO turns(id, session_id, status, completed_at, settings) + VALUES ($1, $2, $3::text, CASE WHEN $3::text IN ('completed', 'failed', 'cancelled') THEN clock_timestamp() END, '{}')`, turn, session, status); err != nil { t.Fatal(err) } return pgtype.UUID{Bytes: tenant, Valid: true}, pgtype.UUID{Bytes: session, Valid: true}, pgtype.UUID{Bytes: turn, Valid: true} diff --git a/services/core/internal/sessions/artifacts_test.go b/services/core/internal/sessions/artifacts_test.go index 68bd1174a..9b1a3b7be 100644 --- a/services/core/internal/sessions/artifacts_test.go +++ b/services/core/internal/sessions/artifacts_test.go @@ -268,8 +268,8 @@ func (s *fakeArtifactStorage) WithSessionDeletion(context.Context, string, strin return nil } -func (s *fakeArtifactStorage) UpdateSessionMetadata(context.Context, string, string, []byte) (Session, error) { - s.t.Fatal("unexpected call to UpdateSessionMetadata") +func (s *fakeArtifactStorage) UpdateSession(context.Context, string, string, []byte, ConfigureSession) (Session, error) { + s.t.Fatal("unexpected call to UpdateSession") return Session{}, nil } diff --git a/services/core/internal/sessions/creation.go b/services/core/internal/sessions/creation.go index 56957dad3..615691e68 100644 --- a/services/core/internal/sessions/creation.go +++ b/services/core/internal/sessions/creation.go @@ -99,8 +99,9 @@ type CreationTx interface { // returns its ID. CreateEnvironment(ctx context.Context) (string, error) // CreateInputReservation reserves the batch under key until the deadline - // the database clock sets, marked as the Session's initial input. - CreateInputReservation(ctx context.Context, key string, batch json.RawMessage, initial bool) (EnvironmentInputReservation, error) + // the database clock sets, marked as the Session's initial input and + // bound to settings. + CreateInputReservation(ctx context.Context, key string, batch json.RawMessage, initial bool, settings TurnSettings) (EnvironmentInputReservation, error) // PruneChanges bounds the Session's retained public changes. PruneChanges(ctx context.Context) error // AuditCreation records the create write audit, listing created. @@ -234,14 +235,18 @@ func (s *Service) createResources(ctx context.Context, tx CreationTx, session Se // The input key is internal, independent of the creation key and of keys // callers send to the events endpoint. key := uuid.NewString() + settings, err := TurnSettingsOf(session.Configuration) + if err != nil { + return nil, err + } if environment != "" { // Environment input waits for preparation and the leased promotion. err = TrackInputActivity(ctx, tx, func(ctx context.Context) error { - _, err := tx.CreateInputReservation(ctx, key, encoded, true) + _, err := tx.CreateInputReservation(ctx, key, encoded, true, settings) return err }) } else { - _, err = AdmitInputs(ctx, tx, key, batch) + _, err = AdmitInputs(ctx, tx, key, batch, settings) } if err != nil { return nil, err @@ -305,6 +310,9 @@ func freezeProjection(session Session, frozen v1.SessionExecutionConfiguration, !validExecutionSource(frozen.Model.Source) || !validExecutionSource(frozen.Harness.Source) { return v1.SessionExecutionConfiguration{}, uuid.Nil, fmt.Errorf("%w: execution projection does not match Session configuration", ErrInvalidInput) } + // The model is read from the Session configuration, which a Session update + // changes; no second copy is stored. + frozen.Model.Value = nil recorded := uuid.Nil switch frozen.ModelProvider.Source { case v1.ExecutionSourceDeployment: diff --git a/services/core/internal/sessions/devices_test.go b/services/core/internal/sessions/devices_test.go index c7cfbe77d..36d590353 100644 --- a/services/core/internal/sessions/devices_test.go +++ b/services/core/internal/sessions/devices_test.go @@ -95,8 +95,8 @@ func (s *fakeStorage) WithSessionDeletion(ctx context.Context, tenant, session s return apply(ctx, s.locked, s.deletion) } -func (s *fakeStorage) UpdateSessionMetadata(_ context.Context, tenant, session string, encoded []byte) (Session, error) { - s.record("UpdateSessionMetadata", s.updateMetadata != nil, tenant, session, string(encoded)) +func (s *fakeStorage) UpdateSession(_ context.Context, tenant, session string, encoded []byte, _ ConfigureSession) (Session, error) { + s.record("UpdateSession", s.updateMetadata != nil, tenant, session, string(encoded)) return s.updateMetadata(string(encoded)) } diff --git a/services/core/internal/sessions/environment.go b/services/core/internal/sessions/environment.go index d0465df7e..78fb7afb5 100644 --- a/services/core/internal/sessions/environment.go +++ b/services/core/internal/sessions/environment.go @@ -107,6 +107,9 @@ type EnvironmentInputReservation struct { Deadline time.Time SettledAt *time.Time Receipts []InputReceipt + // Settings are bound when the reservation is created and become the + // Turn's settings when it is admitted. + Settings TurnSettings } type EnvironmentInputWork struct{ TenantID, SessionID, ReservationID string } diff --git a/services/core/internal/sessions/environment_inputs.go b/services/core/internal/sessions/environment_inputs.go index 5eb7e37eb..12842af11 100644 --- a/services/core/internal/sessions/environment_inputs.go +++ b/services/core/internal/sessions/environment_inputs.go @@ -100,15 +100,19 @@ func (s *Service) ReserveEnvironmentInput(ctx context.Context, tenant, session, if err != nil { return err } + settings, err := tx.LoadTurnSettings(ctx) + if err != nil { + return err + } if joinsActiveTurn(turn, active) { - receipts, err := AdmitInputs(ctx, tx, key, batch) + receipts, err := AdmitInputs(ctx, tx, key, batch, settings) if err != nil { return err } result = EnvironmentInputReservation{SessionID: session, State: EnvironmentInputAdmitted, Receipts: receipts} return tx.RecordInputAudit(ctx) } - if result, err = tx.CreateInputReservation(ctx, key, encoded, false); err != nil { + if result, err = tx.CreateInputReservation(ctx, key, encoded, false, settings); err != nil { return err } return tx.RecordInputAudit(ctx) diff --git a/services/core/internal/sessions/execution_inputs.go b/services/core/internal/sessions/execution_inputs.go index 111195033..54d5949e0 100644 --- a/services/core/internal/sessions/execution_inputs.go +++ b/services/core/internal/sessions/execution_inputs.go @@ -42,7 +42,7 @@ func (o *ExecutionOperations) PromoteEnvironmentInput(ctx context.Context, tenan if err := CheckInputStart(ctx, tx); err != nil { return err } - if result.Receipts, err = AdmitInputs(ctx, tx, result.Key, result.Inputs); err != nil { + if result.Receipts, err = AdmitInputs(ctx, tx, result.Key, result.Inputs, result.Settings); err != nil { return err } settled, err := tx.AdmitInputReservation(ctx, reservation) diff --git a/services/core/internal/sessions/inputs.go b/services/core/internal/sessions/inputs.go index ce474d558..458d78230 100644 --- a/services/core/internal/sessions/inputs.go +++ b/services/core/internal/sessions/inputs.go @@ -164,9 +164,9 @@ type InputAdmissionTx interface { TurnCancellationTx InputProjectionTx ActiveTurnTx - // CreateTurn creates a queued Turn in the Session and returns it as - // stored. - CreateTurn(ctx context.Context) (Turn, error) + // CreateTurn creates a queued Turn in the Session bound to settings and + // returns it as stored. + CreateTurn(ctx context.Context, settings TurnSettings) (Turn, error) // CreateTurnInput records input at position of the batch admitted under // key, joined to turn, or to no Turn when turn is empty, and returns the // Session sequence it allocates. @@ -179,11 +179,11 @@ type InputAdmissionTx interface { // Session, starts a new Turn, which publishes turn.created, then the message's // Items, then the Session activity. A cancellation requests the cancellation // of the active Turn; on an idle Session it joins no Turn and keeps only its -// retry identity. -func AdmitInputs(ctx context.Context, tx InputAdmissionTx, key string, batch []Input) ([]InputReceipt, error) { +// retry identity. A started Turn binds settings. +func AdmitInputs(ctx context.Context, tx InputAdmissionTx, key string, batch []Input, settings TurnSettings) ([]InputReceipt, error) { receipts := make([]InputReceipt, 0, len(batch)) for position, input := range batch { - receipt, err := admitInput(ctx, tx, key, int32(position), input) + receipt, err := admitInput(ctx, tx, key, int32(position), input, settings) if err != nil { return nil, err } @@ -193,7 +193,7 @@ func AdmitInputs(ctx context.Context, tx InputAdmissionTx, key string, batch []I } // admitInput admits the input at position of the batch under key. -func admitInput(ctx context.Context, tx InputAdmissionTx, key string, position int32, input Input) (InputReceipt, error) { +func admitInput(ctx context.Context, tx InputAdmissionTx, key string, position int32, input Input, settings TurnSettings) (InputReceipt, error) { if input.Kind == "tool_result" { result, err := ParseFunctionResultInput(input.Payload) if err != nil { @@ -215,7 +215,7 @@ func admitInput(ctx context.Context, tx InputAdmissionTx, key string, position i } placement := placeInput(input.Kind, active) if placement == startsTurn { - if turn, err = tx.CreateTurn(ctx); err != nil { + if turn, err = tx.CreateTurn(ctx, settings); err != nil { return InputReceipt{}, err } if err := tx.AppendChanges(ctx, TurnChanges(turn, true)...); err != nil { @@ -259,6 +259,9 @@ type InputTx interface { // LoadInputGate reads what the Session's Environment input reservations // show about batch under key. LoadInputGate(ctx context.Context, key string, batch json.RawMessage) (InputGate, error) + // LoadTurnSettings projects the Session's current configuration into the + // settings a Turn accepted now binds. + LoadTurnSettings(ctx context.Context) (TurnSettings, error) // LoadEnvironment reads the Session's Environment; a Session without one // is ErrNotFound. LoadEnvironment(ctx context.Context) (Environment, error) @@ -272,8 +275,8 @@ type InputTx interface { LoadInputReservation(ctx context.Context, reservation string) (EnvironmentInputReservation, error) // CreateInputReservation reserves batch under key until the deadline the // database clock sets, marked initial when it is the Session's initial - // input, and returns the pending reservation as stored. - CreateInputReservation(ctx context.Context, key string, batch json.RawMessage, initial bool) (EnvironmentInputReservation, error) + // input, bound to settings, and returns the pending reservation as stored. + CreateInputReservation(ctx context.Context, key string, batch json.RawMessage, initial bool, settings TurnSettings) (EnvironmentInputReservation, error) // ExpireInputReservation expires the reservation while it is pending and // its deadline has passed by the database clock; otherwise it changes // nothing. @@ -361,7 +364,11 @@ func (s *Service) SubmitInputs(ctx context.Context, tenant, session, key string, if err := checkInputGate(gate); err != nil { return err } - if receipts, err = AdmitInputs(ctx, tx, key, batch); err != nil { + settings, err := tx.LoadTurnSettings(ctx) + if err != nil { + return err + } + if receipts, err = AdmitInputs(ctx, tx, key, batch, settings); err != nil { return err } return tx.RecordInputAudit(ctx) diff --git a/services/core/internal/sessions/inputs_test.go b/services/core/internal/sessions/inputs_test.go index d847b3f82..7006692bc 100644 --- a/services/core/internal/sessions/inputs_test.go +++ b/services/core/internal/sessions/inputs_test.go @@ -38,7 +38,11 @@ func newInputTx(t *testing.T) *fakeInputTx { return &fakeInputTx{fakeFunctionTx: &fakeFunctionTx{fakeTx: &fakeTx{t: t}}} } -func (f *fakeInputTx) CreateTurn(context.Context) (Turn, error) { +func (f *fakeInputTx) LoadTurnSettings(context.Context) (TurnSettings, error) { + return TurnSettings{}, nil +} + +func (f *fakeInputTx) CreateTurn(context.Context, TurnSettings) (Turn, error) { f.record("CreateTurn", f.createTurn != nil) return f.createTurn() } @@ -68,7 +72,7 @@ func (f *fakeInputTx) LoadInputReservation(_ context.Context, reservation string return f.loadInputReservation() } -func (f *fakeInputTx) CreateInputReservation(_ context.Context, key string, batch json.RawMessage, initial bool) (EnvironmentInputReservation, error) { +func (f *fakeInputTx) CreateInputReservation(_ context.Context, key string, batch json.RawMessage, initial bool, _ TurnSettings) (EnvironmentInputReservation, error) { detail := []string{key, string(batch)} if initial { detail = append(detail, "initial") @@ -213,7 +217,7 @@ func TestAdmitInput(t *testing.T) { tx.createTurnInput, tx.loadUsage = sequences(7), returns(json.RawMessage(`{}`)) tx.loadInputSource = returns(Source{Turn: testTurn, Kind: "message", Sequence: 7, Payload: messageInput("hi").Payload}) tx.loadItem, tx.putItem = returns(items.Stored{}), func(items.Change) (*int32, error) { return nil, nil } - receipt, err := admitInput(t.Context(), tx, inputKey, 0, messageInput("hi")) + receipt, err := admitInput(t.Context(), tx, inputKey, 0, messageInput("hi"), TurnSettings{}) if err != nil || receipt != (InputReceipt{Sequence: 7, TurnID: testTurn}) { t.Fatalf("receipt %+v, %v", receipt, err) } @@ -239,7 +243,7 @@ func TestAdmitInput(t *testing.T) { t.Run(test.name, func(t *testing.T) { tx := newInputTx(t) tx.loadActiveTurn, tx.createTurnInput, tx.loadInputSource, tx.requestTurnCancel = activeTurn(test.active), sequences(8), unprojected, done - receipt, err := admitInput(t.Context(), tx, inputKey, 1, test.input) + receipt, err := admitInput(t.Context(), tx, inputKey, 1, test.input, TurnSettings{}) if err != nil || receipt != test.want { t.Fatalf("receipt %+v, %v", receipt, err) } @@ -251,7 +255,7 @@ func TestAdmitInput(t *testing.T) { tx.findResultTurn = func() (Turn, bool, error) { return turnWith(TurnWaiting), true, nil } tx.matchFunctionResult, tx.submitFunctionResult, tx.createTurnInput = returns(FunctionResultMatch{Recorded: true}), done, sequences(9) payload := json.RawMessage(`{"turn_id":"` + testTurn + `","call_id":"call","result":{"ok":true}}`) - receipt, err := admitInput(t.Context(), tx, inputKey, 0, Input{Kind: "tool_result", Payload: payload}) + receipt, err := admitInput(t.Context(), tx, inputKey, 0, Input{Kind: "tool_result", Payload: payload}, TurnSettings{}) if err != nil || receipt != (InputReceipt{Sequence: 9, TurnID: testTurn}) { t.Fatalf("receipt %+v, %v", receipt, err) } diff --git a/services/core/internal/sessions/session.go b/services/core/internal/sessions/session.go index e2cad8b4c..efde00f16 100644 --- a/services/core/internal/sessions/session.go +++ b/services/core/internal/sessions/session.go @@ -91,10 +91,12 @@ type SessionStorage interface { // when both succeed. A malformed tenant is ErrInvalidInput; a malformed or // missing Session is ErrNotFound. WithSessionDeletion(ctx context.Context, tenantID, sessionID string, apply func(context.Context, LockedSession, SessionDeletionTx) error) error - // UpdateSessionMetadata replaces the visible Session's metadata with the - // encoded object, records the write audit and reads the Session as - // GetSession does, in one transaction, or returns ErrNotFound. - UpdateSessionMetadata(ctx context.Context, tenantID, sessionID string, encoded []byte) (Session, error) + // UpdateSession locks the visible Session, replaces its configuration with + // the result of configure and its metadata with encoded, keeping each that + // is nil, records the write audit and reads the Session as GetSession does, + // in one transaction. A failure of configure writes nothing. A missing + // Session is ErrNotFound. + UpdateSession(ctx context.Context, tenantID, sessionID string, encoded []byte, configure ConfigureSession) (Session, error) // AuditSessionOperation records the write audit of action on the visible // Session under its lock, or returns ErrNotFound. AuditSessionOperation(ctx context.Context, tenantID, sessionID, action string) error @@ -161,21 +163,31 @@ func (s *Service) DeleteSession(ctx context.Context, command DeleteSessionComman }) } -// UpdateSessionMetadataCommand replaces a Session's metadata. -type UpdateSessionMetadataCommand struct { +// ConfigureSession computes a Session's updated configuration from the engine +// and configuration the Session lock shows. +type ConfigureSession func(engine string, configuration json.RawMessage) (json.RawMessage, error) + +// UpdateSessionCommand updates a Session. +type UpdateSessionCommand struct { TenantID string SessionID string - Metadata map[string]string + // Metadata replaces the Session's metadata; nil keeps it. + Metadata map[string]string + // Configure computes the updated configuration; nil keeps it. + Configure ConfigureSession } -// UpdateSessionMetadata replaces the visible Session's metadata and returns -// the Session. Invalid metadata is ErrInvalidInput. -func (s *Service) UpdateSessionMetadata(ctx context.Context, command UpdateSessionMetadataCommand) (Session, error) { - encoded, err := metadata.Encode(command.Metadata) - if err != nil { - return Session{}, fmt.Errorf("%w: %w", ErrInvalidInput, err) +// UpdateSession applies the update under the Session lock and returns the +// Session. Invalid metadata is ErrInvalidInput. +func (s *Service) UpdateSession(ctx context.Context, command UpdateSessionCommand) (Session, error) { + var encoded []byte + if command.Metadata != nil { + var err error + if encoded, err = metadata.Encode(command.Metadata); err != nil { + return Session{}, fmt.Errorf("%w: %w", ErrInvalidInput, err) + } } - return s.storage.UpdateSessionMetadata(ctx, command.TenantID, command.SessionID, encoded) + return s.storage.UpdateSession(ctx, command.TenantID, command.SessionID, encoded, command.Configure) } // AuditSessionOperationCommand records an authorized public operation that diff --git a/services/core/internal/sessions/session_test.go b/services/core/internal/sessions/session_test.go index 53013d9e6..ddc1f8019 100644 --- a/services/core/internal/sessions/session_test.go +++ b/services/core/internal/sessions/session_test.go @@ -72,12 +72,12 @@ func TestUpdateSessionMetadata(t *testing.T) { if err != nil { t.Fatal(err) } - session, err := service.UpdateSessionMetadata(t.Context(), UpdateSessionMetadataCommand{TenantID: "tenant", SessionID: "session", Metadata: map[string]string{"k": "v"}}) - if err != nil || session.ID != "session" || strings.Join(storage.calls, "\n") != `UpdateSessionMetadata tenant session {"k":"v"}` { + session, err := service.UpdateSession(t.Context(), UpdateSessionCommand{TenantID: "tenant", SessionID: "session", Metadata: map[string]string{"k": "v"}}) + if err != nil || session.ID != "session" || strings.Join(storage.calls, "\n") != `UpdateSession tenant session {"k":"v"}` { t.Fatal(session, err, storage.calls) } invalid := map[string]string{"k": strings.Repeat("v", 64*1024)} - if _, err := service.UpdateSessionMetadata(t.Context(), UpdateSessionMetadataCommand{TenantID: "tenant", SessionID: "session", Metadata: invalid}); !errors.Is(err, ErrInvalidInput) || len(storage.calls) != 1 { + if _, err := service.UpdateSession(t.Context(), UpdateSessionCommand{TenantID: "tenant", SessionID: "session", Metadata: invalid}); !errors.Is(err, ErrInvalidInput) || len(storage.calls) != 1 { t.Fatal("invalid metadata reached storage", err, storage.calls) } } diff --git a/services/core/internal/sessions/turn.go b/services/core/internal/sessions/turn.go index 9f62421af..78de37e0d 100644 --- a/services/core/internal/sessions/turn.go +++ b/services/core/internal/sessions/turn.go @@ -28,6 +28,8 @@ type Turn struct { CancelRequestedAt time.Time Usage json.RawMessage Outcome json.RawMessage + // Settings are bound when the Turn's input is accepted. + Settings TurnSettings // ArtifactCaptureStarted is private Runtime coordination, never a wire field. ArtifactCaptureStarted bool `json:"-"` } diff --git a/services/core/internal/sessions/turn_settings.go b/services/core/internal/sessions/turn_settings.go new file mode 100644 index 000000000..b2604853f --- /dev/null +++ b/services/core/internal/sessions/turn_settings.go @@ -0,0 +1,39 @@ +package sessions + +import ( + "encoding/json" + "errors" +) + +// TurnSettings are the official settings a root Turn runs with, under their +// official names. A Turn binds them when Core accepts the input that creates +// it, so a later Session update never reaches a Turn that already exists. +type TurnSettings struct { + Model string `json:"model"` + ReasoningEffort *string `json:"reasoning_effort"` + ServiceTier string `json:"service_tier"` + Review string `json:"review"` +} + +// TurnSettingsOf projects a stored Session configuration into the settings a +// Turn accepted now binds. An absent review is none. +func TurnSettingsOf(configuration json.RawMessage) (TurnSettings, error) { + var stored struct { + Agent struct { + Model string `json:"model"` + Reasoning struct { + Effort *string `json:"effort"` + } `json:"reasoning"` + ServiceTier string `json:"service_tier"` + } `json:"agent"` + Review string `json:"review"` + } + if err := json.Unmarshal(configuration, &stored); err != nil { + return TurnSettings{}, errors.New("invalid stored Session configuration") + } + settings := TurnSettings{Model: stored.Agent.Model, ReasoningEffort: stored.Agent.Reasoning.Effort, ServiceTier: stored.Agent.ServiceTier, Review: stored.Review} + if settings.Review == "" { + settings.Review = "none" + } + return settings, nil +} diff --git a/services/core/migrations/000012_turn_settings.sql b/services/core/migrations/000012_turn_settings.sql new file mode 100644 index 000000000..9818714a2 --- /dev/null +++ b/services/core/migrations/000012_turn_settings.sql @@ -0,0 +1,3 @@ +-- +goose Up +ALTER TABLE turns ADD COLUMN settings jsonb NOT NULL CHECK (jsonb_typeof(settings) = 'object'); +ALTER TABLE environment_input_reservations ADD COLUMN settings jsonb NOT NULL CHECK (jsonb_typeof(settings) = 'object'); diff --git a/services/core/tests/integration/environments_test.go b/services/core/tests/integration/environments_test.go index 3628aea25..6c8b1d799 100644 --- a/services/core/tests/integration/environments_test.go +++ b/services/core/tests/integration/environments_test.go @@ -63,7 +63,7 @@ func TestEnvironmentOwnershipPersistsAndStaysScoped(t *testing.T) { if _, err := s.CreateSession(ctx, tenant, changed); !errors.Is(err, sessions.ErrIdempotencyConflict) { t.Fatal("changed configuration accepted", err) } - if _, err := sessionService(t, s).UpdateSessionMetadata(ctx, sessions.UpdateSessionMetadataCommand{TenantID: tenant, SessionID: session.ID, Metadata: map[string]string{"updated": "yes"}}); err != nil { + if _, err := sessionService(t, s).UpdateSession(ctx, sessions.UpdateSessionCommand{TenantID: tenant, SessionID: session.ID, Metadata: map[string]string{"updated": "yes"}}); err != nil { t.Fatal(err) } restarted := reopenStore(t, s) diff --git a/services/core/tests/integration/runtime_compute_lifecycle_test.go b/services/core/tests/integration/runtime_compute_lifecycle_test.go index f697455c0..5b209a8f4 100644 --- a/services/core/tests/integration/runtime_compute_lifecycle_test.go +++ b/services/core/tests/integration/runtime_compute_lifecycle_test.go @@ -371,13 +371,13 @@ func (f *computeLifecycleFixture) phase(tenant, environment, phase string) deplo } func (f *computeLifecycleFixture) complete(owner deployment.Allocation) string { id := uuid.NewString() - f.sql(`INSERT INTO turns(id,session_id,status,completed_at) VALUES($1,$2,'completed',clock_timestamp()-interval '2 minutes')`, id, owner.SessionID) + f.sql(`INSERT INTO turns(id,session_id,status,completed_at,settings) VALUES($1,$2,'completed',clock_timestamp()-interval '2 minutes','{}')`, id, owner.SessionID) f.sql(`UPDATE runtime_allocations SET compute_activity_at=clock_timestamp()-interval '2 minutes',compute_phase_changed_at=clock_timestamp()-interval '2 minutes' WHERE id=$1`, owner.ID) return id } func (f *computeLifecycleFixture) queued(owner deployment.Allocation) string { id := uuid.NewString() - f.sql(`INSERT INTO turns(id,session_id,status) VALUES($1,$2,'queued')`, id, owner.SessionID) + f.sql(`INSERT INTO turns(id,session_id,status,settings) VALUES($1,$2,'queued','{}')`, id, owner.SessionID) return id } diff --git a/services/core/tests/integration/runtime_idle_clock_test.go b/services/core/tests/integration/runtime_idle_clock_test.go index ce1ca3299..04fbd9f59 100644 --- a/services/core/tests/integration/runtime_idle_clock_test.go +++ b/services/core/tests/integration/runtime_idle_clock_test.go @@ -87,7 +87,7 @@ func TestManagedIdleClockIgnoresRootHostSkew(t *testing.T) { t.Run(skew.String(), func(t *testing.T) { s, w, owner := managedIdleClockFixture(t) turn := uuid.NewString() - runtimeSuspensionSQL(t, s.pool, "INSERT INTO turns(id,session_id,status,started_at) VALUES($1,$2,'in_progress',clock_timestamp())", turn, owner.SessionID) + runtimeSuspensionSQL(t, s.pool, "INSERT INTO turns(id,session_id,status,started_at,settings) VALUES($1,$2,'in_progress',clock_timestamp(),'{}')", turn, owner.SessionID) source := runtimeDatabaseTime(t, s).Add(skew).UnixMilli() outcome := json.RawMessage(fmt.Sprintf(`{"done":{"source_completed_at_ms":%d}}`, source)) before := runtimeDatabaseTime(t, s) diff --git a/services/core/tests/integration/runtime_node_lifecycle_test.go b/services/core/tests/integration/runtime_node_lifecycle_test.go index e601d41b4..93d05756a 100644 --- a/services/core/tests/integration/runtime_node_lifecycle_test.go +++ b/services/core/tests/integration/runtime_node_lifecycle_test.go @@ -21,7 +21,7 @@ func TestManagedNodesIsolateBlockedProviderAndInitialization(t *testing.T) { wakeTenant, _, wakeEnv := f.session(f.nodeB, false) wakeOwner := f.provision(wakeTenant, wakeEnv) f.phase(wakeTenant, wakeEnv.ID, "running") - if _, err := f.pool.Exec(t.Context(), "INSERT INTO turns(id,session_id,status,completed_at) VALUES($1,$2,'completed',clock_timestamp()-interval '2 minutes')", uuid.NewString(), wakeOwner.SessionID); err != nil { + if _, err := f.pool.Exec(t.Context(), "INSERT INTO turns(id,session_id,status,completed_at,settings) VALUES($1,$2,'completed',clock_timestamp()-interval '2 minutes','{}')", uuid.NewString(), wakeOwner.SessionID); err != nil { t.Fatal(err) } if _, err := f.pool.Exec(t.Context(), "UPDATE runtime_allocations SET compute_activity_at=clock_timestamp()-interval '2 minutes',compute_phase_changed_at=clock_timestamp()-interval '2 minutes' WHERE id=$1", wakeOwner.ID); err != nil { diff --git a/services/core/tests/integration/runtime_suspension_concurrency_test.go b/services/core/tests/integration/runtime_suspension_concurrency_test.go index 4c4df51fd..8e7fb953e 100644 --- a/services/core/tests/integration/runtime_suspension_concurrency_test.go +++ b/services/core/tests/integration/runtime_suspension_concurrency_test.go @@ -57,7 +57,7 @@ func TestRuntimeSuspensionClaimReadsPhaseAfterSessionLock(t *testing.T) { t.Run(phase, func(t *testing.T) { s, w, pool, owner := runtimeSuspensionFixture(t) turn := uuid.NewString() - runtimeSuspensionSQL(t, pool, `INSERT INTO turns(id,session_id,status) VALUES($1,$2,'queued')`, turn, owner.SessionID) + runtimeSuspensionSQL(t, pool, `INSERT INTO turns(id,session_id,status,settings) VALUES($1,$2,'queued','{}')`, turn, owner.SessionID) ctx, tx, blocker := runtimeSuspensionLockedSession(t, pool, owner.SessionID) done := make(chan error, 1) go func() { @@ -131,9 +131,9 @@ func TestRuntimeSuspensionCaptureRechecksNewPendingWork(t *testing.T) { var err error switch kind { case "queued": - _, err = tx.Exec(ctx, `INSERT INTO turns(id,session_id,status) VALUES($1,$2,'queued')`, uuid.NewString(), owner.SessionID) + _, err = tx.Exec(ctx, `INSERT INTO turns(id,session_id,status,settings) VALUES($1,$2,'queued','{}')`, uuid.NewString(), owner.SessionID) case "input": - _, err = tx.Exec(ctx, `INSERT INTO environment_input_reservations(id,session_id,idempotency_key,batch,created_at,deadline) VALUES($1,$2,'pending','[{}]',clock_timestamp(),clock_timestamp()+interval '1 minute')`, uuid.NewString(), owner.SessionID) + _, err = tx.Exec(ctx, `INSERT INTO environment_input_reservations(id,session_id,idempotency_key,batch,created_at,deadline,settings) VALUES($1,$2,'pending','[{}]',clock_timestamp(),clock_timestamp()+interval '1 minute','{}')`, uuid.NewString(), owner.SessionID) case "file_write": _, err = tx.Exec(ctx, `INSERT INTO environment_file_writes(id,environment_id,device_id,request_sha256) VALUES($1,$2,$3,$4)`, uuid.NewString(), owner.EnvironmentID, owner.DeviceID, strings.Repeat("a", 64)) case "wake": @@ -198,7 +198,7 @@ func TestRuntimeSuspensionQuiesceCannotOvertakeClaim(t *testing.T) { }() runtimeSuspensionWaitBlocked(t, ctx, pool, blocker, done) turn := uuid.NewString() - if _, err := tx.Exec(ctx, `INSERT INTO turns(id,session_id,status) VALUES($1,$2,'queued')`, turn, owner.SessionID); err != nil { + if _, err := tx.Exec(ctx, `INSERT INTO turns(id,session_id,status,settings) VALUES($1,$2,'queued','{}')`, turn, owner.SessionID); err != nil { t.Fatal(err) } params, err := sessionpg.TurnLookup(owner.TenantID, owner.SessionID, turn) diff --git a/services/core/tests/integration/runtime_suspension_test.go b/services/core/tests/integration/runtime_suspension_test.go index 06e3dd30e..8ed3a7767 100644 --- a/services/core/tests/integration/runtime_suspension_test.go +++ b/services/core/tests/integration/runtime_suspension_test.go @@ -49,7 +49,7 @@ func runtimeSuspensionSQL(t *testing.T, pool *pgxpool.Pool, query string, args . func runtimeSuspensionCompleted(t *testing.T, pool *pgxpool.Pool, owner deployment.Allocation) string { t.Helper() id := uuid.NewString() - runtimeSuspensionSQL(t, pool, `INSERT INTO turns(id,session_id,status,completed_at) VALUES($1,$2,'completed',clock_timestamp()-interval '10 minutes')`, id, owner.SessionID) + runtimeSuspensionSQL(t, pool, `INSERT INTO turns(id,session_id,status,completed_at,settings) VALUES($1,$2,'completed',clock_timestamp()-interval '10 minutes','{}')`, id, owner.SessionID) return id } @@ -79,16 +79,16 @@ func TestRuntimeSuspensionRequiresIdleAndNoPendingWork(t *testing.T) { } switch kind { case "queued", "in_progress", "waiting": - runtimeSuspensionSQL(t, pool, `INSERT INTO turns(id,session_id,status) VALUES($1,$2,$3)`, uuid.NewString(), owner.SessionID, kind) + runtimeSuspensionSQL(t, pool, `INSERT INTO turns(id,session_id,status,settings) VALUES($1,$2,$3,'{}')`, uuid.NewString(), owner.SessionID, kind) case "subagent_queued", "subagent_in_progress", "subagent_waiting": child := uuid.NewString() runtimeSuspensionSQL(t, pool, `INSERT INTO turn_events(session_id,turn_id,ordinal,kind,payload) VALUES($1,$2,1,'subagent','{}')`, owner.SessionID, completed) runtimeSuspensionSQL(t, pool, `INSERT INTO subagent_identities(id,session_id,device_id,engine,native_id,parent_native_id,native_created_at,first_turn_id,first_event_ordinal) VALUES($1,$2,$3,'codex','child','root',1,$4,1)`, child, owner.SessionID, owner.DeviceID, completed) runtimeSuspensionSQL(t, pool, `INSERT INTO subagent_turns(id,session_id,subagent_id,native_id,status,created_at) VALUES($1,$2,$3,'child-turn',$4,clock_timestamp())`, uuid.NewString(), owner.SessionID, child, strings.TrimPrefix(kind, "subagent_")) case "initial_input": - runtimeSuspensionSQL(t, pool, `INSERT INTO environment_input_reservations(id,session_id,idempotency_key,batch,is_initial,created_at,deadline) VALUES($1,$2,'initial','[{}]',true,clock_timestamp(),clock_timestamp()+interval '1 minute')`, uuid.NewString(), owner.SessionID) + runtimeSuspensionSQL(t, pool, `INSERT INTO environment_input_reservations(id,session_id,idempotency_key,batch,is_initial,created_at,deadline,settings) VALUES($1,$2,'initial','[{}]',true,clock_timestamp(),clock_timestamp()+interval '1 minute','{}')`, uuid.NewString(), owner.SessionID) case "input_reservation": - runtimeSuspensionSQL(t, pool, `INSERT INTO environment_input_reservations(id,session_id,idempotency_key,batch,created_at,deadline) VALUES($1,$2,'pending','[{}]',clock_timestamp(),clock_timestamp()+interval '1 minute')`, uuid.NewString(), owner.SessionID) + runtimeSuspensionSQL(t, pool, `INSERT INTO environment_input_reservations(id,session_id,idempotency_key,batch,created_at,deadline,settings) VALUES($1,$2,'pending','[{}]',clock_timestamp(),clock_timestamp()+interval '1 minute','{}')`, uuid.NewString(), owner.SessionID) case "file_write": runtimeSuspensionSQL(t, pool, `INSERT INTO environment_file_writes(id,environment_id,device_id,request_sha256) VALUES($1,$2,$3,$4)`, uuid.NewString(), owner.EnvironmentID, owner.DeviceID, strings.Repeat("a", 64)) } diff --git a/services/core/tests/integration/runtime_worker_recovery_test.go b/services/core/tests/integration/runtime_worker_recovery_test.go index 7cb9c7f68..d27a83d5d 100644 --- a/services/core/tests/integration/runtime_worker_recovery_test.go +++ b/services/core/tests/integration/runtime_worker_recovery_test.go @@ -142,7 +142,7 @@ func TestWorkerWaitsForComputeAndSurvivesPromotionConflict(t *testing.T) { func TestWorkerRestartPreservesQueuedTurnWhileComputeWakes(t *testing.T) { h, pool := runtimeWorkerHarness(t) turn := uuid.NewString() - if _, err := pool.Exec(t.Context(), `INSERT INTO turns(id,session_id,status) VALUES($1,$2,'queued')`, turn, h.session.ID); err != nil { + if _, err := pool.Exec(t.Context(), `INSERT INTO turns(id,session_id,status,settings) VALUES($1,$2,'queued','{}')`, turn, h.session.ID); err != nil { t.Fatal(err) } worker, err := startWorkerErr(t, t.Context(), h.s, h.d) diff --git a/services/core/tests/integration/sandbox_reset_test.go b/services/core/tests/integration/sandbox_reset_test.go index 146a5c086..b20812d44 100644 --- a/services/core/tests/integration/sandbox_reset_test.go +++ b/services/core/tests/integration/sandbox_reset_test.go @@ -78,10 +78,10 @@ func TestSandboxResetAutoUsesStartedWorkAndLockedRecheck(t *testing.T) { busy := kind == "in_progress" || kind == "waiting" || kind == "subagent_in_progress" || kind == "subagent_waiting" || kind == "file_write" switch kind { case "queued", "in_progress", "waiting": - runtimeSuspensionSQL(t, s.pool, `INSERT INTO turns(id,session_id,status) VALUES($1,$2,$3)`, uuid.NewString(), session.ID, kind) + runtimeSuspensionSQL(t, s.pool, `INSERT INTO turns(id,session_id,status,settings) VALUES($1,$2,$3,'{}')`, uuid.NewString(), session.ID, kind) case "subagent_queued", "subagent_in_progress", "subagent_waiting": parent, child := uuid.NewString(), uuid.NewString() - runtimeSuspensionSQL(t, s.pool, `INSERT INTO turns(id,session_id,status,completed_at) VALUES($1,$2,'completed',clock_timestamp())`, parent, session.ID) + runtimeSuspensionSQL(t, s.pool, `INSERT INTO turns(id,session_id,status,completed_at,settings) VALUES($1,$2,'completed',clock_timestamp(),'{}')`, parent, session.ID) runtimeSuspensionSQL(t, s.pool, `INSERT INTO turn_events(session_id,turn_id,ordinal,kind,payload) VALUES($1,$2,1,'subagent','{}')`, session.ID, parent) runtimeSuspensionSQL(t, s.pool, `INSERT INTO subagent_identities(id,session_id,device_id,engine,native_id,parent_native_id,native_created_at,first_turn_id,first_event_ordinal) VALUES($1,$2,$3,'codex','child','root',1,$4,1)`, child, session.ID, owner.DeviceID, parent) runtimeSuspensionSQL(t, s.pool, `INSERT INTO subagent_turns(id,session_id,subagent_id,native_id,status,created_at) VALUES($1,$2,$3,'child-turn',$4,clock_timestamp())`, uuid.NewString(), session.ID, child, strings.TrimPrefix(kind, "subagent_")) @@ -325,7 +325,7 @@ func TestSandboxResetPaginationSkipsBusyPrefixAndPreservesSelfHosted(t *testing. runtimeSuspensionSQL(t, s.pool, `INSERT INTO sessions(id,tenant_id,engine,idempotency_key,request_hash,configuration) SELECT $1::uuid,tenant_id,engine,$1::text,$1::text,configuration FROM sessions WHERE id=$2`, id, session.ID) runtimeSuspensionSQL(t, s.pool, `INSERT INTO environments(id,session_id) VALUES($1,$2)`, uuid.NewString(), id) if i <= 34 { - runtimeSuspensionSQL(t, s.pool, `INSERT INTO turns(id,session_id,status) VALUES($1,$2,'in_progress')`, uuid.NewString(), id) + runtimeSuspensionSQL(t, s.pool, `INSERT INTO turns(id,session_id,status,settings) VALUES($1,$2,'in_progress','{}')`, uuid.NewString(), id) } if err := sessionService(t, s).DeleteSession(t.Context(), sessions.DeleteSessionCommand{TenantID: tenant, SessionID: session.ID}); err != nil { t.Fatal(err) diff --git a/services/core/tests/integration/session_diagnostics_test.go b/services/core/tests/integration/session_diagnostics_test.go index 8b185ffd2..510d27a70 100644 --- a/services/core/tests/integration/session_diagnostics_test.go +++ b/services/core/tests/integration/session_diagnostics_test.go @@ -70,7 +70,7 @@ func TestDiagnosticForceSettlementIgnoresNativeClock(t *testing.T) { t.Run(skew.String(), func(t *testing.T) { s, w, owner := managedIdleClockFixture(t) turn := uuid.NewString() - runtimeSuspensionSQL(t, s.pool, "INSERT INTO turns(id,session_id,status,started_at) VALUES($1,$2,'in_progress',clock_timestamp())", turn, owner.SessionID) + runtimeSuspensionSQL(t, s.pool, "INSERT INTO turns(id,session_id,status,started_at,settings) VALUES($1,$2,'in_progress',clock_timestamp(),'{}')", turn, owner.SessionID) events := []sessions.ExecutionEvent{diagnosticToolEvent("first", "before", "in_progress"), diagnosticToolEvent("second", "before", "in_progress")} if err := sessionExecution(t, w.lease).AppendTurnEvents(t.Context(), owner.TenantID, owner.SessionID, turn, 1, events); err != nil { t.Fatal(err) diff --git a/services/core/tests/integration/session_execution_configuration_test.go b/services/core/tests/integration/session_execution_configuration_test.go index c8fd9d99c..91180203a 100644 --- a/services/core/tests/integration/session_execution_configuration_test.go +++ b/services/core/tests/integration/session_execution_configuration_test.go @@ -85,7 +85,7 @@ func TestSessionExecutionConfigurationFrozenAcrossCreationPathsAndRetry(t *testi if err != nil || replay.ID != session.ID { t.Fatal("projection metadata changed retry identity", err) } - if _, err := sessionService(t, s).UpdateSessionMetadata(t.Context(), sessions.UpdateSessionMetadataCommand{TenantID: tenant, SessionID: session.ID, Metadata: map[string]string{"edited": "yes"}}); err != nil { + if _, err := sessionService(t, s).UpdateSession(t.Context(), sessions.UpdateSessionCommand{TenantID: tenant, SessionID: session.ID, Metadata: map[string]string{"edited": "yes"}}); err != nil { t.Fatal(err) } if _, err := pool.Exec(t.Context(), "UPDATE session_model_execution SET encrypted_config='\\x00'::bytea WHERE session_id=$1", session.ID); err != nil { diff --git a/services/core/tests/integration/session_metadata_test.go b/services/core/tests/integration/session_metadata_test.go index 2dad9bbb9..b11bb056c 100644 --- a/services/core/tests/integration/session_metadata_test.go +++ b/services/core/tests/integration/session_metadata_test.go @@ -31,11 +31,8 @@ func TestSessionMetadataPreservesCreationAndExecutionData(t *testing.T) { return row } before := snapshot() - for _, metadata := range []map[string]string{{"new": "value"}, nil, {}, {"unicode": "中文🧪"}} { - updated, err := sessionService(t, s).UpdateSessionMetadata(ctx, sessions.UpdateSessionMetadataCommand{TenantID: tenant, SessionID: first.ID, Metadata: metadata}) - if metadata == nil { - metadata = map[string]string{} - } + for _, metadata := range []map[string]string{{"new": "value"}, {}, {"unicode": "中文🧪"}} { + updated, err := sessionService(t, s).UpdateSession(ctx, sessions.UpdateSessionCommand{TenantID: tenant, SessionID: first.ID, Metadata: metadata}) if err != nil || !reflect.DeepEqual(updated.Metadata, metadata) { t.Fatalf("update = %+v, %v", updated, err) } @@ -68,7 +65,7 @@ func TestSessionMetadataPreservesCreationAndExecutionData(t *testing.T) { {tenant, "invalid", nil, sessions.ErrNotFound}, {tenant, first.ID, map[string]string{"large": strings.Repeat("x", 64*1024)}, sessions.ErrInvalidInput}, } { - if _, err := sessionService(t, s).UpdateSessionMetadata(ctx, sessions.UpdateSessionMetadataCommand{TenantID: test.tenant, SessionID: test.session, Metadata: test.metadata}); !errors.Is(err, test.want) { + if _, err := sessionService(t, s).UpdateSession(ctx, sessions.UpdateSessionCommand{TenantID: test.tenant, SessionID: test.session, Metadata: test.metadata}); !errors.Is(err, test.want) { t.Fatalf("rejected update error = %v, want %v", err, test.want) } } @@ -92,7 +89,7 @@ func TestSessionMetadataConcurrentReplacement(t *testing.T) { go func() { defer wg.Done() key := fmt.Sprint(i) - got, err := sessionService(t, s).UpdateSessionMetadata(ctx, sessions.UpdateSessionMetadataCommand{TenantID: tenant, SessionID: first.ID, Metadata: map[string]string{key: key}}) + got, err := sessionService(t, s).UpdateSession(ctx, sessions.UpdateSessionCommand{TenantID: tenant, SessionID: first.ID, Metadata: map[string]string{key: key}}) if err != nil || len(got.Metadata) != 1 || got.Metadata[key] != key { t.Errorf("concurrent update = %+v, %v", got, err) } @@ -129,7 +126,7 @@ func TestSessionMetadataPreservesTerminalActivity(t *testing.T) { t.Fatal(err) } before.Metadata = map[string]string{"label": status} - updated, err := sessionService(t, s).UpdateSessionMetadata(ctx, sessions.UpdateSessionMetadataCommand{TenantID: tenant, SessionID: session.ID, Metadata: before.Metadata}) + updated, err := sessionService(t, s).UpdateSession(ctx, sessions.UpdateSessionCommand{TenantID: tenant, SessionID: session.ID, Metadata: before.Metadata}) if err != nil || !reflect.DeepEqual(updated, before) { t.Fatalf("metadata changed %s activity: %+v, %v", status, updated, err) } diff --git a/services/core/tests/integration/session_turn_settings_test.go b/services/core/tests/integration/session_turn_settings_test.go new file mode 100644 index 000000000..95856e381 --- /dev/null +++ b/services/core/tests/integration/session_turn_settings_test.go @@ -0,0 +1,56 @@ +package integration + +import ( + "context" + "encoding/json" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" + "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/codex" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" +) + +// A Session update reaches the Turns whose input is accepted after it. A Turn +// queued before keeps its settings, and a change the Harness does not declare +// is refused without a write. +func TestSessionUpdateBindsModelToLaterTurns(t *testing.T) { + h := newDispatchHarness(t) + ctx := context.Background() + declared := codex.Declaration.Clone() + declared.SessionUpdates.Model = proto.CapabilitySupported + supporting := execution.Policy{Declarations: harnessconfig.NewRegistry(declared)} + update := func(policy execution.Policy, model string) error { + _, err := sessionService(t, h.s).UpdateSession(ctx, sessions.UpdateSessionCommand{TenantID: h.tenant, SessionID: h.session.ID, Configure: func(engine string, stored json.RawMessage) (json.RawMessage, error) { + var configuration map[string]any + _ = json.Unmarshal(stored, &configuration) + configuration["agent"].(map[string]any)["model"] = model + updated, _ := json.Marshal(configuration) + return updated, policy.ValidateSessionUpdate(engine, stored, updated) + }}) + return err + } + prepared := func(turn string) proto.PromptRequestPayload { + result := h.run(ctx, turn) + var prompt proto.PromptRequestPayload + _ = h.read(testExecutionRequest).DecodePayload(&prompt) + h.write(turn, proto.TypeDone, proto.DonePayload{Content: "Done"}) + h.finished(result, sessions.TurnCompleted) + return prompt + } + first := h.message("first", "Before the update") + if update(execution.Policy{}, "refused-model") == nil { + t.Fatal("a change the Harness does not declare was accepted") + } + if err := update(supporting, "later-model"); err != nil { + t.Fatal(err) + } + if got := prepared(first.TurnID).Turn.Model; got != "test-model" { + t.Fatalf("the queued Turn ran with model %q", got) + } + second := h.message("second", "After the update") + if got := prepared(second.TurnID).Turn.Model; got != "later-model" { + t.Fatalf("the later Turn ran with model %q", got) + } +} diff --git a/services/core/tests/integration/session_write_audit_test.go b/services/core/tests/integration/session_write_audit_test.go index 907dea345..1bba5a0e9 100644 --- a/services/core/tests/integration/session_write_audit_test.go +++ b/services/core/tests/integration/session_write_audit_test.go @@ -74,7 +74,7 @@ func TestSessionWriteAuditCreationReplayNoopAndDeletion(t *testing.T) { t.Fatal(err) } } - if _, err := sessionService(t, s).UpdateSessionMetadata(sessionAuditContext(t, tenant, "b"), sessions.UpdateSessionMetadataCommand{TenantID: tenant, SessionID: created.ID, Metadata: map[string]string{"private": "not in audit"}}); err != nil { + if _, err := sessionService(t, s).UpdateSession(sessionAuditContext(t, tenant, "b"), sessions.UpdateSessionCommand{TenantID: tenant, SessionID: created.ID, Metadata: map[string]string{"private": "not in audit"}}); err != nil { t.Fatal(err) } for range 2 { @@ -138,7 +138,7 @@ func TestSessionWriteAuditRollback(t *testing.T) { } switch operation { case "update": - _, err = sessionService(t, s).UpdateSessionMetadata(ctx, sessions.UpdateSessionMetadataCommand{TenantID: tenant, SessionID: created.ID, Metadata: map[string]string{"new": "value"}}) + _, err = sessionService(t, s).UpdateSession(ctx, sessions.UpdateSessionCommand{TenantID: tenant, SessionID: created.ID, Metadata: map[string]string{"new": "value"}}) case "delete": err = sessionService(t, s).DeleteSession(ctx, sessions.DeleteSessionCommand{TenantID: tenant, SessionID: created.ID}) case "events": diff --git a/services/core/tests/official_session_metadata.py b/services/core/tests/official_session_metadata.py index ac5343163..64841a36e 100644 --- a/services/core/tests/official_session_metadata.py +++ b/services/core/tests/official_session_metadata.py @@ -117,11 +117,18 @@ def assert_metadata(session, expected): assert list(sessions.list(metadata={key: value, "absent": "x"})) == [] assert list(other.beta.agents.sessions.list(metadata={key: value})) == [] expect_error(BadRequestError, lambda: sessions.list(metadata={"k" * 65: "v"})) - # Core cannot run a Session review or change the agent of a Session. - for fields in [{"review": "auto_review"}, {"agent": {"model": "other"}}]: - expect_error(BadRequestError, lambda: sessions.update(first.id, **fields)) - assert sessions.update(first.id, review="none") == current + # No built-in Harness applies a settings update natively, so a real change is a typed error; a same-value update succeeds. + agent = current.agent + same = {"model": agent.model, "reasoning": {"effort": None}, "service_tier": None} + assert agent.reasoning.effort is None and agent.service_tier == "auto" + assert sessions.update(first.id, agent=same, review="none") == current assert sessions.update(first.id, agent={}) == current + for fields in [{"review": "auto_review"}, {"agent": {"model": "other"}}, {"agent": {"reasoning": {"effort": "high"}}}, {"agent": {"service_tier": "flex"}}]: + expect_error(BadRequestError, lambda: sessions.update(first.id, **fields)) + with httpx2.Client(trust_env=False, timeout=10) as raw: + for tools in ([], [{"type": "tool_search"}]): + response = raw.post(url, headers=headers, json={"agent": {"tools": tools}}) + assert response.status_code == 400 and response.json()["error"]["param"] == "agent.tools" assert sessions.retrieve(first.id) == current print("Session metadata: pinned SDK and raw HTTP replacement, clearing, omission, limits, authentication, tenant isolation, unchanged configuration and creation retry identity passed.") return current