From edfbe7447570e607bc8166714f6f89768bddb9f6 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 23 Sep 2026 18:05:52 +0000 Subject: [PATCH 1/7] Save every pinned web_search mode on Agents Saved Agents now keep web_search modes live, cached, null and omitted as resource data with the official projection (TV-05): omitted or null mode is saved as live, omitted or null context_size as medium, allowed_domains keeps null versus [] and a present location includes all four keys. Session admission keeps the unchanged execution parser, so enabled search, saved or inline, still rejects with unsupported_or_invalid_configuration before any write on plain, streamed, self-hosted and hosted creation, unless the Session replaces its tools. --- contracts/agents-api/openapi.yaml | 23 +- services/agents-api/internal/api/agents.go | 2 +- .../api/configuration_validation_test.go | 12 +- .../agents-api/internal/api/disabled_tools.go | 78 ++++-- services/agents-api/internal/api/handler.go | 2 +- .../agents-api/internal/api/saved_tools.go | 2 +- .../internal/api/saved_web_search_test.go | 137 ++++++++++ .../configuration_validation_public_test.go | 7 +- .../store/saved_web_search_public_test.go | 258 ++++++++++++++++++ 9 files changed, 479 insertions(+), 42 deletions(-) create mode 100644 services/agents-api/internal/api/saved_web_search_test.go create mode 100644 services/agents-api/internal/store/saved_web_search_public_test.go diff --git a/contracts/agents-api/openapi.yaml b/contracts/agents-api/openapi.yaml index d565ea321..e3c7890ec 100644 --- a/contracts/agents-api/openapi.yaml +++ b/contracts/agents-api/openapi.yaml @@ -2927,16 +2927,18 @@ paths: with the JSON path as param; duplicate function names, repeated web_search or tool_search and non-object schema root types return it with a null param. Supports model/name/instructions/metadata, explicit reasoning and service - tiers, multi_agent, text/json_schema, function/tool_search/programmatic_tool_calling + tiers, multi_agent, text/json_schema, function/tool_search/programmatic_tool_calling/web_search and HTTP MCP with nullable credential_id and explicit service origin and boolean required defaulting to false. Saving credential_id grants no access: Session admission checks attached Vault ownership and destination. MCP allowed_tools preserves null versus empty; saved HTTP transport includes empty headers. - Model-derived reasoning defaults, other MCP variants, enabled web_search and - public retry conformance remain incomplete. Explicit disabled web_search can - be saved; Session execution also accepts explicit disabled programmatic_tool_calling - through qualified Runtime controls. Session execution admits only its supported - configuration subset.' + Model-derived reasoning defaults, other MCP variants and public retry conformance + remain incomplete. web_search saves every pinned mode: omitted or null mode + is saved as live and omitted or null context_size as medium; allowed_domains + preserves null versus empty and a present location includes all four keys. + Session execution accepts only explicit disabled web_search and disabled programmatic_tool_calling + through qualified Runtime controls; saved enabled forms reject at Session + admission. Session execution admits only its supported configuration subset.' parameters: - description: agents=v1 in: header @@ -3892,10 +3894,11 @@ paths: tool_search and per-function defer_loading in the qualified single-agent Claude environment:none function profile, including qualified inline image messages and text results. Explicit web_search mode disabled and programmatic_tool_calling - enabled false use frozen common Runtime controls. Enabled forms remain unqualified. - Omitted programmatic configuration preserves native behavior, a documented - difference from the official default-on behavior. Other combinations remain - unqualified; see the operation coverage. + enabled false use frozen common Runtime controls. Enabled forms, including + those saved on an Agent, remain unqualified and reject before any write unless + the Session replaces tools. Omitted programmatic configuration preserves native + behavior, a documented difference from the official default-on behavior. Other + combinations remain unqualified; see the operation coverage. parameters: - description: agents=v1 in: header diff --git a/services/agents-api/internal/api/agents.go b/services/agents-api/internal/api/agents.go index ff95c17e0..aca94451c 100644 --- a/services/agents-api/internal/api/agents.go +++ b/services/agents-api/internal/api/agents.go @@ -20,7 +20,7 @@ type AgentStore interface { } // @Summary Create a reusable Agent -// @Description Persists configuration independently of execution. Names over 128 characters and metadata outside 16 string pairs with 64-character keys and 512-character values return invalid_request_error with the official param; U+0000 in stored strings is rejected as a local storage limit. Missing, unknown, repeated, wrongly typed or unsupported enum members of the pinned configuration shapes (tools, text, reasoning, service_tier, multi_agent) return invalid_request_error with the JSON path as param; duplicate function names, repeated web_search or tool_search and non-object schema root types return it with a null param. Supports model/name/instructions/metadata, explicit reasoning and service tiers, multi_agent, text/json_schema, function/tool_search/programmatic_tool_calling and HTTP MCP with nullable credential_id and explicit service origin and boolean required defaulting to false. Saving credential_id grants no access: Session admission checks attached Vault ownership and destination. MCP allowed_tools preserves null versus empty; saved HTTP transport includes empty headers. Model-derived reasoning defaults, other MCP variants, enabled web_search and public retry conformance remain incomplete. Explicit disabled web_search can be saved; Session execution also accepts explicit disabled programmatic_tool_calling through qualified Runtime controls. Session execution admits only its supported configuration subset. +// @Description Persists configuration independently of execution. Names over 128 characters and metadata outside 16 string pairs with 64-character keys and 512-character values return invalid_request_error with the official param; U+0000 in stored strings is rejected as a local storage limit. Missing, unknown, repeated, wrongly typed or unsupported enum members of the pinned configuration shapes (tools, text, reasoning, service_tier, multi_agent) return invalid_request_error with the JSON path as param; duplicate function names, repeated web_search or tool_search and non-object schema root types return it with a null param. Supports model/name/instructions/metadata, explicit reasoning and service tiers, multi_agent, text/json_schema, function/tool_search/programmatic_tool_calling/web_search and HTTP MCP with nullable credential_id and explicit service origin and boolean required defaulting to false. Saving credential_id grants no access: Session admission checks attached Vault ownership and destination. MCP allowed_tools preserves null versus empty; saved HTTP transport includes empty headers. Model-derived reasoning defaults, other MCP variants and public retry conformance remain incomplete. web_search saves every pinned mode: omitted or null mode is saved as live and omitted or null context_size as medium; allowed_domains preserves null versus empty and a present location includes all four keys. Session execution accepts only explicit disabled web_search and disabled programmatic_tool_calling through qualified Runtime controls; saved enabled forms reject at Session admission. Session execution admits only its supported configuration subset. // @Tags Agents // @Accept json // @Produce json diff --git a/services/agents-api/internal/api/configuration_validation_test.go b/services/agents-api/internal/api/configuration_validation_test.go index d1b6bf457..575b74295 100644 --- a/services/agents-api/internal/api/configuration_validation_test.go +++ b/services/agents-api/internal/api/configuration_validation_test.go @@ -204,6 +204,10 @@ func TestAgentConfigurationAcceptedValuesUnchanged(t *testing.T) { } saved := append([]string{ `"tools":[{"type":"programmatic_tool_calling","enabled":true}]`, + // TV-05: every pinned web_search mode is saved; admission still qualifies only disabled. + `"tools":[{"type":"web_search"}]`, `"tools":[{"type":"web_search","mode":null}]`, + `"tools":[{"type":"web_search","mode":"live","allowed_domains":[]}]`, + `"tools":[{"type":"web_search","mode":"cached","context_size":"high","allowed_domains":["example.com"],"location":{"country":"FR","city":"Paris"}}]`, `"reasoning":{"effort":"max","summary":"auto"}`, `"service_tier":"flex"`, `"text":{"format":{"type":"json_schema","schema":{"type":"object"}}}`, `"text":{"format":{"type":"json_schema","schema":{"properties":{}}}}`, @@ -226,12 +230,15 @@ func TestAgentConfigurationAcceptedValuesUnchanged(t *testing.T) { } } -// K2 and K3: execution admission and saved web_search limits keep their codes. +// K2: execution admission limits keep their codes. Enabled or omitted-mode +// web_search is saved (TV-05) but still rejected at Session admission. func TestAgentConfigurationLocalLimitsKeepCodes(t *testing.T) { h, s := configurationHandler(t, nil) session := configurationOperations()[2] for _, tc := range []struct{ fields, message string }{ {`"tools":[{"type":"web_search","mode":"live"}]`, "Only disabled web_search is qualified for execution."}, + {`"tools":[{"type":"web_search","mode":"cached"}]`, "Only disabled web_search is qualified for execution."}, + {`"tools":[{"type":"web_search","mode":null}]`, "Only disabled web_search is qualified for execution."}, {`"tools":[{"type":"web_search"}]`, "Only disabled web_search is qualified for execution."}, {`"tools":[{"type":"programmatic_tool_calling","enabled":true}]`, "Programmatic tool calling is not qualified for execution."}, {`"tools":[{"type":"programmatic_tool_calling","enabled":false},{"type":"programmatic_tool_calling","enabled":false}]`, "Execution requires distinct tool controls."}, @@ -243,9 +250,6 @@ func TestAgentConfigurationLocalLimitsKeepCodes(t *testing.T) { assertConfigurationError(t, credentialRequest(h, http.MethodPost, session.path, session.body(tc.fields)), "unsupported_or_invalid_configuration", nil, tc.message) } for _, op := range configurationOperations()[:2] { - for _, fields := range []string{`"tools":[{"type":"web_search","mode":"live"}]`, `"tools":[{"type":"web_search"}]`, `"tools":[{"type":"web_search","mode":"cached"}]`, `"tools":[{"type":"web_search","mode":null}]`} { - assertConfigurationError(t, credentialRequest(h, http.MethodPost, op.path, op.body(fields)), "unsupported_or_invalid_configuration", nil, "Only disabled web_search is qualified for execution.") - } assertConfigurationError(t, credentialRequest(h, http.MethodPost, op.path, op.body(`"multi_agent":{"enabled":true,"max_concurrent_subagents":4294967296}`)), "unsupported_or_invalid_configuration", nil, "max_concurrent_subagents must be an integer from 1 to 4294967295.") } if s.writes != 0 { diff --git a/services/agents-api/internal/api/disabled_tools.go b/services/agents-api/internal/api/disabled_tools.go index f6f89133f..b52f3d9a3 100644 --- a/services/agents-api/internal/api/disabled_tools.go +++ b/services/agents-api/internal/api/disabled_tools.go @@ -23,36 +23,70 @@ func resolveProgrammaticTool(raw json.RawMessage) (json.RawMessage, error) { }{input.Type, enabled}) } -// Only disabled search is qualified; its optional settings remain resource data. -func resolveDisabledWebSearch(raw json.RawMessage) (json.RawMessage, error) { - var input struct { - Type string `json:"type"` - Mode string `json:"mode"` - ContextSize *string `json:"context_size"` - AllowedDomains []*string `json:"allowed_domains"` - Location *struct { - City *string `json:"city"` - Country *string `json:"country"` - Region *string `json:"region"` - Timezone *string `json:"timezone"` - } `json:"location"` - } - if decodeInputObject(raw, &input, "type", "mode", "context_size", "allowed_domains", "location") != nil || input.Mode != "disabled" { - return nil, errors.New("Only disabled web_search is qualified for execution.") - } - if input.ContextSize == nil { +// webSearchTool is the resolved web_search projection. A present location +// projects all four keys; null and empty allowed_domains stay distinct. +type webSearchTool struct { + Type string `json:"type"` + Mode *string `json:"mode"` + ContextSize *string `json:"context_size"` + AllowedDomains []*string `json:"allowed_domains"` + Location *struct { + City *string `json:"city"` + Country *string `json:"country"` + Region *string `json:"region"` + Timezone *string `json:"timezone"` + } `json:"location"` +} + +func decodeWebSearch(raw json.RawMessage) (webSearchTool, bool) { + var tool webSearchTool + return tool, decodeInputObject(raw, &tool, "type", "mode", "context_size", "allowed_domains", "location") == nil +} + +// Optional settings are resource data in every mode; they never enable execution. +func (tool webSearchTool) resolveSettings() (json.RawMessage, error) { + if tool.ContextSize == nil { value := "medium" - input.ContextSize = &value + tool.ContextSize = &value } - for _, domain := range input.AllowedDomains { + for _, domain := range tool.AllowedDomains { if domain == nil { return nil, errors.New("web_search.allowed_domains must contain strings.") } } - switch *input.ContextSize { + switch *tool.ContextSize { case "low", "medium", "high": default: return nil, errors.New("Invalid web_search.context_size.") } - return json.Marshal(input) + return json.Marshal(tool) +} + +// Saved Agents keep every pinned mode, as the official service does; an omitted +// or null mode is saved as live. Session admission still qualifies only disabled +// search (resolveDisabledWebSearch), so saving never enables execution. +func resolveSavedWebSearch(raw json.RawMessage) (json.RawMessage, error) { + tool, ok := decodeWebSearch(raw) + if !ok { + return nil, errors.New("Invalid web_search fields.") + } + if tool.Mode == nil { + value := "live" + tool.Mode = &value + } + switch *tool.Mode { + case "disabled", "cached", "live": + default: + return nil, errors.New("web_search.mode must be disabled, cached or live.") + } + return tool.resolveSettings() +} + +// Only disabled search is qualified for execution. +func resolveDisabledWebSearch(raw json.RawMessage) (json.RawMessage, error) { + tool, ok := decodeWebSearch(raw) + if !ok || tool.Mode == nil || *tool.Mode != "disabled" { + return nil, errors.New("Only disabled web_search is qualified for execution.") + } + return tool.resolveSettings() } diff --git a/services/agents-api/internal/api/handler.go b/services/agents-api/internal/api/handler.go index 070ae329d..40abfe338 100644 --- a/services/agents-api/internal/api/handler.go +++ b/services/agents-api/internal/api/handler.go @@ -148,7 +148,7 @@ func NewHandler(s ResourceStore, auth *Authenticator, engine string, options ... // createSession atomically reserves or admits initial text with the Session. // @Summary Create an execution Session -// @Description The optional Core model_provider bundle resolves from the Session override, saved Agent defaults, then deployment defaults. Hosted Sessions encrypt and freeze the resolved bundle; later Agent edits and same-key retries cannot change it. Keys are never returned. Supports inline configuration or a tenant-owned saved agent_id with per-Session field replacements. Execution supports model/instructions, text verbosity, non-deferred function tools, adapter-qualified multi_agent with persisted Subagent reads, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with explicit service origin, native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Ambiguous selection rejects creation. Frozen private selections never populate an omitted public credential_id; missing decryption configuration fails dispatch without anonymous fallback. Required initialization uses native startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and OAuth remain unsupported. The self_hosted profile requires Codex, an absolute workspace_directory and empty capability_directories, with optional non-deferred function tools and HTTP MCP using explicit service origin, optionally authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication each require separately advertised combination support; old peers cannot receive unsupported work. Omitted/null capability_directories use the empty-list default; self_hosted requires configured execution plus executor registry. Claude SDK currently requires medium verbosity and object-root function schemas. It supports anonymous or attached static-bearer service-origin HTTP MCP on none with boolean required and separately advertised MCP/bearer/required runtime support. Required servers must be connected before the first native input is released; pending or failed startup rejects execution. The shared Vault selection and immutable binding rules apply; unsupported native labels/tool names reject before persistence. An attached Vault with no matching credential may remain anonymous; missing keys or failed credential lookup/decryption never fall back to anonymous execution. Omitted stream defaults to false; stream and agent_id cannot be null. Metadata may be null; non-string values and limit violations return invalid_request_error with a metadata or metadata. param. The inline agent uses the Agent create configuration validation with agent.-prefixed params, reported before the input requirement and saved-Agent lookup; saved configurations with conflicting tools or schema roots reject admission with the same errors, and execution limits keep unsupported_or_invalid_configuration. Hosted network policy rejections return invalid_request_error with a null param. Initial input accepts a string or ordered user-message array. Codex and Claude SDK on none and qualified openai_hosted also accept inline PNG/JPEG image content; other image combinations and remote URLs are unsupported. None initial input atomically starts a Turn; self_hosted initial input is reserved while returning its Environment connection target, with execution deferred to native readiness and Session failure on initial timeout. Initial input is required for none and for streamed creation outside self_hosted. Omitted/null input remains valid for non-streaming hosted and self_hosted creation. With stream=true, returns live Session events starting with the committed creation snapshot and closes right after the first agent.session.idle recorded when a Turn ends or an input reservation stops being pending, or any agent.session.failed, without sending later events. A creation that admitted nothing closes after the snapshot; a settlement that records no event closes after events up to the cursor read with a settled Session projection. Required actions keep it open; disconnect does not cancel execution. The GET events stream remains live-only. New Sessions retain their authenticated creator; all creation retries require the same typed subject, including across key rotation. Saved-Agent retries and inline requests using Vault attachments or credential references retain caller intent independently of later resource changes; new hosted inline requests also freeze caller intent before deployment defaults resolve; unrelated non-hosted inline retries preserve resolved/default equivalences. Unknown historical creators reject retries; known creators without recorded intent retain resolved-snapshot retry rules. These conflict policies are local and not verified hosted parity. A same-key stream=true retry of an existing creation returns 201 with no events and closes at once; retry with stream=false or use the GET events stream to recover. Claude SDK on none and Core-managed Docker openai_hosted supports qualified object-root json_schema output with medium verbosity, single-Agent execution and ordinary functions. Hosted execution reuses native workspace tools and Files/Artifacts; Skills, Plugins, capability directories, HTTP MCP, Subagent and tool_search combinations remain unqualified, including inherited template contents. Other non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile supports non-deferred function tools with text or successful inline PNG/JPEG results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled and restricted exact ASCII hostnames are supported. Restricted policy requires 1–100 allowed domains. Unsupported hostname forms and startup installations are rejected. Confidential env, system/npm/Python packages and ordered setup commands use the shared initialization lifecycle; requested network applies after setup. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the common Core lifecycle before native execution or live Files access. With a template reference, omitted/null files, env, packages and setup_commands inherit. Non-null files and command lists replace; env overlays by key; each package manager inherits on omission/null and otherwise replaces its list. Empty lists clear their selected field. Tenant-owned environment_template_id references inherit omitted/null network and allow only narrowing overrides. Inline hosted network:null retains the enabled default; updating a Template with network:null resets its saved policy to enabled. Core freezes effective configuration; template updates/deletion do not alter Session snapshots or same-intent creation retries. Inline or tenant-owned skill_reference Skills share initialization. Templates preserve default/latest/explicit selectors; Session creation freezes concrete metadata and encrypted content atomically. Skill, Plugin and capability-directory list omission/null inherit; a non-null list replaces, including empty-list clearing. Omitted/null Skill version selectors resolve the default version. Source deletion/default updates cannot change committed Session Skill contents. Deferred function discovery uses type-only tool_search and per-function defer_loading in the qualified single-agent Claude environment:none function profile, including qualified inline image messages and text results. Explicit web_search mode disabled and programmatic_tool_calling enabled false use frozen common Runtime controls. Enabled forms remain unqualified. Omitted programmatic configuration preserves native behavior, a documented difference from the official default-on behavior. Other combinations remain unqualified; see the operation coverage. +// @Description The optional Core model_provider bundle resolves from the Session override, saved Agent defaults, then deployment defaults. Hosted Sessions encrypt and freeze the resolved bundle; later Agent edits and same-key retries cannot change it. Keys are never returned. Supports inline configuration or a tenant-owned saved agent_id with per-Session field replacements. Execution supports model/instructions, text verbosity, non-deferred function tools, adapter-qualified multi_agent with persisted Subagent reads, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with explicit service origin, native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Ambiguous selection rejects creation. Frozen private selections never populate an omitted public credential_id; missing decryption configuration fails dispatch without anonymous fallback. Required initialization uses native startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and OAuth remain unsupported. The self_hosted profile requires Codex, an absolute workspace_directory and empty capability_directories, with optional non-deferred function tools and HTTP MCP using explicit service origin, optionally authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication each require separately advertised combination support; old peers cannot receive unsupported work. Omitted/null capability_directories use the empty-list default; self_hosted requires configured execution plus executor registry. Claude SDK currently requires medium verbosity and object-root function schemas. It supports anonymous or attached static-bearer service-origin HTTP MCP on none with boolean required and separately advertised MCP/bearer/required runtime support. Required servers must be connected before the first native input is released; pending or failed startup rejects execution. The shared Vault selection and immutable binding rules apply; unsupported native labels/tool names reject before persistence. An attached Vault with no matching credential may remain anonymous; missing keys or failed credential lookup/decryption never fall back to anonymous execution. Omitted stream defaults to false; stream and agent_id cannot be null. Metadata may be null; non-string values and limit violations return invalid_request_error with a metadata or metadata. param. The inline agent uses the Agent create configuration validation with agent.-prefixed params, reported before the input requirement and saved-Agent lookup; saved configurations with conflicting tools or schema roots reject admission with the same errors, and execution limits keep unsupported_or_invalid_configuration. Hosted network policy rejections return invalid_request_error with a null param. Initial input accepts a string or ordered user-message array. Codex and Claude SDK on none and qualified openai_hosted also accept inline PNG/JPEG image content; other image combinations and remote URLs are unsupported. None initial input atomically starts a Turn; self_hosted initial input is reserved while returning its Environment connection target, with execution deferred to native readiness and Session failure on initial timeout. Initial input is required for none and for streamed creation outside self_hosted. Omitted/null input remains valid for non-streaming hosted and self_hosted creation. With stream=true, returns live Session events starting with the committed creation snapshot and closes right after the first agent.session.idle recorded when a Turn ends or an input reservation stops being pending, or any agent.session.failed, without sending later events. A creation that admitted nothing closes after the snapshot; a settlement that records no event closes after events up to the cursor read with a settled Session projection. Required actions keep it open; disconnect does not cancel execution. The GET events stream remains live-only. New Sessions retain their authenticated creator; all creation retries require the same typed subject, including across key rotation. Saved-Agent retries and inline requests using Vault attachments or credential references retain caller intent independently of later resource changes; new hosted inline requests also freeze caller intent before deployment defaults resolve; unrelated non-hosted inline retries preserve resolved/default equivalences. Unknown historical creators reject retries; known creators without recorded intent retain resolved-snapshot retry rules. These conflict policies are local and not verified hosted parity. A same-key stream=true retry of an existing creation returns 201 with no events and closes at once; retry with stream=false or use the GET events stream to recover. Claude SDK on none and Core-managed Docker openai_hosted supports qualified object-root json_schema output with medium verbosity, single-Agent execution and ordinary functions. Hosted execution reuses native workspace tools and Files/Artifacts; Skills, Plugins, capability directories, HTTP MCP, Subagent and tool_search combinations remain unqualified, including inherited template contents. Other non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile supports non-deferred function tools with text or successful inline PNG/JPEG results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled and restricted exact ASCII hostnames are supported. Restricted policy requires 1–100 allowed domains. Unsupported hostname forms and startup installations are rejected. Confidential env, system/npm/Python packages and ordered setup commands use the shared initialization lifecycle; requested network applies after setup. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the common Core lifecycle before native execution or live Files access. With a template reference, omitted/null files, env, packages and setup_commands inherit. Non-null files and command lists replace; env overlays by key; each package manager inherits on omission/null and otherwise replaces its list. Empty lists clear their selected field. Tenant-owned environment_template_id references inherit omitted/null network and allow only narrowing overrides. Inline hosted network:null retains the enabled default; updating a Template with network:null resets its saved policy to enabled. Core freezes effective configuration; template updates/deletion do not alter Session snapshots or same-intent creation retries. Inline or tenant-owned skill_reference Skills share initialization. Templates preserve default/latest/explicit selectors; Session creation freezes concrete metadata and encrypted content atomically. Skill, Plugin and capability-directory list omission/null inherit; a non-null list replaces, including empty-list clearing. Omitted/null Skill version selectors resolve the default version. Source deletion/default updates cannot change committed Session Skill contents. Deferred function discovery uses type-only tool_search and per-function defer_loading in the qualified single-agent Claude environment:none function profile, including qualified inline image messages and text results. Explicit web_search mode disabled and programmatic_tool_calling enabled false use frozen common Runtime controls. Enabled forms, including those saved on an Agent, remain unqualified and reject before any write unless the Session replaces tools. Omitted programmatic configuration preserves native behavior, a documented difference from the official default-on behavior. Other combinations remain unqualified; see the operation coverage. // @Tags Sessions // @Accept json // @Produce json,text/event-stream diff --git a/services/agents-api/internal/api/saved_tools.go b/services/agents-api/internal/api/saved_tools.go index 9a4a2f2c8..844c4c637 100644 --- a/services/agents-api/internal/api/saved_tools.go +++ b/services/agents-api/internal/api/saved_tools.go @@ -46,7 +46,7 @@ func resolveSavedTools(input []json.RawMessage) ([]json.RawMessage, error) { } value = resolved case "web_search": - resolved, err := resolveDisabledWebSearch(raw) + resolved, err := resolveSavedWebSearch(raw) if err != nil { return nil, err } diff --git a/services/agents-api/internal/api/saved_web_search_test.go b/services/agents-api/internal/api/saved_web_search_test.go new file mode 100644 index 000000000..d377e2378 --- /dev/null +++ b/services/agents-api/internal/api/saved_web_search_test.go @@ -0,0 +1,137 @@ +package api + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/google/uuid" +) + +const onlyDisabledSearch = "Only disabled web_search is qualified for execution." + +// TV-05 W1/W2: saved web_search keeps every pinned mode with the official +// projection, while Session execution still qualifies only the disabled form. +func TestSavedWebSearchProjection(t *testing.T) { + const defaults = `"context_size":"medium","allowed_domains":null,"location":null}` + live := `{"type":"web_search","mode":"live",` + defaults + for _, tc := range []struct{ input, want string }{ + {`{"type":"web_search"}`, live}, + {`{"type":"web_search","mode":null}`, live}, + {`{"type":"web_search","mode":"live"}`, live}, + {`{"type":"web_search","mode":null,"context_size":null,"allowed_domains":null,"location":null}`, live}, + {`{"type":"web_search","mode":"cached"}`, `{"type":"web_search","mode":"cached",` + defaults}, + {`{"type":"web_search","mode":"disabled"}`, `{"type":"web_search","mode":"disabled",` + defaults}, + {`{"type":"web_search","context_size":"low"}`, `{"type":"web_search","mode":"live","context_size":"low","allowed_domains":null,"location":null}`}, + {`{"type":"web_search","mode":"live","allowed_domains":[]}`, `{"type":"web_search","mode":"live","context_size":"medium","allowed_domains":[],"location":null}`}, + {`{"type":"web_search","mode":"cached","context_size":"high","allowed_domains":["example.com"],"location":{"country":"FR","city":"Paris"}}`, + `{"type":"web_search","mode":"cached","context_size":"high","allowed_domains":["example.com"],"location":{"city":"Paris","country":"FR","region":null,"timezone":null}}`}, + {`{"type":"web_search","location":{}}`, `{"type":"web_search","mode":"live","context_size":"medium","allowed_domains":null,"location":{"city":null,"country":null,"region":null,"timezone":null}}`}, + } { + saved, err := resolveSavedTools([]json.RawMessage{json.RawMessage(tc.input)}) + if err != nil || len(saved) != 1 || string(saved[0]) != tc.want { + t.Fatalf("%s: %s %v", tc.input, saved, err) + } + // Saving is idempotent, so retrieved records resolve to the same bytes. + again, err := resolveSavedTools(saved) + if err != nil || string(again[0]) != tc.want { + t.Fatalf("%s: resaved %s %v", tc.input, again, err) + } + executed, err := resolveSessionTools(saved) + var mode struct{ Mode string } + _ = json.Unmarshal(saved[0], &mode) + if mode.Mode == "disabled" { + if err != nil || string(executed[0]) != tc.want { + t.Fatalf("%s: disabled execution changed: %s %v", tc.input, executed, err) + } + } else if err == nil || err.Error() != onlyDisabledSearch { + t.Fatalf("%s: enabled search admitted: %v", tc.input, err) + } + } +} + +// TV-05 W1: Agent create echoes the saved projection. +func TestSavedWebSearchAgentCreate(t *testing.T) { + h, s := configurationHandler(t, nil) + w := credentialRequest(h, http.MethodPost, "/v1/agents", `{"model":"m","tools":[{"type":"web_search"},{"type":"programmatic_tool_calling","enabled":true}]}`) + var agent struct{ Tools []json.RawMessage } + if w.Code != http.StatusCreated || json.Unmarshal(w.Body.Bytes(), &agent) != nil || len(agent.Tools) != 2 || s.writes != 1 || + string(agent.Tools[0]) != `{"type":"web_search","mode":"live","context_size":"medium","allowed_domains":null,"location":null}` { + t.Fatal(w.Code, w.Body) + } + if w := credentialRequest(h, http.MethodPost, "/v1/agents/"+uuid.NewString(), `{"tools":[{"type":"web_search","mode":"cached"}]}`); w.Code != http.StatusOK || s.writes != 2 { + t.Fatal(w.Code, w.Body) + } +} + +// TV-05 W4-W7: a saved Agent with enabled search cannot reach execution through +// any creation mode; a Session tools replacement or disabled search is admitted. +func TestSavedWebSearchSessionAdmission(t *testing.T) { + saved := func(tool string) string { + return `{"model":"m","multi_agent":{"enabled":false,"max_concurrent_subagents":null},"reasoning":{},"service_tier":"auto","text":{"format":{"type":"text"},"verbosity":"medium"},"tools":[` + tool + `]}` + } + enabled := map[string]string{} + for _, mode := range []string{"live", "cached"} { + enabled[uuid.NewString()] = saved(`{"type":"web_search","mode":"` + mode + `","context_size":"medium","allowed_domains":null,"location":null}`) + } + disabled := uuid.NewString() + agents := map[string]string{disabled: saved(`{"type":"web_search","mode":"disabled","context_size":"medium","allowed_domains":[],"location":null}`)} + for id, configuration := range enabled { + agents[id] = configuration + } + h, s := configurationHandler(t, agents) + for id := range enabled { + for _, suffix := range []string{ + `"environment":{"type":"none"},"input":"x"}`, + `"environment":{"type":"none"},"input":"x","stream":true}`, + `"environment":{"type":"self_hosted","workspace_directory":"/workspace"},"input":"x"}`, + `"environment":{"type":"self_hosted","workspace_directory":"/workspace"}}`, + `"environment":{"type":"openai_hosted"}}`, + // Replacing another field keeps the saved tools. + `"agent":{"instructions":"x"},"environment":{"type":"none"},"input":"x"}`, + } { + w := credentialRequest(h, http.MethodPost, "/v1/agents/sessions", `{"agent_id":"`+id+`",`+suffix) + assertConfigurationError(t, w, "unsupported_or_invalid_configuration", nil, onlyDisabledSearch) + } + // C4: the input requirement still precedes execution admission. + w := credentialRequest(h, http.MethodPost, "/v1/agents/sessions", `{"agent_id":"`+id+`","environment":{"type":"none"}}`) + assertConfigurationError(t, w, "invalid_request_error", nil, "conversation-only sessions currently require initial input") + } + // W6: inline enabled or omitted-mode search, also as a saved-Agent override. + for _, tool := range []string{`{"type":"web_search"}`, `{"type":"web_search","mode":null}`, `{"type":"web_search","mode":"live"}`, `{"type":"web_search","mode":"cached"}`} { + for _, body := range []string{ + `{"agent":{"model":"m","tools":[` + tool + `]},"environment":{"type":"none"},"input":"x"}`, + `{"agent_id":"` + disabled + `","agent":{"tools":[` + tool + `]},"environment":{"type":"none"},"input":"x"}`, + } { + assertConfigurationError(t, credentialRequest(h, http.MethodPost, "/v1/agents/sessions", body), "unsupported_or_invalid_configuration", nil, onlyDisabledSearch) + } + } + if s.writes != 0 { + t.Fatal("rejected configuration reached storage") + } + // W5 and W7: a per-Session tools replacement and saved disabled search are admitted. + for id := range enabled { + for _, tools := range []string{`[]`, `null`, `[{"type":"web_search","mode":"disabled"}]`} { + w := credentialRequest(h, http.MethodPost, "/v1/agents/sessions", `{"agent_id":"`+id+`","agent":{"tools":`+tools+`},"environment":{"type":"none"},"input":"x"}`) + var session struct { + Agent struct{ Tools []json.RawMessage } + } + if w.Code != http.StatusCreated || json.Unmarshal(w.Body.Bytes(), &session) != nil { + t.Fatal(tools, w.Code, w.Body) + } + for _, tool := range session.Agent.Tools { + if string(tool) != `{"type":"web_search","mode":"disabled","context_size":"medium","allowed_domains":null,"location":null}` { + t.Fatalf("saved search leaked into the Session: %s", w.Body) + } + } + } + } + w := credentialRequest(h, http.MethodPost, "/v1/agents/sessions", `{"agent_id":"`+disabled+`","environment":{"type":"none"},"input":"x"}`) + var session struct { + Agent struct{ Tools []json.RawMessage } + } + if w.Code != http.StatusCreated || json.Unmarshal(w.Body.Bytes(), &session) != nil || len(session.Agent.Tools) != 1 || + string(session.Agent.Tools[0]) != `{"type":"web_search","mode":"disabled","context_size":"medium","allowed_domains":[],"location":null}` { + t.Fatal(w.Code, w.Body) + } +} diff --git a/services/agents-api/internal/store/configuration_validation_public_test.go b/services/agents-api/internal/store/configuration_validation_public_test.go index 9205d741a..d37b5e2b0 100644 --- a/services/agents-api/internal/store/configuration_validation_public_test.go +++ b/services/agents-api/internal/store/configuration_validation_public_test.go @@ -126,10 +126,11 @@ func TestAgentConfigurationValidationRejectsWithoutWritesPostgres(t *testing.T) if status, body := client.do(foreign, http.MethodPost, "/v1/agents/sessions", "application/json", []byte(`{"agent_id":"`+legacy.ID+`","environment":{"type":"none"},"input":"hi"}`)); status != http.StatusNotFound { t.Errorf("foreign legacy saved Agent: %d %s", status, body) } - // K2 and K3 keep the local configuration code. + // K2 keeps the local configuration code; saved enabled web_search (TV-05) is + // covered by TestSavedWebSearchPostgres. for _, request := range []struct{ path, body, message string }{ - {"/v1/agents", `{"model":"m","tools":[{"type":"web_search","mode":"live"}]}`, "Only disabled web_search is qualified for execution."}, - {"/v1/agents/" + agent, `{"tools":[{"type":"web_search"}]}`, "Only disabled web_search is qualified for execution."}, + {"/v1/agents/sessions", `{"agent":{"model":"m","tools":[{"type":"web_search","mode":"live"}]},"environment":{"type":"none"},"input":"hi"}`, "Only disabled web_search is qualified for execution."}, + {"/v1/agents/sessions", `{"agent":{"model":"m","tools":[{"type":"web_search"}]},"environment":{"type":"none"},"input":"hi"}`, "Only disabled web_search is qualified for execution."}, {"/v1/agents/sessions", `{"agent":{"model":"m","tools":[{"type":"programmatic_tool_calling","enabled":true}]},"environment":{"type":"none"},"input":"hi"}`, "Programmatic tool calling is not qualified for execution."}, {"/v1/agents/sessions", `{"agent":{"model":"m","text":{"format":{"type":"json_schema","schema":{"type":"object"}}}},"environment":{"type":"none"},"input":"hi"}`, "Harness codex does not support the requested Agent/environment configuration: Structured output is not qualified for this engine."}, } { diff --git a/services/agents-api/internal/store/saved_web_search_public_test.go b/services/agents-api/internal/store/saved_web_search_public_test.go new file mode 100644 index 000000000..62ce27622 --- /dev/null +++ b/services/agents-api/internal/store/saved_web_search_public_test.go @@ -0,0 +1,258 @@ +package store_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +// TV-05: saved Agents keep every pinned web_search mode with the official +// projection (W1/W2), while Session admission still rejects enabled search +// before any write on every creation mode (W4), admits a tools replacement (W5) +// and keeps tenant isolation (W8). +func TestSavedWebSearchPostgres(t *testing.T) { + // An isolated database keeps the no-write digest independent of other tests. + s, pool := store.NewManagedTestStore(t) + owner, foreign, ownerTenant := uuid.NewString(), uuid.NewString(), uuid.NewString() + auth, err := api.NewAuthenticator([]api.APIKey{ + {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "search-owner", TokenSHA256: device.HashCredential(owner), TenantID: ownerTenant}, + {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "search-foreign", TokenSHA256: device.HashCredential(foreign), TenantID: uuid.NewString()}, + }) + if err != nil { + t.Fatal(err) + } + h, err := api.NewHandler(s, auth, "codex", api.WithExecution(s)) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(h) + defer server.Close() + client := pathIDClient{t: t, server: server} + + // tools returns the exact tool bytes of an Agent response and checks them + // against the official projection. + tools := func(name string, status int, body string, want int, official string) string { + t.Helper() + var agent struct { + ID string + Tools []json.RawMessage + } + if status != want || json.Unmarshal([]byte(body), &agent) != nil || len(agent.Tools) != 1 { + t.Fatalf("%s: %d %s", name, status, body) + } + var got, expected any + if json.Unmarshal(agent.Tools[0], &got) != nil || json.Unmarshal([]byte(official), &expected) != nil || !reflect.DeepEqual(got, expected) { + t.Fatalf("%s: tool %s, official %s", name, agent.Tools[0], official) + } + return string(agent.Tools[0]) + } + // readBack checks that retrieve and list return the same Agent bytes. + readBack := func(name, id, body string) { + t.Helper() + if status, retrieved := client.do(owner, http.MethodGet, "/v1/agents/"+id, "", nil); status != http.StatusOK || retrieved != body { + t.Fatalf("%s retrieve: %d %s", name, status, retrieved) + } + status, raw := client.do(owner, http.MethodGet, "/v1/agents?limit=100", "", nil) + var page struct{ Data []json.RawMessage } + if status != http.StatusOK || json.Unmarshal([]byte(raw), &page) != nil { + t.Fatalf("%s list: %d %s", name, status, raw) + } + for _, item := range page.Data { + if strings.Contains(string(item), `"id":"`+id+`"`) { + if string(item)+"\n" != body { + t.Fatalf("%s list item %s, want %s", name, item, body) + } + return + } + } + t.Fatalf("%s: Agent missing from list", name) + } + const defaults = `"context_size":"medium","allowed_domains":null,"location":null}` + live := `{"type":"web_search","mode":"live",` + defaults + + // W1: Agent create with every pinned mode (official create 201). + agents := map[string]string{} + for _, tc := range []struct{ name, tool, official string }{ + {"type-only", `{"type":"web_search"}`, live}, + {"mode-null", `{"type":"web_search","mode":null}`, live}, + {"mode-live", `{"type":"web_search","mode":"live"}`, live}, + {"mode-cached", `{"type":"web_search","mode":"cached"}`, `{"type":"web_search","mode":"cached",` + defaults}, + {"mode-cached-full", `{"type":"web_search","mode":"cached","context_size":"high","allowed_domains":["example.com"],"location":{"country":"FR","city":"Paris"}}`, + `{"type":"web_search","mode":"cached","context_size":"high","allowed_domains":["example.com"],"location":{"country":"FR","region":null,"city":"Paris","timezone":null}}`}, + {"mode-disabled", `{"type":"web_search","mode":"disabled"}`, `{"type":"web_search","mode":"disabled",` + defaults}, + } { + status, body := client.do(owner, http.MethodPost, "/v1/agents", "application/json", []byte(`{"model":"search-model","tools":[`+tc.tool+`]}`)) + tools(tc.name, status, body, http.StatusCreated, tc.official) + var created struct{ ID string } + _ = json.Unmarshal([]byte(body), &created) + readBack(tc.name, created.ID, body) + agents[tc.name] = created.ID + } + + // W2: update replaces tools with each observed form (official update 200). + updated := client.created(owner, "/v1/agents", `{"model":"search-model","tools":[{"type":"web_search"}]}`) + for _, tc := range []struct{ name, tool, official string }{ + {"update-disabled", `{"type":"web_search","mode":"disabled"}`, `{"type":"web_search","mode":"disabled",` + defaults}, + {"update-omitted-low", `{"type":"web_search","context_size":"low"}`, `{"type":"web_search","mode":"live","context_size":"low","allowed_domains":null,"location":null}`}, + {"update-live-domains-empty", `{"type":"web_search","mode":"live","allowed_domains":[]}`, `{"type":"web_search","mode":"live","context_size":"medium","allowed_domains":[],"location":null}`}, + {"update-cached-location", `{"type":"web_search","mode":"cached","location":{"timezone":"Europe/Paris"}}`, + `{"type":"web_search","mode":"cached","context_size":"medium","allowed_domains":null,"location":{"city":null,"country":null,"region":null,"timezone":"Europe/Paris"}}`}, + } { + status, body := client.do(owner, http.MethodPost, "/v1/agents/"+updated, "application/json", []byte(`{"tools":[`+tc.tool+`]}`)) + tools(tc.name, status, body, http.StatusOK, tc.official) + readBack(tc.name, updated, body) + } + + // A disabled record saved before this batch reads and executes unchanged (W7). + legacy, err := s.CreateAgent(t.Context(), ownerTenant, store.CreateAgentInput{Metadata: map[string]string{}, Configuration: json.RawMessage( + `{"model":"search-model","name":null,"instructions":null,"multi_agent":{"enabled":false,"max_concurrent_subagents":null},"reasoning":{},"service_tier":"auto","text":{"format":{"type":"text"},"verbosity":"medium"},"tools":[{"type":"web_search","mode":"disabled","context_size":"medium","allowed_domains":[],"location":null}]}`)}) + if err != nil { + t.Fatal(err) + } + status, body := client.do(owner, http.MethodGet, "/v1/agents/"+legacy.ID, "", nil) + legacyTool := tools("legacy", status, body, http.StatusOK, `{"type":"web_search","mode":"disabled","context_size":"medium","allowed_domains":[],"location":null}`) + readBack("legacy", legacy.ID, body) + // sessionTool returns the first frozen Session tool in canonical form; Session + // snapshots keep their own key order. + sessionTool := func(name, session string) string { + t.Helper() + status, raw := client.do(owner, http.MethodGet, "/v1/agents/sessions/"+session, "", nil) + var value struct { + Agent struct{ Tools []json.RawMessage } + } + if status != http.StatusOK || json.Unmarshal([]byte(raw), &value) != nil { + t.Fatalf("%s Session: %d %s", name, status, raw) + } + if len(value.Agent.Tools) == 0 { + return "" + } + return canonicalTool(t, value.Agent.Tools[0]) + } + if got := sessionTool("legacy", client.created(owner, "/v1/agents/sessions", `{"agent_id":"`+legacy.ID+`","environment":{"type":"none"},"input":"hi"}`)); got != canonicalTool(t, json.RawMessage(legacyTool)) { + t.Fatalf("legacy Session tool %s, want %s", got, legacyTool) + } + + // A same-key retry recovers its Session after the Agent enables search. + retry := func(key string) (int, string) { + request, err := http.NewRequest(http.MethodPost, server.URL+"/v1/agents/sessions", strings.NewReader(`{"agent_id":"`+agents["mode-disabled"]+`","environment":{"type":"none"},"input":"hi"}`)) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Authorization", "Bearer "+owner) + request.Header.Set("OpenAI-Beta", "agents=v1") + request.Header.Set("Idempotency-Key", key) + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + var session struct{ ID string } + _ = json.NewDecoder(response.Body).Decode(&session) + return response.StatusCode, session.ID + } + status, original := retry("before-search") + if status != http.StatusCreated || original == "" { + t.Fatalf("retry fixture: %d", status) + } + client.created(owner, "/v1/agents/"+agents["mode-disabled"], `{"tools":[{"type":"web_search","mode":"live"}]}`) + enabledTools := map[string]string{"mode-live": agents["mode-live"], "mode-cached": agents["mode-cached"], "type-only": agents["type-only"], "updated-to-live": agents["mode-disabled"]} + + // W4: every creation mode rejects enabled saved search without writes. + before := databaseDigest(t, pool) + const rejection = `{"error":{"message":"Only disabled web_search is qualified for execution.","type":"invalid_request_error","code":"unsupported_or_invalid_configuration","param":null}}` + "\n" + for name, id := range enabledTools { + for _, suffix := range []string{ + `"environment":{"type":"none"},"input":"hi"}`, + `"environment":{"type":"none"},"input":"hi","stream":true}`, + `"environment":{"type":"self_hosted","workspace_directory":"/workspace"},"input":"hi"}`, + `"environment":{"type":"self_hosted","workspace_directory":"/workspace"}}`, + `"environment":{"type":"openai_hosted"}}`, + `"agent":{"instructions":"Keep the saved tools."},"environment":{"type":"none"},"input":"hi"}`, + } { + if status, body := client.do(owner, http.MethodPost, "/v1/agents/sessions", "application/json", []byte(`{"agent_id":"`+id+`",`+suffix)); status != http.StatusBadRequest || body != rejection { + t.Errorf("%s %s: %d %s", name, suffix, status, body) + } + } + // C4: the input requirement precedes execution admission. + const input = `{"error":{"message":"conversation-only sessions currently require initial input","type":"invalid_request_error","code":"invalid_request_error","param":null}}` + "\n" + if status, body := client.do(owner, http.MethodPost, "/v1/agents/sessions", "application/json", []byte(`{"agent_id":"`+id+`","environment":{"type":"none"}}`)); status != http.StatusBadRequest || body != input { + t.Errorf("%s without input: %d %s", name, status, body) + } + } + // W6: inline enabled or omitted-mode search keeps its rejection. + for _, tool := range []string{`{"type":"web_search"}`, `{"type":"web_search","mode":null}`, `{"type":"web_search","mode":"cached"}`} { + for _, request := range []string{ + `{"agent":{"model":"m","tools":[` + tool + `]},"environment":{"type":"none"},"input":"hi"}`, + `{"agent_id":"` + legacy.ID + `","agent":{"tools":[` + tool + `]},"environment":{"type":"none"},"input":"hi"}`, + } { + if status, body := client.do(owner, http.MethodPost, "/v1/agents/sessions", "application/json", []byte(request)); status != http.StatusBadRequest || body != rejection { + t.Errorf("inline %s: %d %s", request, status, body) + } + } + } + // W8: another tenant sees the same 404 as a missing Agent and writes nothing. + missing := uuid.NewString() + for _, request := range []struct{ method, path, body string }{ + {http.MethodGet, "/v1/agents/%s", ""}, + {http.MethodPost, "/v1/agents/%s", `{"tools":[{"type":"web_search","mode":"disabled"}]}`}, + {http.MethodPost, "/v1/agents/sessions", `{"agent_id":"%s","environment":{"type":"none"},"input":"hi"}`}, + {http.MethodPost, "/v1/agents/sessions", `{"agent_id":"%s","agent":{"tools":[]},"environment":{"type":"none"},"input":"hi"}`}, + } { + send := func(token, id string) (int, string) { + return client.do(token, request.method, strings.ReplaceAll(request.path, "%s", id), "application/json", []byte(strings.ReplaceAll(request.body, "%s", id))) + } + foreignStatus, foreignBody := send(foreign, agents["mode-live"]) + missingStatus, missingBody := send(owner, missing) + if foreignStatus != http.StatusNotFound || foreignStatus != missingStatus || foreignBody != missingBody { + t.Errorf("%s %s: foreign %d %s, missing %d %s", request.method, request.path, foreignStatus, foreignBody, missingStatus, missingBody) + } + } + if status, raw := client.do(foreign, http.MethodGet, "/v1/agents?limit=100", "", nil); status != http.StatusOK || strings.Contains(raw, "search-model") { + t.Errorf("foreign list: %d %s", status, raw) + } + if after := databaseDigest(t, pool); !mapsEqual(before, after) { + t.Fatal("rejected Session creation changed persisted state") + } + + // Same-key retries keep their rule: the recorded Session is returned unchanged. + if status, recovered := retry("before-search"); status != http.StatusCreated || recovered != original { + t.Fatalf("same-key retry: %d %s, want %s", status, recovered, original) + } + disabled := canonicalTool(t, json.RawMessage(`{"type":"web_search","mode":"disabled",`+defaults)) + if got := sessionTool("retry", original); got != disabled { + t.Fatalf("recovered Session tool %s", got) + } + if status, _ := retry("after-search"); status != http.StatusBadRequest { + t.Fatalf("new key after enabling search: %d", status) + } + + // W5: a per-Session tools replacement admits the saved Agent without its search. + for _, replacement := range []string{`[]`, `[{"type":"web_search","mode":"disabled"}]`} { + session := client.created(owner, "/v1/agents/sessions", `{"agent_id":"`+agents["mode-live"]+`","agent":{"tools":`+replacement+`},"environment":{"type":"none"},"input":"hi"}`) + if got := sessionTool("replacement", session); got != "" && got != disabled { + t.Fatalf("replacement %s: Session tool %s", replacement, got) + } + } +} + +// canonicalTool re-encodes a tool with sorted keys for comparison across resources. +func canonicalTool(t *testing.T, raw json.RawMessage) string { + t.Helper() + var value any + if err := json.Unmarshal(raw, &value); err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return string(encoded) +} From 4b6dc623712eb6fbc507557cb36f64549d6d7ac4 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 23 Sep 2026 18:05:52 +0000 Subject: [PATCH 2/7] Assert saved web_search modes in the pinned-SDK scripts The resource scripts now expect every pinned web_search mode to save with the official projection and Session admission to keep rejecting enabled search, while a same-key retry recovers its earlier Session. The live tool policy script adds saved enabled search to its admission rejections. --- .../agents-api/tests/official_agent_update.py | 16 +++++++++++++ services/agents-api/tests/official_agents.py | 24 ++++++++++++++++--- .../agents-api/tests/official_tool_policy.py | 4 ++++ 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/services/agents-api/tests/official_agent_update.py b/services/agents-api/tests/official_agent_update.py index 5da7fead3..488c6e0fe 100644 --- a/services/agents-api/tests/official_agent_update.py +++ b/services/agents-api/tests/official_agent_update.py @@ -71,6 +71,22 @@ def main(): response = http.post(base + "/v1/agents/" + target, headers=auth, json=body) assert response.status_code == 400 and response.json()["error"] == expected, (body, response.text) assert agents.retrieve(original.id) == updated + # Saved web_search keeps every pinned mode (TV-05). Session admission still + # rejects enabled search, while a same-key retry recovers its earlier Session. + searched = agents.update(original.id, tools=[{"type": "web_search", "context_size": "low"}]) + assert [t.to_dict() for t in searched.tools] == [ + {"type": "web_search", "mode": "live", "context_size": "low", "allowed_domains": None, "location": None}] + assert agents.retrieve(original.id) == searched + response = http.post(base + "/v1/agents/sessions", headers=headers, json=spec) + assert response.status_code == 400 and response.json()["error"] == { + "type": "invalid_request_error", "code": "unsupported_or_invalid_configuration", "param": None, + "message": "Only disabled web_search is qualified for execution."}, response.text + assert sessions.create(**spec, extra_headers=retry) == old + emptied = agents.update(original.id, tools=[{"type": "web_search", "mode": "live", "allowed_domains": []}]) + assert http.get(endpoint, headers=headers).json()["tools"] == [ + {"type": "web_search", "mode": "live", "context_size": "medium", "allowed_domains": [], "location": None}] + assert agents.retrieve(original.id) == emptied + updated = agents.update(original.id, tools=[tool]) for target in (original.id, str(uuid.uuid4()), "invalid", str(uuid.UUID(int=0))): response = http.post(base + "/v1/agents/" + target, headers=headers | {"Authorization": "Bearer " + foreign}, json={"name": "foreign"}) diff --git a/services/agents-api/tests/official_agents.py b/services/agents-api/tests/official_agents.py index 388d8ac92..dc98028e6 100644 --- a/services/agents-api/tests/official_agents.py +++ b/services/agents-api/tests/official_agents.py @@ -153,7 +153,25 @@ def verify_agents(client, other, invalid, expect_error): expect_error(BadRequestError, lambda: agents.create(model="x", extra_headers={"OpenAI-Beta": ""})) assert raw.post(base, json={"model": "x"}).status_code == 401 # Unsupported families are explicit gaps, not schema-conformance evidence. - for tool in ({"type": "web_search"}, {"type": "mcp", "server_label": "x", "transport": {"type": "http", "server_url": "https://example.invalid"}}): - expect_error(BadRequestError, lambda: agents.create(model="x", tools=[tool])) - print("Reusable Agents: fixed SDK/raw HTTP create/retrieve, explicit configuration, known defaults, isolation and validation passed; model defaults/MCP/web_search/retry semantics remain gaps.") + mcp = {"type": "mcp", "server_label": "x", "transport": {"type": "http", "server_url": "https://example.invalid"}} + expect_error(BadRequestError, lambda: agents.create(model="x", tools=[mcp])) + # Every pinned web_search mode is saved as the official service does (TV-05); + # omitted or null mode is saved as live. Session admission still rejects it. + live = {"type": "web_search", "mode": "live", "context_size": "medium", "allowed_domains": None, "location": None} + for tool, expected in ( + ({"type": "web_search"}, live), + ({"type": "web_search", "mode": None}, live), + ({"type": "web_search", "mode": "cached", "context_size": "high", "allowed_domains": ["example.com"], + "location": {"country": "FR", "city": "Paris"}}, + {"type": "web_search", "mode": "cached", "context_size": "high", "allowed_domains": ["example.com"], + "location": {"city": "Paris", "country": "FR", "region": None, "timezone": None}}), + ): + response = agents.with_raw_response.create(model="x", tools=[tool]) + agent = response.parse() + assert response.status_code == 201 and response.http_response.json()["tools"] == [expected] + assert [t.to_dict() for t in agent.tools] == [expected] + assert raw.get(base + "/" + agent.id, headers=headers).json()["tools"] == [expected] + assert agents.retrieve(agent.id) == agent + saved.append(agent) + print("Reusable Agents: fixed SDK/raw HTTP create/retrieve, explicit configuration, known defaults, saved web_search modes, isolation and validation passed; model defaults/MCP/retry semantics and enabled web_search execution remain gaps.") return saved diff --git a/services/agents-api/tests/official_tool_policy.py b/services/agents-api/tests/official_tool_policy.py index c172e92b9..63083300c 100644 --- a/services/agents-api/tests/official_tool_policy.py +++ b/services/agents-api/tests/official_tool_policy.py @@ -133,6 +133,10 @@ def reject_configuration(payload): # Saving PTC intent is independent of Session execution qualification. enabled_agent = client.beta.agents.create(model=model, tools=[{"type": "programmatic_tool_calling", "enabled": True}]) reject_configuration({"agent_id": enabled_agent.id, "environment": {"type": "none"}, "input": "Verify rejected tool policy configuration."}) + # Saved enabled or omitted-mode search is resource data (TV-05); admission still rejects it. + for search in ({"type": "web_search"}, {"type": "web_search", "mode": "cached"}): + searched = client.beta.agents.create(model=model, tools=[search]) + reject_configuration({"agent_id": searched.id, "environment": {"type": "none"}, "input": "Verify rejected tool policy configuration."}) # Omitted-tool default projections are covered by the queued SDK/raw # resource fixture; these live cases exercise explicit disabled tools. for aid in proof["agents"]: From 74eac8bdb9c2171b67ed1509b2d9d4841339c166 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 23 Sep 2026 18:05:52 +0000 Subject: [PATCH 3/7] Type saved web_search in the client and label it in Core Web The saved Agent tool union gains the web_search declaration with its mode union; inline execution tool types are unchanged. Core Web shows saved search as a read-only saved-only tool and blocks Sessions from such Agents, as it does for tool_search and programmatic tool calling. --- .../src/features/agents/agent-form.test.ts | 15 ++++++++++ apps/web/src/features/agents/agent-form.ts | 4 +++ .../features/agents/session-admission.test.ts | 1 + .../src/features/agents/session-admission.ts | 1 + .../agents-client/src/protocol-types.test.ts | 8 +++++ .../src/saved-agent-defaults.test.ts | 24 ++++++++++++++- packages/agents-client/src/types.ts | 29 ++++++++++++++++++- 7 files changed, 80 insertions(+), 2 deletions(-) diff --git a/apps/web/src/features/agents/agent-form.test.ts b/apps/web/src/features/agents/agent-form.test.ts index 4f6505e2f..a7df06b47 100644 --- a/apps/web/src/features/agents/agent-form.test.ts +++ b/apps/web/src/features/agents/agent-form.test.ts @@ -270,6 +270,21 @@ describe("Agent form contract", () => { })).toEqual({ toolsError: "At most 64 Functions can be configured." }); }); + it("labels saved web_search in every mode as a read-only saved tool", () => { + for (const mode of ["live", "cached", "disabled"]) { + const agent = { + id: "agent_1", object: "agent" as const, model: "provider/model", name: null, instructions: null, metadata: {}, + multi_agent: { enabled: false, max_concurrent_subagents: null }, reasoning: {}, service_tier: "auto" as const, + text: { format: { type: "text" as const }, verbosity: "medium" as const }, created_at: 1, updated_at: 1, + tools: [{ type: "web_search", mode, context_size: "medium", allowed_domains: [], location: null }], + }; + expect(valuesFromAgent(agent, null).tools[0]).toMatchObject({ + kind: "read-only", + label: "web_search is saved-only and cannot run in a Session", + }); + } + }); + it("omits read-only saved tools for unrelated updates and rejects deliberate Tool changes", () => { const opaque = { type: "mcp", credential_id: "vault-bound", headers: { Authorization: "not-rendered" } }; const agent = { diff --git a/apps/web/src/features/agents/agent-form.ts b/apps/web/src/features/agents/agent-form.ts index 564f80da1..28c6920a0 100644 --- a/apps/web/src/features/agents/agent-form.ts +++ b/apps/web/src/features/agents/agent-form.ts @@ -169,6 +169,10 @@ function readOnlyToolLabel(value: unknown): string { && hasOnlyKeys(value, ["type", "enabled"]) && (value.enabled === undefined || typeof value.enabled === "boolean") ) return "programmatic_tool_calling is saved-only and cannot run in a Session"; + if ( + value.type === "web_search" + && hasOnlyKeys(value, ["type", "mode", "context_size", "allowed_domains", "location"]) + ) return "web_search is saved-only and cannot run in a Session"; if (value.type === "function" && value.defer_loading === true) return "Deferred Function is saved-only"; if (value.type === "mcp" && value.credential_id != null) return "Credentialed MCP is unresolved or URL-mismatched in the current Vault catalog"; if (value.type === "function") return "Unsupported Function tool definition"; diff --git a/apps/web/src/features/agents/session-admission.test.ts b/apps/web/src/features/agents/session-admission.test.ts index 6272689e2..1a9bf6258 100644 --- a/apps/web/src/features/agents/session-admission.test.ts +++ b/apps/web/src/features/agents/session-admission.test.ts @@ -90,6 +90,7 @@ describe("known Session admission blockers", () => { [{ text: { format: { type: "json_schema" as const, schema: { type: "object" } }, verbosity: "medium" as const } }, "text format must be text"], [{ multi_agent: { enabled: true, max_concurrent_subagents: 2 } }, "multi-agent execution is not supported"], [{ tools: [{ type: "tool_search" }] }, "tool_search is saved-only"], + [{ tools: [{ type: "web_search", mode: "live", context_size: "medium", allowed_domains: null, location: null }] }, "web_search is saved-only"], [{ tools: [{ type: "function", name: "later", description: "", parameters: {}, defer_loading: true }] }, "deferred functions are saved-only"], [{ tools: [{ type: "function", name: " ", description: "", parameters: {}, defer_loading: false }] }, "function names must be non-empty and at most 512 bytes"], [{ tools: [{ type: "function", name: "\u0085", description: "", parameters: {}, defer_loading: false }] }, "function names must be non-empty and at most 512 bytes"], diff --git a/apps/web/src/features/agents/session-admission.ts b/apps/web/src/features/agents/session-admission.ts index 90c700841..747752be5 100644 --- a/apps/web/src/features/agents/session-admission.ts +++ b/apps/web/src/features/agents/session-admission.ts @@ -160,6 +160,7 @@ export function knownSessionAdmissionBlockers(agent: SavedAgent): string[] { break; case "tool_search": case "programmatic_tool_calling": + case "web_search": blockers.push(`${String(tool.type)} is saved-only`); break; default: diff --git a/packages/agents-client/src/protocol-types.test.ts b/packages/agents-client/src/protocol-types.test.ts index ea6e9c682..b811f0c86 100644 --- a/packages/agents-client/src/protocol-types.test.ts +++ b/packages/agents-client/src/protocol-types.test.ts @@ -146,6 +146,14 @@ describe("Parsar 2b34ea46 bounded tool profiles", () => { expectTypeOf[number]>().not.toEqualTypeOf(); }); + it("saves every pinned web_search mode without adding it to inline execution profiles", () => { + type SavedSearch = Extract; + expectTypeOf().toEqualTypeOf<"disabled" | "cached" | "live" | null | undefined>(); + expectTypeOf().toEqualTypeOf<"low" | "medium" | "high" | null | undefined>(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf[number], { type: "web_search" }>>().toEqualTypeOf(); + }); + it("captures the two Web-configurable write shapes without credentials or browser MCP", () => { const functionTool = toolProfiles.write_profiles.function; const mcpTool = toolProfiles.write_profiles.anonymous_http_mcp as AnonymousHttpMcpToolInput; diff --git a/packages/agents-client/src/saved-agent-defaults.test.ts b/packages/agents-client/src/saved-agent-defaults.test.ts index b071fc84a..13e09f946 100644 --- a/packages/agents-client/src/saved-agent-defaults.test.ts +++ b/packages/agents-client/src/saved-agent-defaults.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { OpenAIAgentsClient } from "./client"; -import type { ModelProviderInput, SavedAgentCore } from "./types"; +import type { ModelProviderInput, SavedAgentCore, WebSearchToolInput } from "./types"; describe("saved Agent execution defaults", () => { it("sends complete replacement inputs and preserves omission versus null", async () => { @@ -41,4 +41,26 @@ describe("saved Agent execution defaults", () => { { x_agents_core: null }, ]); }); + + it("sends saved web_search modes unchanged", async () => { + const calls: unknown[] = []; + const client = new OpenAIAgentsClient({ + token: "tenant-token", + fetch: (async (_input: RequestInfo | URL, init?: RequestInit) => { + calls.push(JSON.parse(String(init?.body))); + return new Response(JSON.stringify({ id: "saved-agent", object: "agent" }), { + status: 200, headers: { "Content-Type": "application/json" }, + }); + }) as typeof fetch, + }); + const cached: WebSearchToolInput = { + type: "web_search", mode: "cached", context_size: "high", allowed_domains: [], location: { country: "FR" }, + }; + await client.createAgent({ model: "example-model", tools: [{ type: "web_search" }] }); + await client.updateAgent("saved-agent", { tools: [{ type: "web_search", mode: null }, cached] }); + expect(calls).toEqual([ + { model: "example-model", tools: [{ type: "web_search" }] }, + { tools: [{ type: "web_search", mode: null }, cached] }, + ]); + }); }); diff --git a/packages/agents-client/src/types.ts b/packages/agents-client/src/types.ts index a15b9a4d3..0c6677234 100644 --- a/packages/agents-client/src/types.ts +++ b/packages/agents-client/src/types.ts @@ -84,7 +84,34 @@ export interface ProgrammaticToolCallingInput { enabled?: boolean; } -export type SavedAgentToolInput = FunctionToolInput | ServiceHttpMcpToolInput | ToolSearchInput | ProgrammaticToolCallingInput; +export interface WebSearchLocationInput { + city?: string | null; + country?: string | null; + region?: string | null; + timezone?: string | null; +} + +/** + * Saved Agents keep every pinned mode; omitted or null `mode` is saved as `live` + * and omitted or null `context_size` as `medium`. Session admission executes only + * `disabled` and rejects the other modes unless the Session replaces its tools. + */ +export interface WebSearchToolInput { + type: "web_search"; + mode?: "disabled" | "cached" | "live" | null; + context_size?: "low" | "medium" | "high" | null; + /** null and [] are saved distinctly. */ + allowed_domains?: string[] | null; + /** A saved location includes all four keys, with null for omitted ones. */ + location?: WebSearchLocationInput | null; +} + +export type SavedAgentToolInput = + | FunctionToolInput + | ServiceHttpMcpToolInput + | ToolSearchInput + | ProgrammaticToolCallingInput + | WebSearchToolInput; export type SessionFunctionToolInput = Omit & { defer_loading?: false }; export type ConfigurableAgentToolInput = SessionFunctionToolInput | ServiceHttpMcpToolInput; From 2f11270e80193f7a51fd643d54e6e8f9bf795217 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 23 Sep 2026 18:05:52 +0000 Subject: [PATCH 4/7] Document saved web_search modes (TV-05) Resolve K3 and the deferred TV-05 note, add the September 23 saved web_search section with rows W1-W8, register the SW evidence code and update the tool policy, execution coverage and contributor guidance. --- CONTRIBUTING.md | 11 ++-- contracts/agents-api/README.md | 11 ++-- contracts/agents-api/execution-tools.md | 2 +- .../official-semantics-alignment.md | 66 +++++++++++++++++-- contracts/agents-api/operation-evidence.md | 7 +- contracts/agents-api/tool-policy.md | 27 ++++---- 6 files changed, 95 insertions(+), 29 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 95c36bf4b..414b0098e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1849,13 +1849,13 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti timestamps and metadata, separately from saved configuration and Session state. Resolve known defaults and validate supported schema before writing. Preserve model/name/instructions verbatim, nullable fields and structured JSON numbers. - Stored reasoning/service tiers, enabled multi-agent settings, JSON Schema output - and deferred/tool-search/programmatic tools do not imply execution support. + Stored reasoning/service tiers, enabled multi-agent settings, JSON Schema output, + enabled web-search modes and deferred/tool-search/programmatic tools do not imply + execution support. Reuse function wire validation, keeping Session execution restrictions separate. Model-derived reasoning effort is unresolved when omitted; do not infer it from the selected harness. Omitted/null service tier currently uses `auto`; complete - upstream default/error/retry conformance and remaining MCP/web-search variants - remain gaps. Unknown/unsupported variants fail explicitly. No product lookup is permitted. + upstream default/error/retry conformance and remaining MCP variants remain gaps. Unknown/unsupported variants fail explicitly. No product lookup is permitted. - Service-origin public HTTP MCP uses the native harness client and tool loop on trusted service-owned `environment:none` compute, with Codex or Claude SDK. The V1 colocated `self_hosted` profile rejects it: user-owned compute cannot be @@ -2211,7 +2211,8 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti not remove unrelated native utilities or claim enabled programmatic support. Explicit `web_search.mode=disabled` reuses the existing disabled search control. Search remains off when omitted. Optional search settings are resource data and - do not cause execution while disabled. Enabled search remains unqualified here. + do not cause execution while disabled. Saved Agents keep every pinned search mode; + enabled search remains unqualified and rejects at Session admission. - `web_search_control` advertises the Codex adapter's explicit `web_search` option (`disabled`, `cached`, or `live`). Agents API requires this capability before Codex dispatch; the typed execution controls force search off on new and resumed Turns. Native configuration translation stays in the diff --git a/contracts/agents-api/README.md b/contracts/agents-api/README.md index faa746bc4..dd64d73de 100644 --- a/contracts/agents-api/README.md +++ b/contracts/agents-api/README.md @@ -284,10 +284,11 @@ upgrade the protocol. - [Explicit disabled tools](tool-policy.md) can be saved, used inline or resolved from saved Agents: `web_search.mode=disabled` and `programmatic_tool_calling.enabled=false`. Search responses include `context_size=medium` for omitted/null size, nullable domains - and location; an empty domain list stays empty. Only explicit disabled mode is - qualified; omitted/null mode and enabled search remain gaps. Sessions reject - enabled programmatic execution, including the default true on a supplied PTC - declaration. An omitted PTC declaration preserves native behavior: this is an + and location; an empty domain list stays empty. Saved Agents keep every pinned + search mode, saving omitted/null mode as `live`; only explicit disabled mode is + qualified, so Session admission rejects enabled search unless the Session + replaces the saved tools. Sessions reject enabled programmatic execution, + including the default true on a supplied PTC declaration. An omitted PTC declaration preserves native behavior: this is an approved difference from the official default-on behavior, not full compatibility. Core carries the frozen disabled intent through the common Runtime contract; native translation and inventory restrictions stay in adapters. Codex checks @@ -355,7 +356,7 @@ upgrade the protocol. rechecked before dispatch-only decryption; authenticated execution requires the separate bearer capability and never downgrades on failure. Exact URL/selection timing, implicit response population and hosted errors remain local or unverified. - Other MCP variants and enabled web-search remain gaps, not changes to the pinned target + Other MCP variants and enabled web-search execution remain gaps, not changes to the pinned target or claims of complete resource coverage. - Use `/agents/sessions` beneath the configured API base URL, bearer authentication diff --git a/contracts/agents-api/execution-tools.md b/contracts/agents-api/execution-tools.md index cc67571fc..c88cded0e 100644 --- a/contracts/agents-api/execution-tools.md +++ b/contracts/agents-api/execution-tools.md @@ -27,7 +27,7 @@ and Environment Plugin MCP have separate inventories and qualification. | Function text results and native application | Codex waits for a matching live root dynamic-tool completion; Claude waits for a matching live root native tool result. Text/error results, retry/conflict, cancellation and cold continuation: F1/F2. [Receipt contract](function-result-images.md). | Transport writes alone do not confirm application. Confirmation is not provider consumption, crash recovery or exactly-once external effects. No automatic result replay. | | Function image results | Successful ordered inline PNG/JPEG with text, large PNG and image-only JPEG: Codex/Claude `none` F1/F2; Docker M2. Public Items retain submitted bytes. Codex receipt regression: F2. | Claude rejects failed images and remote references before persistence; native resizing may change its image bytes. MiniMax functions and self-hosted image workflows are unqualified. Other Codex image/error/reference combinations cannot be inferred from successful-inline evidence. | | Deferred function discovery | Type-only `tool_search` plus mixed eager/deferred functions: Claude SDK 0.3.269/native 2.1.269, Kimi K3, single Agent, medium, `none`, text results; text and PNG input D1. Native provider observations establish lazy schema loading. [Contract](tool-search.md). | A repeated `tool_search` is a protocol error. Codex/MiniMax discovery, search-only/missing-search, workspace, MCP, structured-output and Subagent combinations remain gaps. Opaque native policy changes lack a reliable pre-input deferral signal. Saved tools include `tool_search`; the pinned Session response union excludes it. Exact hosted projection is unverified. | -| Explicit disabled search/PTC | Saved and inline `web_search.mode=disabled` and `programmatic_tool_calling.enabled=false`; shared native controls on initial and cold execution. All three harnesses on `none`: P1, including native inventory/control evidence. [Contract](tool-policy.md), [parser](../../services/agents-api/internal/api/disabled_tools.go). | Unsupported explicit enablement rejects; a repeated `web_search` is a protocol error, while saved `live`/`cached`/omitted modes keep the local rejection (TV-05). Omission retains approved native behavior, which does not establish official default-on PTC parity. Enabled search and default/error parity remain gaps; unrelated native utilities are not implicitly removed. | +| Explicit disabled search/PTC | Saved and inline `web_search.mode=disabled` and `programmatic_tool_calling.enabled=false`; shared native controls on initial and cold execution. All three harnesses on `none`: P1, including native inventory/control evidence. [Contract](tool-policy.md), [parser](../../services/agents-api/internal/api/disabled_tools.go). | Unsupported explicit enablement rejects at Session admission; saved Agents keep every pinned search mode as resource data (TV-05), and Sessions from such Agents reject unless they replace the tools. A repeated `web_search` is a protocol error. Omission retains approved native behavior, which does not establish official default-on PTC parity. Enabled search and default/error parity remain gaps; unrelated native utilities are not implicitly removed. | | Agent service-origin MCP | Implemented Codex/Claude HTTP `none` profiles, anonymous/static bearer, scoped Vault selection and native `mcp_call` Items. [Configuration and qualification limits](../../services/agents-api/README.md#http-mcp-execution), [profiles](../../services/agents-api/internal/engine/profile.go). | This closure batch does not requalify MCP/provider combinations. MiniMax, self-hosted/hosted service-origin MCP, OAuth, nonempty inline headers/metadata and other transports/origins reject. Claude requires a static connected inventory; original MCP-envelope fidelity and continuing server health are unverified. | | Environment Plugin MCP/native tools | Separate [Docker Plugin transport matrix](environment-templates.md#environment-origin-mcp-plugins) and [V1 deployment evidence](user-managed-runtime-v1.md). Native tools stay within the existing colocated Runtime. | Plugin MCP is not Agent service-origin MCP or a public function action. No new optional cross-product is qualified here. Native utility inventories need not be identical. | | Required action: `function_call` | Session GET/list and Session SSE expose persisted `{arguments,call_id,name,turn_id,type}`. Actions remain pending until native application or cancellation/terminal settlement. [Projection](../../services/agents-api/internal/store/function_state.go), [confirmation](../../services/agents-api/internal/store/function_results.go); real handling F1/F2/M2/S1/S2/D1; explicit client disconnect/query/reconnect: F3. | Function-call history is not pending-state authority. Client reconnect does not replay events or redo external application effects. | diff --git a/contracts/agents-api/official-semantics-alignment.md b/contracts/agents-api/official-semantics-alignment.md index c6e9b5289..21ea22f45 100644 --- a/contracts/agents-api/official-semantics-alignment.md +++ b/contracts/agents-api/official-semantics-alignment.md @@ -378,7 +378,7 @@ deleted and a read confirmed 404. | C4 | Session create on `none` without input and with an invalid inline agent (TV-04) | The configuration error first. Valid configurations, including enabled `web_search` or programmatic tool calling, still receive the input requirement. | | K1 | Function names with any characters or over 64 characters, programmatic tool calling enabled on a saved Agent, reasoning effort `max`, service tier `flex` (TV-07) | Unchanged: saved and echoed. | | K2 | Harness and execution admission limits: enabled `web_search` or programmatic tool calling, structured output on an unqualified harness, explicit reasoning or a non-`auto` service tier on Session create (TV-06) | Unchanged: `unsupported_or_invalid_configuration` with the existing messages, after protocol validation. | -| K3 | Saved `web_search` with mode `live`, `cached`, null or omitted (TV-05) | Unchanged: `unsupported_or_invalid_configuration`, "Only disabled web_search is qualified for execution." | +| K3 | Saved `web_search` with mode `live`, `cached`, null or omitted (TV-05) | Resolved by [Saved web_search modes](#saved-web_search-modes--september-23): saved with the official projection; Session admission keeps the K2 rejection. | Decisions: @@ -434,9 +434,9 @@ Decisions: checks run after recovery. Core is pre-release, so the order is not changed for such retries. -Deferred and unchanged: TV-05, saving `web_search` with mode `live`, `cached` or -omitted (officially saved, omitted stored as `live`), needs a separate decision -about saved-but-unqualified settings. Duplicate `programmatic_tool_calling` +TV-05, saving `web_search` with mode `live`, `cached` or omitted, was deferred +here and is resolved by [Saved web_search modes](#saved-web_search-modes--september-23). +Deferred and unchanged: duplicate `programmatic_tool_calling` declarations and MCP server labels were not sampled: saved Agents accept them and Session admission keeps "Execution requires distinct tool controls." and "Execution requires distinct MCP server labels.". A missing model without @@ -597,3 +597,61 @@ rollback. The pinned-SDK scripts `official_function_inputs.py`, `official_pending_actions_native.py` and `official_session_creators.py` assert the new fields, and TypeScript client and Core Web unit tests cover them. Real Core, daemon and model acceptance is recorded separately by the coordinator. + +## Saved web_search modes — September 23 + +Saved Agents now keep every pinned `web_search` mode, as the official service +does, from Core main `1eb60c27`. Evidence is TV-05 (official W01/W02) in the +campaign scan recorded privately in +`~/.parsar/remediation/20260923/campaign-scan-3/subagents-tools/findings.json`, +and owned probes under `~/.parsar/remediation/20260923/saved-web-search/official/` +(`results.json`, `ledger.jsonl`): four owned Agents with the create records +`type-only`, `mode-null`, `mode-cached` and `mode-cached-full`, the update records +`update-disabled`, `update-omitted-low` and `update-live-domains-empty`, and a +`retrieve`, without a Session or model. All four Agents were deleted. + +| Row | Case | Core behavior | +| --- | --- | --- | +| W1 | Agent create with `web_search` mode `live`, `cached`, null or omitted | 201. Omitted or null mode is saved as `live`, and omitted or null `context_size` as `medium`. `allowed_domains` keeps null versus `[]`. `location` stays null, or else includes `city`, `country`, `region` and `timezone`, with null for omitted keys. | +| W2 | Agent update replacing tools with these forms, including a return to `disabled` | 200 with the same projection. Retrieve and list return the saved form. | +| W3 | Protocol errors in a `web_search` declaration | Unchanged: the C1 and C2 fields. | +| W4 | Session creation from a saved Agent with enabled search, without a Session `tools` replacement | Unchanged K2 rejection: 400 `unsupported_or_invalid_configuration`, "Only disabled web_search is qualified for execution.", writing no Session, Turn, Environment or reservation, for plain, streamed, self-hosted and hosted creation. Protocol errors and the C4 input requirement still come first. | +| W5 | The same creation with a per-Session `tools` replacement | Admitted as before; the saved search is not used. | +| W6 | Inline Session agent with enabled or omitted-mode search | Unchanged K2 rejection. | +| W7 | Explicit `disabled` search, saved or inline, including records saved before this batch | Unchanged, including the frozen Runtime control on all three harnesses. | +| W8 | Another tenant's `agent_id` | Unchanged: the same 404 as a missing Agent. | + +Decisions: + +- Saved Agents use a separate saved-form parser. Session admission re-resolves the + effective tools with the unchanged execution parser, which admits only disabled + search. This is the path saved enabled `programmatic_tool_calling` already takes + (K1, K2). All creation modes share that admission, and Worker device selection + and the final preclaim still refuse a non-disabled search control, so enabled + search cannot reach dispatch. +- Same-key creation retries keep their rules. A retry of a Session created before + its Agent enabled search recovers that Session with its frozen disabled control; + a new key is rejected. +- An inline agent passes the saved-form parser before execution admission, so its + enabled search is now reported by the execution check, with the same message. + When one configuration hits several execution limits, another limit, such as + explicit reasoning, can be reported first, as for saved Agents. +- Saved tools keep the stored key order of other saved configuration; Session + snapshots keep their own. Clients compare decoded values. +- The TypeScript client types the saved `web_search` declaration with its mode + union; inline execution types are unchanged. Core Web shows saved search as a + read-only, saved-only tool and keeps blocking Sessions from such Agents, as for + `tool_search` and programmatic tool calling. + +Unchanged: execution qualification, Runtime controls, the schema and the +configuration validation errors. Enabling search execution needs its own +qualification. + +Go handler tests cover the projection table and W4–W7 on every creation mode. A +real-PostgreSQL HTTP test creates, updates, retrieves and lists Agents with exact +tool bytes, rejects W4 on plain, streamed, self-hosted and hosted creation under a +whole-database digest, checks a same-key retry, admits W5 and checks tenant B. +The pinned-SDK scripts `official_agents.py` and `official_agent_update.py` assert +the saved projections and the admission rejection, and `official_tool_policy.py` +adds saved enabled search to its live rejection cases. Real Core acceptance is +recorded separately by the coordinator. diff --git a/contracts/agents-api/operation-evidence.md b/contracts/agents-api/operation-evidence.md index 04c20b710..5ce8080c9 100644 --- a/contracts/agents-api/operation-evidence.md +++ b/contracts/agents-api/operation-evidence.md @@ -1,6 +1,6 @@ # Pinned operation evidence inventory — 2026-09-23 -Baseline inventory of main `b5715912f09333e2b4449ec6f0eecaabce44c9b7`. The Session admission batch below updates creation and metadata validation, the list query tolerance batch (L) updates list and resource query handling, the validation error batch (X) updates field error codes/params, malformed path IDs and U+0000 handling, the Environment Files wire batch (G) updates Files.create/list status, envelope, query, path and empty-page behavior, and the creation stream settlement batch (J) updates creation SSE lifetime/snapshot, terminal Turn usage and Turn start order, and the Session deletion batch (Z) updates the deletion lifecycle, and the Agent configuration validation batch (M) updates saved and inline Agent configuration errors, the whitespace input batch (P) admits whitespace-only message text, the list cursor error batch (CE) updates unresolved `after` cursor errors on every list, and the input conflict batch (CF) gives every 409 type `conflict_error` and aligns Session input conflicts and tool result target errors; historical evidence retains its original revision and scope. This inventory guides repeated qualification and does not assert complete compatibility. +Baseline inventory of main `b5715912f09333e2b4449ec6f0eecaabce44c9b7`. The Session admission batch below updates creation and metadata validation, the list query tolerance batch (L) updates list and resource query handling, the validation error batch (X) updates field error codes/params, malformed path IDs and U+0000 handling, the Environment Files wire batch (G) updates Files.create/list status, envelope, query, path and empty-page behavior, and the creation stream settlement batch (J) updates creation SSE lifetime/snapshot, terminal Turn usage and Turn start order, and the Session deletion batch (Z) updates the deletion lifecycle, and the Agent configuration validation batch (M) updates saved and inline Agent configuration errors, the whitespace input batch (P) admits whitespace-only message text, the list cursor error batch (CE) updates unresolved `after` cursor errors on every list, the input conflict batch (CF) gives every 409 type `conflict_error` and aligns Session input conflicts and tool result target errors, and the saved web_search batch (SW) saves every pinned `web_search` mode while Session admission keeps rejecting enabled search; historical evidence retains its original revision and scope. This inventory guides repeated qualification and does not assert complete compatibility. Baseline: `contracts/agents-api/upstream.json`, SDK **3.13.0**, upstream commit **d7c41efee1b0802b79f3f88a678ef2052b06e9ce**, `OpenAI-Beta: agents=v1`. AGENTS.md and relevant CONTRIBUTING.md compatibility, ownership and evidence rules govern this inventory. @@ -47,6 +47,7 @@ Repository paths below are relative to the inspected worktree; private evidence | CE | [List cursor errors](list-query-semantics.md#list-cursor-errors--september-23-2026); private `~/.parsar/remediation/20260923/campaign-scan-4/errors/findings.json` ERR-01..06 (ERR-07..13 and ERR-17 match or record upstream failures) with raw records labelled `cur-*` in `official/results.json`, plus SAT-04 (campaign scan 3) and HE-57 (campaign scan 2): owned Agent, Session, Turn, Item, Subagent, Artifact, Template, Vault, Credential, File, Skill and Skill version cursors without a model. Rows C1–C5 and K1–K3 of that section. Go store, real-PostgreSQL HTTP tenant A/B and pinned-SDK tests; independent acceptance is recorded with the batch. | | Z | [Session deletion lifecycle](official-semantics-alignment.md#session-deletion-lifecycle--september-23); private `~/.parsar/remediation/20260923/campaign-scan-1/sessions/findings.json` SES-29/30 with raw records under `official/` (`q5-delete-repeat`, `q5b-delete-while-in-progress`, `q5-delete-never-existed`, `q5-delete-while-running`, `q5-get-after-delete`). Rows D1–D5 of that section. Handler, real-PostgreSQL HTTP/store/lock-race, Worker, pinned-SDK, TypeScript client and Web tests without a model; live acceptance is recorded with the batch. | | CF | [Session input conflicts and result targets](official-semantics-alignment.md#session-input-conflicts-and-result-targets--september-23); private `~/.parsar/remediation/20260923/campaign-scan-4/errors/findings.json` ERR-22/27 (raw `official/results.json`: `sessB-message-while-running`, `sessA-delete-while-waiting`) and `~/.parsar/remediation/20260923/campaign-scan-2/events-tools/findings.json` EVT-11/12/14 (raw `official/calls-s2.json`: `s2-result-unknown-call`, `s2-result-unknown-turn`, `s2-result-duplicate-after-terminal`, `s2-result-changed-after-terminal`, `s2-result-after-cancel`). Rows CF1–CF9 of that section. Go API bodies, real-PostgreSQL HTTP replay with tenant B and a no-write digest, pinned-SDK scripts; live acceptance is recorded with the batch. | +| SW | [Saved web_search modes](official-semantics-alignment.md#saved-web_search-modes--september-23); private `~/.parsar/remediation/20260923/campaign-scan-3/subagents-tools/findings.json` TV-05 (W01/W02) and `~/.parsar/remediation/20260923/saved-web-search/official/{results.json,ledger.jsonl}`: four owned Agents (all deleted) with create records `type-only`, `mode-null`, `mode-cached`, `mode-cached-full`, update records `update-disabled`, `update-omitted-low`, `update-live-domains-empty` and `retrieve`, without a Session or model. Rows W1–W8 of that section. Go handler, real-PostgreSQL HTTP exact-bytes/no-write/tenant B, pinned-SDK, TypeScript client and Web tests; independent acceptance is recorded with the batch. | ## Per-operation evidence matrix @@ -54,9 +55,9 @@ Paths in the appendix include `/v1`. SDK names here omit `client.`. `P` means pa | # | SDK operation | Implemented behavior | Official wire observation | Core validation | Known difference / unverified semantics | | --- | --- | --- | --- | --- | --- | -| 1 | beta.agents.create | P: saved configuration, 201; metadata/name errors use official code and param; configuration protocol errors use official code, JSON-path param and message; repeated tools and non-object schema roots reject | R `agent-create-supported`; initial unsupported model case 400; X VA-07/08/09; M TV-01/02 `AC01`/`AC02` | C DB create; T saved-Agent execution references; X DB field errors and U+0000 no-write; M DB configuration errors, no-write and tenant replay | Model-derived reasoning defaults and unsupported configurations; saved `web_search` live/omitted still rejects locally (TV-05); multi-error order, unsampled kind phrases and complete default/null/errors unknown | +| 1 | beta.agents.create | P: saved configuration, 201; metadata/name errors use official code and param; configuration protocol errors use official code, JSON-path param and message; repeated tools and non-object schema roots reject; every pinned `web_search` mode is saved, omitted/null mode as `live` | R `agent-create-supported`; initial unsupported model case 400; X VA-07/08/09; M TV-01/02 `AC01`/`AC02`; SW TV-05 `type-only`, `mode-null`, `mode-cached`, `mode-cached-full` | C DB create; T saved-Agent execution references; X DB field errors and U+0000 no-write; M DB configuration errors, no-write and tenant replay; SW DB exact tool bytes on create/retrieve/list, admission rejection without writes and tenant B | Model-derived reasoning defaults and unsupported configurations; enabled `web_search` is saved but rejects at Session admission; multi-error order, unsampled kind phrases and complete default/null/errors unknown | | 2 | beta.agents.retrieve | P: tenant-owned saved read | R `agent-read`, `agent-read-deleted` | C DB own/foreign/deleted read | Full field defaults and inline-vs-saved lifetime | -| 3 | beta.agents.update | P: atomic replacements; empty body touches timestamp; metadata/name errors use official code and param; configuration errors as for create, before the Agent lookup | R `agent-patch-metadata`, `agent-null-fields`, `agent-noop`, `agent-nested-reasoning`, rejection labels; X VA-07/08/09/10; M TV-01..03 and TV-07 update labels (`F01`–`X01`, `W04`–`M02`) | C DB no-op/unchanged snapshot; resource implementation-validation.md actual PostgreSQL SDK update tests; M DB owned/foreign/missing/malformed-ID replay, K1 values and isolation | Model-dependent default recomputation; uncommon nested/null/error variants; TV-05 saved `web_search` modes | +| 3 | beta.agents.update | P: atomic replacements; empty body touches timestamp; metadata/name errors use official code and param; configuration errors as for create, before the Agent lookup; `web_search` projections as for create | R `agent-patch-metadata`, `agent-null-fields`, `agent-noop`, `agent-nested-reasoning`, rejection labels; X VA-07/08/09/10; M TV-01..03 and TV-07 update labels (`F01`–`X01`, `W04`–`M02`); SW `update-disabled`, `update-omitted-low`, `update-live-domains-empty`, `retrieve` | C DB no-op/unchanged snapshot; resource implementation-validation.md actual PostgreSQL SDK update tests; M DB owned/foreign/missing/malformed-ID replay, K1 values and isolation; SW DB update projections, retrieve/list bytes and same-key Session retry | Model-dependent default recomputation; uncommon nested/null/error variants | | 4 | beta.agents.list | P: scoped cursor list; unknown keys ignored, limit 0/above 100 clamp; any unresolved cursor, malformed included, is the missing 404 | R `agent-list-empty-scoped`, `agent-list-limit101`; L VA-01/02/03/04/18; CE ERR-13 `cur-agents-random`, `cur-agents-othertype-session` | L DB `official_list_query.py` tenant A/B; CE DB cursor matrix tenant A/B | Core page capacity 100; no inferred official cap. Overflowing limits unsampled | | 5 | beta.agents.delete | P: resource deletion | R `cleanup-agent`, subsequent 404 | C DB delete/post-delete | Referenced/in-flight/repeated-delete exact parity | | 6 | beta.agents.sessions.create | P: JSON/live SSE 201, saved/inline frozen config, initial messages, native profiles; inline agent configuration errors use official fields with `agent.` params before the input requirement, and saved records with repeated tools or non-object schema roots reject admission; fresh creation SSE sends the JSON 201 projection, then ends right after the first idle recorded when a Turn ends or an input reservation stops being pending, or any failed, never sending later events; nothing admitted ends after `created`; a silent settlement ends after events up to the cursor read with a settled projection in one snapshot. A same-key stream retry returns 201, sends no events and ends at once; whitespace-only text is admitted and stored verbatim, while empty text/content/input keep the local 400 | S `create-1/2.json`, `omitted-input.json`, `null-input.json`, `empty-array-input.json`, `retry-status-original/repeat.json`; H stream; J creation streams closed after idle, open through requires_action; M TV-01..04 `SC01`–`SC11`; P SES-01..03 whitespace-only string and message input 201, verbatim Item | N Live none admission and retry; C Live three hosted profiles; D/T/K/I recorded additional workflows; J DB creation-stream lifetime/snapshot/retry; M DB inline and saved-override configuration errors without writes; P DB verbatim whitespace Items and unchanged empty-input 400 without writes | Session admission batch removes idle `none` creation; local idempotent create still differs from two official IDs. Stream retry, self-hosted, hosted and no-input stream lifetimes are local; work drained before a silent-settlement read can still be sent. Many input/tool/environment combinations restricted; harness admission limits keep the local code (TV-06). Empty-input 400s keep the local code and message; `["", text]` parts are accepted but unobserved officially (SES-08); Claude SDK and MiniMax Code reject whitespace-only messages at admission as a declared native limitation (W6) | diff --git a/contracts/agents-api/tool-policy.md b/contracts/agents-api/tool-policy.md index f2d2a752e..674e4b97d 100644 --- a/contracts/agents-api/tool-policy.md +++ b/contracts/agents-api/tool-policy.md @@ -14,18 +14,23 @@ Saved Agents and inline Session configuration accept these declarations: ] ``` -Saved references use the same parser and immutable Session snapshot. Disabled -search retains optional settings as resource data: omitted/null context size +Saved Agents also keep every other pinned `web_search` mode, as the official +service does: omitted/null mode is saved as `live`, and `cached` and `live` are +saved as sent ([TV-05](official-semantics-alignment.md#saved-web_search-modes--september-23)). +Search settings are resource data in every mode: omitted/null context size resolves to `medium`; domain and location omission resolves to null; an empty -domain list remains empty. These settings cannot enable execution while disabled. -Web-search mode omission/null and enabled search remain unqualified. Protocol -errors in these declarations, such as an unsupported `mode` or `context_size`, a -non-boolean `enabled` or a repeated `web_search`, use the official fields -([validation](official-semantics-alignment.md#agent-configuration-validation--september-23)); -saved modes other than `disabled`, including omission, keep the local -`unsupported_or_invalid_configuration` rejection, which the official service does -not apply (TV-05, deferred). Explicit enabled programmatic execution rejects at Session admission; -saving that intent remains separate from execution qualification. +domain list remains empty; a supplied location includes `city`, `country`, +`region` and `timezone`, with null for omitted keys. These settings cannot +enable execution. Session admission resolves saved references and inline +declarations with the execution parser into the immutable Session snapshot. +Only explicit `disabled` search is qualified: enabled or omitted-mode search, +inline or saved, rejects with `unsupported_or_invalid_configuration` before any +write unless the Session replaces the saved tools. Protocol errors in these +declarations, such as an unsupported `mode` or `context_size`, a non-boolean +`enabled` or a repeated `web_search`, use the official fields +([validation](official-semantics-alignment.md#agent-configuration-validation--september-23)). +Explicit enabled programmatic execution likewise saves and rejects at Session +admission; saving intent remains separate from execution qualification. Omitting programmatic configuration preserves each harness's native behavior. The user approved this difference from the official default-on behavior. Native From b287d91fbad4a9d3e1e2b43bc8ea0c14de528657 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 23 Sep 2026 18:32:05 +0000 Subject: [PATCH 5/7] Cite observed web_search location projections and qualify retry recovery A second official probe observed a partial location and {} saved with all four keys (req_db41d2f6261b4abfb69465eafe719ab5, req_165d53b88445490b9146d8272c54134d). Cite them in the Agent create description, the tool policy and alignment docs, the PostgreSQL test and the pinned-SDK script, which now cover both cases. The alignment doc now limits same-key recovery to Sessions with a recorded creation request hash, and a long CONTRIBUTING line is re-wrapped. --- CONTRIBUTING.md | 11 +++++----- .../official-semantics-alignment.md | 20 ++++++++++++------- contracts/agents-api/openapi.yaml | 10 ++++++---- contracts/agents-api/operation-evidence.md | 2 +- contracts/agents-api/tool-policy.md | 5 +++-- services/agents-api/internal/api/agents.go | 2 +- .../store/saved_web_search_public_test.go | 14 ++++++++++--- services/agents-api/tests/official_agents.py | 7 ++++++- 8 files changed, 47 insertions(+), 24 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 414b0098e..e2d347e74 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1851,11 +1851,12 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti model/name/instructions verbatim, nullable fields and structured JSON numbers. Stored reasoning/service tiers, enabled multi-agent settings, JSON Schema output, enabled web-search modes and deferred/tool-search/programmatic tools do not imply - execution support. - Reuse function wire validation, keeping Session execution restrictions separate. - Model-derived reasoning effort is unresolved when omitted; do not infer it from - the selected harness. Omitted/null service tier currently uses `auto`; complete - upstream default/error/retry conformance and remaining MCP variants remain gaps. Unknown/unsupported variants fail explicitly. No product lookup is permitted. + execution support. Reuse function wire validation, keeping Session execution + restrictions separate. Model-derived reasoning effort is unresolved when omitted; + do not infer it from the selected harness. Omitted/null service tier currently + uses `auto`; complete upstream default/error/retry conformance and remaining MCP + variants remain gaps. Unknown/unsupported variants fail explicitly. No product + lookup is permitted. - Service-origin public HTTP MCP uses the native harness client and tool loop on trusted service-owned `environment:none` compute, with Codex or Claude SDK. The V1 colocated `self_hosted` profile rejects it: user-owned compute cannot be diff --git a/contracts/agents-api/official-semantics-alignment.md b/contracts/agents-api/official-semantics-alignment.md index 21ea22f45..ff02268e3 100644 --- a/contracts/agents-api/official-semantics-alignment.md +++ b/contracts/agents-api/official-semantics-alignment.md @@ -608,11 +608,15 @@ and owned probes under `~/.parsar/remediation/20260923/saved-web-search/official (`results.json`, `ledger.jsonl`): four owned Agents with the create records `type-only`, `mode-null`, `mode-cached` and `mode-cached-full`, the update records `update-disabled`, `update-omitted-low` and `update-live-domains-empty`, and a -`retrieve`, without a Session or model. All four Agents were deleted. +`retrieve`, without a Session or model. All four Agents were deleted. A second +probe created two more Agents, both deleted and confirmed 404 afterwards: +`location-partial-omitted` (`{"city":"Paris","country":"FR"}` saved with null +`region` and `timezone`, `req_db41d2f6261b4abfb69465eafe719ab5`) and `location-empty` +(`{}` saved with all four keys null, `req_165d53b88445490b9146d8272c54134d`). | Row | Case | Core behavior | | --- | --- | --- | -| W1 | Agent create with `web_search` mode `live`, `cached`, null or omitted | 201. Omitted or null mode is saved as `live`, and omitted or null `context_size` as `medium`. `allowed_domains` keeps null versus `[]`. `location` stays null, or else includes `city`, `country`, `region` and `timezone`, with null for omitted keys. | +| W1 | Agent create with `web_search` mode `live`, `cached`, null or omitted | 201. Omitted or null mode is saved as `live`, and omitted or null `context_size` as `medium`. `allowed_domains` keeps null versus `[]`. `location` stays null, or else includes `city`, `country`, `region` and `timezone`, with null for omitted keys, also for `{}`. | | W2 | Agent update replacing tools with these forms, including a return to `disabled` | 200 with the same projection. Retrieve and list return the saved form. | | W3 | Protocol errors in a `web_search` declaration | Unchanged: the C1 and C2 fields. | | W4 | Session creation from a saved Agent with enabled search, without a Session `tools` replacement | Unchanged K2 rejection: 400 `unsupported_or_invalid_configuration`, "Only disabled web_search is qualified for execution.", writing no Session, Turn, Environment or reservation, for plain, streamed, self-hosted and hosted creation. Protocol errors and the C4 input requirement still come first. | @@ -629,9 +633,10 @@ Decisions: (K1, K2). All creation modes share that admission, and Worker device selection and the final preclaim still refuse a non-disabled search control, so enabled search cannot reach dispatch. -- Same-key creation retries keep their rules. A retry of a Session created before - its Agent enabled search recovers that Session with its frozen disabled control; - a new key is rejected. +- Same-key creation retries keep their rules. A retry recovers the earlier Session + with its frozen disabled control only when that Session recorded its creation + request hash, as current Sessions do. An older Session without that record falls + through to admission and, like a new key, receives the 400. - An inline agent passes the saved-form parser before execution admission, so its enabled search is now reported by the execution check, with the same message. When one configuration hits several execution limits, another limit, such as @@ -640,8 +645,9 @@ Decisions: snapshots keep their own. Clients compare decoded values. - The TypeScript client types the saved `web_search` declaration with its mode union; inline execution types are unchanged. Core Web shows saved search as a - read-only, saved-only tool and keeps blocking Sessions from such Agents, as for - `tool_search` and programmatic tool calling. + read-only tool. Its Session admission check mirrors Core: it starts Sessions + from Agents with one well-formed disabled search and blocks enabled or + omitted-mode search, which Core does not run. Unchanged: execution qualification, Runtime controls, the schema and the configuration validation errors. Enabling search execution needs its own diff --git a/contracts/agents-api/openapi.yaml b/contracts/agents-api/openapi.yaml index e3c7890ec..aa2602e9d 100644 --- a/contracts/agents-api/openapi.yaml +++ b/contracts/agents-api/openapi.yaml @@ -2935,10 +2935,12 @@ paths: Model-derived reasoning defaults, other MCP variants and public retry conformance remain incomplete. web_search saves every pinned mode: omitted or null mode is saved as live and omitted or null context_size as medium; allowed_domains - preserves null versus empty and a present location includes all four keys. - Session execution accepts only explicit disabled web_search and disabled programmatic_tool_calling - through qualified Runtime controls; saved enabled forms reject at Session - admission. Session execution admits only its supported configuration subset.' + preserves null versus empty and a present location, including {}, includes + all four keys with null for omitted ones, as observed officially (req_db41d2f6261b4abfb69465eafe719ab5, + req_165d53b88445490b9146d8272c54134d). Session execution accepts only explicit + disabled web_search and disabled programmatic_tool_calling through qualified + Runtime controls; saved enabled forms reject at Session admission. Session + execution admits only its supported configuration subset.' parameters: - description: agents=v1 in: header diff --git a/contracts/agents-api/operation-evidence.md b/contracts/agents-api/operation-evidence.md index 5ce8080c9..b02cabeed 100644 --- a/contracts/agents-api/operation-evidence.md +++ b/contracts/agents-api/operation-evidence.md @@ -47,7 +47,7 @@ Repository paths below are relative to the inspected worktree; private evidence | CE | [List cursor errors](list-query-semantics.md#list-cursor-errors--september-23-2026); private `~/.parsar/remediation/20260923/campaign-scan-4/errors/findings.json` ERR-01..06 (ERR-07..13 and ERR-17 match or record upstream failures) with raw records labelled `cur-*` in `official/results.json`, plus SAT-04 (campaign scan 3) and HE-57 (campaign scan 2): owned Agent, Session, Turn, Item, Subagent, Artifact, Template, Vault, Credential, File, Skill and Skill version cursors without a model. Rows C1–C5 and K1–K3 of that section. Go store, real-PostgreSQL HTTP tenant A/B and pinned-SDK tests; independent acceptance is recorded with the batch. | | Z | [Session deletion lifecycle](official-semantics-alignment.md#session-deletion-lifecycle--september-23); private `~/.parsar/remediation/20260923/campaign-scan-1/sessions/findings.json` SES-29/30 with raw records under `official/` (`q5-delete-repeat`, `q5b-delete-while-in-progress`, `q5-delete-never-existed`, `q5-delete-while-running`, `q5-get-after-delete`). Rows D1–D5 of that section. Handler, real-PostgreSQL HTTP/store/lock-race, Worker, pinned-SDK, TypeScript client and Web tests without a model; live acceptance is recorded with the batch. | | CF | [Session input conflicts and result targets](official-semantics-alignment.md#session-input-conflicts-and-result-targets--september-23); private `~/.parsar/remediation/20260923/campaign-scan-4/errors/findings.json` ERR-22/27 (raw `official/results.json`: `sessB-message-while-running`, `sessA-delete-while-waiting`) and `~/.parsar/remediation/20260923/campaign-scan-2/events-tools/findings.json` EVT-11/12/14 (raw `official/calls-s2.json`: `s2-result-unknown-call`, `s2-result-unknown-turn`, `s2-result-duplicate-after-terminal`, `s2-result-changed-after-terminal`, `s2-result-after-cancel`). Rows CF1–CF9 of that section. Go API bodies, real-PostgreSQL HTTP replay with tenant B and a no-write digest, pinned-SDK scripts; live acceptance is recorded with the batch. | -| SW | [Saved web_search modes](official-semantics-alignment.md#saved-web_search-modes--september-23); private `~/.parsar/remediation/20260923/campaign-scan-3/subagents-tools/findings.json` TV-05 (W01/W02) and `~/.parsar/remediation/20260923/saved-web-search/official/{results.json,ledger.jsonl}`: four owned Agents (all deleted) with create records `type-only`, `mode-null`, `mode-cached`, `mode-cached-full`, update records `update-disabled`, `update-omitted-low`, `update-live-domains-empty` and `retrieve`, without a Session or model. Rows W1–W8 of that section. Go handler, real-PostgreSQL HTTP exact-bytes/no-write/tenant B, pinned-SDK, TypeScript client and Web tests; independent acceptance is recorded with the batch. | +| SW | [Saved web_search modes](official-semantics-alignment.md#saved-web_search-modes--september-23); private `~/.parsar/remediation/20260923/campaign-scan-3/subagents-tools/findings.json` TV-05 (W01/W02) and `~/.parsar/remediation/20260923/saved-web-search/official/{results.json,ledger.jsonl}`: four owned Agents (all deleted) with create records `type-only`, `mode-null`, `mode-cached`, `mode-cached-full`, update records `update-disabled`, `update-omitted-low`, `update-live-domains-empty` and `retrieve`, plus a second probe of two Agents (deleted, 404 confirmed) with `location-partial-omitted` and `location-empty`, without a Session or model. Rows W1–W8 of that section. Go handler, real-PostgreSQL HTTP exact-bytes/no-write/tenant B, pinned-SDK, TypeScript client and Web tests; independent acceptance is recorded with the batch. | ## Per-operation evidence matrix diff --git a/contracts/agents-api/tool-policy.md b/contracts/agents-api/tool-policy.md index 674e4b97d..35c251b7c 100644 --- a/contracts/agents-api/tool-policy.md +++ b/contracts/agents-api/tool-policy.md @@ -19,8 +19,9 @@ service does: omitted/null mode is saved as `live`, and `cached` and `live` are saved as sent ([TV-05](official-semantics-alignment.md#saved-web_search-modes--september-23)). Search settings are resource data in every mode: omitted/null context size resolves to `medium`; domain and location omission resolves to null; an empty -domain list remains empty; a supplied location includes `city`, `country`, -`region` and `timezone`, with null for omitted keys. These settings cannot +domain list remains empty; a supplied location, including `{}`, includes `city`, +`country`, `region` and `timezone`, with null for omitted keys (observed +officially: `req_db41d2f6261b4abfb69465eafe719ab5` and `req_165d53b88445490b9146d8272c54134d`). These settings cannot enable execution. Session admission resolves saved references and inline declarations with the execution parser into the immutable Session snapshot. Only explicit `disabled` search is qualified: enabled or omitted-mode search, diff --git a/services/agents-api/internal/api/agents.go b/services/agents-api/internal/api/agents.go index aca94451c..20ed1bfdc 100644 --- a/services/agents-api/internal/api/agents.go +++ b/services/agents-api/internal/api/agents.go @@ -20,7 +20,7 @@ type AgentStore interface { } // @Summary Create a reusable Agent -// @Description Persists configuration independently of execution. Names over 128 characters and metadata outside 16 string pairs with 64-character keys and 512-character values return invalid_request_error with the official param; U+0000 in stored strings is rejected as a local storage limit. Missing, unknown, repeated, wrongly typed or unsupported enum members of the pinned configuration shapes (tools, text, reasoning, service_tier, multi_agent) return invalid_request_error with the JSON path as param; duplicate function names, repeated web_search or tool_search and non-object schema root types return it with a null param. Supports model/name/instructions/metadata, explicit reasoning and service tiers, multi_agent, text/json_schema, function/tool_search/programmatic_tool_calling/web_search and HTTP MCP with nullable credential_id and explicit service origin and boolean required defaulting to false. Saving credential_id grants no access: Session admission checks attached Vault ownership and destination. MCP allowed_tools preserves null versus empty; saved HTTP transport includes empty headers. Model-derived reasoning defaults, other MCP variants and public retry conformance remain incomplete. web_search saves every pinned mode: omitted or null mode is saved as live and omitted or null context_size as medium; allowed_domains preserves null versus empty and a present location includes all four keys. Session execution accepts only explicit disabled web_search and disabled programmatic_tool_calling through qualified Runtime controls; saved enabled forms reject at Session admission. Session execution admits only its supported configuration subset. +// @Description Persists configuration independently of execution. Names over 128 characters and metadata outside 16 string pairs with 64-character keys and 512-character values return invalid_request_error with the official param; U+0000 in stored strings is rejected as a local storage limit. Missing, unknown, repeated, wrongly typed or unsupported enum members of the pinned configuration shapes (tools, text, reasoning, service_tier, multi_agent) return invalid_request_error with the JSON path as param; duplicate function names, repeated web_search or tool_search and non-object schema root types return it with a null param. Supports model/name/instructions/metadata, explicit reasoning and service tiers, multi_agent, text/json_schema, function/tool_search/programmatic_tool_calling/web_search and HTTP MCP with nullable credential_id and explicit service origin and boolean required defaulting to false. Saving credential_id grants no access: Session admission checks attached Vault ownership and destination. MCP allowed_tools preserves null versus empty; saved HTTP transport includes empty headers. Model-derived reasoning defaults, other MCP variants and public retry conformance remain incomplete. web_search saves every pinned mode: omitted or null mode is saved as live and omitted or null context_size as medium; allowed_domains preserves null versus empty and a present location, including {}, includes all four keys with null for omitted ones, as observed officially (req_db41d2f6261b4abfb69465eafe719ab5, req_165d53b88445490b9146d8272c54134d). Session execution accepts only explicit disabled web_search and disabled programmatic_tool_calling through qualified Runtime controls; saved enabled forms reject at Session admission. Session execution admits only its supported configuration subset. // @Tags Agents // @Accept json // @Produce json diff --git a/services/agents-api/internal/store/saved_web_search_public_test.go b/services/agents-api/internal/store/saved_web_search_public_test.go index 62ce27622..3c4de540c 100644 --- a/services/agents-api/internal/store/saved_web_search_public_test.go +++ b/services/agents-api/internal/store/saved_web_search_public_test.go @@ -85,8 +85,14 @@ func TestSavedWebSearchPostgres(t *testing.T) { {"mode-null", `{"type":"web_search","mode":null}`, live}, {"mode-live", `{"type":"web_search","mode":"live"}`, live}, {"mode-cached", `{"type":"web_search","mode":"cached"}`, `{"type":"web_search","mode":"cached",` + defaults}, - {"mode-cached-full", `{"type":"web_search","mode":"cached","context_size":"high","allowed_domains":["example.com"],"location":{"country":"FR","city":"Paris"}}`, + {"mode-cached-full", `{"type":"web_search","mode":"cached","context_size":"high","allowed_domains":["example.com"],"location":{"city":"Paris","country":"FR","region":null,"timezone":null}}`, `{"type":"web_search","mode":"cached","context_size":"high","allowed_domains":["example.com"],"location":{"country":"FR","region":null,"city":"Paris","timezone":null}}`}, + // A supplied location projects all four keys, with null for omitted ones + // (req_db41d2f6261b4abfb69465eafe719ab5, req_165d53b88445490b9146d8272c54134d). + {"location-partial-omitted", `{"type":"web_search","mode":"cached","location":{"city":"Paris","country":"FR"}}`, + `{"type":"web_search","mode":"cached","context_size":"medium","allowed_domains":null,"location":{"country":"FR","region":null,"city":"Paris","timezone":null}}`}, + {"location-empty", `{"type":"web_search","mode":"cached","location":{}}`, + `{"type":"web_search","mode":"cached","context_size":"medium","allowed_domains":null,"location":{"country":null,"region":null,"city":null,"timezone":null}}`}, {"mode-disabled", `{"type":"web_search","mode":"disabled"}`, `{"type":"web_search","mode":"disabled",` + defaults}, } { status, body := client.do(owner, http.MethodPost, "/v1/agents", "application/json", []byte(`{"model":"search-model","tools":[`+tc.tool+`]}`)) @@ -111,7 +117,8 @@ func TestSavedWebSearchPostgres(t *testing.T) { readBack(tc.name, updated, body) } - // A disabled record saved before this batch reads and executes unchanged (W7). + // A disabled record saved before this batch reads unchanged and is admitted with + // the same frozen Session tool (W7). legacy, err := s.CreateAgent(t.Context(), ownerTenant, store.CreateAgentInput{Metadata: map[string]string{}, Configuration: json.RawMessage( `{"model":"search-model","name":null,"instructions":null,"multi_agent":{"enabled":false,"max_concurrent_subagents":null},"reasoning":{},"service_tier":"auto","text":{"format":{"type":"text"},"verbosity":"medium"},"tools":[{"type":"web_search","mode":"disabled","context_size":"medium","allowed_domains":[],"location":null}]}`)}) if err != nil { @@ -140,7 +147,8 @@ func TestSavedWebSearchPostgres(t *testing.T) { t.Fatalf("legacy Session tool %s, want %s", got, legacyTool) } - // A same-key retry recovers its Session after the Agent enables search. + // A same-key retry of a Session that recorded its creation request recovers it + // after the Agent enables search. retry := func(key string) (int, string) { request, err := http.NewRequest(http.MethodPost, server.URL+"/v1/agents/sessions", strings.NewReader(`{"agent_id":"`+agents["mode-disabled"]+`","environment":{"type":"none"},"input":"hi"}`)) if err != nil { diff --git a/services/agents-api/tests/official_agents.py b/services/agents-api/tests/official_agents.py index dc98028e6..0097583ca 100644 --- a/services/agents-api/tests/official_agents.py +++ b/services/agents-api/tests/official_agents.py @@ -156,7 +156,9 @@ def verify_agents(client, other, invalid, expect_error): mcp = {"type": "mcp", "server_label": "x", "transport": {"type": "http", "server_url": "https://example.invalid"}} expect_error(BadRequestError, lambda: agents.create(model="x", tools=[mcp])) # Every pinned web_search mode is saved as the official service does (TV-05); - # omitted or null mode is saved as live. Session admission still rejects it. + # omitted or null mode is saved as live. A supplied location, including {}, + # has all four keys (req_db41d2f6261b4abfb69465eafe719ab5, + # req_165d53b88445490b9146d8272c54134d). live = {"type": "web_search", "mode": "live", "context_size": "medium", "allowed_domains": None, "location": None} for tool, expected in ( ({"type": "web_search"}, live), @@ -165,6 +167,9 @@ def verify_agents(client, other, invalid, expect_error): "location": {"country": "FR", "city": "Paris"}}, {"type": "web_search", "mode": "cached", "context_size": "high", "allowed_domains": ["example.com"], "location": {"city": "Paris", "country": "FR", "region": None, "timezone": None}}), + ({"type": "web_search", "mode": "cached", "location": {}}, + {"type": "web_search", "mode": "cached", "context_size": "medium", "allowed_domains": None, + "location": {"city": None, "country": None, "region": None, "timezone": None}}), ): response = agents.with_raw_response.create(model="x", tools=[tool]) agent = response.parse() From 31e4bb94119f124b2c9f8fd4443d56baed3acb55 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 23 Sep 2026 18:32:06 +0000 Subject: [PATCH 6/7] Test that enabled web_search never reaches dispatch executionTools, dispatch request construction and every harness's admission qualification reject live, cached and omitted-mode search. The live tool policy script now asserts the rejection code and message. --- .../internal/execution/disabled_tools_test.go | 30 +++++++++++++++++++ .../agents-api/tests/official_tool_policy.py | 21 +++++++------ 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/services/agents-api/internal/execution/disabled_tools_test.go b/services/agents-api/internal/execution/disabled_tools_test.go index 308700237..742d31ad7 100644 --- a/services/agents-api/internal/execution/disabled_tools_test.go +++ b/services/agents-api/internal/execution/disabled_tools_test.go @@ -50,3 +50,33 @@ func TestDisabledToolRequestPreservesIntentOnResume(t *testing.T) { } } } + +// TV-05: saved Agents store enabled and omitted-mode search, but such a snapshot +// fails admission qualification, device selection/preclaim tool decoding and +// dispatch request construction. +func TestEnabledWebSearchNeverReachesDispatch(t *testing.T) { + for _, tool := range []string{ + `{"type":"web_search","mode":"live","context_size":"medium","allowed_domains":null,"location":null}`, + `{"type":"web_search","mode":"cached","context_size":"medium","allowed_domains":null,"location":null}`, + `{"type":"web_search","context_size":"medium","allowed_domains":null,"location":null}`, + `{"type":"web_search","mode":null}`, + } { + tools := []json.RawMessage{json.RawMessage(tool)} + if _, err := executionTools(tools); err == nil || err.Error() != "only disabled web search is qualified for execution" { + t.Fatal(tool, err) + } + snapshot := Snapshot{Agent: v1.Agent{Model: "model", Tools: tools}} + if _, err := (&Dispatcher{}).executionRequest(t.Context(), store.Session{ID: "session"}, snapshot, device.KindCapabilities{}, store.SessionExecutionBinding{}); err == nil { + t.Fatal("dispatch request built for", tool) + } + raw := json.RawMessage(`{"agent":{"model":"model","tools":[` + tool + `]},"environment":{"type":"none"}}`) + for _, kind := range []string{"codex", "claude_sdk", "mcode"} { + if err := (Policy{}).ValidateSessionConfiguration(kind, raw); err == nil { + t.Fatal(kind, "admitted", tool) + } + } + } + if _, err := executionTools([]json.RawMessage{json.RawMessage(`{"type":"web_search","mode":"disabled","context_size":"medium","allowed_domains":null,"location":null}`)}); err != nil { + t.Fatal(err) + } +} diff --git a/services/agents-api/tests/official_tool_policy.py b/services/agents-api/tests/official_tool_policy.py index 63083300c..1da3e06fa 100644 --- a/services/agents-api/tests/official_tool_policy.py +++ b/services/agents-api/tests/official_tool_policy.py @@ -77,8 +77,9 @@ def execute(entry, prompt, creation=None): entry[stage + "_events"] = events save() - def reject_configuration(payload): - request("POST", "/sessions", status=400, json=payload) + def reject_configuration(payload, message): + error = request("POST", "/sessions", status=400, json=payload)["error"] + assert error["code"] == "unsupported_or_invalid_configuration" and error["message"] == message, error try: sessions.create(**payload) raise AssertionError("Unsupported enabled tool configuration was admitted") @@ -125,18 +126,20 @@ def reject_configuration(payload): save() check_config(sid) execute(entry, prompt, creation=creation) - for enabled in [{"type": "web_search", "mode": "cached"}, - {"type": "web_search", "mode": "live"}, - {"type": "programmatic_tool_calling", "enabled": True}]: - reject_configuration({"agent": {"model": model, "tools": [enabled]}, "environment": {"type": "none"}, "input": "Verify rejected tool policy configuration."}) - reject_configuration({"agent_id": sdk_agent.id, "agent": {"tools": [enabled]}, "environment": {"type": "none"}, "input": "Verify rejected tool policy configuration."}) + search_only = "Only disabled web_search is qualified for execution." + ptc_only = "Programmatic tool calling is not qualified for execution." + for enabled, message in [({"type": "web_search", "mode": "cached"}, search_only), + ({"type": "web_search", "mode": "live"}, search_only), + ({"type": "programmatic_tool_calling", "enabled": True}, ptc_only)]: + reject_configuration({"agent": {"model": model, "tools": [enabled]}, "environment": {"type": "none"}, "input": "Verify rejected tool policy configuration."}, message) + reject_configuration({"agent_id": sdk_agent.id, "agent": {"tools": [enabled]}, "environment": {"type": "none"}, "input": "Verify rejected tool policy configuration."}, message) # Saving PTC intent is independent of Session execution qualification. enabled_agent = client.beta.agents.create(model=model, tools=[{"type": "programmatic_tool_calling", "enabled": True}]) - reject_configuration({"agent_id": enabled_agent.id, "environment": {"type": "none"}, "input": "Verify rejected tool policy configuration."}) + reject_configuration({"agent_id": enabled_agent.id, "environment": {"type": "none"}, "input": "Verify rejected tool policy configuration."}, ptc_only) # Saved enabled or omitted-mode search is resource data (TV-05); admission still rejects it. for search in ({"type": "web_search"}, {"type": "web_search", "mode": "cached"}): searched = client.beta.agents.create(model=model, tools=[search]) - reject_configuration({"agent_id": searched.id, "environment": {"type": "none"}, "input": "Verify rejected tool policy configuration."}) + reject_configuration({"agent_id": searched.id, "environment": {"type": "none"}, "input": "Verify rejected tool policy configuration."}, search_only) # Omitted-tool default projections are covered by the queued SDK/raw # resource fixture; these live cases exercise explicit disabled tools. for aid in proof["agents"]: From 481981b0f8a10ba662d0bcfb282800e721e60e95 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 23 Sep 2026 18:32:06 +0000 Subject: [PATCH 7/7] Let Core Web start Sessions from Agents with disabled web_search Core admits explicit disabled search, so Web admission now mirrors it: one well-formed disabled declaration is allowed, while enabled or omitted-mode search is blocked because Core does not run it. Saved search labels no longer claim that disabled search cannot run. --- .../src/features/agents/agent-form.test.ts | 25 +++++++++---------- apps/web/src/features/agents/agent-form.ts | 6 ++++- .../features/agents/session-admission.test.ts | 11 +++++++- .../src/features/agents/session-admission.ts | 21 +++++++++++++++- 4 files changed, 47 insertions(+), 16 deletions(-) diff --git a/apps/web/src/features/agents/agent-form.test.ts b/apps/web/src/features/agents/agent-form.test.ts index a7df06b47..055564a5d 100644 --- a/apps/web/src/features/agents/agent-form.test.ts +++ b/apps/web/src/features/agents/agent-form.test.ts @@ -270,19 +270,18 @@ describe("Agent form contract", () => { })).toEqual({ toolsError: "At most 64 Functions can be configured." }); }); - it("labels saved web_search in every mode as a read-only saved tool", () => { - for (const mode of ["live", "cached", "disabled"]) { - const agent = { - id: "agent_1", object: "agent" as const, model: "provider/model", name: null, instructions: null, metadata: {}, - multi_agent: { enabled: false, max_concurrent_subagents: null }, reasoning: {}, service_tier: "auto" as const, - text: { format: { type: "text" as const }, verbosity: "medium" as const }, created_at: 1, updated_at: 1, - tools: [{ type: "web_search", mode, context_size: "medium", allowed_domains: [], location: null }], - }; - expect(valuesFromAgent(agent, null).tools[0]).toMatchObject({ - kind: "read-only", - label: "web_search is saved-only and cannot run in a Session", - }); - } + it.each([ + ["live", "Enabled web_search is saved-only; Core does not run enabled search in a Session"], + ["cached", "Enabled web_search is saved-only; Core does not run enabled search in a Session"], + ["disabled", "Disabled web_search is read-only in Web; Sessions keep search off"], + ])("labels saved %s web_search as a read-only tool without misstating Core admission", (mode, label) => { + const agent = { + id: "agent_1", object: "agent" as const, model: "provider/model", name: null, instructions: null, metadata: {}, + multi_agent: { enabled: false, max_concurrent_subagents: null }, reasoning: {}, service_tier: "auto" as const, + text: { format: { type: "text" as const }, verbosity: "medium" as const }, created_at: 1, updated_at: 1, + tools: [{ type: "web_search", mode, context_size: "medium", allowed_domains: [], location: null }], + }; + expect(valuesFromAgent(agent, null).tools[0]).toMatchObject({ kind: "read-only", label }); }); it("omits read-only saved tools for unrelated updates and rejects deliberate Tool changes", () => { diff --git a/apps/web/src/features/agents/agent-form.ts b/apps/web/src/features/agents/agent-form.ts index 28c6920a0..8fa0ccb95 100644 --- a/apps/web/src/features/agents/agent-form.ts +++ b/apps/web/src/features/agents/agent-form.ts @@ -172,7 +172,11 @@ function readOnlyToolLabel(value: unknown): string { if ( value.type === "web_search" && hasOnlyKeys(value, ["type", "mode", "context_size", "allowed_domains", "location"]) - ) return "web_search is saved-only and cannot run in a Session"; + ) { + return value.mode === "disabled" + ? "Disabled web_search is read-only in Web; Sessions keep search off" + : "Enabled web_search is saved-only; Core does not run enabled search in a Session"; + } if (value.type === "function" && value.defer_loading === true) return "Deferred Function is saved-only"; if (value.type === "mcp" && value.credential_id != null) return "Credentialed MCP is unresolved or URL-mismatched in the current Vault catalog"; if (value.type === "function") return "Unsupported Function tool definition"; diff --git a/apps/web/src/features/agents/session-admission.test.ts b/apps/web/src/features/agents/session-admission.test.ts index 1a9bf6258..decaff87c 100644 --- a/apps/web/src/features/agents/session-admission.test.ts +++ b/apps/web/src/features/agents/session-admission.test.ts @@ -90,7 +90,11 @@ describe("known Session admission blockers", () => { [{ text: { format: { type: "json_schema" as const, schema: { type: "object" } }, verbosity: "medium" as const } }, "text format must be text"], [{ multi_agent: { enabled: true, max_concurrent_subagents: 2 } }, "multi-agent execution is not supported"], [{ tools: [{ type: "tool_search" }] }, "tool_search is saved-only"], - [{ tools: [{ type: "web_search", mode: "live", context_size: "medium", allowed_domains: null, location: null }] }, "web_search is saved-only"], + [{ tools: [{ type: "web_search", mode: "live", context_size: "medium", allowed_domains: null, location: null }] }, "web_search mode must be disabled because Core does not run enabled search"], + [{ tools: [{ type: "web_search", mode: "cached" }] }, "web_search mode must be disabled because Core does not run enabled search"], + [{ tools: [{ type: "web_search" }] }, "web_search mode must be disabled because Core does not run enabled search"], + [{ tools: [{ type: "web_search", mode: "disabled", location: { extra: "x" } }] }, "the saved web_search tool is incomplete or malformed"], + [{ tools: [{ type: "web_search", mode: "disabled" }, { type: "web_search", mode: "disabled" }] }, "at most one web_search tool can execute"], [{ tools: [{ type: "function", name: "later", description: "", parameters: {}, defer_loading: true }] }, "deferred functions are saved-only"], [{ tools: [{ type: "function", name: " ", description: "", parameters: {}, defer_loading: false }] }, "function names must be non-empty and at most 512 bytes"], [{ tools: [{ type: "function", name: "\u0085", description: "", parameters: {}, defer_loading: false }] }, "function names must be non-empty and at most 512 bytes"], @@ -119,6 +123,11 @@ describe("known Session admission blockers", () => { expect(knownSessionAdmissionBlockers(agent)).toContain(message); }); + it("admits saved disabled web_search, which Core runs with search off", () => { + const tools = [{ type: "web_search", mode: "disabled", context_size: "medium", allowed_domains: [], location: { city: "Paris", country: null, region: null, timezone: null } }]; + expect(knownSessionAdmissionBlocker({ ...executableAgent, tools })).toBeNull(); + }); + it("does not turn conditional Codex verbosity support into a deterministic blocker", () => { const agent = { ...executableAgent, text: { ...executableAgent.text, verbosity: "high" as const } }; expect(knownSessionAdmissionBlocker(agent)).toBeNull(); diff --git a/apps/web/src/features/agents/session-admission.ts b/apps/web/src/features/agents/session-admission.ts index 747752be5..1390cfbcd 100644 --- a/apps/web/src/features/agents/session-admission.ts +++ b/apps/web/src/features/agents/session-admission.ts @@ -49,6 +49,19 @@ function isCanonicalExecutionFunction(tool: Record): boolean { && (tool.defer_loading === undefined || typeof tool.defer_loading === "boolean"); } +// Mirrors Core's saved web_search projection; Core runs only mode "disabled". +function isCanonicalWebSearch(tool: Record): boolean { + const domains = tool.allowed_domains; + const location = tool.location; + return hasOnlyKeys(tool, ["type", "mode", "context_size", "allowed_domains", "location"]) + && (tool.mode == null || ["disabled", "cached", "live"].includes(String(tool.mode))) + && (tool.context_size == null || ["low", "medium", "high"].includes(String(tool.context_size))) + && (domains == null || Array.isArray(domains) && domains.every((domain) => typeof domain === "string")) + && (location == null || isRecord(location) + && hasOnlyKeys(location, ["city", "country", "region", "timezone"]) + && Object.values(location).every((value) => value === null || typeof value === "string")); +} + function isCanonicalExecutionMcp(tool: Record): boolean { const transport = tool.transport; const allowedTools = tool.allowed_tools; @@ -130,6 +143,7 @@ export function knownSessionAdmissionBlockers(agent: SavedAgent): string[] { const functionNames = new Set(); const mcpLabels = new Set(); let functionCount = 0; + let searchCount = 0; for (const rawTool of agent.tools) { if (!rawTool || typeof rawTool !== "object" || Array.isArray(rawTool)) { blockers.push("the saved tool configuration is not executable"); @@ -158,9 +172,13 @@ export function knownSessionAdmissionBlockers(agent: SavedAgent): string[] { mcpLabels.add(tool.server_label); } break; + case "web_search": + searchCount += 1; + if (!isCanonicalWebSearch(tool)) blockers.push("the saved web_search tool is incomplete or malformed"); + else if (tool.mode !== "disabled") blockers.push("web_search mode must be disabled because Core does not run enabled search"); + break; case "tool_search": case "programmatic_tool_calling": - case "web_search": blockers.push(`${String(tool.type)} is saved-only`); break; default: @@ -168,6 +186,7 @@ export function knownSessionAdmissionBlockers(agent: SavedAgent): string[] { } } if (functionCount > 64) blockers.push("at most 64 function tools can execute"); + if (searchCount > 1) blockers.push("at most one web_search tool can execute"); return [...new Set(blockers)]; }