From 39540fbb6556baf80ed6b8cd1a6d8f67885647ba Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 5 Nov 2026 04:01:24 +0000 Subject: [PATCH 1/3] Carry per-root-Turn settings over the Runtime protocol Add proto.TurnSettings (model, reasoning effort, service tier, review) to the prepare configuration, moved out of agent_options and ExecutionControls. The Executor identity ignores it; the Runtime hands each Turn's settings to StartTurn. harnessconfig gains Reviews and ValidateTurnSettings, and Prepare takes the model. Protocol version 0.24.0. No behavior change. --- .../agent/claudesdk/cancellation_test.go | 2 +- .../agent/claudesdk/commands_session_test.go | 2 +- .../claudesdk/error_classification_test.go | 2 +- .../claudesdk/execution_controls_test.go | 39 +++++++++--------- .../internal/agent/claudesdk/executor.go | 2 +- .../agent/claudesdk/executor_fixture_test.go | 2 +- .../internal/agent/claudesdk/executor_test.go | 10 ++--- .../agent/claudesdk/functions_test.go | 2 +- .../agent/claudesdk/harness_config_test.go | 2 +- .../agent/claudesdk/mcp_bearer_test.go | 4 +- .../internal/agent/claudesdk/mcp_test.go | 2 +- .../internal/agent/claudesdk/messages_test.go | 2 +- .../internal/agent/claudesdk/options.go | 17 ++++---- .../internal/agent/claudesdk/options_test.go | 13 +++--- .../agent/claudesdk/preparation_test.go | 8 ++-- .../agent/claudesdk/readiness_test.go | 6 +-- .../agent/claudesdk/restrictions_test.go | 2 +- .../internal/agent/claudesdk/session_test.go | 4 +- .../internal/agent/claudesdk/steering_test.go | 2 +- .../internal/agent/claudesdk/usage_test.go | 2 +- .../claudesdk/workspace_structured_test.go | 2 +- .../agent/claudesdk/workspace_test.go | 2 +- .../agent/codex/execution_controls_test.go | 28 ++++++++++--- apps/daemon/internal/agent/codex/executor.go | 2 +- .../internal/agent/codex/executor_test.go | 22 +++++----- .../agent/codex/harness_config_test.go | 17 ++++---- .../agent/codex/mcp_http_preflight_test.go | 4 +- .../internal/agent/codex/mcp_required_test.go | 4 +- .../internal/agent/codex/model_route_test.go | 9 +++-- apps/daemon/internal/agent/codex/options.go | 29 ++++++++------ .../internal/agent/codex/options_test.go | 8 ++-- .../agent/codex/preparation_helpers_test.go | 2 +- .../agent/codex/provider_config_test.go | 12 +++--- .../internal/agent/codex/recovery_test.go | 2 +- .../internal/agent/codex/session_plan.go | 2 +- .../agent/codex/terminal_cleanup_test.go | 6 +-- .../internal/agent/configuration_test.go | 14 +++---- .../internal/agent/contracttest/text.go | 2 +- apps/daemon/internal/agent/harness.go | 4 +- .../agent/mcode/environment_mcp_test.go | 2 +- apps/daemon/internal/agent/mcode/execution.go | 3 ++ apps/daemon/internal/agent/mcode/executor.go | 2 +- .../agent/mcode/executor_backpressure_test.go | 4 +- .../agent/mcode/executor_native_test.go | 6 +-- .../internal/agent/mcode/executor_test.go | 14 +++---- .../agent/mcode/functions_native_test.go | 2 +- .../agent/mcode/model_provider_test.go | 4 +- .../agent/mcode/native_effort_mcp_test.go | 4 +- apps/daemon/internal/agent/mcode/options.go | 8 +--- .../internal/agent/mcode/options_test.go | 2 +- apps/daemon/internal/agent/mcode/session.go | 4 +- .../internal/agent/mcode/session_test.go | 6 +-- .../agent/mcode/workspace_native_test.go | 2 +- .../internal/cli/connect_cleanup_test.go | 2 +- apps/daemon/internal/dispatch/executor.go | 3 +- .../dispatch/executor_cancel_receipt_test.go | 2 +- .../dispatch/executor_handoff_test.go | 2 +- .../daemon/internal/dispatch/executor_test.go | 36 +++++++++++++++-- .../dispatch/functions_native_test.go | 2 +- apps/daemon/internal/dispatch/preparation.go | 3 +- .../preparation_executor_fixture_test.go | 2 +- .../internal/dispatch/preparation_start.go | 2 +- apps/daemon/internal/dispatch/suspend_test.go | 2 +- .../internal/wireconformance/wire_test.go | 5 ++- apps/daemon/testdata/onboarding/main.go | 2 +- contracts/agents-api/harness-onboarding.md | 6 +-- contracts/agents-api/zh/harness-onboarding.md | 8 ++-- docs/runtime-protocol.md | 9 +++-- docs/zh/runtime-protocol.md | 11 ++--- internal/agentdaemon/proto/outbound.go | 29 ++++++++++---- internal/agentdaemon/proto/prototest/wire.go | 6 ++- internal/agentdaemon/proto/version.go | 2 +- .../proto/workspace_read_preparation.go | 2 +- internal/harnessconfig/harness.go | 40 +++++++++++++------ internal/harnessconfig/preparation_test.go | 30 +++++++------- services/core/internal/execution/controls.go | 19 ++++++--- .../core/internal/execution/controls_test.go | 14 ++++++- .../internal/execution/harness_admission.go | 3 ++ services/core/internal/execution/request.go | 4 +- .../core/tests/integration/dispatch_test.go | 2 +- 80 files changed, 356 insertions(+), 251 deletions(-) diff --git a/apps/daemon/internal/agent/claudesdk/cancellation_test.go b/apps/daemon/internal/agent/claudesdk/cancellation_test.go index 8d1bf3503..1b2a675ec 100644 --- a/apps/daemon/internal/agent/claudesdk/cancellation_test.go +++ b/apps/daemon/internal/agent/claudesdk/cancellation_test.go @@ -163,7 +163,7 @@ func cancellationConfig(root, mode string) Config { } func cancellationRequest() proto.PromptRequestPayload { - return proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), AgentSessionID: "native-session", AgentOptions: map[string]any{"model": "fake-model", "system_prompt": "instructions"}} + return proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), AgentSessionID: "native-session", Turn: proto.TurnSettings{Model: "fake-model"}, AgentOptions: map[string]any{"system_prompt": "instructions"}} } func runCancellationHelper(request startRequest, mode string, scanner *bufio.Scanner, emit func(bridgeEvent)) { diff --git a/apps/daemon/internal/agent/claudesdk/commands_session_test.go b/apps/daemon/internal/agent/claudesdk/commands_session_test.go index b19a7584e..58ba1e7f4 100644 --- a/apps/daemon/internal/agent/claudesdk/commands_session_test.go +++ b/apps/daemon/internal/agent/claudesdk/commands_session_test.go @@ -37,7 +37,7 @@ func TestWorkspaceCommandFramesKeepStartIdentityAndObservedOutput(t *testing.T) t.Fatal("preparation submitted a command") } out := make(chan proto.Envelope, 16) - s, err := resource.StartTurn(t.Context(), "actual-command-run", proto.TextInput("hello"), out) + s, err := resource.StartTurn(t.Context(), "actual-command-run", proto.TurnSettings{}, proto.TextInput("hello"), out) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/claudesdk/error_classification_test.go b/apps/daemon/internal/agent/claudesdk/error_classification_test.go index 6ebc1eab2..2b4d2fdb8 100644 --- a/apps/daemon/internal/agent/claudesdk/error_classification_test.go +++ b/apps/daemon/internal/agent/claudesdk/error_classification_test.go @@ -21,7 +21,7 @@ func TestClassifiedBridgeFailurePreservesTerminalEvidence(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=classified-" + mode, "GORACE=atexit_sleep_ms=0"}} - req := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), AgentSessionID: "native-session", AgentOptions: map[string]any{"model": "fake-model", "system_prompt": "instructions"}} + req := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), AgentSessionID: "native-session", Turn: proto.TurnSettings{Model: "fake-model"}, AgentOptions: map[string]any{"system_prompt": "instructions"}} ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() out := make(chan proto.Envelope, 16) diff --git a/apps/daemon/internal/agent/claudesdk/execution_controls_test.go b/apps/daemon/internal/agent/claudesdk/execution_controls_test.go index ffd8c8230..fdfb74bbf 100644 --- a/apps/daemon/internal/agent/claudesdk/execution_controls_test.go +++ b/apps/daemon/internal/agent/claudesdk/execution_controls_test.go @@ -17,7 +17,7 @@ func TestExecutionControlsPreserveNativeDefaultsAndInstructions(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) config := Config{Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")} - request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("Original input."), AgentSessionID: "native-session", AgentOptions: map[string]any{"model": "native-model", "system_prompt": "Keep these exact instructions.\nDo not replace them."}} + request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("Original input."), AgentSessionID: "native-session", Turn: proto.TurnSettings{Model: "native-model"}, AgentOptions: map[string]any{"system_prompt": "Keep these exact instructions.\nDo not replace them."}} ordinary, _, err := prepareConfiguration(config, request) if err != nil { t.Fatal(err) @@ -49,7 +49,7 @@ func TestLiveWebSearchReachesTheBridgeOnEveryPlacement(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) none, _, err := prepareConfiguration(Config{Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")}, proto.PromptRequestPayload{ - RunID: "run", Input: proto.TextInput("Search."), ExecutionControls: live, AgentOptions: map[string]any{"model": "native-model"}}) + RunID: "run", Input: proto.TextInput("Search."), ExecutionControls: live, Turn: proto.TurnSettings{Model: "native-model"}, AgentOptions: map[string]any{}}) if err != nil || none.WebSearch != "live" { t.Fatal("live search on environment none did not reach the bridge", err) } @@ -63,23 +63,24 @@ func TestLiveWebSearchReachesTheBridgeOnEveryPlacement(t *testing.T) { func TestExecutionControlsRejectUnsupportedProfilesBeforeLaunch(t *testing.T) { enabled := true - cases := map[string]proto.ExecutionControls{ - "cached-search": {WebSearch: &proto.WebSearchControl{Mode: "cached", 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: "concise"}, - "service-tier": {ServiceTier: "priority"}, - } - for name, controls := range cases { + model := proto.TurnSettings{Model: "native-model"} + cases := map[string]proto.PromptRequestPayload{ + "cached-search": {Turn: model, ExecutionControls: &proto.ExecutionControls{WebSearch: &proto.WebSearchControl{Mode: "cached", ContextSize: "medium"}}}, + "enabled-ptc": {Turn: model, ExecutionControls: &proto.ExecutionControls{ProgrammaticToolCalling: &enabled}}, + "low-verbosity": {Turn: model, ExecutionControls: &proto.ExecutionControls{TextVerbosity: "low"}}, + "high-verbosity": {Turn: model, ExecutionControls: &proto.ExecutionControls{TextVerbosity: "high"}}, + "unknown-verbosity": {Turn: model, ExecutionControls: &proto.ExecutionControls{TextVerbosity: "invalid"}}, + "summary": {Turn: model, ExecutionControls: &proto.ExecutionControls{ReasoningSummary: "concise"}}, + "no-effort": {Turn: proto.TurnSettings{Model: "native-model", ReasoningEffort: "none"}}, + "minimal-effort": {Turn: proto.TurnSettings{Model: "native-model", ReasoningEffort: "minimal"}}, + "service-tier": {Turn: proto.TurnSettings{Model: "native-model", ServiceTier: "priority"}}, + } + for name, request := range cases { t.Run(name, func(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) 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"}} + request.RunID, request.Input = "run", proto.TextInput("Original input.") _, err := startSingleTurn(t.Context(), config, request, make(chan proto.Envelope, 1)) if err == nil || !strings.Contains(err.Error(), "not supported by this Harness") { t.Fatal("unsupported controls did not fail at admission", err) @@ -96,7 +97,7 @@ func TestReasoningEffortReachesNativeOptions(t *testing.T) { 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"}}}} + request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), ExecutionControls: &proto.ExecutionControls{TextVerbosity: "medium"}, Turn: proto.TurnSettings{Model: "native-model", ReasoningEffort: effort}, AgentOptions: map[string]any{"harness_config": map[string]any{"thinking": map[string]any{"type": "adaptive"}}}} start, _, err := prepareConfiguration(config, request) if err != nil { t.Fatal(effort, err) @@ -127,7 +128,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."), 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}}} + request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("Original input."), DisableSubagents: true, Turn: proto.TurnSettings{Model: "model"}, AgentOptions: map[string]any{"system_prompt": "Original instructions."}, ExecutionControls: &proto.ExecutionControls{TextVerbosity: "medium", OutputFormat: &proto.OutputFormat{Type: "json_schema", Schema: schema}}} start, _, err := prepareConfiguration(config, request) if err != nil { t.Fatal(err) @@ -151,7 +152,7 @@ func TestToolDiscoveryPreservesFrozenFunctionsBesideOtherProfiles(t *testing.T) root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) config := Config{Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")} - request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("Original input."), DisableSubagents: true, ToolSearch: true, AgentOptions: map[string]any{"model": "model"}, FunctionTools: []proto.FunctionTool{ + request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("Original input."), DisableSubagents: true, ToolSearch: true, Turn: proto.TurnSettings{Model: "model"}, AgentOptions: map[string]any{}, FunctionTools: []proto.FunctionTool{ {Name: "lookup", Description: "Lookup", Parameters: json.RawMessage(`{"type":"object","properties":{"ticket":{"const":"original"}}}`), DeferLoading: true}, {Name: "clock", Description: "Clock", Parameters: json.RawMessage(`{"type":"object"}`)}, }} @@ -174,7 +175,7 @@ func TestReasoningSummaryReachesTheBridgeOnlyWhenRequested(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) config := Config{Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")} - request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("Input."), AgentOptions: map[string]any{"model": "native-model"}} + request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("Input."), Turn: proto.TurnSettings{Model: "native-model"}, AgentOptions: map[string]any{}} for _, summary := range []string{"", "auto"} { request.ExecutionControls = &proto.ExecutionControls{TextVerbosity: "medium", ReasoningSummary: summary} start, _, err := prepareConfiguration(config, request) diff --git a/apps/daemon/internal/agent/claudesdk/executor.go b/apps/daemon/internal/agent/claudesdk/executor.go index 4fc143057..d895e9082 100644 --- a/apps/daemon/internal/agent/claudesdk/executor.go +++ b/apps/daemon/internal/agent/claudesdk/executor.go @@ -179,7 +179,7 @@ func (e *executor) read() { close(e.done) } -func (e *executor) StartTurn(ctx context.Context, run string, input proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { +func (e *executor) StartTurn(ctx context.Context, run string, _ proto.TurnSettings, input proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { if ctx == nil { ctx = context.Background() } diff --git a/apps/daemon/internal/agent/claudesdk/executor_fixture_test.go b/apps/daemon/internal/agent/claudesdk/executor_fixture_test.go index e6ad19996..80a8b5164 100644 --- a/apps/daemon/internal/agent/claudesdk/executor_fixture_test.go +++ b/apps/daemon/internal/agent/claudesdk/executor_fixture_test.go @@ -21,7 +21,7 @@ func startSingleTurn(ctx context.Context, config Config, req proto.PromptRequest if err != nil { return nil, err } - turn, err := resource.StartTurn(ctx, run, input, out) + turn, err := resource.StartTurn(ctx, run, proto.TurnSettings{}, input, out) if turn == nil { _ = resource.Close(context.Background()) return nil, err diff --git a/apps/daemon/internal/agent/claudesdk/executor_test.go b/apps/daemon/internal/agent/claudesdk/executor_test.go index 964f2be19..a938d076d 100644 --- a/apps/daemon/internal/agent/claudesdk/executor_test.go +++ b/apps/daemon/internal/agent/claudesdk/executor_test.go @@ -26,7 +26,7 @@ func persistentConfig(t *testing.T, mode string) (Config, proto.PromptRequestPay if err := os.WriteFile(entry, []byte("version-one"), 0600); err != nil { t.Fatal(err) } - return Config{Node: os.Args[0], Entrypoint: entry, StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_EXECUTOR_HELPER=1", "SDK_EXECUTOR_DIR=" + root, "SDK_EXECUTOR_MODE=" + mode, "GORACE=atexit_sleep_ms=0"}}, proto.PromptRequestPayload{DisableExecutionEnvironment: true, AgentOptions: map[string]any{"model": "fixture"}} + return Config{Node: os.Args[0], Entrypoint: entry, StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_EXECUTOR_HELPER=1", "SDK_EXECUTOR_DIR=" + root, "SDK_EXECUTOR_MODE=" + mode, "GORACE=atexit_sleep_ms=0"}}, proto.PromptRequestPayload{DisableExecutionEnvironment: true, Turn: proto.TurnSettings{Model: "fixture"}, AgentOptions: map[string]any{}} } func runPersistentExecutorHelper() { @@ -135,7 +135,7 @@ func runPersistentExecutorHelper() { func consumeExecutorTurn(t *testing.T, owner agent.Executor, id, input string) (agent.Turn, <-chan proto.Envelope) { t.Helper() out := make(chan proto.Envelope, 16) - turn, err := owner.StartTurn(t.Context(), id, proto.TextInput(input), out) + turn, err := owner.StartTurn(t.Context(), id, proto.TurnSettings{}, proto.TextInput(input), out) if err != nil || turn == nil { t.Fatalf("StartTurn: %v", err) } @@ -215,7 +215,7 @@ func TestExecutorLateTurnEventInvalidatesWithoutRetargeting(t *testing.T) { t.Fatal("unknown native outcome marked settled") } next := make(chan proto.Envelope, 1) - if turn, err := owner.StartTurn(t.Context(), "third", proto.TextInput("hello"), next); turn != nil || err == nil { + if turn, err := owner.StartTurn(t.Context(), "third", proto.TurnSettings{}, proto.TextInput("hello"), next); turn != nil || err == nil { t.Fatal("invalid executor accepted input") } } @@ -262,7 +262,7 @@ func TestExecutorSeparatesPreInputRejectionFromUnknownWrite(t *testing.T) { rejected := make(chan proto.Envelope, 1) cancelled, stop := context.WithCancel(t.Context()) stop() - if turn, err := owner.StartTurn(cancelled, "not-written", proto.TextInput("hello"), rejected); turn != nil || err == nil { + if turn, err := owner.StartTurn(cancelled, "not-written", proto.TurnSettings{}, proto.TextInput("hello"), rejected); turn != nil || err == nil { t.Fatal("cancelled admission reached native input") } select { @@ -275,7 +275,7 @@ func TestExecutorSeparatesPreInputRejectionFromUnknownWrite(t *testing.T) { t.Fatal(err) } out := make(chan proto.Envelope, 8) - turn, err := owner.StartTurn(t.Context(), "unknown-write", proto.TextInput("hello"), out) + turn, err := owner.StartTurn(t.Context(), "unknown-write", proto.TurnSettings{}, proto.TextInput("hello"), out) if turn == nil || err == nil { t.Fatalf("attempted write must preserve unknown Turn ownership: %T %v", turn, err) } diff --git a/apps/daemon/internal/agent/claudesdk/functions_test.go b/apps/daemon/internal/agent/claudesdk/functions_test.go index 5f99ec7bb..3d4d5e5d5 100644 --- a/apps/daemon/internal/agent/claudesdk/functions_test.go +++ b/apps/daemon/internal/agent/claudesdk/functions_test.go @@ -22,7 +22,7 @@ func TestFunctionFactoryNativeReceipts(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=" + mode, "GORACE=atexit_sleep_ms=0"}} - request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), AgentSessionID: "native-session", AgentOptions: map[string]any{"model": "fake-model", "system_prompt": "instructions"}, FunctionTools: []proto.FunctionTool{{Name: "lookup", Description: "Lookup.", Parameters: json.RawMessage(`{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"}}}}`)}}} + request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), AgentSessionID: "native-session", Turn: proto.TurnSettings{Model: "fake-model"}, AgentOptions: map[string]any{"system_prompt": "instructions"}, FunctionTools: []proto.FunctionTool{{Name: "lookup", Description: "Lookup.", Parameters: json.RawMessage(`{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"}}}}`)}}} ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() out := make(chan proto.Envelope, 16) diff --git a/apps/daemon/internal/agent/claudesdk/harness_config_test.go b/apps/daemon/internal/agent/claudesdk/harness_config_test.go index ae4dc36e1..835a6b891 100644 --- a/apps/daemon/internal/agent/claudesdk/harness_config_test.go +++ b/apps/daemon/internal/agent/claudesdk/harness_config_test.go @@ -13,7 +13,7 @@ func TestHarnessConfigReachesBridgeAndOwnsSnapshot(t *testing.T) { t.Setenv("OAC_RUNTIME_HOME", root) config := Config{Entrypoint: filepath.Join(root, "main.js"), StateDir: filepath.Join(root, "state")} 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"}} + req := proto.PromptRequestPayload{Turn: proto.TurnSettings{Model: "fixture", ReasoningEffort: "high"}, AgentOptions: map[string]any{"harness_config": native}, ExecutionControls: &proto.ExecutionControls{TextVerbosity: "medium"}} start, _, err := prepareConfiguration(config, req) if err != nil { t.Fatal(err) diff --git a/apps/daemon/internal/agent/claudesdk/mcp_bearer_test.go b/apps/daemon/internal/agent/claudesdk/mcp_bearer_test.go index 6fd5830cd..34a7d749a 100644 --- a/apps/daemon/internal/agent/claudesdk/mcp_bearer_test.go +++ b/apps/daemon/internal/agent/claudesdk/mcp_bearer_test.go @@ -22,7 +22,7 @@ func TestMCPBearerUsesFreshOwnedEnvironmentReferences(t *testing.T) { {ConnectionOrigin: "service", ServerLabel: "second", ServerURL: "https://second.example/mcp", BearerToken: &tokens[1]}, {ConnectionOrigin: "service", ServerLabel: "anonymous", ServerURL: "http://anonymous.example/mcp"}, } - req := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), DisableExecutionEnvironment: true, MCPHTTPServers: &servers, AgentOptions: map[string]any{"model": "fixture"}} + req := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), DisableExecutionEnvironment: true, MCPHTTPServers: &servers, Turn: proto.TurnSettings{Model: "fixture"}, AgentOptions: map[string]any{}} seen := map[string]bool{} for range 2 { start, env, err := prepareConfiguration(config, req) @@ -66,7 +66,7 @@ func TestMCPBearerRejectsInvalidCredentialBeforeStateCreation(t *testing.T) { t.Setenv("OAC_RUNTIME_HOME", root) config := Config{Entrypoint: filepath.Join(root, "main.js"), StateDir: filepath.Join(root, "state")} servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "fixture", ServerURL: "https://example.invalid/mcp", BearerToken: &token}} - req := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), DisableExecutionEnvironment: true, MCPHTTPServers: &servers, AgentOptions: map[string]any{"model": "fixture"}} + req := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), DisableExecutionEnvironment: true, MCPHTTPServers: &servers, Turn: proto.TurnSettings{Model: "fixture"}, AgentOptions: map[string]any{}} if _, _, err := prepareConfiguration(config, req); err == nil || err.Error() != "claudesdk: unsupported HTTPS MCP bearer credential" { t.Fatal("invalid bearer accepted or unsafe error returned") } diff --git a/apps/daemon/internal/agent/claudesdk/mcp_test.go b/apps/daemon/internal/agent/claudesdk/mcp_test.go index 25ba5ab6b..49c1c9eba 100644 --- a/apps/daemon/internal/agent/claudesdk/mcp_test.go +++ b/apps/daemon/internal/agent/claudesdk/mcp_test.go @@ -16,7 +16,7 @@ func TestHTTPMCPDeclaration(t *testing.T) { t.Setenv("OAC_RUNTIME_HOME", root) config := Config{Entrypoint: filepath.Join(root, "main.js"), StateDir: filepath.Join(root, "state")} servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "fixture", ServerURL: "https://example.invalid/mcp"}} - req := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), DisableExecutionEnvironment: true, MCPHTTPServers: &servers, AgentOptions: map[string]any{"model": "fixture"}} + req := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), DisableExecutionEnvironment: true, MCPHTTPServers: &servers, Turn: proto.TurnSettings{Model: "fixture"}, AgentOptions: map[string]any{}} tools := []string{"echo"} switch mode { case "selected": diff --git a/apps/daemon/internal/agent/claudesdk/messages_test.go b/apps/daemon/internal/agent/claudesdk/messages_test.go index 7a6c0ffa1..3c41be51d 100644 --- a/apps/daemon/internal/agent/claudesdk/messages_test.go +++ b/apps/daemon/internal/agent/claudesdk/messages_test.go @@ -20,7 +20,7 @@ func TestMessageObservations(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=" + mode, "GORACE=atexit_sleep_ms=0"}} - request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), AgentSessionID: "native-session", AgentOptions: map[string]any{"model": "fake-model", "system_prompt": "instructions"}} + request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), AgentSessionID: "native-session", Turn: proto.TurnSettings{Model: "fake-model"}, AgentOptions: map[string]any{"system_prompt": "instructions"}} ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() out := make(chan proto.Envelope, 16) diff --git a/apps/daemon/internal/agent/claudesdk/options.go b/apps/daemon/internal/agent/claudesdk/options.go index d82cfd9d1..4817260af 100644 --- a/apps/daemon/internal/agent/claudesdk/options.go +++ b/apps/daemon/internal/agent/claudesdk/options.go @@ -45,18 +45,18 @@ func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startR fail := func(reason string) (startRequest, []string, error) { return startRequest{}, nil, fmt.Errorf("claudesdk: %s", reason) } - modelConfiguration, err := harnessconfiguration.Declaration.Configuration.Prepare(req.AgentOptions) + modelConfiguration, err := harnessconfiguration.Declaration.Configuration.Prepare(req.Turn.Model, req.AgentOptions) if err != nil { return startRequest{}, nil, err } if err := harnessconfiguration.Declaration.ValidateExecutionControls(req.ExecutionControls); err != nil { return fail(err.Error()) } - effort := "" - if req.ExecutionControls != nil { - effort = req.ExecutionControls.ReasoningEffort + if err := harnessconfiguration.Declaration.ValidateTurnSettings(req.Turn); err != nil { + return fail(err.Error()) } - start.NativeModelOptions = compileNativeModelOptions(modelConfiguration.HarnessConfig, effort) + start.Model = req.Turn.Model + start.NativeModelOptions = compileNativeModelOptions(modelConfiguration.HarnessConfig, req.Turn.ReasoningEffort) if err := req.ValidateToolSearch(true); err != nil { return startRequest{}, nil, err } @@ -113,12 +113,9 @@ func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startR } value, ok := raw.(string) if !ok { - return fail("model and system_prompt options must be strings") + return fail("system_prompt option must be a string") } - switch name { - case "model": - start.Model = value - case "system_prompt": + if name == "system_prompt" { start.SystemPrompt = value } } diff --git a/apps/daemon/internal/agent/claudesdk/options_test.go b/apps/daemon/internal/agent/claudesdk/options_test.go index c3dc1305d..9e79b3318 100644 --- a/apps/daemon/internal/agent/claudesdk/options_test.go +++ b/apps/daemon/internal/agent/claudesdk/options_test.go @@ -14,18 +14,17 @@ func TestNullableSystemPrompt(t *testing.T) { want string reject bool }{ - {"omitted", map[string]any{"model": "test-model"}, "", false}, - {"null", map[string]any{"model": "test-model", "system_prompt": nil}, "", false}, - {"empty", map[string]any{"model": "test-model", "system_prompt": ""}, "", false}, - {"text", map[string]any{"model": "test-model", "system_prompt": "instructions"}, "instructions", false}, - {"null-model", map[string]any{"model": nil}, "", true}, - {"null-unknown", map[string]any{"model": "test-model", "unsupported": nil}, "", true}, + {"omitted", map[string]any{}, "", false}, + {"null", map[string]any{"system_prompt": nil}, "", false}, + {"empty", map[string]any{"system_prompt": ""}, "", false}, + {"text", map[string]any{"system_prompt": "instructions"}, "instructions", false}, + {"null-unknown", map[string]any{"unsupported": nil}, "", true}, } { t.Run(tc.name, func(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")} - start, _, err := prepareConfiguration(config, proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), AgentOptions: tc.options}) + start, _, err := prepareConfiguration(config, proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), Turn: proto.TurnSettings{Model: "test-model"}, AgentOptions: tc.options}) if (err != nil) != tc.reject || (!tc.reject && start.SystemPrompt != tc.want) { t.Fatalf("system prompt %q, error %v", start.SystemPrompt, err) } diff --git a/apps/daemon/internal/agent/claudesdk/preparation_test.go b/apps/daemon/internal/agent/claudesdk/preparation_test.go index 27a4436ad..8fa71d7e4 100644 --- a/apps/daemon/internal/agent/claudesdk/preparation_test.go +++ b/apps/daemon/internal/agent/claudesdk/preparation_test.go @@ -65,11 +65,11 @@ func TestPreparationWaitsForReceiptAndRetainsConfiguration(t *testing.T) { } config.Env[0] = "ANTHROPIC_AUTH_TOKEN=changed" config.Workspace.Directory = "/changed" - req.AgentOptions["model"] = "changed" + req.Turn.Model = "changed" req.AgentSessionID = "changed" out := make(chan proto.Envelope, 16) operation, stopOperation := context.WithCancel(ctx) - turn, err := e.StartTurn(operation, "actual-run", proto.TextInput("hello"), out) + turn, err := e.StartTurn(operation, "actual-run", proto.TurnSettings{}, proto.TextInput("hello"), out) stopOperation() if err != nil { t.Fatal(err) @@ -161,7 +161,7 @@ func TestPreparationFailureAndUnusedRelease(t *testing.T) { prompt = "hello" cancel() } - _, startErr := e.StartTurn(operation, "run", proto.TextInput(prompt), make(chan proto.Envelope, 8)) + _, startErr := e.StartTurn(operation, "run", proto.TurnSettings{}, proto.TextInput(prompt), make(chan proto.Envelope, 8)) cancel() if startErr == nil { t.Fatal("invalid or cancelled StartTurn succeeded") @@ -176,7 +176,7 @@ func TestPreparationFailureAndUnusedRelease(t *testing.T) { case <-time.After(5 * time.Second): t.Fatal("unused process was not settled") } - if _, err := e.StartTurn(t.Context(), "late", proto.TextInput("hello"), make(chan proto.Envelope, 8)); err == nil { + if _, err := e.StartTurn(t.Context(), "late", proto.TurnSettings{}, proto.TextInput("hello"), make(chan proto.Envelope, 8)); err == nil { t.Fatal("released Executor was reusable") } }) diff --git a/apps/daemon/internal/agent/claudesdk/readiness_test.go b/apps/daemon/internal/agent/claudesdk/readiness_test.go index 466d6e38e..a6e093e0e 100644 --- a/apps/daemon/internal/agent/claudesdk/readiness_test.go +++ b/apps/daemon/internal/agent/claudesdk/readiness_test.go @@ -24,7 +24,7 @@ func TestRequiredMCPNeedsQualifiedRuntime(t *testing.T) { "GO_CLAUDE_READINESS_HELPER=1", "READINESS_MODE=ready-http-mcp", "GORACE=atexit_sleep_ms=0", }} req := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), DisableExecutionEnvironment: true, - AgentOptions: map[string]any{"model": "fixture"}, MCPHTTPServers: &[]proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "fixture", ServerURL: "https://example.invalid/mcp", Required: true}}} + Turn: proto.TurnSettings{Model: "fixture"}, AgentOptions: map[string]any{}, MCPHTTPServers: &[]proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "fixture", ServerURL: "https://example.invalid/mcp", Required: true}}} if _, err := startSingleTurn(t.Context(), config, req, make(chan proto.Envelope, 1)); err == nil || err.Error() != "claudesdk: packaged runtime does not support required HTTP MCP" { t.Fatalf("unqualified runtime executed required MCP: %v", err) } @@ -45,7 +45,7 @@ func TestHTTPMCPRejectsOldPackagedRuntime(t *testing.T) { t.Fatal("runtime feature not recognized") } req := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), DisableExecutionEnvironment: true, - AgentOptions: map[string]any{"model": "fixture"}, MCPHTTPServers: &[]proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "fixture", ServerURL: "https://example.invalid/mcp"}}} + Turn: proto.TurnSettings{Model: "fixture"}, AgentOptions: map[string]any{}, MCPHTTPServers: &[]proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "fixture", ServerURL: "https://example.invalid/mcp"}}} if _, err := startSingleTurn(t.Context(), config, req, make(chan proto.Envelope, 1)); err == nil || !strings.Contains(err.Error(), "packaged runtime does not support HTTP MCP") { t.Fatalf("old runtime was not rejected before execution: %v", err) } @@ -82,7 +82,7 @@ func TestMCPBearerRejectsAnonymousOnlyRuntimeWithoutProbeSecrets(t *testing.T) { }} token := "private-fixture-token" req := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), DisableExecutionEnvironment: true, - AgentOptions: map[string]any{"model": "fixture"}, MCPHTTPServers: &[]proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "fixture", ServerURL: "https://example.invalid/mcp", BearerToken: &token}}} + Turn: proto.TurnSettings{Model: "fixture"}, AgentOptions: map[string]any{}, MCPHTTPServers: &[]proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "fixture", ServerURL: "https://example.invalid/mcp", BearerToken: &token}}} if _, err := startSingleTurn(t.Context(), config, req, make(chan proto.Envelope, 1)); err == nil || err.Error() != "claudesdk: packaged runtime does not support authenticated HTTP MCP" { t.Fatalf("old runtime executed authenticated request or readiness received its secret: %v", err) } diff --git a/apps/daemon/internal/agent/claudesdk/restrictions_test.go b/apps/daemon/internal/agent/claudesdk/restrictions_test.go index 494e32727..d84fa2379 100644 --- a/apps/daemon/internal/agent/claudesdk/restrictions_test.go +++ b/apps/daemon/internal/agent/claudesdk/restrictions_test.go @@ -25,7 +25,7 @@ func TestTextFactoryAcceptsRestrictiveCapabilities(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=success", "GORACE=atexit_sleep_ms=0"}} - request := proto.PromptRequestPayload{RunID: "restricted-run", Input: proto.TextInput("hello"), AgentSessionID: "native-session", DisableExecutionEnvironment: test.environment, DisableSubagents: test.subagents, ExecutionControls: test.controls, AgentOptions: map[string]any{"model": "fake-model", "system_prompt": "instructions"}} + request := proto.PromptRequestPayload{RunID: "restricted-run", Input: proto.TextInput("hello"), AgentSessionID: "native-session", DisableExecutionEnvironment: test.environment, DisableSubagents: test.subagents, ExecutionControls: test.controls, Turn: proto.TurnSettings{Model: "fake-model"}, AgentOptions: map[string]any{"system_prompt": "instructions"}} ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) defer cancel() out := make(chan proto.Envelope, 16) diff --git a/apps/daemon/internal/agent/claudesdk/session_test.go b/apps/daemon/internal/agent/claudesdk/session_test.go index e885c51a8..abf339ddd 100644 --- a/apps/daemon/internal/agent/claudesdk/session_test.go +++ b/apps/daemon/internal/agent/claudesdk/session_test.go @@ -22,7 +22,7 @@ func TestTextFactoryCompletionAndFailures(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=" + mode, "GORACE=atexit_sleep_ms=0"}} - request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), AgentSessionID: "native-session", AgentOptions: map[string]any{"model": "fake-model", "system_prompt": "instructions"}} + request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), AgentSessionID: "native-session", Turn: proto.TurnSettings{Model: "fake-model"}, AgentOptions: map[string]any{"system_prompt": "instructions"}} ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() out := make(chan proto.Envelope, 16) @@ -76,7 +76,7 @@ func TestTextFactoryRejectsUnsupportedInput(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) config := Config{Node: "must-not-run", Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")} - request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), AgentOptions: map[string]any{"model": "fake"}} + request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), Turn: proto.TurnSettings{Model: "fake"}, AgentOptions: map[string]any{}} switch kind { case "execution-controls": request.ExecutionControls = &proto.ExecutionControls{TextVerbosity: "low"} diff --git a/apps/daemon/internal/agent/claudesdk/steering_test.go b/apps/daemon/internal/agent/claudesdk/steering_test.go index 2313476b6..7459f56f5 100644 --- a/apps/daemon/internal/agent/claudesdk/steering_test.go +++ b/apps/daemon/internal/agent/claudesdk/steering_test.go @@ -27,7 +27,7 @@ func TestSteeringReceiptsAndLifecycle(t *testing.T) { if mode == "phased" { config.Env[1] = "SDK_HELPER_MODE=steering-timeout" } - request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), AgentSessionID: "native", AgentOptions: map[string]any{"model": "fake-model", "system_prompt": "instructions"}} + request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), AgentSessionID: "native", Turn: proto.TurnSettings{Model: "fake-model"}, AgentOptions: map[string]any{"system_prompt": "instructions"}} ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() out := make(chan proto.Envelope, 16) diff --git a/apps/daemon/internal/agent/claudesdk/usage_test.go b/apps/daemon/internal/agent/claudesdk/usage_test.go index d3c49bf3c..e2ea723a3 100644 --- a/apps/daemon/internal/agent/claudesdk/usage_test.go +++ b/apps/daemon/internal/agent/claudesdk/usage_test.go @@ -23,7 +23,7 @@ func TestUsageTransportPreservesSnapshotOnFailureAndDone(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=usage-" + mode, "GORACE=atexit_sleep_ms=0"}} - request := proto.PromptRequestPayload{RunID: "usage-run", Input: proto.TextInput("hello"), AgentSessionID: "native-session", AgentOptions: map[string]any{"model": "fake-model", "system_prompt": "instructions"}} + request := proto.PromptRequestPayload{RunID: "usage-run", Input: proto.TextInput("hello"), AgentSessionID: "native-session", Turn: proto.TurnSettings{Model: "fake-model"}, AgentOptions: map[string]any{"system_prompt": "instructions"}} ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() out := make(chan proto.Envelope, 16) diff --git a/apps/daemon/internal/agent/claudesdk/workspace_structured_test.go b/apps/daemon/internal/agent/claudesdk/workspace_structured_test.go index 4b77d59ee..6a4fb98ad 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace_structured_test.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_structured_test.go @@ -43,7 +43,7 @@ func TestWorkspaceStructuredPreparationQualificationAndFrozenSchema(t *testing.T t.Fatal("prepared native schema changed with caller memory") } out := make(chan proto.Envelope, 16) - if _, err := e.StartTurn(t.Context(), "run", proto.TextInput("hello"), out); err != nil { + if _, err := e.StartTurn(t.Context(), "run", proto.TurnSettings{}, proto.TextInput("hello"), out); err != nil { t.Fatal(err) } for event := range out { diff --git a/apps/daemon/internal/agent/claudesdk/workspace_test.go b/apps/daemon/internal/agent/claudesdk/workspace_test.go index de2eac9a1..69f7d3d7e 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace_test.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_test.go @@ -36,7 +36,7 @@ func workspaceFixture(t *testing.T) Config { func workspaceRequest() proto.PromptRequestPayload { return proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), DisableSubagents: true, - AgentOptions: map[string]any{"model": "fixture"}} + Turn: proto.TurnSettings{Model: "fixture"}, AgentOptions: map[string]any{}} } func TestWorkspaceTrustedBindingAndEnvironment(t *testing.T) { diff --git a/apps/daemon/internal/agent/codex/execution_controls_test.go b/apps/daemon/internal/agent/codex/execution_controls_test.go index 12d072865..bd861172f 100644 --- a/apps/daemon/internal/agent/codex/execution_controls_test.go +++ b/apps/daemon/internal/agent/codex/execution_controls_test.go @@ -2,6 +2,7 @@ package codex import ( "reflect" + "slices" "testing" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" @@ -28,11 +29,10 @@ func TestExecutionControlsMapToNativeSettings(t *testing.T) { {"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"`}}}, + {"reasoning summary", &proto.ExecutionControls{ReasoningSummary: "concise"}, [][2]string{{"model_reasoning_summary", `"concise"`}}}, } { t.Run(tc.name, func(t *testing.T) { - plan, err := BuildSessionPlan(testStateRoot(t), "state", map[string]any{"model": "test-model"}, tc.controls) + plan, err := BuildSessionPlan(testStateRoot(t), "state", map[string]any{}, proto.TurnSettings{}, tc.controls) if err != nil { t.Fatal(err) } @@ -55,11 +55,29 @@ func TestExecutionControlsRejectUndeclaredValues(t *testing.T) { t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) for _, controls := range []proto.ExecutionControls{ {WebSearch: &proto.WebSearchControl{Mode: "indexed", ContextSize: "medium"}}, - {TextVerbosity: "invalid"}, {ReasoningEffort: "ultra"}, {ReasoningSummary: "none"}, {ServiceTier: "scale"}, + {TextVerbosity: "invalid"}, {ReasoningSummary: "none"}, } { - if plan, err := BuildSessionPlan(testStateRoot(t), "state", nil, &controls); err == nil { + if plan, err := BuildSessionPlan(testStateRoot(t), "state", nil, proto.TurnSettings{}, &controls); err == nil { plan.Cleanup() t.Fatal("undeclared controls accepted", controls) } } } + +func TestTurnSettingsReachNativeConfigAndRejectUndeclaredValues(t *testing.T) { + t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) + plan, err := BuildSessionPlan(testStateRoot(t), "state", nil, proto.TurnSettings{Model: "m", ReasoningEffort: "max", ServiceTier: "priority"}, nil) + if err != nil { + t.Fatal(err) + } + plan.Cleanup() + if plan.Model != "m" || !slices.Contains(plan.ExtraConfig, [2]string{"model_reasoning_effort", `"max"`}) || !slices.Contains(plan.ExtraConfig, [2]string{"service_tier", `"priority"`}) { + t.Fatalf("turn settings not applied: %v", plan.ExtraConfig) + } + for _, turn := range []proto.TurnSettings{{ReasoningEffort: "ultra"}, {ServiceTier: "scale"}, {Review: "auto_review"}} { + if plan, err := BuildSessionPlan(testStateRoot(t), "state", nil, turn, nil); err == nil { + plan.Cleanup() + t.Fatal("undeclared turn setting accepted", turn) + } + } +} diff --git a/apps/daemon/internal/agent/codex/executor.go b/apps/daemon/internal/agent/codex/executor.go index 3bd077a98..3ee78ec58 100644 --- a/apps/daemon/internal/agent/codex/executor.go +++ b/apps/daemon/internal/agent/codex/executor.go @@ -40,7 +40,7 @@ func newExecutor(ctx context.Context, req proto.PromptRequestPayload, cfg sessio return &Executor{prepared: p}, nil } -func (e *Executor) StartTurn(ctx context.Context, runID string, input proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { +func (e *Executor) StartTurn(ctx context.Context, runID string, _ proto.TurnSettings, input proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { if out == nil || strings.TrimSpace(runID) == "" || input.Validate() != nil { return nil, errors.New("codex: start requires a run identity, input and output") } diff --git a/apps/daemon/internal/agent/codex/executor_test.go b/apps/daemon/internal/agent/codex/executor_test.go index 1f1696d6e..dbef24595 100644 --- a/apps/daemon/internal/agent/codex/executor_test.go +++ b/apps/daemon/internal/agent/codex/executor_test.go @@ -84,7 +84,7 @@ func TestExecutorNormalTurnsKeepProcessAndThread(t *testing.T) { var previous agent.Turn for _, id := range []string{"one", "two"} { out := make(chan proto.Envelope, 20) - turn, err := e.StartTurn(t.Context(), id, proto.TextInput("answer"), out) + turn, err := e.StartTurn(t.Context(), id, proto.TurnSettings{}, proto.TextInput("answer"), out) if err != nil { t.Fatal(err) } @@ -126,11 +126,11 @@ func TestExecutorFreezesPreparedConfiguration(t *testing.T) { assertPreparationOnly(t, root) cwd := e.prepared.plan.Cwd // Caller-owned data cannot revise the prepared native configuration. - req.AgentOptions["model"] = "different-model" + req.Turn.Model = "different-model" req.AgentSessionID = "different-thread" copy(req.FunctionTools[0].Parameters, strings.ReplaceAll(string(req.FunctionTools[0].Parameters), "integer", "boolean")) out := make(chan proto.Envelope, 20) - turn, err := e.StartTurn(t.Context(), "actual-run", proto.TextInput("actual prompt"), out) + turn, err := e.StartTurn(t.Context(), "actual-run", proto.TurnSettings{}, proto.TextInput("actual prompt"), out) if err != nil { t.Fatal(err) } @@ -189,7 +189,7 @@ func TestExecutorUnavailableOwnerStartsNoTurn(t *testing.T) { t.Fatal(err) } out := make(chan proto.Envelope, 1) - if turn, err := e.StartTurn(t.Context(), "late-run", proto.TextInput("must not start"), out); turn != nil || err == nil { + if turn, err := e.StartTurn(t.Context(), "late-run", proto.TurnSettings{}, proto.TextInput("must not start"), out); turn != nil || err == nil { t.Fatal("unavailable executor started a Turn", err) } if len(out) != 0 { @@ -203,11 +203,11 @@ func TestExecutorUnavailableOwnerStartsNoTurn(t *testing.T) { func TestExecutorCancellationSettlesThenReuses(t *testing.T) { e, root := executorFixture(t, "complete") out := make(chan proto.Envelope, 20) - first, err := e.StartTurn(t.Context(), "cancel", proto.TextInput("hold"), out) + first, err := e.StartTurn(t.Context(), "cancel", proto.TurnSettings{}, proto.TextInput("hold"), out) if err != nil { t.Fatal(err) } - if _, err := e.StartTurn(t.Context(), "overlap", proto.TextInput("answer"), make(chan proto.Envelope, 20)); err == nil { + if _, err := e.StartTurn(t.Context(), "overlap", proto.TurnSettings{}, proto.TextInput("answer"), make(chan proto.Envelope, 20)); err == nil { t.Fatal("overlapping turn admitted") } ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) @@ -219,7 +219,7 @@ func TestExecutorCancellationSettlesThenReuses(t *testing.T) { t.Fatal("cancelled healthy executor unavailable") } secondOut := make(chan proto.Envelope, 20) - second, err := e.StartTurn(t.Context(), "next", proto.TextInput("hold"), secondOut) + second, err := e.StartTurn(t.Context(), "next", proto.TurnSettings{}, proto.TextInput("hold"), secondOut) if err != nil { t.Fatal(err) } @@ -250,7 +250,7 @@ func TestExecutorStartErrorsRetainExactOwnership(t *testing.T) { out := make(chan proto.Envelope, 20) ctx, cancel := context.WithCancel(t.Context()) cancel() - turn, err := e.StartTurn(ctx, "before", proto.TextInput("answer"), out) + turn, err := e.StartTurn(ctx, "before", proto.TurnSettings{}, proto.TextInput("answer"), out) if turn != nil || !errors.Is(err, context.Canceled) { t.Fatal(turn, err) } @@ -259,7 +259,7 @@ func TestExecutorStartErrorsRetainExactOwnership(t *testing.T) { t.Fatal("pre-admission output was retained or closed") default: } - turn, err = e.StartTurn(t.Context(), "after", proto.TextInput("answer"), out) + turn, err = e.StartTurn(t.Context(), "after", proto.TurnSettings{}, proto.TextInput("answer"), out) if turn == nil || err == nil { t.Fatal("uncertain submission lost owner", err) } @@ -313,7 +313,7 @@ func TestExecutorCloseRetainsPlanUntilReaped(t *testing.T) { func TestExecutorCloseSeparatesTurnFailureFromResourceCleanup(t *testing.T) { e, root := executorFixture(t, "interrupt-error") out := make(chan proto.Envelope, 20) - turn, err := e.StartTurn(t.Context(), "cancel-failure", proto.TextInput("hold"), out) + turn, err := e.StartTurn(t.Context(), "cancel-failure", proto.TurnSettings{}, proto.TextInput("hold"), out) if err != nil { t.Fatal(err) } @@ -339,7 +339,7 @@ func TestExecutorCloseSeparatesTurnFailureFromResourceCleanup(t *testing.T) { func TestExecutorCloseTerminatesAfterMissingCancellationTerminal(t *testing.T) { e, root := executorFixture(t, "interrupt-no-terminal") out := make(chan proto.Envelope, 20) - turn, err := e.StartTurn(t.Context(), "missing-terminal", proto.TextInput("hold"), out) + turn, err := e.StartTurn(t.Context(), "missing-terminal", proto.TurnSettings{}, proto.TextInput("hold"), out) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/codex/harness_config_test.go b/apps/daemon/internal/agent/codex/harness_config_test.go index 7b9150ccc..93526155f 100644 --- a/apps/daemon/internal/agent/codex/harness_config_test.go +++ b/apps/daemon/internal/agent/codex/harness_config_test.go @@ -15,9 +15,8 @@ import ( func TestReasoningEffortAppliedWithoutChangingProvider(t *testing.T) { t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) plan, err := BuildSessionPlan(testStateRoot(t), "native-config", map[string]any{ - "model": "fixture", "model_provider": map[string]any{"base_url": "https://provider.invalid/v1", "protocol": "responses", "api_key": "test-key"}, - }, &proto.ExecutionControls{ReasoningEffort: "max"}) + }, proto.TurnSettings{Model: "fixture", ReasoningEffort: "max"}, nil) if err != nil { t.Fatal(err) } @@ -28,14 +27,14 @@ func TestReasoningEffortAppliedWithoutChangingProvider(t *testing.T) { } func TestReasoningEffortHasNoHarnessConfigHome(t *testing.T) { - _, err := BuildSessionPlan(testStateRoot(t), "", map[string]any{"harness_config": map[string]any{"model_reasoning_effort": "high"}}, nil) + _, err := BuildSessionPlan(testStateRoot(t), "", map[string]any{"harness_config": map[string]any{"model_reasoning_effort": "high"}}, proto.TurnSettings{}, 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) + _, err := BuildSessionPlan(testStateRoot(t), "", map[string]any{"harness_config": map[string]any{"model_provider": "bypass"}}, proto.TurnSettings{}, nil) if err != harnessconfig.ErrHarnessConfig { t.Fatalf("configuration must fail before filesystem preparation: %v", err) } @@ -47,8 +46,8 @@ func TestNativeTurnsCarryNoCollaborationMode(t *testing.T) { req, cfg, root := preparationFixture(t) t.Setenv("OAC_TEST_EXECUTOR_MODE", "complete") req.AgentSessionID = resumeID - controls := &proto.ExecutionControls{TextVerbosity: "medium", ReasoningEffort: "high"} - req.ExecutionControls = controls + req.ExecutionControls = &proto.ExecutionControls{TextVerbosity: "medium"} + req.Turn.ReasoningEffort = "high" ownerCtx, cancelOwner := context.WithCancel(context.Background()) t.Cleanup(cancelOwner) e, err := newExecutor(ownerCtx, req, cfg) @@ -62,10 +61,10 @@ func TestNativeTurnsCarryNoCollaborationMode(t *testing.T) { t.Error(err) } }) - controls.ReasoningEffort = "low" + req.Turn.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) + turn, err := e.StartTurn(t.Context(), id, proto.TurnSettings{}, proto.TextInput("answer"), out) if err != nil { t.Fatal(err) } @@ -117,7 +116,7 @@ func TestOutputSchemaReachesEveryNativeTurn(t *testing.T) { }) for _, id := range []string{"one", "two"} { out := make(chan proto.Envelope, 20) - turn, err := e.StartTurn(t.Context(), id, proto.TextInput("answer"), out) + turn, err := e.StartTurn(t.Context(), id, proto.TurnSettings{}, proto.TextInput("answer"), out) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/codex/mcp_http_preflight_test.go b/apps/daemon/internal/agent/codex/mcp_http_preflight_test.go index 7af9982d5..e88bf7fb9 100644 --- a/apps/daemon/internal/agent/codex/mcp_http_preflight_test.go +++ b/apps/daemon/internal/agent/codex/mcp_http_preflight_test.go @@ -130,7 +130,7 @@ func TestPublicMCPHTTPPreparationChecksBeforeNewAndResumedThread(t *testing.T) { for _, mode := range []string{"new", "resume", "extra server", "reject url", "reject bearer reference"} { t.Run(mode, func(t *testing.T) { req, cfg, root := preparationFixture(t) - req.AgentOptions = map[string]any{"model": "fixture-model"} + req.Turn.Model = "fixture-model" servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "docs", ServerURL: "https://docs.example/mcp"}} if mode == "reject bearer reference" { token := "synthetic-private-bearer" @@ -179,7 +179,7 @@ func TestPublicMCPHTTPPreparationChecksBeforeNewAndResumedThread(t *testing.T) { t.Fatal(err) } out := make(chan proto.Envelope, 20) - turn, err := e.StartTurn(t.Context(), "actual-run", proto.TextInput("actual prompt"), out) + turn, err := e.StartTurn(t.Context(), "actual-run", proto.TurnSettings{}, proto.TextInput("actual prompt"), out) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/codex/mcp_required_test.go b/apps/daemon/internal/agent/codex/mcp_required_test.go index 4dc500470..9f5b7f90d 100644 --- a/apps/daemon/internal/agent/codex/mcp_required_test.go +++ b/apps/daemon/internal/agent/codex/mcp_required_test.go @@ -17,7 +17,7 @@ func TestRequiredMCPWaitsForNativeThreadAndNeverRestartsFailedResume(t *testing. for _, mode := range []string{"new ready", "new failed", "resume ready", "resume failed"} { t.Run(mode, func(t *testing.T) { req, cfg, root := preparationFixture(t) - req.AgentOptions = map[string]any{"model": "fixture-model"} + req.Turn.Model = "fixture-model" servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "docs", ServerURL: "https://docs.example/mcp", Required: true}} req.MCPHTTPServers = &servers method := "thread/start" @@ -45,7 +45,7 @@ func TestRequiredMCPWaitsForNativeThreadAndNeverRestartsFailedResume(t *testing. } result := make(chan started, 1) go func() { - turn, err := e.StartTurn(t.Context(), "required-run", proto.TextInput("actual prompt"), out) + turn, err := e.StartTurn(t.Context(), "required-run", proto.TurnSettings{}, proto.TextInput("actual prompt"), out) result <- started{turn, err} }() waitPreparationMethod(t, root, method) diff --git a/apps/daemon/internal/agent/codex/model_route_test.go b/apps/daemon/internal/agent/codex/model_route_test.go index 74ad45684..92aebb079 100644 --- a/apps/daemon/internal/agent/codex/model_route_test.go +++ b/apps/daemon/internal/agent/codex/model_route_test.go @@ -1,6 +1,7 @@ package codex import ( + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "reflect" "slices" "strings" @@ -12,7 +13,7 @@ func TestPlanRejectsNonNativeFrozenProvider(t *testing.T) { for _, protocol := range []string{"anthropic", "chat_completions"} { t.Run(protocol, func(t *testing.T) { provider := map[string]any{"protocol": protocol, "base_url": "https://model.invalid/v1", "api_key": "private-sentinel"} - plan, err := BuildSessionPlan(testStateRoot(t), "frozen-state", map[string]any{"model": "frozen-model", "model_provider": provider}, nil) + plan, err := BuildSessionPlan(testStateRoot(t), "frozen-state", map[string]any{"model_provider": provider}, proto.TurnSettings{Model: "frozen-model"}, nil) if plan.Cleanup != nil { plan.Cleanup() } @@ -31,7 +32,7 @@ func TestPlanRejectsIncompleteExplicitProvider(t *testing.T) { {"model": "chosen", "model_provider": nil}, {"model_provider": map[string]any{"protocol": "responses", "base_url": "https://model.example/v1", "api_key": "fixture"}}, } { - if _, err := BuildSessionPlan(testStateRoot(t), "state", options, nil); err == nil { + if _, err := BuildSessionPlan(testStateRoot(t), "state", options, proto.TurnSettings{}, nil); err == nil { t.Fatal("explicit provider fell back to native defaults") } } @@ -40,13 +41,13 @@ func TestPlanRejectsIncompleteExplicitProvider(t *testing.T) { func TestPlanMapsTheContextWindowNatively(t *testing.T) { t.Setenv("HOME", t.TempDir()) provider := map[string]any{"protocol": "responses", "base_url": "https://model.example/v1", "api_key": "fixture", "context_window": 64000} - plan, err := BuildSessionPlan(testStateRoot(t), "limits", map[string]any{"model": "m", "model_provider": provider}, nil) + plan, err := BuildSessionPlan(testStateRoot(t), "limits", map[string]any{"model_provider": provider}, proto.TurnSettings{Model: "m"}, nil) if err != nil || !slices.Contains(plan.ExtraConfig, [2]string{"model_context_window", "64000"}) { t.Fatalf("context window not native: %v %v", plan.ExtraConfig, err) } plan.Cleanup() provider["max_output_tokens"] = 4096 - if _, err := BuildSessionPlan(testStateRoot(t), "limits", map[string]any{"model": "m", "model_provider": provider}, nil); err == nil { + if _, err := BuildSessionPlan(testStateRoot(t), "limits", map[string]any{"model_provider": provider}, proto.TurnSettings{Model: "m"}, nil); err == nil { t.Fatal("max_output_tokens without a native home accepted") } } diff --git a/apps/daemon/internal/agent/codex/options.go b/apps/daemon/internal/agent/codex/options.go index 00331a6db..e7d75f88f 100644 --- a/apps/daemon/internal/agent/codex/options.go +++ b/apps/daemon/internal/agent/codex/options.go @@ -66,16 +66,15 @@ type SessionPlan struct { } // BuildSessionPlan derives a SessionPlan from the request's agent_options and -// execution controls. It reads these agent_options keys: +// turn settings and execution controls. It reads these agent_options keys: // -// model string codex model slug, e.g. "gpt-5.5" // system_prompt string forwarded as developerInstructions // model_provider object frozen upstream protocol, endpoint, credential and token limits // harness_config object native parameters accepted by harnessconfig/codex // // 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) { +func BuildSessionPlan(stateRoot, agentStateKey string, opts map[string]any, turn proto.TurnSettings, controls *proto.ExecutionControls) (SessionPlan, error) { plan := SessionPlan{ // Harnesses run unattended: Codex never offers its ask-the-user tool, // an unreachable model host fails the Turn after the provider's @@ -90,7 +89,7 @@ func BuildSessionPlan(stateRoot, agentStateKey string, opts map[string]any, cont return plan, err } - if value, present := opts["model_provider"]; present && (value == nil || stringOpt(opts, "model") == "") { + if value, present := opts["model_provider"]; present && (value == nil || turn.Model == "") { return plan, errors.New("codex: model and complete model_provider are required") } @@ -98,7 +97,11 @@ func BuildSessionPlan(stateRoot, agentStateKey string, opts map[string]any, cont return plan, err } - plan.Model = stringOpt(opts, "model") + if err := harnessconfiguration.Declaration.ValidateTurnSettings(turn); err != nil { + return plan, err + } + + plan.Model = turn.Model plan.SystemPrompt = stringOpt(opts, "system_prompt") codexHome, err := allocSessionDir(stateRoot, agentStateKey) @@ -130,7 +133,7 @@ func BuildSessionPlan(stateRoot, agentStateKey string, opts map[string]any, cont } plan.Env = env - applyExecutionControls(&plan, controls) + applyExecutionControls(&plan, turn, controls) if plan.ModelProvider != "" { // Pin model_provider at the CLI layer so codex skips its builtin // "openai" provider — without this the [model_providers.oac] @@ -160,12 +163,18 @@ func excludeFromShellEnvironment(plan *SessionPlan, pattern string) { // 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) { +func applyExecutionControls(plan *SessionPlan, turn proto.TurnSettings, controls *proto.ExecutionControls) { set := func(key, value string) { plan.ExtraConfig = append(plan.ExtraConfig, [2]string{key, value}) } if controls == nil || controls.ReasoningSummary == "" { // The official default is no reasoning summary, while Codex defaults to a model-dependent one. set("model_reasoning_summary", strconv("none")) } + if turn.ReasoningEffort != "" { + set("model_reasoning_effort", strconv(turn.ReasoningEffort)) + } + if turn.ServiceTier != "" { + set("service_tier", strconv(turn.ServiceTier)) + } if controls == nil { return } @@ -193,16 +202,10 @@ func applyExecutionControls(plan *SessionPlan, controls *proto.ExecutionControls if format := controls.OutputFormat; format != nil { plan.OutputSchema = format.Schema } - if controls.ReasoningEffort != "" { - set("model_reasoning_effort", strconv(controls.ReasoningEffort)) - } if controls.ReasoningSummary != "" { plan.ReasoningSummary = true 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)) } diff --git a/apps/daemon/internal/agent/codex/options_test.go b/apps/daemon/internal/agent/codex/options_test.go index a63c5db19..cf2b9311d 100644 --- a/apps/daemon/internal/agent/codex/options_test.go +++ b/apps/daemon/internal/agent/codex/options_test.go @@ -8,7 +8,7 @@ import ( ) func TestBuildSessionPlan_DefaultsToBypass(t *testing.T) { - plan, err := BuildSessionPlan(testStateRoot(t), "conv-1/agent-1/codex", nil, nil) + plan, err := BuildSessionPlan(testStateRoot(t), "conv-1/agent-1/codex", nil, proto.TurnSettings{}, nil) if err != nil { t.Fatalf("BuildSessionPlan: %v", err) } @@ -22,7 +22,7 @@ func TestBuildSessionPlan_DefaultsToBypass(t *testing.T) { } func TestBuildSessionPlan_AllocsCodexHomeAndEnv(t *testing.T) { - plan, err := BuildSessionPlan(testStateRoot(t), "conv-1/agent-1/codex", nil, nil) + plan, err := BuildSessionPlan(testStateRoot(t), "conv-1/agent-1/codex", nil, proto.TurnSettings{}, nil) if err != nil { t.Fatalf("BuildSessionPlan: %v", err) } @@ -43,11 +43,11 @@ func TestBuildSessionPlan_AllocsCodexHomeAndEnv(t *testing.T) { func TestBuildSessionPlan_StableCodexHomeByStateKey(t *testing.T) { stateKey := "conv-stable/agent-stable/codex" - planA, err := BuildSessionPlan(testStateRoot(t), stateKey, nil, nil) + planA, err := BuildSessionPlan(testStateRoot(t), stateKey, nil, proto.TurnSettings{}, nil) if err != nil { t.Fatalf("BuildSessionPlan A: %v", err) } - planB, err := BuildSessionPlan(testStateRoot(t), stateKey, nil, nil) + planB, err := BuildSessionPlan(testStateRoot(t), stateKey, nil, proto.TurnSettings{}, nil) if err != nil { t.Fatalf("BuildSessionPlan B: %v", err) } diff --git a/apps/daemon/internal/agent/codex/preparation_helpers_test.go b/apps/daemon/internal/agent/codex/preparation_helpers_test.go index d61484e14..308e39177 100644 --- a/apps/daemon/internal/agent/codex/preparation_helpers_test.go +++ b/apps/daemon/internal/agent/codex/preparation_helpers_test.go @@ -41,7 +41,7 @@ func preparationFixture(t *testing.T) (proto.PromptRequestPayload, sessionConfig cfg.codexBinary = binary req := proto.PromptRequestPayload{ AgentKind: "codex", AgentStateKey: "prepared-session", - AgentOptions: map[string]any{"model": "fixture-model"}, + Turn: proto.TurnSettings{Model: "fixture-model"}, AgentOptions: map[string]any{}, 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/provider_config_test.go b/apps/daemon/internal/agent/codex/provider_config_test.go index a0c7e033a..c33b4bb70 100644 --- a/apps/daemon/internal/agent/codex/provider_config_test.go +++ b/apps/daemon/internal/agent/codex/provider_config_test.go @@ -1,6 +1,7 @@ package codex import ( + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "os" "path/filepath" "slices" @@ -141,12 +142,11 @@ func TestNormaliseProviderConfig_Nil(t *testing.T) { func TestBuildSessionPlan_PinsModelProviderWhenProviderSet(t *testing.T) { plan, err := BuildSessionPlan(testStateRoot(t), "conv-1/agent-1/codex", map[string]any{ - "model": "fixture-model", "model_provider": map[string]any{"protocol": "responses", "base_url": "https://x/v1", "api_key": "sk-x", }, - }, nil) + }, proto.TurnSettings{Model: "fixture-model"}, nil) if err != nil { t.Fatalf("BuildSessionPlan: %v", err) } @@ -178,7 +178,7 @@ func TestBuildSessionPlan_PinsModelProviderWhenProviderSet(t *testing.T) { } func TestBuildSessionPlan_NoProviderLeavesBuiltinDefault(t *testing.T) { - plan, err := BuildSessionPlan(testStateRoot(t), "conv-1/agent-1/codex", nil, nil) + plan, err := BuildSessionPlan(testStateRoot(t), "conv-1/agent-1/codex", nil, proto.TurnSettings{}, nil) if err != nil { t.Fatalf("BuildSessionPlan: %v", err) } @@ -205,7 +205,7 @@ func mustReadFile(t *testing.T, path string) string { // Codex's default unbounded connection retries keep a Turn whose model host // is unreachable in progress forever. func TestModelConnectionRetriesStayBounded(t *testing.T) { - plan, err := BuildSessionPlan(t.TempDir(), "bounded", map[string]any{}, nil) + plan, err := BuildSessionPlan(t.TempDir(), "bounded", map[string]any{}, proto.TurnSettings{}, nil) if err != nil { t.Fatal(err) } @@ -217,7 +217,7 @@ func TestModelConnectionRetriesStayBounded(t *testing.T) { // An active Codex goal starts native turns on its own whenever its thread is // idle, which would run work after the Turn ends. func TestGoalsStayDisabled(t *testing.T) { - plan, err := BuildSessionPlan(t.TempDir(), "goals", map[string]any{}, nil) + plan, err := BuildSessionPlan(t.TempDir(), "goals", map[string]any{}, proto.TurnSettings{}, nil) if err != nil { t.Fatal(err) } @@ -229,7 +229,7 @@ func TestGoalsStayDisabled(t *testing.T) { func TestProviderCredentialOnlyInNativeLoopEnvironment(t *testing.T) { root := t.TempDir() const token = "synthetic-private-provider-token" - plan, err := BuildSessionPlan(root, "stable-session", map[string]any{"model": "fixture-model", "model_provider": map[string]any{"protocol": "responses", "base_url": "https://model.example/v1", "api_key": token}}, nil) + plan, err := BuildSessionPlan(root, "stable-session", map[string]any{"model_provider": map[string]any{"protocol": "responses", "base_url": "https://model.example/v1", "api_key": token}}, proto.TurnSettings{Model: "fixture-model"}, nil) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/codex/recovery_test.go b/apps/daemon/internal/agent/codex/recovery_test.go index 5c6431a15..1743afa2c 100644 --- a/apps/daemon/internal/agent/codex/recovery_test.go +++ b/apps/daemon/internal/agent/codex/recovery_test.go @@ -168,7 +168,7 @@ func TestPreparedRecoveryCannotStartWithoutExistingHistory(t *testing.T) { } assertPreparationOnly(t, root) out := make(chan proto.Envelope, 16) - turn, err := e.StartTurn(t.Context(), "recovery-run", proto.TextInput("continue"), out) + turn, err := e.StartTurn(t.Context(), "recovery-run", proto.TurnSettings{}, proto.TextInput("continue"), out) if err == nil { t.Fatal("missing history started a Turn") } diff --git a/apps/daemon/internal/agent/codex/session_plan.go b/apps/daemon/internal/agent/codex/session_plan.go index e7d055c40..5a67e794f 100644 --- a/apps/daemon/internal/agent/codex/session_plan.go +++ b/apps/daemon/internal/agent/codex/session_plan.go @@ -20,7 +20,7 @@ func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg if err != nil { return SessionPlan{}, nil, err } - plan, err := BuildSessionPlan(cfg.stateRoot, req.AgentStateKey, req.AgentOptions, req.ExecutionControls) + plan, err := BuildSessionPlan(cfg.stateRoot, req.AgentStateKey, req.AgentOptions, req.Turn, req.ExecutionControls) if err != nil { return SessionPlan{}, nil, fmt.Errorf("codex: build session plan: %w", err) } diff --git a/apps/daemon/internal/agent/codex/terminal_cleanup_test.go b/apps/daemon/internal/agent/codex/terminal_cleanup_test.go index c0a208c6b..9a1374e25 100644 --- a/apps/daemon/internal/agent/codex/terminal_cleanup_test.go +++ b/apps/daemon/internal/agent/codex/terminal_cleanup_test.go @@ -24,7 +24,7 @@ func TestExecutorCancellationRequiresNativeTerminalCleanup(t *testing.T) { allow() } out := make(chan proto.Envelope, 20) - turn, err := e.StartTurn(t.Context(), "cancel", proto.TextInput("hold"), out) + turn, err := e.StartTurn(t.Context(), "cancel", proto.TurnSettings{}, proto.TextInput("hold"), out) if err != nil { t.Fatal(err) } @@ -60,7 +60,7 @@ func TestExecutorCancellationRequiresNativeTerminalCleanup(t *testing.T) { } if confirmed { nextOut := make(chan proto.Envelope, 20) - next, err := e.StartTurn(ctx, "next", proto.TextInput("answer"), nextOut) + next, err := e.StartTurn(ctx, "next", proto.TurnSettings{}, proto.TextInput("answer"), nextOut) if err != nil || !awaitExecutorTurn(t, next, nextOut).Reusable { t.Fatal("confirmed cleanup prevented reuse", err) } @@ -83,7 +83,7 @@ func TestExecutorCancellationRequiresNativeTerminalCleanup(t *testing.T) { func TestExecutorCloseCleansNativeTerminalsAfterNormalCompletion(t *testing.T) { e, root := executorFixture(t, "terminal-cleanup") out := make(chan proto.Envelope, 20) - turn, err := e.StartTurn(t.Context(), "normal", proto.TextInput("answer"), out) + turn, err := e.StartTurn(t.Context(), "normal", proto.TurnSettings{}, proto.TextInput("answer"), out) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/configuration_test.go b/apps/daemon/internal/agent/configuration_test.go index 2d36d96da..a98ae6127 100644 --- a/apps/daemon/internal/agent/configuration_test.go +++ b/apps/daemon/internal/agent/configuration_test.go @@ -24,18 +24,18 @@ func TestEveryRegistryEntryPreparesTheBoundModelConfiguration(t *testing.T) { }) configuration.Providers[0].Protocol = "anthropic" executor, _ := registry.ResolveExecutor("fixture") - for _, options := range []map[string]any{ - {"model": nil}, - {"model": "fixture", "model_provider": map[string]any{"protocol": "anthropic", "base_url": "https://provider.example", "api_key": "private-sentinel"}}, - {"model_provider": map[string]any{"protocol": "responses", "base_url": "https://provider.example", "api_key": "private-sentinel"}}, - {"harness_config": map[string]any{"unknown": "private-sentinel"}}, + for _, request := range []proto.PromptRequestPayload{ + {Turn: proto.TurnSettings{Model: " "}}, + {Turn: proto.TurnSettings{Model: "fixture"}, AgentOptions: map[string]any{"model_provider": map[string]any{"protocol": "anthropic", "base_url": "https://provider.example", "api_key": "private-sentinel"}}}, + {AgentOptions: map[string]any{"model_provider": map[string]any{"protocol": "responses", "base_url": "https://provider.example", "api_key": "private-sentinel"}}}, + {AgentOptions: map[string]any{"harness_config": map[string]any{"unknown": "private-sentinel"}}}, } { - _, err := executor(t.Context(), proto.PromptRequestPayload{AgentOptions: options}) + _, err := executor(t.Context(), request) if err == nil || errors.Is(err, expected) || calls != 0 || strings.Contains(err.Error(), "private-sentinel") { t.Fatal("invalid configuration reached native entry or leaked values") } } - if _, err := executor(t.Context(), proto.PromptRequestPayload{AgentOptions: map[string]any{"model": "fixture", "model_provider": map[string]any{"protocol": "responses", "base_url": "https://provider.example", "api_key": "private-sentinel"}}}); !errors.Is(err, expected) || calls != 1 { + if _, err := executor(t.Context(), proto.PromptRequestPayload{Turn: proto.TurnSettings{Model: "fixture"}, AgentOptions: map[string]any{"model_provider": map[string]any{"protocol": "responses", "base_url": "https://provider.example", "api_key": "private-sentinel"}}}); !errors.Is(err, expected) || calls != 1 { t.Fatal("bound declaration was lost or mutated", err) } } diff --git a/apps/daemon/internal/agent/contracttest/text.go b/apps/daemon/internal/agent/contracttest/text.go index 90e513753..9c2f95d4b 100644 --- a/apps/daemon/internal/agent/contracttest/text.go +++ b/apps/daemon/internal/agent/contracttest/text.go @@ -111,7 +111,7 @@ func start(t *testing.T, ctx context.Context, f TextFixture, id string, input pr } } }() - turn, err := f.Executor.StartTurn(ctx, id, input, out) + turn, err := f.Executor.StartTurn(ctx, id, proto.TurnSettings{}, input, out) if turn == nil { close(out) } diff --git a/apps/daemon/internal/agent/harness.go b/apps/daemon/internal/agent/harness.go index 25b5ff61e..bc36665d8 100644 --- a/apps/daemon/internal/agent/harness.go +++ b/apps/daemon/internal/agent/harness.go @@ -135,7 +135,7 @@ type Executor interface { // A nil Turn guarantees no input was submitted or output retained and leaves // out with the caller. A non-nil Turn owns out, including on error; input // may have been submitted and must never be replayed automatically. - StartTurn(context.Context, string, proto.MessageInput, chan<- proto.Envelope) (Turn, error) + StartTurn(ctx context.Context, runID string, turn proto.TurnSettings, input proto.MessageInput, out chan<- proto.Envelope) (Turn, error) // Success confirms all native work and owned output streams have stopped. // It does not establish an otherwise unknown Turn result. An error retains // resource ownership; another caller may await or retry Close. @@ -259,7 +259,7 @@ func (r *Registry) RegisterExecutor(kind string, factory ExecutorFactory) { panic("agent.Registry.RegisterExecutor: configuration required") } r.executors[kind] = func(ctx context.Context, req proto.PromptRequestPayload) (Executor, error) { - if _, err := configuration.Prepare(req.AgentOptions); err != nil { + if _, err := configuration.Prepare(req.Turn.Model, req.AgentOptions); err != nil { return nil, err } return factory(ctx, req) diff --git a/apps/daemon/internal/agent/mcode/environment_mcp_test.go b/apps/daemon/internal/agent/mcode/environment_mcp_test.go index eb26734af..e4c7f941f 100644 --- a/apps/daemon/internal/agent/mcode/environment_mcp_test.go +++ b/apps/daemon/internal/agent/mcode/environment_mcp_test.go @@ -117,7 +117,7 @@ func TestEnvironmentMCPCancelSettlesPendingObservationBeforeDone(t *testing.T) { ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) defer cancel() out := make(chan proto.Envelope, 16) - session, err := resource.StartTurn(ctx, "run", proto.TextInput("invoke and wait"), out) + session, err := resource.StartTurn(ctx, "run", proto.TurnSettings{}, proto.TextInput("invoke and wait"), out) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/mcode/execution.go b/apps/daemon/internal/agent/mcode/execution.go index 729d0a3d8..0cee3d352 100644 --- a/apps/daemon/internal/agent/mcode/execution.go +++ b/apps/daemon/internal/agent/mcode/execution.go @@ -19,6 +19,9 @@ func validateExecutionRequest(req proto.PromptRequestPayload) error { if err := declaration.Declaration.ValidateExecutionControls(req.ExecutionControls); err != nil { return err } + if err := declaration.Declaration.ValidateTurnSettings(req.Turn); err != nil { + return err + } if !req.DisableSubagents { if req.MaxConcurrentSubagents == nil || *req.MaxConcurrentSubagents < 1 { return fmt.Errorf("mcode: Subagent concurrency limit is required") diff --git a/apps/daemon/internal/agent/mcode/executor.go b/apps/daemon/internal/agent/mcode/executor.go index 9853f9d5a..879f3e497 100644 --- a/apps/daemon/internal/agent/mcode/executor.go +++ b/apps/daemon/internal/agent/mcode/executor.go @@ -105,7 +105,7 @@ func newExecutor(ctx context.Context, req proto.PromptRequestPayload, opts launc return e, nil } -func (e *executor) StartTurn(ctx context.Context, runID string, input proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { +func (e *executor) StartTurn(ctx context.Context, runID string, _ proto.TurnSettings, input proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { if ctx == nil { ctx = context.Background() } diff --git a/apps/daemon/internal/agent/mcode/executor_backpressure_test.go b/apps/daemon/internal/agent/mcode/executor_backpressure_test.go index 00bddf925..da395e1ee 100644 --- a/apps/daemon/internal/agent/mcode/executor_backpressure_test.go +++ b/apps/daemon/internal/agent/mcode/executor_backpressure_test.go @@ -14,7 +14,7 @@ func TestExecutorBlockedPromptWriteCanBeCancelledOrClosed(t *testing.T) { t.Run(operation, func(t *testing.T) { e, _ := executorFixture(t, "executor-backpressure", true) out := make(chan proto.Envelope, 32) - turn, err := e.StartTurn(t.Context(), "blocked", proto.TextInput(strings.Repeat("x", 4*1024*1024)), out) + turn, err := e.StartTurn(t.Context(), "blocked", proto.TurnSettings{}, proto.TextInput(strings.Repeat("x", 4*1024*1024)), out) if err != nil { t.Fatal(err) } @@ -66,7 +66,7 @@ func TestExecutorBlockedPromptWriteCanBeCancelledOrClosed(t *testing.T) { func TestExecutorUnknownSteeringOutcomeCannotBecomeAppliedCancellation(t *testing.T) { e, _ := executorFixture(t, "executor-steer-unknown", true) out := make(chan proto.Envelope, 32) - turn, err := e.StartTurn(t.Context(), "unknown-input", proto.TextInput("wait"), out) + turn, err := e.StartTurn(t.Context(), "unknown-input", proto.TurnSettings{}, proto.TextInput("wait"), out) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/mcode/executor_native_test.go b/apps/daemon/internal/agent/mcode/executor_native_test.go index 7f5b6afc5..6089e7a2d 100644 --- a/apps/daemon/internal/agent/mcode/executor_native_test.go +++ b/apps/daemon/internal/agent/mcode/executor_native_test.go @@ -60,7 +60,7 @@ func TestNativeMCodeExecutorReuse(t *testing.T) { run := func(id, prompt string) agent.Turn { t.Helper() out := make(chan proto.Envelope, 256) - turn, startErr := e.StartTurn(ctx, id, proto.TextInput(prompt), out) + turn, startErr := e.StartTurn(ctx, id, proto.TurnSettings{}, proto.TextInput(prompt), out) if startErr != nil || turn == nil { t.Fatal("native Turn admission failed") } @@ -96,7 +96,7 @@ func TestNativeMCodeExecutorReuse(t *testing.T) { first := run("first", "Remember the exact marker ORCHID-72-BLUE for this conversation. Reply with only that marker.") run("second", "What exact marker did I ask you to remember? Reply with only that marker.") out := make(chan proto.Envelope, 256) - interrupted, err := e.StartTurn(ctx, "cancelled", proto.TextInput("Produce 2000 numbered lines now. Each line must contain a different sentence about rain. Start immediately at line 1 and continue without stopping or summarizing."), out) + interrupted, err := e.StartTurn(ctx, "cancelled", proto.TurnSettings{}, proto.TextInput("Produce 2000 numbered lines now. Each line must contain a different sentence about rain. Start immediately at line 1 and continue without stopping or summarizing."), out) if err != nil || interrupted == nil { t.Fatal("cancellation Turn admission failed") } @@ -141,7 +141,7 @@ func TestNativeMCodeExecutorReuse(t *testing.T) { if !settlement.Reusable { t.Log("Native cancellation cannot establish reusable settlement; successor must use recovery") successor := make(chan proto.Envelope, 1) - next, nextErr := e.StartTurn(ctx, "unsafe-successor", proto.TextInput("must not execute"), successor) + next, nextErr := e.StartTurn(ctx, "unsafe-successor", proto.TurnSettings{}, proto.TextInput("must not execute"), successor) if next != nil || nextErr == nil { t.Fatal("unsettled native owner accepted a successor") } diff --git a/apps/daemon/internal/agent/mcode/executor_test.go b/apps/daemon/internal/agent/mcode/executor_test.go index c304d5bf4..8ccad2a51 100644 --- a/apps/daemon/internal/agent/mcode/executor_test.go +++ b/apps/daemon/internal/agent/mcode/executor_test.go @@ -70,7 +70,7 @@ func executorFixture(t *testing.T, scenario string, workspace bool) (*executor, func runExecutorFixtureTurn(t *testing.T, e *executor, id, text string) (*Session, []proto.Envelope) { t.Helper() out := make(chan proto.Envelope, 256) - turn, err := e.StartTurn(t.Context(), id, proto.TextInput(text), out) + turn, err := e.StartTurn(t.Context(), id, proto.TurnSettings{}, proto.TextInput(text), out) if err != nil { t.Fatal(err) } @@ -154,7 +154,7 @@ func TestExecutorCancellationRetiresOwnerAndLateCancelCannotRetarget(t *testing. t.Setenv("OAC_RUNTIME_MCODE_COMPANION", dir) } out := make(chan proto.Envelope, 32) - second, err := e.StartTurn(t.Context(), "second", proto.TextInput("wait"), out) + second, err := e.StartTurn(t.Context(), "second", proto.TurnSettings{}, proto.TextInput("wait"), out) if err != nil { t.Fatal(err) } @@ -193,7 +193,7 @@ func TestExecutorCancellationRetiresOwnerAndLateCancelCannotRetarget(t *testing. for range out { } thirdOut := make(chan proto.Envelope, 1) - third, err := e.StartTurn(t.Context(), "third", proto.TextInput("three"), thirdOut) + third, err := e.StartTurn(t.Context(), "third", proto.TurnSettings{}, proto.TextInput("three"), thirdOut) if third != nil || err == nil { t.Fatal("unproven native owner admitted a successor") } @@ -218,7 +218,7 @@ func TestExecutorStartFailureOwnership(t *testing.T) { {"attachment", "does not support image input", proto.MessageInput{{Content: []proto.InputContent{{Type: "input_image", ImageURL: &image}}}}}, } { out := make(chan proto.Envelope, 1) - turn, err := e.StartTurn(t.Context(), invalid.id, invalid.input, out) + turn, err := e.StartTurn(t.Context(), invalid.id, proto.TurnSettings{}, invalid.input, out) if err == nil || turn != nil || !strings.Contains(err.Error(), invalid.reason) { t.Fatal("invalid Start acquired output", err) } @@ -233,7 +233,7 @@ func TestExecutorStartFailureOwnership(t *testing.T) { t.Run("before input", func(t *testing.T) { e, record := executorFixture(t, "executor-preexit", true) out := make(chan proto.Envelope, 1) - turn, err := e.StartTurn(t.Context(), "run", proto.TextInput("input"), out) + turn, err := e.StartTurn(t.Context(), "run", proto.TurnSettings{}, proto.TextInput("input"), out) if err == nil || turn != nil { t.Fatal("failed readiness retained caller output") } @@ -246,7 +246,7 @@ func TestExecutorStartFailureOwnership(t *testing.T) { t.Run("after submission", func(t *testing.T) { e, record := executorFixture(t, "executor-exit", true) out := make(chan proto.Envelope, 32) - turn, err := e.StartTurn(t.Context(), "run", proto.TextInput("input"), out) + turn, err := e.StartTurn(t.Context(), "run", proto.TurnSettings{}, proto.TextInput("input"), out) if err != nil || turn == nil { t.Fatal("submitted Turn lost ownership", err) } @@ -263,7 +263,7 @@ func TestExecutorStartFailureOwnership(t *testing.T) { t.Fatal("uncertain input was replayed") } rejected := make(chan proto.Envelope) - again, err := e.StartTurn(t.Context(), "again", proto.TextInput("again"), rejected) + again, err := e.StartTurn(t.Context(), "again", proto.TurnSettings{}, proto.TextInput("again"), rejected) if again != nil || err == nil { t.Fatal("invalid owner accepted another Turn") } diff --git a/apps/daemon/internal/agent/mcode/functions_native_test.go b/apps/daemon/internal/agent/mcode/functions_native_test.go index aa8cbaafb..087236915 100644 --- a/apps/daemon/internal/agent/mcode/functions_native_test.go +++ b/apps/daemon/internal/agent/mcode/functions_native_test.go @@ -35,7 +35,7 @@ func TestNativeMCodePublicFunction(t *testing.T) { t.Fatal("native Executor preparation failed") } out := make(chan proto.Envelope, 256) - turn, err := e.StartTurn(ctx, "turn", proto.TextInput("Call the lookup_code function to get the access code, then reply with only the code."), out) + turn, err := e.StartTurn(ctx, "turn", proto.TurnSettings{}, proto.TextInput("Call the lookup_code function to get the access code, then reply with only the code."), out) if err != nil { t.Fatal("native Turn admission failed") } diff --git a/apps/daemon/internal/agent/mcode/model_provider_test.go b/apps/daemon/internal/agent/mcode/model_provider_test.go index ca1a846bb..e4dd5867c 100644 --- a/apps/daemon/internal/agent/mcode/model_provider_test.go +++ b/apps/daemon/internal/agent/mcode/model_provider_test.go @@ -15,7 +15,7 @@ func TestOptionsModelProviderProtocols(t *testing.T) { t.Run(tc.protocol, func(t *testing.T) { req := testRequest(t) req.AgentOptions["model_provider"].(map[string]any)["protocol"] = tc.protocol - req.AgentOptions["model"] = "chosen-model" + req.Turn.Model = "chosen-model" opts, err := prepareOptions(testStateRoot(t), req, nil) if err != nil { t.Fatal(err) @@ -120,7 +120,7 @@ func TestOptionsAllowLoopbackProviderFixture(t *testing.T) { func TestOptionsWriteNativeThinkingEffort(t *testing.T) { req := testRequest(t) - req.ExecutionControls.ReasoningEffort = "high" + req.Turn.ReasoningEffort = "high" opts, err := prepareOptions(testStateRoot(t), req, nil) if err != nil { t.Fatal(err) diff --git a/apps/daemon/internal/agent/mcode/native_effort_mcp_test.go b/apps/daemon/internal/agent/mcode/native_effort_mcp_test.go index 60cfa4c94..91c497aeb 100644 --- a/apps/daemon/internal/agent/mcode/native_effort_mcp_test.go +++ b/apps/daemon/internal/agent/mcode/native_effort_mcp_test.go @@ -94,7 +94,7 @@ func TestNativeMCodeEffortMCPAndPlainText(t *testing.T) { })) defer mcp.Close() - req.ExecutionControls.ReasoningEffort = "high" + req.Turn.ReasoningEffort = "high" req.MCPHTTPServers = &[]proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "fixture", ServerURL: mcp.URL + "/mcp"}} value, err := NewExecutorFactory(testStateRoot(t), nil)(ctx, req) if err != nil { @@ -107,7 +107,7 @@ func TestNativeMCodeEffortMCPAndPlainText(t *testing.T) { _ = e.Close(cleanup) }() out := make(chan proto.Envelope, 256) - turn, err := e.StartTurn(ctx, "turn", proto.TextInput("Use the MCP tool to get the access code, then reply with only the code."), out) + turn, err := e.StartTurn(ctx, "turn", proto.TurnSettings{}, proto.TextInput("Use the MCP tool to get the access code, then reply with only the code."), out) if err != nil { t.Fatal("native Turn admission failed") } diff --git a/apps/daemon/internal/agent/mcode/options.go b/apps/daemon/internal/agent/mcode/options.go index 8fb9e5fce..362987bb0 100644 --- a/apps/daemon/internal/agent/mcode/options.go +++ b/apps/daemon/internal/agent/mcode/options.go @@ -55,15 +55,11 @@ func prepareOptions(runtimeRoot string, req proto.PromptRequestPayload, workspac return result, err } config := map[string]any{} - effort := "" - if req.ExecutionControls != nil { - effort = req.ExecutionControls.ReasoningEffort - } - result.Model = optionString(opts, "model") + result.Model = req.Turn.Model if result.Model == "" { return result, fmt.Errorf("mcode: model is required") } - provider, err := modelProviderConfig(opts["model_provider"], result.Model, effort) + provider, err := modelProviderConfig(opts["model_provider"], result.Model, req.Turn.ReasoningEffort) if err != nil { return result, err } diff --git a/apps/daemon/internal/agent/mcode/options_test.go b/apps/daemon/internal/agent/mcode/options_test.go index 027b5a961..acc270a04 100644 --- a/apps/daemon/internal/agent/mcode/options_test.go +++ b/apps/daemon/internal/agent/mcode/options_test.go @@ -67,7 +67,7 @@ func TestOptionsRejectDroppedContext(t *testing.T) { name string edit func(*proto.PromptRequestPayload) }{ - {"missing model", func(r *proto.PromptRequestPayload) { delete(r.AgentOptions, "model") }}, + {"missing model", func(r *proto.PromptRequestPayload) { r.Turn.Model = "" }}, {"missing provider", func(r *proto.PromptRequestPayload) { delete(r.AgentOptions, "model_provider") }}, } for _, tt := range tests { diff --git a/apps/daemon/internal/agent/mcode/session.go b/apps/daemon/internal/agent/mcode/session.go index 826214f7d..bf4d3f838 100644 --- a/apps/daemon/internal/agent/mcode/session.go +++ b/apps/daemon/internal/agent/mcode/session.go @@ -112,7 +112,7 @@ func (s *Session) prepareNative() error { s.mu.Unlock() // A model with effort options is advertised only with its "thinking" variant. variant := "" - if s.req.ExecutionControls != nil && s.req.ExecutionControls.ReasoningEffort != "" { + if s.req.Turn.ReasoningEffort != "" { variant = "thinking" } model, err := advertisedModel(session.ConfigOptions, s.opts.Model, variant) @@ -130,7 +130,7 @@ func (s *Session) prepareNative() error { } if variant != "" { // A selected variant takes no model default, so the Session's effort is set explicitly. - return s.call("session/set_config_option", map[string]any{"sessionId": session.SessionID, "configId": "thinkingEffort", "value": s.req.ExecutionControls.ReasoningEffort}, nil, false) + return s.call("session/set_config_option", map[string]any{"sessionId": session.SessionID, "configId": "thinkingEffort", "value": s.req.Turn.ReasoningEffort}, nil, false) } return nil } diff --git a/apps/daemon/internal/agent/mcode/session_test.go b/apps/daemon/internal/agent/mcode/session_test.go index c080aa50c..15e1ab44e 100644 --- a/apps/daemon/internal/agent/mcode/session_test.go +++ b/apps/daemon/internal/agent/mcode/session_test.go @@ -18,8 +18,8 @@ import ( func testRequest(t *testing.T) proto.PromptRequestPayload { t.Helper() 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", + return proto.PromptRequestPayload{RunID: "run-1", AgentStateKey: "conversation-1/agent-1/mcode", Input: proto.TextInput("Hello"), Turn: proto.TurnSettings{Model: "fixture"}, AgentOptions: map[string]any{ + "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{TextVerbosity: "medium"}} } @@ -71,7 +71,7 @@ func startTurn(t *testing.T, ctx context.Context, req proto.PromptRequestPayload if err != nil { return nil, err } - turn, err := e.StartTurn(ctx, req.RunID, req.Input, out) + turn, err := e.StartTurn(ctx, req.RunID, proto.TurnSettings{}, req.Input, out) if err != nil { return nil, err } diff --git a/apps/daemon/internal/agent/mcode/workspace_native_test.go b/apps/daemon/internal/agent/mcode/workspace_native_test.go index d9639eed1..95df6530d 100644 --- a/apps/daemon/internal/agent/mcode/workspace_native_test.go +++ b/apps/daemon/internal/agent/mcode/workspace_native_test.go @@ -75,7 +75,7 @@ func TestNativeMCodeWorkspaceTools(t *testing.T) { } }() out := make(chan proto.Envelope, 1024) - turn, err := e.StartTurn(ctx, "turn", proto.TextInput(prompt), out) + turn, err := e.StartTurn(ctx, "turn", proto.TurnSettings{}, proto.TextInput(prompt), out) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/cli/connect_cleanup_test.go b/apps/daemon/internal/cli/connect_cleanup_test.go index e511c233d..f67516bf8 100644 --- a/apps/daemon/internal/cli/connect_cleanup_test.go +++ b/apps/daemon/internal/cli/connect_cleanup_test.go @@ -73,7 +73,7 @@ type cleanupExecutor struct { confirm chan struct{} } -func (e *cleanupExecutor) StartTurn(context.Context, string, proto.MessageInput, chan<- proto.Envelope) (agent.Turn, error) { +func (e *cleanupExecutor) StartTurn(context.Context, string, proto.TurnSettings, proto.MessageInput, chan<- proto.Envelope) (agent.Turn, error) { return nil, errors.New("unexpected Turn") } func (e *cleanupExecutor) Close(context.Context) error { diff --git a/apps/daemon/internal/dispatch/executor.go b/apps/daemon/internal/dispatch/executor.go index 4c478c1cd..599d7ef7b 100644 --- a/apps/daemon/internal/dispatch/executor.go +++ b/apps/daemon/internal/dispatch/executor.go @@ -38,6 +38,7 @@ func executorFingerprint(req proto.PromptRequestPayload) ([32]byte, error) { req.RunID, req.Input = "", nil req.AgentSessionID = "" req.RequireExistingNativeSession = false + req.Turn = proto.TurnSettings{} data, err := json.Marshal(req) return sha256.Sum256(data), err } @@ -145,7 +146,7 @@ func (r *Router) handleExecutorPrepare(ctx context.Context, env proto.Envelope, r.log.Info("executor owner_created", "executor_id", owner.id, "session_id", owner.sessionID) } operation, cancel := context.WithCancel(context.WithoutCancel(ctx)) - p := &preparationState{capabilities: owner.capabilities, requestID: env.ID, trace: env.Trace, fingerprint: requestFingerprint, ctx: operation, cancel: cancel, environmentID: req.EnvironmentID(), executor: owner, owns: true, busy: !reused, deadline: time.Now().Add(r.preparationTimeout)} + p := &preparationState{capabilities: owner.capabilities, requestID: env.ID, trace: env.Trace, fingerprint: requestFingerprint, turn: req.Turn, ctx: operation, cancel: cancel, environmentID: req.EnvironmentID(), executor: owner, owns: true, busy: !reused, deadline: time.Now().Add(r.preparationTimeout)} state := "preparing" if reused { state = "ready" diff --git a/apps/daemon/internal/dispatch/executor_cancel_receipt_test.go b/apps/daemon/internal/dispatch/executor_cancel_receipt_test.go index e5b6cddaf..9da938e81 100644 --- a/apps/daemon/internal/dispatch/executor_cancel_receipt_test.go +++ b/apps/daemon/internal/dispatch/executor_cancel_receipt_test.go @@ -47,7 +47,7 @@ type receiptCancelExecutor struct { closeOnce sync.Once } -func (e *receiptCancelExecutor) StartTurn(_ context.Context, id string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { +func (e *receiptCancelExecutor) StartTurn(_ context.Context, id string, _ proto.TurnSettings, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { turn := &receiptCancelTurn{id: id, out: out, owner: e, written: make(chan struct{}), cancelled: make(chan struct{}), closed: make(chan struct{}), settled: make(chan struct{}), awaiting: make(chan struct{})} e.current = turn e.turn <- turn diff --git a/apps/daemon/internal/dispatch/executor_handoff_test.go b/apps/daemon/internal/dispatch/executor_handoff_test.go index 7962ee220..029a28003 100644 --- a/apps/daemon/internal/dispatch/executor_handoff_test.go +++ b/apps/daemon/internal/dispatch/executor_handoff_test.go @@ -46,7 +46,7 @@ type terminalHandoffExecutor struct { } func (e *terminalHandoffExecutor) Close(context.Context) error { e.closes.Add(1); return nil } -func (e *terminalHandoffExecutor) StartTurn(_ context.Context, id string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { +func (e *terminalHandoffExecutor) StartTurn(_ context.Context, id string, _ proto.TurnSettings, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { turn := &terminalHandoffTurn{id: id, out: out, settled: make(chan struct{})} e.turns <- turn return turn, nil diff --git a/apps/daemon/internal/dispatch/executor_test.go b/apps/daemon/internal/dispatch/executor_test.go index 500b28f4e..81d87a1b5 100644 --- a/apps/daemon/internal/dispatch/executor_test.go +++ b/apps/daemon/internal/dispatch/executor_test.go @@ -23,6 +23,7 @@ type reusableExecutor struct { mu sync.Mutex closeErr error startErr error + turns chan proto.TurnSettings } func (e *reusableExecutor) Close(context.Context) error { @@ -31,7 +32,10 @@ func (e *reusableExecutor) Close(context.Context) error { defer e.mu.Unlock() return e.closeErr } -func (e *reusableExecutor) StartTurn(_ context.Context, id string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { +func (e *reusableExecutor) StartTurn(_ context.Context, id string, turn proto.TurnSettings, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { + if e.turns != nil { + e.turns <- turn + } if e.startErr != nil { return nil, e.startErr } @@ -172,6 +176,32 @@ func TestExecutorCancelSettlesTurnWithoutClosingOwner(t *testing.T) { t.Fatalf("cancel=%+v closes=%d", ack, owner.closes.Load()) } } +func TestExecutorTakesEachTurnsSettingsWithoutChangingIdentity(t *testing.T) { + owner := &reusableExecutor{starts: make(chan *reusableTurn, 2), turns: make(chan proto.TurnSettings, 2)} + r, s, calls := executorRouter(t, owner, time.Minute) + for i, turn := range []proto.TurnSettings{{Model: "one", ReasoningEffort: "low"}, {Model: "two", ServiceTier: "flex"}} { + key := fmt.Sprint("turn-", i) + req := executorRequest() + req.Configuration.Turn = turn + if i == 1 { + req.Configuration.AgentSessionID = "native-session" + } + p := executorAdmission(t, r, s, key, req) + if p.Reused != (i == 1) { + t.Fatalf("Turn %d: reused=%v", i, p.Reused) + } + startExecutorTurn(t, r, s, key, key, p) + if got := <-owner.turns; got != turn { + t.Fatalf("StartTurn got %+v, want %+v", got, turn) + } + (<-owner.starts).finish() + waitFor(t, func() bool { return r.ActiveRuns() == 0 }, "Turn settlement") + } + if calls.Load() != 1 || owner.closes.Load() != 0 { + t.Fatal("a Turn setting change replaced the Executor") + } +} + func TestExecutorRejectsConfigurationAndNativeIdentityChanges(t *testing.T) { owner := &reusableExecutor{starts: make(chan *reusableTurn, 1)} r, s, calls := executorRouter(t, owner, time.Minute) @@ -182,7 +212,7 @@ func TestExecutorRejectsConfigurationAndNativeIdentityChanges(t *testing.T) { for _, kind := range []string{"configuration", "native"} { req := executorRequest() if kind == "configuration" { - req.Configuration.AgentOptions = map[string]any{"model": "changed"} + req.Configuration.AgentOptions = map[string]any{"system_prompt": "changed"} } else { req.Configuration.AgentSessionID = "other-native" } @@ -380,7 +410,7 @@ func TestExecutorResumesSameOwnerAndNextTurnAfterSuspension(t *testing.T) { req := preparationRequest() req.Configuration.AgentSessionID = "native-session" conflict := req - conflict.Configuration.AgentOptions = map[string]any{"model": "changed"} + conflict.Configuration.AgentOptions = map[string]any{"system_prompt": "changed"} if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "conflict", conflict)); err == nil { t.Fatal("suspension relaxed the fixed configuration") } diff --git a/apps/daemon/internal/dispatch/functions_native_test.go b/apps/daemon/internal/dispatch/functions_native_test.go index 056576d93..81b35e2fc 100644 --- a/apps/daemon/internal/dispatch/functions_native_test.go +++ b/apps/daemon/internal/dispatch/functions_native_test.go @@ -162,7 +162,7 @@ func TestNativeFunctionBridge(t *testing.T) { run := fmt.Sprintf("run-%d", index) request := noEnvironmentPreparation("native-functions", proto.PromptRequestPayload{AgentKind: "codex", AgentSessionID: nativeID, FunctionTools: []proto.FunctionTool{{Name: "lookup_ticket", Description: "Read a synthetic ticket", Parameters: json.RawMessage(`{"type":"object","properties":{"ticket":{"type":"string"}},"required":["ticket"],"additionalProperties":false}`)}}, - AgentOptions: map[string]any{"model": "gpt-5.5", "model_provider": map[string]any{"protocol": "responses", "base_url": model.URL + "/v1", "api_key": "synthetic-local-token"}}}) + Turn: proto.TurnSettings{Model: "gpt-5.5"}, AgentOptions: map[string]any{"model_provider": map[string]any{"protocol": "responses", "base_url": model.URL + "/v1", "api_key": "synthetic-local-token"}}}) prepare, _ := proto.NewEnvelope(proto.TypeExecutionPrepare, run, request) if err := router.Handle(ctx, prepare); err != nil { t.Fatal(err) diff --git a/apps/daemon/internal/dispatch/preparation.go b/apps/daemon/internal/dispatch/preparation.go index fdfe21631..0ad980196 100644 --- a/apps/daemon/internal/dispatch/preparation.go +++ b/apps/daemon/internal/dispatch/preparation.go @@ -23,6 +23,7 @@ type preparationState struct { requestID string trace string fingerprint [32]byte + turn proto.TurnSettings startFingerprint [32]byte status proto.PreparationStatusPayload deadline time.Time @@ -101,7 +102,7 @@ func (r *Router) handleExecutionPrepare(ctx context.Context, env proto.Envelope) return r.rejectPreparation(env, "preparation_capacity") } owner, cancel := context.WithCancel(context.WithoutCancel(ctx)) - p := &preparationState{capabilities: caps, requestID: env.ID, trace: env.Trace, fingerprint: fingerprint, ctx: owner, cancel: cancel, environmentID: req.EnvironmentID(), busy: true, owns: true, deadline: time.Now().Add(r.preparationTimeout)} + p := &preparationState{capabilities: caps, requestID: env.ID, trace: env.Trace, fingerprint: fingerprint, turn: req.Turn, ctx: owner, cancel: cancel, environmentID: req.EnvironmentID(), busy: true, owns: true, deadline: time.Now().Add(r.preparationTimeout)} p.status = proto.PreparationStatusPayload{Handle: uuid.NewString(), Revision: 1, State: "preparing", ExpiresAt: p.deadline.UnixMilli()} r.preparations[p.status.Handle], r.preparationRequests[p.requestID] = p, p p.timer = time.AfterFunc(r.preparationTimeout, func() { r.releasePreparation(p, "expired", "", true) }) diff --git a/apps/daemon/internal/dispatch/preparation_executor_fixture_test.go b/apps/daemon/internal/dispatch/preparation_executor_fixture_test.go index c3e8c9ebf..1aa47ea4b 100644 --- a/apps/daemon/internal/dispatch/preparation_executor_fixture_test.go +++ b/apps/daemon/internal/dispatch/preparation_executor_fixture_test.go @@ -57,7 +57,7 @@ func (e *preparationExecutor) Close(ctx context.Context) error { return err } -func (e *preparationExecutor) StartTurn(ctx context.Context, id string, input proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { +func (e *preparationExecutor) StartTurn(ctx context.Context, id string, _ proto.TurnSettings, input proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { upstream := make(chan proto.Envelope, 64) terminal := make(chan struct{}) var once sync.Once diff --git a/apps/daemon/internal/dispatch/preparation_start.go b/apps/daemon/internal/dispatch/preparation_start.go index 887f42f11..ac5b06d42 100644 --- a/apps/daemon/internal/dispatch/preparation_start.go +++ b/apps/daemon/internal/dispatch/preparation_start.go @@ -95,7 +95,7 @@ func (r *Router) startPreparedExecution(p *preparationState, state *sessionState if blocked { startErr = context.Canceled } else { - turn, startErr = handoff.target.StartTurn(p.ctx, input.RunID, input.Input, state.out) + turn, startErr = handoff.target.StartTurn(p.ctx, input.RunID, p.turn, input.Input, state.out) } r.mu.Lock() handoff.turn = turn diff --git a/apps/daemon/internal/dispatch/suspend_test.go b/apps/daemon/internal/dispatch/suspend_test.go index 36562c3e2..5aba63398 100644 --- a/apps/daemon/internal/dispatch/suspend_test.go +++ b/apps/daemon/internal/dispatch/suspend_test.go @@ -20,7 +20,7 @@ type suspendedExecutor struct { close func(context.Context) error } -func (e *suspendedExecutor) StartTurn(context.Context, string, proto.MessageInput, chan<- proto.Envelope) (agent.Turn, error) { +func (e *suspendedExecutor) StartTurn(context.Context, string, proto.TurnSettings, proto.MessageInput, chan<- proto.Envelope) (agent.Turn, error) { return nil, errors.New("suspended executor must not start a Turn") } diff --git a/apps/daemon/internal/wireconformance/wire_test.go b/apps/daemon/internal/wireconformance/wire_test.go index 3fef719dc..d48bfa2bd 100644 --- a/apps/daemon/internal/wireconformance/wire_test.go +++ b/apps/daemon/internal/wireconformance/wire_test.go @@ -323,7 +323,10 @@ type controlledExecutor struct { } func (e *controlledExecutor) Close(context.Context) error { e.closes.Add(1); return nil } -func (e *controlledExecutor) StartTurn(_ context.Context, id string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { +func (e *controlledExecutor) StartTurn(_ context.Context, id string, settings proto.TurnSettings, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { + if settings != prototest.Turn { + return nil, errors.New("Turn settings did not reach StartTurn") + } e.starts.Add(1) turn := &controlledTurn{id: id, out: out, cancelling: make(chan struct{}), allowCancel: make(chan struct{}), settled: make(chan struct{})} e.turn <- turn diff --git a/apps/daemon/testdata/onboarding/main.go b/apps/daemon/testdata/onboarding/main.go index 59bc3e2ea..03a4df227 100644 --- a/apps/daemon/testdata/onboarding/main.go +++ b/apps/daemon/testdata/onboarding/main.go @@ -63,7 +63,7 @@ type executor struct { closed bool } -func (e *executor) StartTurn(ctx context.Context, run string, input proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { +func (e *executor) StartTurn(ctx context.Context, run string, _ proto.TurnSettings, input proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { if ctx.Err() != nil || run == "" || out == nil { return nil, errors.New("invalid fixture Start") } diff --git a/contracts/agents-api/harness-onboarding.md b/contracts/agents-api/harness-onboarding.md index 59c43844e..9540b75f5 100644 --- a/contracts/agents-api/harness-onboarding.md +++ b/contracts/agents-api/harness-onboarding.md @@ -96,7 +96,7 @@ The declaration admits public combinations and the Runtime advertises the instal | Session Executor | Runtime | `Executor.Close` on idle expiry, shutdown or confirmed invalidation | | Turn | Adapter `Turn`, tracked by the Runtime | `AwaitSettlement` confirms settlement | -A Session owns one reusable Executor in its connected Runtime; a Turn owns one input execution, its output stream and its cancellation. `agent.ExecutorFactory` prepares the fixed configuration without model input, and `Executor.StartTurn` creates a new `agent.Turn` without replacing healthy native resources. Normal completion settles only the Turn. `Executor.Close` releases native resources on idle expiry, Environment shutdown or confirmed invalidation; it releases neither the Environment allocation nor the workspace. Core keeps no second Executor cache. The same lifecycle applies to hosted, self-hosted and `none` placements. +A Session owns one reusable Executor in its connected Runtime; a Turn owns one input execution, its output stream and its cancellation. `agent.ExecutorFactory` prepares the fixed configuration without model input, and `Executor.StartTurn(ctx, runID, turn, input, out)` receives the root Turn's `proto.TurnSettings` and creates a new `agent.Turn` without replacing healthy native resources. Normal completion settles only the Turn. `Executor.Close` releases native resources on idle expiry, Environment shutdown or confirmed invalidation; it releases neither the Environment allocation nor the workspace. Core keeps no second Executor cache. The same lifecycle applies to hosted, self-hosted and `none` placements. An Executor moves through these states. Each Turn runs between `StartTurn` and `AwaitSettlement`. @@ -112,7 +112,7 @@ stateDiagram-v2 Closed --> [*] ``` -**Binding.** The Runtime binds its Executor record to the Session, Environment, connection and immutable execution configuration. Resume identity and prior-Turn recovery flags are continuity assertions, not configuration changes. A supplied native identity must match the retained owner, and when existing history is required, recovery never starts a new root. A configuration conflict is an error, not a hot switch. An ordinary lost connection retires its owners and handles; [planned suspension](../../docs/runtime-protocol.md#preparation-and-execution-order) retains settled idle owners through exact authenticated resume. Old timers, output and cancellation cannot affect replacement owners. +**Binding.** The Runtime binds its Executor record to the Session, Environment, connection and immutable execution configuration. Resume identity and prior-Turn recovery flags are continuity assertions, not configuration changes. A supplied native identity must match the retained owner, and when existing history is required, recovery never starts a new root. The root Turn's `turn` settings (model, reasoning effort, service tier, review) are not part of that binding; they reach `StartTurn` with each Turn, and the factory sees the first Turn's values for native startup. Every other configuration conflict is an error, not a hot switch. An ordinary lost connection retires its owners and handles; [planned suspension](../../docs/runtime-protocol.md#preparation-and-execution-order) retains settled idle owners through exact authenticated resume. Old timers, output and cancellation cannot affect replacement owners. **Per-Turn state.** Each Turn gets a fresh wrapper, output channel and receipt state. Steering and function interfaces belong to that Turn. Native callbacks capture the originating Turn before asynchronous work, so a late event is never attributed to whichever Turn is active. Native processes, query or transport connections, fixed capability configuration and native session identity belong to the Executor. Do not reset completed `sync.Once` values or reuse an old Turn object. @@ -184,7 +184,7 @@ The declaration holds: - the model `Configuration` ([native model configuration](#native-model-configuration)); - `Capabilities`, a `proto.AgentKindCapabilities` that states the most the Harness can do on a capable host, with every field `Supported` or `Unsupported` and the zero value invalid ([Capability declarations](../../docs/runtime-protocol.md#capability-declarations)); - the supported `Placements` and public `MCPOrigins`, whether whitespace-only text is accepted, and `MaxInstructionsBytes`, the native byte limit on an Agent's instructions (zero declares none), which admission rejects above; -- `Execution`, the official execution option values the adapter maps to native settings: web search modes and whether search settings are accepted, reasoning efforts and summaries, service tiers, programmatic-tool-calling values and text verbosities. Core admission and the Runtime reject an undeclared value with the same check ([tool policy](./execution-tools.md#web-search-and-programmatic-tool-calling)). Core always sends the official default text verbosity, so `TextVerbosities` must include `medium`; +- `Execution`, the official execution option values the adapter maps to native settings: web search modes and whether search settings are accepted, reasoning efforts and summaries, service tiers, review values other than `none`, 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`, `ValidateMessageInput` and `ValidateFunctionResult`, that add native limits to the common checks. `ValidateMessageInput` runs only for messages that carry images, after the `MessageImages` capability check and before anything is stored, and decides which `image_url` values the Harness accepts; Core checks only that the reference is not blank. A nil validator means the common checks suffice. They cannot query business data, decrypt credentials or control native processes. Configuration restrictions run first, and a tool-decoding error precedes `ValidateTools`. `harnessconfig.NewRegistry` validates every declaration and panics on an invalid or duplicate one: kinds and labels must be nonempty, placements must list at least one supported placement, MCP origins must be a non-nil list, unknown or duplicate choices and origins without a corresponding placement are rejected, bearer support requires an MCP origin, and `Execution` must declare text verbosity `medium`. Shared dispatch checks capability combinations, never engine names. diff --git a/contracts/agents-api/zh/harness-onboarding.md b/contracts/agents-api/zh/harness-onboarding.md index 33a247402..fcd544ae0 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: bb67d20b3c7684d18944ac0bf51038835df199a91fda3445b727aaae7a049e64 +source_hash: 936790abf3ff052067f0f8736353e1cea4652029e8eb832fc877a3968b9c82d5 --- **Harness** 是一种运行模型和工具循环的原生代理引擎(Codex、Claude Code、MiniMax Code);**Harness 适配器**将 Runtime 的 Executor 和 Turn 契约转换到该引擎的 SDK 或协议。Harness 协议是 Runtime 与适配器之间的契约:适配器接口及其生命周期义务、声明、注册和验收。本文面向添加或维护 Harness 的开发者。 @@ -98,7 +98,7 @@ func (s *Session) SubmitFunctionResult(context.Context, proto.FunctionResultPayl | Session Executor | Runtime | 空闲过期、关闭或确认失效时执行 `Executor.Close` | | Turn | 由 Runtime 跟踪的适配器 `Turn` | `AwaitSettlement` 确认结算完成 | -Session 在其已连接的 Runtime 中拥有一个可复用的 Executor;Turn 拥有一次输入执行、其输出流和其取消操作。`agent.ExecutorFactory` 在没有模型输入的情况下准备固定配置,而 `Executor.StartTurn` 创建新的 `agent.Turn`,不替换健康的原生资源。正常完成仅结算 Turn。`Executor.Close` 在空闲过期、Environment 关闭或确认失效时释放原生资源;它既不释放 Environment 分配,也不释放工作区。Core 不保留第二套 Executor 缓存。相同的生命周期适用于托管、自托管和 `none` 放置方式。 +Session 在其已连接的 Runtime 中拥有一个可复用的 Executor;Turn 拥有一次输入执行、其输出流和其取消操作。`agent.ExecutorFactory` 在没有模型输入的情况下准备固定配置,而 `Executor.StartTurn(ctx, runID, turn, input, out)` 接收根 Turn 的 `proto.TurnSettings` 并创建新的 `agent.Turn`,不替换健康的原生资源。正常完成仅结算 Turn。`Executor.Close` 在空闲过期、Environment 关闭或确认失效时释放原生资源;它既不释放 Environment 分配,也不释放工作区。Core 不保留第二套 Executor 缓存。相同的生命周期适用于托管、自托管和 `none` 放置方式。 Executor 按下列状态转移。每个 Turn 在 `StartTurn` 与 `AwaitSettlement` 之间运行。 @@ -114,7 +114,7 @@ stateDiagram-v2 Closed --> [*] ``` -**绑定。** Runtime 将其 Executor 记录绑定到 Session、Environment、连接和不可变执行配置。恢复身份和先前 Turn 恢复标志是连续性断言,而不是配置更改。提供的原生身份必须与保留的所有者匹配;当需要现有历史时,恢复绝不能启动新的根。配置冲突属于错误,而不是热切换。普通连接丢失会让其所有者和句柄退役;[计划性挂起](../../../docs/zh/runtime-protocol.md#preparation-and-execution-order) 则保留已结算的空闲所有者,直至精确匹配且已认证的恢复完成。旧计时器、输出和取消操作不能影响替代所有者。 +**绑定。** Runtime 将其 Executor 记录绑定到 Session、Environment、连接和不可变执行配置。恢复身份和先前 Turn 恢复标志是连续性断言,而不是配置更改。提供的原生身份必须与保留的所有者匹配;当需要现有历史时,恢复绝不能启动新的根。根 Turn 的 `turn` 设置(模型、推理强度、服务层级、review)不属于该绑定;它们随每个 Turn 传给 `StartTurn`,工厂看到的是第一个 Turn 的取值,用于原生启动。其他任何配置冲突都属于错误,而不是热切换。普通连接丢失会让其所有者和句柄退役;[计划性挂起](../../../docs/zh/runtime-protocol.md#preparation-and-execution-order) 则保留已结算的空闲所有者,直至精确匹配且已认证的恢复完成。旧计时器、输出和取消操作不能影响替代所有者。 **每 Turn 状态。** 每个 Turn 都会获得全新的包装器、输出通道和回执状态。引导和函数接口均属于该 Turn。原生回调必须在异步工作开始前捕获来源 Turn,因此迟到事件绝不会被归到当前活动的 Turn 上。原生进程、query 或传输连接、固定能力配置和原生 session 身份均属于 Executor。不要重置已完成的 `sync.Once` 值,也不要复用旧 Turn 对象。 @@ -186,7 +186,7 @@ Runtime 统一解析[状态目录](../../../docs/zh/configuration.md#runtime-res - 模型 `Configuration`([原生模型配置](#native-model-configuration)); - `Capabilities`,即 `proto.AgentKindCapabilities`,表示 Harness 在能力完备的主机上最多能做什么;每个字段都是 `Supported` 或 `Unsupported`,零值无效([能力声明](../../../docs/zh/runtime-protocol.md#capability-declarations)); - 受支持的 `Placements` 和公共 `MCPOrigins`、是否接受仅含空白的文本,以及 `MaxInstructionsBytes`(原生对 Agent instructions 的字节上限,零表示没有上限,超出时准入会拒绝); -- `Execution`,即适配器映射到原生设置的官方执行选项取值:web search 模式及是否接受搜索设置、推理强度和摘要、服务层级、程序化工具调用取值和文本详细程度。Core 准入和 Runtime 用同一检查拒绝未声明的取值([工具策略](execution-tools.md#web-search-and-programmatic-tool-calling))。Core 总会发送官方默认文本详细程度,因此 `TextVerbosities` 必须包含 `medium`; +- `Execution`,即适配器映射到原生设置的官方执行选项取值:web search 模式及是否接受搜索设置、推理强度和摘要、服务层级、`none` 以外的 review 取值、程序化工具调用取值和文本详细程度。Core 准入和 Runtime 用同一检查拒绝未声明的取值([工具策略](execution-tools.md#web-search-and-programmatic-tool-calling))。Core 总会发送官方默认文本详细程度,因此 `TextVerbosities` 必须包含 `medium`; - 可选的纯限制 `ValidateConfiguration`、`ValidateTools`、`ValidateMessageInput` 和 `ValidateFunctionResult`,在通用检查之外添加原生限制。`ValidateMessageInput` 仅对携带图像的消息运行,在 `MessageImages` 能力检查之后、任何存储之前执行,决定 Harness 接受哪些 `image_url`;Core 只检查引用不为空白。验证函数为 nil 表示通用检查已足够。它们不能查询业务数据、解密凭据或控制原生进程。配置限制最先执行,工具解码错误先于 `ValidateTools`。 `harnessconfig.NewRegistry` 会验证每个声明,对无效或重复的声明触发 panic:kind 和 label 必须非空,放置位置必须至少列出一个受支持的位置,MCP 来源必须是非 nil 列表,未知或重复的选项以及没有对应放置位置的来源会被拒绝,bearer 支持要求存在 MCP 来源,`Execution` 必须声明文本详细程度 `medium`。共享调度检查能力组合,绝不检查引擎名称。 diff --git a/docs/runtime-protocol.md b/docs/runtime-protocol.md index b1bd39c8b..17abdba0b 100644 --- a/docs/runtime-protocol.md +++ b/docs/runtime-protocol.md @@ -91,7 +91,7 @@ Every data frame is one JSON [`Envelope`](https://github.com/MiniMax-AI/OpenAgen | --- | --- | | Device ID and connection | Authenticated Runtime routing and connection ownership | | Session ID / Environment ID | Core-owned configuration and workspace binding; canonical UUIDs where the payload validator requires them | -| Executor ID | Runtime-owned native resource, possibly retained across settled Turns with identical configuration | +| Executor ID | Runtime-owned native resource, possibly retained across settled Turns with identical configuration; the root Turn's `turn` settings are not part of the identity | | Preparation request ID | `Envelope.id` for prepare, start, release and status; distinct from a Run | | Admission handle | Runtime-generated reservation, valid only on the connection that accepted it | | Run ID | One execution attempt; `Envelope.id` for output, cancellation, active input and functions | @@ -189,7 +189,7 @@ sequenceDiagram - Consume Run events until a native terminal outcome or loss of observation. An execution error is followed by `done`, which closes the stream; the preceding errors remain part of its outcome. - To abandon before start, send `execution_release`. After ownership passes to a Run, use `prompt_cancel`; releasing the old handle cannot cancel its successor. -A preparation reserves a per-Turn admission, not a new Executor. It carries an explicit Session identity and immutable configuration without model input or a Run ID. A fresh request returns a connection-local handle and the owning Executor ID; a reused healthy Executor returns `ready` without native preparation. Per-handle revisions order status observations: ignore older or repeated revisions and never apply a status to another handle. An admission moves through the states below. A `rejected` control operation carries an `operation` and error code and does not replace the handle's current revision. Releasing an admission abandons only that admission; it does not close the Session's idle Executor or cancel a later Turn. +A preparation reserves a per-Turn admission, not a new Executor. It carries an explicit Session identity, immutable configuration and the root Turn's settings (`turn`) without model input or a Run ID. A fresh request returns a connection-local handle and the owning Executor ID; a reused healthy Executor returns `ready` without native preparation. Per-handle revisions order status observations: ignore older or repeated revisions and never apply a status to another handle. An admission moves through the states below. A `rejected` control operation carries an `operation` and error code and does not replace the handle's current revision. Releasing an admission abandons only that admission; it does not close the Session's idle Executor or cancel a later Turn. ```mermaid stateDiagram-v2 @@ -220,8 +220,9 @@ 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: 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 | +| `turn` | Always: the root Turn's `model`, plus `reasoning_effort` and a non-`auto` `service_tier` when the Agent sets them. `review` (`auto_review`; empty is none) is part of the same object. A retained Executor accepts a different `turn` without a `configuration_conflict`; the Runtime passes each Turn's settings to `StartTurn`, and a settled Executor's next Turn takes the settings of its own `execution_prepare`. Read-only workspace preparation sends an empty `turn` | +| `agent_options` | Always `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: 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 summary or a `json_schema` output format. Core validates these and the `turn` settings against the Harness declaration before dispatch; native option names belong to the adapter | | `observe_subagent_identities`, `disable_subagents` | From the Agent's `multi_agent.enabled` | | `disable_execution_environment` | For an Environment of type `none` | | `local_environment` | For `openai_hosted` and `self_hosted`, with the exact Environment binding. The request carries no working directory; the Runtime checks `workspace_directory` against its binding | diff --git a/docs/zh/runtime-protocol.md b/docs/zh/runtime-protocol.md index 211d94737..c58cd563a 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: ac10ed0c636be22b92802eeae3af6ded928239c637e24cafe748a62fa16174f0 +source_hash: 368945a521422e6ffbe03e1ff81a1e9fd54b935ca50c042f9afe740bdfb4643b --- Core–Runtime 协议连接 Core 与 Runtime daemon,定义 daemon 连接上消息的含义和顺序。托管和自托管 Runtime 使用同一协议。 @@ -93,7 +93,7 @@ Core 对 `usage` 和 `resume` 没有准入规则。 | --- | --- | | Device ID 和连接 | 经认证的 Runtime 路由与连接所有权 | | Session ID / Environment ID | Core 拥有的配置与工作区绑定;payload validator 要求时使用规范 UUID | -| Executor ID | Runtime 拥有的原生资源,可在配置相同的已结算 Turn 之间保留 | +| Executor ID | Runtime 拥有的原生资源,可在配置相同的已结算 Turn 之间保留;根 Turn 的 `turn` 设置不属于该身份 | | Preparation request ID | prepare、start、release 和 status 的 `Envelope.id`;与 Run 不同 | | Admission handle | Runtime 生成的预约,仅在接受它的连接上有效 | | Run ID | 一次执行尝试;输出、取消、活动输入和 function 的 `Envelope.id` | @@ -191,7 +191,7 @@ sequenceDiagram - 消费 Run 事件,直到原生终结结果或观测丢失。执行 error 后跟随 `done`,关闭流;之前的 error 仍属于结果的一部分。 - start 前放弃时发送 `execution_release`。所有权转给 Run 后使用 `prompt_cancel`;释放旧 handle 不能取消后继 Turn。 -preparation 预约每个 Turn 的准入,而不是新 Executor。它携带明确 Session 身份和不可变配置,不包含模型输入或 Run ID。新请求返回连接本地 handle 与所属 Executor ID;复用健康 Executor 时直接返回 `ready`,无需原生 preparation。每个 handle 的 revision 决定 status 观测顺序:忽略旧的或重复的 revision,不将 status 应用于其他 handle。admission 按下列状态转移。`rejected` 控制操作携带 `operation` 和 error code,不替代 handle 的当前 revision。释放 admission 仅放弃该 admission;不关闭 Session 的空闲 Executor,也不取消后续 Turn。 +preparation 预约每个 Turn 的准入,而不是新 Executor。它携带明确 Session 身份、不可变配置和根 Turn 的设置(`turn`),不包含模型输入或 Run ID。新请求返回连接本地 handle 与所属 Executor ID;复用健康 Executor 时直接返回 `ready`,无需原生 preparation。每个 handle 的 revision 决定 status 观测顺序:忽略旧的或重复的 revision,不将 status 应用于其他 handle。admission 按下列状态转移。`rejected` 控制操作携带 `operation` 和 error code,不替代 handle 的当前 revision。释放 admission 仅放弃该 admission;不关闭 Session 的空闲 Executor,也不取消后续 Turn。 ```mermaid stateDiagram-v2 @@ -222,8 +222,9 @@ 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` | 始终设置:解析后的 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 声明校验每个值;原生选项名称属于适配器 | +| `turn` | 始终设置:根 Turn 的 `model`,以及 Agent 设置时的 `reasoning_effort` 和非 `auto` 的 `service_tier`。`review`(`auto_review`;为空表示 none)属于同一对象。保留的 Executor 接受不同的 `turn`,不会产生 `configuration_conflict`;Runtime 把每个 Turn 的设置传给 `StartTurn`,已结算 Executor 的下一个 Turn 使用其自身 `execution_prepare` 中的设置。只读工作区 preparation 发送空的 `turn` | +| `agent_options` | 始终设置 Agent 的 `system_prompt`;Session 冻结了模型提供商时设置 `model_provider`;Agent 设置 `x_agents_core.harness_config` 时设置 `harness_config`。Core 不发送其他键;Runtime 在准备之前以 `unsupported_configuration` 拒绝任何其他键 | +| `execution_controls` | 始终设置:解析后的 text verbosity(默认 `medium`),以及 Agent 设置的每个选项,包括 web search(mode、`context_size`、`allowed_domains`、`location`)、programmatic tool calling、reasoning summary,以及 `json_schema` 输出格式。Core 在分发前按 Harness 声明校验这些值和 `turn` 设置;原生选项名称属于适配器 | | `observe_subagent_identities`, `disable_subagents` | 根据 Agent 的 `multi_agent.enabled` 设置 | | `disable_execution_environment` | Environment 类型为 `none` 时设置 | | `local_environment` | 为 `openai_hosted` 和 `self_hosted` 设置,包含精确的 Environment 绑定。请求不携带 working directory;Runtime 按自身绑定检查 `workspace_directory` | diff --git a/internal/agentdaemon/proto/outbound.go b/internal/agentdaemon/proto/outbound.go index 106fe054a..13ae75cd3 100644 --- a/internal/agentdaemon/proto/outbound.go +++ b/internal/agentdaemon/proto/outbound.go @@ -28,6 +28,11 @@ type PromptRequestPayload struct { // Input preserves ordered user messages and content. Input MessageInput `json:"input,omitempty"` + // Turn holds the root Turn's official settings. It is not part of the + // Executor identity: a retained Executor receives each Turn's settings at + // StartTurn. + Turn TurnSettings `json:"turn"` + // AgentOptions carries only the keys ValidateAgentOptions accepts. The // daemon's agent interprets them; the gateway never inspects them. AgentOptions map[string]any `json:"agent_options,omitempty"` @@ -67,13 +72,21 @@ type PromptCancelPayload struct { type ExecutionControls struct { 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"` + ProgrammaticToolCalling *bool `json:"programmatic_tool_calling,omitempty"` + ReasoningSummary string `json:"reasoning_summary,omitempty"` + TextVerbosity string `json:"text_verbosity,omitempty"` + OutputFormat *OutputFormat `json:"output_format,omitempty"` +} + +// TurnSettings are the official settings a Session update may change for later +// root Turns. An empty member means the native default. +type TurnSettings struct { + Model string `json:"model"` + ReasoningEffort string `json:"reasoning_effort,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"` + ServiceTier string `json:"service_tier,omitempty"` + // Review is "auto_review"; empty is none. + Review string `json:"review,omitempty"` } // WebSearchControl is the resolved official web_search tool. A nil @@ -99,12 +112,12 @@ type OutputFormat struct { Schema json.RawMessage `json:"schema"` } -// ValidateAgentOptions rejects any agent_options key other than model, +// ValidateAgentOptions rejects any agent_options key other than // system_prompt, model_provider and harness_config. func (r PromptRequestPayload) ValidateAgentOptions() error { for key := range r.AgentOptions { switch key { - case "model", "system_prompt", "model_provider", "harness_config": + case "system_prompt", "model_provider", "harness_config": default: return errors.New("unsupported agent option") } diff --git a/internal/agentdaemon/proto/prototest/wire.go b/internal/agentdaemon/proto/prototest/wire.go index 82142f3b2..cf056af07 100644 --- a/internal/agentdaemon/proto/prototest/wire.go +++ b/internal/agentdaemon/proto/prototest/wire.go @@ -33,6 +33,10 @@ const ( CancelDeliveryID = "cancel" ) +// Turn is the root Turn's settings the scenarios prepare with. The Runtime's +// Executor must receive them at StartTurn. +var Turn = proto.TurnSettings{Model: "fixture-model", ReasoningEffort: "high"} + // Placeholders for values the Runtime generates. Core's side sends them as // written; the Runtime's side binds each to the value its Runtime sends. const ( @@ -110,7 +114,7 @@ func WireScenarios() []WireScenario { } prepare := send(Core, proto.TypeExecutionPrepare, PreparationID, proto.ExecutionPreparePayload{ SessionID: SessionID, - Configuration: proto.PromptRequestPayload{AgentKind: HarnessKind, AgentStateKey: StateKey, DisableExecutionEnvironment: true}, + Configuration: proto.PromptRequestPayload{AgentKind: HarnessKind, AgentStateKey: StateKey, DisableExecutionEnvironment: true, Turn: Turn}, }) started := []Step{ prepare, diff --git a/internal/agentdaemon/proto/version.go b/internal/agentdaemon/proto/version.go index 9ae7aecb6..8a1077e2c 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.23.0" +const Version = "0.24.0" // VersionCompatible accepts only this contract. Patch drift, prerelease suffixes // and malformed versions do not select an implicit compatibility path. diff --git a/internal/agentdaemon/proto/workspace_read_preparation.go b/internal/agentdaemon/proto/workspace_read_preparation.go index cd8ff66a9..a9344852f 100644 --- a/internal/agentdaemon/proto/workspace_read_preparation.go +++ b/internal/agentdaemon/proto/workspace_read_preparation.go @@ -6,7 +6,7 @@ func ValidWorkspaceReadPreparation(r PromptRequestPayload) bool { return r.WorkspaceReadOnly && r.LocalEnvironment != nil && r.AgentStateKey != "" && r.RunID == "" && len(r.Input) == 0 && r.AgentSessionID == "" && !r.RequireExistingNativeSession && !r.DisableExecutionEnvironment && - len(r.AgentOptions) == 0 && r.ExecutionControls == nil && r.MCPHTTPServers == nil && + r.Turn == (TurnSettings{}) && len(r.AgentOptions) == 0 && r.ExecutionControls == nil && r.MCPHTTPServers == nil && len(r.FunctionTools) == 0 && !r.ToolSearch && !r.ObserveSubagentIdentities } diff --git a/internal/harnessconfig/harness.go b/internal/harnessconfig/harness.go index 51838d690..1b10ec7c0 100644 --- a/internal/harnessconfig/harness.go +++ b/internal/harnessconfig/harness.go @@ -157,6 +157,9 @@ func (d Declaration) ValidateDeclaration() error { (d.Capabilities.MCPHTTPBearerAuth.IsSupported() && len(d.MCPOrigins) == 0) { return errors.New("harnessconfig: invalid MCP origins") } + if !validChoices(d.Execution.Reviews, "auto_review") { + return errors.New("harnessconfig: invalid reviews") + } // Core always sends the official default text verbosity. if !slices.Contains(d.Execution.TextVerbosities, "medium") { return errors.New("harnessconfig: execution must declare text verbosity medium") @@ -196,15 +199,14 @@ func ValidateModel(value any) (string, error) { } // Prepare validates the current model/provider/native-parameter contract before -// creating native resources. Other options belong to the execution contract. -func (c Configuration) Prepare(options map[string]any) (PreparedConfiguration, error) { - var result PreparedConfiguration - if value, present := options["model"]; present { - model, err := ValidateModel(value) - if err != nil { +// creating native resources. An empty model is absent. Other options belong to +// the execution contract. +func (c Configuration) Prepare(model string, options map[string]any) (PreparedConfiguration, error) { + result := PreparedConfiguration{Model: model} + if model != "" { + if _, err := ValidateModel(model); err != nil { return PreparedConfiguration{}, err } - result.Model = model } if value, present := options["model_provider"]; present { if result.Model == "" { @@ -373,6 +375,8 @@ type ExecutionOptions struct { ReasoningEfforts, ReasoningSummaries []string // ServiceTiers are the supported tiers other than auto. ServiceTiers []string + // Reviews are the supported review values other than none. + Reviews []string // ProgrammaticToolCalling lists the supported enabled values. ProgrammaticToolCalling []bool // TextVerbosities are the supported text verbosity levels. @@ -384,6 +388,7 @@ func (o ExecutionOptions) Clone() ExecutionOptions { o.ReasoningEfforts = slices.Clone(o.ReasoningEfforts) o.ReasoningSummaries = slices.Clone(o.ReasoningSummaries) o.ServiceTiers = slices.Clone(o.ServiceTiers) + o.Reviews = slices.Clone(o.Reviews) o.ProgrammaticToolCalling = slices.Clone(o.ProgrammaticToolCalling) o.TextVerbosities = slices.Clone(o.TextVerbosities) return o @@ -413,17 +418,26 @@ func (d Declaration) ValidateExecutionControls(controls *proto.ExecutionControls 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 } + +// ValidateTurnSettings rejects an effort, tier or review the Harness does not +// declare. Core admission and the Runtime call it with the same settings. +func (d Declaration) ValidateTurnSettings(turn proto.TurnSettings) error { + o := d.Execution + for _, check := range []struct { + field, value string + allowed []string + }{{"reasoning.effort", turn.ReasoningEffort, o.ReasoningEfforts}, {"service_tier", turn.ServiceTier, o.ServiceTiers}, {"review", turn.Review, o.Reviews}} { + if check.value != "" && !slices.Contains(check.allowed, check.value) { + return fmt.Errorf("%s %q is not supported by this Harness.", check.field, check.value) + } + } + return nil +} diff --git a/internal/harnessconfig/preparation_test.go b/internal/harnessconfig/preparation_test.go index 748e7ecbe..2ebbcfdc3 100644 --- a/internal/harnessconfig/preparation_test.go +++ b/internal/harnessconfig/preparation_test.go @@ -13,24 +13,22 @@ func TestPrepareModelConfiguration(t *testing.T) { } for _, tc := range []struct { name string + model string options map[string]any valid bool }{ - {"native owned", nil, true}, - {"native model", map[string]any{"model": "fixture"}, true}, - {"explicit", map[string]any{"model": "fixture", "model_provider": provider()}, true}, - {"missing model", map[string]any{"model_provider": provider()}, false}, - {"empty model", map[string]any{"model": ""}, false}, - {"blank model", map[string]any{"model": " "}, false}, - {"null model", map[string]any{"model": nil}, false}, - {"non string model", map[string]any{"model": 42}, false}, - {"null provider", map[string]any{"model": "fixture", "model_provider": nil}, false}, - {"null native", map[string]any{"harness_config": nil}, false}, - {"array native", map[string]any{"harness_config": []any{}}, false}, - {"undeclared native", map[string]any{"harness_config": map[string]any{"effort": "high"}}, false}, + {"native owned", "", nil, true}, + {"native model", "fixture", nil, true}, + {"explicit", "fixture", map[string]any{"model_provider": provider()}, true}, + {"missing model", "", map[string]any{"model_provider": provider()}, false}, + {"blank model", " ", nil, false}, + {"null provider", "fixture", map[string]any{"model_provider": nil}, false}, + {"null native", "", map[string]any{"harness_config": nil}, false}, + {"array native", "", map[string]any{"harness_config": []any{}}, false}, + {"undeclared native", "", map[string]any{"harness_config": map[string]any{"effort": "high"}}, false}, } { t.Run(tc.name, func(t *testing.T) { - got, err := c.Prepare(tc.options) + got, err := c.Prepare(tc.model, tc.options) if (err == nil) != tc.valid { t.Fatalf("valid=%v err=%v", tc.valid, err) } @@ -42,17 +40,17 @@ func TestPrepareModelConfiguration(t *testing.T) { } }) } - got, err := c.Prepare(map[string]any{"model": "fixture", "model_provider": provider()}) + got, err := c.Prepare("fixture", map[string]any{"model_provider": provider()}) if err != nil { t.Fatal(err) } if got.Model != "fixture" { t.Fatal("prepared model changed") } - if _, err := (Configuration{}).Prepare(map[string]any{"model": "fixture", "model_provider": provider()}); err == nil { + if _, err := (Configuration{}).Prepare("fixture", map[string]any{"model_provider": provider()}); err == nil { t.Fatal("empty declaration inferred provider support") } - if _, err := c.Prepare(map[string]any{"model_provider": provider()}); !errors.Is(err, ErrModel) { + if _, err := c.Prepare("", map[string]any{"model_provider": provider()}); !errors.Is(err, ErrModel) { t.Fatal("model error was not shared") } } diff --git a/services/core/internal/execution/controls.go b/services/core/internal/execution/controls.go index b9ebf371a..d632b7b34 100644 --- a/services/core/internal/execution/controls.go +++ b/services/core/internal/execution/controls.go @@ -15,17 +15,24 @@ func executionControls(agent v1.Agent, tools executionToolSet) *proto.ExecutionC 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 } + +// turnSettings assembles the root Turn's settings from the Session snapshot. +// Auto service tier means unset. +func turnSettings(agent v1.Agent) proto.TurnSettings { + turn := proto.TurnSettings{Model: agent.Model} + if agent.Reasoning.Effort != nil { + turn.ReasoningEffort = *agent.Reasoning.Effort + } + if agent.ServiceTier != "auto" { + turn.ServiceTier = agent.ServiceTier + } + return turn +} diff --git a/services/core/internal/execution/controls_test.go b/services/core/internal/execution/controls_test.go index bbdcf0e15..40a00c17d 100644 --- a/services/core/internal/execution/controls_test.go +++ b/services/core/internal/execution/controls_test.go @@ -70,7 +70,7 @@ func TestExecutionControlsCarryOnlyOfficialValues(t *testing.T) { {"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"}}, + proto.ExecutionControls{TextVerbosity: "low", 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}`)}}, @@ -96,3 +96,15 @@ func TestExecutionControlsCarryOnlyOfficialValues(t *testing.T) { }) } } + +func TestTurnSettingsComeFromTheSnapshot(t *testing.T) { + effort := "max" + snapshot := Snapshot{Agent: v1.Agent{Model: "m", ServiceTier: "flex", Reasoning: v1.Reasoning{Effort: &effort}}} + request, err := (&Dispatcher{}).executionRequest(t.Context(), sessions.Session{ID: "session"}, snapshot, proto.AgentKindCapabilities{}, sessions.ExecutionBinding{}) + if err != nil || request.Turn != (proto.TurnSettings{Model: "m", ReasoningEffort: "max", ServiceTier: "flex"}) || request.AgentOptions["model"] != nil { + t.Fatalf("%+v %v", request.Turn, err) + } + if turnSettings(v1.Agent{Model: "m", ServiceTier: "auto"}) != (proto.TurnSettings{Model: "m"}) { + t.Fatal("auto service tier was sent") + } +} diff --git a/services/core/internal/execution/harness_admission.go b/services/core/internal/execution/harness_admission.go index 64012acf1..f3341e548 100644 --- a/services/core/internal/execution/harness_admission.go +++ b/services/core/internal/execution/harness_admission.go @@ -63,6 +63,9 @@ func validateDeclaredConfiguration(declaration harnessconfig.Declaration, snapsh if err := declaration.ValidateExecutionControls(executionControls(snapshot.Agent, tools)); err != nil { return err } + if err := declaration.ValidateTurnSettings(turnSettings(snapshot.Agent)); err != nil { + return err + } request := proto.PromptRequestPayload{ToolSearch: tools.Search, FunctionTools: tools.Functions} if err := request.ValidateToolSearch(declaration.Capabilities.ToolSearch.IsSupported()); err != nil { return err diff --git a/services/core/internal/execution/request.go b/services/core/internal/execution/request.go index 2cf366442..0762491af 100644 --- a/services/core/internal/execution/request.go +++ b/services/core/internal/execution/request.go @@ -26,7 +26,7 @@ func (d *Dispatcher) executionRequest(ctx context.Context, session sessions.Sess return proto.PromptRequestPayload{}, err } } - options["model"], options["system_prompt"] = snapshot.Agent.Model, snapshot.Agent.Instructions + options["system_prompt"] = snapshot.Agent.Instructions if snapshot.Agent.XAgentsCore != nil && len(snapshot.Agent.XAgentsCore.HarnessConfig) > 0 { var native map[string]any if err := json.Unmarshal(snapshot.Agent.XAgentsCore.HarnessConfig, &native); err != nil { @@ -35,7 +35,7 @@ func (d *Dispatcher) executionRequest(ctx context.Context, session sessions.Sess options["harness_config"] = native } request := proto.PromptRequestPayload{AgentKind: session.Engine, FunctionTools: tools.Functions, ToolSearch: tools.Search, - AgentOptions: options, ExecutionControls: executionControls(snapshot.Agent, tools), AgentStateKey: session.ID, + Turn: turnSettings(snapshot.Agent), AgentOptions: options, ExecutionControls: executionControls(snapshot.Agent, tools), AgentStateKey: session.ID, AgentSessionID: bound.NativeSessionID, RequireExistingNativeSession: recoverNativeSession, ObserveSubagentIdentities: snapshot.Agent.MultiAgent.Enabled, MaxConcurrentSubagents: snapshot.Agent.MultiAgent.MaxConcurrentSubagents, diff --git a/services/core/tests/integration/dispatch_test.go b/services/core/tests/integration/dispatch_test.go index 2e5ac4dae..f032611e0 100644 --- a/services/core/tests/integration/dispatch_test.go +++ b/services/core/tests/integration/dispatch_test.go @@ -231,7 +231,7 @@ func TestExecutionDispatchSteeringAndNativeContinuity(t *testing.T) { request := h.read(testExecutionRequest) var prompt proto.PromptRequestPayload _ = request.DecodePayload(&prompt) - if inputTextForTest(t, prompt.Input) != "Initial input" || prompt.AgentStateKey != h.session.ID || prompt.AgentOptions["model"] != "test-model" || prompt.AgentOptions["system_prompt"] != "Keep this instruction." { + if inputTextForTest(t, prompt.Input) != "Initial input" || prompt.AgentStateKey != h.session.ID || prompt.Turn.Model != "test-model" || prompt.AgentOptions["system_prompt"] != "Keep this instruction." { t.Fatalf("wrong resolved request: %+v", prompt) } if _, err := h.bound().Run(ctx, uuid.NewString(), h.session.ID, first.TurnID); !errors.Is(err, sessions.ErrNotFound) { From cb0c642ef462b47b7e65a1fffa3d25325dd0d9f9 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 5 Nov 2026 04:24:48 +0000 Subject: [PATCH 2/3] Keep Turn settings in the Executor identity until a Harness applies them A retained Executor that accepted a different turn would have run the prepared settings silently. Review support has no sender yet, so it waits for the change that sends it. --- .../agent/codex/execution_controls_test.go | 2 +- .../agent/codex/harness_config_test.go | 1 - apps/daemon/internal/dispatch/executor.go | 1 - .../daemon/internal/dispatch/executor_test.go | 36 ++----------------- apps/daemon/internal/dispatch/preparation.go | 2 +- contracts/agents-api/execution-tools.md | 2 +- contracts/agents-api/harness-onboarding.md | 8 ++--- contracts/agents-api/zh/execution-tools.md | 4 +-- contracts/agents-api/zh/harness-onboarding.md | 10 +++--- docs/runtime-protocol.md | 6 ++-- docs/zh/runtime-protocol.md | 8 ++--- internal/agentdaemon/proto/outbound.go | 6 +--- internal/harnessconfig/harness.go | 24 +++++-------- internal/harnessconfig/preparation_test.go | 6 +--- 14 files changed, 34 insertions(+), 82 deletions(-) diff --git a/apps/daemon/internal/agent/codex/execution_controls_test.go b/apps/daemon/internal/agent/codex/execution_controls_test.go index bd861172f..eee968aea 100644 --- a/apps/daemon/internal/agent/codex/execution_controls_test.go +++ b/apps/daemon/internal/agent/codex/execution_controls_test.go @@ -74,7 +74,7 @@ func TestTurnSettingsReachNativeConfigAndRejectUndeclaredValues(t *testing.T) { if plan.Model != "m" || !slices.Contains(plan.ExtraConfig, [2]string{"model_reasoning_effort", `"max"`}) || !slices.Contains(plan.ExtraConfig, [2]string{"service_tier", `"priority"`}) { t.Fatalf("turn settings not applied: %v", plan.ExtraConfig) } - for _, turn := range []proto.TurnSettings{{ReasoningEffort: "ultra"}, {ServiceTier: "scale"}, {Review: "auto_review"}} { + for _, turn := range []proto.TurnSettings{{ReasoningEffort: "ultra"}, {ServiceTier: "scale"}} { if plan, err := BuildSessionPlan(testStateRoot(t), "state", nil, turn, nil); err == nil { plan.Cleanup() t.Fatal("undeclared turn setting accepted", turn) diff --git a/apps/daemon/internal/agent/codex/harness_config_test.go b/apps/daemon/internal/agent/codex/harness_config_test.go index 93526155f..df345f9f9 100644 --- a/apps/daemon/internal/agent/codex/harness_config_test.go +++ b/apps/daemon/internal/agent/codex/harness_config_test.go @@ -61,7 +61,6 @@ func TestNativeTurnsCarryNoCollaborationMode(t *testing.T) { t.Error(err) } }) - req.Turn.ReasoningEffort = "low" for _, id := range []string{"one", "two"} { out := make(chan proto.Envelope, 20) turn, err := e.StartTurn(t.Context(), id, proto.TurnSettings{}, proto.TextInput("answer"), out) diff --git a/apps/daemon/internal/dispatch/executor.go b/apps/daemon/internal/dispatch/executor.go index 599d7ef7b..79f436802 100644 --- a/apps/daemon/internal/dispatch/executor.go +++ b/apps/daemon/internal/dispatch/executor.go @@ -38,7 +38,6 @@ func executorFingerprint(req proto.PromptRequestPayload) ([32]byte, error) { req.RunID, req.Input = "", nil req.AgentSessionID = "" req.RequireExistingNativeSession = false - req.Turn = proto.TurnSettings{} data, err := json.Marshal(req) return sha256.Sum256(data), err } diff --git a/apps/daemon/internal/dispatch/executor_test.go b/apps/daemon/internal/dispatch/executor_test.go index 81d87a1b5..4220c4612 100644 --- a/apps/daemon/internal/dispatch/executor_test.go +++ b/apps/daemon/internal/dispatch/executor_test.go @@ -23,7 +23,6 @@ type reusableExecutor struct { mu sync.Mutex closeErr error startErr error - turns chan proto.TurnSettings } func (e *reusableExecutor) Close(context.Context) error { @@ -32,10 +31,7 @@ func (e *reusableExecutor) Close(context.Context) error { defer e.mu.Unlock() return e.closeErr } -func (e *reusableExecutor) StartTurn(_ context.Context, id string, turn proto.TurnSettings, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { - if e.turns != nil { - e.turns <- turn - } +func (e *reusableExecutor) StartTurn(_ context.Context, id string, _ proto.TurnSettings, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { if e.startErr != nil { return nil, e.startErr } @@ -176,32 +172,6 @@ func TestExecutorCancelSettlesTurnWithoutClosingOwner(t *testing.T) { t.Fatalf("cancel=%+v closes=%d", ack, owner.closes.Load()) } } -func TestExecutorTakesEachTurnsSettingsWithoutChangingIdentity(t *testing.T) { - owner := &reusableExecutor{starts: make(chan *reusableTurn, 2), turns: make(chan proto.TurnSettings, 2)} - r, s, calls := executorRouter(t, owner, time.Minute) - for i, turn := range []proto.TurnSettings{{Model: "one", ReasoningEffort: "low"}, {Model: "two", ServiceTier: "flex"}} { - key := fmt.Sprint("turn-", i) - req := executorRequest() - req.Configuration.Turn = turn - if i == 1 { - req.Configuration.AgentSessionID = "native-session" - } - p := executorAdmission(t, r, s, key, req) - if p.Reused != (i == 1) { - t.Fatalf("Turn %d: reused=%v", i, p.Reused) - } - startExecutorTurn(t, r, s, key, key, p) - if got := <-owner.turns; got != turn { - t.Fatalf("StartTurn got %+v, want %+v", got, turn) - } - (<-owner.starts).finish() - waitFor(t, func() bool { return r.ActiveRuns() == 0 }, "Turn settlement") - } - if calls.Load() != 1 || owner.closes.Load() != 0 { - t.Fatal("a Turn setting change replaced the Executor") - } -} - func TestExecutorRejectsConfigurationAndNativeIdentityChanges(t *testing.T) { owner := &reusableExecutor{starts: make(chan *reusableTurn, 1)} r, s, calls := executorRouter(t, owner, time.Minute) @@ -212,7 +182,7 @@ func TestExecutorRejectsConfigurationAndNativeIdentityChanges(t *testing.T) { for _, kind := range []string{"configuration", "native"} { req := executorRequest() if kind == "configuration" { - req.Configuration.AgentOptions = map[string]any{"system_prompt": "changed"} + req.Configuration.Turn.Model = "changed" } else { req.Configuration.AgentSessionID = "other-native" } @@ -410,7 +380,7 @@ func TestExecutorResumesSameOwnerAndNextTurnAfterSuspension(t *testing.T) { req := preparationRequest() req.Configuration.AgentSessionID = "native-session" conflict := req - conflict.Configuration.AgentOptions = map[string]any{"system_prompt": "changed"} + conflict.Configuration.Turn.Model = "changed" if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "conflict", conflict)); err == nil { t.Fatal("suspension relaxed the fixed configuration") } diff --git a/apps/daemon/internal/dispatch/preparation.go b/apps/daemon/internal/dispatch/preparation.go index 0ad980196..08bbee734 100644 --- a/apps/daemon/internal/dispatch/preparation.go +++ b/apps/daemon/internal/dispatch/preparation.go @@ -102,7 +102,7 @@ func (r *Router) handleExecutionPrepare(ctx context.Context, env proto.Envelope) return r.rejectPreparation(env, "preparation_capacity") } owner, cancel := context.WithCancel(context.WithoutCancel(ctx)) - p := &preparationState{capabilities: caps, requestID: env.ID, trace: env.Trace, fingerprint: fingerprint, turn: req.Turn, ctx: owner, cancel: cancel, environmentID: req.EnvironmentID(), busy: true, owns: true, deadline: time.Now().Add(r.preparationTimeout)} + p := &preparationState{capabilities: caps, requestID: env.ID, trace: env.Trace, fingerprint: fingerprint, ctx: owner, cancel: cancel, environmentID: req.EnvironmentID(), busy: true, owns: true, deadline: time.Now().Add(r.preparationTimeout)} p.status = proto.PreparationStatusPayload{Handle: uuid.NewString(), Revision: 1, State: "preparing", ExpiresAt: p.deadline.UnixMilli()} r.preparations[p.status.Handle], r.preparationRequests[p.requestID] = p, p p.timer = time.AfterFunc(r.preparationTimeout, func() { r.releasePreparation(p, "expired", "", true) }) diff --git a/contracts/agents-api/execution-tools.md b/contracts/agents-api/execution-tools.md index 7a074a728..3b28da7f1 100644 --- a/contracts/agents-api/execution-tools.md +++ b/contracts/agents-api/execution-tools.md @@ -69,7 +69,7 @@ Saved Agents keep every pinned `web_search` mode: omitted or null is saved as `l 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. Claude Code on a workspace placement also keeps `WebSearch` and `WebFetch` when the control is omitted; [Turns and Items](./sessions-events.md#turns-and-items) gives their `web_search_call` actions. -`ExecutionControls` carries the options ([Runtime protocol](../../docs/runtime-protocol.md#capability-declarations)). The Runtime needs only the `execution_controls` capability. +`ExecutionControls` carries the options, except reasoning effort and service tier, which travel in the Turn settings ([Runtime protocol](../../docs/runtime-protocol.md#capability-declarations)). The Runtime needs only the `execution_controls` capability. | Harness | Web search | Programmatic tool calling | | --- | --- | --- | diff --git a/contracts/agents-api/harness-onboarding.md b/contracts/agents-api/harness-onboarding.md index 9540b75f5..fdac84fed 100644 --- a/contracts/agents-api/harness-onboarding.md +++ b/contracts/agents-api/harness-onboarding.md @@ -96,7 +96,7 @@ The declaration admits public combinations and the Runtime advertises the instal | Session Executor | Runtime | `Executor.Close` on idle expiry, shutdown or confirmed invalidation | | Turn | Adapter `Turn`, tracked by the Runtime | `AwaitSettlement` confirms settlement | -A Session owns one reusable Executor in its connected Runtime; a Turn owns one input execution, its output stream and its cancellation. `agent.ExecutorFactory` prepares the fixed configuration without model input, and `Executor.StartTurn(ctx, runID, turn, input, out)` receives the root Turn's `proto.TurnSettings` and creates a new `agent.Turn` without replacing healthy native resources. Normal completion settles only the Turn. `Executor.Close` releases native resources on idle expiry, Environment shutdown or confirmed invalidation; it releases neither the Environment allocation nor the workspace. Core keeps no second Executor cache. The same lifecycle applies to hosted, self-hosted and `none` placements. +A Session owns one reusable Executor in its connected Runtime; a Turn owns one input execution, its output stream and its cancellation. `agent.ExecutorFactory` prepares the fixed configuration without model input, and `Executor.StartTurn` creates a new `agent.Turn` from the input and the Turn's `proto.TurnSettings`, which equal the prepared ones, without replacing healthy native resources. Normal completion settles only the Turn. `Executor.Close` releases native resources on idle expiry, Environment shutdown or confirmed invalidation; it releases neither the Environment allocation nor the workspace. Core keeps no second Executor cache. The same lifecycle applies to hosted, self-hosted and `none` placements. An Executor moves through these states. Each Turn runs between `StartTurn` and `AwaitSettlement`. @@ -112,7 +112,7 @@ stateDiagram-v2 Closed --> [*] ``` -**Binding.** The Runtime binds its Executor record to the Session, Environment, connection and immutable execution configuration. Resume identity and prior-Turn recovery flags are continuity assertions, not configuration changes. A supplied native identity must match the retained owner, and when existing history is required, recovery never starts a new root. The root Turn's `turn` settings (model, reasoning effort, service tier, review) are not part of that binding; they reach `StartTurn` with each Turn, and the factory sees the first Turn's values for native startup. Every other configuration conflict is an error, not a hot switch. An ordinary lost connection retires its owners and handles; [planned suspension](../../docs/runtime-protocol.md#preparation-and-execution-order) retains settled idle owners through exact authenticated resume. Old timers, output and cancellation cannot affect replacement owners. +**Binding.** The Runtime binds its Executor record to the Session, Environment, connection and immutable execution configuration. Resume identity and prior-Turn recovery flags are continuity assertions, not configuration changes. A supplied native identity must match the retained owner, and when existing history is required, recovery never starts a new root. A configuration conflict is an error, not a hot switch. An ordinary lost connection retires its owners and handles; [planned suspension](../../docs/runtime-protocol.md#preparation-and-execution-order) retains settled idle owners through exact authenticated resume. Old timers, output and cancellation cannot affect replacement owners. **Per-Turn state.** Each Turn gets a fresh wrapper, output channel and receipt state. Steering and function interfaces belong to that Turn. Native callbacks capture the originating Turn before asynchronous work, so a late event is never attributed to whichever Turn is active. Native processes, query or transport connections, fixed capability configuration and native session identity belong to the Executor. Do not reset completed `sync.Once` values or reuse an old Turn object. @@ -184,7 +184,7 @@ The declaration holds: - the model `Configuration` ([native model configuration](#native-model-configuration)); - `Capabilities`, a `proto.AgentKindCapabilities` that states the most the Harness can do on a capable host, with every field `Supported` or `Unsupported` and the zero value invalid ([Capability declarations](../../docs/runtime-protocol.md#capability-declarations)); - the supported `Placements` and public `MCPOrigins`, whether whitespace-only text is accepted, and `MaxInstructionsBytes`, the native byte limit on an Agent's instructions (zero declares none), which admission rejects above; -- `Execution`, the official execution option values the adapter maps to native settings: web search modes and whether search settings are accepted, reasoning efforts and summaries, service tiers, review values other than `none`, programmatic-tool-calling values and text verbosities. Core admission and the Runtime reject an undeclared value with the same check ([tool policy](./execution-tools.md#web-search-and-programmatic-tool-calling)). Core always sends the official default text verbosity, so `TextVerbosities` must include `medium`; +- `Execution`, the official execution option values the adapter maps to native settings: web search modes and whether search settings are accepted, reasoning efforts and summaries, service tiers, programmatic-tool-calling values and text verbosities. Core admission and the Runtime reject an undeclared value with the same check ([tool policy](./execution-tools.md#web-search-and-programmatic-tool-calling)). Core always sends the official default text verbosity, so `TextVerbosities` must include `medium`; - optional pure restrictions, `ValidateConfiguration`, `ValidateTools`, `ValidateMessageInput` and `ValidateFunctionResult`, that add native limits to the common checks. `ValidateMessageInput` runs only for messages that carry images, after the `MessageImages` capability check and before anything is stored, and decides which `image_url` values the Harness accepts; Core checks only that the reference is not blank. A nil validator means the common checks suffice. They cannot query business data, decrypt credentials or control native processes. Configuration restrictions run first, and a tool-decoding error precedes `ValidateTools`. `harnessconfig.NewRegistry` validates every declaration and panics on an invalid or duplicate one: kinds and labels must be nonempty, placements must list at least one supported placement, MCP origins must be a non-nil list, unknown or duplicate choices and origins without a corresponding placement are rejected, bearer support requires an MCP origin, and `Execution` must declare text verbosity `medium`. Shared dispatch checks capability combinations, never engine names. @@ -199,7 +199,7 @@ Run the `harnessconfig` and `execution` tests for validation, combination and er [`internal/harnessconfig/harness.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/harnessconfig/harness.go) owns the shared configuration declaration and pure preparation contract. Each declaration carries one `Configuration`, which Core's composition and the Runtime's `RegisterKind` both use. The Executor path validates through that declaration before native side effects. The wire object is `proto.HarnessConfig`. [Model execution](./model-execution.md#native-model-parameters) lists each Harness's accepted fields. -A supplied `model` must be a nonempty string, and an explicit `model_provider` requires it. The native-owned connection path may omit both; explicit null is invalid. An explicitly empty declaration accepts no provider or nonempty native parameters and advertises no provider support. Unknown protocol formats and duplicate protocol declarations fail at registration. +`turn.model` must not be blank, and an explicit `model_provider` requires it. The native-owned connection path may leave both out. An explicitly empty declaration accepts no provider or nonempty native parameters and advertises no provider support. Unknown protocol formats and duplicate protocol declarations fail at registration. The declaration's ordered `protocols` list is the only source of accepted protocols and the default (the first entry); it also feeds Core's configuration-support descriptor, and Core and Runtime reject unsupported combinations through it. Adapters connect directly through native configuration; they never introduce a model API proxy or protocol converter, a second model capability registry, or capabilities inferred from model names. Claude's private bridge receives compiled native options and performs structural checks only, not a second copy of the declaration's rules. diff --git a/contracts/agents-api/zh/execution-tools.md b/contracts/agents-api/zh/execution-tools.md index df431e5a0..bd32c1e05 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: 7783010b0968214d235d349fd60d1ee6096f0ab9d33d58397d90bb0fd935cded +source_hash: 5d24e4ec54240ace34300b551ddf0ecf14fb8cf0fa0aef227e0068643a6728ff --- Agent 在 `tools` 中声明应用函数、控制项和 MCP 服务器,并可在 `text.format` 中声明输出 schema。本契约说明 Core 如何验证声明、哪些内容跨越 Runtime 边界,以及调用方如何恢复待执行操作。[Harness 能力](harness-capabilities.md)列出各 Harness 在不同部署位置支持的操作。原生工作区工具和 Environment Plugin MCP 属于 [Environment](environments.md#skills-plugins-and-environment-mcp)。 @@ -71,7 +71,7 @@ Core 发送 `PromptRequestPayload.ToolSearch` 和每个 `FunctionTool.DeferLoadi 执行把解析后的值交给 Harness,并按 `internal/harnessconfig` 中的声明(`Execution`)校验:支持的 Web 搜索模式以及是否接受搜索设置、reasoning effort 与 summary、service tier、程序化工具调用的取值和 text verbosity。声明之外的值会在 Session 准入时被拒绝,消息会指出字段,除非 Session 替换了保存的工具。Core 仅在 Agent 设置了选项或适用固定版本的默认值时才发送该选项,因此省略 `web_search` 或 `programmatic_tool_calling` 会保留各 Harness 的原生行为。对程序化调用而言,这与官方默认启用的行为不同。无关的原生实用工具不会被移除。省略该控制项时,工作区位置上的 Claude Code 也保留 `WebSearch` 和 `WebFetch`;其 `web_search_call` 操作见 [Turn 与 Item](./sessions-events.md#turns-and-items)。 -`ExecutionControls` 携带这些选项([Runtime 协议](../../../docs/zh/runtime-protocol.md#capability-declarations))。Runtime 只需要 `execution_controls` 能力。 +`ExecutionControls` 携带这些选项,推理强度和服务层级除外,它们随 Turn 设置传递([Runtime 协议](../../../docs/zh/runtime-protocol.md#capability-declarations))。Runtime 只需要 `execution_controls` 能力。 | Harness | Web 搜索 | 程序化工具调用 | | --- | --- | --- | diff --git a/contracts/agents-api/zh/harness-onboarding.md b/contracts/agents-api/zh/harness-onboarding.md index fcd544ae0..4beb553e3 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: 936790abf3ff052067f0f8736353e1cea4652029e8eb832fc877a3968b9c82d5 +source_hash: 0d3cfce2ec6a1ff9714c021b8ba154c1a955efbdc7f1a77b6d16616a6b16ab1e --- **Harness** 是一种运行模型和工具循环的原生代理引擎(Codex、Claude Code、MiniMax Code);**Harness 适配器**将 Runtime 的 Executor 和 Turn 契约转换到该引擎的 SDK 或协议。Harness 协议是 Runtime 与适配器之间的契约:适配器接口及其生命周期义务、声明、注册和验收。本文面向添加或维护 Harness 的开发者。 @@ -98,7 +98,7 @@ func (s *Session) SubmitFunctionResult(context.Context, proto.FunctionResultPayl | Session Executor | Runtime | 空闲过期、关闭或确认失效时执行 `Executor.Close` | | Turn | 由 Runtime 跟踪的适配器 `Turn` | `AwaitSettlement` 确认结算完成 | -Session 在其已连接的 Runtime 中拥有一个可复用的 Executor;Turn 拥有一次输入执行、其输出流和其取消操作。`agent.ExecutorFactory` 在没有模型输入的情况下准备固定配置,而 `Executor.StartTurn(ctx, runID, turn, input, out)` 接收根 Turn 的 `proto.TurnSettings` 并创建新的 `agent.Turn`,不替换健康的原生资源。正常完成仅结算 Turn。`Executor.Close` 在空闲过期、Environment 关闭或确认失效时释放原生资源;它既不释放 Environment 分配,也不释放工作区。Core 不保留第二套 Executor 缓存。相同的生命周期适用于托管、自托管和 `none` 放置方式。 +Session 在其已连接的 Runtime 中拥有一个可复用的 Executor;Turn 拥有一次输入执行、其输出流和其取消操作。`agent.ExecutorFactory` 在没有模型输入的情况下准备固定配置,而 `Executor.StartTurn` 依据输入和该 Turn 的 `proto.TurnSettings`(与准备时的取值相同)创建新的 `agent.Turn`,不替换健康的原生资源。正常完成仅结算 Turn。`Executor.Close` 在空闲过期、Environment 关闭或确认失效时释放原生资源;它既不释放 Environment 分配,也不释放工作区。Core 不保留第二套 Executor 缓存。相同的生命周期适用于托管、自托管和 `none` 放置方式。 Executor 按下列状态转移。每个 Turn 在 `StartTurn` 与 `AwaitSettlement` 之间运行。 @@ -114,7 +114,7 @@ stateDiagram-v2 Closed --> [*] ``` -**绑定。** Runtime 将其 Executor 记录绑定到 Session、Environment、连接和不可变执行配置。恢复身份和先前 Turn 恢复标志是连续性断言,而不是配置更改。提供的原生身份必须与保留的所有者匹配;当需要现有历史时,恢复绝不能启动新的根。根 Turn 的 `turn` 设置(模型、推理强度、服务层级、review)不属于该绑定;它们随每个 Turn 传给 `StartTurn`,工厂看到的是第一个 Turn 的取值,用于原生启动。其他任何配置冲突都属于错误,而不是热切换。普通连接丢失会让其所有者和句柄退役;[计划性挂起](../../../docs/zh/runtime-protocol.md#preparation-and-execution-order) 则保留已结算的空闲所有者,直至精确匹配且已认证的恢复完成。旧计时器、输出和取消操作不能影响替代所有者。 +**绑定。** Runtime 将其 Executor 记录绑定到 Session、Environment、连接和不可变执行配置。恢复身份和先前 Turn 恢复标志是连续性断言,而不是配置更改。提供的原生身份必须与保留的所有者匹配;当需要现有历史时,恢复绝不能启动新的根。配置冲突属于错误,而不是热切换。普通连接丢失会让其所有者和句柄退役;[计划性挂起](../../../docs/zh/runtime-protocol.md#preparation-and-execution-order) 则保留已结算的空闲所有者,直至精确匹配且已认证的恢复完成。旧计时器、输出和取消操作不能影响替代所有者。 **每 Turn 状态。** 每个 Turn 都会获得全新的包装器、输出通道和回执状态。引导和函数接口均属于该 Turn。原生回调必须在异步工作开始前捕获来源 Turn,因此迟到事件绝不会被归到当前活动的 Turn 上。原生进程、query 或传输连接、固定能力配置和原生 session 身份均属于 Executor。不要重置已完成的 `sync.Once` 值,也不要复用旧 Turn 对象。 @@ -186,7 +186,7 @@ Runtime 统一解析[状态目录](../../../docs/zh/configuration.md#runtime-res - 模型 `Configuration`([原生模型配置](#native-model-configuration)); - `Capabilities`,即 `proto.AgentKindCapabilities`,表示 Harness 在能力完备的主机上最多能做什么;每个字段都是 `Supported` 或 `Unsupported`,零值无效([能力声明](../../../docs/zh/runtime-protocol.md#capability-declarations)); - 受支持的 `Placements` 和公共 `MCPOrigins`、是否接受仅含空白的文本,以及 `MaxInstructionsBytes`(原生对 Agent instructions 的字节上限,零表示没有上限,超出时准入会拒绝); -- `Execution`,即适配器映射到原生设置的官方执行选项取值:web search 模式及是否接受搜索设置、推理强度和摘要、服务层级、`none` 以外的 review 取值、程序化工具调用取值和文本详细程度。Core 准入和 Runtime 用同一检查拒绝未声明的取值([工具策略](execution-tools.md#web-search-and-programmatic-tool-calling))。Core 总会发送官方默认文本详细程度,因此 `TextVerbosities` 必须包含 `medium`; +- `Execution`,即适配器映射到原生设置的官方执行选项取值:web search 模式及是否接受搜索设置、推理强度和摘要、服务层级、程序化工具调用取值和文本详细程度。Core 准入和 Runtime 用同一检查拒绝未声明的取值([工具策略](execution-tools.md#web-search-and-programmatic-tool-calling))。Core 总会发送官方默认文本详细程度,因此 `TextVerbosities` 必须包含 `medium`; - 可选的纯限制 `ValidateConfiguration`、`ValidateTools`、`ValidateMessageInput` 和 `ValidateFunctionResult`,在通用检查之外添加原生限制。`ValidateMessageInput` 仅对携带图像的消息运行,在 `MessageImages` 能力检查之后、任何存储之前执行,决定 Harness 接受哪些 `image_url`;Core 只检查引用不为空白。验证函数为 nil 表示通用检查已足够。它们不能查询业务数据、解密凭据或控制原生进程。配置限制最先执行,工具解码错误先于 `ValidateTools`。 `harnessconfig.NewRegistry` 会验证每个声明,对无效或重复的声明触发 panic:kind 和 label 必须非空,放置位置必须至少列出一个受支持的位置,MCP 来源必须是非 nil 列表,未知或重复的选项以及没有对应放置位置的来源会被拒绝,bearer 支持要求存在 MCP 来源,`Execution` 必须声明文本详细程度 `medium`。共享调度检查能力组合,绝不检查引擎名称。 @@ -201,7 +201,7 @@ Runtime 统一解析[状态目录](../../../docs/zh/configuration.md#runtime-res [`internal/harnessconfig/harness.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/harnessconfig/harness.go) 负责共享配置声明和纯准备契约。每个声明携带一个 `Configuration`,Core 组合和 Runtime 的 `RegisterKind` 都使用它。Executor 路径会在产生原生副作用之前通过该声明进行验证。线协议对象是 `proto.HarnessConfig`。[Model execution](model-execution.md#native-model-parameters) 列出了每个 Harness 接受的字段。 -提供的 `model` 必须是非空字符串,并且显式指定 `model_provider` 时必须提供它。原生所有权连接路径可以省略二者;显式 null 无效。显式为空的声明不接受任何提供商或非空原生参数,也不宣称支持提供商。未知协议格式和重复协议声明会导致注册失败。 +`turn.model` 不能为空白,并且显式指定 `model_provider` 时必须提供它。原生所有权连接路径可以二者都不提供。显式为空的声明不接受任何提供商或非空原生参数,也不宣称支持提供商。未知协议格式和重复协议声明会导致注册失败。 声明中的有序 `protocols` 列表是接受协议及默认协议(第一个条目)的唯一来源;它还为 Core 的配置支持描述符提供数据,Core 和 Runtime 通过它拒绝不受支持的组合。适配器通过原生配置直接连接;它们绝不引入模型 API 代理或协议转换器、第二套模型能力 registry,也不会从模型名称推断能力。Claude 的私有 bridge 接收编译后的原生选项,并且只执行结构检查,而不是声明规则的第二份副本。 diff --git a/docs/runtime-protocol.md b/docs/runtime-protocol.md index 17abdba0b..0a30d9411 100644 --- a/docs/runtime-protocol.md +++ b/docs/runtime-protocol.md @@ -91,7 +91,7 @@ Every data frame is one JSON [`Envelope`](https://github.com/MiniMax-AI/OpenAgen | --- | --- | | Device ID and connection | Authenticated Runtime routing and connection ownership | | Session ID / Environment ID | Core-owned configuration and workspace binding; canonical UUIDs where the payload validator requires them | -| Executor ID | Runtime-owned native resource, possibly retained across settled Turns with identical configuration; the root Turn's `turn` settings are not part of the identity | +| Executor ID | Runtime-owned native resource, possibly retained across settled Turns with identical configuration | | Preparation request ID | `Envelope.id` for prepare, start, release and status; distinct from a Run | | Admission handle | Runtime-generated reservation, valid only on the connection that accepted it | | Run ID | One execution attempt; `Envelope.id` for output, cancellation, active input and functions | @@ -189,7 +189,7 @@ sequenceDiagram - Consume Run events until a native terminal outcome or loss of observation. An execution error is followed by `done`, which closes the stream; the preceding errors remain part of its outcome. - To abandon before start, send `execution_release`. After ownership passes to a Run, use `prompt_cancel`; releasing the old handle cannot cancel its successor. -A preparation reserves a per-Turn admission, not a new Executor. It carries an explicit Session identity, immutable configuration and the root Turn's settings (`turn`) without model input or a Run ID. A fresh request returns a connection-local handle and the owning Executor ID; a reused healthy Executor returns `ready` without native preparation. Per-handle revisions order status observations: ignore older or repeated revisions and never apply a status to another handle. An admission moves through the states below. A `rejected` control operation carries an `operation` and error code and does not replace the handle's current revision. Releasing an admission abandons only that admission; it does not close the Session's idle Executor or cancel a later Turn. +A preparation reserves a per-Turn admission, not a new Executor. It carries an explicit Session identity and immutable configuration without model input or a Run ID. A fresh request returns a connection-local handle and the owning Executor ID; a reused healthy Executor returns `ready` without native preparation. Per-handle revisions order status observations: ignore older or repeated revisions and never apply a status to another handle. An admission moves through the states below. A `rejected` control operation carries an `operation` and error code and does not replace the handle's current revision. Releasing an admission abandons only that admission; it does not close the Session's idle Executor or cancel a later Turn. ```mermaid stateDiagram-v2 @@ -220,7 +220,7 @@ The `execution_prepare` configuration carries the opt-ins Core sets for each Run | Field | Set by Core | | --- | --- | -| `turn` | Always: the root Turn's `model`, plus `reasoning_effort` and a non-`auto` `service_tier` when the Agent sets them. `review` (`auto_review`; empty is none) is part of the same object. A retained Executor accepts a different `turn` without a `configuration_conflict`; the Runtime passes each Turn's settings to `StartTurn`, and a settled Executor's next Turn takes the settings of its own `execution_prepare`. Read-only workspace preparation sends an empty `turn` | +| `turn` | Always: the root Turn's `model`, plus `reasoning_effort` and a non-`auto` `service_tier` when the Agent sets them. Read-only workspace preparation sends an empty `turn` | | `agent_options` | Always `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: 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 summary or a `json_schema` output format. Core validates these and the `turn` settings against the Harness declaration before dispatch; native option names belong to the adapter | | `observe_subagent_identities`, `disable_subagents` | From the Agent's `multi_agent.enabled` | diff --git a/docs/zh/runtime-protocol.md b/docs/zh/runtime-protocol.md index c58cd563a..0e1e26dec 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: 368945a521422e6ffbe03e1ff81a1e9fd54b935ca50c042f9afe740bdfb4643b +source_hash: 1154cf99b5c9d5f2d3d1079b074df946946f2b490a617eac0074cb666ff0385b --- Core–Runtime 协议连接 Core 与 Runtime daemon,定义 daemon 连接上消息的含义和顺序。托管和自托管 Runtime 使用同一协议。 @@ -93,7 +93,7 @@ Core 对 `usage` 和 `resume` 没有准入规则。 | --- | --- | | Device ID 和连接 | 经认证的 Runtime 路由与连接所有权 | | Session ID / Environment ID | Core 拥有的配置与工作区绑定;payload validator 要求时使用规范 UUID | -| Executor ID | Runtime 拥有的原生资源,可在配置相同的已结算 Turn 之间保留;根 Turn 的 `turn` 设置不属于该身份 | +| Executor ID | Runtime 拥有的原生资源,可在配置相同的已结算 Turn 之间保留 | | Preparation request ID | prepare、start、release 和 status 的 `Envelope.id`;与 Run 不同 | | Admission handle | Runtime 生成的预约,仅在接受它的连接上有效 | | Run ID | 一次执行尝试;输出、取消、活动输入和 function 的 `Envelope.id` | @@ -191,7 +191,7 @@ sequenceDiagram - 消费 Run 事件,直到原生终结结果或观测丢失。执行 error 后跟随 `done`,关闭流;之前的 error 仍属于结果的一部分。 - start 前放弃时发送 `execution_release`。所有权转给 Run 后使用 `prompt_cancel`;释放旧 handle 不能取消后继 Turn。 -preparation 预约每个 Turn 的准入,而不是新 Executor。它携带明确 Session 身份、不可变配置和根 Turn 的设置(`turn`),不包含模型输入或 Run ID。新请求返回连接本地 handle 与所属 Executor ID;复用健康 Executor 时直接返回 `ready`,无需原生 preparation。每个 handle 的 revision 决定 status 观测顺序:忽略旧的或重复的 revision,不将 status 应用于其他 handle。admission 按下列状态转移。`rejected` 控制操作携带 `operation` 和 error code,不替代 handle 的当前 revision。释放 admission 仅放弃该 admission;不关闭 Session 的空闲 Executor,也不取消后续 Turn。 +preparation 预约每个 Turn 的准入,而不是新 Executor。它携带明确 Session 身份和不可变配置,不包含模型输入或 Run ID。新请求返回连接本地 handle 与所属 Executor ID;复用健康 Executor 时直接返回 `ready`,无需原生 preparation。每个 handle 的 revision 决定 status 观测顺序:忽略旧的或重复的 revision,不将 status 应用于其他 handle。admission 按下列状态转移。`rejected` 控制操作携带 `operation` 和 error code,不替代 handle 的当前 revision。释放 admission 仅放弃该 admission;不关闭 Session 的空闲 Executor,也不取消后续 Turn。 ```mermaid stateDiagram-v2 @@ -222,7 +222,7 @@ Executor 空闲到期属于 Runtime 资源策略,与 Core 的活动 Turn 并 | 字段 | Core 设置方式 | | --- | --- | -| `turn` | 始终设置:根 Turn 的 `model`,以及 Agent 设置时的 `reasoning_effort` 和非 `auto` 的 `service_tier`。`review`(`auto_review`;为空表示 none)属于同一对象。保留的 Executor 接受不同的 `turn`,不会产生 `configuration_conflict`;Runtime 把每个 Turn 的设置传给 `StartTurn`,已结算 Executor 的下一个 Turn 使用其自身 `execution_prepare` 中的设置。只读工作区 preparation 发送空的 `turn` | +| `turn` | 始终设置:根 Turn 的 `model`,以及 Agent 设置时的 `reasoning_effort` 和非 `auto` 的 `service_tier`。只读工作区 preparation 发送空的 `turn` | | `agent_options` | 始终设置 Agent 的 `system_prompt`;Session 冻结了模型提供商时设置 `model_provider`;Agent 设置 `x_agents_core.harness_config` 时设置 `harness_config`。Core 不发送其他键;Runtime 在准备之前以 `unsupported_configuration` 拒绝任何其他键 | | `execution_controls` | 始终设置:解析后的 text verbosity(默认 `medium`),以及 Agent 设置的每个选项,包括 web search(mode、`context_size`、`allowed_domains`、`location`)、programmatic tool calling、reasoning summary,以及 `json_schema` 输出格式。Core 在分发前按 Harness 声明校验这些值和 `turn` 设置;原生选项名称属于适配器 | | `observe_subagent_identities`, `disable_subagents` | 根据 Agent 的 `multi_agent.enabled` 设置 | diff --git a/internal/agentdaemon/proto/outbound.go b/internal/agentdaemon/proto/outbound.go index 13ae75cd3..b17a1708e 100644 --- a/internal/agentdaemon/proto/outbound.go +++ b/internal/agentdaemon/proto/outbound.go @@ -28,9 +28,7 @@ type PromptRequestPayload struct { // Input preserves ordered user messages and content. Input MessageInput `json:"input,omitempty"` - // Turn holds the root Turn's official settings. It is not part of the - // Executor identity: a retained Executor receives each Turn's settings at - // StartTurn. + // Turn holds the root Turn's official settings. Turn TurnSettings `json:"turn"` // AgentOptions carries only the keys ValidateAgentOptions accepts. The @@ -85,8 +83,6 @@ type TurnSettings struct { ReasoningEffort string `json:"reasoning_effort,omitempty"` // ServiceTier is never "auto": auto means unset. ServiceTier string `json:"service_tier,omitempty"` - // Review is "auto_review"; empty is none. - Review string `json:"review,omitempty"` } // WebSearchControl is the resolved official web_search tool. A nil diff --git a/internal/harnessconfig/harness.go b/internal/harnessconfig/harness.go index 1b10ec7c0..88e364ebf 100644 --- a/internal/harnessconfig/harness.go +++ b/internal/harnessconfig/harness.go @@ -157,9 +157,6 @@ func (d Declaration) ValidateDeclaration() error { (d.Capabilities.MCPHTTPBearerAuth.IsSupported() && len(d.MCPOrigins) == 0) { return errors.New("harnessconfig: invalid MCP origins") } - if !validChoices(d.Execution.Reviews, "auto_review") { - return errors.New("harnessconfig: invalid reviews") - } // Core always sends the official default text verbosity. if !slices.Contains(d.Execution.TextVerbosities, "medium") { return errors.New("harnessconfig: execution must declare text verbosity medium") @@ -179,11 +176,8 @@ func validChoices(values []string, allowed ...string) bool { } // PreparedConfiguration owns a validated snapshot without native side effects. -// Model may be absent only for the existing native-owned connection path. A -// supplied model must be a nonempty string; an explicit provider requires an -// explicit model. HarnessConfig is always an independently owned object. +// HarnessConfig is always an independently owned object. type PreparedConfiguration struct { - Model string HarnessConfig proto.HarnessConfig } @@ -199,17 +193,18 @@ func ValidateModel(value any) (string, error) { } // Prepare validates the current model/provider/native-parameter contract before -// creating native resources. An empty model is absent. Other options belong to -// the execution contract. +// creating native resources. An empty model is absent, which only the +// native-owned connection path without a provider may use. Other options belong +// to the execution contract. func (c Configuration) Prepare(model string, options map[string]any) (PreparedConfiguration, error) { - result := PreparedConfiguration{Model: model} + var result PreparedConfiguration if model != "" { if _, err := ValidateModel(model); err != nil { return PreparedConfiguration{}, err } } if value, present := options["model_provider"]; present { - if result.Model == "" { + if model == "" { return PreparedConfiguration{}, ErrModel } if _, err := c.ParseProvider(value); err != nil { @@ -375,8 +370,6 @@ type ExecutionOptions struct { ReasoningEfforts, ReasoningSummaries []string // ServiceTiers are the supported tiers other than auto. ServiceTiers []string - // Reviews are the supported review values other than none. - Reviews []string // ProgrammaticToolCalling lists the supported enabled values. ProgrammaticToolCalling []bool // TextVerbosities are the supported text verbosity levels. @@ -388,7 +381,6 @@ func (o ExecutionOptions) Clone() ExecutionOptions { o.ReasoningEfforts = slices.Clone(o.ReasoningEfforts) o.ReasoningSummaries = slices.Clone(o.ReasoningSummaries) o.ServiceTiers = slices.Clone(o.ServiceTiers) - o.Reviews = slices.Clone(o.Reviews) o.ProgrammaticToolCalling = slices.Clone(o.ProgrammaticToolCalling) o.TextVerbosities = slices.Clone(o.TextVerbosities) return o @@ -427,14 +419,14 @@ func (d Declaration) ValidateExecutionControls(controls *proto.ExecutionControls return nil } -// ValidateTurnSettings rejects an effort, tier or review the Harness does not +// ValidateTurnSettings rejects an effort or tier the Harness does not // declare. Core admission and the Runtime call it with the same settings. func (d Declaration) ValidateTurnSettings(turn proto.TurnSettings) error { o := d.Execution for _, check := range []struct { field, value string allowed []string - }{{"reasoning.effort", turn.ReasoningEffort, o.ReasoningEfforts}, {"service_tier", turn.ServiceTier, o.ServiceTiers}, {"review", turn.Review, o.Reviews}} { + }{{"reasoning.effort", turn.ReasoningEffort, o.ReasoningEfforts}, {"service_tier", turn.ServiceTier, o.ServiceTiers}} { if check.value != "" && !slices.Contains(check.allowed, check.value) { return fmt.Errorf("%s %q is not supported by this Harness.", check.field, check.value) } diff --git a/internal/harnessconfig/preparation_test.go b/internal/harnessconfig/preparation_test.go index 2ebbcfdc3..c97f40818 100644 --- a/internal/harnessconfig/preparation_test.go +++ b/internal/harnessconfig/preparation_test.go @@ -40,13 +40,9 @@ func TestPrepareModelConfiguration(t *testing.T) { } }) } - got, err := c.Prepare("fixture", map[string]any{"model_provider": provider()}) - if err != nil { + if _, err := c.Prepare("fixture", map[string]any{"model_provider": provider()}); err != nil { t.Fatal(err) } - if got.Model != "fixture" { - t.Fatal("prepared model changed") - } if _, err := (Configuration{}).Prepare("fixture", map[string]any{"model_provider": provider()}); err == nil { t.Fatal("empty declaration inferred provider support") } From db13b07160dcbe8e096e00992b639115a248cf68 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 5 Nov 2026 04:31:34 +0000 Subject: [PATCH 3/3] Check the Turn model in the none-device request test --- services/core/internal/execution/model_execution_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/core/internal/execution/model_execution_test.go b/services/core/internal/execution/model_execution_test.go index e7a4dea91..41912d376 100644 --- a/services/core/internal/execution/model_execution_test.go +++ b/services/core/internal/execution/model_execution_test.go @@ -26,8 +26,8 @@ func TestSessionModelExecutionNeverFallsBack(t *testing.T) { instructions := "Keep this instruction." snapshot := Snapshot{Agent: v1.Agent{Model: "device-model", Instructions: &instructions}, Environment: v1.Environment{Type: "none"}} request, err := d.executionRequest(t.Context(), sessions.Session{Engine: "codex"}, snapshot, proto.AgentKindCapabilities{}, sessions.ExecutionBinding{}) - if err != nil || !reflect.DeepEqual(request.AgentOptions, map[string]any{"model": "device-model", "system_prompt": &instructions}) { - t.Fatal("none Session received adapter options Core does not own", request.AgentOptions, err) + if err != nil || request.Turn.Model != "device-model" || !reflect.DeepEqual(request.AgentOptions, map[string]any{"system_prompt": &instructions}) { + t.Fatal("none Session received adapter options Core does not own", request.Turn, request.AgentOptions, err) } }