diff --git a/apps/daemon/internal/agent/claudesdk/cancellation_live_linux_test.go b/apps/daemon/internal/agent/claudesdk/cancellation_live_linux_test.go index 4cbab1ca5..e676a4f2f 100644 --- a/apps/daemon/internal/agent/claudesdk/cancellation_live_linux_test.go +++ b/apps/daemon/internal/agent/claudesdk/cancellation_live_linux_test.go @@ -65,7 +65,7 @@ func TestLiveClaudeSDKCancelResume(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) defer cancel() out := make(chan proto.Envelope, 64) - request := proto.PromptRequestPayload{RunID: uuid.NewString(), Input: proto.TextInput(prompt), AgentSessionID: resume, ObserveMessages: true, DisableExecutionEnvironment: true, DisableSubagents: true, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, AgentOptions: map[string]any{"model": "MiniMax-M3", "system_prompt": "Follow the user's requested format. Preserve the exact verification value in conversation history. Use no tools."}} + request := proto.PromptRequestPayload{RunID: uuid.NewString(), Input: proto.TextInput(prompt), AgentSessionID: resume, ObserveMessages: true, DisableExecutionEnvironment: true, DisableSubagents: true, ExecutionControls: &proto.ExecutionControls{TextVerbosity: "medium"}, AgentOptions: map[string]any{"model": "MiniMax-M3", "system_prompt": "Follow the user's requested format. Preserve the exact verification value in conversation history. Use no tools."}} running, err := startSingleTurn(ctx, config, request, out) if err != nil { t.Fatal(err) diff --git a/apps/daemon/internal/agent/claudesdk/execution_controls_test.go b/apps/daemon/internal/agent/claudesdk/execution_controls_test.go index 0381cf750..84786b5a7 100644 --- a/apps/daemon/internal/agent/claudesdk/execution_controls_test.go +++ b/apps/daemon/internal/agent/claudesdk/execution_controls_test.go @@ -22,7 +22,7 @@ func TestExecutionControlsPreserveNativeDefaultsAndInstructions(t *testing.T) { if err != nil { t.Fatal(err) } - request.ExecutionControls = &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"} + request.ExecutionControls = &proto.ExecutionControls{TextVerbosity: "medium"} before, _ := json.Marshal(request) controlled, _, err := prepare(config, request) if err != nil { @@ -35,14 +35,18 @@ func TestExecutionControlsPreserveNativeDefaultsAndInstructions(t *testing.T) { } func TestExecutionControlsRejectUnsupportedProfilesBeforeLaunch(t *testing.T) { + enabled := true cases := map[string]proto.ExecutionControls{ - "empty": {}, "missing-search": {TextVerbosity: "medium"}, "missing-verbosity": {WebSearch: "disabled"}, - "cached-search": {WebSearch: "cached", TextVerbosity: "medium"}, - "live-search": {WebSearch: "live", TextVerbosity: "medium"}, - "unknown-search": {WebSearch: "invalid", TextVerbosity: "medium"}, - "low-verbosity": {WebSearch: "disabled", TextVerbosity: "low"}, - "high-verbosity": {WebSearch: "disabled", TextVerbosity: "high"}, - "unknown-verbosity": {WebSearch: "disabled", TextVerbosity: "invalid"}, + "cached-search": {WebSearch: &proto.WebSearchControl{Mode: "cached", ContextSize: "medium"}}, + "live-search": {WebSearch: &proto.WebSearchControl{Mode: "live", ContextSize: "medium"}}, + "enabled-ptc": {ProgrammaticToolCalling: &enabled}, + "low-verbosity": {TextVerbosity: "low"}, + "high-verbosity": {TextVerbosity: "high"}, + "unknown-verbosity": {TextVerbosity: "invalid"}, + "no-effort": {ReasoningEffort: "none"}, + "minimal-effort": {ReasoningEffort: "minimal"}, + "summary": {ReasoningSummary: "auto"}, + "service-tier": {ServiceTier: "priority"}, } for name, controls := range cases { t.Run(name, func(t *testing.T) { @@ -51,7 +55,7 @@ func TestExecutionControlsRejectUnsupportedProfilesBeforeLaunch(t *testing.T) { config := Config{Node: "must-not-run", Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")} request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("Original input."), ExecutionControls: &controls, AgentOptions: map[string]any{"model": "native-model"}} _, err := startSingleTurn(t.Context(), config, request, make(chan proto.Envelope, 1)) - if err == nil || !strings.Contains(err.Error(), "execution controls require") { + if err == nil || !strings.Contains(err.Error(), "not supported by this Harness") { t.Fatal("unsupported controls did not fail at admission", err) } if _, err := os.Stat(config.StateDir); !os.IsNotExist(err) { @@ -61,6 +65,22 @@ func TestExecutionControlsRejectUnsupportedProfilesBeforeLaunch(t *testing.T) { } } +func TestReasoningEffortReachesNativeOptions(t *testing.T) { + root := t.TempDir() + t.Setenv("OAC_RUNTIME_HOME", root) + config := Config{Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")} + for _, effort := range []string{"low", "medium", "high", "xhigh", "max"} { + request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), ExecutionControls: &proto.ExecutionControls{TextVerbosity: "medium", ReasoningEffort: effort}, AgentOptions: map[string]any{"model": "native-model", "harness_config": map[string]any{"thinking": map[string]any{"type": "adaptive"}}}} + start, _, err := prepare(config, request) + if err != nil { + t.Fatal(effort, err) + } + if start.NativeModelOptions == nil || start.NativeModelOptions.Effort != effort || start.NativeModelOptions.Thinking["type"] != "adaptive" { + t.Fatalf("effort %s: %+v", effort, start.NativeModelOptions) + } + } +} + func TestMCPWithoutEnvironmentNoneRejectedBeforeSetup(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) @@ -81,7 +101,7 @@ func TestStructuredOutputConfigurationReachesNativeUnchanged(t *testing.T) { t.Setenv("OAC_RUNTIME_HOME", root) config := Config{Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")} schema := json.RawMessage(`{"type":"object","properties":{"n":{"const":9007199254740992}}}`) - request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("Original input."), ObserveMessages: true, DisableSubagents: true, AgentOptions: map[string]any{"model": "model", "system_prompt": "Original instructions."}, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium", OutputFormat: &proto.OutputFormat{Type: "json_schema", Schema: schema}}} + request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("Original input."), ObserveMessages: true, DisableSubagents: true, AgentOptions: map[string]any{"model": "model", "system_prompt": "Original instructions."}, ExecutionControls: &proto.ExecutionControls{TextVerbosity: "medium", OutputFormat: &proto.OutputFormat{Type: "json_schema", Schema: schema}}} start, _, err := prepare(config, request) if err != nil { t.Fatal(err) diff --git a/apps/daemon/internal/agent/claudesdk/executor_live_linux_test.go b/apps/daemon/internal/agent/claudesdk/executor_live_linux_test.go index 669f2dce1..82abddc30 100644 --- a/apps/daemon/internal/agent/claudesdk/executor_live_linux_test.go +++ b/apps/daemon/internal/agent/claudesdk/executor_live_linux_test.go @@ -80,7 +80,7 @@ func TestLiveClaudeExecutorReuseAndCancel(t *testing.T) { _ = os.WriteFile(filepath.Join(proof, "executor-evidence.json"), raw, 0600) } defer persist() - request := proto.PromptRequestPayload{DisableExecutionEnvironment: true, DisableSubagents: true, ObserveMessages: true, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, AgentOptions: map[string]any{"model": model, "system_prompt": "Follow requested formats briefly. Remember the exact verification marker across the conversation. Use no tools."}} + request := proto.PromptRequestPayload{DisableExecutionEnvironment: true, DisableSubagents: true, ObserveMessages: true, ExecutionControls: &proto.ExecutionControls{TextVerbosity: "medium"}, AgentOptions: map[string]any{"model": model, "system_prompt": "Follow requested formats briefly. Remember the exact verification marker across the conversation. Use no tools."}} factory := NewExecutorFactory(config) prepared := time.Now() owner, err := factory(ctx, request) diff --git a/apps/daemon/internal/agent/claudesdk/harness_config_test.go b/apps/daemon/internal/agent/claudesdk/harness_config_test.go index cf5dbd5a0..ae4dc36e1 100644 --- a/apps/daemon/internal/agent/claudesdk/harness_config_test.go +++ b/apps/daemon/internal/agent/claudesdk/harness_config_test.go @@ -12,8 +12,8 @@ func TestHarnessConfigReachesBridgeAndOwnsSnapshot(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) config := Config{Entrypoint: filepath.Join(root, "main.js"), StateDir: filepath.Join(root, "state")} - native := map[string]any{"effort": "high", "thinking": map[string]any{"type": "enabled", "budgetTokens": 1024}} - req := proto.PromptRequestPayload{AgentOptions: map[string]any{"model": "fixture", "harness_config": native}} + native := map[string]any{"thinking": map[string]any{"type": "enabled", "budgetTokens": 1024}} + req := proto.PromptRequestPayload{AgentOptions: map[string]any{"model": "fixture", "harness_config": native}, ExecutionControls: &proto.ExecutionControls{TextVerbosity: "medium", ReasoningEffort: "high"}} start, _, err := prepareConfiguration(config, req) if err != nil { t.Fatal(err) diff --git a/apps/daemon/internal/agent/claudesdk/live_linux_test.go b/apps/daemon/internal/agent/claudesdk/live_linux_test.go index 1f534d1a4..fdf765dd2 100644 --- a/apps/daemon/internal/agent/claudesdk/live_linux_test.go +++ b/apps/daemon/internal/agent/claudesdk/live_linux_test.go @@ -137,7 +137,7 @@ func TestLiveClaudeSDKTextResume(t *testing.T) { requestStart := len(requests) mu.Unlock() out := make(chan proto.Envelope, 64) - request := proto.PromptRequestPayload{RunID: uuid.NewString(), Input: proto.TextInput(prompt), AgentSessionID: resume, ObserveMessages: true, DisableExecutionEnvironment: true, DisableSubagents: true, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, AgentOptions: map[string]any{"model": "MiniMax-M3", "system_prompt": "Answer briefly and preserve the exact verification value in the conversation. Use no tools."}} + request := proto.PromptRequestPayload{RunID: uuid.NewString(), Input: proto.TextInput(prompt), AgentSessionID: resume, ObserveMessages: true, DisableExecutionEnvironment: true, DisableSubagents: true, ExecutionControls: &proto.ExecutionControls{TextVerbosity: "medium"}, AgentOptions: map[string]any{"model": "MiniMax-M3", "system_prompt": "Answer briefly and preserve the exact verification value in the conversation. Use no tools."}} if success != nil { request.AgentOptions["system_prompt"] = "Call lookup exactly once as requested, then report both result parts and any prior verification value. Never retry a failed tool." request.FunctionTools = []proto.FunctionTool{{Name: "lookup", Description: "Return a synthetic verification value.", Parameters: json.RawMessage(`{"type":"object","properties":{"id":{"type":"string"}},"required":["id"],"additionalProperties":false}`)}} diff --git a/apps/daemon/internal/agent/claudesdk/native_model_options.go b/apps/daemon/internal/agent/claudesdk/native_model_options.go index 65ea8248c..b378e2b12 100644 --- a/apps/daemon/internal/agent/claudesdk/native_model_options.go +++ b/apps/daemon/internal/agent/claudesdk/native_model_options.go @@ -9,14 +9,11 @@ type nativeModelOptions struct { Thinking map[string]any `json:"thinking,omitempty"` } -func compileNativeModelOptions(config proto.HarnessConfig) *nativeModelOptions { - if len(config) == 0 { +func compileNativeModelOptions(config proto.HarnessConfig, effort string) *nativeModelOptions { + if len(config) == 0 && effort == "" { return nil } - options := &nativeModelOptions{} - if value, exists := config["effort"]; exists { - options.Effort = value.(string) - } + options := &nativeModelOptions{Effort: effort} if value, exists := config["thinking"]; exists { options.Thinking = value.(map[string]any) } diff --git a/apps/daemon/internal/agent/claudesdk/options.go b/apps/daemon/internal/agent/claudesdk/options.go index a0c60153b..9e267210f 100644 --- a/apps/daemon/internal/agent/claudesdk/options.go +++ b/apps/daemon/internal/agent/claudesdk/options.go @@ -61,7 +61,14 @@ func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startR if err != nil { return startRequest{}, nil, err } - start.NativeModelOptions = compileNativeModelOptions(modelConfiguration.HarnessConfig) + if err := harnessconfiguration.Declaration.ValidateExecutionControls(req.ExecutionControls); err != nil { + return fail(err.Error()) + } + effort := "" + if req.ExecutionControls != nil { + effort = req.ExecutionControls.ReasoningEffort + } + start.NativeModelOptions = compileNativeModelOptions(modelConfiguration.HarnessConfig, effort) if err := req.ValidateToolSearch(true); err != nil { return startRequest{}, nil, err } @@ -74,11 +81,6 @@ func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startR if err := validateMCP(req); err != nil { return startRequest{}, nil, err } - // Search is disabled by the fixed native tool profile. Medium selects the - // SDK's default text generation; it has no native verbosity-level option. - if controls := req.ExecutionControls; controls != nil && (controls.WebSearch != "disabled" || controls.TextVerbosity != "medium") { - return fail("execution controls require disabled web search and medium text verbosity") - } if req.ExecutionControls != nil && req.ExecutionControls.OutputFormat != nil { format := req.ExecutionControls.OutputFormat if format.Type != "json_schema" || !req.ObserveMessages || !req.DisableSubagents || req.MCPHTTPServers != nil || (req.LocalEnvironment != nil && (len(req.LocalEnvironment.MCP) != 0 || len(req.LocalEnvironment.Skills) != 0)) { diff --git a/apps/daemon/internal/agent/claudesdk/preparation_test.go b/apps/daemon/internal/agent/claudesdk/preparation_test.go index 5c03d23c5..2d88c6024 100644 --- a/apps/daemon/internal/agent/claudesdk/preparation_test.go +++ b/apps/daemon/internal/agent/claudesdk/preparation_test.go @@ -117,7 +117,7 @@ func TestPreparationRejectsInputAndUnavailableProfilesBeforeLaunch(t *testing.T) case "mcp": req.MCPHTTPServers = &[]proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "remote", ServerURL: "https://example.test/mcp"}} case "controls": - req.ExecutionControls = &proto.ExecutionControls{WebSearch: "enabled", TextVerbosity: "medium"} + req.ExecutionControls = &proto.ExecutionControls{WebSearch: &proto.WebSearchControl{Mode: "live", ContextSize: "medium"}} } if _, err := NewExecutorFactory(config)(t.Context(), req); err == nil { t.Fatal("invalid preparation was accepted") diff --git a/apps/daemon/internal/agent/claudesdk/restrictions_test.go b/apps/daemon/internal/agent/claudesdk/restrictions_test.go index a574a5009..494e32727 100644 --- a/apps/daemon/internal/agent/claudesdk/restrictions_test.go +++ b/apps/daemon/internal/agent/claudesdk/restrictions_test.go @@ -19,7 +19,7 @@ func TestTextFactoryAcceptsRestrictiveCapabilities(t *testing.T) { controls *proto.ExecutionControls }{ {"environment", true, false, nil}, {"subagents", false, true, nil}, {"both", true, true, nil}, - {"execution-controls", true, true, &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}}, + {"execution-controls", true, true, &proto.ExecutionControls{TextVerbosity: "medium"}}, } { t.Run(test.name, func(t *testing.T) { root := t.TempDir() diff --git a/apps/daemon/internal/agent/claudesdk/session_test.go b/apps/daemon/internal/agent/claudesdk/session_test.go index 7c0f094c2..c23de108e 100644 --- a/apps/daemon/internal/agent/claudesdk/session_test.go +++ b/apps/daemon/internal/agent/claudesdk/session_test.go @@ -82,7 +82,7 @@ func TestTextFactoryRejectsUnsupportedInput(t *testing.T) { request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), AgentOptions: map[string]any{"model": "fake"}} switch kind { case "execution-controls": - request.ExecutionControls = &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "low"} + request.ExecutionControls = &proto.ExecutionControls{TextVerbosity: "low"} case "tool": request.FunctionTools = []proto.FunctionTool{{}} case "outside": diff --git a/apps/daemon/internal/agent/claudesdk/workspace_structured_test.go b/apps/daemon/internal/agent/claudesdk/workspace_structured_test.go index ec627a49e..8434e3a09 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace_structured_test.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_structured_test.go @@ -20,7 +20,7 @@ func TestWorkspaceStructuredPreparationQualificationAndFrozenSchema(t *testing.T req := preparationRequest() req.ObserveMessages = true schema := `{"type":"object","properties":{"n":{"const":9007199254740992}}}` - req.ExecutionControls = &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium", OutputFormat: &proto.OutputFormat{Type: "json_schema", Schema: json.RawMessage(schema)}} + req.ExecutionControls = &proto.ExecutionControls{TextVerbosity: "medium", OutputFormat: &proto.OutputFormat{Type: "json_schema", Schema: json.RawMessage(schema)}} e, err := NewExecutorFactory(config)(t.Context(), req) if mode == "structured-missing" { if err == nil || !strings.Contains(err.Error(), "workspace structured output") { diff --git a/apps/daemon/internal/agent/codex/declaration.go b/apps/daemon/internal/agent/codex/declaration.go index 3f4fcae52..ff96549d3 100644 --- a/apps/daemon/internal/agent/codex/declaration.go +++ b/apps/daemon/internal/agent/codex/declaration.go @@ -33,8 +33,6 @@ func discoverWithCheck(parent context.Context, options agent.DiscoveryOptions, i for _, capability := range []*proto.CapabilitySupport{&caps.LocalEnvironment, &caps.WorkspaceReadPreparation, &caps.WorkspaceOutputExport, &caps.RetainedNativeHistory} { agent.Narrow(capability, local) } - agent.Narrow(&caps.ExecutionControls, SupportsTextVerbosity) - agent.Narrow(&caps.TextVerbosity, SupportsTextVerbosity) runtime.Executor = NewExecutorFactory(options.StateRoot) fmt.Fprintf(options.Stdout, "Codex preflight ok (%s)\n", version) return runtime diff --git a/apps/daemon/internal/agent/codex/execution_controls_test.go b/apps/daemon/internal/agent/codex/execution_controls_test.go index 374e0e1a0..c31b004d4 100644 --- a/apps/daemon/internal/agent/codex/execution_controls_test.go +++ b/apps/daemon/internal/agent/codex/execution_controls_test.go @@ -7,32 +7,52 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -func TestExecutionControlsSelectNativeSettings(t *testing.T) { +func ptr[T any](v T) *T { return &v } + +func TestExecutionControlsMapToNativeSettings(t *testing.T) { t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - for _, search := range []string{"disabled", "cached", "live"} { - for _, verbosity := range []string{"low", "medium", "high"} { - plan, err := BuildSessionPlan(testStateRoot(t), "state", map[string]any{"model": "test-model"}, &proto.ExecutionControls{WebSearch: search, TextVerbosity: verbosity}) + base := [][2]string{{"tools.experimental_request_user_input.enabled", "false"}} + for _, tc := range []struct { + name string + controls *proto.ExecutionControls + want [][2]string + }{ + {"absent controls send nothing", nil, nil}, + {"empty controls send nothing", &proto.ExecutionControls{}, nil}, + {"verbosity", &proto.ExecutionControls{TextVerbosity: "high"}, [][2]string{{"model_verbosity", `"high"`}}}, + {"search without settings", &proto.ExecutionControls{WebSearch: &proto.WebSearchControl{Mode: "live", ContextSize: "medium"}}, + [][2]string{{"web_search", `"live"`}, {"tools.web_search.context_size", `"medium"`}}}, + {"search settings", &proto.ExecutionControls{WebSearch: &proto.WebSearchControl{Mode: "cached", ContextSize: "high", AllowedDomains: []string{"a.example", "b.example"}, + Location: &proto.SearchLocation{City: ptr("Paris"), Timezone: ptr("Europe/Paris")}}}, + [][2]string{{"web_search", `"cached"`}, {"tools.web_search.context_size", `"high"`}, {"tools.web_search.allowed_domains", `["a.example","b.example"]`}, + {"tools.web_search.location.city", `"Paris"`}, {"tools.web_search.location.timezone", `"Europe/Paris"`}}}, + {"empty domain list stays distinct from null", &proto.ExecutionControls{WebSearch: &proto.WebSearchControl{Mode: "disabled", ContextSize: "low", AllowedDomains: []string{}}}, + [][2]string{{"web_search", `"disabled"`}, {"tools.web_search.context_size", `"low"`}, {"tools.web_search.allowed_domains", `[]`}}}, + {"reasoning and tier", &proto.ExecutionControls{ReasoningEffort: "max", ReasoningSummary: "concise", ServiceTier: "priority"}, + [][2]string{{"model_reasoning_effort", `"max"`}, {"model_reasoning_summary", `"concise"`}, {"service_tier", `"priority"`}}}, + } { + t.Run(tc.name, func(t *testing.T) { + plan, err := BuildSessionPlan(testStateRoot(t), "state", map[string]any{"model": "test-model"}, tc.controls) if err != nil { t.Fatal(err) } plan.Cleanup() - want := [][2]string{{"tools.experimental_request_user_input.enabled", "false"}, {"web_search", `"` + search + `"`}, {"model_verbosity", `"` + verbosity + `"`}} - if !reflect.DeepEqual(plan.ExtraConfig, want) { + if want := append(append([][2]string{}, base...), tc.want...); !reflect.DeepEqual(plan.ExtraConfig, want) { t.Fatalf("config = %v, want %v", plan.ExtraConfig, want) } - } + }) } } -func TestExecutionControlsRejectIncompleteOrInvalidValues(t *testing.T) { +func TestExecutionControlsRejectUndeclaredValues(t *testing.T) { t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) for _, controls := range []proto.ExecutionControls{ - {}, {WebSearch: "disabled"}, {TextVerbosity: "medium"}, - {WebSearch: "invalid", TextVerbosity: "medium"}, {WebSearch: "disabled", TextVerbosity: "invalid"}, + {WebSearch: &proto.WebSearchControl{Mode: "indexed", ContextSize: "medium"}}, + {TextVerbosity: "invalid"}, {ReasoningEffort: "ultra"}, {ReasoningSummary: "none"}, {ServiceTier: "scale"}, } { if plan, err := BuildSessionPlan(testStateRoot(t), "state", nil, &controls); err == nil { plan.Cleanup() - t.Fatal("invalid controls accepted", controls) + t.Fatal("undeclared controls accepted", controls) } } } diff --git a/apps/daemon/internal/agent/codex/harness_config_test.go b/apps/daemon/internal/agent/codex/harness_config_test.go index 7ca086aa9..a72417722 100644 --- a/apps/daemon/internal/agent/codex/harness_config_test.go +++ b/apps/daemon/internal/agent/codex/harness_config_test.go @@ -11,21 +11,28 @@ import ( "testing" ) -func TestHarnessConfigAppliedWithoutChangingProvider(t *testing.T) { +func TestReasoningEffortAppliedWithoutChangingProvider(t *testing.T) { t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) plan, err := BuildSessionPlan(testStateRoot(t), "native-config", map[string]any{ - "model": "fixture", "harness_config": map[string]any{"model_reasoning_effort": "high"}, + "model": "fixture", "model_provider": map[string]any{"base_url": "https://provider.invalid/v1", "protocol": "responses", "api_key": "test-key"}, - }, nil) + }, &proto.ExecutionControls{ReasoningEffort: "max"}) if err != nil { t.Fatal(err) } defer plan.Cleanup() - if plan.Model != "fixture" || plan.ModelProvider != oacProviderSlug || !slices.Contains(plan.ExtraConfig, [2]string{"model_reasoning_effort", `"high"`}) { + if plan.Model != "fixture" || plan.ModelProvider != oacProviderSlug || !slices.Contains(plan.ExtraConfig, [2]string{"model_reasoning_effort", `"max"`}) { t.Fatalf("native configuration not applied: %+v", plan.ExtraConfig) } } +func TestReasoningEffortHasNoHarnessConfigHome(t *testing.T) { + _, err := BuildSessionPlan(testStateRoot(t), "", map[string]any{"harness_config": map[string]any{"model_reasoning_effort": "high"}}, nil) + if err != harnessconfig.ErrHarnessConfig { + t.Fatalf("duplicate effort home accepted: %v", err) + } +} + func TestHarnessConfigConflictFailsBeforePreparation(t *testing.T) { _, err := BuildSessionPlan(testStateRoot(t), "", map[string]any{"harness_config": map[string]any{"model_provider": "bypass"}}, nil) if err != harnessconfig.ErrHarnessConfig { @@ -33,14 +40,14 @@ func TestHarnessConfigConflictFailsBeforePreparation(t *testing.T) { } } -func TestHarnessConfigReachesEveryNativeTurn(t *testing.T) { +func TestReasoningEffortReachesEveryNativeTurn(t *testing.T) { for _, resumeID := range []string{"", "fixture-native-thread"} { t.Run("resume="+resumeID, func(t *testing.T) { req, cfg, root := preparationFixture(t) t.Setenv("OAC_TEST_EXECUTOR_MODE", "complete") req.AgentSessionID = resumeID - native := map[string]any{"model_reasoning_effort": "high"} - req.AgentOptions["harness_config"] = native + controls := &proto.ExecutionControls{TextVerbosity: "medium", ReasoningEffort: "high"} + req.ExecutionControls = controls ownerCtx, cancelOwner := context.WithCancel(context.Background()) t.Cleanup(cancelOwner) e, err := newExecutor(ownerCtx, req, cfg) @@ -54,7 +61,7 @@ func TestHarnessConfigReachesEveryNativeTurn(t *testing.T) { t.Error(err) } }) - native["model_reasoning_effort"] = "low" + controls.ReasoningEffort = "low" for _, id := range []string{"one", "two"} { out := make(chan proto.Envelope, 20) turn, err := e.StartTurn(t.Context(), id, proto.TextInput("answer"), out) diff --git a/apps/daemon/internal/agent/codex/mcp_http_bearer_test.go b/apps/daemon/internal/agent/codex/mcp_http_bearer_test.go index 5a68ce270..b26f3df10 100644 --- a/apps/daemon/internal/agent/codex/mcp_http_bearer_test.go +++ b/apps/daemon/internal/agent/codex/mcp_http_bearer_test.go @@ -68,32 +68,6 @@ func TestMCPHTTPBearerRejectsInvalidTokensWithoutPersistence(t *testing.T) { } } -func TestMCPHTTPBearerDoesNotReachModelCatalogProbe(t *testing.T) { - if !SupportsTextVerbosity { - t.Skip("catalog probe requires Unix") - } - t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - binary := filepath.Join(t.TempDir(), "catalog-probe") - script := "#!/bin/sh\nif env | grep -q '^OAC_RUNTIME_MCP_BEARER_'; then exit 9; fi\nprintf '%s' '{\"models\":[{\"slug\":\"fixture-model\",\"support_verbosity\":true}]}'\n" - if err := os.WriteFile(binary, []byte(script), 0o700); err != nil { - t.Fatal(err) - } - token := "synthetic-catalog-secret" - servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "tools", ServerURL: "https://tools.example/mcp", BearerToken: &token}} - req := proto.PromptRequestPayload{AgentStateKey: "catalog", DisableExecutionEnvironment: true, MCPHTTPServers: &servers, - AgentOptions: map[string]any{"model": "fixture-model"}, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}} - cfg := defaultSessionConfig(testStateRoot(t)) - cfg.codexBinary = binary - plan, _, err := prepareSessionPlan(t.Context(), req, cfg) - if err != nil { - t.Fatal("catalog probe inherited bearer or failed", err) - } - defer plan.Cleanup() - if !slices.Contains(plan.Env, plan.mcpServers["tools"].BearerTokenEnvVar+"="+token) { - t.Fatal("app-server did not receive bearer after catalog probe") - } -} - func TestMCPHTTPBearerPreflightMatchesOnlyItsServerReference(t *testing.T) { servers := map[string]mcpServerConfig{ "first": {URL: "https://first.example/mcp", BearerTokenEnvVar: "OAC_RUNTIME_MCP_BEARER_FIRST"}, diff --git a/apps/daemon/internal/agent/codex/model_catalog_command_other.go b/apps/daemon/internal/agent/codex/model_catalog_command_other.go deleted file mode 100644 index 38a3fb6ae..000000000 --- a/apps/daemon/internal/agent/codex/model_catalog_command_other.go +++ /dev/null @@ -1,16 +0,0 @@ -//go:build !unix - -package codex - -import ( - "context" - "errors" - "os/exec" -) - -// SupportsTextVerbosity requires bounded cancellation of the catalog probe. -const SupportsTextVerbosity = false - -func modelCatalogCommand(context.Context, string, ...string) (*exec.Cmd, error) { - return nil, errors.New("codex: text verbosity requires Unix process-group cancellation support") -} diff --git a/apps/daemon/internal/agent/codex/model_catalog_command_unix.go b/apps/daemon/internal/agent/codex/model_catalog_command_unix.go deleted file mode 100644 index 949b3b86e..000000000 --- a/apps/daemon/internal/agent/codex/model_catalog_command_unix.go +++ /dev/null @@ -1,21 +0,0 @@ -//go:build unix - -package codex - -import ( - "context" - "os/exec" - "syscall" - "time" -) - -// SupportsTextVerbosity requires bounded cancellation of the catalog probe. -const SupportsTextVerbosity = true - -func modelCatalogCommand(ctx context.Context, binary string, args ...string) (*exec.Cmd, error) { - cmd := exec.CommandContext(ctx, binary, args...) - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) } - cmd.WaitDelay = time.Second - return cmd, nil -} diff --git a/apps/daemon/internal/agent/codex/model_catalog_command_unix_test.go b/apps/daemon/internal/agent/codex/model_catalog_command_unix_test.go deleted file mode 100644 index 79b74bf3a..000000000 --- a/apps/daemon/internal/agent/codex/model_catalog_command_unix_test.go +++ /dev/null @@ -1,56 +0,0 @@ -//go:build unix - -package codex - -import ( - "context" - "os" - "path/filepath" - "testing" - "time" -) - -func TestModelCatalogCancellationStopsDescendants(t *testing.T) { - for _, finish := range []string{"wait", "exit 0"} { - t.Run(finish, func(t *testing.T) { - checkCatalogDescendantCancellation(t, finish) - }) - } -} - -func checkCatalogDescendantCancellation(t *testing.T, finish string) { - t.Helper() - started := time.Now() - dir := t.TempDir() - binary := filepath.Join(dir, "codex") - if err := os.WriteFile(binary, []byte("#!/bin/sh\n(sleep 2; printf leaked > leaked) &\nprintf ready > ready\n"+finish+"\n"), 0700); err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - done := make(chan error, 1) - go func() { done <- prepareModelVerbosity(ctx, binary, &SessionPlan{Cwd: dir}) }() - deadline := time.Now().Add(3 * time.Second) - for { - if _, err := os.Stat(filepath.Join(dir, "ready")); err == nil { - break - } - if time.Now().After(deadline) { - t.Fatal("catalog launcher did not start") - } - time.Sleep(10 * time.Millisecond) - } - cancel() - select { - case err := <-done: - if err == nil { - t.Fatal("cancelled catalog probe succeeded") - } - case <-time.After(1500 * time.Millisecond): - t.Fatal("catalog probe kept waiting on child output pipes") - } - time.Sleep(time.Until(started.Add(2200 * time.Millisecond))) - if _, err := os.Stat(filepath.Join(dir, "leaked")); !os.IsNotExist(err) { - t.Fatalf("catalog child survived cancellation: %v", err) - } -} diff --git a/apps/daemon/internal/agent/codex/model_verbosity.go b/apps/daemon/internal/agent/codex/model_verbosity.go deleted file mode 100644 index 6c9d4f28e..000000000 --- a/apps/daemon/internal/agent/codex/model_verbosity.go +++ /dev/null @@ -1,106 +0,0 @@ -package codex - -import ( - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "slices" - "strings" -) - -// Validate against the binary's active catalog and use that same snapshot for -// execution. A CLI override alone is silently ignored for unsupported models. -func prepareModelVerbosity(ctx context.Context, binary string, plan *SessionPlan) error { - args := []string{} - for _, kv := range plan.ExtraConfig { - args = append(args, "-c", kv[0]+"="+kv[1]) - } - args = append(args, "debug", "models") - ctx, cancel := context.WithTimeout(ctx, rpcDefaultRequestTimeout) - defer cancel() - cmd, err := modelCatalogCommand(ctx, binary, args...) - if err != nil { - return err - } - cmd.Dir = plan.Cwd - cmd.Env = append(os.Environ(), plan.Env...) - catalog, err := cmd.Output() - // The launcher can exit before its children, ending the context watcher. - if cmd.Process != nil { - _ = cmd.Cancel() - } - if err != nil { - return fmt.Errorf("codex: cannot verify model verbosity support: %w", err) - } - supported, err := catalogSupportsVerbosity(catalog, plan.Model) - if err != nil { - return fmt.Errorf("codex: cannot read model verbosity support: %w", err) - } - if !supported { - if !slices.Contains(plan.ExtraConfig, [2]string{"model_verbosity", `"medium"`}) { - return fmt.Errorf("codex: model %q does not declare text verbosity support", plan.Model) - } - // Protocol medium means the default text amount, which needs no native override. - plan.ExtraConfig = slices.DeleteFunc(plan.ExtraConfig, func(kv [2]string) bool { return kv[0] == "model_verbosity" }) - } - codexHome := nativeHomeFromPlan(*plan) - if !filepath.IsAbs(codexHome) { - return fmt.Errorf("codex: missing managed home for model catalog") - } - file, err := os.CreateTemp(codexHome, "model-catalog-*.json") - if err != nil { - return err - } - _, writeErr := file.Write(catalog) - closeErr := file.Close() - if writeErr != nil || closeErr != nil { - _ = os.Remove(file.Name()) - if writeErr != nil { - return writeErr - } - return closeErr - } - cleanup := plan.Cleanup - plan.Cleanup = func() { _ = os.Remove(file.Name()); cleanup() } - plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"model_catalog_json", strconv(file.Name())}) - return nil -} - -func catalogSupportsVerbosity(raw []byte, model string) (bool, error) { - var catalog struct { - Models []struct { - Slug string `json:"slug"` - SupportVerbosity bool `json:"support_verbosity"` - } `json:"models"` - } - if err := json.Unmarshal(raw, &catalog); err != nil { - return false, err - } - // Match Codex models-manager's longest-prefix lookup, then its single, - // simple provider namespace fallback. Do not infer support from a model name. - match := func(name string) (bool, bool) { - longest, supported := 0, false - for _, candidate := range catalog.Models { - if len(candidate.Slug) > longest && strings.HasPrefix(name, candidate.Slug) { - longest, supported = len(candidate.Slug), candidate.SupportVerbosity - } - } - return supported, longest > 0 - } - if supported, found := match(model); found { - return supported, nil - } - namespace, suffix, found := strings.Cut(model, "/") - if !found || namespace == "" || strings.Contains(suffix, "/") { - return false, nil - } - for _, c := range namespace { - if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-') { - return false, nil - } - } - supported, _ := match(suffix) - return supported, nil -} diff --git a/apps/daemon/internal/agent/codex/model_verbosity_test.go b/apps/daemon/internal/agent/codex/model_verbosity_test.go deleted file mode 100644 index 83a65e502..000000000 --- a/apps/daemon/internal/agent/codex/model_verbosity_test.go +++ /dev/null @@ -1,118 +0,0 @@ -package codex - -import ( - "context" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -func TestCatalogVerbositySupport(t *testing.T) { - catalog := []byte(`{"models":[{"slug":"gpt-5","support_verbosity":true},{"slug":"gpt-5-special","support_verbosity":false}]}`) - for model, want := range map[string]bool{ - "gpt-5": true, "gpt-5.5": true, "provider/gpt-5.5": true, - "gpt-5-special": false, "provider/gpt-5-special": false, - "custom-provider-model": false, "": false, "a/b/gpt-5": false, "a b/gpt-5": false, - } { - got, err := catalogSupportsVerbosity(catalog, model) - if err != nil || got != want { - t.Errorf("%q: got %v, %v; want %v", model, got, err, want) - } - } - if _, err := catalogSupportsVerbosity([]byte(`{"models":false}`), "gpt-5"); err == nil { - t.Fatal("accepted malformed catalog") - } -} - -func TestPrepareModelVerbosity(t *testing.T) { - if !SupportsTextVerbosity { - t.Skip("catalog probe requires Unix") - } - t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - binary := filepath.Join(t.TempDir(), "codex") - catalog := `{"models":[{"slug":"known-model","support_verbosity":true,"native_extra":{"keep":true}}]}` - if err := os.WriteFile(binary, []byte("#!/bin/sh\nprintf '%s' '"+catalog+"'\n"), 0700); err != nil { - t.Fatal(err) - } - plan, err := BuildSessionPlan(testStateRoot(t), "state", map[string]any{"model": "known-model"}, &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "high"}) - if err != nil { - t.Fatal(err) - } - defer plan.Cleanup() - if err := prepareModelVerbosity(context.Background(), binary, &plan); err != nil { - t.Fatal(err) - } - kv := plan.ExtraConfig[len(plan.ExtraConfig)-1] - if kv[0] != "model_catalog_json" { - t.Fatal("catalog was not pinned") - } - name := strings.Trim(kv[1], `"`) - got, err := os.ReadFile(name) - if err != nil || string(got) != catalog { - t.Fatalf("catalog changed: %s, %v", got, err) - } - plan.Cleanup() - if _, err := os.Stat(name); !os.IsNotExist(err) { - t.Fatalf("catalog was not removed: %v", err) - } - plan.Model = "custom-provider-model" - if err := prepareModelVerbosity(context.Background(), binary, &plan); err == nil { - t.Fatal("accepted model that would ignore verbosity") - } - if err := prepareModelVerbosity(context.Background(), "/missing-codex", &plan); err == nil { - t.Fatal("accepted unreadable catalog") - } -} - -func TestPrepareDefaultModelVerbosity(t *testing.T) { - if !SupportsTextVerbosity { - t.Skip("catalog probe requires Unix") - } - t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - binary := filepath.Join(t.TempDir(), "codex") - catalog := `{"models":[{"slug":"supported","support_verbosity":true,"default_verbosity":"low"},{"slug":"unsupported","support_verbosity":false}]}` - if err := os.WriteFile(binary, []byte("#!/bin/sh\nprintf '%s' '"+catalog+"'\n"), 0700); err != nil { - t.Fatal(err) - } - for _, model := range []string{"supported", "unsupported", "unknown-provider-model"} { - for _, level := range []string{"low", "medium", "high"} { - t.Run(model+"/"+level, func(t *testing.T) { - plan, err := BuildSessionPlan(testStateRoot(t), "state", map[string]any{"model": model}, &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: level}) - if err != nil { - t.Fatal(err) - } - defer func() { plan.Cleanup() }() - err = prepareModelVerbosity(context.Background(), binary, &plan) - if model != "supported" && level != "medium" { - if err == nil { - t.Fatal("accepted unsupported non-default verbosity") - } - return - } - if err != nil { - t.Fatal(err) - } - found := false - for _, kv := range plan.ExtraConfig { - if kv[0] == "model_verbosity" { - found = true - if kv[1] != `"`+level+`"` { - t.Fatal("configured verbosity changed", kv) - } - } - } - if found != (model == "supported") || plan.Model != model { - t.Fatal("native default or model identity changed", plan.ExtraConfig, plan.Model) - } - kv := plan.ExtraConfig[len(plan.ExtraConfig)-1] - raw, err := os.ReadFile(strings.Trim(kv[1], `"`)) - if kv[0] != "model_catalog_json" || err != nil || string(raw) != catalog { - t.Fatal("native catalog snapshot changed", err) - } - }) - } - } -} diff --git a/apps/daemon/internal/agent/codex/options.go b/apps/daemon/internal/agent/codex/options.go index 6c285b2b0..bd5b29a9a 100644 --- a/apps/daemon/internal/agent/codex/options.go +++ b/apps/daemon/internal/agent/codex/options.go @@ -6,7 +6,6 @@ import ( "fmt" "os" "path/filepath" - "slices" "strings" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" @@ -68,7 +67,7 @@ type SessionPlan struct { // model_provider object frozen upstream protocol, endpoint, credential and token limits // harness_config object native parameters accepted by harnessconfig/codex // -// Execution controls select the native web_search and model_verbosity settings. +// Execution controls map one to one to native settings; see applyExecutionControls. // The codex binary itself is resolved via PATH only. func BuildSessionPlan(stateRoot, agentStateKey string, opts map[string]any, controls *proto.ExecutionControls) (SessionPlan, error) { plan := SessionPlan{ @@ -80,8 +79,7 @@ func BuildSessionPlan(stateRoot, agentStateKey string, opts map[string]any, cont Cleanup: func() {}, } - nativeConfig, err := harnessconfiguration.Declaration.Configuration.PrepareHarnessConfig(opts) - if err != nil { + if _, err := harnessconfiguration.Declaration.Configuration.PrepareHarnessConfig(opts); err != nil { return plan, err } @@ -89,13 +87,8 @@ func BuildSessionPlan(stateRoot, agentStateKey string, opts map[string]any, cont return plan, errors.New("codex: model and complete model_provider are required") } - if controls != nil { - if !slices.Contains([]string{"disabled", "cached", "live"}, controls.WebSearch) { - return plan, errors.New("codex: web_search must be disabled, cached or live") - } - if !slices.Contains([]string{"low", "medium", "high"}, controls.TextVerbosity) { - return plan, errors.New("codex: text_verbosity must be low, medium or high") - } + if err := harnessconfiguration.Declaration.ValidateExecutionControls(controls); err != nil { + return plan, err } plan.Model = stringOpt(opts, "model") @@ -108,7 +101,7 @@ func BuildSessionPlan(stateRoot, agentStateKey string, opts map[string]any, cont if err := resetGeneratedConfig(codexHome); err != nil { return plan, err } - env := []string{"DISABLE_TELEMETRY=1", "CODEX_HOME=" + codexHome} + env := []string{"CODEX_HOME=" + codexHome} provider, hasProvider, err := normaliseProviderConfig(opts["model_provider"]) if err != nil { @@ -127,13 +120,7 @@ func BuildSessionPlan(stateRoot, agentStateKey string, opts map[string]any, cont } plan.Env = env - if controls != nil { - plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"web_search", strconv(controls.WebSearch)}, [2]string{"model_verbosity", strconv(controls.TextVerbosity)}) - } - if effort, ok := nativeConfig["model_reasoning_effort"].(string); ok { - plan.ModelReasoningEffort = effort - plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"model_reasoning_effort", strconv(effort)}) - } + applyExecutionControls(&plan, controls) if plan.ModelProvider != "" { // Pin model_provider at the CLI layer so codex skips its builtin // "openai" provider — without this the [model_providers.oac] @@ -145,6 +132,50 @@ func BuildSessionPlan(stateRoot, agentStateKey string, opts map[string]any, cont return plan, nil } +// applyExecutionControls maps each official value to its native setting. An +// absent control sends nothing, so Codex keeps its native default; Codex itself +// ignores a value the selected model does not support. +func applyExecutionControls(plan *SessionPlan, controls *proto.ExecutionControls) { + if controls == nil { + return + } + set := func(key, value string) { plan.ExtraConfig = append(plan.ExtraConfig, [2]string{key, value}) } + if search := controls.WebSearch; search != nil { + set("web_search", strconv(search.Mode)) + set("tools.web_search.context_size", strconv(search.ContextSize)) + if search.AllowedDomains != nil { + quoted := make([]string, len(search.AllowedDomains)) + for i, domain := range search.AllowedDomains { + quoted[i] = strconv(domain) + } + set("tools.web_search.allowed_domains", "["+strings.Join(quoted, ",")+"]") + } + if location := search.Location; location != nil { + for _, field := range []struct { + key string + value *string + }{{"city", location.City}, {"country", location.Country}, {"region", location.Region}, {"timezone", location.Timezone}} { + if field.value != nil { + set("tools.web_search.location."+field.key, strconv(*field.value)) + } + } + } + } + if controls.ReasoningEffort != "" { + plan.ModelReasoningEffort = controls.ReasoningEffort + set("model_reasoning_effort", strconv(controls.ReasoningEffort)) + } + if controls.ReasoningSummary != "" { + set("model_reasoning_summary", strconv(controls.ReasoningSummary)) + } + if controls.ServiceTier != "" { + set("service_tier", strconv(controls.ServiceTier)) + } + if controls.TextVerbosity != "" { + set("model_verbosity", strconv(controls.TextVerbosity)) + } +} + // --------------------------------------------------------------------------- // helpers // --------------------------------------------------------------------------- diff --git a/apps/daemon/internal/agent/codex/options_test.go b/apps/daemon/internal/agent/codex/options_test.go index eb64d84de..a63c5db19 100644 --- a/apps/daemon/internal/agent/codex/options_test.go +++ b/apps/daemon/internal/agent/codex/options_test.go @@ -28,21 +28,17 @@ func TestBuildSessionPlan_AllocsCodexHomeAndEnv(t *testing.T) { } defer plan.Cleanup() hasCodexHome := false - hasTelemetry := false for _, kv := range plan.Env { switch { case strings.HasPrefix(kv, "CODEX_HOME="): hasCodexHome = true - case kv == "DISABLE_TELEMETRY=1": - hasTelemetry = true + case strings.HasPrefix(kv, "DISABLE_TELEMETRY="): + t.Fatalf("env sets a variable Codex does not read: %+v", plan.Env) } } if !hasCodexHome { t.Fatalf("env missing CODEX_HOME: %+v", plan.Env) } - if !hasTelemetry { - t.Fatalf("env missing DISABLE_TELEMETRY: %+v", plan.Env) - } } func TestBuildSessionPlan_StableCodexHomeByStateKey(t *testing.T) { diff --git a/apps/daemon/internal/agent/codex/preparation.go b/apps/daemon/internal/agent/codex/preparation.go index 0098920c0..9c299de8b 100644 --- a/apps/daemon/internal/agent/codex/preparation.go +++ b/apps/daemon/internal/agent/codex/preparation.go @@ -84,8 +84,8 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg if _, err := rpc.Start(cancelCtx, initParams); err != nil { return p.preparationFailed(fmt.Errorf("codex: rpc start: %w", err)) } - if req.ExecutionControls != nil && req.ExecutionControls.DisableProgrammaticToolCalling { - if err := verifyProgrammaticToolsDisabled(cancelCtx, rpc); err != nil { + if req.ExecutionControls != nil && req.ExecutionControls.ProgrammaticToolCalling != nil { + if err := verifyProgrammaticTools(cancelCtx, rpc, *req.ExecutionControls.ProgrammaticToolCalling); err != nil { return p.preparationFailed(err) } } diff --git a/apps/daemon/internal/agent/codex/preparation_helpers_test.go b/apps/daemon/internal/agent/codex/preparation_helpers_test.go index 1472c3a5d..780febc73 100644 --- a/apps/daemon/internal/agent/codex/preparation_helpers_test.go +++ b/apps/daemon/internal/agent/codex/preparation_helpers_test.go @@ -42,7 +42,7 @@ func preparationFixture(t *testing.T) (proto.PromptRequestPayload, sessionConfig req := proto.PromptRequestPayload{ AgentKind: "codex", AgentStateKey: "prepared-session", AgentOptions: map[string]any{"model": "fixture-model"}, - ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, + ExecutionControls: &proto.ExecutionControls{TextVerbosity: "medium"}, DisableExecutionEnvironment: true, FunctionTools: []proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{"type":"object","properties":{"value":{"type":"integer"}}}`)}}, } diff --git a/apps/daemon/internal/agent/codex/programmatic_tools.go b/apps/daemon/internal/agent/codex/programmatic_tools.go index c2da9f9a1..52dac4668 100644 --- a/apps/daemon/internal/agent/codex/programmatic_tools.go +++ b/apps/daemon/internal/agent/codex/programmatic_tools.go @@ -11,8 +11,17 @@ import ( var programmaticFeatures = []string{"code_mode", "code_mode_only", "code_mode_prewarm"} -func disableProgrammaticTools(plan *SessionPlan, controls *proto.ExecutionControls) { - if controls == nil || !controls.DisableProgrammaticToolCalling { +// applyProgrammaticTools maps the official programmatic_tool_calling.enabled +// value to Codex code mode, whose model-written JavaScript calls the other +// tools. Disabling also turns off its dependent features. Omission keeps the +// native default. +func applyProgrammaticTools(plan *SessionPlan, controls *proto.ExecutionControls) { + if controls == nil || controls.ProgrammaticToolCalling == nil { + return + } + if *controls.ProgrammaticToolCalling { + plan.EnableFeatures = append(plan.EnableFeatures, "code_mode") + plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"features.code_mode", "true"}) return } for _, feature := range programmaticFeatures { @@ -25,7 +34,7 @@ func disableProgrammaticTools(plan *SessionPlan, controls *proto.ExecutionContro // Managed native requirements can override command-line feature settings. // Check before starting or resuming a native Session, not after model execution. -func verifyProgrammaticToolsDisabled(ctx context.Context, rpc *JSONRPCClient) error { +func verifyProgrammaticTools(ctx context.Context, rpc *JSONRPCClient, enabled bool) error { raw, err := rpc.Request(ctx, "configRequirements/read", nil) var response struct { Requirements json.RawMessage `json:"requirements"` @@ -40,6 +49,12 @@ func verifyProgrammaticToolsDisabled(ctx context.Context, rpc *JSONRPCClient) er if json.Unmarshal(response.Requirements, &requirements) != nil { return errors.New("codex: invalid programmatic tool requirements") } + if enabled { + if required, pinned := requirements.Features["code_mode"]; pinned && !required { + return errors.New("codex: required programmatic tools conflict with explicit enabling") + } + return nil + } for _, feature := range programmaticFeatures { if requirements.Features[feature] { return errors.New("codex: required programmatic tools conflict with explicit disabling") diff --git a/apps/daemon/internal/agent/codex/programmatic_tools_test.go b/apps/daemon/internal/agent/codex/programmatic_tools_test.go index fd0aeee8a..920d58720 100644 --- a/apps/daemon/internal/agent/codex/programmatic_tools_test.go +++ b/apps/daemon/internal/agent/codex/programmatic_tools_test.go @@ -3,6 +3,7 @@ package codex import ( "context" "encoding/json" + "fmt" "slices" "testing" "time" @@ -10,13 +11,19 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -func TestProgrammaticToolsExplicitDisableOverridesNativeOptions(t *testing.T) { +func TestProgrammaticToolsFollowTheOfficialValue(t *testing.T) { + enabled, disabled := true, false plan := SessionPlan{ExtraConfig: [][2]string{{"features.code_mode", "true"}}} - disableProgrammaticTools(&plan, nil) - if len(plan.DisableFeatures) != 0 || len(plan.ExtraConfig) != 1 { + applyProgrammaticTools(&plan, nil) + applyProgrammaticTools(&plan, &proto.ExecutionControls{}) + if len(plan.DisableFeatures) != 0 || len(plan.EnableFeatures) != 0 || len(plan.ExtraConfig) != 1 { t.Fatal("omission changed native configuration") } - disableProgrammaticTools(&plan, &proto.ExecutionControls{DisableProgrammaticToolCalling: true}) + applyProgrammaticTools(&plan, &proto.ExecutionControls{ProgrammaticToolCalling: &enabled}) + if !slices.Equal(plan.EnableFeatures, []string{"code_mode"}) || len(plan.DisableFeatures) != 0 || plan.ExtraConfig[len(plan.ExtraConfig)-1] != [2]string{"features.code_mode", "true"} { + t.Fatal("enabling did not select code mode", plan) + } + applyProgrammaticTools(&plan, &proto.ExecutionControls{ProgrammaticToolCalling: &disabled}) for _, feature := range programmaticFeatures { if !slices.Contains(plan.DisableFeatures, feature) { t.Fatal("missing disable", feature) @@ -36,22 +43,26 @@ func TestProgrammaticToolsExplicitDisableOverridesNativeOptions(t *testing.T) { func TestProgrammaticToolsRejectManagedOverridesBeforeExecution(t *testing.T) { for _, tc := range []struct { response string + enabled bool accepted bool }{ - {`{"requirements":null}`, true}, - {`{"requirements":{"featureRequirements":{"code_mode":false,"unrelated":true}}}`, true}, - {`{"requirements":{"featureRequirements":{"code_mode":true}}}`, false}, - {`{"requirements":{"featureRequirements":{"code_mode_only":true}}}`, false}, - {`{"requirements":{"featureRequirements":{"code_mode_prewarm":true}}}`, false}, - {`{}`, false}, {`null`, false}, {`{"requirements":[]}`, false}, + {`{"requirements":null}`, false, true}, + {`{"requirements":{"featureRequirements":{"code_mode":false,"unrelated":true}}}`, false, true}, + {`{"requirements":{"featureRequirements":{"code_mode":true}}}`, false, false}, + {`{"requirements":{"featureRequirements":{"code_mode_only":true}}}`, false, false}, + {`{"requirements":{"featureRequirements":{"code_mode_prewarm":true}}}`, false, false}, + {`{}`, false, false}, {`null`, false, false}, {`{"requirements":[]}`, false, false}, + {`{"requirements":null}`, true, true}, + {`{"requirements":{"featureRequirements":{"code_mode":true}}}`, true, true}, + {`{"requirements":{"featureRequirements":{"code_mode":false}}}`, true, false}, } { - t.Run(tc.response, func(t *testing.T) { + t.Run(fmt.Sprint(tc.enabled, tc.response), func(t *testing.T) { client, server, cleanup := NewTestClient() defer cleanup() ctx, cancel := context.WithTimeout(t.Context(), time.Second) defer cancel() done := make(chan error, 1) - go func() { done <- verifyProgrammaticToolsDisabled(ctx, client.JSONRPCClient) }() + go func() { done <- verifyProgrammaticTools(ctx, client.JSONRPCClient, tc.enabled) }() var request struct{ ID, Method string } if err := json.NewDecoder(server.FromClient).Decode(&request); err != nil { t.Fatal(err) diff --git a/apps/daemon/internal/agent/codex/session_plan.go b/apps/daemon/internal/agent/codex/session_plan.go index b917805f4..e627fa585 100644 --- a/apps/daemon/internal/agent/codex/session_plan.go +++ b/apps/daemon/internal/agent/codex/session_plan.go @@ -28,7 +28,7 @@ func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg plan.Cleanup() return SessionPlan{}, nil, err } - disableProgrammaticTools(&plan, req.ExecutionControls) + applyProgrammaticTools(&plan, req.ExecutionControls) if req.LocalEnvironment != nil { plan.Cwd = req.LocalEnvironment.WorkspaceRoot } else { @@ -60,13 +60,6 @@ func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg } } - if req.ExecutionControls != nil { - if err := prepareModelVerbosity(ctx, cfg.codexBinary, &plan); err != nil { - plan.Cleanup() - return SessionPlan{}, nil, err - } - } - if req.DisableExecutionEnvironment { plan.Env = append(plan.Env, "CODEX_EXEC_SERVER_URL=none") } diff --git a/apps/daemon/internal/agent/mcode/execution.go b/apps/daemon/internal/agent/mcode/execution.go index e35d927a2..a2204e470 100644 --- a/apps/daemon/internal/agent/mcode/execution.go +++ b/apps/daemon/internal/agent/mcode/execution.go @@ -5,15 +5,19 @@ import ( "os" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + declaration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/mcode" ) func validateExecutionRequest(req proto.PromptRequestPayload) error { if !req.DisableExecutionEnvironment || req.AgentStateKey == "" || req.LocalEnvironment != nil || req.RequireExistingNativeSession || len(req.FunctionTools) != 0 || (req.MCPHTTPServers != nil && len(*req.MCPHTTPServers) != 0) { return fmt.Errorf("mcode: unsupported execution configuration") } - if req.ExecutionControls == nil || req.ExecutionControls.OutputFormat != nil || req.ExecutionControls.WebSearch != "disabled" || (req.ExecutionControls.TextVerbosity != "" && req.ExecutionControls.TextVerbosity != "medium") { + if req.ExecutionControls == nil || req.ExecutionControls.OutputFormat != nil { return fmt.Errorf("mcode: unsupported execution controls") } + if err := declaration.Declaration.ValidateExecutionControls(req.ExecutionControls); err != nil { + return err + } if !req.DisableSubagents { if req.MaxConcurrentSubagents == nil || *req.MaxConcurrentSubagents < 1 { return fmt.Errorf("mcode: Subagent concurrency limit is required") @@ -28,6 +32,7 @@ func validateExecutionRequest(req proto.PromptRequestPayload) error { func configureTextExecution(config map[string]any) { config["agents"] = map[string]any{"default": map[string]any{ "tools": []string{}, "builtinTools": []string{}, "skills": []string{}, + // Native web search calls MiniMax's cloud with a MiniMax account credential that Core does not hold. "features": map[string]bool{"mavis": false, "delegation": false, "webSearch": false}, }} config["askUser"] = map[string]bool{"enabled": false} diff --git a/apps/daemon/internal/agent/mcode/execution_test.go b/apps/daemon/internal/agent/mcode/execution_test.go index afa6b0ad1..9e8860680 100644 --- a/apps/daemon/internal/agent/mcode/execution_test.go +++ b/apps/daemon/internal/agent/mcode/execution_test.go @@ -44,7 +44,9 @@ func TestExecutionRejectsUnqualifiedAuthority(t *testing.T) { func(r *proto.PromptRequestPayload) { r.DisableSubagents = false }, func(r *proto.PromptRequestPayload) { r.RequireExistingNativeSession = true }, func(r *proto.PromptRequestPayload) { r.FunctionTools = []proto.FunctionTool{{Name: "f"}} }, - func(r *proto.PromptRequestPayload) { r.ExecutionControls.WebSearch = "enabled" }, + func(r *proto.PromptRequestPayload) { + r.ExecutionControls.WebSearch = &proto.WebSearchControl{Mode: "live", ContextSize: "medium"} + }, } { r := testRequest(t) change(&r) diff --git a/apps/daemon/internal/agent/mcode/session_test.go b/apps/daemon/internal/agent/mcode/session_test.go index 044a1bb35..9f4726fe0 100644 --- a/apps/daemon/internal/agent/mcode/session_test.go +++ b/apps/daemon/internal/agent/mcode/session_test.go @@ -18,7 +18,7 @@ func testRequest(t *testing.T) proto.PromptRequestPayload { t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) return proto.PromptRequestPayload{RunID: "run-1", AgentStateKey: "conversation-1/agent-1/mcode", Input: proto.TextInput("Hello"), AgentOptions: map[string]any{ "model": "fixture", "model_provider": map[string]any{"protocol": "anthropic", "base_url": "https://provider.example/v1", "api_key": "fixture-key", "context_window": 64000, "max_output_tokens": 4096}, "system_prompt": "Current instructions", - }, DisableExecutionEnvironment: true, DisableSubagents: true, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}} + }, DisableExecutionEnvironment: true, DisableSubagents: true, ExecutionControls: &proto.ExecutionControls{TextVerbosity: "medium"}} } // helperRequest selects a protocol fixture scenario as the native CLI. diff --git a/apps/daemon/internal/cli/claude_sdk_live_linux_test.go b/apps/daemon/internal/cli/claude_sdk_live_linux_test.go index 283a34291..8d2f33f17 100644 --- a/apps/daemon/internal/cli/claude_sdk_live_linux_test.go +++ b/apps/daemon/internal/cli/claude_sdk_live_linux_test.go @@ -87,7 +87,7 @@ func TestLiveRegisteredClaudeSDK(t *testing.T) { ctx, cancel := context.WithTimeout(t.Context(), 120*time.Second) defer cancel() id, prepare := uuid.NewString(), uuid.NewString() - request := proto.PromptRequestPayload{AgentKind: "claude_sdk", AgentStateKey: "agents-api-registered-acceptance", AgentSessionID: resume, ObserveMessages: true, DisableExecutionEnvironment: true, DisableSubagents: true, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, AgentOptions: map[string]any{"model": "MiniMax-M3", "system_prompt": nil}} + request := proto.PromptRequestPayload{AgentKind: "claude_sdk", AgentStateKey: "agents-api-registered-acceptance", AgentSessionID: resume, ObserveMessages: true, DisableExecutionEnvironment: true, DisableSubagents: true, ExecutionControls: &proto.ExecutionControls{TextVerbosity: "medium"}, AgentOptions: map[string]any{"model": "MiniMax-M3", "system_prompt": nil}} if callFunction { request.FunctionTools = []proto.FunctionTool{{Name: "lookup", Description: "Return a verification value.", Parameters: json.RawMessage(`{"type":"object","properties":{"id":{"type":"string"}},"required":["id"],"additionalProperties":false}`)}} } diff --git a/apps/daemon/internal/dispatch/environment.go b/apps/daemon/internal/dispatch/environment.go index 74de20411..5c120ca87 100644 --- a/apps/daemon/internal/dispatch/environment.go +++ b/apps/daemon/internal/dispatch/environment.go @@ -13,9 +13,6 @@ func validateExecutionEnvironment(req proto.PromptRequestPayload, caps proto.Age if err := req.ValidateAgentOptions(); err != nil { return err } - if err := req.ValidateProgrammaticToolCallingDisable(caps.ProgrammaticToolCallingDisable.IsSupported()); err != nil { - return err - } if err := req.ValidateToolSearch(caps.ToolSearch.IsSupported()); err != nil { return err } diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index e662bc01a..7606bac36 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -554,7 +554,7 @@ function providerProblem(harness, input) { * DELETE is 204 and safe to repeat. A disabled harness may still be configured. */ async function harnessRoute(request, response, path) { - const support = (id) => ({ protocols: id === "codex" ? ["responses"] : id === "claude_sdk" ? ["anthropic"] : ["anthropic", "responses", "chat_completions"], accepts_harness_config: id !== "mcode", token_limits_required: id === "mcode" }); + const support = (id) => ({ protocols: id === "codex" ? ["responses"] : id === "claude_sdk" ? ["anthropic"] : ["anthropic", "responses", "chat_completions"], accepts_harness_config: id === "claude_sdk", token_limits_required: id === "mcode" }); const view = (id) => ({ model_configuration_support: support(id), object: "core.harness", id, enabled: state.harnesses[id].enabled, default: state.harnesses[id].default, model_configuration: state.harnesses[id].provider }); if (path === "/harnesses" && request.method === "GET") return send(response, 200, { object: "list", data: Object.keys(state.harnesses).map(view) }); const match = path.match(HARNESS_PROVIDER); diff --git a/apps/web/e2e/system.spec.ts b/apps/web/e2e/system.spec.ts index 052e2eb98..4e6d6eefa 100644 --- a/apps/web/e2e/system.spec.ts +++ b/apps/web/e2e/system.spec.ts @@ -38,16 +38,9 @@ test("sets, replaces and clears a harness's default model configuration, and kee } expect(await writes(request)).toEqual([]); await set.getByLabel("Base URL").fill("https://model.example/v1"); - await expect(set.getByLabel("Harness configuration (JSON)")).toBeHidden(); + // Codex accepts no native Harness configuration. await set.getByText("Advanced settings", { exact: true }).click(); - const config = set.getByLabel("Harness configuration (JSON)"); - await config.fill("[]"); - await config.blur(); - await expect(set.getByText("Enter a valid JSON object, such as {}.")).toBeVisible(); - await expect(set.getByRole("button", { name: "Save", exact: true })).toBeDisabled(); - await config.fill('{"model_reasoning_effort":"high"}'); - await set.getByRole("button", { name: "Format JSON" }).click(); - await expect(config).toHaveValue('{\n "model_reasoning_effort": "high"\n}'); + await expect(set.getByLabel("Harness configuration (JSON)")).toHaveCount(0); // Limits are Core's 32-bit whole numbers, and max output needs a context window at least as large. await set.getByLabel("Max output tokens").fill("32000"); await expect(set.getByText("Set a context window at least this large.")).toBeVisible(); @@ -70,30 +63,19 @@ test("sets, replaces and clears a harness's default model configuration, and kee let replace = page.getByRole("dialog", { name: "Replace default model configuration for Codex" }); await expect(replace.getByRole("combobox", { name: "Protocol", exact: true })).toHaveText("OpenAI Responses"); await expect(replace.getByLabel("Default model ID")).toHaveValue("fixture-model"); - await expect(replace.getByLabel("Harness configuration (JSON)")).toHaveValue(/model_reasoning_effort/); await expect(replace.getByLabel("Base URL")).toHaveValue("https://model.example/v1"); await expect(replace.getByLabel("API key")).toHaveValue(""); await expect(replace.getByRole("button", { name: "Save" })).toBeDisabled(); await replace.getByLabel("API key").fill(KEY); - const nativeSettings = replace.getByLabel("Harness configuration (JSON)"); - await expect(nativeSettings).toHaveValue(/model_reasoning_effort/); - await replace.getByLabel("Default model ID").fill("different-model"); - await expect(nativeSettings).toHaveValue("{}"); - await nativeSettings.fill('{"model_reasoning_effort":"low"}'); - await replace.getByLabel("Base URL").fill("https://another-model.example/v1"); - await expect(nativeSettings).toHaveValue("{}"); - await nativeSettings.fill('{"model_reasoning_effort":"high"}'); await replace.getByRole("button", { name: "Cancel" }).click(); expect(await page.content()).not.toContain(KEY); await codex.getByRole("button", { name: "Replace the default model configuration for Codex" }).click(); replace = page.getByRole("dialog", { name: "Replace default model configuration for Codex" }); await expect(replace.getByRole("combobox", { name: "Protocol", exact: true })).toHaveText("OpenAI Responses"); await expect(replace.getByLabel("Default model ID")).toHaveValue("fixture-model"); - await expect(replace.getByLabel("Harness configuration (JSON)")).toHaveValue(/model_reasoning_effort/); await expect(replace.getByLabel("API key")).toHaveValue(""); await replace.getByLabel("Base URL").fill("https://model.example/v2"); await replace.getByLabel("API key").fill(KEY); - await expect(replace.getByLabel("Harness configuration (JSON)")).toHaveValue("{}"); // Enter saves. await replace.getByLabel("API key").press("Enter"); await expect(replace).toBeHidden(); @@ -106,6 +88,39 @@ test("sets, replaces and clears a harness's default model configuration, and kee await expect(confirm).toBeHidden(); await expect(codex).toContainText("Model provider not set"); + // Claude Code accepts the native `thinking` configuration: invalid JSON blocks saving, and the values follow the model and URL they were set for. + await section.getByRole("article", { name: "Claude Code" }).getByRole("button", { name: "Set the default model configuration for Claude Code" }).click(); + const claude = page.getByRole("dialog", { name: "Set default model configuration for Claude Code" }); + await claude.getByRole("combobox", { name: "Protocol", exact: true }).click(); + await page.getByRole("option", { name: "Anthropic Messages", exact: true }).click(); + await claude.getByLabel("Base URL").fill("https://model.example/anthropic"); + await claude.getByLabel("API key").fill(KEY); + await claude.getByLabel("Default model ID").fill("fixture-model"); + await expect(claude.getByLabel("Harness configuration (JSON)")).toBeHidden(); + await claude.getByText("Advanced settings", { exact: true }).click(); + const config = claude.getByLabel("Harness configuration (JSON)"); + await config.fill("[]"); + await config.blur(); + await expect(claude.getByText("Enter a valid JSON object, such as {}.")).toBeVisible(); + await expect(claude.getByRole("button", { name: "Save", exact: true })).toBeDisabled(); + await config.fill('{"thinking":{"type":"adaptive"}}'); + await claude.getByRole("button", { name: "Format JSON" }).click(); + await expect(config).toHaveValue('{\n "thinking": {\n "type": "adaptive"\n }\n}'); + await claude.getByRole("button", { name: "Save", exact: true }).click(); + await expect(claude).toBeHidden(); + await section.getByRole("article", { name: "Claude Code" }).getByRole("button", { name: "Replace the default model configuration for Claude Code" }).click(); + const claudeReplace = page.getByRole("dialog", { name: "Replace default model configuration for Claude Code" }); + const saved = claudeReplace.getByLabel("Harness configuration (JSON)"); + await expect(saved).toHaveValue(/adaptive/); + await claudeReplace.getByLabel("API key").fill(KEY); + await claudeReplace.getByLabel("Default model ID").fill("different-model"); + await expect(saved).toHaveValue("{}"); + await saved.fill('{"thinking":{"type":"disabled"}}'); + await claudeReplace.getByLabel("Base URL").fill("https://another-model.example/anthropic"); + await expect(saved).toHaveValue("{}"); + await claudeReplace.getByRole("button", { name: "Cancel" }).click(); + expect(await page.content()).not.toContain(KEY); + // MiniMax Code needs both limits; the form shows Core's reason. A disabled harness may still be configured. await mcode.getByRole("button", { name: "Set the default model configuration for MiniMax Code" }).click(); const limits = page.getByRole("dialog", { name: "Set default model configuration for MiniMax Code" }); @@ -124,6 +139,7 @@ test("sets, replaces and clears a harness's default model configuration, and kee "PUT /core/v1/harnesses/codex/model-configuration", "PUT /core/v1/harnesses/codex/model-configuration", "DELETE /core/v1/harnesses/codex/model-configuration", + "PUT /core/v1/harnesses/claude_sdk/model-configuration", ]); const browserState = await page.evaluate(() => JSON.stringify({ local: { ...localStorage }, session: { ...sessionStorage }, url: location.href, cookie: document.cookie })); expect(browserState).not.toContain(KEY); @@ -154,7 +170,6 @@ test("requires an explicit supported protocol choice for an older default config const data = await response.json(); const codex = data.data.find((harness: { id: string }) => harness.id === "codex"); codex.model_configuration.model_provider.protocol = "chat_completions"; - codex.model_configuration.harness_config = { model_reasoning_effort: "high" }; await route.fulfill({ response, json: data }); }); await openConsole(page, request, "settings?id=models"); @@ -173,7 +188,7 @@ test("requires an explicit supported protocol choice for an older default config await expect(page.getByRole("option")).toHaveText(["OpenAI Responses"]); await page.getByRole("option", { name: "OpenAI Responses", exact: true }).click(); await expect(dialog.getByRole("alert")).toHaveCount(0); - await expect(dialog.getByLabel("Harness configuration (JSON)")).toHaveValue("{}"); + await expect(dialog.getByLabel("Harness configuration (JSON)")).toHaveCount(0); await expect(dialog.getByRole("button", { name: "Save", exact: true })).toBeEnabled(); expect(await writes(request)).toEqual([]); }); diff --git a/contracts/agents-api/admin-api.md b/contracts/agents-api/admin-api.md index 1c027750a..907d9e989 100644 --- a/contracts/agents-api/admin-api.md +++ b/contracts/agents-api/admin-api.md @@ -142,7 +142,7 @@ After an uncertain `POST` response, `GET` the archive before writing again. Repe "session_id": "013773a9-44b9-4f84-baca-b51c04a01201", "model": {"value": "requested-model", "source": "session"}, "harness": {"value": "codex", "source": "agent"}, - "harness_config": {"value": {"model_reasoning_effort": "high"}, "source": "agent"}, + "harness_config": {"value": {"thinking": {"type": "adaptive"}}, "source": "agent"}, "model_provider": { "source": "agent", "status": "available", diff --git a/contracts/agents-api/execution-tools.md b/contracts/agents-api/execution-tools.md index 074ad8008..683a5c41d 100644 --- a/contracts/agents-api/execution-tools.md +++ b/contracts/agents-api/execution-tools.md @@ -60,15 +60,15 @@ Core sends `PromptRequestPayload.ToolSearch` and each `FunctionTool.DeferLoading Saved Agents keep every pinned `web_search` mode: omitted or null is saved as `live`, and `cached` and `live` as sent ([saved modes](./wire-semantics.md#saved-configuration)). Search settings are resource data: omitted or null `context_size` resolves to `medium`; omitted domains and location resolve to null; an empty domain list stays empty; a supplied location, including `{}`, has `city`, `country`, `region` and `timezone`, null where omitted. -Execution admits only `mode: "disabled"` and `enabled: false`. Enabled or omitted-mode search and enabled or omitted-`enabled` programmatic calling are rejected at Session admission unless the Session replaces the saved tools. Omitting programmatic configuration keeps each Harness's native behavior, which differs from the official default-on behavior. Unrelated native utility tools are not removed. +Execution carries the resolved values to the Harness and validates them against its declaration in `internal/harnessconfig` (`Execution`): the supported web search modes and whether search settings are accepted, reasoning efforts and summaries, service tiers, programmatic-tool-calling values and text verbosities. A value outside the declaration is rejected at Session admission with a message naming the field, unless the Session replaces the saved tools. Core sends an option only when the Agent sets it or the pinned default applies, so an omitted `web_search` or `programmatic_tool_calling` keeps each Harness's native behavior. For programmatic calling this differs from the official default-on behavior. Unrelated native utility tools are not removed. -`DisableProgrammaticToolCalling` carries the disabled intent on initial execution and cold continuation and requires the Runtime capability only when present. Search uses the existing disabled control. +`ExecutionControls` carries the options ([Runtime protocol](../../docs/runtime-protocol.md#capability-declarations)). The Runtime needs only the `execution_controls` capability. -| Harness | Native enforcement | -| --- | --- | -| Codex | Disables code-mode features; checks native managed requirements before thread start or resume and rejects a forced conflicting feature | -| Claude Code | Keeps the restricted built-in inventory and verifies native initialization against it | -| MiniMax Code | Keeps the restricted native tool profile, an empty text-execution inventory and disabled web search | +| Harness | Web search | Programmatic tool calling | +| --- | --- | --- | +| Codex | `disabled`, `cached` and `live`, with `context_size`, `allowed_domains` and `location`, as native `web_search` and `tools.web_search` settings | `enabled: true` turns on native `code_mode`; `false` turns off `code_mode`, `code_mode_only` and `code_mode_prewarm` and checks native managed requirements before a thread starts or resumes | +| Claude Code | `disabled` only, with the restricted built-in inventory verified at native initialization | `false` only | +| MiniMax Code | `disabled` only, through the native `features.webSearch=false` because the restricted profile has no search tool | `false` only | ## HTTP MCP diff --git a/contracts/agents-api/harness-capabilities.md b/contracts/agents-api/harness-capabilities.md index b0186282a..26f473ab7 100644 --- a/contracts/agents-api/harness-capabilities.md +++ b/contracts/agents-api/harness-capabilities.md @@ -21,7 +21,9 @@ Placements are `none` (no Environment), hosted (`openai_hosted`) and self-hosted | [Whitespace-only message text](./message-content.md) | Admitted; delivered unchanged | Rejected | Rejected | | [Inline PNG and JPEG message images](./message-content.md) | Verified on all placements | Verified on all placements | Rejected | | Remote image URLs | Rejected | Rejected | Rejected | -| Explicit `reasoning`; `service_tier` other than `auto` | Rejected | Rejected | Rejected | +| `reasoning.effort` | `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`: admitted | `low`, `medium`, `high`, `xhigh`, `max`: admitted | Rejected | +| `reasoning.summary` | `concise`, `detailed`, `auto`: admitted | Rejected | Rejected | +| `service_tier` other than `auto` | `default`, `flex`, `priority`: admitted | Rejected | Rejected | | `text.verbosity` other than `medium` | Admitted; the native model decides | Rejected | Rejected | | [Public token usage](./sessions-events.md) | Measured counters | Null | Null | @@ -36,7 +38,7 @@ Native model parameters and provider protocols per Harness are in [model executi | [Structured output](./execution-tools.md#structured-output) | Rejected | Verified on all placements | Rejected | | [Deferred function discovery](./execution-tools.md#deferred-function-discovery) | Rejected | Verified: `none`, self-hosted; admitted: hosted | Rejected | | [Disabled web search and programmatic tool calling](./execution-tools.md#web-search-and-programmatic-tool-calling) | Verified: `none`; admitted: hosted, self-hosted | Verified: `none`; admitted: hosted, self-hosted | Verified: `none`; admitted: hosted, self-hosted | -| Enabled web search or programmatic tool calling | Rejected | Rejected | Rejected | +| Web search `cached` or `live` and programmatic tool calling `enabled` | Admitted on all placements | Rejected | Rejected | | [Service-origin HTTP MCP](./environments.md#public-mcp-connection-origin) | Verified: `none`; rejected elsewhere | Verified: `none`; rejected elsewhere | Rejected | | [Environment-origin HTTP MCP](./environments.md#public-mcp-connection-origin) | Verified: hosted, self-hosted | Verified: hosted, self-hosted | Verified: hosted, self-hosted; `allowed_tools` null and `required` false only | | [HTTP MCP bearer credentials](./execution-tools.md#http-mcp) | Verified | Verified | Verified | diff --git a/contracts/agents-api/harness-onboarding.md b/contracts/agents-api/harness-onboarding.md index d0361f4cc..883f24941 100644 --- a/contracts/agents-api/harness-onboarding.md +++ b/contracts/agents-api/harness-onboarding.md @@ -188,9 +188,10 @@ The declaration holds: - the model `Configuration` ([native model configuration](#native-model-configuration)); - `Capabilities`, a `proto.AgentKindCapabilities` that states the most the Harness can do on a capable host, with every field `Supported` or `Unsupported` and the zero value invalid ([Capability declarations](../../docs/runtime-protocol.md#capability-declarations)); - the supported `Placements` and public `MCPOrigins`, and whether whitespace-only text is accepted; +- `Execution`, the official execution option values the adapter maps to native settings: web search modes and whether search settings are accepted, reasoning efforts and summaries, service tiers, programmatic-tool-calling values and text verbosities. Core admission and the Runtime reject an undeclared value with the same check ([tool policy](./execution-tools.md#web-search-and-programmatic-tool-calling)). Core always sends the official default text verbosity, so `TextVerbosities` must include `medium`; - optional pure restrictions, `ValidateConfiguration`, `ValidateTools` and `ValidateFunctionResult`, that add native limits to the common checks. A nil validator means the common checks suffice. They cannot query business data, decrypt credentials or control native processes, Configuration restrictions run first. With a `ValidateConfiguration` callback, tool-decoding errors precede `ValidateTools` errors; without one, `ValidateTools` errors precede a decoding error. -`harnessconfig.NewRegistry` validates every declaration and panics on an invalid or duplicate one: kinds and labels must be nonempty, placements must list at least one supported placement, MCP origins must be a non-nil list, unknown or duplicate choices and origins without a corresponding placement are rejected, and bearer support requires an MCP origin. Shared dispatch checks capability combinations, never engine names. +`harnessconfig.NewRegistry` validates every declaration and panics on an invalid or duplicate one: kinds and labels must be nonempty, placements must list at least one supported placement, MCP origins must be a non-nil list, unknown or duplicate choices and origins without a corresponding placement are rejected, and bearer support requires an MCP origin, and `Execution` must declare text verbosity `medium`. Shared dispatch checks capability combinations, never engine names. The Runtime binding in `apps/daemon/internal/agent//declaration.go` embeds the same declaration and adds `Discover`, which owns native availability probes and factories and only narrows the declared capabilities to what the installation verified. The binding test `TestDiscoveryOnlyNarrowsDeclaration` checks that discovery never exceeds the declaration. `harnessconfig/harnesstest.Declaration` builds a synthetic declaration for tests. diff --git a/contracts/agents-api/index.md b/contracts/agents-api/index.md index 67f23d1de..21bd147b2 100644 --- a/contracts/agents-api/index.md +++ b/contracts/agents-api/index.md @@ -69,7 +69,9 @@ Each item is Core's deliberate or native behavior where the official service beh - A repeated Session creation with the same `Idempotency-Key` returns the original Session; the official service creates a new one. - Omitted programmatic tool calling keeps the Harness's native behavior; the official default is on. +- An Agent without a `web_search` tool keeps the Harness's native search, which for Codex is cached search; the pinned contract does not define search without the tool. - An omitted reasoning effort stays null instead of taking the model's default. +- Codex maps programmatic tool calling to its native `code_mode`, whose model-written scripts call the other tools; the feature is under development upstream. - A Session's `agent.tools` omits `tool_search` declarations. - Deleting a Session right after an events 202 returns 409, because Core admits the Turn in the same transaction; the official service returned 200. @@ -104,14 +106,14 @@ Each item is Core's deliberate or native behavior where the official service beh **Configuration and tools** -- Explicit reasoning effort or summary, service tiers other than `auto`, enabled `web_search` and enabled programmatic tool calling are saved but rejected at Session admission. +- A `reasoning.effort`, `reasoning.summary`, `service_tier`, `web_search` mode or setting or `programmatic_tool_calling` value that the selected Harness does not declare is saved but rejected at Session admission ([Harness capabilities](./harness-capabilities.md#execution-and-input)). - Harness support for tools, structured output, deferred discovery, subagents and MCP differs by Harness and placement; see the [Harness capabilities](./harness-capabilities.md). MiniMax Code has no public functions, no service-origin MCP and no image input. - Model-derived reasoning defaults are not resolved. - MCP tools support the `http` transport only; `stdio` is rejected, and so is an inline `authorization` on a Session MCP transport ([HTTP MCP](./execution-tools.md#http-mcp)). **Execution and history** -- The stream does not emit reasoning-summary events, Environment `pending` or `ready` events, or every pinned interim tool-output variant. +- Codex reasoning summaries are requested but not projected as `reasoning` Items, and the stream does not emit reasoning-summary events, Environment `pending` or `ready` events, or every pinned interim tool-output variant. - Native Item variants beyond those listed under [Turns and Items](./sessions-events.md#turns-and-items) are not projected, and Items cannot be modified. - A function result that cancellation prevents from being applied never appears as an Item. - Pinned Codex can lose command output emitted before its stream subscription. diff --git a/contracts/agents-api/model-execution.md b/contracts/agents-api/model-execution.md index b3157d2ec..eb9e1039b 100644 --- a/contracts/agents-api/model-execution.md +++ b/contracts/agents-api/model-execution.md @@ -107,13 +107,13 @@ At dispatch, Core sends the snapshot as one confidential provider bundle over th | Harness | Accepted fields | Applied as | | --- | --- | --- | -| Codex | `model_reasoning_effort`: `none`, `minimal`, `low`, `medium`, `high`, `xhigh` | App-server `-c model_reasoning_effort=...` and each Turn's `collaborationMode.settings.reasoning_effort` | -| Claude Code | `effort`: `low`, `medium`, `high`, `xhigh`, `max`; `thinking`: the SDK's `adaptive`, `enabled` or `disabled` object | SDK `Options.effort` and `Options.thinking` | +| Codex | Empty object only | Reasoning effort belongs to the public `reasoning.effort` | +| Claude Code | `thinking`: the SDK's `adaptive`, `enabled` or `disabled` object | SDK `Options.thinking`; the public `reasoning.effort` becomes `Options.effort` | | MiniMax Code | Empty object only | Provider token limits remain required | Claude `thinking` accepts `display` (`summarized` or `omitted`) for `adaptive` and `enabled`, and a positive integer `budgetTokens` only for `enabled`; `maxThinkingTokens` is rejected. These are native settings, not a shared reasoning vocabulary; model availability and provider support are the Harness's responsibility. -A supplied object replaces the whole object; `{}` clears it and null is invalid. A Session that selects a model or provider without supplying native parameters uses `{}` instead of inheriting another model's parameters. Otherwise a saved Agent supplies its object, and an inline Session using the deployment model uses the deployment object. Changing the Harness clears inherited parameters, and so does an Agent update of model, provider or Harness that does not supply `harness_config`. There is no deep merge. An inline extension that sets only native parameters keeps the saved Harness. Neither this object nor a deployment default enables public `reasoning` options. +A supplied object replaces the whole object; `{}` clears it and null is invalid. A Session that selects a model or provider without supplying native parameters uses `{}` instead of inheriting another model's parameters. Otherwise a saved Agent supplies its object, and an inline Session using the deployment model uses the deployment object. Changing the Harness clears inherited parameters, and so does an Agent update of model, provider or Harness that does not supply `harness_config`. There is no deep merge. An inline extension that sets only native parameters keeps the saved Harness. Core validates the resolved configuration before writing the Session and freezes it in the Session's Agent configuration; the administrator execution-configuration read shows its value and source. Native parameters are not confidential; provider keys stay in the separate encrypted bundle. Reconnection uses the frozen configuration and cannot change the provider, protocol or parameters; an incompatible frozen configuration fails. Protocol support does not qualify structured output, tool discovery, web search, verbosity or image input by itself, and a supported connection does not prove that the remote model accepts a parameter. diff --git a/contracts/agents-api/wire-semantics.md b/contracts/agents-api/wire-semantics.md index edaa32cd8..e64456471 100644 --- a/contracts/agents-api/wire-semantics.md +++ b/contracts/agents-api/wire-semantics.md @@ -177,7 +177,7 @@ Agent create and update bodies and the inline `agent` of Session create are chec Within one object Core reports a union's `type` first, then unknown members, then member values in document order, then missing members in the pinned schema's order; tools before `text`, and the whole object before the duplicate and schema-root checks. Schemas without a string root `type` are not checked. Function and output schemas, `request_metadata` and `x_agents_core` keep their own parsers. Update bodies and the inline Session agent are validated before the Agent lookup, so owned, foreign, missing and malformed Agent IDs give the same response. -Core saves values the pinned shapes allow even when it cannot run them: function names of any length, enabled programmatic tool calling, reasoning effort `max` and service tier `flex`. +Core saves values the pinned shapes allow even when the selected Harness cannot run them: function names of any length and the reasoning, service tier, web search and programmatic tool calling values a Harness does not declare. ### Session admission @@ -185,15 +185,13 @@ A Session's effective configuration must also pass execution admission, which ap | Configuration | Message | | --- | --- | -| Explicit `reasoning.effort` or `reasoning.summary` | "Explicit reasoning execution options are not supported by this service yet." | -| `service_tier` other than `auto` | "Execution currently supports service_tier=auto only." | -| Enabled or omitted-mode `web_search`, enabled `programmatic_tool_calling` | See [tool policy](./execution-tools.md#web-search-and-programmatic-tool-calling) | +| A `reasoning.effort`, `reasoning.summary`, `service_tier`, `web_search` mode or setting, `programmatic_tool_calling` value or `text.verbosity` the Harness does not declare | A message naming the field, such as `reasoning.effort "max" is not supported by this Harness.`; see [tool policy](./execution-tools.md#web-search-and-programmatic-tool-calling) and [Harness capabilities](./harness-capabilities.md#execution-and-input) | | More than 64 functions, or a function name that is blank or longer than 512 bytes | "This service supports at most 64 function tools." or "Function names must be nonempty, unique and at most 512 bytes." | -| Two `programmatic_tool_calling` declarations, two MCP servers with one label | "Execution requires distinct tool controls.", "Execution requires distinct MCP server labels." | +| Two MCP servers with one label | "Execution requires distinct MCP server labels." | A per-Session `tools` replacement admits a Session whose saved tools would be rejected. Support for each tool and Harness is in [execution and tools](./execution-tools.md). -Omitted, null and explicit `medium` text verbosity give the same Session configuration. For a model whose native catalog declares no verbosity support, the Codex adapter drops a `medium` setting and uses the model's default, and rejects `low` or `high`. +Omitted, null and explicit `medium` text verbosity give the same Session configuration. ### Update, delete and list diff --git a/contracts/agents-api/zh/admin-api.md b/contracts/agents-api/zh/admin-api.md index b8f57df5b..9e550d491 100644 --- a/contracts/agents-api/zh/admin-api.md +++ b/contracts/agents-api/zh/admin-api.md @@ -1,7 +1,7 @@ --- title: "Core 管理 API" source: contracts/agents-api/admin-api.md -source_hash: 25364c00b8123a4f5dc70916e98d89af8cf3eae05bb5a4b597cf86b14c491800 +source_hash: da7f631aea81c3b934e61e8749de577f2dc458d5b45ede3f5492a3f184affe08 --- Core 管理 API(`/core/v1`)用于管理安装实例:项目及其 API 密钥、项目资源的读取和删除、执行器凭据、部署默认模型、沙箱部署及其节点、监控和审计。Web 的[控制台服务器](../../../docs/zh/web/console-server.md#forwarding-to-core)会为已登录的管理员调用它;运维人员则从 Core 主机上的脚本调用它([编写 Core API 脚本](../../../docs/zh/getting-started/operations.md#script-the-core-api))。生成的架构是 [core.openapi.yaml](../core.openapi.yaml),所有错误都使用 [Core 错误封装](core-errors.md)。 @@ -144,7 +144,7 @@ GET 返回所选配置和租户容量策略;尚未配置时返回 404 `workspa "session_id": "013773a9-44b9-4f84-baca-b51c04a01201", "model": {"value": "requested-model", "source": "session"}, "harness": {"value": "codex", "source": "agent"}, - "harness_config": {"value": {"model_reasoning_effort": "high"}, "source": "agent"}, + "harness_config": {"value": {"thinking": {"type": "adaptive"}}, "source": "agent"}, "model_provider": { "source": "agent", "status": "available", diff --git a/contracts/agents-api/zh/execution-tools.md b/contracts/agents-api/zh/execution-tools.md index 4a0e7dce8..16805aefe 100644 --- a/contracts/agents-api/zh/execution-tools.md +++ b/contracts/agents-api/zh/execution-tools.md @@ -1,7 +1,7 @@ --- title: "执行工具" source: contracts/agents-api/execution-tools.md -source_hash: 80502785bc87eb9863b38400eb86723483640b41acce2803be809779b8ea53d9 +source_hash: e9c3fd5267e24f56f1e24ef04af39abc3839ba6f31d1cc6537ba2553eb5417fe --- Agent 在 `tools` 中声明应用函数、控制项和 MCP 服务器,并可在 `text.format` 中声明输出 schema。本契约说明 Core 如何验证声明、哪些内容跨越 Runtime 边界,以及调用方如何恢复待执行操作。[Harness 能力](harness-capabilities.md)列出各 Harness 在不同部署位置支持的操作。原生工作区工具和 Environment Plugin MCP 属于 [Environment](environments.md#skills-plugins-and-environment-mcp)。 @@ -62,15 +62,15 @@ Core 发送 `PromptRequestPayload.ToolSearch` 和每个 `FunctionTool.DeferLoadi 已保存 Agent 保留固定版本的所有 `web_search` 模式:省略或 null 保存为 `live`,`cached` 和 `live` 原样保存([保存模式](wire-semantics.md#saved-configuration))。搜索设置是资源数据:省略或 null 的 `context_size` 解析为 `medium`;省略域名和位置解析为 null;空域名列表保持为空;提供的位置(包括 `{}`)包含 `city`、`country`、`region` 和 `timezone`,省略字段为 null。 -执行仅允许 `mode: "disabled"` 和 `enabled: false`。启用或省略模式的搜索、启用或省略 `enabled` 的程序化调用,在 Session 准入时被拒绝,除非 Session 替换了保存的工具。省略程序化配置会保留各 Harness 原生行为,这与官方默认启用行为不同。无关的原生实用工具不会被移除。 +执行把解析后的值交给 Harness,并按 `internal/harnessconfig` 中的声明(`Execution`)校验:支持的 Web 搜索模式以及是否接受搜索设置、reasoning effort 与 summary、service tier、程序化工具调用的取值和 text verbosity。声明之外的值会在 Session 准入时被拒绝,消息会指出字段,除非 Session 替换了保存的工具。Core 仅在 Agent 设置了选项或适用固定版本的默认值时才发送该选项,因此省略 `web_search` 或 `programmatic_tool_calling` 会保留各 Harness 的原生行为。对程序化调用而言,这与官方默认启用的行为不同。无关的原生实用工具不会被移除。 -`DisableProgrammaticToolCalling` 在初次执行和冷继续执行中携带禁用意图,仅在存在时要求 Runtime 能力。搜索使用已有禁用控制项。 +`ExecutionControls` 携带这些选项([Runtime 协议](../../../docs/zh/runtime-protocol.md#capability-declarations))。Runtime 只需要 `execution_controls` 能力。 -| Harness | 原生执行约束 | -| --- | --- | -| Codex | 禁用 code-mode 功能;在启动或恢复线程前检查原生受管要求,拒绝被强制启用的冲突功能 | -| Claude Code | 保留受限内置工具清单,并据此验证原生初始化 | -| MiniMax Code | 保留受限原生工具配置、空文本执行工具清单和禁用的 Web 搜索 | +| Harness | Web 搜索 | 程序化工具调用 | +| --- | --- | --- | +| Codex | `disabled`、`cached` 和 `live`,以及 `context_size`、`allowed_domains` 和 `location`,对应原生 `web_search` 与 `tools.web_search` 设置 | `enabled: true` 开启原生 `code_mode`;`false` 关闭 `code_mode`、`code_mode_only` 和 `code_mode_prewarm`,并在启动或恢复线程前检查原生受管要求 | +| Claude Code | 仅 `disabled`,并在原生初始化时验证受限内置工具清单 | 仅 `false` | +| MiniMax Code | 仅 `disabled`,受限配置没有搜索工具,通过原生 `features.webSearch=false` 实现 | 仅 `false` | ## HTTP MCP {#http-mcp} diff --git a/contracts/agents-api/zh/harness-capabilities.md b/contracts/agents-api/zh/harness-capabilities.md index 0dca26f85..9ff421073 100644 --- a/contracts/agents-api/zh/harness-capabilities.md +++ b/contracts/agents-api/zh/harness-capabilities.md @@ -1,7 +1,7 @@ --- title: "Harness 能力" source: contracts/agents-api/harness-capabilities.md -source_hash: b136a3c2c7eb583eef88acc2c214ad7e6391c3cf17c95c767e67cc1fe296271e +source_hash: ebe62fa0536333b51a80233883039ed732a6ce7232030cd90e8f8854dfde9859 --- 本页列出每个 Harness 在每种部署位置支持的能力。Core 根据 `internal/harnessconfig//declaration.go` 中 Harness 的声明决定准入,运行 Session 的 Runtime 也必须声明操作。所链接契约定义各操作;[Harness 接入](harness-onboarding.md#qualify-the-adapter)说明资格验证方法。 @@ -23,7 +23,9 @@ source_hash: b136a3c2c7eb583eef88acc2c214ad7e6391c3cf17c95c767e67cc1fe296271e | [仅空白消息文本](message-content.md) | 已准入;原样交付 | 已拒绝 | 已拒绝 | | [内联 PNG、JPEG 消息图像](message-content.md) | 所有位置已验证 | 所有位置已验证 | 已拒绝 | | 远程图像 URL | 已拒绝 | 已拒绝 | 已拒绝 | -| 显式 `reasoning`;非 `auto` 的 `service_tier` | 已拒绝 | 已拒绝 | 已拒绝 | +| `reasoning.effort` | `none`、`minimal`、`low`、`medium`、`high`、`xhigh`、`max`:已准入 | `low`、`medium`、`high`、`xhigh`、`max`:已准入 | 已拒绝 | +| `reasoning.summary` | `concise`、`detailed`、`auto`:已准入 | 已拒绝 | 已拒绝 | +| 非 `auto` 的 `service_tier` | `default`、`flex`、`priority`:已准入 | 已拒绝 | 已拒绝 | | 非 `medium` 的 `text.verbosity` | 已准入;由原生模型决定 | 已拒绝 | 已拒绝 | | [公开 token 用量](sessions-events.md) | 测量计数器 | Null | Null | @@ -38,7 +40,7 @@ source_hash: b136a3c2c7eb583eef88acc2c214ad7e6391c3cf17c95c767e67cc1fe296271e | [结构化输出](execution-tools.md#structured-output) | 已拒绝 | 所有位置已验证 | 已拒绝 | | [延迟函数发现](execution-tools.md#deferred-function-discovery) | 已拒绝 | 已验证:`none`、自托管;已准入:托管 | 已拒绝 | | [禁用 Web 搜索与程序化工具调用](execution-tools.md#web-search-and-programmatic-tool-calling) | 已验证:`none`;已准入:托管、自托管 | 已验证:`none`;已准入:托管、自托管 | 已验证:`none`;已准入:托管、自托管 | -| 启用 Web 搜索或程序化工具调用 | 已拒绝 | 已拒绝 | 已拒绝 | +| Web 搜索 `cached` 或 `live`,以及程序化工具调用 `enabled` | 所有位置已准入 | 已拒绝 | 已拒绝 | | [服务端来源 HTTP MCP](environments.md#public-mcp-connection-origin) | 已验证:`none`;其他位置拒绝 | 已验证:`none`;其他位置拒绝 | 已拒绝 | | [Environment 来源 HTTP MCP](environments.md#public-mcp-connection-origin) | 已验证:托管、自托管 | 已验证:托管、自托管 | 已验证:托管、自托管;仅 `allowed_tools` null、`required` false | | [HTTP MCP bearer 凭据](execution-tools.md#http-mcp) | 已验证 | 已验证 | 已验证 | diff --git a/contracts/agents-api/zh/harness-onboarding.md b/contracts/agents-api/zh/harness-onboarding.md index 219d1621e..9f009794b 100644 --- a/contracts/agents-api/zh/harness-onboarding.md +++ b/contracts/agents-api/zh/harness-onboarding.md @@ -1,7 +1,7 @@ --- title: "Harness 协议" source: contracts/agents-api/harness-onboarding.md -source_hash: 43cc8f744d205cfb26a3b6e510ade6356e03c721d49cd238ae20aa36f8500f70 +source_hash: 9ee093a477717517aac1f7d51eded6810afaedbefda0d2c13b900cbeba49a278 --- **Harness** 是一种运行模型和工具循环的原生代理引擎(Codex、Claude Code、MiniMax Code);**Harness 适配器**将 Runtime 的 Executor 和 Turn 契约转换到该引擎的 SDK 或协议。Harness 协议是 Runtime 与适配器之间的契约:适配器接口及其生命周期义务、声明、注册和验收。本文面向添加或维护 Harness 的开发者。 @@ -190,9 +190,10 @@ Runtime 统一解析[状态目录](../../../docs/zh/configuration.md#runtime-res - 模型 `Configuration`([原生模型配置](#native-model-configuration)); - `Capabilities`,即 `proto.AgentKindCapabilities`,表示 Harness 在能力完备的主机上最多能做什么;每个字段都是 `Supported` 或 `Unsupported`,零值无效([能力声明](../../../docs/zh/runtime-protocol.md#capability-declarations)); - 受支持的 `Placements` 和公共 `MCPOrigins`,以及是否接受仅含空白的文本; +- `Execution`,即适配器映射到原生设置的官方执行选项取值:web search 模式及是否接受搜索设置、推理强度和摘要、服务层级、程序化工具调用取值和文本详细程度。Core 准入和 Runtime 用同一检查拒绝未声明的取值([工具策略](execution-tools.md#web-search-and-programmatic-tool-calling))。Core 总会发送官方默认文本详细程度,因此 `TextVerbosities` 必须包含 `medium`; - 可选的纯限制 `ValidateConfiguration`、`ValidateTools` 和 `ValidateFunctionResult`,在通用检查之外添加原生限制。验证函数为 nil 表示通用检查已足够。它们不能查询业务数据、解密凭据或控制原生进程,配置限制最先执行。存在 `ValidateConfiguration` 回调时,工具解码错误先于 `ValidateTools` 错误;不存在时,`ValidateTools` 错误先于解码错误。 -`harnessconfig.NewRegistry` 会验证每个声明,对无效或重复的声明触发 panic:kind 和 label 必须非空,放置位置必须至少列出一个受支持的位置,MCP 来源必须是非 nil 列表,未知或重复的选项以及没有对应放置位置的来源会被拒绝,bearer 支持要求存在 MCP 来源。共享调度检查能力组合,绝不检查引擎名称。 +`harnessconfig.NewRegistry` 会验证每个声明,对无效或重复的声明触发 panic:kind 和 label 必须非空,放置位置必须至少列出一个受支持的位置,MCP 来源必须是非 nil 列表,未知或重复的选项以及没有对应放置位置的来源会被拒绝,bearer 支持要求存在 MCP 来源,`Execution` 必须声明文本详细程度 `medium`。共享调度检查能力组合,绝不检查引擎名称。 `apps/daemon/internal/agent//declaration.go` 中的 Runtime 绑定内嵌同一份声明并添加 `Discover`;它负责原生可用性探测和工厂,并且只把声明的能力收窄到该安装已验证的范围。绑定测试 `TestDiscoveryOnlyNarrowsDeclaration` 检查发现结果不超出声明。`harnessconfig/harnesstest.Declaration` 为测试构造合成声明。 diff --git a/contracts/agents-api/zh/index.md b/contracts/agents-api/zh/index.md index b45440123..378b0771f 100644 --- a/contracts/agents-api/zh/index.md +++ b/contracts/agents-api/zh/index.md @@ -1,7 +1,7 @@ --- title: "OpenAI 兼容性" source: contracts/agents-api/index.md -source_hash: 5032e5d52040d39196c2cfeb46daa004d268c766a440b155fe34129500207772 +source_hash: e66771e892b2d9c4a0e4d24a81f0976d3f7db5ca8b9920ea6155199face758f6 --- Core 实现下方固定版本的 OpenAI Agents API。本台账列出各项资源的覆盖情况、相对于官方服务的所有差异以及已知缺口。 @@ -72,7 +72,9 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh - 使用相同 `Idempotency-Key` 重复创建 Session 会返回原 Session;官方服务会创建一个新的 Session。 - 未指定程序化工具调用时,会保留 Harness 的原生行为;官方默认值为启用。 +- Agent 未声明 `web_search` 工具时,会保留 Harness 的原生搜索,Codex 为缓存搜索;固定的官方契约未定义未声明该工具时是否搜索。 - 未指定推理强度时会保持为 null,而不是采用模型的默认值。 +- Codex 把程序化工具调用映射到原生 `code_mode`,由模型编写的脚本调用其他工具;该功能在上游仍处于开发中。 - Session 的 `agent.tools` 会省略 `tool_search` 声明。 - 在 events 202 之后立即删除 Session 会返回 409,因为 Core 会在同一事务中准入该 Turn;官方服务返回 200。 @@ -107,14 +109,14 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh **配置和工具** -- 显式指定推理强度或摘要、使用 `auto` 之外的服务层级、启用 `web_search` 或启用程序化工具调用,这些设置都会被保存,但在 Session 准入时会被拒绝。 +- 所选 Harness 未声明的 `reasoning.effort`、`reasoning.summary`、`service_tier`、`web_search` 模式或设置、`programmatic_tool_calling` 取值,会被保存,但在 Session 准入时被拒绝([Harness 能力](harness-capabilities.md#execution-and-input))。 - Harness 对工具、结构化输出、延迟发现、subagents 和 MCP 的支持因 Harness 和部署位置而异;请参阅 [Harness capabilities](harness-capabilities.md)。MiniMax Code 不提供公共 functions、没有服务源 MCP,也不支持图像输入。 - 由模型推导出的推理默认值不会被解析确定。 - MCP 工具仅支持 `http` 传输,`stdio` 会被拒绝,Session MCP 传输中的内联 `authorization` 也会被拒绝([HTTP MCP](execution-tools.md#http-mcp))。 **执行和历史** -- 流不会发出 reasoning-summary 事件、Environment 的 `pending` 或 `ready` 事件,也不会覆盖固定版本中的所有临时 tool-output 变体。 +- Codex 的推理摘要会被请求,但不会投影为 `reasoning` Item;流也不会发出 reasoning-summary 事件、Environment 的 `pending` 或 `ready` 事件,也不会覆盖固定版本中的所有临时 tool-output 变体。 - 除 [Turns and Items](sessions-events.md#turns-and-items) 中列出的变体外,其他原生 Item 变体不会被投影,而且 Items 无法修改。 - 如果取消导致函数结果无法应用,该结果将永远不会作为 Item 出现。 - 固定版本的 Codex 可能会丢失在订阅其流之前发出的命令输出。 diff --git a/contracts/agents-api/zh/model-execution.md b/contracts/agents-api/zh/model-execution.md index 3370132a3..e32eeb8ce 100644 --- a/contracts/agents-api/zh/model-execution.md +++ b/contracts/agents-api/zh/model-execution.md @@ -1,7 +1,7 @@ --- title: "模型执行" source: contracts/agents-api/model-execution.md -source_hash: 2be5448431eec645227d40404ecdd292546c63a7e3f943f11aef605804f89e34 +source_hash: 3007a19d5f030e14149218f9d1822efceb1188c3a31c6505180df511fb1ab205 --- 每个 Session 都运行一个 Harness,并使用一个模型提供商。Core 通过三个固定版本上游协议未定义的 Core 扩展来选择它们:`x_agents_core.harness` 选择 Harness,`x_agents_core.model_provider` 提供端点和密钥,`x_agents_core.harness_config` 携带原生模型参数。Core 没有提供商目录、模型别名解析或产品权限模型;除 Session 和已保存 Agent 配置包外,唯一存储的配置包是每个 Harness 的一个 [deployment default](#deployment-defaults)。本文档定义 Harness—模型提供商协议:[`internal/modelprovider/config.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/modelprovider/config.go) 负责验证冻结的提供商连接,每个 Harness 则通过 [`internal/harnessconfig/harness.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/harnessconfig/harness.go) 声明其协议和原生参数。 @@ -109,13 +109,13 @@ Core 从同一个数据库快照读取 Agent 配置和加密配置包;显式 | Harness | 接受的字段 | 应用方式 | | --- | --- | --- | -| Codex | `model_reasoning_effort`:`none`、`minimal`、`low`、`medium`、`high`、`xhigh` | App-server `-c model_reasoning_effort=...` 以及每个 Turn 的 `collaborationMode.settings.reasoning_effort` | -| Claude Code | `effort`:`low`、`medium`、`high`、`xhigh`、`max`;`thinking`:SDK 的 `adaptive`、`enabled` 或 `disabled` 对象 | SDK `Options.effort` 和 `Options.thinking` | +| Codex | 仅空对象 | 推理强度属于公开的 `reasoning.effort` | +| Claude Code | `thinking`:SDK 的 `adaptive`、`enabled` 或 `disabled` 对象 | SDK `Options.thinking`;公开的 `reasoning.effort` 对应 `Options.effort` | | MiniMax Code | 仅空对象 | 仍必须给出提供商 token 限制 | 对于 `adaptive` 和 `enabled`,Claude `thinking` 接受 `display`(`summarized` 或 `omitted`);只有 `enabled` 接受正整数 `budgetTokens`;拒绝 `maxThinkingTokens`。这些是原生设置,而不是通用推理词汇表;模型可用性和提供商支持均由 Harness 负责。 -提供对象时会替换整个对象;`{}` 会清除它,null 则无效。Session 在选择模型或提供商但未提供原生参数时,会使用 `{}`,而不会继承另一个模型的参数。否则,使用已保存 Agent 提供的对象;使用部署模型的内联 Session 使用部署对象。更改 Harness 会清除继承的参数;对模型、提供商或 Harness 的 Agent 更新如果未提供 `harness_config`,也会清除继承的参数。不进行深度合并。仅设置原生参数的内联扩展会保留已保存的 Harness。该对象和部署默认值都不会启用公开的 `reasoning` 选项。 +提供对象时会替换整个对象;`{}` 会清除它,null 则无效。Session 在选择模型或提供商但未提供原生参数时,会使用 `{}`,而不会继承另一个模型的参数。否则,使用已保存 Agent 提供的对象;使用部署模型的内联 Session 使用部署对象。更改 Harness 会清除继承的参数;对模型、提供商或 Harness 的 Agent 更新如果未提供 `harness_config`,也会清除继承的参数。不进行深度合并。仅设置原生参数的内联扩展会保留已保存的 Harness。 Core 会在写入 Session 前验证解析后的配置,并将其冻结在 Session 的 Agent 配置中;管理员的 execution-configuration 读取结果会显示其值和来源。原生参数不属于机密信息;提供商密钥保存在单独加密的配置包中。重新连接会使用冻结的配置,无法更改提供商、协议或参数;不兼容的冻结配置会导致失败。仅仅支持某种协议,并不能说明结构化输出、工具发现、Web 搜索、详细程度或图像输入是否可用;连接受支持也不能证明远程模型接受某个参数。 diff --git a/contracts/agents-api/zh/wire-semantics.md b/contracts/agents-api/zh/wire-semantics.md index 2f25cde17..fe522ed0f 100644 --- a/contracts/agents-api/zh/wire-semantics.md +++ b/contracts/agents-api/zh/wire-semantics.md @@ -1,7 +1,7 @@ --- title: "API 行为" source: contracts/agents-api/wire-semantics.md -source_hash: c87426d2e0c1f461087ec3416aabad84998508239120b36a9604c3e3a029c66f +source_hash: c84b3d24f75bdc6fc608a7944bd0b23e7b9d6f969c12691667c72a5013ea59b9 --- 已锁定版本的 OpenAI Python SDK([upstream.json](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/contracts/agents-api/upstream.json))定义了 `/v1` 路由、字段和类型。本页面说明这些类型未作规定之处 Core 的行为,例如状态码、错误字段、默认值和列表边界,以及 Core 与官方服务存在差异的地方。[OpenAI 兼容性](index.md) 列出了这些差异和尚存缺口;[Sessions, events and history](sessions-events.md)、[message content](message-content.md)、[Vaults](vaults.md)、[source Files and Skills](source-files.md) 和 [Environment files and Artifacts](environment-files.md) 分别负责各自资源的规则。 @@ -179,7 +179,7 @@ Agent 创建和更新正文以及 Session 创建中的内联 `agent`,会在其 在同一个对象内,Core 会先报告联合类型的 `type`,然后报告未知成员,再按文档顺序报告成员值,最后按固定 schema 的顺序报告缺失成员;先检查 tools,再检查 `text`,并在重复项和 schema 根检查之前检查整个对象。没有字符串根 `type` 的 schema 不会被检查。Function 和 output schema、`request_metadata` 和 `x_agents_core` 使用各自的解析器。更新正文和 Session 内联 Agent 会在查找 Agent 之前进行验证,因此属于当前租户、属于外部租户、缺失和格式错误的 Agent ID 会得到相同的响应。 -即使无法执行,Core 也会保存已锁定形状允许的值:任意长度的函数名称、已启用的 programmatic tool calling、reasoning effort `max` 和 service tier `flex`。 +即使所选 Harness 无法执行,Core 也会保存已锁定形状允许的值:任意长度的函数名称,以及 Harness 未声明的 reasoning、service tier、Web 搜索和 programmatic tool calling 取值。 ### Session 准入 {#session-admission} @@ -187,15 +187,13 @@ Session 的有效配置还必须通过执行准入,无论配置来自保存还 | 配置 | 消息 | | --- | --- | -| 显式 `reasoning.effort` 或 `reasoning.summary` | "Explicit reasoning execution options are not supported by this service yet." | -| `service_tier` 不是 `auto` | "Execution currently supports service_tier=auto only." | -| 已启用的 `web_search` 或省略模式的 `web_search`、已启用的 `programmatic_tool_calling` | 见 [tool policy](execution-tools.md#web-search-and-programmatic-tool-calling) | +| Harness 未声明的 `reasoning.effort`、`reasoning.summary`、`service_tier`、`web_search` 模式或设置、`programmatic_tool_calling` 取值或 `text.verbosity` | 指出字段的消息,例如 `reasoning.effort "max" is not supported by this Harness.`;见 [tool policy](execution-tools.md#web-search-and-programmatic-tool-calling) 和 [Harness 能力](harness-capabilities.md#execution-and-input) | | 超过 64 个函数,或函数名称为空或超过 512 字节 | "This service supports at most 64 function tools." 或 "Function names must be nonempty, unique and at most 512 bytes." | -| 两个 `programmatic_tool_calling` 声明、两个使用同一标签的 MCP 服务器 | "Execution requires distinct tool controls."、"Execution requires distinct MCP server labels." | +| 两个使用同一标签的 MCP 服务器 | "Execution requires distinct MCP server labels." | 每 Session 的 `tools` 替换可以允许一个其已保存 tools 原本会被拒绝的 Session。每种工具和 Harness 的支持情况见 [execution and tools](execution-tools.md)。 -省略、null 和显式 `medium` text verbosity 会产生相同的 Session 配置。对于原生目录未声明 verbosity 支持的模型,Codex 适配器会丢弃 `medium` 设置并使用模型默认值,同时拒绝 `low` 或 `high`。 +省略、null 和显式 `medium` text verbosity 会产生相同的 Session 配置。 ### 更新、删除和列表 {#update-delete-and-list} diff --git a/docs/api/public-agent-api.md b/docs/api/public-agent-api.md index 308dbc83f..7380bf9b7 100644 --- a/docs/api/public-agent-api.md +++ b/docs/api/public-agent-api.md @@ -146,7 +146,7 @@ Core runs several Harnesses and accepts your own model access. Those settings ar | --- | --- | --- | | `harness` | Agent, or a Session's inline `agent` | `codex`, `claude_sdk` or `mcode` | | `model_provider` | Agent, or Session creation (top level) | `protocol`, `base_url`, `api_key`, and for `mcode` also `context_window` and `max_output_tokens`. The protocol must be one of the Harness's native protocols | -| `harness_config` | Agent, inline `agent`, or Session creation (top level, wins) | The Harness's native model parameters, such as Codex's `model_reasoning_effort` | +| `harness_config` | Agent, inline `agent`, or Session creation (top level, wins) | The Harness's native model parameters, such as Claude Code's `thinking` | | `environment` | `openai_hosted` or `self_hosted` Session creation (top level) | Portable preparation: `environment_template_id`, `files`, `env`, `packages`, `setup_commands`, `skills`, `plugins`, `capability_directories`. A field may not also appear in `environment`; see [Environments](../../contracts/agents-api/environments.md#preparation-order) | | `installation` | Read-only, on `self_hosted` Sessions | Short-lived install commands for your machine; see [Run self-hosted Sessions](../getting-started/self-hosted.md) | diff --git a/docs/runtime-protocol.md b/docs/runtime-protocol.md index 814e350ab..a9d50a066 100644 --- a/docs/runtime-protocol.md +++ b/docs/runtime-protocol.md @@ -71,12 +71,10 @@ A declaration describes what the Runtime can do. Core admits a public feature on | `workspace_read_preparation` | An idle Files directory read needs a read-only preparation | | `native_session_recovery` | A Session with a started Turn has no recorded native Session ID | | `retained_native_history` | The Environment uses an external workspace binding; owned workspace storage does not require this capability | -| `web_search_control`, `text_verbosity` | The Harness's declaration supports that control | | `structured_output` and `message_items` | The Agent requests `json_schema` output | | `subagent_observations` | `multi_agent.enabled` is true | | `subagent_control` | `multi_agent.enabled` is false | | `tool_search` | The Agent enables tool search or defers function loading | -| `programmatic_tool_calling_disable` | The Agent explicitly disables programmatic tool calling | | `function_tools` | The Agent declares function tools | | `message_images`, `function_result_images` | A message, or a function result, carries an image | | `mcp_http_tools`, `mcp_http_required`, `mcp_http_bearer_auth` | The Agent declares HTTP MCP servers; one is `required`; a Vault credential is selected for one | @@ -214,7 +212,7 @@ The `execution_prepare` configuration carries the opt-ins Core sets for each Run | Field | Set by Core | | --- | --- | | `agent_options` | Always `model` and `system_prompt` from the Agent; `model_provider` when the Session froze one; `harness_config` when the Agent sets `x_agents_core.harness_config`. Core sends no other key; the Runtime rejects any other key with `unsupported_configuration` before preparation | -| `execution_controls` | Always: web search `disabled`, the resolved text verbosity (default `medium`), an explicit programmatic-tool-calling disable and any `json_schema` output format. Native option names belong to the adapter | +| `execution_controls` | Always: the resolved text verbosity (default `medium`) plus each option the Agent sets, whether web search (mode, `context_size`, `allowed_domains`, `location`), programmatic tool calling, reasoning effort and summary, a non-`auto` service tier or a `json_schema` output format. Core validates every value against the Harness declaration before dispatch; native option names belong to the adapter | | `observe_messages` | When the Runtime declares `message_items`. Text deltas then carry the native item ID, and `output_message` frames report message start, completion, phase and the completion text | | `observe_subagent_identities`, `disable_subagents` | From the Agent's `multi_agent.enabled` | | `disable_execution_environment` | For an Environment of type `none` | diff --git a/docs/zh/api/public-agent-api.md b/docs/zh/api/public-agent-api.md index 535604e64..7f42c7b65 100644 --- a/docs/zh/api/public-agent-api.md +++ b/docs/zh/api/public-agent-api.md @@ -1,7 +1,7 @@ --- title: "Agents API 指南" source: docs/api/public-agent-api.md -source_hash: 6c7a12683a6cfb0ceda9ea0440f3e1fb90091203e4d044cc21850ae651399254 +source_hash: ad2aea1d04536a7b12079a14f9926fc91b5202d394c1cd132d6c0a004a489451 --- Core 在 `/v1` 提供 OpenAI Agents API。本指南用官方 OpenAI SDK 和普通 HTTP 两种方式演示每项常见任务,并说明 Core 与 OpenAI 的差异。如果尚未启动过 Session,请先阅读[运行第一个 Session](../getting-started/quickstart.md)。 @@ -148,7 +148,7 @@ Core 可以运行多种 Harness,并允许接入你自己的模型访问方式 | --- | --- | --- | | `harness` | Agent,或 Session 的内联 `agent` | `codex`、`claude_sdk` 或 `mcode` | | `model_provider` | Agent,或 Session 创建请求(顶层) | `protocol`、`base_url`、`api_key`;对于 `mcode`,还包括 `context_window` 和 `max_output_tokens`。协议必须是该 Harness 的原生协议之一 | -| `harness_config` | Agent、内联 `agent`,或 Session 创建请求(顶层,优先) | Harness 的原生模型参数,例如 Codex 的 `model_reasoning_effort` | +| `harness_config` | Agent、内联 `agent`,或 Session 创建请求(顶层,优先) | Harness 的原生模型参数,例如 Claude Code 的 `thinking` | | `environment` | `openai_hosted` 或 `self_hosted` Session 创建请求(顶层) | 可移植的准备配置:`environment_template_id`、`files`、`env`、`packages`、`setup_commands`、`skills`、`plugins`、`capability_directories`。同一字段不能也出现在 `environment` 中;请参阅 [Environments](../../../contracts/agents-api/zh/environments.md#preparation-order) | | `installation` | 只读,适用于 `self_hosted` Session | 在你的计算机上运行的短期安装命令;请参阅 [运行自托管 Session](../getting-started/self-hosted.md) | diff --git a/docs/zh/runtime-protocol.md b/docs/zh/runtime-protocol.md index a1e31eca2..78b1d98d9 100644 --- a/docs/zh/runtime-protocol.md +++ b/docs/zh/runtime-protocol.md @@ -1,7 +1,7 @@ --- title: "Core–Runtime 协议" source: docs/runtime-protocol.md -source_hash: c46df89c43896c27b6c799a22055388161074e613a25c5308adc3fbcfce83a6d +source_hash: d8e2ef6c37e49d482f9eed39490ce5f20dc892c29667fb71070fe88c1321600f --- Core–Runtime 协议连接 Core 与 Runtime daemon,定义 daemon 连接上消息的含义和顺序。托管和自托管 Runtime 使用同一协议。 @@ -73,12 +73,10 @@ wire 上每个字段都是 JSON boolean,所有字段都必须出现,包括 ` | `workspace_read_preparation` | 空闲 Files 目录读取需要只读 preparation | | `native_session_recovery` | Session 已启动过 Turn,但未记录原生 Session ID | | `retained_native_history` | Environment 使用外部工作区绑定;自带工作区存储不要求此能力 | -| `web_search_control`, `text_verbosity` | Harness 的声明支持该控制 | | `structured_output` 和 `message_items` | Agent 请求 `json_schema` 输出 | | `subagent_observations` | `multi_agent.enabled` 为 true | | `subagent_control` | `multi_agent.enabled` 为 false | | `tool_search` | Agent 启用 tool search 或延迟 function 加载 | -| `programmatic_tool_calling_disable` | Agent 明确禁用 programmatic tool calling | | `function_tools` | Agent 声明 function tool | | `message_images`, `function_result_images` | 消息或 function result 携带图像 | | `mcp_http_tools`, `mcp_http_required`, `mcp_http_bearer_auth` | Agent 声明 HTTP MCP server;其中一个为 `required`;其中一个选用了 Vault 凭据 | @@ -216,7 +214,7 @@ Executor 空闲到期属于 Runtime 资源策略,与 Core 的活动 Turn 并 | 字段 | Core 设置方式 | | --- | --- | | `agent_options` | 始终设置 Agent 的 `model` 和 `system_prompt`;Session 冻结了模型提供商时设置 `model_provider`;Agent 设置 `x_agents_core.harness_config` 时设置 `harness_config`。Core 不发送其他键;Runtime 在准备之前以 `unsupported_configuration` 拒绝任何其他键 | -| `execution_controls` | 始终设置:web search 为 `disabled`、解析后的 text verbosity(默认 `medium`)、明确禁用 programmatic tool calling,以及任何 `json_schema` 输出格式。原生选项名称由 adapter 负责 | +| `execution_controls` | 始终设置:解析后的 text verbosity(默认 `medium`),以及 Agent 设置的每个选项,包括 web search(mode、`context_size`、`allowed_domains`、`location`)、programmatic tool calling、reasoning effort 和 summary、非 `auto` 的 service tier,以及 `json_schema` 输出格式。Core 在分发前按 Harness 声明校验每个值;原生选项名称属于适配器 | | `observe_messages` | Runtime 声明 `message_items` 时设置。文本 delta 随后携带原生 item ID,`output_message` frame 报告消息开始、完成、phase 和完成文本 | | `observe_subagent_identities`, `disable_subagents` | 根据 Agent 的 `multi_agent.enabled` 设置 | | `disable_execution_environment` | Environment 类型为 `none` 时设置 | diff --git a/internal/agentdaemon/proto/execution_controls.go b/internal/agentdaemon/proto/execution_controls.go deleted file mode 100644 index 5ccd11220..000000000 --- a/internal/agentdaemon/proto/execution_controls.go +++ /dev/null @@ -1,12 +0,0 @@ -package proto - -import "errors" - -// ValidateProgrammaticToolCallingDisable applies only to explicit tool disabling. -// Omission preserves the adapter's ordinary native behavior. -func (r PromptRequestPayload) ValidateProgrammaticToolCallingDisable(supported bool) error { - if r.ExecutionControls != nil && r.ExecutionControls.DisableProgrammaticToolCalling && !supported { - return errors.New("engine does not support disabling programmatic tool calling") - } - return nil -} diff --git a/internal/agentdaemon/proto/inbound.go b/internal/agentdaemon/proto/inbound.go index dfbc36f2d..35872f80e 100644 --- a/internal/agentdaemon/proto/inbound.go +++ b/internal/agentdaemon/proto/inbound.go @@ -165,17 +165,14 @@ type AgentKindCapabilities struct { Steering CapabilitySupport `json:"steering"` MessageItems CapabilitySupport `json:"message_items"` - ToolObservations CapabilitySupport `json:"tool_observations"` - EnvironmentNone CapabilitySupport `json:"environment_none"` - LocalEnvironment CapabilitySupport `json:"local_environment"` - Preparation CapabilitySupport `json:"preparation"` - WorkspaceReadPreparation CapabilitySupport `json:"workspace_read_preparation"` - WorkspaceOutputExport CapabilitySupport `json:"workspace_output_export"` - ProgrammaticToolCallingDisable CapabilitySupport `json:"programmatic_tool_calling_disable"` - WebSearchControl CapabilitySupport `json:"web_search_control"` - // ExecutionControls supports typed search and verbosity controls. + ToolObservations CapabilitySupport `json:"tool_observations"` + EnvironmentNone CapabilitySupport `json:"environment_none"` + LocalEnvironment CapabilitySupport `json:"local_environment"` + Preparation CapabilitySupport `json:"preparation"` + WorkspaceReadPreparation CapabilitySupport `json:"workspace_read_preparation"` + WorkspaceOutputExport CapabilitySupport `json:"workspace_output_export"` + // ExecutionControls accepts the typed execution controls. ExecutionControls CapabilitySupport `json:"execution_controls"` - TextVerbosity CapabilitySupport `json:"text_verbosity"` StructuredOutput CapabilitySupport `json:"structured_output"` ToolSearch CapabilitySupport `json:"tool_search"` MessageImages CapabilitySupport `json:"message_images"` diff --git a/internal/agentdaemon/proto/outbound.go b/internal/agentdaemon/proto/outbound.go index f5f2062a9..906f6b2e2 100644 --- a/internal/agentdaemon/proto/outbound.go +++ b/internal/agentdaemon/proto/outbound.go @@ -61,13 +61,37 @@ type PromptCancelPayload struct { DeliveryID string `json:"delivery_id,omitempty"` } -// ExecutionControls requires both values when supplied; omitting the block preserves agent options. -// Send only to a peer advertising execution_controls, independently of older option capabilities. +// ExecutionControls carries the official execution options the Session sets or +// whose official default applies. An absent member means the Harness's native +// default: Core never sends a value the official configuration does not define. +// Send only to a peer advertising execution_controls. type ExecutionControls struct { - DisableProgrammaticToolCalling bool `json:"disable_programmatic_tool_calling,omitempty"` - WebSearch string `json:"web_search"` - TextVerbosity string `json:"text_verbosity"` - OutputFormat *OutputFormat `json:"output_format,omitempty"` + WebSearch *WebSearchControl `json:"web_search,omitempty"` + // ProgrammaticToolCalling is the official programmatic_tool_calling.enabled value. + ProgrammaticToolCalling *bool `json:"programmatic_tool_calling,omitempty"` + ReasoningEffort string `json:"reasoning_effort,omitempty"` + ReasoningSummary string `json:"reasoning_summary,omitempty"` + // ServiceTier is never "auto": auto means unset. + ServiceTier string `json:"service_tier,omitempty"` + TextVerbosity string `json:"text_verbosity,omitempty"` + OutputFormat *OutputFormat `json:"output_format,omitempty"` +} + +// WebSearchControl is the resolved official web_search tool. A nil +// AllowedDomains is the official null; an empty list is kept distinct. +type WebSearchControl struct { + Mode string `json:"mode"` + ContextSize string `json:"context_size"` + AllowedDomains []string `json:"allowed_domains"` + Location *SearchLocation `json:"location,omitempty"` +} + +// SearchLocation carries only the keys the Session set. +type SearchLocation struct { + City *string `json:"city,omitempty"` + Country *string `json:"country,omitempty"` + Region *string `json:"region,omitempty"` + Timezone *string `json:"timezone,omitempty"` } // OutputFormat passes the public schema unchanged to a qualified native adapter. diff --git a/internal/agentdaemon/proto/prototest/capabilities.go b/internal/agentdaemon/proto/prototest/capabilities.go index 69cabdaf0..b998f36af 100644 --- a/internal/agentdaemon/proto/prototest/capabilities.go +++ b/internal/agentdaemon/proto/prototest/capabilities.go @@ -12,35 +12,32 @@ import ( // fixture is deliberately updated, so declaration validation fails on omission. func Capabilities(overrides proto.AgentKindCapabilities) proto.AgentKindCapabilities { c := proto.AgentKindCapabilities{ - SubagentObservations: proto.CapabilityUnsupported, - Streaming: proto.CapabilityUnsupported, - Usage: proto.CapabilityUnsupported, - Resume: proto.CapabilityUnsupported, - NativeSessionRecovery: proto.CapabilityUnsupported, - RetainedNativeHistory: proto.CapabilityUnsupported, - Steering: proto.CapabilityUnsupported, - MessageItems: proto.CapabilityUnsupported, - ToolObservations: proto.CapabilityUnsupported, - EnvironmentNone: proto.CapabilityUnsupported, - LocalEnvironment: proto.CapabilityUnsupported, - Preparation: proto.CapabilityUnsupported, - WorkspaceReadPreparation: proto.CapabilityUnsupported, - WorkspaceOutputExport: proto.CapabilityUnsupported, - ProgrammaticToolCallingDisable: proto.CapabilityUnsupported, - WebSearchControl: proto.CapabilityUnsupported, - ExecutionControls: proto.CapabilityUnsupported, - TextVerbosity: proto.CapabilityUnsupported, - StructuredOutput: proto.CapabilityUnsupported, - ToolSearch: proto.CapabilityUnsupported, - MessageImages: proto.CapabilityUnsupported, - FunctionResultImages: proto.CapabilityUnsupported, - SubagentControl: proto.CapabilityUnsupported, - DurableInputReceipts: proto.CapabilityUnsupported, - DurableTurns: proto.CapabilityUnsupported, - FunctionTools: proto.CapabilityUnsupported, - MCPHTTPTools: proto.CapabilityUnsupported, - MCPHTTPRequired: proto.CapabilityUnsupported, - MCPHTTPBearerAuth: proto.CapabilityUnsupported, + SubagentObservations: proto.CapabilityUnsupported, + Streaming: proto.CapabilityUnsupported, + Usage: proto.CapabilityUnsupported, + Resume: proto.CapabilityUnsupported, + NativeSessionRecovery: proto.CapabilityUnsupported, + RetainedNativeHistory: proto.CapabilityUnsupported, + Steering: proto.CapabilityUnsupported, + MessageItems: proto.CapabilityUnsupported, + ToolObservations: proto.CapabilityUnsupported, + EnvironmentNone: proto.CapabilityUnsupported, + LocalEnvironment: proto.CapabilityUnsupported, + Preparation: proto.CapabilityUnsupported, + WorkspaceReadPreparation: proto.CapabilityUnsupported, + WorkspaceOutputExport: proto.CapabilityUnsupported, + ExecutionControls: proto.CapabilityUnsupported, + StructuredOutput: proto.CapabilityUnsupported, + ToolSearch: proto.CapabilityUnsupported, + MessageImages: proto.CapabilityUnsupported, + FunctionResultImages: proto.CapabilityUnsupported, + SubagentControl: proto.CapabilityUnsupported, + DurableInputReceipts: proto.CapabilityUnsupported, + DurableTurns: proto.CapabilityUnsupported, + FunctionTools: proto.CapabilityUnsupported, + MCPHTTPTools: proto.CapabilityUnsupported, + MCPHTTPRequired: proto.CapabilityUnsupported, + MCPHTTPBearerAuth: proto.CapabilityUnsupported, } dst, src := reflect.ValueOf(&c).Elem(), reflect.ValueOf(overrides) for i := 0; i < src.NumField(); i++ { diff --git a/internal/agentdaemon/proto/version.go b/internal/agentdaemon/proto/version.go index 2dcbf7337..b8e55ce11 100644 --- a/internal/agentdaemon/proto/version.go +++ b/internal/agentdaemon/proto/version.go @@ -2,7 +2,7 @@ package proto // Version identifies the complete Core–Runtime wire contract. Change it when // removing or changing a payload or its semantics; deploy both endpoints together. -const Version = "0.16.0" +const Version = "0.17.0" // VersionCompatible accepts only this contract. Patch drift, prerelease suffixes // and malformed versions do not select an implicit compatibility path. diff --git a/internal/harnessconfig/admission.go b/internal/harnessconfig/admission.go deleted file mode 100644 index 2d864a739..000000000 --- a/internal/harnessconfig/admission.go +++ /dev/null @@ -1,80 +0,0 @@ -package harnessconfig - -import ( - "encoding/json" - "errors" - "slices" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -// ErrInvalidInput marks a configuration a Harness declaration rejects. Core -// reports it as an invalid request, without echoing submitted values. -var ErrInvalidInput = errors.New("invalid harness configuration") - -// Admission is the saved Agent and Environment a Harness declaration validates. -// Core projects its public types into it, so declarations stay independent of -// the public API package. -type Admission struct { - Model string - // Reasoning reports an explicit effort or summary. - Reasoning bool - ServiceTier string - TextVerbosity string - TextFormat string - TextSchema json.RawMessage - MultiAgent bool - Tools []json.RawMessage - // Environment is nil when the request names none. - Environment *AdmissionEnvironment -} - -// AdmissionEnvironment carries the placement and whether the Environment adds -// Skills, Plugins or capability directories. -type AdmissionEnvironment struct { - Type string - Extensions bool -} - -// ValidateMCPOrigins preserves outbound authority independently of Harness names. -// Workspace connections never stand in for service-network connections. -func (d Declaration) ValidateMCPOrigins(environment *AdmissionEnvironment, servers []proto.MCPHTTPServer) error { - for _, server := range servers { - switch server.ConnectionOrigin { - case "service": - if environment == nil || environment.Type != "none" { - return errors.New("Service-origin MCP requires the service-side environment:none profile.") - } - case "environment": - if environment == nil || (environment.Type != "openai_hosted" && environment.Type != "self_hosted") { - return errors.New("Environment-origin MCP requires a managed or self-hosted execution Environment.") - } - default: - return errors.New("MCP requires an explicit connection origin.") - } - if !slices.Contains(d.MCPOrigins, server.ConnectionOrigin) { - return errors.New("The configured engine does not support this MCP connection origin.") - } - } - return nil -} - -// RejectSubagentTools rejects the declared tool types that a Harness cannot -// combine with Subagents. -func RejectSubagentTools(admission Admission, unsupported ...string) error { - if !admission.MultiAgent { - return nil - } - for _, raw := range admission.Tools { - var tool struct { - Type string `json:"type"` - } - if json.Unmarshal(raw, &tool) != nil { - return ErrInvalidInput - } - if slices.Contains(unsupported, tool.Type) { - return errors.New("The configured Subagent profile does not support this tool combination.") - } - } - return nil -} diff --git a/internal/harnessconfig/builtin/admission_test.go b/internal/harnessconfig/builtin/admission_test.go index 114f42307..c13d87dbd 100644 --- a/internal/harnessconfig/builtin/admission_test.go +++ b/internal/harnessconfig/builtin/admission_test.go @@ -69,3 +69,14 @@ func TestSubagentDeclarationsPreserveSupportedBoundaries(t *testing.T) { }) } } + +func TestCodexRejectsCodeModeWithSubagents(t *testing.T) { + declaration, _ := Registry().Lookup("codex") + tools := []json.RawMessage{json.RawMessage(`{"type":"programmatic_tool_calling","enabled":true}`)} + for _, multiAgent := range []bool{false, true} { + err := declaration.ValidateConfiguration(harnessconfig.Admission{Model: "real-model", MultiAgent: multiAgent, Tools: tools}) + if (err == nil) == multiAgent { + t.Fatalf("multi_agent=%v: %v", multiAgent, err) + } + } +} diff --git a/internal/harnessconfig/builtin/native_test.go b/internal/harnessconfig/builtin/native_test.go index b17070962..8869462a6 100644 --- a/internal/harnessconfig/builtin/native_test.go +++ b/internal/harnessconfig/builtin/native_test.go @@ -13,13 +13,14 @@ func TestNativeModelParameters(t *testing.T) { kind, raw string valid bool }{ - {"codex", `{"model_reasoning_effort":"high"}`, true}, + {"codex", `{"model_reasoning_effort":"high"}`, false}, {"codex", `{"model_reasoning_effort":"private-sentinel","model_reasoning_effort":"high"}`, false}, {"claude_sdk", `{"thinking":{"type":"private-sentinel","type":"enabled"}}`, false}, {"codex", `{"model_reasoning_effort":{}}`, false}, {"codex", `{"model_reasoning_effort":"max"}`, false}, {"codex", `{"effort":"high"}`, false}, - {"claude_sdk", `{"effort":"max","thinking":{"type":"adaptive","display":"summarized"}}`, true}, + {"claude_sdk", `{"thinking":{"type":"adaptive","display":"summarized"}}`, true}, + {"claude_sdk", `{"effort":"max"}`, false}, {"claude_sdk", `{"thinking":{"type":"enabled","budgetTokens":1024}}`, true}, {"claude_sdk", `{"thinking":{"type":"disabled"}}`, true}, {"claude_sdk", `{"thinking":{"type":"enabled"}}`, true}, @@ -32,8 +33,9 @@ func TestNativeModelParameters(t *testing.T) { {"claude_sdk", `{"maxThinkingTokens":1024}`, false}, {"mcode", `{}`, true}, {"mcode", `{"effort":"high"}`, false}, - {"", `{"effort":"high"}`, true}, - {"", `{"model_reasoning_effort":"high"}`, true}, + {"", `{"thinking":{"type":"adaptive"}}`, true}, + {"", `{"effort":"high"}`, false}, + {"", `{"model_reasoning_effort":"high"}`, false}, {"", `{"secret":"private-value"}`, false}, {"unknown", `{}`, true}, {"unknown", `{"effort":"high"}`, false}, diff --git a/internal/harnessconfig/builtin/route_test.go b/internal/harnessconfig/builtin/route_test.go index cc8620627..6a074700f 100644 --- a/internal/harnessconfig/builtin/route_test.go +++ b/internal/harnessconfig/builtin/route_test.go @@ -21,7 +21,7 @@ func TestNativeProviderDeclarationIsSharedWithRuntime(t *testing.T) { if !reflect.DeepEqual(protocols, declared) { t.Fatalf("protocols: %v", declared) } - if config.AcceptsHarnessConfig() != (kind != "mcode") { + if config.AcceptsHarnessConfig() != (kind == "claude_sdk") { t.Fatal("native configuration support drift") } for _, protocol := range []string{"anthropic", "responses", "chat_completions"} { diff --git a/internal/harnessconfig/claudesdk/declaration.go b/internal/harnessconfig/claudesdk/declaration.go index 65676f297..d52648303 100644 --- a/internal/harnessconfig/claudesdk/declaration.go +++ b/internal/harnessconfig/claudesdk/declaration.go @@ -2,6 +2,12 @@ package claudesdk import ( + "encoding/json" + "errors" + "math" + "regexp" + "strings" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" ) @@ -18,35 +24,42 @@ var Declaration = harnessconfig.Declaration{ Providers: []harnessconfig.Provider{{Protocol: "anthropic"}}, }, Capabilities: proto.AgentKindCapabilities{ - SubagentObservations: proto.CapabilitySupported, - Streaming: proto.CapabilitySupported, - Usage: proto.CapabilitySupported, - Resume: proto.CapabilitySupported, - RetainedNativeHistory: proto.CapabilitySupported, - NativeSessionRecovery: proto.CapabilitySupported, - Steering: proto.CapabilitySupported, - MessageItems: proto.CapabilitySupported, - ToolObservations: proto.CapabilitySupported, - EnvironmentNone: proto.CapabilitySupported, - LocalEnvironment: proto.CapabilitySupported, - Preparation: proto.CapabilitySupported, - WorkspaceReadPreparation: proto.CapabilitySupported, - WorkspaceOutputExport: proto.CapabilitySupported, - ProgrammaticToolCallingDisable: proto.CapabilitySupported, - WebSearchControl: proto.CapabilityUnsupported, - ExecutionControls: proto.CapabilitySupported, - TextVerbosity: proto.CapabilityUnsupported, - StructuredOutput: proto.CapabilitySupported, - ToolSearch: proto.CapabilitySupported, - MessageImages: proto.CapabilitySupported, - FunctionResultImages: proto.CapabilitySupported, - SubagentControl: proto.CapabilitySupported, - DurableInputReceipts: proto.CapabilitySupported, - DurableTurns: proto.CapabilitySupported, - FunctionTools: proto.CapabilitySupported, - MCPHTTPTools: proto.CapabilitySupported, - MCPHTTPRequired: proto.CapabilitySupported, - MCPHTTPBearerAuth: proto.CapabilitySupported, + SubagentObservations: proto.CapabilitySupported, + Streaming: proto.CapabilitySupported, + Usage: proto.CapabilitySupported, + Resume: proto.CapabilitySupported, + RetainedNativeHistory: proto.CapabilitySupported, + NativeSessionRecovery: proto.CapabilitySupported, + Steering: proto.CapabilitySupported, + MessageItems: proto.CapabilitySupported, + ToolObservations: proto.CapabilitySupported, + EnvironmentNone: proto.CapabilitySupported, + LocalEnvironment: proto.CapabilitySupported, + Preparation: proto.CapabilitySupported, + WorkspaceReadPreparation: proto.CapabilitySupported, + WorkspaceOutputExport: proto.CapabilitySupported, + ExecutionControls: proto.CapabilitySupported, + StructuredOutput: proto.CapabilitySupported, + ToolSearch: proto.CapabilitySupported, + MessageImages: proto.CapabilitySupported, + FunctionResultImages: proto.CapabilitySupported, + SubagentControl: proto.CapabilitySupported, + DurableInputReceipts: proto.CapabilitySupported, + DurableTurns: proto.CapabilitySupported, + FunctionTools: proto.CapabilitySupported, + MCPHTTPTools: proto.CapabilitySupported, + MCPHTTPRequired: proto.CapabilitySupported, + MCPHTTPBearerAuth: proto.CapabilitySupported, + }, + // The SDK takes effort low to max. It has no verbosity, summary, service + // tier, search mode or search setting, and enabling programmatic tool calling + // is not wired. Search is off in the fixed tool inventory, so disabled is the + // only mode; medium is the official default verbosity and needs no setting. + Execution: harnessconfig.ExecutionOptions{ + WebSearchModes: []string{"disabled"}, + ReasoningEfforts: []string{"low", "medium", "high", "xhigh", "max"}, + ProgrammaticToolCalling: []bool{false}, + TextVerbosities: []string{"medium"}, }, Placements: []string{"none", "openai_hosted", "self_hosted"}, MCPOrigins: []string{"service", "environment"}, @@ -55,3 +68,133 @@ var Declaration = harnessconfig.Declaration{ ValidateTools: validateTools, ValidateFunctionResult: validateFunctionResult, } + +var ( + mcpLabel = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) + mcpTool = regexp.MustCompile(`^[a-zA-Z0-9_.-]+$`) +) + +// validateConfiguration holds the packaged adapter's execution limits on a saved +// Agent and Environment, not saved-Agent schema rules. +func validateConfiguration(admission harnessconfig.Admission) error { + environment := admission.Environment + if environment == nil || (environment.Type != "none" && environment.Type != "openai_hosted" && environment.Type != "self_hosted") || strings.TrimSpace(admission.Model) == "" { + return harnessconfig.ErrInvalidInput + } + if admission.TextFormat == "json_schema" { + if err := proto.ValidateBinary64Schema(admission.TextSchema); err != nil { + return err + } + if admission.MultiAgent { + return errors.New("Structured output requires a single Agent.") + } + if environment.Extensions { + return errors.New("Structured output with environment Skills or Plugins is not supported.") + } + for _, raw := range admission.Tools { + var tool struct { + Type string `json:"type"` + } + if json.Unmarshal(raw, &tool) != nil || (tool.Type != "function" && tool.Type != "web_search" && tool.Type != "programmatic_tool_calling") { + return harnessconfig.ErrInvalidInput + } + } + } else if admission.TextFormat != "" && admission.TextFormat != "text" { + return harnessconfig.ErrInvalidInput + } + search, otherTools := false, false + for _, raw := range admission.Tools { + var tool struct { + Type string `json:"type"` + } + if json.Unmarshal(raw, &tool) != nil { + return harnessconfig.ErrInvalidInput + } + search = search || tool.Type == "tool_search" + otherTools = otherTools || (tool.Type != "function" && tool.Type != "tool_search" && tool.Type != "web_search" && tool.Type != "programmatic_tool_calling") + } + if search && (admission.MultiAgent || otherTools || environment.Extensions || admission.TextFormat == "json_schema") { + return errors.New("Tool discovery requires a single-agent function profile without Skills or Plugins.") + } + return harnessconfig.RejectSubagentTools(admission, "function", "mcp") +} + +// validateTools applies execution limits of the packaged adapter. Shared +// resolution still owns URL, transport and credential-binding validation. +func validateTools(tools []proto.FunctionTool, servers []proto.MCPHTTPServer) error { + for _, server := range servers { + if !mcpLabel.MatchString(server.ServerLabel) || server.ServerLabel == "functions" || strings.ContainsAny(server.ServerURL, "?#") { + return errors.New("The configured engine does not support this HTTP MCP declaration.") + } + if server.AllowedTools != nil { + for _, name := range *server.AllowedTools { + if !mcpTool.MatchString(name) { + return errors.New("The configured engine does not support this MCP tool name.") + } + } + } + } + for _, tool := range tools { + var schema struct { + Type string `json:"type"` + } + if json.Unmarshal(tool.Parameters, &schema) != nil || schema.Type != "object" { + return errors.New("The configured engine currently requires function schemas with root type object.") + } + } + return nil +} + +func validateFunctionResult(result proto.FunctionResultPayload) error { + for _, part := range result.Content { + if part.Type == "input_image" && !result.Success { + return harnessconfig.ErrInvalidInput + } + } + if (proto.MessageInput{{Content: result.Content}}).ValidateInlineImages() != nil { + return harnessconfig.ErrInvalidInput + } + return nil +} + +func validateNativeConfig(config proto.HarnessConfig) bool { + for key, value := range config { + switch key { + case "thinking": + thinking, ok := value.(map[string]any) + if !ok || !validThinking(thinking) { + return false + } + default: + return false + } + } + return true +} + +// The pinned SDK's native ThinkingConfig union; deprecated maxThinkingTokens +// is intentionally absent so a thinking budget has exactly one owner. +func validThinking(thinking map[string]any) bool { + kind, ok := thinking["type"].(string) + if !ok || (kind != "adaptive" && kind != "enabled" && kind != "disabled") { + return false + } + for key, value := range thinking { + switch key { + case "type": + case "budgetTokens": + n, ok := value.(float64) + if kind != "enabled" || !ok || n < 1 || n > 9007199254740991 || math.Trunc(n) != n { + return false + } + case "display": + text, ok := value.(string) + if kind == "disabled" || !ok || (text != "summarized" && text != "omitted") { + return false + } + default: + return false + } + } + return true +} diff --git a/internal/harnessconfig/claudesdk/native.go b/internal/harnessconfig/claudesdk/native.go deleted file mode 100644 index 9606efd5d..000000000 --- a/internal/harnessconfig/claudesdk/native.go +++ /dev/null @@ -1,58 +0,0 @@ -package claudesdk - -import ( - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "math" -) - -func validateNativeConfig(config proto.HarnessConfig) bool { - for key, value := range config { - switch key { - case "effort": - text, ok := value.(string) - if !ok { - return false - } - switch text { - case "low", "medium", "high", "xhigh", "max": - default: - return false - } - case "thinking": - thinking, ok := value.(map[string]any) - if !ok || !validThinking(thinking) { - return false - } - default: - return false - } - } - return true -} - -// The pinned SDK's native ThinkingConfig union; deprecated maxThinkingTokens -// is intentionally absent so a thinking budget has exactly one owner. -func validThinking(thinking map[string]any) bool { - kind, ok := thinking["type"].(string) - if !ok || (kind != "adaptive" && kind != "enabled" && kind != "disabled") { - return false - } - for key, value := range thinking { - switch key { - case "type": - case "budgetTokens": - n, ok := value.(float64) - if kind != "enabled" || !ok || n < 1 || n > 9007199254740991 || math.Trunc(n) != n { - return false - } - case "display": - text, ok := value.(string) - if kind == "disabled" || !ok || (text != "summarized" && text != "omitted") { - return false - } - default: - return false - } - } - return true -} diff --git a/internal/harnessconfig/claudesdk/restrictions.go b/internal/harnessconfig/claudesdk/restrictions.go deleted file mode 100644 index 07cb47352..000000000 --- a/internal/harnessconfig/claudesdk/restrictions.go +++ /dev/null @@ -1,105 +0,0 @@ -package claudesdk - -import ( - "encoding/json" - "errors" - "regexp" - "strings" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" -) - -var ( - mcpLabel = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) - mcpTool = regexp.MustCompile(`^[a-zA-Z0-9_.-]+$`) -) - -// validateConfiguration holds the packaged adapter's execution limits on a saved -// Agent and Environment, not saved-Agent schema rules. -func validateConfiguration(admission harnessconfig.Admission) error { - environment := admission.Environment - if environment == nil || (environment.Type != "none" && environment.Type != "openai_hosted" && environment.Type != "self_hosted") || strings.TrimSpace(admission.Model) == "" { - return harnessconfig.ErrInvalidInput - } - if admission.TextVerbosity != "" && admission.TextVerbosity != "medium" { - return errors.New("The configured engine currently supports medium text verbosity only.") - } - if admission.Reasoning || (admission.ServiceTier != "" && admission.ServiceTier != "auto") { - return harnessconfig.ErrInvalidInput - } - if admission.TextFormat == "json_schema" { - if err := proto.ValidateBinary64Schema(admission.TextSchema); err != nil { - return err - } - if admission.MultiAgent { - return errors.New("Structured output requires a single Agent.") - } - if environment.Extensions { - return errors.New("Structured output with environment Skills or Plugins is not supported.") - } - for _, raw := range admission.Tools { - var tool struct { - Type string `json:"type"` - } - if json.Unmarshal(raw, &tool) != nil || (tool.Type != "function" && tool.Type != "web_search" && tool.Type != "programmatic_tool_calling") { - return harnessconfig.ErrInvalidInput - } - } - } else if admission.TextFormat != "" && admission.TextFormat != "text" { - return harnessconfig.ErrInvalidInput - } - search, otherTools := false, false - for _, raw := range admission.Tools { - var tool struct { - Type string `json:"type"` - } - if json.Unmarshal(raw, &tool) != nil { - return harnessconfig.ErrInvalidInput - } - search = search || tool.Type == "tool_search" - otherTools = otherTools || (tool.Type != "function" && tool.Type != "tool_search" && tool.Type != "web_search" && tool.Type != "programmatic_tool_calling") - } - if search && (admission.MultiAgent || otherTools || environment.Extensions || admission.TextFormat == "json_schema") { - return errors.New("Tool discovery requires a single-agent function profile without Skills or Plugins.") - } - return harnessconfig.RejectSubagentTools(admission, "function", "mcp") -} - -// validateTools applies execution limits of the packaged adapter. Shared -// resolution still owns URL, transport and credential-binding validation. -func validateTools(tools []proto.FunctionTool, servers []proto.MCPHTTPServer) error { - for _, server := range servers { - if !mcpLabel.MatchString(server.ServerLabel) || server.ServerLabel == "functions" || strings.ContainsAny(server.ServerURL, "?#") { - return errors.New("The configured engine does not support this HTTP MCP declaration.") - } - if server.AllowedTools != nil { - for _, name := range *server.AllowedTools { - if !mcpTool.MatchString(name) { - return errors.New("The configured engine does not support this MCP tool name.") - } - } - } - } - for _, tool := range tools { - var schema struct { - Type string `json:"type"` - } - if json.Unmarshal(tool.Parameters, &schema) != nil || schema.Type != "object" { - return errors.New("The configured engine currently requires function schemas with root type object.") - } - } - return nil -} - -func validateFunctionResult(result proto.FunctionResultPayload) error { - for _, part := range result.Content { - if part.Type == "input_image" && !result.Success { - return harnessconfig.ErrInvalidInput - } - } - if (proto.MessageInput{{Content: result.Content}}).ValidateInlineImages() != nil { - return harnessconfig.ErrInvalidInput - } - return nil -} diff --git a/internal/harnessconfig/codex/declaration.go b/internal/harnessconfig/codex/declaration.go index 375c3f39f..f906e99d5 100644 --- a/internal/harnessconfig/codex/declaration.go +++ b/internal/harnessconfig/codex/declaration.go @@ -2,6 +2,9 @@ package codex import ( + "encoding/json" + "errors" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" ) @@ -12,44 +15,75 @@ var Declaration = harnessconfig.Declaration{ Kind: "codex", Label: "Codex", Configuration: harnessconfig.Configuration{ - ValidateNativeConfig: validateNativeConfig, - Providers: []harnessconfig.Provider{{Protocol: "responses"}}, + Providers: []harnessconfig.Provider{{Protocol: "responses"}}, }, Capabilities: proto.AgentKindCapabilities{ - SubagentObservations: proto.CapabilitySupported, - Streaming: proto.CapabilitySupported, - Usage: proto.CapabilitySupported, - Resume: proto.CapabilitySupported, - RetainedNativeHistory: proto.CapabilitySupported, - NativeSessionRecovery: proto.CapabilitySupported, - Steering: proto.CapabilitySupported, - MessageItems: proto.CapabilitySupported, - ToolObservations: proto.CapabilitySupported, - EnvironmentNone: proto.CapabilitySupported, - LocalEnvironment: proto.CapabilitySupported, - Preparation: proto.CapabilitySupported, - WorkspaceReadPreparation: proto.CapabilitySupported, - WorkspaceOutputExport: proto.CapabilitySupported, - ProgrammaticToolCallingDisable: proto.CapabilitySupported, - WebSearchControl: proto.CapabilitySupported, - ExecutionControls: proto.CapabilitySupported, - TextVerbosity: proto.CapabilitySupported, - StructuredOutput: proto.CapabilityUnsupported, - ToolSearch: proto.CapabilityUnsupported, - MessageImages: proto.CapabilitySupported, - FunctionResultImages: proto.CapabilitySupported, - SubagentControl: proto.CapabilitySupported, - DurableInputReceipts: proto.CapabilitySupported, - DurableTurns: proto.CapabilitySupported, - FunctionTools: proto.CapabilitySupported, - MCPHTTPTools: proto.CapabilitySupported, - MCPHTTPRequired: proto.CapabilitySupported, - MCPHTTPBearerAuth: proto.CapabilitySupported, + SubagentObservations: proto.CapabilitySupported, + Streaming: proto.CapabilitySupported, + Usage: proto.CapabilitySupported, + Resume: proto.CapabilitySupported, + RetainedNativeHistory: proto.CapabilitySupported, + NativeSessionRecovery: proto.CapabilitySupported, + Steering: proto.CapabilitySupported, + MessageItems: proto.CapabilitySupported, + ToolObservations: proto.CapabilitySupported, + EnvironmentNone: proto.CapabilitySupported, + LocalEnvironment: proto.CapabilitySupported, + Preparation: proto.CapabilitySupported, + WorkspaceReadPreparation: proto.CapabilitySupported, + WorkspaceOutputExport: proto.CapabilitySupported, + ExecutionControls: proto.CapabilitySupported, + StructuredOutput: proto.CapabilityUnsupported, + ToolSearch: proto.CapabilityUnsupported, + MessageImages: proto.CapabilitySupported, + FunctionResultImages: proto.CapabilitySupported, + SubagentControl: proto.CapabilitySupported, + DurableInputReceipts: proto.CapabilitySupported, + DurableTurns: proto.CapabilitySupported, + FunctionTools: proto.CapabilitySupported, + MCPHTTPTools: proto.CapabilitySupported, + MCPHTTPRequired: proto.CapabilitySupported, + MCPHTTPBearerAuth: proto.CapabilitySupported, + }, + // Codex maps every official value natively: web_search, tools.web_search.*, + // model_reasoning_effort, model_reasoning_summary, service_tier, + // model_verbosity and features.code_mode. Native indexed search has no + // official value. Official auto service tier means unset, and fast is Codex's + // native alias of priority. Codex ignores a value the selected model does not + // support. + Execution: harnessconfig.ExecutionOptions{ + WebSearchModes: []string{"disabled", "cached", "live"}, + WebSearchSettings: true, + ReasoningEfforts: []string{"none", "minimal", "low", "medium", "high", "xhigh", "max"}, + ReasoningSummaries: []string{"concise", "detailed", "auto"}, + ServiceTiers: []string{"default", "flex", "priority", "fast"}, + ProgrammaticToolCalling: []bool{true, false}, + TextVerbosities: []string{"low", "medium", "high"}, }, Placements: []string{"none", "self_hosted", "openai_hosted"}, MCPOrigins: []string{"service", "environment"}, WhitespaceOnlyText: proto.CapabilitySupported, ValidateConfiguration: func(admission harnessconfig.Admission) error { - return harnessconfig.RejectSubagentTools(admission, "function", "mcp") + if err := harnessconfig.RejectSubagentTools(admission, "function", "mcp"); err != nil { + return err + } + // Subagent observation turns native code_mode off. + if admission.MultiAgent && enablesProgrammaticToolCalling(admission.Tools) { + return errors.New("programmatic_tool_calling.enabled=true is not supported with multi_agent by this Harness.") + } + return nil }, } + +func enablesProgrammaticToolCalling(tools []json.RawMessage) bool { + for _, raw := range tools { + var tool struct { + Type string `json:"type"` + Enabled *bool `json:"enabled"` + } + if json.Unmarshal(raw, &tool) == nil && tool.Type == "programmatic_tool_calling" && tool.Enabled != nil && *tool.Enabled { + return true + } + } + return false +} diff --git a/internal/harnessconfig/codex/native.go b/internal/harnessconfig/codex/native.go deleted file mode 100644 index 2f87051a3..000000000 --- a/internal/harnessconfig/codex/native.go +++ /dev/null @@ -1,21 +0,0 @@ -package codex - -import "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - -func validateNativeConfig(config proto.HarnessConfig) bool { - for key, value := range config { - if key != "model_reasoning_effort" { - return false - } - text, ok := value.(string) - if !ok { - return false - } - switch text { - case "none", "minimal", "low", "medium", "high", "xhigh": - default: - return false - } - } - return true -} diff --git a/internal/harnessconfig/configuration_test.go b/internal/harnessconfig/configuration_test.go index b56bcdc88..8c0e072c7 100644 --- a/internal/harnessconfig/configuration_test.go +++ b/internal/harnessconfig/configuration_test.go @@ -8,7 +8,7 @@ import ( func testDeclaration(kind string, configuration Configuration) Declaration { return Declaration{Kind: kind, Label: kind, Configuration: configuration, Capabilities: proto.UnsupportedCapabilities(), - Placements: []string{"none"}, MCPOrigins: []string{}, WhitespaceOnlyText: proto.CapabilityUnsupported} + Placements: []string{"none"}, MCPOrigins: []string{}, WhitespaceOnlyText: proto.CapabilityUnsupported, Execution: ExecutionOptions{TextVerbosities: []string{"medium"}}} } func TestRegistryOwnsDeclarations(t *testing.T) { @@ -60,6 +60,7 @@ func TestDeclarationRequiresExplicitSupport(t *testing.T) { "bearer": func(d *Declaration) { d.Capabilities.MCPHTTPBearerAuth = proto.CapabilitySupported }, "label": func(d *Declaration) { d.Label = " " }, "unknown kind": func(d *Declaration) { d.Kind = " example" }, + "verbosity": func(d *Declaration) { d.Execution.TextVerbosities = []string{"high"} }, } { declaration := valid.Clone() change(&declaration) diff --git a/internal/harnessconfig/harness.go b/internal/harnessconfig/harness.go index 650d90b6e..2137d1cb9 100644 --- a/internal/harnessconfig/harness.go +++ b/internal/harnessconfig/harness.go @@ -31,7 +31,9 @@ package harnessconfig import ( + "encoding/json" "errors" + "fmt" "maps" "slices" "strings" @@ -79,6 +81,9 @@ type Declaration struct { // MCPOrigins lists where HTTP MCP connections originate: "service" for the // service-side environment:none profile, "environment" for the Environment. MCPOrigins []string + // Execution declares which official execution option values the Harness + // maps to a native setting. Admission rejects any other user-set value. + Execution ExecutionOptions // WhitespaceOnlyText declares messages without an image or non-whitespace // text. Unsupported Harnesses reject them at admission; Core never trims or // pads model input to fit a Harness. @@ -104,6 +109,7 @@ func (d Declaration) Accepts(placement string) bool { func (d Declaration) Clone() Declaration { d.Configuration = d.Configuration.Clone() + d.Execution = d.Execution.Clone() d.Placements = slices.Clone(d.Placements) d.MCPOrigins = slices.Clone(d.MCPOrigins) return d @@ -134,6 +140,10 @@ func (d Declaration) ValidateDeclaration() error { (d.Capabilities.MCPHTTPBearerAuth.IsSupported() && len(d.MCPOrigins) == 0) { return errors.New("harnessconfig: invalid MCP origins") } + // Core always sends the official default text verbosity. + if !slices.Contains(d.Execution.TextVerbosities, "medium") { + return errors.New("harnessconfig: execution must declare text verbosity medium") + } return nil } @@ -298,3 +308,139 @@ func (r Registry) SupportsProtocol(protocol string) bool { } return false } + +// ErrInvalidInput marks a configuration a Harness declaration rejects. Core +// reports it as an invalid request, without echoing submitted values. +var ErrInvalidInput = errors.New("invalid harness configuration") + +// Admission is the saved Agent and Environment a Harness declaration validates. +// Core projects its public types into it, so declarations stay independent of +// the public API package. +type Admission struct { + Model string + TextFormat string + TextSchema json.RawMessage + MultiAgent bool + Tools []json.RawMessage + // Environment is nil when the request names none. + Environment *AdmissionEnvironment +} + +// AdmissionEnvironment carries the placement and whether the Environment adds +// Skills, Plugins or capability directories. +type AdmissionEnvironment struct { + Type string + Extensions bool +} + +// ValidateMCPOrigins preserves outbound authority independently of Harness names. +// Workspace connections never stand in for service-network connections. +func (d Declaration) ValidateMCPOrigins(environment *AdmissionEnvironment, servers []proto.MCPHTTPServer) error { + for _, server := range servers { + switch server.ConnectionOrigin { + case "service": + if environment == nil || environment.Type != "none" { + return errors.New("Service-origin MCP requires the service-side environment:none profile.") + } + case "environment": + if environment == nil || (environment.Type != "openai_hosted" && environment.Type != "self_hosted") { + return errors.New("Environment-origin MCP requires a managed or self-hosted execution Environment.") + } + default: + return errors.New("MCP requires an explicit connection origin.") + } + if !slices.Contains(d.MCPOrigins, server.ConnectionOrigin) { + return errors.New("The configured engine does not support this MCP connection origin.") + } + } + return nil +} + +// RejectSubagentTools rejects the declared tool types that a Harness cannot +// combine with Subagents. +func RejectSubagentTools(admission Admission, unsupported ...string) error { + if !admission.MultiAgent { + return nil + } + for _, raw := range admission.Tools { + var tool struct { + Type string `json:"type"` + } + if json.Unmarshal(raw, &tool) != nil { + return ErrInvalidInput + } + if slices.Contains(unsupported, tool.Type) { + return errors.New("The configured Subagent profile does not support this tool combination.") + } + } + return nil +} + +// ExecutionOptions lists the official execution option values a Harness maps to +// a native setting. An empty set declares the option unsupported. Absent +// options need no declaration: they follow the Harness's native default. Auto +// service tier means unset, so it is always accepted. +type ExecutionOptions struct { + // WebSearchModes are the supported web_search modes: disabled, cached, live. + WebSearchModes []string + // WebSearchSettings reports native homes for context_size, allowed_domains + // and location. + WebSearchSettings bool + // ReasoningEfforts and ReasoningSummaries are the supported reasoning values. + ReasoningEfforts, ReasoningSummaries []string + // ServiceTiers are the supported tiers other than auto. + ServiceTiers []string + // ProgrammaticToolCalling lists the supported enabled values. + ProgrammaticToolCalling []bool + // TextVerbosities are the supported text verbosity levels. + TextVerbosities []string +} + +func (o ExecutionOptions) Clone() ExecutionOptions { + o.WebSearchModes = slices.Clone(o.WebSearchModes) + o.ReasoningEfforts = slices.Clone(o.ReasoningEfforts) + o.ReasoningSummaries = slices.Clone(o.ReasoningSummaries) + o.ServiceTiers = slices.Clone(o.ServiceTiers) + o.ProgrammaticToolCalling = slices.Clone(o.ProgrammaticToolCalling) + o.TextVerbosities = slices.Clone(o.TextVerbosities) + return o +} + +// ValidateExecutionControls rejects a user-set or default value the Harness +// does not declare. Core admission and the Runtime call it with the same +// controls, so neither keeps a second list. Messages name the field and never +// echo configuration other than the rejected value. +func (d Declaration) ValidateExecutionControls(controls *proto.ExecutionControls) error { + if controls == nil { + return nil + } + o := d.Execution + reject := func(field, value string) error { + return fmt.Errorf("%s %q is not supported by this Harness.", field, value) + } + if search := controls.WebSearch; search != nil { + if !slices.Contains(o.WebSearchModes, search.Mode) { + return reject("web_search.mode", search.Mode) + } + // Settings of disabled search configure nothing. + if search.Mode != "disabled" && !o.WebSearchSettings && (search.ContextSize != "medium" || search.AllowedDomains != nil || search.Location != nil) { + return errors.New("web_search context_size, allowed_domains and location are not supported by this Harness.") + } + } + if enabled := controls.ProgrammaticToolCalling; enabled != nil && !slices.Contains(o.ProgrammaticToolCalling, *enabled) { + return fmt.Errorf("programmatic_tool_calling.enabled=%t is not supported by this Harness.", *enabled) + } + if v := controls.ReasoningEffort; v != "" && !slices.Contains(o.ReasoningEfforts, v) { + return reject("reasoning.effort", v) + } + if v := controls.ReasoningSummary; v != "" && !slices.Contains(o.ReasoningSummaries, v) { + return reject("reasoning.summary", v) + } + if v := controls.ServiceTier; v != "" && !slices.Contains(o.ServiceTiers, v) { + return reject("service_tier", v) + } + if v := controls.TextVerbosity; v != "" && !slices.Contains(o.TextVerbosities, v) { + return reject("text.verbosity", v) + } + return nil +} diff --git a/internal/harnessconfig/harnesstest/fixture.go b/internal/harnessconfig/harnesstest/fixture.go index 7cd855033..9c5ff5020 100644 --- a/internal/harnessconfig/harnesstest/fixture.go +++ b/internal/harnessconfig/harnesstest/fixture.go @@ -7,12 +7,14 @@ import ( ) // Declaration supports only common text admission in the "none" placement by -// default: every capability is unsupported. change adjusts the result. +// default: every capability is unsupported, with the official default medium +// verbosity that Core always sends. change adjusts the result. func Declaration(kind string, change func(*harnessconfig.Declaration)) harnessconfig.Declaration { d := harnessconfig.Declaration{ Kind: kind, Label: kind, Capabilities: proto.UnsupportedCapabilities(), + Execution: harnessconfig.ExecutionOptions{TextVerbosities: []string{"medium"}}, Placements: []string{"none"}, MCPOrigins: []string{}, WhitespaceOnlyText: proto.CapabilityUnsupported, diff --git a/internal/harnessconfig/mcode/declaration.go b/internal/harnessconfig/mcode/declaration.go index ddf3bcce6..185fc5b32 100644 --- a/internal/harnessconfig/mcode/declaration.go +++ b/internal/harnessconfig/mcode/declaration.go @@ -22,35 +22,41 @@ var Declaration = harnessconfig.Declaration{ {Protocol: "chat_completions", RequiresTokenLimits: true}, }}, Capabilities: proto.AgentKindCapabilities{ - SubagentObservations: proto.CapabilitySupported, - Streaming: proto.CapabilitySupported, - Usage: proto.CapabilityUnsupported, - Resume: proto.CapabilitySupported, - RetainedNativeHistory: proto.CapabilityUnsupported, - NativeSessionRecovery: proto.CapabilityUnsupported, - Steering: proto.CapabilitySupported, - MessageItems: proto.CapabilityUnsupported, - ToolObservations: proto.CapabilitySupported, - EnvironmentNone: proto.CapabilitySupported, - LocalEnvironment: proto.CapabilitySupported, - Preparation: proto.CapabilitySupported, - WorkspaceReadPreparation: proto.CapabilitySupported, - WorkspaceOutputExport: proto.CapabilitySupported, - ProgrammaticToolCallingDisable: proto.CapabilitySupported, - WebSearchControl: proto.CapabilityUnsupported, - ExecutionControls: proto.CapabilitySupported, - TextVerbosity: proto.CapabilityUnsupported, - StructuredOutput: proto.CapabilityUnsupported, - ToolSearch: proto.CapabilityUnsupported, - MessageImages: proto.CapabilityUnsupported, - FunctionResultImages: proto.CapabilityUnsupported, - SubagentControl: proto.CapabilitySupported, - DurableInputReceipts: proto.CapabilitySupported, - DurableTurns: proto.CapabilitySupported, - FunctionTools: proto.CapabilityUnsupported, - MCPHTTPTools: proto.CapabilitySupported, - MCPHTTPRequired: proto.CapabilityUnsupported, - MCPHTTPBearerAuth: proto.CapabilitySupported, + SubagentObservations: proto.CapabilitySupported, + Streaming: proto.CapabilitySupported, + Usage: proto.CapabilityUnsupported, + Resume: proto.CapabilitySupported, + RetainedNativeHistory: proto.CapabilityUnsupported, + NativeSessionRecovery: proto.CapabilityUnsupported, + Steering: proto.CapabilitySupported, + MessageItems: proto.CapabilityUnsupported, + ToolObservations: proto.CapabilitySupported, + EnvironmentNone: proto.CapabilitySupported, + LocalEnvironment: proto.CapabilitySupported, + Preparation: proto.CapabilitySupported, + WorkspaceReadPreparation: proto.CapabilitySupported, + WorkspaceOutputExport: proto.CapabilitySupported, + ExecutionControls: proto.CapabilitySupported, + StructuredOutput: proto.CapabilityUnsupported, + ToolSearch: proto.CapabilityUnsupported, + MessageImages: proto.CapabilityUnsupported, + FunctionResultImages: proto.CapabilityUnsupported, + SubagentControl: proto.CapabilitySupported, + DurableInputReceipts: proto.CapabilitySupported, + DurableTurns: proto.CapabilitySupported, + FunctionTools: proto.CapabilityUnsupported, + MCPHTTPTools: proto.CapabilitySupported, + MCPHTTPRequired: proto.CapabilityUnsupported, + MCPHTTPBearerAuth: proto.CapabilitySupported, + }, + // Native search needs a MiniMax account credential that Core does not hold, so + // features.webSearch stays false and disabled is the only mode. MiniMax Code has + // no native home for effort, summary, service tier or programmatic tool calling. + // Medium is the official default verbosity and needs no setting. + Execution: harnessconfig.ExecutionOptions{ + WebSearchModes: []string{"disabled"}, + ProgrammaticToolCalling: []bool{false}, + TextVerbosities: []string{"medium"}, }, Placements: []string{"none", "openai_hosted", "self_hosted"}, MCPOrigins: []string{"environment"}, @@ -58,8 +64,7 @@ var Declaration = harnessconfig.Declaration{ ValidateConfiguration: func(admission harnessconfig.Admission) error { environment := admission.Environment if environment == nil || (environment.Type != "none" && environment.Type != "openai_hosted" && environment.Type != "self_hosted") || - strings.TrimSpace(admission.Model) == "" || admission.Reasoning || (admission.ServiceTier != "" && admission.ServiceTier != "auto") || - (admission.TextFormat != "" && admission.TextFormat != "text") || (admission.TextVerbosity != "" && admission.TextVerbosity != "medium") { + strings.TrimSpace(admission.Model) == "" || (admission.TextFormat != "" && admission.TextFormat != "text") { return harnessconfig.ErrInvalidInput } return harnessconfig.RejectSubagentTools(admission, "mcp") diff --git a/packages/agents-client/README.md b/packages/agents-client/README.md index 0e471313f..8d077867f 100644 --- a/packages/agents-client/README.md +++ b/packages/agents-client/README.md @@ -38,9 +38,9 @@ import { OpenAIAgentsClient } from "@oac/agents-client"; const client = new OpenAIAgentsClient({ baseUrl: apiBaseURL, token: projectAPIKey }); const agent = await client.createAgent({ model: "requested-model", + reasoning: { effort: "high" }, x_agents_core: { harness: "codex", - harness_config: { model_reasoning_effort: "high" }, model_provider: { protocol: "responses", base_url: modelBaseURL, @@ -82,7 +82,6 @@ if (frozen.model_provider.status === "available") { await admin.setHarnessModelConfiguration("codex", { model_provider: { protocol: "responses", base_url: modelBaseURL, api_key: modelAPIKey }, model: "requested-model", - harness_config: { model_reasoning_effort: "high" }, }); const defaults = await admin.retrieveHarnessModelConfiguration("codex"); console.log(defaults.model, defaults.harness_config, defaults.model_provider.api_key_configured); diff --git a/packages/agents-client/src/admin-client.test.ts b/packages/agents-client/src/admin-client.test.ts index b240b71f4..385518bf5 100644 --- a/packages/agents-client/src/admin-client.test.ts +++ b/packages/agents-client/src/admin-client.test.ts @@ -154,7 +154,7 @@ describe("AdminClient transport boundary", () => { }); it("manages deployment default model providers without ever reading a key", async () => { - const provider = { last_used_at: null, last_error_code: null, last_error_at: null, object: "core.model_configuration", harness: "codex", model: "test-model", harness_config: { model_reasoning_effort: "high" }, model_provider: { protocol: "responses", base_url: "https://model.example/v1", api_key_configured: true }, updated_at: "2026-09-26T08:00:00Z" }; + const provider = { last_used_at: null, last_error_code: null, last_error_at: null, object: "core.model_configuration", harness: "codex", model: "test-model", harness_config: {}, model_provider: { protocol: "responses", base_url: "https://model.example/v1", api_key_configured: true }, updated_at: "2026-09-26T08:00:00Z" }; const harnesses = { object: "list", data: [ { object: "core.harness", id: "claude_sdk", enabled: false, default: false, model_configuration: null, model_configuration_support: { protocols: ["anthropic"], accepts_harness_config: true, token_limits_required: false } }, { object: "core.harness", id: "codex", enabled: true, default: true, model_configuration: provider, model_configuration_support: { protocols: ["responses"], accepts_harness_config: true, token_limits_required: false } }, @@ -162,7 +162,7 @@ describe("AdminClient transport boundary", () => { expect(await clientWith(harnesses).client.listHarnesses()).toEqual(harnesses); expect(await clientWith(provider).client.retrieveHarnessModelConfiguration("codex")).toEqual(provider); const { client, fetch } = clientWith(provider); - const input = { model: "test-model", harness_config: { model_reasoning_effort: "high" }, model_provider: { protocol: "responses", base_url: "https://model.example/v1", api_key: "write-only", context_window: 200000, max_output_tokens: 8000 } } as const; + const input = { model: "test-model", harness_config: {}, model_provider: { protocol: "responses", base_url: "https://model.example/v1", api_key: "write-only", context_window: 200000, max_output_tokens: 8000 } } as const; expect(await client.setHarnessModelConfiguration("codex", input)).toEqual(provider); expect(JSON.parse(fetch.mock.calls[0]![1]!.body as string)).toEqual(input); for (const unsafe of [{ ...provider, api_key: "leak" }, { ...provider, harness: "mcode" }, { ...provider, model_provider: { ...provider.model_provider, api_key_configured: false } }, { ...provider, model_provider: { ...provider.model_provider, base_url: "http://model.example/v1" } }, { ...provider, model_provider: { ...provider.model_provider, api_key: "leak" } }, { ...provider, model: "" }, { ...provider, harness_config: [] }, { ...provider, extra: 1 }]) { diff --git a/packages/agents-client/src/execution-configuration.test.ts b/packages/agents-client/src/execution-configuration.test.ts index c3938f2f9..4a2f36dcd 100644 --- a/packages/agents-client/src/execution-configuration.test.ts +++ b/packages/agents-client/src/execution-configuration.test.ts @@ -6,8 +6,8 @@ const projectId = "11111111-1111-4111-8111-111111111111"; const id = "013773a9-44b9-4f84-baca-b51c04a01201"; const snapshot: SessionExecutionConfiguration = { object: "agent.session.execution_configuration", schema_version: 1, session_id: id, - model: { value: "frozen-model", source: "session" }, harness: { value: "codex", source: "agent" }, - harness_config: { value: { model_reasoning_effort: "high" }, source: "agent" }, + model: { value: "frozen-model", source: "session" }, harness: { value: "claude_sdk", source: "agent" }, + harness_config: { value: { thinking: { type: "adaptive" } }, source: "agent" }, model_provider: { status: "available", source: "agent", configuration: { protocol: "responses", base_url: "https://model.example/v1", api_key_configured: true } }, }; function clientReturning(value: unknown, seen?: (url: string, init?: RequestInit) => void) { diff --git a/packages/agents-client/src/provider-observations.test.ts b/packages/agents-client/src/provider-observations.test.ts index 15d6d444b..a910be149 100644 --- a/packages/agents-client/src/provider-observations.test.ts +++ b/packages/agents-client/src/provider-observations.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { projectCoreHarnessList, projectHarnessModelConfiguration } from "./admin-projection"; -const provider = { object: "core.model_configuration", harness: "codex", model: "test-model", harness_config: { model_reasoning_effort: "high" }, model_provider: { protocol: "responses", base_url: "https://model.example/v1", api_key_configured: true }, updated_at: "2026-09-26T08:00:00Z", last_used_at: null, last_error_code: null, last_error_at: null }; +const provider = { object: "core.model_configuration", harness: "codex", model: "test-model", harness_config: {}, model_provider: { protocol: "responses", base_url: "https://model.example/v1", api_key_configured: true }, updated_at: "2026-09-26T08:00:00Z", last_used_at: null, last_error_code: null, last_error_at: null }; describe("deployment default observations", () => { it("preserves null and safe observations", () => { expect(projectHarnessModelConfiguration(provider)).toEqual(provider); diff --git a/packages/agents-client/src/saved-agent-defaults.test.ts b/packages/agents-client/src/saved-agent-defaults.test.ts index 573b822ef..6f8c2cbdc 100644 --- a/packages/agents-client/src/saved-agent-defaults.test.ts +++ b/packages/agents-client/src/saved-agent-defaults.test.ts @@ -8,7 +8,7 @@ describe("saved Agent execution defaults", () => { const calls: unknown[] = []; const safe: SavedAgentCore = { harness: "codex", - harness_config: { model_reasoning_effort: "high" }, + harness_config: {}, model_provider: { protocol, base_url: "https://model.example/v1", api_key_configured: true, }, @@ -25,7 +25,7 @@ describe("saved Agent execution defaults", () => { const provider: ModelProviderInput = { protocol, base_url: "https://model.example/v1", api_key: "write-only-fixture", }; - const agent = await client.createAgent({ model: "example-model", x_agents_core: { harness: "codex", harness_config: { model_reasoning_effort: "high" }, model_provider: provider } }); + const agent = await client.createAgent({ model: "example-model", x_agents_core: { harness: "codex", harness_config: {}, model_provider: provider } }); expect(agent.x_agents_core).toEqual(safe); expect(JSON.stringify(agent)).not.toContain("write-only-fixture"); await client.updateAgent(agent.id, { model: "new-model" }); @@ -35,7 +35,7 @@ describe("saved Agent execution defaults", () => { await client.updateAgent(agent.id, { x_agents_core: { harness_config: {} } }); await client.updateAgent(agent.id, { x_agents_core: null }); expect(calls).toEqual([ - { model: "example-model", x_agents_core: { harness: "codex", harness_config: { model_reasoning_effort: "high" }, model_provider: provider } }, + { model: "example-model", x_agents_core: { harness: "codex", harness_config: {}, model_provider: provider } }, { model: "new-model" }, { x_agents_core: { harness: "codex" } }, { x_agents_core: { model_provider: provider } }, @@ -76,7 +76,7 @@ describe("native model configuration projections", () => { id: "test-agent", model: "test-model", name: null, instructions: null, multi_agent: { enabled: false, max_concurrent_subagents: null }, reasoning: { effort: null, summary: null }, service_tier: "auto", text: { format: { type: "text" }, verbosity: "medium" }, tools: [], }; - for (const harness_config of [{}, { effort: "high", thinking: { type: "adaptive" } }]) { + for (const harness_config of [{}, { thinking: { type: "adaptive" } }]) { const value = { ...agent, x_agents_core: { harness_config } }; expect(projectAgentSnapshot(value).x_agents_core).toEqual({ harness_config }); expect(projectSavedAgentConfiguration(value).x_agents_core).toEqual({ harness_config }); diff --git a/packages/claude-sdk-adapter/README.md b/packages/claude-sdk-adapter/README.md index 8b1c9a525..f0a8b4a41 100644 --- a/packages/claude-sdk-adapter/README.md +++ b/packages/claude-sdk-adapter/README.md @@ -74,7 +74,7 @@ The SDK descriptor advertises the validated daemon subset, including durable Tur Core owns public admission for Claude: [harness selection](../../contracts/agents-api/model-execution.md#harness-selection) chooses the engine for each Session; one [engine policy](../../contracts/agents-api/harness-onboarding.md#declare-the-harness) serves API admission, device selection and the final claim; the [supported operations](../../contracts/agents-api/harness-capabilities.md) table records Claude's medium-only verbosity and object-root function schemas; [function result images](../../contracts/agents-api/message-content.md#function-results) defines which placements accept image results; and [deployment defaults](../../contracts/agents-api/model-execution.md#deployment-defaults) define the model provider Core freezes for a Session. The adapter receives that provider as the adapter-owned `model_provider`, never in public Session configuration. Without one, a `none` host uses the daemon's own provider environment. The adapter alone selects the provider environment and removes credentials from native tool environments. -The `none` profile accepts only text, explicit model/system instructions, managed state, exact native resume and declared functions with ordered text or successful inline PNG/JPEG results, and the HTTP MCP subset described above. It rejects unsupported request options and disables built-in tools and undeclared MCP discovery. `DisableExecutionEnvironment` and `DisableSubagents` are accepted assertions about the single-Agent restrictive profile. Omission does not enable built-in tools. Explicit Subagent observation enables only the native delegation tools described under [Subagents](#subagents). Single-Agent new and resumed queries use the SDK's empty built-in tool set, explicit function MCP configuration and allowlist, strict MCP configuration and empty user/project/local setting sources. Without HTTP MCP declarations, native initialization and real provider request inventories must contain only the declared host functions. Managed operator policy may further restrict execution; it must not widen the profile. The profile limits model tool access; it does not isolate native state files or filesystem access by an explicitly supplied host function. The private factory accepts typed execution controls only for disabled search and medium text verbosity. Search remains excluded by the native tool inventory; medium retains the SDK's default text generation, without adding instructions or changing caller input. The pinned SDK has no native verbosity-level option, so low and high verbosity and enabled search are unsupported. Missing/invalid fields in a supplied control block fail before native setup; omitting the block keeps the same restrictive profile. Use the SDK's history lookup before explicit resume; never fall back to a new Session. Native files remain device-affine under a caller-selected managed runtime directory. The launch configuration supplies trusted provider environment; request options cannot supply environment variables or business write authority. Omitted, null and empty `system_prompt` map to empty SDK instructions only at this adapter boundary; null model values and unsupported options remain rejected. +The `none` profile accepts only text, explicit model/system instructions, managed state, exact native resume and declared functions with ordered text or successful inline PNG/JPEG results, and the HTTP MCP subset described above. It rejects unsupported request options and disables built-in tools and undeclared MCP discovery. `DisableExecutionEnvironment` and `DisableSubagents` are accepted assertions about the single-Agent restrictive profile. Omission does not enable built-in tools. Explicit Subagent observation enables only the native delegation tools described under [Subagents](#subagents). Single-Agent new and resumed queries use the SDK's empty built-in tool set, explicit function MCP configuration and allowlist, strict MCP configuration and empty user/project/local setting sources. Without HTTP MCP declarations, native initialization and real provider request inventories must contain only the declared host functions. Managed operator policy may further restrict execution; it must not widen the profile. The profile limits model tool access; it does not isolate native state files or filesystem access by an explicitly supplied host function. The private factory accepts the execution controls in the [Claude declaration](../../internal/harnessconfig/claudesdk/declaration.go): disabled search, medium text verbosity and reasoning effort, which maps to the SDK's `effort` option. Search remains excluded by the native tool inventory; medium retains the SDK's default text generation, without adding instructions or changing caller input. The pinned SDK has no verbosity, summary or service-tier option. An undeclared value fails before native setup; omitting the block keeps the same restrictive profile. Use the SDK's history lookup before explicit resume; never fall back to a new Session. Native files remain device-affine under a caller-selected managed runtime directory. The launch configuration supplies trusted provider environment; request options cannot supply environment variables or business write authority. Omitted, null and empty `system_prompt` map to empty SDK instructions only at this adapter boundary; null model values and unsupported options remain rejected. ### Function server and results diff --git a/services/core/deploy/codex/README.md b/services/core/deploy/codex/README.md index 303b3d3f4..61edf7e7e 100644 --- a/services/core/deploy/codex/README.md +++ b/services/core/deploy/codex/README.md @@ -21,9 +21,9 @@ By default Codex retries a failed model connection forever, waiting up to 60 sec The adapter applies Core's typed controls and opt-ins, described in the [Runtime protocol](../../../../docs/runtime-protocol.md#capability-declarations), through native Codex settings on both new and resumed Turns. - **`environment: none`.** The daemon forces `CODEX_EXEC_SERVER_URL=none` and confirms that Codex reports its `local` and `remote` environments as unknown before it starts or resumes a thread; a binary that cannot do this fails closed. The engine process runs on the bound device, which is not a user execution environment. -- **Web search.** The control becomes Codex's `web_search` option (`disabled`, `cached` or `live`); Core sends `disabled`. -- **Programmatic tool calling.** An explicit disable turns off the native `code_mode`, `code_mode_only` and `code_mode_prewarm` features and checks managed requirements before a thread starts or resumes, rejecting a conflicting requirement. -- **Text verbosity.** The adapter reads the model catalog with `codex debug models`, checks the model's support and pins that catalog snapshot for the execution. The probe needs Unix process-group cancellation, so other hosts do not declare `text_verbosity`. For a model without declared verbosity support, including Codex's unknown-model fallback, `medium` omits the override and keeps native default text; a supported model receives an explicit `medium`. Unsupported `low` or `high` and an unreadable catalog fail before model execution. +- **Web search.** The control becomes Codex's `web_search` option (`disabled`, `cached` or `live`) with `tools.web_search` `context_size`, `allowed_domains` and `location`. +- **Programmatic tool calling.** `enabled: true` turns on the native `code_mode` feature. An explicit disable turns off the native `code_mode`, `code_mode_only` and `code_mode_prewarm` features and checks managed requirements before a thread starts or resumes, rejecting a conflicting requirement. +- **Reasoning, service tier and verbosity.** `reasoning.effort` and `reasoning.summary` become `model_reasoning_effort` and `model_reasoning_summary`, a non-`auto` service tier becomes `service_tier` and the text verbosity becomes `model_verbosity`. - **Subagents.** Disabling Subagents turns off both native multi-agent feature generations, `multi_agent` and `multi_agent_v2`, overriding the operator's feature preferences. ## MCP servers diff --git a/services/core/internal/api/configuration_validation_test.go b/services/core/internal/api/configuration_validation_test.go index e553afdd7..3126f3170 100644 --- a/services/core/internal/api/configuration_validation_test.go +++ b/services/core/internal/api/configuration_validation_test.go @@ -227,7 +227,7 @@ 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. + // TV-05: every pinned web_search mode is saved. `"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"}}]`, @@ -253,21 +253,15 @@ func TestAgentConfigurationAcceptedValuesUnchanged(t *testing.T) { } } -// K2: execution admission limits keep their codes. Enabled or omitted-mode -// web_search is saved (TV-05) but still rejected at Session admission. +// K2: execution admission limits keep their codes, and a value the selected +// Harness does not declare is rejected with the field named. 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."}, {`"text":{"format":{"type":"json_schema","schema":{"type":"object"}}}`, "Harness codex does not support the requested Agent/environment configuration: Structured output is not supported by this engine."}, - {`"reasoning":{"effort":"max"}`, "Explicit reasoning execution options are not supported by this service yet."}, - {`"service_tier":"flex"`, "Execution currently supports service_tier=auto only."}, + {`"multi_agent":{"enabled":true},"tools":[{"type":"programmatic_tool_calling","enabled":true}]`, "Harness codex does not support the requested Agent/environment configuration: programmatic_tool_calling.enabled=true is not supported with multi_agent by this Harness."}, {`"tools":[{"type":"function","name":"","description":"","parameters":{}}]`, "Function names must be nonempty, unique and at most 512 bytes."}, } { assertConfigurationError(t, credentialRequest(h, http.MethodPost, session.path, session.body(tc.fields)), "unsupported_or_invalid_configuration", nil, tc.message) diff --git a/services/core/internal/api/function_configuration_test.go b/services/core/internal/api/function_configuration_test.go index 33bb9c3a7..2a612aa16 100644 --- a/services/core/internal/api/function_configuration_test.go +++ b/services/core/internal/api/function_configuration_test.go @@ -44,7 +44,7 @@ func TestPublicFunctionConfiguration(t *testing.T) { func TestPublicFunctionConfigurationRejectsInvalidOrUnsupported(t *testing.T) { base := `"type":"function","name":"lookup","description":"","parameters":{}` for _, raw := range []string{ - `[null]`, `[{}]`, `[{"type":"web_search"}]`, `[{"type":"function","name":"lookup","parameters":{}}]`, + `[null]`, `[{}]`, `[{"type":"function","name":"lookup","parameters":{}}]`, `[{"type":"function","name":null,"description":"","parameters":{}}]`, `[{"type":"function","name":"lookup","description":null,"parameters":{}}]`, `[{"type":"function","name":"lookup","description":""}]`, diff --git a/services/core/internal/api/model_configuration_route_test.go b/services/core/internal/api/model_configuration_route_test.go index 882032148..69603c144 100644 --- a/services/core/internal/api/model_configuration_route_test.go +++ b/services/core/internal/api/model_configuration_route_test.go @@ -12,7 +12,7 @@ func TestModelConfigurationRouteAdmission(t *testing.T) { {"invalid model", "responses", "{}", "", "model_configuration_model_invalid", "model"}, {"reserved parameter", "responses", `{"api_key":"native-secret"}`, "model", "harness_config_invalid", "harness_config"}, {"unknown parameter", "responses", `{"unknown":"native-secret"}`, "model", "harness_config_invalid", "harness_config"}, - {"non native route", "anthropic", `{"model_reasoning_effort":"high"}`, "model", "model_provider_protocol_unsupported", "protocol"}, + {"non native route", "anthropic", `{}`, "model", "model_provider_protocol_unsupported", "protocol"}, {"null", "responses", "null", "model", "invalid_model_provider", ""}, } { t.Run(tc.name, func(t *testing.T) { diff --git a/services/core/internal/api/saved_tools.go b/services/core/internal/api/saved_tools.go index e15a41c7e..6cb301e03 100644 --- a/services/core/internal/api/saved_tools.go +++ b/services/core/internal/api/saved_tools.go @@ -34,7 +34,7 @@ func resolveSavedTools(input []json.RawMessage) ([]json.RawMessage, error) { case "tool_search": value, _ = json.Marshal(kind) case "programmatic_tool_calling": - resolved, _, err := resolveProgrammaticTool(raw) + resolved, err := resolveProgrammaticTool(raw) if err != nil { return nil, err } @@ -46,7 +46,7 @@ func resolveSavedTools(input []json.RawMessage) ([]json.RawMessage, error) { } value = resolved case "web_search": - resolved, err := resolveSavedWebSearch(raw) + resolved, err := resolveWebSearch(raw) if err != nil { return nil, err } diff --git a/services/core/internal/api/saved_web_search_test.go b/services/core/internal/api/saved_web_search_test.go index d377e2378..0b3d7c3b3 100644 --- a/services/core/internal/api/saved_web_search_test.go +++ b/services/core/internal/api/saved_web_search_test.go @@ -8,10 +8,8 @@ import ( "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. +// projection, and Session admission resolves it to the same bytes. func TestSavedWebSearchProjection(t *testing.T) { const defaults = `"context_size":"medium","allowed_domains":null,"location":null}` live := `{"type":"web_search","mode":"live",` + defaults @@ -38,14 +36,8 @@ func TestSavedWebSearchProjection(t *testing.T) { 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) + if err != nil || string(executed[0]) != tc.want { + t.Fatalf("%s: Session resolution changed the saved tool: %s %v", tc.input, executed, err) } } } @@ -64,74 +56,36 @@ func TestSavedWebSearchAgentCreate(t *testing.T) { } } -// 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. +// TV-05 W4-W7: a Session keeps the saved search mode, an inline omitted mode +// resolves to live, and a per-Session tools replacement wins. 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) + const cached = `{"type":"web_search","mode":"cached","context_size":"medium","allowed_domains":[],"location":null}` + id := uuid.NewString() + h, _ := configurationHandler(t, map[string]string{id: saved(cached)}) + for _, tc := range []struct{ body, want string }{ + {`{"agent_id":"` + id + `","environment":{"type":"none"},"input":"x"}`, cached}, + {`{"agent_id":"` + id + `","agent":{"instructions":"x"},"environment":{"type":"none"},"input":"x"}`, cached}, + {`{"agent_id":"` + id + `","agent":{"tools":[{"type":"web_search"}]},"environment":{"type":"none"},"input":"x"}`, `{"type":"web_search","mode":"live","context_size":"medium","allowed_domains":null,"location":null}`}, + {`{"agent":{"model":"m","tools":[{"type":"web_search","mode":null}]},"environment":{"type":"none"},"input":"x"}`, `{"type":"web_search","mode":"live","context_size":"medium","allowed_domains":null,"location":null}`}, + } { + w := credentialRequest(h, http.MethodPost, "/v1/agents/sessions", tc.body) + var session struct { + Agent struct{ Tools []json.RawMessage } } - // 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 w.Code != http.StatusCreated || json.Unmarshal(w.Body.Bytes(), &session) != nil || len(session.Agent.Tools) != 1 || string(session.Agent.Tools[0]) != tc.want { + t.Fatal(tc.body, w.Code, w.Body) } } - 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) - } - } + for _, tools := range []string{`[]`, `null`} { + 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 || len(session.Agent.Tools) != 0 { + t.Fatal(tools, w.Code, 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/core/internal/api/session_agent.go b/services/core/internal/api/session_agent.go index 279a25894..fc6366f0b 100644 --- a/services/core/internal/api/session_agent.go +++ b/services/core/internal/api/session_agent.go @@ -78,12 +78,6 @@ func admitSessionAgent(cfg v1.SavedAgentConfiguration) (v1.Agent, error) { if strings.TrimSpace(cfg.Model) == "" { return v1.Agent{}, errors.New("Execution currently requires a nonempty model.") } - if cfg.Reasoning.Effort != nil || cfg.Reasoning.Summary != nil { - return v1.Agent{}, errors.New("Explicit reasoning execution options are not supported by this service yet.") - } - if cfg.ServiceTier != "auto" { - return v1.Agent{}, errors.New("Execution currently supports service_tier=auto only.") - } tools, err := resolveSessionTools(cfg.Tools) if err != nil { return v1.Agent{}, err diff --git a/services/core/internal/api/session_model_configuration_test.go b/services/core/internal/api/session_model_configuration_test.go index d3e378cd4..511d412c9 100644 --- a/services/core/internal/api/session_model_configuration_test.go +++ b/services/core/internal/api/session_model_configuration_test.go @@ -10,22 +10,22 @@ import ( ) func TestSessionNativeConfigurationSources(t *testing.T) { - provider := fixtureModelProvider("codex") - saved := &v1.SavedAgent{SavedAgentConfiguration: v1.SavedAgentConfiguration{Model: "saved-model", XAgentsCore: &v1.SavedAgentCore{Harness: "codex", HarnessConfig: json.RawMessage(`{"model_reasoning_effort":"low"}`)}}} + provider := fixtureModelProvider("claude_sdk") + saved := &v1.SavedAgent{SavedAgentConfiguration: v1.SavedAgentConfiguration{Model: "saved-model", XAgentsCore: &v1.SavedAgentCore{Harness: "claude_sdk", HarnessConfig: json.RawMessage(`{"thinking":{"type":"enabled","budgetTokens":1000}}`)}}} for _, tc := range []struct { name, body string saved *v1.SavedAgent model, native, source string wantError bool }{ - {"deployment", `{"agent":{},"environment":{"type":"openai_hosted"}}`, nil, "deployment-model", `{"model_reasoning_effort":"high"}`, "deployment", false}, - {"none deployment", `{"agent":{},"environment":{"type":"none"}}`, nil, "deployment-model", `{"model_reasoning_effort":"high"}`, "deployment", false}, - {"saved", `{"agent_id":"a","environment":{"type":"openai_hosted"}}`, saved, "saved-model", `{"model_reasoning_effort":"low"}`, "agent", false}, + {"deployment", `{"agent":{},"environment":{"type":"openai_hosted"}}`, nil, "deployment-model", `{"thinking":{"type":"enabled","budgetTokens":3000}}`, "deployment", false}, + {"none deployment", `{"agent":{},"environment":{"type":"none"}}`, nil, "deployment-model", `{"thinking":{"type":"enabled","budgetTokens":3000}}`, "deployment", false}, + {"saved", `{"agent_id":"a","environment":{"type":"openai_hosted"}}`, saved, "saved-model", `{"thinking":{"type":"enabled","budgetTokens":1000}}`, "agent", false}, {"model override discards", `{"agent_id":"a","agent":{"model":"other"},"environment":{"type":"openai_hosted"}}`, saved, "other", `{}`, "session", false}, {"explicit model discards deployment", `{"agent":{"model":"other"},"environment":{"type":"openai_hosted"}}`, nil, "other", `{}`, "session", false}, {"explicit clear", `{"agent_id":"a","environment":{"type":"openai_hosted"},"x_agents_core":{"harness_config":{}}}`, saved, "saved-model", `{}`, "session", false}, - {"inline native", `{"agent":{"model":"other","x_agents_core":{"harness_config":{"model_reasoning_effort":"medium"}}},"environment":{"type":"openai_hosted"}}`, nil, "other", `{"model_reasoning_effort":"medium"}`, "session", false}, - {"session beats inline", `{"agent":{"model":"other","x_agents_core":{"harness_config":{"model_reasoning_effort":"medium"}}},"environment":{"type":"openai_hosted"},"x_agents_core":{"harness_config":{}}}`, nil, "other", `{}`, "session", false}, + {"inline native", `{"agent":{"model":"other","x_agents_core":{"harness_config":{"thinking":{"type":"enabled","budgetTokens":2000}}}},"environment":{"type":"openai_hosted"}}`, nil, "other", `{"thinking":{"type":"enabled","budgetTokens":2000}}`, "session", false}, + {"session beats inline", `{"agent":{"model":"other","x_agents_core":{"harness_config":{"thinking":{"type":"enabled","budgetTokens":2000}}}},"environment":{"type":"openai_hosted"},"x_agents_core":{"harness_config":{}}}`, nil, "other", `{}`, "session", false}, {"null rejects", `{"agent":{"model":"other"},"environment":{"type":"openai_hosted"},"x_agents_core":{"harness_config":null}}`, nil, "", "", "", true}, {"reserved rejects", `{"agent":{"model":"other"},"environment":{"type":"openai_hosted"},"x_agents_core":{"harness_config":{"api_key":"secret"}}}`, nil, "", "", "", true}, {"self hosted never defaults", `{"agent":{},"environment":{"type":"self_hosted","workspace_directory":"/tmp/work"}}`, nil, "", "", "", true}, @@ -41,9 +41,10 @@ func TestSessionNativeConfigurationSources(t *testing.T) { } calls := 0 deps, fakes := testDependencies(t) + deps.Engine = "claude_sdk" fakes.modelProviders.resolve = func(context.Context, string) (*modelconfiguration.Snapshot, error) { calls++ - return &modelconfiguration.Snapshot{Provider: provider, Model: "deployment-model", HarnessConfig: json.RawMessage(`{"model_reasoning_effort":"high"}`)}, nil + return &modelconfiguration.Snapshot{Provider: provider, Model: "deployment-model", HarnessConfig: json.RawMessage(`{"thinking":{"type":"enabled","budgetTokens":3000}}`)}, nil } h := &Handler{Dependencies: deps} err = h.prepareSessionModelConfiguration(t.Context(), &input, tc.saved, nil) @@ -72,11 +73,12 @@ func TestSessionNativeConfigurationSources(t *testing.T) { } func TestProviderReplacementDiscardsNativeConfiguration(t *testing.T) { - provider := fixtureModelProvider("codex") + provider := fixtureModelProvider("claude_sdk") model := "saved-model" input := sessionRequest{CreateSessionRequest: v1.CreateSessionRequest{Agent: &v1.InlineAgent{Model: nil}, Environment: &v1.Environment{Type: "self_hosted"}, XAgentsCore: &v1.SessionExecutionInput{ModelProvider: provider}}} - saved := &v1.SavedAgent{SavedAgentConfiguration: v1.SavedAgentConfiguration{Model: model, XAgentsCore: &v1.SavedAgentCore{Harness: "codex", HarnessConfig: json.RawMessage(`{"model_reasoning_effort":"high"}`)}}} + saved := &v1.SavedAgent{SavedAgentConfiguration: v1.SavedAgentConfiguration{Model: model, XAgentsCore: &v1.SavedAgentCore{Harness: "claude_sdk", HarnessConfig: json.RawMessage(`{"thinking":{"type":"enabled","budgetTokens":3000}}`)}}} deps, _ := testDependencies(t) + deps.Engine = "claude_sdk" h := &Handler{Dependencies: deps} if err := h.prepareSessionModelConfiguration(t.Context(), &input, saved, provider); err != nil { t.Fatal(err) diff --git a/services/core/internal/api/session_tools.go b/services/core/internal/api/session_tools.go index c66888e99..f2f02d0da 100644 --- a/services/core/internal/api/session_tools.go +++ b/services/core/internal/api/session_tools.go @@ -27,17 +27,11 @@ func resolveSessionTools(input []json.RawMessage) ([]json.RawMessage, error) { return nil, errors.New("Execution requires distinct tool controls.") } controls[kind.Type] = true - var resolved json.RawMessage - var err error + resolve := resolveProgrammaticTool if kind.Type == "web_search" { - resolved, err = resolveDisabledWebSearch(raw) - } else { - var enabled bool - resolved, enabled, err = resolveProgrammaticTool(raw) - if err == nil && enabled { - err = errors.New("Programmatic tool calling is not qualified for execution.") - } + resolve = resolveWebSearch } + resolved, err := resolve(raw) if err != nil { return nil, err } @@ -70,7 +64,7 @@ func resolveSessionTools(input []json.RawMessage) ([]json.RawMessage, error) { functions = append(functions, function) positions = append(positions, i) default: - return nil, errors.New("Unsupported execution tool; supported tools include functions, qualified tool_search, qualified HTTP MCP and explicit disabled controls.") + return nil, errors.New("Unsupported execution tool; supported tools include functions, qualified tool_search, qualified HTTP MCP and execution controls.") } } resolved, err := resolveFunctions(functions) diff --git a/services/core/internal/api/disabled_tools.go b/services/core/internal/api/tool_controls.go similarity index 60% rename from services/core/internal/api/disabled_tools.go rename to services/core/internal/api/tool_controls.go index 884f8092d..e7006f120 100644 --- a/services/core/internal/api/disabled_tools.go +++ b/services/core/internal/api/tool_controls.go @@ -1,23 +1,19 @@ package api -import ( - "encoding/json" - "errors" -) +import "encoding/json" // Tool resolvers read tools whose pinned shape was checked at the /v1 boundary: // request tools, or a saved Agent's tools that resolveSavedTools stored. A // decode failure is Core's own fault and is reported as a service error. -func resolveProgrammaticTool(raw json.RawMessage) (json.RawMessage, bool, error) { +func resolveProgrammaticTool(raw json.RawMessage) (json.RawMessage, error) { input := struct { Type string `json:"type"` Enabled bool `json:"enabled"` }{Enabled: true} if err := json.Unmarshal(raw, &input); err != nil { - return nil, false, &storedDataError{err} + return nil, &storedDataError{err} } - value, err := json.Marshal(input) - return value, input.Enabled, err + return json.Marshal(input) } // webSearchTool is the resolved web_search projection. A present location @@ -35,7 +31,7 @@ type webSearchTool struct { } `json:"location"` } -// Optional settings are resource data in every mode; they never enable execution. +// Optional settings are resource data in every mode. func (tool webSearchTool) resolveSettings() (json.RawMessage, error) { if tool.ContextSize == nil { value := "medium" @@ -44,10 +40,10 @@ func (tool webSearchTool) resolveSettings() (json.RawMessage, error) { 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) { +// An omitted or null mode resolves to live, as the official service does. The +// Session keeps every pinned mode; the selected Harness's declaration decides +// whether it can run. +func resolveWebSearch(raw json.RawMessage) (json.RawMessage, error) { var tool webSearchTool if err := json.Unmarshal(raw, &tool); err != nil { return nil, &storedDataError{err} @@ -58,15 +54,3 @@ func resolveSavedWebSearch(raw json.RawMessage) (json.RawMessage, error) { } return tool.resolveSettings() } - -// Only disabled search is qualified for execution. -func resolveDisabledWebSearch(raw json.RawMessage) (json.RawMessage, error) { - var tool webSearchTool - if err := json.Unmarshal(raw, &tool); err != nil { - return nil, &storedDataError{err} - } - if tool.Mode == nil || *tool.Mode != "disabled" { - return nil, errors.New("Only disabled web_search is qualified for execution.") - } - return tool.resolveSettings() -} diff --git a/services/core/internal/api/disabled_tools_test.go b/services/core/internal/api/tool_controls_test.go similarity index 88% rename from services/core/internal/api/disabled_tools_test.go rename to services/core/internal/api/tool_controls_test.go index 0f8004f2e..4dca0e74b 100644 --- a/services/core/internal/api/disabled_tools_test.go +++ b/services/core/internal/api/tool_controls_test.go @@ -8,10 +8,13 @@ import ( "testing" ) -func TestDisabledToolConfigurationRoundTrip(t *testing.T) { +func TestToolControlConfigurationRoundTrip(t *testing.T) { for _, raw := range []string{ `{"type":"programmatic_tool_calling","enabled":false}`, + `{"type":"programmatic_tool_calling","enabled":true}`, `{"type":"web_search","mode":"disabled"}`, + `{"type":"web_search","mode":"live","context_size":"low"}`, + `{"type":"web_search","mode":"cached"}`, `{"type":"web_search","mode":"disabled","context_size":"high","allowed_domains":[],"location":{"city":"Paris","country":null}}`, `{"type":"web_search","mode":"disabled","context_size":null,"allowed_domains":["example.com"],"location":null}`, } { @@ -36,16 +39,12 @@ func TestDisabledToolConfigurationRoundTrip(t *testing.T) { } } -func TestDisabledToolAdmissionPrecedesPersistence(t *testing.T) { +func TestMalformedToolControlsAreRejectedBeforePersistence(t *testing.T) { for _, tools := range []string{ - `[{"type":"programmatic_tool_calling"}]`, - `[{"type":"programmatic_tool_calling","enabled":true}]`, `[{"type":"programmatic_tool_calling","enabled":null}]`, `[{"type":"programmatic_tool_calling","enabled":"false"}]`, `[{"type":"programmatic_tool_calling","enabled":false,"extra":true}]`, `[{"type":"programmatic_tool_calling","enabled":false},{"type":"programmatic_tool_calling","enabled":false}]`, - `[{"type":"web_search","mode":"live"}]`, - `[{"type":"web_search","mode":"cached"}]`, `[{"type":"web_search","mode":"disabled","allowed_domains":[null]}]`, `[{"type":"web_search","mode":"disabled","context_size":"enormous"}]`, `[{"type":"web_search","mode":"disabled","location":{"extra":true}}]`, diff --git a/services/core/internal/execution/controls.go b/services/core/internal/execution/controls.go new file mode 100644 index 000000000..b9ebf371a --- /dev/null +++ b/services/core/internal/execution/controls.go @@ -0,0 +1,31 @@ +package execution + +import ( + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +// executionControls assembles the official execution options for the Runtime. +// It carries a value only when the Session sets it or the official API defines +// a default (text verbosity medium, web_search context_size medium); everything +// else is absent and follows the Harness's native default. Auto service tier +// means unset. +func executionControls(agent v1.Agent, tools executionToolSet) *proto.ExecutionControls { + controls := &proto.ExecutionControls{WebSearch: tools.WebSearch, ProgrammaticToolCalling: tools.ProgrammaticToolCalling, TextVerbosity: agent.Text.Verbosity} + if controls.TextVerbosity == "" { + controls.TextVerbosity = "medium" + } + if agent.Reasoning.Effort != nil { + controls.ReasoningEffort = *agent.Reasoning.Effort + } + if agent.Reasoning.Summary != nil { + controls.ReasoningSummary = *agent.Reasoning.Summary + } + if agent.ServiceTier != "auto" { + controls.ServiceTier = agent.ServiceTier + } + if agent.Text.Format.Type == "json_schema" { + controls.OutputFormat = &proto.OutputFormat{Type: "json_schema", Schema: agent.Text.Format.Schema} + } + return controls +} diff --git a/services/core/internal/execution/controls_test.go b/services/core/internal/execution/controls_test.go new file mode 100644 index 000000000..7bfc57455 --- /dev/null +++ b/services/core/internal/execution/controls_test.go @@ -0,0 +1,99 @@ +package execution + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" + "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/harnesstest" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" +) + +func sessionConfiguration(agent string) json.RawMessage { + return json.RawMessage(`{"agent":` + agent + `,"environment":{"type":"none"}}`) +} + +// Admission asks the Harness declaration alone: Core holds no per-Harness rule. +func TestAdmissionValidatesOfficialOptionsAgainstTheDeclaration(t *testing.T) { + declared := harnesstest.Declaration("declared", func(d *harnessconfig.Declaration) { + d.Execution = harnessconfig.ExecutionOptions{ + WebSearchModes: []string{"live"}, WebSearchSettings: true, ReasoningEfforts: []string{"high"}, ReasoningSummaries: []string{"auto"}, + ServiceTiers: []string{"flex"}, ProgrammaticToolCalling: []bool{true}, TextVerbosities: []string{"medium", "high"}, + } + }) + bare := harnesstest.Declaration("bare", nil) + policy := Policy{Declarations: harnessconfig.NewRegistry(declared, bare)} + for _, tc := range []struct { + agent string + declared, ok bool + field string + }{ + {`{"model":"m"}`, true, true, ""}, + {`{"model":"m"}`, false, true, ""}, + {`{"model":"m","tools":[{"type":"web_search","mode":"live","context_size":"high","allowed_domains":["a.example"],"location":{"city":"Paris"}}]}`, true, true, ""}, + {`{"model":"m","tools":[{"type":"web_search","mode":"live","context_size":"medium","allowed_domains":null,"location":null}]}`, false, false, "web_search.mode"}, + {`{"model":"m","tools":[{"type":"web_search","mode":"cached","context_size":"medium","allowed_domains":null,"location":null}]}`, true, false, "web_search.mode"}, + {`{"model":"m","tools":[{"type":"programmatic_tool_calling","enabled":true}]}`, true, true, ""}, + {`{"model":"m","tools":[{"type":"programmatic_tool_calling","enabled":false}]}`, true, false, "programmatic_tool_calling"}, + {`{"model":"m","tools":[{"type":"programmatic_tool_calling","enabled":true}]}`, false, false, "programmatic_tool_calling"}, + {`{"model":"m","reasoning":{"effort":"high","summary":"auto"},"service_tier":"flex","text":{"verbosity":"high"}}`, true, true, ""}, + {`{"model":"m","reasoning":{"effort":"max"}}`, true, false, "reasoning.effort"}, + {`{"model":"m","reasoning":{"summary":"concise"}}`, true, false, "reasoning.summary"}, + {`{"model":"m","service_tier":"priority"}`, true, false, "service_tier"}, + {`{"model":"m","service_tier":"auto"}`, false, true, ""}, + {`{"model":"m","service_tier":"flex"}`, false, false, "service_tier"}, + {`{"model":"m","text":{"verbosity":"low"}}`, true, false, "text.verbosity"}, + } { + kind := "bare" + if tc.declared { + kind = "declared" + } + err := policy.ValidateSessionConfiguration(kind, sessionConfiguration(tc.agent)) + if (err == nil) != tc.ok || (err != nil && !strings.Contains(err.Error(), tc.field)) { + t.Fatalf("%s %s: %v", kind, tc.agent, err) + } + } +} + +func TestExecutionControlsCarryOnlyOfficialValues(t *testing.T) { + enabled, disabled := true, false + effort, summary := "max", "detailed" + for _, tc := range []struct { + name string + agent v1.Agent + want proto.ExecutionControls + }{ + {"nothing set", v1.Agent{Model: "m", ServiceTier: "auto"}, proto.ExecutionControls{TextVerbosity: "medium"}}, + {"unset tier from an empty snapshot", v1.Agent{Model: "m"}, proto.ExecutionControls{TextVerbosity: "medium"}}, + {"set values", v1.Agent{Model: "m", ServiceTier: "flex", Reasoning: v1.Reasoning{Effort: &effort, Summary: &summary}, Text: v1.TextConfig{Verbosity: "low"}}, + proto.ExecutionControls{TextVerbosity: "low", ServiceTier: "flex", ReasoningEffort: "max", ReasoningSummary: "detailed"}}, + {"tools", v1.Agent{Model: "m", Tools: []json.RawMessage{ + json.RawMessage(`{"type":"web_search","mode":"cached","context_size":"high","allowed_domains":[],"location":{"city":"Paris","country":null,"region":null,"timezone":null}}`), + json.RawMessage(`{"type":"programmatic_tool_calling","enabled":false}`)}}, + proto.ExecutionControls{TextVerbosity: "medium", ProgrammaticToolCalling: &disabled, WebSearch: &proto.WebSearchControl{Mode: "cached", ContextSize: "high", AllowedDomains: []string{}, + Location: &proto.SearchLocation{City: func() *string { s := "Paris"; return &s }()}}}}, + {"empty location is absent", v1.Agent{Model: "m", Tools: []json.RawMessage{ + json.RawMessage(`{"type":"web_search","mode":"live","context_size":"medium","allowed_domains":null,"location":{"city":null,"country":null,"region":null,"timezone":null}}`), + json.RawMessage(`{"type":"programmatic_tool_calling","enabled":true}`)}}, + proto.ExecutionControls{TextVerbosity: "medium", ProgrammaticToolCalling: &enabled, WebSearch: &proto.WebSearchControl{Mode: "live", ContextSize: "medium"}}}, + } { + t.Run(tc.name, func(t *testing.T) { + snapshot := Snapshot{Agent: tc.agent} + before, _ := json.Marshal(snapshot) + for _, nativeID := range []string{"", "native-session"} { + request, err := (&Dispatcher{}).executionRequest(t.Context(), sessions.Session{ID: "session"}, snapshot, runtimedevice.KindCapabilities{}, sessions.ExecutionBinding{NativeSessionID: nativeID}) + if err != nil || !reflect.DeepEqual(*request.ExecutionControls, tc.want) || request.AgentSessionID != nativeID { + t.Fatalf("%+v %v", request.ExecutionControls, err) + } + } + if after, _ := json.Marshal(snapshot); string(before) != string(after) { + t.Fatal("snapshot mutated") + } + }) + } +} diff --git a/services/core/internal/execution/disabled_tools_test.go b/services/core/internal/execution/disabled_tools_test.go deleted file mode 100644 index 43f7be5e6..000000000 --- a/services/core/internal/execution/disabled_tools_test.go +++ /dev/null @@ -1,86 +0,0 @@ -package execution - -import ( - "encoding/json" - "testing" - - v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" - "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/harnesstest" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" -) - -func TestDisabledToolsUseCommonOperationQualification(t *testing.T) { - raw := json.RawMessage(`{"agent":{"model":"model","tools":[{"type":"web_search","mode":"disabled"},{"type":"programmatic_tool_calling","enabled":false}]},"environment":{"type":"none"}}`) - for _, kind := range []string{"codex", "claude_sdk", "mcode"} { - if err := (Policy{}).ValidateSessionConfiguration(kind, raw); err != nil { - t.Fatal(kind, err) - } - } - for _, qualified := range []bool{false, true} { - policy := Policy{Declarations: harnessconfig.NewRegistry(harnesstest.Declaration("new_harness", func(d *harnessconfig.Declaration) { - d.Capabilities.ProgrammaticToolCallingDisable = proto.CapabilityFromBool(qualified) - }))} - if err := policy.ValidateSessionConfiguration("new_harness", raw); (err == nil) != qualified { - t.Fatal("qualification differs", qualified, err) - } - if err := policy.ValidateSessionConfiguration("new_harness", json.RawMessage(`{"agent":{"model":"model"},"environment":{"type":"none"}}`)); err != nil { - t.Fatal("omission acquired a new prerequisite", err) - } - } -} - -func TestDisabledToolRequestPreservesIntentOnResume(t *testing.T) { - for _, disabled := range []bool{false, true} { - snapshot := Snapshot{Agent: v1.Agent{Model: "model"}} - if disabled { - snapshot.Agent.Tools = []json.RawMessage{json.RawMessage(`{"type":"programmatic_tool_calling","enabled":false}`)} - } - before, _ := json.Marshal(snapshot) - for _, nativeID := range []string{"", "native-session"} { - request, err := (&Dispatcher{}).executionRequest(t.Context(), sessions.Session{ID: "session"}, snapshot, runtimedevice.KindCapabilities{}, sessions.ExecutionBinding{NativeSessionID: nativeID}) - if err != nil || request.ExecutionControls.DisableProgrammaticToolCalling != disabled || request.ExecutionControls.WebSearch != "disabled" || request.AgentSessionID != nativeID { - t.Fatal(request, err) - } - if request.ValidateProgrammaticToolCallingDisable(true) != nil || (request.ValidateProgrammaticToolCallingDisable(false) != nil) != disabled { - t.Fatal("runtime operation qualification differs") - } - } - after, _ := json.Marshal(snapshot) - if string(before) != string(after) { - t.Fatal("snapshot mutated") - } - } -} - -// 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(), sessions.Session{ID: "session"}, snapshot, runtimedevice.KindCapabilities{}, sessions.ExecutionBinding{}); 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/core/internal/execution/harness_admission.go b/services/core/internal/execution/harness_admission.go index cc525e113..10d2e0d21 100644 --- a/services/core/internal/execution/harness_admission.go +++ b/services/core/internal/execution/harness_admission.go @@ -32,15 +32,12 @@ func admissionEnvironment(environment *v1.Environment) *harnessconfig.AdmissionE func admissionOf(snapshot Snapshot) harnessconfig.Admission { agent := snapshot.Agent return harnessconfig.Admission{ - Model: agent.Model, - Reasoning: agent.Reasoning.Effort != nil || agent.Reasoning.Summary != nil, - ServiceTier: agent.ServiceTier, - TextVerbosity: agent.Text.Verbosity, - TextFormat: agent.Text.Format.Type, - TextSchema: agent.Text.Format.Schema, - MultiAgent: agent.MultiAgent.Enabled, - Tools: agent.Tools, - Environment: admissionEnvironment(snapshot.Environment), + Model: agent.Model, + TextFormat: agent.Text.Format.Type, + TextSchema: agent.Text.Format.Schema, + MultiAgent: agent.MultiAgent.Enabled, + Tools: agent.Tools, + Environment: admissionEnvironment(snapshot.Environment), } } @@ -64,8 +61,8 @@ func validateDeclaredConfiguration(declaration harnessconfig.Declaration, snapsh if err := declaration.ValidateMCPOrigins(admissionEnvironment(snapshot.Environment), tools.MCP); err != nil { return err } - if tools.DisableProgrammatic && !declaration.Capabilities.ProgrammaticToolCallingDisable.IsSupported() { - return errors.New("Disabling programmatic tool calling is not supported by this engine.") + if err := declaration.ValidateExecutionControls(executionControls(snapshot.Agent, tools)); err != nil { + return err } request := proto.PromptRequestPayload{ToolSearch: tools.Search, FunctionTools: tools.Functions} if err := request.ValidateToolSearch(declaration.Capabilities.ToolSearch.IsSupported()); err != nil { diff --git a/services/core/internal/execution/mcp.go b/services/core/internal/execution/mcp.go index a92286e25..eb17b443f 100644 --- a/services/core/internal/execution/mcp.go +++ b/services/core/internal/execution/mcp.go @@ -12,18 +12,20 @@ import ( ) type executionToolSet struct { - Functions []proto.FunctionTool - MCP []proto.MCPHTTPServer - Search bool - DisableProgrammatic bool + Functions []proto.FunctionTool + MCP []proto.MCPHTTPServer + Search bool + // WebSearch and ProgrammaticToolCalling are set only when the Session declares the tool. + WebSearch *proto.WebSearchControl + ProgrammaticToolCalling *bool } func executionTools(raw []json.RawMessage) (executionToolSet, error) { functions := make([]json.RawMessage, 0, len(raw)) var servers []proto.MCPHTTPServer search := false - disableProgrammatic := false - controls := map[string]bool{} + var webSearch *proto.WebSearchControl + var programmatic *bool names := map[string]bool{} for _, value := range raw { var kind struct { @@ -32,30 +34,30 @@ func executionTools(raw []json.RawMessage) (executionToolSet, error) { if json.Unmarshal(value, &kind) != nil { return executionToolSet{}, errors.New("invalid execution tool") } - if kind.Type == "programmatic_tool_calling" || kind.Type == "web_search" { - if controls[kind.Type] { - return executionToolSet{}, errors.New("execution requires distinct tool controls") + if kind.Type == "programmatic_tool_calling" { + var control struct { + Enabled *bool `json:"enabled"` } - controls[kind.Type] = true - if kind.Type == "programmatic_tool_calling" { - var control struct { - Type string `json:"type"` - Enabled *bool `json:"enabled"` - } - decoder := json.NewDecoder(bytes.NewReader(value)) - decoder.DisallowUnknownFields() - if decoder.Decode(&control) != nil || control.Enabled == nil || *control.Enabled { - return executionToolSet{}, errors.New("programmatic tool calling is not qualified for execution") - } - disableProgrammatic = true - } else { - var control struct { - Mode string `json:"mode"` - } - if json.Unmarshal(value, &control) != nil || control.Mode != "disabled" { - return executionToolSet{}, errors.New("only disabled web search is qualified for execution") - } + if json.Unmarshal(value, &control) != nil || control.Enabled == nil { + return executionToolSet{}, errors.New("invalid programmatic_tool_calling control") + } + programmatic = control.Enabled + continue + } + if kind.Type == "web_search" { + var control struct { + Mode string `json:"mode"` + ContextSize string `json:"context_size"` + AllowedDomains []string `json:"allowed_domains"` + Location *proto.SearchLocation `json:"location"` + } + if json.Unmarshal(value, &control) != nil || control.Mode == "" || control.ContextSize == "" { + return executionToolSet{}, errors.New("invalid web_search control") + } + if location := control.Location; location != nil && location.City == nil && location.Country == nil && location.Region == nil && location.Timezone == nil { + control.Location = nil } + webSearch = &proto.WebSearchControl{Mode: control.Mode, ContextSize: control.ContextSize, AllowedDomains: control.AllowedDomains, Location: control.Location} continue } if kind.Type == "tool_search" { @@ -93,5 +95,5 @@ func executionTools(raw []json.RawMessage) (executionToolSet, error) { ServerURL: tool.Transport.ServerURL, AllowedTools: tool.AllowedTools, Required: tool.Required}) } resolved, err := functionTools(functions) - return executionToolSet{Functions: resolved, MCP: servers, Search: search, DisableProgrammatic: disableProgrammatic}, err + return executionToolSet{Functions: resolved, MCP: servers, Search: search, WebSearch: webSearch, ProgrammaticToolCalling: programmatic}, err } diff --git a/services/core/internal/execution/request.go b/services/core/internal/execution/request.go index 621bc16ad..b5521ce9e 100644 --- a/services/core/internal/execution/request.go +++ b/services/core/internal/execution/request.go @@ -35,16 +35,8 @@ func (d *Dispatcher) executionRequest(ctx context.Context, session sessions.Sess } options["harness_config"] = native } - verbosity := snapshot.Agent.Text.Verbosity - if verbosity == "" { - verbosity = "medium" - } - controls := &proto.ExecutionControls{DisableProgrammaticToolCalling: tools.DisableProgrammatic, WebSearch: "disabled", TextVerbosity: verbosity} - if snapshot.Agent.Text.Format.Type == "json_schema" { - controls.OutputFormat = &proto.OutputFormat{Type: "json_schema", Schema: snapshot.Agent.Text.Format.Schema} - } request := proto.PromptRequestPayload{AgentKind: session.Engine, FunctionTools: tools.Functions, ToolSearch: tools.Search, - AgentOptions: options, ExecutionControls: controls, AgentStateKey: "agents-api-" + session.ID, + AgentOptions: options, ExecutionControls: executionControls(snapshot.Agent, tools), AgentStateKey: "agents-api-" + session.ID, AgentSessionID: bound.NativeSessionID, RequireExistingNativeSession: recoverNativeSession, ObserveMessages: caps.MessageItems, ObserveSubagentIdentities: snapshot.Agent.MultiAgent.Enabled, diff --git a/services/core/internal/execution/support.go b/services/core/internal/execution/support.go index 3eb94a6ca..61087fe06 100644 --- a/services/core/internal/execution/support.go +++ b/services/core/internal/execution/support.go @@ -97,15 +97,9 @@ func (p Policy) engineCapabilities(peer *runtimegateway.Session, engine string, if !caps.ExecutionControls { return fail("device must advertise execution_controls") } - if declaration.Capabilities.WebSearchControl.IsSupported() && !caps.WebSearchControl { - return fail("device must advertise web_search_control") - } if snapshot.Agent.Text.Format.Type == "json_schema" && (!caps.StructuredOutput || !caps.MessageItems) { return fail("device must support structured output and message observations") } - if declaration.Capabilities.TextVerbosity.IsSupported() && !caps.TextVerbosity { - return fail("device must advertise text_verbosity") - } if !caps.ToolObservations { return fail("device must advertise tool_observations") } @@ -122,9 +116,6 @@ func (p Policy) engineCapabilities(peer *runtimegateway.Session, engine string, if err := (proto.PromptRequestPayload{ToolSearch: tools.Search, FunctionTools: tools.Functions}).ValidateToolSearch(caps.ToolSearch); err != nil { return fail(err.Error()) } - if tools.DisableProgrammatic && !caps.ProgrammaticToolCallingDisable { - return fail("device must support disabling programmatic tool calling") - } if len(tools.Functions) > 0 && !caps.FunctionTools { return fail("device must advertise function_tools") } diff --git a/services/core/internal/runtimedevice/state.go b/services/core/internal/runtimedevice/state.go index fd27fc78d..8a929f028 100644 --- a/services/core/internal/runtimedevice/state.go +++ b/services/core/internal/runtimedevice/state.go @@ -69,17 +69,14 @@ type KindCapabilities struct { Steering bool `json:"steering,omitempty"` MessageItems bool `json:"message_items,omitempty"` - ToolObservations bool `json:"tool_observations,omitempty"` - EnvironmentNone bool `json:"environment_none,omitempty"` - LocalEnvironment bool `json:"local_environment,omitempty"` - Preparation bool `json:"preparation,omitempty"` - WorkspaceReadPreparation bool `json:"workspace_read_preparation,omitempty"` - WorkspaceOutputExport bool `json:"workspace_output_export,omitempty"` - ProgrammaticToolCallingDisable bool `json:"programmatic_tool_calling_disable,omitempty"` - WebSearchControl bool `json:"web_search_control,omitempty"` - // ExecutionControls supports typed search and verbosity controls. + ToolObservations bool `json:"tool_observations,omitempty"` + EnvironmentNone bool `json:"environment_none,omitempty"` + LocalEnvironment bool `json:"local_environment,omitempty"` + Preparation bool `json:"preparation,omitempty"` + WorkspaceReadPreparation bool `json:"workspace_read_preparation,omitempty"` + WorkspaceOutputExport bool `json:"workspace_output_export,omitempty"` + // ExecutionControls accepts the typed execution controls. ExecutionControls bool `json:"execution_controls,omitempty"` - TextVerbosity bool `json:"text_verbosity,omitempty"` StructuredOutput bool `json:"structured_output,omitempty"` ToolSearch bool `json:"tool_search,omitempty"` MessageImages bool `json:"message_images,omitempty"` diff --git a/services/core/internal/runtimegateway/mcp_bearer_live_linux_test.go b/services/core/internal/runtimegateway/mcp_bearer_live_linux_test.go index c0cfe329f..fb6d06bc0 100644 --- a/services/core/internal/runtimegateway/mcp_bearer_live_linux_test.go +++ b/services/core/internal/runtimegateway/mcp_bearer_live_linux_test.go @@ -96,7 +96,7 @@ func TestLiveMCPBearerGatewayColdContinuation(t *testing.T) { turn := &mcpBearerTurn{} turns = append(turns, turn) runID, prepareID := uuid.NewString(), uuid.NewString() - request := proto.PromptRequestPayload{AgentKind: "codex", AgentStateKey: "agents-api-mcp-bearer-acceptance", AgentSessionID: resume, ObserveMessages: true, DisableExecutionEnvironment: true, DisableSubagents: true, MCPHTTPServers: &servers, AgentOptions: map[string]any{"model": "MiniMax-M3"}, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}} + request := proto.PromptRequestPayload{AgentKind: "codex", AgentStateKey: "agents-api-mcp-bearer-acceptance", AgentSessionID: resume, ObserveMessages: true, DisableExecutionEnvironment: true, DisableSubagents: true, MCPHTTPServers: &servers, AgentOptions: map[string]any{"model": "MiniMax-M3"}, ExecutionControls: &proto.ExecutionControls{TextVerbosity: "medium"}} send := func(kind, id string, payload any) { t.Helper() envelope, err := proto.NewEnvelope(kind, id, payload) diff --git a/services/core/internal/runtimegateway/session.go b/services/core/internal/runtimegateway/session.go index 42191c76e..0b142a139 100644 --- a/services/core/internal/runtimegateway/session.go +++ b/services/core/internal/runtimegateway/session.go @@ -502,26 +502,23 @@ func deviceKindsFromHeartbeat(p proto.HeartbeatPayload) []runtimedevice.Supporte RetainedNativeHistory: info.Capabilities.RetainedNativeHistory.IsSupported(), MessageItems: info.Capabilities.MessageItems.IsSupported(), - ToolObservations: info.Capabilities.ToolObservations.IsSupported(), - EnvironmentNone: info.Capabilities.EnvironmentNone.IsSupported(), - LocalEnvironment: info.Capabilities.LocalEnvironment.IsSupported(), - Preparation: info.Capabilities.Preparation.IsSupported(), - WorkspaceReadPreparation: info.Capabilities.WorkspaceReadPreparation.IsSupported(), - WorkspaceOutputExport: info.Capabilities.WorkspaceOutputExport.IsSupported(), - WebSearchControl: info.Capabilities.WebSearchControl.IsSupported(), - ProgrammaticToolCallingDisable: info.Capabilities.ProgrammaticToolCallingDisable.IsSupported(), - TextVerbosity: info.Capabilities.TextVerbosity.IsSupported(), - StructuredOutput: info.Capabilities.StructuredOutput.IsSupported(), - ToolSearch: info.Capabilities.ToolSearch.IsSupported(), - MessageImages: info.Capabilities.MessageImages.IsSupported(), - FunctionResultImages: info.Capabilities.FunctionResultImages.IsSupported(), - ExecutionControls: info.Capabilities.ExecutionControls.IsSupported(), - SubagentControl: info.Capabilities.SubagentControl.IsSupported(), - SubagentObservations: info.Capabilities.SubagentObservations.IsSupported(), - FunctionTools: info.Capabilities.FunctionTools.IsSupported(), - MCPHTTPTools: info.Capabilities.MCPHTTPTools.IsSupported(), - MCPHTTPRequired: info.Capabilities.MCPHTTPRequired.IsSupported(), - MCPHTTPBearerAuth: info.Capabilities.MCPHTTPBearerAuth.IsSupported(), + ToolObservations: info.Capabilities.ToolObservations.IsSupported(), + EnvironmentNone: info.Capabilities.EnvironmentNone.IsSupported(), + LocalEnvironment: info.Capabilities.LocalEnvironment.IsSupported(), + Preparation: info.Capabilities.Preparation.IsSupported(), + WorkspaceReadPreparation: info.Capabilities.WorkspaceReadPreparation.IsSupported(), + WorkspaceOutputExport: info.Capabilities.WorkspaceOutputExport.IsSupported(), + StructuredOutput: info.Capabilities.StructuredOutput.IsSupported(), + ToolSearch: info.Capabilities.ToolSearch.IsSupported(), + MessageImages: info.Capabilities.MessageImages.IsSupported(), + FunctionResultImages: info.Capabilities.FunctionResultImages.IsSupported(), + ExecutionControls: info.Capabilities.ExecutionControls.IsSupported(), + SubagentControl: info.Capabilities.SubagentControl.IsSupported(), + SubagentObservations: info.Capabilities.SubagentObservations.IsSupported(), + FunctionTools: info.Capabilities.FunctionTools.IsSupported(), + MCPHTTPTools: info.Capabilities.MCPHTTPTools.IsSupported(), + MCPHTTPRequired: info.Capabilities.MCPHTTPRequired.IsSupported(), + MCPHTTPBearerAuth: info.Capabilities.MCPHTTPBearerAuth.IsSupported(), }, }) } diff --git a/services/core/internal/runtimegateway/session_test.go b/services/core/internal/runtimegateway/session_test.go index c4e27e0f3..f32ab234f 100644 --- a/services/core/internal/runtimegateway/session_test.go +++ b/services/core/internal/runtimegateway/session_test.go @@ -363,7 +363,7 @@ func TestSession_HeartbeatPersistsSupportedAgentKinds(t *testing.T) { { Kind: "codex", Available: true, - Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{MCPHTTPTools: proto.CapabilitySupported, Steering: proto.CapabilitySupported, MessageItems: proto.CapabilitySupported, ToolObservations: proto.CapabilitySupported, EnvironmentNone: proto.CapabilitySupported, WebSearchControl: proto.CapabilitySupported, TextVerbosity: proto.CapabilitySupported, ExecutionControls: proto.CapabilitySupported, SubagentControl: proto.CapabilitySupported}), + Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{MCPHTTPTools: proto.CapabilitySupported, Steering: proto.CapabilitySupported, MessageItems: proto.CapabilitySupported, ToolObservations: proto.CapabilitySupported, EnvironmentNone: proto.CapabilitySupported, ExecutionControls: proto.CapabilitySupported, SubagentControl: proto.CapabilitySupported}), }, }, }) @@ -389,7 +389,7 @@ func TestSession_HeartbeatPersistsSupportedAgentKinds(t *testing.T) { if fake_beta.Available || fake_beta.Version != "missing" || !fake_beta.Capabilities.Streaming { t.Fatalf("fake_beta descriptor not converted: %#v", fake_beta) } - if !byKind["codex"].Capabilities.ExecutionControls || claude.Capabilities.ExecutionControls || fake_beta.Capabilities.ExecutionControls || !byKind["codex"].Capabilities.ToolObservations || claude.Capabilities.ToolObservations || fake_beta.Capabilities.ToolObservations || !byKind["codex"].Capabilities.SubagentControl || claude.Capabilities.SubagentControl || fake_beta.Capabilities.SubagentControl || !byKind["codex"].Capabilities.TextVerbosity || claude.Capabilities.TextVerbosity || fake_beta.Capabilities.TextVerbosity || !byKind["codex"].Capabilities.WebSearchControl || claude.Capabilities.WebSearchControl || fake_beta.Capabilities.WebSearchControl || !byKind["codex"].Capabilities.EnvironmentNone || claude.Capabilities.EnvironmentNone || fake_beta.Capabilities.EnvironmentNone || !byKind["codex"].Capabilities.MessageItems || !byKind["codex"].Capabilities.Steering || claude.Capabilities.Steering || fake_beta.Capabilities.Steering { + if !byKind["codex"].Capabilities.ExecutionControls || claude.Capabilities.ExecutionControls || fake_beta.Capabilities.ExecutionControls || !byKind["codex"].Capabilities.ToolObservations || claude.Capabilities.ToolObservations || fake_beta.Capabilities.ToolObservations || !byKind["codex"].Capabilities.SubagentControl || claude.Capabilities.SubagentControl || fake_beta.Capabilities.SubagentControl || !byKind["codex"].Capabilities.EnvironmentNone || claude.Capabilities.EnvironmentNone || fake_beta.Capabilities.EnvironmentNone || !byKind["codex"].Capabilities.MessageItems || !byKind["codex"].Capabilities.Steering || claude.Capabilities.Steering || fake_beta.Capabilities.Steering { t.Fatalf("steering capability not preserved: %#v", byKind) } if !byKind["codex"].Capabilities.MCPHTTPTools || claude.Capabilities.MCPHTTPTools || fake_beta.Capabilities.MCPHTTPTools { diff --git a/services/core/migrations/000102_reasoning_effort_single_home.sql b/services/core/migrations/000102_reasoning_effort_single_home.sql new file mode 100644 index 000000000..9113864f1 --- /dev/null +++ b/services/core/migrations/000102_reasoning_effort_single_home.sql @@ -0,0 +1,32 @@ +-- +goose Up +-- Reasoning effort has one home, reasoning.effort. Move the Harness-native keys there when it is unset, then drop them everywhere they were stored. +UPDATE agents SET configuration = jsonb_set( + configuration, + '{reasoning}', + CASE WHEN jsonb_typeof(configuration -> 'reasoning') = 'object' THEN configuration -> 'reasoning' ELSE '{}'::jsonb END + || jsonb_build_object('effort', COALESCE(configuration #> '{x_agents_core,harness_config,model_reasoning_effort}', configuration #> '{x_agents_core,harness_config,effort}'))) +WHERE COALESCE(configuration #> '{x_agents_core,harness_config,model_reasoning_effort}', configuration #> '{x_agents_core,harness_config,effort}') IS NOT NULL + AND COALESCE(configuration #> '{reasoning,effort}', 'null'::jsonb) = 'null'::jsonb; + +UPDATE agents SET configuration = configuration #- '{x_agents_core,harness_config,model_reasoning_effort}' #- '{x_agents_core,harness_config,effort}' +WHERE configuration #> '{x_agents_core,harness_config}' ?| ARRAY['model_reasoning_effort', 'effort']; + +UPDATE sessions SET configuration = jsonb_set( + configuration, + '{agent,reasoning}', + CASE WHEN jsonb_typeof(configuration #> '{agent,reasoning}') = 'object' THEN configuration #> '{agent,reasoning}' ELSE '{}'::jsonb END + || jsonb_build_object('effort', COALESCE(configuration #> '{agent,x_agents_core,harness_config,model_reasoning_effort}', configuration #> '{agent,x_agents_core,harness_config,effort}'))) +WHERE COALESCE(configuration #> '{agent,x_agents_core,harness_config,model_reasoning_effort}', configuration #> '{agent,x_agents_core,harness_config,effort}') IS NOT NULL + AND COALESCE(configuration #> '{agent,reasoning,effort}', 'null'::jsonb) = 'null'::jsonb; + +UPDATE sessions SET configuration = configuration #- '{agent,x_agents_core,harness_config,model_reasoning_effort}' #- '{agent,x_agents_core,harness_config,effort}' +WHERE configuration #> '{agent,x_agents_core,harness_config}' ?| ARRAY['model_reasoning_effort', 'effort']; + +UPDATE session_execution_configuration SET configuration = configuration #- '{harness_config,value,model_reasoning_effort}' #- '{harness_config,value,effort}' +WHERE configuration #> '{harness_config,value}' ?| ARRAY['model_reasoning_effort', 'effort']; + +UPDATE deployment_model_providers SET harness_config = harness_config - 'model_reasoning_effort' - 'effort' +WHERE harness_config ?| ARRAY['model_reasoning_effort', 'effort']; + +-- +goose Down +SELECT 1; diff --git a/services/core/tests/integration/claude_execution_test.go b/services/core/tests/integration/claude_execution_test.go index c5c718d74..cb0e10d6d 100644 --- a/services/core/tests/integration/claude_execution_test.go +++ b/services/core/tests/integration/claude_execution_test.go @@ -85,7 +85,7 @@ func TestClaudeWorkerSelectsStoredEngineAndRestrictiveCapabilities(t *testing.T) if h.read(testExecutionRequest).DecodePayload(&prompt) != nil { t.Fatal("invalid prompt") } - if prompt.AgentKind != "claude_sdk" || len(prompt.FunctionTools) != 1 || !prompt.DisableExecutionEnvironment || !prompt.DisableSubagents || prompt.ExecutionControls == nil || *prompt.ExecutionControls != (proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}) { + if prompt.AgentKind != "claude_sdk" || len(prompt.FunctionTools) != 1 || !prompt.DisableExecutionEnvironment || !prompt.DisableSubagents || prompt.ExecutionControls == nil || *prompt.ExecutionControls != (proto.ExecutionControls{TextVerbosity: "medium"}) { t.Fatal(prompt) } h.write(input.TurnID, proto.TypeDone, proto.DonePayload{Content: "done", Metadata: map[string]any{proto.DoneMetaAgentSessionID: "claude-native"}}) diff --git a/services/core/tests/integration/configuration_validation_public_test.go b/services/core/tests/integration/configuration_validation_public_test.go index ecc2c70b3..8c37f6bbf 100644 --- a/services/core/tests/integration/configuration_validation_public_test.go +++ b/services/core/tests/integration/configuration_validation_public_test.go @@ -121,9 +121,6 @@ func TestAgentConfigurationValidationRejectsWithoutWritesPostgres(t *testing.T) // 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/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 supported by this engine."}, } { encoded, _ := json.Marshal(request.message) diff --git a/services/core/tests/integration/dispatch_test.go b/services/core/tests/integration/dispatch_test.go index 8bbd3612b..3297b70c3 100644 --- a/services/core/tests/integration/dispatch_test.go +++ b/services/core/tests/integration/dispatch_test.go @@ -103,7 +103,7 @@ func newDispatchHarnessForSession(t *testing.T, configuration []byte, local bool t.Fatal("device connection failed") } t.Cleanup(func() { h.conn.Close() }) - h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Streaming: proto.CapabilitySupported, Steering: proto.CapabilitySupported, Resume: proto.CapabilitySupported, DurableTurns: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported, WebSearchControl: proto.CapabilitySupported, TextVerbosity: proto.CapabilitySupported, ExecutionControls: proto.CapabilitySupported, SubagentControl: proto.CapabilitySupported, SubagentObservations: proto.CapabilitySupported, ToolObservations: proto.CapabilitySupported, NativeSessionRecovery: proto.CapabilitySupported, Preparation: proto.CapabilitySupported, EnvironmentNone: proto.CapabilitySupported})}}}) + h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Streaming: proto.CapabilitySupported, Steering: proto.CapabilitySupported, Resume: proto.CapabilitySupported, DurableTurns: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported, ExecutionControls: proto.CapabilitySupported, SubagentControl: proto.CapabilitySupported, SubagentObservations: proto.CapabilitySupported, ToolObservations: proto.CapabilitySupported, NativeSessionRecovery: proto.CapabilitySupported, Preparation: proto.CapabilitySupported, EnvironmentNone: proto.CapabilitySupported})}}}) deadline := time.Now().Add(3 * time.Second) for { peer, e := h.registry.LookupDevice(h.device.ID) @@ -423,8 +423,6 @@ func TestExecutionRejectsRuntimeMissingCapabilityBeforeClaim(t *testing.T) { {"durable_input_receipts", "device must advertise streaming, steering and durable turns for this engine"}, {"preparation", "device must advertise executor preparation"}, {"execution_controls", "device must advertise execution_controls"}, - {"web_search_control", "device must advertise web_search_control"}, - {"text_verbosity", "device must advertise text_verbosity"}, {"tool_observations", "device must advertise tool_observations"}, {"subagent_control", "device must advertise subagent_control"}, {"environment_none", "device must advertise environment_none"}, @@ -432,12 +430,12 @@ func TestExecutionRejectsRuntimeMissingCapabilityBeforeClaim(t *testing.T) { missing := tc.missing t.Run(missing, func(t *testing.T) { h := newDispatchHarness(t) - h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Streaming: proto.CapabilitySupported, Steering: proto.CapabilitySupported, Resume: proto.CapabilitySupported, DurableTurns: proto.CapabilityFromBool(missing != "durable_turns"), DurableInputReceipts: proto.CapabilityFromBool(missing != "durable_input_receipts"), Preparation: proto.CapabilityFromBool(missing != "preparation"), WebSearchControl: proto.CapabilityFromBool(missing != "web_search_control"), TextVerbosity: proto.CapabilityFromBool(missing != "text_verbosity"), ExecutionControls: proto.CapabilityFromBool(missing != "execution_controls"), SubagentControl: proto.CapabilityFromBool(missing != "subagent_control"), ToolObservations: proto.CapabilityFromBool(missing != "tool_observations"), EnvironmentNone: proto.CapabilityFromBool(missing != "environment_none")})}}}) + h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Streaming: proto.CapabilitySupported, Steering: proto.CapabilitySupported, Resume: proto.CapabilitySupported, DurableTurns: proto.CapabilityFromBool(missing != "durable_turns"), DurableInputReceipts: proto.CapabilityFromBool(missing != "durable_input_receipts"), Preparation: proto.CapabilityFromBool(missing != "preparation"), ExecutionControls: proto.CapabilityFromBool(missing != "execution_controls"), SubagentControl: proto.CapabilityFromBool(missing != "subagent_control"), ToolObservations: proto.CapabilityFromBool(missing != "tool_observations"), EnvironmentNone: proto.CapabilityFromBool(missing != "environment_none")})}}}) deadline := time.Now().Add(3 * time.Second) for { peer, _ := h.registry.LookupDevice(h.device.ID) info, _, _ := peer.AgentKindStatus("codex") - if info.Capabilities.DurableInputReceipts == (missing != "durable_input_receipts") && info.Capabilities.Preparation == (missing != "preparation") && info.Capabilities.ExecutionControls == (missing != "execution_controls") && info.Capabilities.DurableTurns == (missing != "durable_turns") && info.Capabilities.WebSearchControl == (missing != "web_search_control") && info.Capabilities.TextVerbosity == (missing != "text_verbosity") && info.Capabilities.SubagentControl == (missing != "subagent_control") && info.Capabilities.ToolObservations == (missing != "tool_observations") && info.Capabilities.EnvironmentNone == (missing != "environment_none") { + if info.Capabilities.DurableInputReceipts == (missing != "durable_input_receipts") && info.Capabilities.Preparation == (missing != "preparation") && info.Capabilities.ExecutionControls == (missing != "execution_controls") && info.Capabilities.DurableTurns == (missing != "durable_turns") && info.Capabilities.SubagentControl == (missing != "subagent_control") && info.Capabilities.ToolObservations == (missing != "tool_observations") && info.Capabilities.EnvironmentNone == (missing != "environment_none") { break } if time.Now().After(deadline) { diff --git a/services/core/tests/integration/environment_expiry_dispatch_test.go b/services/core/tests/integration/environment_expiry_dispatch_test.go index 4bc66594b..f09885f8e 100644 --- a/services/core/tests/integration/environment_expiry_dispatch_test.go +++ b/services/core/tests/integration/environment_expiry_dispatch_test.go @@ -10,7 +10,7 @@ import ( func enableEnvironmentExpiryDispatch(h *dispatchHarness) { h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{ - Streaming: proto.CapabilitySupported, Steering: proto.CapabilitySupported, DurableTurns: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported, EnvironmentNone: proto.CapabilitySupported, WebSearchControl: proto.CapabilitySupported, TextVerbosity: proto.CapabilitySupported, ExecutionControls: proto.CapabilitySupported, SubagentControl: proto.CapabilitySupported, ToolObservations: proto.CapabilitySupported, + Streaming: proto.CapabilitySupported, Steering: proto.CapabilitySupported, DurableTurns: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported, EnvironmentNone: proto.CapabilitySupported, ExecutionControls: proto.CapabilitySupported, SubagentControl: proto.CapabilitySupported, ToolObservations: proto.CapabilitySupported, Preparation: proto.CapabilitySupported})}}}) } diff --git a/services/core/tests/integration/environment_worker_helpers_test.go b/services/core/tests/integration/environment_worker_helpers_test.go index 3ddd0529c..f4eb5a8d1 100644 --- a/services/core/tests/integration/environment_worker_helpers_test.go +++ b/services/core/tests/integration/environment_worker_helpers_test.go @@ -27,7 +27,7 @@ func enableWorkerEnvironment(t *testing.T, h *dispatchHarness) { } func workerEnvironmentCapabilities() proto.AgentKindCapabilities { - return prototest.Capabilities(proto.AgentKindCapabilities{Streaming: proto.CapabilitySupported, Steering: proto.CapabilitySupported, DurableTurns: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported, EnvironmentNone: proto.CapabilitySupported, WebSearchControl: proto.CapabilitySupported, TextVerbosity: proto.CapabilitySupported, ExecutionControls: proto.CapabilitySupported, SubagentControl: proto.CapabilitySupported, ToolObservations: proto.CapabilitySupported, Preparation: proto.CapabilitySupported, LocalEnvironment: proto.CapabilitySupported, WorkspaceReadPreparation: proto.CapabilitySupported, WorkspaceOutputExport: proto.CapabilitySupported}) + return prototest.Capabilities(proto.AgentKindCapabilities{Streaming: proto.CapabilitySupported, Steering: proto.CapabilitySupported, DurableTurns: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported, EnvironmentNone: proto.CapabilitySupported, ExecutionControls: proto.CapabilitySupported, SubagentControl: proto.CapabilitySupported, ToolObservations: proto.CapabilitySupported, Preparation: proto.CapabilitySupported, LocalEnvironment: proto.CapabilitySupported, WorkspaceReadPreparation: proto.CapabilitySupported, WorkspaceOutputExport: proto.CapabilitySupported}) } func workerEnvironmentReservation(t *testing.T, h *dispatchHarness) sessions.EnvironmentInputReservation { diff --git a/services/core/tests/integration/execution_messages_test.go b/services/core/tests/integration/execution_messages_test.go index 860192ca2..30e8f52af 100644 --- a/services/core/tests/integration/execution_messages_test.go +++ b/services/core/tests/integration/execution_messages_test.go @@ -24,7 +24,7 @@ func TestExecutionNegotiatesAndPersistsMessageObservations(t *testing.T) { } h.write(first.TurnID, proto.TypeDone, proto.DonePayload{}) h.finished(result, sessions.TurnCompleted) - h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Streaming: proto.CapabilitySupported, Steering: proto.CapabilitySupported, Resume: proto.CapabilitySupported, DurableTurns: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported, WebSearchControl: proto.CapabilitySupported, TextVerbosity: proto.CapabilitySupported, ExecutionControls: proto.CapabilitySupported, SubagentControl: proto.CapabilitySupported, ToolObservations: proto.CapabilitySupported, MessageItems: proto.CapabilitySupported, NativeSessionRecovery: proto.CapabilitySupported, Preparation: proto.CapabilitySupported, EnvironmentNone: proto.CapabilitySupported})}}}) + h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Streaming: proto.CapabilitySupported, Steering: proto.CapabilitySupported, Resume: proto.CapabilitySupported, DurableTurns: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported, ExecutionControls: proto.CapabilitySupported, SubagentControl: proto.CapabilitySupported, ToolObservations: proto.CapabilitySupported, MessageItems: proto.CapabilitySupported, NativeSessionRecovery: proto.CapabilitySupported, Preparation: proto.CapabilitySupported, EnvironmentNone: proto.CapabilitySupported})}}}) deadline := time.Now().Add(3 * time.Second) for { peer, err := h.registry.LookupDevice(h.device.ID) diff --git a/services/core/tests/integration/function_execution_test.go b/services/core/tests/integration/function_execution_test.go index 16b67e438..031883348 100644 --- a/services/core/tests/integration/function_execution_test.go +++ b/services/core/tests/integration/function_execution_test.go @@ -27,7 +27,7 @@ func newFunctionHarness(t *testing.T) *dispatchHarness { if err := bindSessionDevice(t, h.s, h.tenant, h.session.ID, h.device.ID); err != nil { t.Fatal(err) } - h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Streaming: proto.CapabilitySupported, Steering: proto.CapabilitySupported, Resume: proto.CapabilitySupported, DurableTurns: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported, WebSearchControl: proto.CapabilitySupported, TextVerbosity: proto.CapabilitySupported, ExecutionControls: proto.CapabilitySupported, SubagentControl: proto.CapabilitySupported, ToolObservations: proto.CapabilitySupported, EnvironmentNone: proto.CapabilitySupported, FunctionTools: proto.CapabilitySupported, FunctionResultImages: proto.CapabilitySupported, Preparation: proto.CapabilitySupported})}}}) + h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Streaming: proto.CapabilitySupported, Steering: proto.CapabilitySupported, Resume: proto.CapabilitySupported, DurableTurns: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported, ExecutionControls: proto.CapabilitySupported, SubagentControl: proto.CapabilitySupported, ToolObservations: proto.CapabilitySupported, EnvironmentNone: proto.CapabilitySupported, FunctionTools: proto.CapabilitySupported, FunctionResultImages: proto.CapabilitySupported, Preparation: proto.CapabilitySupported})}}}) deadline := time.Now().Add(time.Second) for { peer, _ := h.registry.LookupDevice(h.device.ID) diff --git a/services/core/tests/integration/function_worker_test.go b/services/core/tests/integration/function_worker_test.go index d15c24f79..68e7502b0 100644 --- a/services/core/tests/integration/function_worker_test.go +++ b/services/core/tests/integration/function_worker_test.go @@ -45,7 +45,7 @@ func TestWorkerWaitsForToolCapabilities(t *testing.T) { t.Fatal(err) } } - caps := prototest.Capabilities(proto.AgentKindCapabilities{Preparation: proto.CapabilityFromBool(missing != "preparation"), Streaming: proto.CapabilitySupported, Steering: proto.CapabilitySupported, DurableTurns: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilityFromBool(missing != "durable_input_receipts"), EnvironmentNone: proto.CapabilitySupported, WebSearchControl: proto.CapabilitySupported, TextVerbosity: proto.CapabilitySupported, ExecutionControls: proto.CapabilityFromBool(missing != "execution_controls"), SubagentControl: proto.CapabilitySupported, ToolObservations: proto.CapabilityFromBool(missing != "tool_observations"), MCPHTTPTools: proto.CapabilityFromBool(missing != "mcp_http_tools"), MCPHTTPRequired: proto.CapabilityFromBool(missing != "mcp_http_required"), MCPHTTPBearerAuth: proto.CapabilityFromBool(missing != "mcp_http_bearer_auth"), FunctionTools: proto.CapabilityFromBool(missing != "function_tools" && !isMCP)}) + caps := prototest.Capabilities(proto.AgentKindCapabilities{Preparation: proto.CapabilityFromBool(missing != "preparation"), Streaming: proto.CapabilitySupported, Steering: proto.CapabilitySupported, DurableTurns: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilityFromBool(missing != "durable_input_receipts"), EnvironmentNone: proto.CapabilitySupported, ExecutionControls: proto.CapabilityFromBool(missing != "execution_controls"), SubagentControl: proto.CapabilitySupported, ToolObservations: proto.CapabilityFromBool(missing != "tool_observations"), MCPHTTPTools: proto.CapabilityFromBool(missing != "mcp_http_tools"), MCPHTTPRequired: proto.CapabilityFromBool(missing != "mcp_http_required"), MCPHTTPBearerAuth: proto.CapabilityFromBool(missing != "mcp_http_bearer_auth"), FunctionTools: proto.CapabilityFromBool(missing != "function_tools" && !isMCP)}) heartbeat := func() { h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: caps}}}) } diff --git a/services/core/tests/integration/harness_onboarding_test.go b/services/core/tests/integration/harness_onboarding_test.go index dc8b0046d..b83de3901 100644 --- a/services/core/tests/integration/harness_onboarding_test.go +++ b/services/core/tests/integration/harness_onboarding_test.go @@ -28,7 +28,7 @@ func TestThirdHarnessPublicOnboarding(t *testing.T) { h := newDispatchHarness(t) fixture := harnesstest.Declaration("fixture_harness", func(d *harnessconfig.Declaration) { d.ValidateConfiguration = func(a harnessconfig.Admission) error { - if a.TextVerbosity != "medium" || a.TextFormat != "text" || a.MultiAgent || a.Reasoning || a.ServiceTier != "auto" { + if a.TextFormat != "text" || a.MultiAgent { return harnessconfig.ErrInvalidInput } return nil diff --git a/services/core/tests/integration/local_environment_worker_test.go b/services/core/tests/integration/local_environment_worker_test.go index 35699205b..9b3f0ef82 100644 --- a/services/core/tests/integration/local_environment_worker_test.go +++ b/services/core/tests/integration/local_environment_worker_test.go @@ -27,7 +27,7 @@ func localWorker(t *testing.T, scoped, execute bool) (*dispatchHarness, *executi if execute { caps.WorkspaceOutputExport = proto.CapabilitySupported caps.Streaming, caps.Steering, caps.DurableTurns, caps.DurableInputReceipts = proto.CapabilitySupported, proto.CapabilitySupported, proto.CapabilitySupported, proto.CapabilitySupported - caps.WebSearchControl, caps.TextVerbosity, caps.ExecutionControls = proto.CapabilitySupported, proto.CapabilitySupported, proto.CapabilitySupported + caps.ExecutionControls = proto.CapabilitySupported caps.SubagentControl, caps.ToolObservations = proto.CapabilitySupported, proto.CapabilitySupported } h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: caps}}}) diff --git a/services/core/tests/integration/public_execution_test.go b/services/core/tests/integration/public_execution_test.go index e52ee9dc5..965a5217e 100644 --- a/services/core/tests/integration/public_execution_test.go +++ b/services/core/tests/integration/public_execution_test.go @@ -23,7 +23,7 @@ func publicSession(t *testing.T, h *dispatchHarness, key string) sessions.Sessio func TestExecutionWorkerAdmissionBindingAndRecovery(t *testing.T) { h := newDispatchHarness(t) - h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Streaming: proto.CapabilitySupported, Steering: proto.CapabilitySupported, DurableTurns: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported, WebSearchControl: proto.CapabilitySupported, TextVerbosity: proto.CapabilitySupported, ExecutionControls: proto.CapabilitySupported, SubagentControl: proto.CapabilitySupported, ToolObservations: proto.CapabilitySupported, EnvironmentNone: proto.CapabilitySupported, Preparation: proto.CapabilitySupported})}}}) + h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Streaming: proto.CapabilitySupported, Steering: proto.CapabilitySupported, DurableTurns: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported, ExecutionControls: proto.CapabilitySupported, SubagentControl: proto.CapabilitySupported, ToolObservations: proto.CapabilitySupported, EnvironmentNone: proto.CapabilitySupported, Preparation: proto.CapabilitySupported})}}}) h.session = publicSession(t, h, "public") ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -60,7 +60,7 @@ func TestExecutionWorkerAdmissionBindingAndRecovery(t *testing.T) { if err := request.DecodePayload(&prompt); err != nil { t.Fatal(err) } - if inputTextForTest(t, prompt.Input) != "First\n\nSecond" || !prompt.DisableExecutionEnvironment || !prompt.DisableSubagents || prompt.ExecutionControls == nil || *prompt.ExecutionControls != (proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}) || prompt.AgentOptions["web_search"] != nil || prompt.AgentOptions["model_verbosity"] != nil { + if inputTextForTest(t, prompt.Input) != "First\n\nSecond" || !prompt.DisableExecutionEnvironment || !prompt.DisableSubagents || prompt.ExecutionControls == nil || *prompt.ExecutionControls != (proto.ExecutionControls{TextVerbosity: "medium"}) || prompt.AgentOptions["web_search"] != nil || prompt.AgentOptions["model_verbosity"] != nil { t.Fatal(prompt) } bound, err := sessionAdapter(h.s).GetSessionDevice(ctx, h.tenant, h.session.ID) diff --git a/services/core/tests/integration/saved_web_search_public_test.go b/services/core/tests/integration/saved_web_search_public_test.go index a509660f3..a0560a5a9 100644 --- a/services/core/tests/integration/saved_web_search_public_test.go +++ b/services/core/tests/integration/saved_web_search_public_test.go @@ -14,9 +14,9 @@ import ( ) // 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). +// projection (W1/W2). Codex declares cached and live search, so saved and inline +// search reach the Session unchanged (W4/W6); a tools replacement replaces it +// (W5) and tenant isolation holds (W8). func TestSavedWebSearchPostgres(t *testing.T) { // An isolated database keeps the no-write digest independent of other tests. s, _ := newManagedTestStore(t) @@ -73,6 +73,7 @@ func TestSavedWebSearchPostgres(t *testing.T) { } const defaults = `"context_size":"medium","allowed_domains":null,"location":null}` live := `{"type":"web_search","mode":"live",` + defaults + cached := `{"type":"web_search","mode":"cached",` + defaults // W1: Agent create with every pinned mode (official create 201). agentIDs := map[string]string{} @@ -80,7 +81,7 @@ func TestSavedWebSearchPostgres(t *testing.T) { {"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", `{"type":"web_search","mode":"cached"}`, cached}, {"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 @@ -169,41 +170,35 @@ func TestSavedWebSearchPostgres(t *testing.T) { t.Fatalf("retry fixture: %d", status) } client.created(owner, "/v1/agents/"+agentIDs["mode-disabled"], `{"tools":[{"type":"web_search","mode":"live"}]}`) - enabledTools := map[string]string{"mode-live": agentIDs["mode-live"], "mode-cached": agentIDs["mode-cached"], "type-only": agentIDs["type-only"], "updated-to-live": agentIDs["mode-disabled"]} + enabledTools := map[string]struct{ id, tool string }{"mode-live": {agentIDs["mode-live"], live}, "mode-cached": {agentIDs["mode-cached"], cached}, "type-only": {agentIDs["type-only"], live}, "updated-to-live": {agentIDs["mode-disabled"], live}} - // W4: every creation mode rejects enabled saved search without writes. - before := databaseDigest(t, s.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) + // W4: each saved search reaches a new Session unchanged. + for name, saved := range enabledTools { + session := client.created(owner, "/v1/agents/sessions", `{"agent_id":"`+saved.id+`","environment":{"type":"none"},"input":"hi"}`) + if got := sessionTool(name, session); got != canonicalTool(t, json.RawMessage(saved.tool)) { + t.Errorf("%s Session tool %s, want %s", name, got, saved.tool) } } - // 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"}`} { + // W6: inline enabled or omitted-mode search is admitted the same way. + for tool, want := range map[string]string{`{"type":"web_search"}`: live, `{"type":"web_search","mode":null}`: live, `{"type":"web_search","mode":"cached"}`: 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) + if got := sessionTool(request, client.created(owner, "/v1/agents/sessions", request)); got != canonicalTool(t, json.RawMessage(want)) { + t.Errorf("inline %s: Session tool %s, want %s", request, got, want) } } } + + before := databaseDigest(t, s.pool) + // C4: the input requirement still applies to a saved search Agent. + const input = `{"error":{"message":"conversation-only sessions currently require initial input","type":"invalid_request_error","code":"invalid_request_error","param":null}}` + "\n" + for name, saved := range enabledTools { + if status, body := client.do(owner, http.MethodPost, "/v1/agents/sessions", "application/json", []byte(`{"agent_id":"`+saved.id+`","environment":{"type":"none"}}`)); status != http.StatusBadRequest || body != input { + t.Errorf("%s without input: %d %s", name, 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 }{ @@ -236,11 +231,11 @@ func TestSavedWebSearchPostgres(t *testing.T) { 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) + if status, session := retry("after-search"); status != http.StatusCreated || session == original { + t.Fatalf("new key after enabling search: %d %s", status, session) } - // W5: a per-Session tools replacement admits the saved Agent without its search. + // W5: a per-Session tools replacement replaces the saved search. for _, replacement := range []string{`[]`, `[{"type":"web_search","mode":"disabled"}]`} { session := client.created(owner, "/v1/agents/sessions", `{"agent_id":"`+agentIDs["mode-live"]+`","agent":{"tools":`+replacement+`},"environment":{"type":"none"},"input":"hi"}`) if got := sessionTool("replacement", session); got != "" && got != disabled { diff --git a/services/core/tests/integration/unified_model_configuration_http_test.go b/services/core/tests/integration/unified_model_configuration_http_test.go index 3fa13586f..90d56ed19 100644 --- a/services/core/tests/integration/unified_model_configuration_http_test.go +++ b/services/core/tests/integration/unified_model_configuration_http_test.go @@ -21,7 +21,7 @@ func TestUnifiedModelConfigurationHTTP(t *testing.T) { if err != nil { t.Fatal(err) } - handler, err := publicHandler(t, st, auth, "codex", withCoreKeys(admin), withHarnesses([]string{"codex", "claude_sdk"})) + handler, err := publicHandler(t, st, auth, "claude_sdk", withCoreKeys(admin), withHarnesses([]string{"codex", "claude_sdk"})) if err != nil { t.Fatal(err) } @@ -69,10 +69,10 @@ func TestUnifiedModelConfigurationHTTP(t *testing.T) { t.Fatalf("JSON mismatch: %s; want %s", got, want) } } - const path = "/core/v1/harnesses/codex/model-configuration" - const high = `{"model_reasoning_effort":"high"}` - const low = `{"model_reasoning_effort":"low"}` - const provider = `{"protocol":"responses","base_url":"https://deployment.example/v1","api_key":"deployment-canary"}` + const path = "/core/v1/harnesses/claude_sdk/model-configuration" + const high = `{"thinking":{"type":"enabled","budgetTokens":3000}}` + const low = `{"thinking":{"type":"enabled","budgetTokens":1000}}` + const provider = `{"protocol":"anthropic","base_url":"https://deployment.example/v1","api_key":"deployment-canary"}` setDefault := func(model, native string) { t.Helper() body := `{"model":"` + model + `","harness_config":` + native + `,"model_provider":` + provider + `}` @@ -84,7 +84,7 @@ func TestUnifiedModelConfigurationHTTP(t *testing.T) { t.Fatal("model configuration identity changed") } equalJSON(view["harness_config"], native) - equalJSON(view["model_provider"], `{"protocol":"responses","base_url":"https://deployment.example/v1","api_key_configured":true}`) + equalJSON(view["model_provider"], `{"protocol":"anthropic","base_url":"https://deployment.example/v1","api_key_configured":true}`) } create := func(body, retry string) string { t.Helper() @@ -134,7 +134,7 @@ func TestUnifiedModelConfigurationHTTP(t *testing.T) { } assertSession(fresh, "model-replacement", low, "deployment", "deployment", "deployment", "deployment-canary") - const explicitProvider = `{"protocol":"responses","base_url":"https://explicit.example/v1","api_key":"explicit-canary"}` + const explicitProvider = `{"protocol":"anthropic","base_url":"https://explicit.example/v1","api_key":"explicit-canary"}` for _, tc := range []struct{ name, body, model, modelSource, providerSource, key string }{ {"model replaces native defaults", `{"agent":{"model":"explicit-model"},"environment":{"type":"openai_hosted"}}`, "explicit-model", "session", "deployment", "deployment-canary"}, {"provider replaces native defaults", `{"agent":{},"environment":{"type":"openai_hosted"},"x_agents_core":{"model_provider":` + explicitProvider + `}}`, "model-replacement", "deployment", "session", "explicit-canary"}, @@ -161,10 +161,10 @@ func TestUnifiedModelConfigurationHTTP(t *testing.T) { for _, tc := range []struct{ name, update string }{ {"model", `{"model":"another-model"}`}, {"provider", `{"x_agents_core":{"model_provider":` + explicitProvider + `}}`}, - {"harness", `{"x_agents_core":{"harness":"claude_sdk","model_provider":` + strings.Replace(provider, `"protocol":"responses"`, `"protocol":"anthropic"`, 1) + `}}`}, + {"harness", `{"x_agents_core":{"harness":"codex","model_provider":` + strings.Replace(provider, `"protocol":"anthropic"`, `"protocol":"responses"`, 1) + `}}`}, } { t.Run("saved Agent "+tc.name, func(t *testing.T) { - body := `{"model":"saved-model","x_agents_core":{"harness":"codex","harness_config":` + high + `,"model_provider":` + provider + `}}` + body := `{"model":"saved-model","x_agents_core":{"harness":"claude_sdk","harness_config":` + high + `,"model_provider":` + provider + `}}` id := text(object(call("POST", "/v1/agents", token, body, "", 201))["id"]) call("POST", "/v1/agents/"+id, token, tc.update, "", 200) saved := object(call("GET", "/v1/agents/"+id, token, "", "", 200)) @@ -181,12 +181,12 @@ func TestUnifiedModelConfigurationHTTP(t *testing.T) { }) } // Disabling tools cannot enable a non-native model protocol. - for _, protocol := range []string{"anthropic", "chat_completions"} { + for _, protocol := range []string{"responses", "chat_completions"} { t.Run("non-native protocol "+protocol, func(t *testing.T) { incompatible := `{"protocol":"` + protocol + `","base_url":"https://model.example/v1","api_key":"rejected-canary"}` agent := `{"model":"fixed-model","tools":[{"type":"web_search","mode":"disabled"}]}` call("POST", "/v1/agents/sessions", token, `{"agent":`+agent+`,"environment":{"type":"openai_hosted"},"x_agents_core":{"model_provider":`+incompatible+`}}`, uuid.NewString(), 400) - saved := strings.TrimSuffix(agent, "}") + `,"x_agents_core":{"harness":"codex","model_provider":` + incompatible + `}}` + saved := strings.TrimSuffix(agent, "}") + `,"x_agents_core":{"harness":"claude_sdk","model_provider":` + incompatible + `}}` call("POST", "/v1/agents", token, saved, "", 400) }) } diff --git a/services/core/tests/integration/worker_lease_loss_test.go b/services/core/tests/integration/worker_lease_loss_test.go index d2b75293f..9abe7b20f 100644 --- a/services/core/tests/integration/worker_lease_loss_test.go +++ b/services/core/tests/integration/worker_lease_loss_test.go @@ -14,7 +14,7 @@ import ( func TestWorkerLeaseLossLeavesUncertainWorkForSuccessor(t *testing.T) { h := newDispatchHarness(t) _, pool := testStore(t) - h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Streaming: proto.CapabilitySupported, Steering: proto.CapabilitySupported, DurableTurns: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported, WebSearchControl: proto.CapabilitySupported, TextVerbosity: proto.CapabilitySupported, ExecutionControls: proto.CapabilitySupported, SubagentControl: proto.CapabilitySupported, ToolObservations: proto.CapabilitySupported, EnvironmentNone: proto.CapabilitySupported, Preparation: proto.CapabilitySupported})}}}) + h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Streaming: proto.CapabilitySupported, Steering: proto.CapabilitySupported, DurableTurns: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported, ExecutionControls: proto.CapabilitySupported, SubagentControl: proto.CapabilitySupported, ToolObservations: proto.CapabilitySupported, EnvironmentNone: proto.CapabilitySupported, Preparation: proto.CapabilitySupported})}}}) h.session = publicSession(t, h, "active") queued := publicSession(t, h, "queued") worker := startWorker(t, t.Context(), h.s, h.d) diff --git a/services/core/tests/official_agent_reference_retry.py b/services/core/tests/official_agent_reference_retry.py index 112352452..2e3811b85 100644 --- a/services/core/tests/official_agent_reference_retry.py +++ b/services/core/tests/official_agent_reference_retry.py @@ -38,7 +38,7 @@ def mutate(**values): fresh = sessions.create(**spec) assert fresh.agent.model == "changed-model" and fresh.agent.instructions == "changed" assert first.agent.model == "original-model" and first.agent.instructions == "frozen" - mutate(patch={"service_tier":"priority"}) + mutate(patch={"text":{"format":{"type":"json_schema","schema":{"type":"object"}},"verbosity":"medium"}}) expect(BadRequestError, lambda:sessions.create(**spec)) assert sessions.create(**spec, extra_headers=headers) == first for changed in ({"input":"different"}, {"metadata":{"changed":"yes"}}, {"agent":{"instructions":"frozen"}}, {"agent_id":fresh.id}): diff --git a/services/core/tests/official_agent_references.py b/services/core/tests/official_agent_references.py index cdddefff0..54f121794 100644 --- a/services/core/tests/official_agent_references.py +++ b/services/core/tests/official_agent_references.py @@ -59,9 +59,6 @@ def verify_agent_references(client, other, expect_error): # Configuration storage is broader than execution. Never silently drop an # unsupported saved option, but admit a supported whole-field replacement. for field, value, replacement in ( - ("reasoning", {"effort": "high"}, None), - ("reasoning", {"summary": "auto"}, {}), - ("service_tier", "fast", "auto"), ("text", {"format": {"type": "json_schema", "schema": {"type": "object"}}}, {"verbosity": "medium"}), ("tools", [dict(tool, defer_loading=True)], None), ("tools", [{"type": "tool_search"}], []), @@ -78,6 +75,9 @@ def verify_agent_references(client, other, expect_error): for field, value, replacement in ( ("multi_agent", {"enabled": True}, {"enabled": False}), ("tools", [{"type": "programmatic_tool_calling", "enabled": False}], []), + ("reasoning", {"effort": "high"}, {"effort": "low"}), + ("reasoning", {"summary": "auto"}, {"summary": "concise"}), + ("service_tier", "fast", "auto"), ): supported = agents.create(model="model", **{field: value}) reference = {"input": "Verify agent references fixture admission.", "agent_id": supported.id, "environment": {"type": "none"}} diff --git a/services/core/tests/official_agent_update.py b/services/core/tests/official_agent_update.py index 9b4b8e7fa..bfcb395aa 100644 --- a/services/core/tests/official_agent_update.py +++ b/services/core/tests/official_agent_update.py @@ -86,16 +86,13 @@ 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. + # Saved web_search keeps every pinned mode (TV-05). A new Session copies the + # live 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 [t.to_dict() for t in sessions.create(**spec).agent.tools] == [t.to_dict() for t in searched.tools] 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"] == [ diff --git a/services/core/tests/official_tool_policy.py b/services/core/tests/official_tool_policy.py index 1da3e06fa..695c166dd 100644 --- a/services/core/tests/official_tool_policy.py +++ b/services/core/tests/official_tool_policy.py @@ -8,7 +8,7 @@ from types import SimpleNamespace import httpx2 -from openai import BadRequestError, NotFoundError, OpenAI +from openai import NotFoundError, OpenAI def main(): @@ -77,15 +77,6 @@ def execute(entry, prompt, creation=None): entry[stage + "_events"] = events save() - 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") - except BadRequestError: - pass - try: if stage == "initial": sdk_agent = client.beta.agents.create(**config) @@ -126,20 +117,6 @@ def reject_configuration(payload, message): save() check_config(sid) execute(entry, prompt, creation=creation) - 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."}, 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."}, 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"]: