diff --git a/apps/daemon/internal/agent/claudesdk/execution_controls_test.go b/apps/daemon/internal/agent/claudesdk/execution_controls_test.go index fdfb74bbf..33ba1c420 100644 --- a/apps/daemon/internal/agent/claudesdk/execution_controls_test.go +++ b/apps/daemon/internal/agent/claudesdk/execution_controls_test.go @@ -71,9 +71,6 @@ func TestExecutionControlsRejectUnsupportedProfilesBeforeLaunch(t *testing.T) { "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) { diff --git a/apps/daemon/internal/agent/claudesdk/options.go b/apps/daemon/internal/agent/claudesdk/options.go index 4817260af..b9c4a981e 100644 --- a/apps/daemon/internal/agent/claudesdk/options.go +++ b/apps/daemon/internal/agent/claudesdk/options.go @@ -52,9 +52,6 @@ func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startR if err := harnessconfiguration.Declaration.ValidateExecutionControls(req.ExecutionControls); err != nil { return fail(err.Error()) } - if err := harnessconfiguration.Declaration.ValidateTurnSettings(req.Turn); err != nil { - return fail(err.Error()) - } start.Model = req.Turn.Model start.NativeModelOptions = compileNativeModelOptions(modelConfiguration.HarnessConfig, req.Turn.ReasoningEffort) if err := req.ValidateToolSearch(true); err != nil { diff --git a/apps/daemon/internal/agent/codex/execution_controls_test.go b/apps/daemon/internal/agent/codex/execution_controls_test.go index eee968aea..8bfd0e6ac 100644 --- a/apps/daemon/internal/agent/codex/execution_controls_test.go +++ b/apps/daemon/internal/agent/codex/execution_controls_test.go @@ -64,7 +64,7 @@ func TestExecutionControlsRejectUndeclaredValues(t *testing.T) { } } -func TestTurnSettingsReachNativeConfigAndRejectUndeclaredValues(t *testing.T) { +func TestTurnSettingsReachNativeConfig(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 { @@ -74,10 +74,4 @@ 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"}} { - 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 3ee78ec58..106d5584f 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, _ proto.TurnSettings, input proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { +func (e *Executor) StartTurn(ctx context.Context, runID string, turn 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") } @@ -96,7 +96,7 @@ func (e *Executor) StartTurn(ctx context.Context, runID string, _ proto.TurnSett req := proto.PromptRequestPayload{RunID: runID, Input: input, AgentSessionID: e.prepared.resumeID, RequireExistingNativeSession: e.prepared.requireExistingNativeSession} // Ownership precedes any native submission. Even an uncertain start returns the // exact Turn so its caller can await settlement without replaying the input. - err := s.startNative(ctx, e.prepared.plan, req) + err := s.startNative(ctx, e.prepared.plan, turn, req) if err != nil { s.emitTerminal("codex: native start failed", true) s.finishAfterTerminal() diff --git a/apps/daemon/internal/agent/codex/executor_test.go b/apps/daemon/internal/agent/codex/executor_test.go index dbef24595..aa1f7da80 100644 --- a/apps/daemon/internal/agent/codex/executor_test.go +++ b/apps/daemon/internal/agent/codex/executor_test.go @@ -372,3 +372,40 @@ func TestExecutorCloseTerminatesAfterMissingCancellationTerminal(t *testing.T) { t.Fatal("original cancellation waiter was abandoned") } } + +func TestTurnStartCarriesTheTurnSettings(t *testing.T) { + e, root := executorFixture(t, "complete") + turns := []proto.TurnSettings{ + {Model: "one", ReasoningEffort: "high", ServiceTier: "priority"}, + {Model: "two", Review: "auto_review"}, + } + for i, settings := range turns { + out := make(chan proto.Envelope, 20) + turn, err := e.StartTurn(t.Context(), "run", settings, proto.TextInput("answer"), out) + if err != nil { + t.Fatal(err) + } + awaitExecutorTurn(t, turn, out) + var sent []string + for _, frame := range preparationFrames(t, root) { + if frame.Method == "turn/start" { + var p struct { + Model, Effort, ApprovalPolicy, ApprovalsReviewer string + ServiceTier *string + } + if err := json.Unmarshal(frame.Params, &p); err != nil { + t.Fatal(err) + } + tier := "null" + if p.ServiceTier != nil { + tier = *p.ServiceTier + } + sent = append(sent, strings.Join([]string{p.Model, p.Effort, tier, p.ApprovalPolicy, p.ApprovalsReviewer}, " ")) + } + } + want := []string{"one high priority never user", "two null on-request auto_review"} + if len(sent) != i+1 || sent[i] != want[i] { + t.Fatalf("turn/start %d = %q, want %q", i, sent, want[i]) + } + } +} diff --git a/apps/daemon/internal/agent/codex/functions_test.go b/apps/daemon/internal/agent/codex/functions_test.go index 6019a1680..07eb4f540 100644 --- a/apps/daemon/internal/agent/codex/functions_test.go +++ b/apps/daemon/internal/agent/codex/functions_test.go @@ -211,3 +211,21 @@ func TestEndedChildTurnWithdrawsItsPendingCall(t *testing.T) { default: } } + +func TestApprovalEscalationsAreDeniedWithMethodNotFound(t *testing.T) { + tc, srv, cleanup := NewTestClient() + defer cleanup() + newFunctionTestSession(tc.JSONRPCClient) + if err := SendServerRequest(srv, "approval", "item/commandExecution/requestApproval", map[string]any{"threadId": "thread"}); err != nil { + t.Fatal(err) + } + var reply struct { + ID string `json:"id"` + Error struct { + Code int `json:"code"` + } `json:"error"` + } + if err := json.NewDecoder(srv.FromClient).Decode(&reply); err != nil || reply.ID != "approval" || reply.Error.Code != -32601 { + t.Fatal(reply, err) + } +} diff --git a/apps/daemon/internal/agent/codex/options.go b/apps/daemon/internal/agent/codex/options.go index e7d75f88f..2b8d7ad4b 100644 --- a/apps/daemon/internal/agent/codex/options.go +++ b/apps/daemon/internal/agent/codex/options.go @@ -97,10 +97,6 @@ func BuildSessionPlan(stateRoot, agentStateKey string, opts map[string]any, turn return plan, err } - if err := harnessconfiguration.Declaration.ValidateTurnSettings(turn); err != nil { - return plan, err - } - plan.Model = turn.Model plan.SystemPrompt = stringOpt(opts, "system_prompt") diff --git a/apps/daemon/internal/agent/codex/preparation_router_test.go b/apps/daemon/internal/agent/codex/preparation_router_test.go index e8f0c7dc9..d5b306cb9 100644 --- a/apps/daemon/internal/agent/codex/preparation_router_test.go +++ b/apps/daemon/internal/agent/codex/preparation_router_test.go @@ -47,7 +47,7 @@ func TestPreparationRouterRetainsActualNativeChild(t *testing.T) { req.DisableExecutionEnvironment = false req.LocalEnvironment = &proto.LocalEnvironment{ID: environment, WorkspaceDirectory: "/workspace", CapabilitySources: &proto.CapabilitySources{}} registry := agent.NewRegistry() - registry.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported, FunctionTools: proto.CapabilitySupported})}, harnessconfig.Configuration{}) + registry.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported, FunctionTools: proto.CapabilitySupported})}, harnessconfig.Declaration{}) prepared := make(chan *Prepared, 1) registry.RegisterExecutor("codex", func(ctx context.Context, req proto.PromptRequestPayload) (agent.Executor, error) { e, err := newExecutor(ctx, req, cfg) diff --git a/apps/daemon/internal/agent/codex/protocol.go b/apps/daemon/internal/agent/codex/protocol.go index baa591ffd..d98344e14 100644 --- a/apps/daemon/internal/agent/codex/protocol.go +++ b/apps/daemon/internal/agent/codex/protocol.go @@ -162,6 +162,14 @@ type TurnStartParams struct { Input []UserInput `json:"input"` // OutputSchema constrains this Turn's final assistant message natively. OutputSchema json.RawMessage `json:"outputSchema,omitempty"` + // The settings below apply to this and later Turns. Every Turn sends them, + // because a cold thread/resume restores the persisted values. + Model string `json:"model,omitempty"` + Effort string `json:"effort,omitempty"` + ServiceTier any `json:"serviceTier"` // a tier, or null for auto + ApprovalPolicy string `json:"approvalPolicy"` + // ApprovalsReviewer is "user" or "auto_review". + ApprovalsReviewer string `json:"approvalsReviewer"` } type TurnInterruptParams struct { diff --git a/apps/daemon/internal/agent/codex/session.go b/apps/daemon/internal/agent/codex/session.go index d5832b64a..93529abca 100644 --- a/apps/daemon/internal/agent/codex/session.go +++ b/apps/daemon/internal/agent/codex/session.go @@ -133,9 +133,9 @@ func (s *Session) registerHandlers() { rpc.OnNotification("thread/tokenUsage/updated", s.onUsageUpdated) rpc.OnNotification("error", s.onErrorNotif) - // Harnesses run unattended. Approval policy never and the session plan keep - // native asks from being raised; any other server request gets the - // client's method-not-found reply. + // Harnesses run unattended. Approval policy never raises no native ask, and + // under auto_review the reviewer answers. Any server request that still + // reaches the client gets method-not-found, which Codex turns into a denial. s.onServerRequest("item/tool/call", s.handleFunctionCall) } diff --git a/apps/daemon/internal/agent/codex/session_run.go b/apps/daemon/internal/agent/codex/session_run.go index f69043f39..3ebcb4990 100644 --- a/apps/daemon/internal/agent/codex/session_run.go +++ b/apps/daemon/internal/agent/codex/session_run.go @@ -8,7 +8,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -func (s *Session) startNative(ctx context.Context, plan SessionPlan, req proto.PromptRequestPayload) error { +func (s *Session) startNative(ctx context.Context, plan SessionPlan, turn proto.TurnSettings, req proto.PromptRequestPayload) error { if s.currentThreadID() == "" { if err := s.resolveThread(req, plan); err != nil { return err @@ -20,9 +20,21 @@ func (s *Session) startNative(ctx context.Context, plan SessionPlan, req proto.P return err } turnParams := TurnStartParams{ - ThreadID: s.currentThreadID(), - Input: input, - OutputSchema: plan.OutputSchema, + ThreadID: s.currentThreadID(), + Input: input, + OutputSchema: plan.OutputSchema, + Model: turn.Model, + Effort: turn.ReasoningEffort, + ApprovalPolicy: approvalPolicyNever, ApprovalsReviewer: approvalsReviewerUser, + } + if turn.ServiceTier != "" { + turnParams.ServiceTier = turn.ServiceTier + } + if turn.Review == "auto_review" { + turnParams.ApprovalPolicy, turnParams.ApprovalsReviewer = approvalPolicyOnRequest, turn.Review + } + if turn.Model != "" { + s.resolvedModel = turn.Model } turnCtx, turnCancel := context.WithTimeout(ctx, 10*time.Second) _, ackErr := s.rpc.requestWithResult(turnCtx, "turn/start", turnParams, s.bindTurnResult) diff --git a/apps/daemon/internal/agent/codex/session_steering_lifecycle_test.go b/apps/daemon/internal/agent/codex/session_steering_lifecycle_test.go index 8cbaa7863..17502bf53 100644 --- a/apps/daemon/internal/agent/codex/session_steering_lifecycle_test.go +++ b/apps/daemon/internal/agent/codex/session_steering_lifecycle_test.go @@ -112,7 +112,7 @@ func TestBlockedSteeringWriteEndsRunWithTerminalFrames(t *testing.T) { ready <- nil }() // Executor.StartTurn starts and settles each Turn through these two steps. - go s.settleExecutorTurn(s.startNative(ctx, SessionPlan{Model: "synthetic"}, proto.PromptRequestPayload{Input: proto.TextInput("first")})) + go s.settleExecutorTurn(s.startNative(ctx, SessionPlan{Model: "synthetic"}, proto.TurnSettings{Model: "synthetic"}, proto.PromptRequestPayload{Input: proto.TextInput("first")})) if err := <-ready; err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/codex/session_thread.go b/apps/daemon/internal/agent/codex/session_thread.go index 65b59fce9..70d03e4cd 100644 --- a/apps/daemon/internal/agent/codex/session_thread.go +++ b/apps/daemon/internal/agent/codex/session_thread.go @@ -5,10 +5,13 @@ import ( "fmt" ) -// Harnesses run unattended with the launching user's permissions: Codex never -// raises a native approval request and applies no inner sandbox. +// Harnesses run unattended with the launching user's permissions and no inner +// sandbox. Codex asks for approval only under review auto_review, which sends +// each request to its native reviewer. const ( approvalPolicyNever = "never" + approvalPolicyOnRequest = "on-request" + approvalsReviewerUser = "user" sandboxDangerFullAccess = "danger-full-access" ) diff --git a/apps/daemon/internal/agent/configuration_test.go b/apps/daemon/internal/agent/configuration_test.go index a98ae6127..f6824a99b 100644 --- a/apps/daemon/internal/agent/configuration_test.go +++ b/apps/daemon/internal/agent/configuration_test.go @@ -17,13 +17,13 @@ func TestEveryRegistryEntryPreparesTheBoundModelConfiguration(t *testing.T) { configuration := harnessconfig.Configuration{Providers: []harnessconfig.Provider{{Protocol: "responses"}}} calls := 0 expected := errors.New("native entry reached") - registry.RegisterKind(proto.SupportedAgentKind{Kind: "fixture", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, configuration) + registry.RegisterKind(proto.SupportedAgentKind{Kind: "fixture", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Declaration{Configuration: configuration}) registry.RegisterExecutor("fixture", func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { calls++ return nil, expected }) configuration.Providers[0].Protocol = "anthropic" - executor, _ := registry.ResolveExecutor("fixture") + executor, _, _ := registry.ResolveExecutor("fixture") 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"}}}, @@ -46,5 +46,5 @@ func TestRegistryRejectsInvalidConfigurationDeclaration(t *testing.T) { t.Fatal("invalid configuration registered") } }() - agent.NewRegistry().RegisterKind(proto.SupportedAgentKind{Kind: "fixture", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{Providers: []harnessconfig.Provider{{Protocol: "unknown"}}}) + agent.NewRegistry().RegisterKind(proto.SupportedAgentKind{Kind: "fixture", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Declaration{Configuration: harnessconfig.Configuration{Providers: []harnessconfig.Provider{{Protocol: "unknown"}}}}) } diff --git a/apps/daemon/internal/agent/harness.go b/apps/daemon/internal/agent/harness.go index bc36665d8..27a0c0d15 100644 --- a/apps/daemon/internal/agent/harness.go +++ b/apps/daemon/internal/agent/harness.go @@ -105,7 +105,7 @@ func (r *Registry) Register(declaration Declaration, runtime Runtime) { if widened := Widened(declaration.Capabilities, advertised); len(widened) > 0 { panic("agent.Registry.Register: discovery widened " + strings.Join(widened, ", ")) } - r.RegisterKind(runtime.Info, declaration.Configuration) + r.RegisterKind(runtime.Info, declaration.Declaration) if runtime.Executor != nil { r.RegisterExecutor(runtime.Info.Kind, runtime.Executor) } @@ -225,21 +225,21 @@ type Installation struct { // RegisterKind installs the heartbeat descriptor and model configuration for an // agent_kind. Callers may set Available=false when an adapter exists but its // underlying CLI is not usable. -func (r *Registry) RegisterKind(info proto.SupportedAgentKind, configuration harnessconfig.Configuration) { +func (r *Registry) RegisterKind(info proto.SupportedAgentKind, declaration harnessconfig.Declaration) { kind := info.Kind if kind == "" { panic("agent.Registry.Register: empty kind") } - if err := configuration.ValidateDeclaration(); err != nil { + if err := declaration.Configuration.ValidateDeclaration(); err != nil { panic(err) } if err := info.ValidateDeclaration(); err != nil { panic(err) } - configuration = configuration.Clone() + declaration.Configuration = declaration.Configuration.Clone() r.mu.Lock() defer r.mu.Unlock() - r.configurations[kind] = configuration + r.declarations[kind] = declaration delete(r.executors, kind) info.Capabilities.Preparation = proto.CapabilityUnsupported r.kinds[kind] = info @@ -254,12 +254,12 @@ func (r *Registry) RegisterExecutor(kind string, factory ExecutorFactory) { if !exists || factory == nil { panic("agent.Registry.RegisterExecutor: registered kind and factory required") } - configuration, declared := r.configurations[kind] + declaration, declared := r.declarations[kind] if !declared { panic("agent.Registry.RegisterExecutor: configuration required") } r.executors[kind] = func(ctx context.Context, req proto.PromptRequestPayload) (Executor, error) { - if _, err := configuration.Prepare(req.Turn.Model, req.AgentOptions); err != nil { + if _, err := declaration.Configuration.Prepare(req.Turn.Model, req.AgentOptions); err != nil { return nil, err } return factory(ctx, req) diff --git a/apps/daemon/internal/agent/mcode/execution.go b/apps/daemon/internal/agent/mcode/execution.go index 0cee3d352..729d0a3d8 100644 --- a/apps/daemon/internal/agent/mcode/execution.go +++ b/apps/daemon/internal/agent/mcode/execution.go @@ -19,9 +19,6 @@ 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/registry.go b/apps/daemon/internal/agent/registry.go index c6998e918..bc3240297 100644 --- a/apps/daemon/internal/agent/registry.go +++ b/apps/daemon/internal/agent/registry.go @@ -12,17 +12,17 @@ import ( // Registry keeps the daemon-advertised capability descriptor and execution // factories for each agent_kind. Safe for concurrent use. type Registry struct { - mu sync.RWMutex - executors map[string]ExecutorFactory - kinds map[string]proto.SupportedAgentKind - configurations map[string]harnessconfig.Configuration + mu sync.RWMutex + executors map[string]ExecutorFactory + kinds map[string]proto.SupportedAgentKind + declarations map[string]harnessconfig.Declaration } func NewRegistry() *Registry { return &Registry{ - executors: make(map[string]ExecutorFactory), - kinds: make(map[string]proto.SupportedAgentKind), - configurations: make(map[string]harnessconfig.Configuration), + executors: make(map[string]ExecutorFactory), + kinds: make(map[string]proto.SupportedAgentKind), + declarations: make(map[string]harnessconfig.Declaration), } } @@ -47,12 +47,14 @@ func (r *Registry) SupportedAgentKinds() []proto.SupportedAgentKind { return out } -func (r *Registry) ResolveExecutor(kind string) (ExecutorFactory, error) { +// ResolveExecutor returns the factory of a kind and the declaration it was +// registered with. +func (r *Registry) ResolveExecutor(kind string) (ExecutorFactory, harnessconfig.Declaration, error) { r.mu.RLock() defer r.mu.RUnlock() factory := r.executors[kind] if factory == nil { - return nil, fmt.Errorf("agent: executor unavailable for %q", kind) + return nil, harnessconfig.Declaration{}, fmt.Errorf("agent: executor unavailable for %q", kind) } - return factory, nil + return factory, r.declarations[kind], nil } diff --git a/apps/daemon/internal/agent/registry_test.go b/apps/daemon/internal/agent/registry_test.go index 04ffc9420..2d51b33f8 100644 --- a/apps/daemon/internal/agent/registry_test.go +++ b/apps/daemon/internal/agent/registry_test.go @@ -15,8 +15,8 @@ import ( func TestRegistryRegisterOverwritesDescriptor(t *testing.T) { reg := agent.NewRegistry() - reg.RegisterKind(proto.SupportedAgentKind{Kind: "k", Available: true, Version: "v1", Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}) - reg.RegisterKind(proto.SupportedAgentKind{Kind: "k", Available: true, Version: "v2", Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}) + reg.RegisterKind(proto.SupportedAgentKind{Kind: "k", Available: true, Version: "v1", Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Declaration{}) + reg.RegisterKind(proto.SupportedAgentKind{Kind: "k", Available: true, Version: "v2", Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Declaration{}) got := reg.SupportedAgentKinds() if len(got) != 1 || got[0].Version != "v2" { @@ -30,7 +30,7 @@ func TestRegistryRegisterPanicsOnEmptyKind(t *testing.T) { t.Fatal("Register(\"\", ...) did not panic") } }() - agent.NewRegistry().RegisterKind(proto.SupportedAgentKind{Kind: "", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}) + agent.NewRegistry().RegisterKind(proto.SupportedAgentKind{Kind: "", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Declaration{}) } func TestRegistryRegisterRejectsFactoriesForUnavailableRuntime(t *testing.T) { @@ -75,7 +75,7 @@ func TestRegistrySupportedAgentKindsReportsDescriptors(t *testing.T) { Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{ Streaming: proto.CapabilitySupported, }), - }, harnessconfig.Configuration{}) + }, harnessconfig.Declaration{}) reg.RegisterKind(proto.SupportedAgentKind{ Kind: "fake_alpha", Available: true, @@ -85,7 +85,7 @@ func TestRegistrySupportedAgentKindsReportsDescriptors(t *testing.T) { Usage: proto.CapabilitySupported, Resume: proto.CapabilitySupported, }), - }, harnessconfig.Configuration{}) + }, harnessconfig.Declaration{}) got := reg.SupportedAgentKinds() if len(got) != 2 { @@ -104,21 +104,21 @@ func TestRegistrySupportedAgentKindsReportsDescriptors(t *testing.T) { func TestRegistryExecutorRequiresExplicitRegistration(t *testing.T) { registry := agent.NewRegistry() - registry.RegisterKind(proto.SupportedAgentKind{Kind: "native", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}) - if _, err := registry.ResolveExecutor("native"); err == nil { + registry.RegisterKind(proto.SupportedAgentKind{Kind: "native", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Declaration{}) + if _, _, err := registry.ResolveExecutor("native"); err == nil { t.Fatal("kind registration implied reusable execution") } expected := errors.New("executor factory") registry.RegisterExecutor("native", func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { return nil, expected }) - factory, err := registry.ResolveExecutor("native") + factory, _, err := registry.ResolveExecutor("native") if err != nil { t.Fatal(err) } if _, err := factory(t.Context(), proto.PromptRequestPayload{}); !errors.Is(err, expected) { t.Fatal(err) } - registry.RegisterKind(proto.SupportedAgentKind{Kind: "native", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}) - if _, err := registry.ResolveExecutor("native"); err == nil { + registry.RegisterKind(proto.SupportedAgentKind{Kind: "native", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Declaration{}) + if _, _, err := registry.ResolveExecutor("native"); err == nil { t.Fatal("replacing a kind retained its old executor capability") } } @@ -129,7 +129,7 @@ func TestRegistryRejectsEveryOmittedCapabilityBeforeReplacement(t *testing.T) { t.Run(reflect.TypeOf(valid).Field(i).Name, func(t *testing.T) { registry := agent.NewRegistry() original := proto.SupportedAgentKind{Kind: "fixture", Available: true, Capabilities: valid} - registry.RegisterKind(original, harnessconfig.Configuration{}) + registry.RegisterKind(original, harnessconfig.Declaration{}) registry.RegisterExecutor("fixture", func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { return nil, nil }) original.Capabilities.Preparation = proto.CapabilitySupported missing := valid @@ -140,9 +140,9 @@ func TestRegistryRejectsEveryOmittedCapabilityBeforeReplacement(t *testing.T) { t.Error("incomplete declaration registered") } }() - registry.RegisterKind(proto.SupportedAgentKind{Kind: "fixture", Available: false, Capabilities: missing}, harnessconfig.Configuration{}) + registry.RegisterKind(proto.SupportedAgentKind{Kind: "fixture", Available: false, Capabilities: missing}, harnessconfig.Declaration{}) }() - if _, err := registry.ResolveExecutor("fixture"); err != nil || !reflect.DeepEqual(registry.SupportedAgentKinds(), []proto.SupportedAgentKind{original}) { + if _, _, err := registry.ResolveExecutor("fixture"); err != nil || !reflect.DeepEqual(registry.SupportedAgentKinds(), []proto.SupportedAgentKind{original}) { t.Fatal("failed declaration changed registry") } }) diff --git a/apps/daemon/internal/cli/connect_cleanup_test.go b/apps/daemon/internal/cli/connect_cleanup_test.go index f67516bf8..0614af36b 100644 --- a/apps/daemon/internal/cli/connect_cleanup_test.go +++ b/apps/daemon/internal/cli/connect_cleanup_test.go @@ -114,7 +114,7 @@ func testDisconnectedPumpCleanup(t *testing.T, suspend bool) { } owner := &cleanupExecutor{retry: make(chan struct{}), confirm: make(chan struct{})} registry := agent.NewRegistry() - registry.RegisterKind(proto.SupportedAgentKind{Kind: "cleanup", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported})}, harnessconfig.Configuration{}) + registry.RegisterKind(proto.SupportedAgentKind{Kind: "cleanup", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported})}, harnessconfig.Declaration{}) var factories atomic.Int32 registry.RegisterExecutor("cleanup", func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { factories.Add(1) diff --git a/apps/daemon/internal/cli/connect_heartbeat_test.go b/apps/daemon/internal/cli/connect_heartbeat_test.go index cc29ac3d4..16a598f0f 100644 --- a/apps/daemon/internal/cli/connect_heartbeat_test.go +++ b/apps/daemon/internal/cli/connect_heartbeat_test.go @@ -14,8 +14,8 @@ func TestHeartbeatNeverAdvertisesUndeclaredExport(t *testing.T) { exporting := local exporting.WorkspaceOutputExport = proto.CapabilitySupported registry := agent.NewRegistry() - registry.RegisterKind(proto.SupportedAgentKind{Kind: "declared", Available: true, Capabilities: prototest.Capabilities(exporting)}, harnessconfig.Configuration{}) - registry.RegisterKind(proto.SupportedAgentKind{Kind: "undeclared", Available: true, Capabilities: prototest.Capabilities(local)}, harnessconfig.Configuration{}) + registry.RegisterKind(proto.SupportedAgentKind{Kind: "declared", Available: true, Capabilities: prototest.Capabilities(exporting)}, harnessconfig.Declaration{}) + registry.RegisterKind(proto.SupportedAgentKind{Kind: "undeclared", Available: true, Capabilities: prototest.Capabilities(local)}, harnessconfig.Declaration{}) for _, canExport := range []bool{false, true} { for _, kind := range advertisedKinds(registry, canExport) { if got, want := kind.Capabilities.WorkspaceOutputExport.IsSupported(), canExport && kind.Kind == "declared"; got != want { diff --git a/apps/daemon/internal/cli/connect_suspend_test.go b/apps/daemon/internal/cli/connect_suspend_test.go index baf1ff5ed..366279eef 100644 --- a/apps/daemon/internal/cli/connect_suspend_test.go +++ b/apps/daemon/internal/cli/connect_suspend_test.go @@ -368,7 +368,7 @@ func TestQuiesceRetainsExecutorAcrossReconnectAndShutdownConfirmsCleanup(t *test } }() registry := agent.NewRegistry() - registry.RegisterKind(proto.SupportedAgentKind{Kind: "cleanup", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported})}, harnessconfig.Configuration{}) + registry.RegisterKind(proto.SupportedAgentKind{Kind: "cleanup", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported})}, harnessconfig.Declaration{}) registry.RegisterExecutor("cleanup", func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { return native, nil }) control := &suspendControl{path: filepath.Join(t.TempDir(), "control.json"), identity: suspendIdentity{EnvironmentID: environment}, signal: make(chan os.Signal, 1)} finished := make(chan error, 1) diff --git a/apps/daemon/internal/dispatch/executor.go b/apps/daemon/internal/dispatch/executor.go index 79f436802..84e9c2eba 100644 --- a/apps/daemon/internal/dispatch/executor.go +++ b/apps/daemon/internal/dispatch/executor.go @@ -23,6 +23,7 @@ type executorState struct { nativeID string ctx context.Context cancel context.CancelFunc + turn proto.TurnSettings admission *preparationState run *sessionState preparing bool @@ -34,8 +35,10 @@ type executorState struct { idleLease uint64 } +// executorFingerprint covers the configuration a retained Executor fixes. The +// Turn settings are not part of it: ValidateSessionUpdate decides their change. func executorFingerprint(req proto.PromptRequestPayload) ([32]byte, error) { - req.RunID, req.Input = "", nil + req.RunID, req.Input, req.Turn = "", nil, proto.TurnSettings{} req.AgentSessionID = "" req.RequireExistingNativeSession = false data, err := json.Marshal(req) @@ -51,7 +54,7 @@ func (r *Router) handleExecutorPrepare(ctx context.Context, env proto.Envelope, if !available { return r.rejectPreparation(env, "resource_unavailable") } - factory, err := r.registry.ResolveExecutor(req.AgentKind) + factory, declaration, err := r.registry.ResolveExecutor(req.AgentKind) if err != nil || !caps.Preparation.IsSupported() { return r.rejectPreparation(env, "unsupported_preparation") } @@ -62,6 +65,9 @@ func (r *Router) handleExecutorPrepare(ctx context.Context, env proto.Envelope, if validateExecutionEnvironment(req, caps) != nil || len(req.FunctionTools) > 0 && !caps.FunctionTools.IsSupported() { return r.rejectPreparation(env, "unsupported_configuration") } + if declaration.ValidateTurnSettings(req.Turn) != nil { + return r.rejectPreparation(env, "unsupported_configuration") + } fingerprint, err := executorFingerprint(req) if err != nil { return r.rejectPreparation(env, "invalid_configuration") @@ -115,7 +121,7 @@ func (r *Router) handleExecutorPrepare(ctx context.Context, env proto.Envelope, if owner != nil { code := "" switch { - case owner.environmentID != req.EnvironmentID() || owner.fingerprint != fingerprint: + case owner.environmentID != req.EnvironmentID() || owner.fingerprint != fingerprint || declaration.ValidateSessionUpdate(owner.turn, req.Turn) != nil: code = "configuration_conflict" case owner.invalid: code = "executor_cleanup_unconfirmed" @@ -134,13 +140,14 @@ func (r *Router) handleExecutorPrepare(ctx context.Context, env proto.Envelope, owner.timer.Stop() } owner.idleLease++ + owner.turn = req.Turn } else { if len(r.executors) >= executorIdleCapacity+preparationCapacity { r.mu.Unlock() return r.rejectPreparation(env, "executor_capacity") } ownerCtx, cancel := context.WithCancel(context.WithoutCancel(ctx)) - owner = &executorState{capabilities: caps, id: uuid.NewString(), sessionID: input.SessionID, environmentID: req.EnvironmentID(), stateKey: req.AgentStateKey, fingerprint: fingerprint, ctx: ownerCtx, cancel: cancel, preparing: true, nativeID: req.AgentSessionID} + owner = &executorState{capabilities: caps, id: uuid.NewString(), sessionID: input.SessionID, environmentID: req.EnvironmentID(), stateKey: req.AgentStateKey, fingerprint: fingerprint, turn: req.Turn, ctx: ownerCtx, cancel: cancel, preparing: true, nativeID: req.AgentSessionID} r.executors[input.SessionID] = owner r.log.Info("executor owner_created", "executor_id", owner.id, "session_id", owner.sessionID) } diff --git a/apps/daemon/internal/dispatch/executor_cancel_receipt_test.go b/apps/daemon/internal/dispatch/executor_cancel_receipt_test.go index 9da938e81..8de91f2df 100644 --- a/apps/daemon/internal/dispatch/executor_cancel_receipt_test.go +++ b/apps/daemon/internal/dispatch/executor_cancel_receipt_test.go @@ -128,7 +128,7 @@ func TestExecutorCancellationReachesNativeBeforeDurableReceiptJoin(t *testing.T) sender := &receiptCancelSender{recSender: &recSender{}, entered: make(chan struct{}), release: make(chan struct{})} owner := &receiptCancelExecutor{turn: make(chan *receiptCancelTurn, 2), cancelFails: mode == "cancel_failure" || mode == "close_failure_retry", closeFailsFirst: mode == "close_failure_retry", closeEntered: make(chan struct{}), closeRelease: make(chan struct{})} reg := agent.NewRegistry() - reg.RegisterKind(proto.SupportedAgentKind{Kind: "reusable", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}) + reg.RegisterKind(proto.SupportedAgentKind{Kind: "reusable", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Declaration{}) reg.RegisterExecutor("reusable", func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { return owner, nil }) r, err := dispatch.New(dispatch.Config{Registry: reg, Sender: sender, IdleTimeout: time.Hour}) if err != nil { diff --git a/apps/daemon/internal/dispatch/executor_handoff_test.go b/apps/daemon/internal/dispatch/executor_handoff_test.go index 029a28003..c67c8fad9 100644 --- a/apps/daemon/internal/dispatch/executor_handoff_test.go +++ b/apps/daemon/internal/dispatch/executor_handoff_test.go @@ -93,7 +93,7 @@ func TestPreparedDonePublishesAfterExecutorHandoff(t *testing.T) { } owner := &terminalHandoffExecutor{turns: make(chan *terminalHandoffTurn, 3)} registry := agent.NewRegistry() - registry.RegisterKind(proto.SupportedAgentKind{Kind: "handoff", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported})}, harnessconfig.Configuration{}) + registry.RegisterKind(proto.SupportedAgentKind{Kind: "handoff", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported})}, harnessconfig.Declaration{}) var creates atomic.Int32 registry.RegisterExecutor("handoff", func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { creates.Add(1) diff --git a/apps/daemon/internal/dispatch/executor_test.go b/apps/daemon/internal/dispatch/executor_test.go index 4220c4612..d4c36dea5 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 + turn proto.TurnSettings } func (e *reusableExecutor) Close(context.Context) error { @@ -31,11 +32,12 @@ func (e *reusableExecutor) Close(context.Context) error { defer e.mu.Unlock() return e.closeErr } -func (e *reusableExecutor) StartTurn(_ context.Context, id string, _ proto.TurnSettings, _ 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.startErr != nil { return nil, e.startErr } t := &reusableTurn{id: id, out: out, settled: make(chan struct{})} + e.turn = turn e.starts <- t return t, nil } @@ -84,7 +86,7 @@ func executorRequest() proto.ExecutionPreparePayload { // registerExecutorKind registers info for prepared execution only. func registerExecutorKind(reg *agent.Registry, info proto.SupportedAgentKind, factory agent.ExecutorFactory) { - reg.RegisterKind(info, harnessconfig.Configuration{}) + reg.RegisterKind(info, harnessconfig.Declaration{Execution: harnessconfig.ExecutionOptions{ReasoningEfforts: []string{"low", "high"}}, SessionUpdates: harnessconfig.SessionUpdates{ReasoningEffort: proto.CapabilitySupported}}) reg.RegisterExecutor(info.Kind, factory) } func executorRouter(t *testing.T, owner *reusableExecutor, idle time.Duration) (*dispatch.Router, *recSender, *atomic.Int32) { @@ -157,6 +159,32 @@ func TestExecutorRetainsOwnerAcrossIndependentTurns(t *testing.T) { t.Fatal("executor closed between Turns") } } +func TestExecutorAcceptsDeclaredTurnChangesAndStartsWithThem(t *testing.T) { + owner := &reusableExecutor{starts: make(chan *reusableTurn, 2)} + r, s, calls := executorRouter(t, owner, time.Minute) + first := executorAdmission(t, r, s, "first", executorRequest()) + startExecutorTurn(t, r, s, "first", "one", first) + (<-owner.starts).finish() + waitFor(t, func() bool { return r.ActiveRuns() == 0 }, "first Turn settled") + req := executorRequest() + req.Configuration.AgentSessionID = "native-session" + req.Configuration.Turn.ReasoningEffort = "high" + second := executorAdmission(t, r, s, "second", req) + startExecutorTurn(t, r, s, "second", "two", second) + (<-owner.starts).finish() + waitFor(t, func() bool { return r.ActiveRuns() == 0 }, "second Turn settled") + req.Configuration.Turn.ReasoningEffort = "" + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "reset", req)); err == nil { + t.Fatal("accepted an undeclared reset of effort") + } + req.Configuration.Turn.ReasoningEffort = "max" + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "undeclared", req)); err == nil { + t.Fatal("accepted an undeclared effort") + } + if !second.Reused || calls.Load() != 1 || owner.turn.ReasoningEffort != "high" { + t.Fatalf("reused=%t calls=%d turn=%+v", second.Reused, calls.Load(), owner.turn) + } +} func TestExecutorCancelSettlesTurnWithoutClosingOwner(t *testing.T) { owner := &reusableExecutor{starts: make(chan *reusableTurn, 1)} r, s, _ := executorRouter(t, owner, time.Minute) @@ -232,7 +260,7 @@ func TestExecutorPreInputFailureConfirmsCloseBeforeRetrySignal(t *testing.T) { func poolRouter(t *testing.T, factory agent.ExecutorFactory) (*dispatch.Router, *recSender) { t.Helper() registry := agent.NewRegistry() - registry.RegisterKind(proto.SupportedAgentKind{Kind: "reusable", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}) + registry.RegisterKind(proto.SupportedAgentKind{Kind: "reusable", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Declaration{}) registry.RegisterExecutor("reusable", factory) sender := &recSender{} router, err := dispatch.New(dispatch.Config{Registry: registry, Sender: sender, IdleTimeout: time.Minute}) diff --git a/apps/daemon/internal/dispatch/local_directory_test.go b/apps/daemon/internal/dispatch/local_directory_test.go index 7ac10720f..8739c7c75 100644 --- a/apps/daemon/internal/dispatch/local_directory_test.go +++ b/apps/daemon/internal/dispatch/local_directory_test.go @@ -29,7 +29,7 @@ func TestLocalDirectoryPreparationNeedsNoHarnessAndRejectsOtherOwners(t *testing } var harnessCalls atomic.Int32 reg := agent.NewRegistry() - reg.RegisterKind(proto.SupportedAgentKind{Kind: "native", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported, WorkspaceReadPreparation: proto.CapabilitySupported})}, harnessconfig.Configuration{}) + reg.RegisterKind(proto.SupportedAgentKind{Kind: "native", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported, WorkspaceReadPreparation: proto.CapabilitySupported})}, harnessconfig.Declaration{}) reg.RegisterExecutor("native", func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { harnessCalls.Add(1) return nil, errors.New("must not prepare a harness") @@ -108,7 +108,7 @@ func TestLocalDirectoryKeepsNotDirectorySeparateFromFailures(t *testing.T) { t.Fatal(err) } reg := agent.NewRegistry() - reg.RegisterKind(proto.SupportedAgentKind{Kind: "native", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported, WorkspaceReadPreparation: proto.CapabilitySupported})}, harnessconfig.Configuration{}) + reg.RegisterKind(proto.SupportedAgentKind{Kind: "native", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported, WorkspaceReadPreparation: proto.CapabilitySupported})}, harnessconfig.Declaration{}) reg.RegisterExecutor("native", func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { return nil, errors.New("must not prepare a harness") }) diff --git a/apps/daemon/internal/dispatch/mcp_http_test.go b/apps/daemon/internal/dispatch/mcp_http_test.go index ff6805d3e..a806e7bf8 100644 --- a/apps/daemon/internal/dispatch/mcp_http_test.go +++ b/apps/daemon/internal/dispatch/mcp_http_test.go @@ -111,7 +111,7 @@ func TestLocalMCPOriginAndCapabilityAdmission(t *testing.T) { req.Configuration.MCPHTTPServers = nil } entered := make(chan struct{}, 1) - h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "prepared", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported, MCPHTTPTools: proto.CapabilityFromBool(mode != "environment missing capability"), MCPHTTPBearerAuth: proto.CapabilitySupported, MCPHTTPRequired: proto.CapabilitySupported})}, harnessconfig.Configuration{}) + h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "prepared", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported, MCPHTTPTools: proto.CapabilityFromBool(mode != "environment missing capability"), MCPHTTPBearerAuth: proto.CapabilitySupported, MCPHTTPRequired: proto.CapabilitySupported})}, harnessconfig.Declaration{}) h.reg.RegisterExecutor("prepared", func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { entered <- struct{}{} return nil, errors.New("controlled stop") diff --git a/apps/daemon/internal/dispatch/preparation_test.go b/apps/daemon/internal/dispatch/preparation_test.go index 959939384..79b816348 100644 --- a/apps/daemon/internal/dispatch/preparation_test.go +++ b/apps/daemon/internal/dispatch/preparation_test.go @@ -111,7 +111,7 @@ func preparationRequest() proto.ExecutionPreparePayload { func preparationRouter(t *testing.T, sender dispatch.Sender, timeout time.Duration, factory preparationFactory) *dispatch.Router { t.Helper() reg := agent.NewRegistry() - reg.RegisterKind(proto.SupportedAgentKind{Kind: "prepared", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported, WorkspaceReadPreparation: proto.CapabilitySupported, FunctionTools: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}) + reg.RegisterKind(proto.SupportedAgentKind{Kind: "prepared", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported, WorkspaceReadPreparation: proto.CapabilitySupported, FunctionTools: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Declaration{}) reg.RegisterExecutor("prepared", preparationExecutorFixture(factory)) r, err := dispatch.New(dispatch.Config{Registry: reg, Sender: sender, PreparationTimeout: timeout, LocalWorkspace: preparationWorkspace(t)}) if err != nil { diff --git a/apps/daemon/internal/wireconformance/wire_test.go b/apps/daemon/internal/wireconformance/wire_test.go index d48bfa2bd..890547db1 100644 --- a/apps/daemon/internal/wireconformance/wire_test.go +++ b/apps/daemon/internal/wireconformance/wire_test.go @@ -157,7 +157,7 @@ func connectRuntime(t *testing.T, peer *corePeer, setupErr error) *runtimeSide { peer.accept(t) rt := &runtimeSide{conn: conn, executor: &controlledExecutor{turn: make(chan *controlledTurn, 1)}, stopped: make(chan struct{})} kinds := agent.NewRegistry() - kinds.RegisterKind(proto.SupportedAgentKind{Kind: prototest.HarnessKind, Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported})}, harnessconfig.Configuration{}) + kinds.RegisterKind(proto.SupportedAgentKind{Kind: prototest.HarnessKind, Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported})}, harnessconfig.Declaration{Execution: harnessconfig.ExecutionOptions{ReasoningEfforts: []string{prototest.Turn.ReasoningEffort}, Reviews: []string{prototest.Turn.Review}}}) kinds.RegisterExecutor(prototest.HarnessKind, func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { return rt.executor, setupErr }) diff --git a/apps/daemon/testdata/onboarding/main.go b/apps/daemon/testdata/onboarding/main.go index 03a4df227..5414e0189 100644 --- a/apps/daemon/testdata/onboarding/main.go +++ b/apps/daemon/testdata/onboarding/main.go @@ -165,7 +165,7 @@ func run() error { h := &harness{history: map[string]string{}} registry.RegisterKind(proto.SupportedAgentKind{Kind: "fixture_harness", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{ Streaming: proto.CapabilitySupported, DurableTurns: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported, ExecutionControls: proto.CapabilitySupported, ToolObservations: proto.CapabilitySupported, SubagentControl: proto.CapabilitySupported, EnvironmentNone: proto.CapabilitySupported, - })}, harnessconfig.Configuration{}) + })}, harnessconfig.Declaration{}) registry.RegisterExecutor("fixture_harness", h.prepare) sink := &sender{encoder: json.NewEncoder(os.Stdout)} router, err := dispatch.New(dispatch.Config{Registry: registry, Sender: sink, Log: slog.New(slog.NewTextHandler(io.Discard, nil))}) diff --git a/contracts/agents-api/harness-capabilities.md b/contracts/agents-api/harness-capabilities.md index 974c48b8d..fc9357e4f 100644 --- a/contracts/agents-api/harness-capabilities.md +++ b/contracts/agents-api/harness-capabilities.md @@ -27,7 +27,7 @@ Placements are `none` (no Environment), hosted (`openai_hosted`) and self-hosted | `reasoning.summary` | `concise`, `detailed`, `auto`: admitted | `auto`: admitted | Rejected | | `service_tier` other than `auto` | `default`, `flex`, `priority`, `fast`: admitted; `ultrafast` rejected | Rejected | Rejected | | `text.verbosity` other than `medium` | Admitted; the native model decides | Rejected | Rejected | -| Session update of `agent.model`, `reasoning.effort` (including null), `service_tier` and `review: auto_review` | Rejected | Rejected | Rejected | +| Session update of `agent.model`, `reasoning.effort`, `service_tier` and `review: auto_review` | Applied on the next root Turn; `reasoning.effort` cannot be reset to null | Rejected | Rejected | | Session update of `agent.tools` | Rejected | Rejected | Rejected | | `compaction.async: true`, `experimental.time_awareness: true` and the `automations` and `computer_use` tools | Rejected | Rejected | Rejected | | [Public token usage](./sessions-events.md) | Measured counters | Null | Null | diff --git a/contracts/agents-api/harness-onboarding.md b/contracts/agents-api/harness-onboarding.md index 86743f53d..b509cf003 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` 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. +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`, 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. A configuration conflict is an error, not a hot switch. The one exception is the `turn` settings the declaration marks changeable (`SessionUpdates.Model`, `ReasoningEffort`, `ReasoningEffortReset`, `ServiceTier`, and `review` when `Execution.Reviews` is not empty): a retained Executor accepts a prepare whose `turn` differs in those, and `StartTurn` receives it. An adapter applies a differing `turn` natively before the Turn's input, or never declares the change. 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. @@ -188,7 +188,7 @@ The declaration holds: - `SessionUpdates`, whether the adapter applies a Session update natively between root Turns of a retained Executor: `Model`, `ReasoningEffort`, `ReasoningEffortReset` (null effort) and `ServiceTier`, each `Supported` or `Unsupported`. Core rejects an update that changes an unsupported setting, and a change to a supported one reaches the adapter in the next Turn's settings; - optional pure restrictions, `ValidateConfiguration`, `ValidateTools`, `ValidateMessageInput` and `ValidateFunctionResult`, that add native limits to the common checks. `ValidateMessageInput` runs only for messages that carry images, after the `MessageImages` capability check and before anything is stored, and decides which `image_url` values the Harness accepts; Core checks only that the reference is not blank. A nil validator means the common checks suffice. They cannot query business data, decrypt credentials or control native processes. Configuration restrictions run first, and a tool-decoding error precedes `ValidateTools`. -`harnessconfig.NewRegistry` validates every declaration and panics on an invalid or duplicate one: kinds and labels must be nonempty, placements must list at least one supported placement, MCP origins must be a non-nil list, unknown or duplicate choices and origins without a corresponding placement are rejected, bearer support requires an MCP origin, and `Execution` must declare text verbosity `medium`. Shared dispatch checks capability combinations, never engine names. +`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, every `SessionUpdates` field must be declared, `Execution.Reviews` may hold only `auto_review`, and `Execution` must declare text verbosity `medium`. Shared dispatch checks capability combinations, never engine names. The Runtime binding in `apps/daemon/internal/agent//declaration.go` embeds the same declaration and adds `Discover`, which owns native availability probes and factories and only narrows the declared capabilities to what the installation verified. The binding test `TestDiscoveryOnlyNarrowsDeclaration` checks that discovery never exceeds the declaration. `harnessconfig/harnesstest.Declaration` builds a synthetic declaration for tests. @@ -214,7 +214,7 @@ The daemon's `clirunner` starts every native child in its own Unix process group Owned output pipes stay readable after the leader exits. Consumers drain stdout and stderr before calling `Wait`, which joins the cached process result and closes the readers. `Done` reports leader reaping and group cleanup signals; it is not a native execution receipt or proof of persisted history. SDK adapters settle each Turn and drain its observations before publishing completion, and Executor close also closes the query and awaits the native child. Process groups are lifecycle supervision, not isolation or containment of descendants that leave the group. -Adapters run native tools unattended with the launching user's permissions, and a Harness never asks a human. Codex runs with approval policy `never`, under which it settles MCP elicitation itself without reaching the client, and with full access; the adapter also disables its blocking `request_user_input` tool. Claude runs through the adapter's tool callback, which allows or denies without asking. MiniMax runs with bypassed permissions; the adapter disables `askUser`, advertises no elicitation and answers `session/request_permission` with the ACP `cancelled` outcome, which MiniMax treats as a denial. No native permission or question request reaches Core: a human in the loop goes through a function tool, the Session reads [`requires_action`](./sessions-events.md#session-status) and the application submits the function result. Do not add permission profiles, bubblewrap wrappers or native sandbox settings; there is one execution path for every Environment origin. Resource paths are operator configuration, not a permission boundary. +Adapters run native tools unattended with the launching user's permissions, and a Harness never asks a human. Codex runs with approval policy `never`, under which it settles MCP elicitation itself without reaching the client, and with full access; under review `auto_review` every Turn instead sends `on-request` with the `auto_review` reviewer, whose denials stay inside Codex, and any request that still reaches the client gets JSON-RPC -32601, a denial; the adapter also disables its blocking `request_user_input` tool. Claude runs through the adapter's tool callback, which allows or denies without asking. MiniMax runs with bypassed permissions; the adapter disables `askUser`, advertises no elicitation and answers `session/request_permission` with the ACP `cancelled` outcome, which MiniMax treats as a denial. No native permission or question request reaches Core: a human in the loop goes through a function tool, the Session reads [`requires_action`](./sessions-events.md#session-status) and the application submits the function result. Do not add permission profiles, bubblewrap wrappers or native sandbox settings; there is one execution path for every Environment origin. Resource paths are operator configuration, not a permission boundary. An unreachable model host must not hold a Turn open. The adapter keeps the Harness's own connection retries bounded through native settings, and the Harness's final connection error becomes `connection_failed` when the Harness reports it as structured data ([native failure classification](../../docs/runtime-protocol.md#native-failure-classification)). Core adds no model timeout of its own. Each adapter guide states its Harness's bound. diff --git a/contracts/agents-api/index.md b/contracts/agents-api/index.md index ca1002210..e825ec100 100644 --- a/contracts/agents-api/index.md +++ b/contracts/agents-api/index.md @@ -81,8 +81,10 @@ Each item is Core's deliberate or native behavior where the official service beh - Claude Code runs a Skill's inline shell inside the Skill expansion, so the command produces no tool observation. - Claude Code with live search on `environment: none` has only `WebSearch`, so search yields `search` actions and no page opens ([web search](./execution-tools.md#web-search-and-programmatic-tool-calling)). - An omitted reasoning effort stays null instead of taking the model's default. +- An empty or blank `agent.model` returns 400 at Session create and update; the pinned schema allows it. - Codex maps programmatic tool calling to its native `code_mode`, whose model-written scripts call the other tools; the feature is under development upstream. - Codex rejects programmatic tool calling together with `multi_agent`. +- Codex `review: auto_review` reviews only the actions Codex escalates, such as a forced `rm` or an MCP tool call that needs approval; Codex has full filesystem access inside the sandbox, so other commands and file edits run without review. Its native reviewer calls the model `codex-auto-review` through the Session's model provider, and a provider that does not serve it denies every escalated action with its own error. - Codex runs without its native goals (`create_goal`, `get_goal` and `update_goal`), because an active goal starts turns of its own after the Turn ends ([Codex Runtime](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/codex/README.md)). - A Session's `agent.tools` omits `tool_search` declarations. - Core rejects an inline `authorization` on a Session MCP transport, an `Authorization` entry in `headers` and a nonempty stdio `env`: credentials come only from Vaults (`credential_id`) and environment values only from the Environment (`env_vars`) ([MCP](./execution-tools.md#mcp)). diff --git a/contracts/agents-api/zh/harness-capabilities.md b/contracts/agents-api/zh/harness-capabilities.md index 554b43e65..9f1bdb794 100644 --- a/contracts/agents-api/zh/harness-capabilities.md +++ b/contracts/agents-api/zh/harness-capabilities.md @@ -1,7 +1,7 @@ --- title: "Harness 能力" source: contracts/agents-api/harness-capabilities.md -source_hash: 97c83c3295b7150e81118d5fa302463b7781689e533d8906ce1b85baa26b54af +source_hash: aa70b282e3e6d03965bd92c82a2af983a4e3fed1e643cba7289eef6414e3a6ea --- 本页列出每个 Harness 在每种部署位置支持的能力。Core 根据 `internal/harnessconfig//declaration.go` 中 Harness 的声明决定准入,运行 Session 的 Runtime 也必须声明操作。所链接契约定义各操作;[Harness 接入](harness-onboarding.md#qualify-the-adapter)说明资格验证方法。 @@ -29,7 +29,7 @@ source_hash: 97c83c3295b7150e81118d5fa302463b7781689e533d8906ce1b85baa26b54af | `reasoning.summary` | `concise`、`detailed`、`auto`:已准入 | `auto`:已准入 | 已拒绝 | | 非 `auto` 的 `service_tier` | `default`、`flex`、`priority`、`fast`:已准入;`ultrafast` 已拒绝 | 已拒绝 | 已拒绝 | | 非 `medium` 的 `text.verbosity` | 已准入;由原生模型决定 | 已拒绝 | 已拒绝 | -| Session 更新 `agent.model`、`reasoning.effort`(含 null)、`service_tier` 和 `review: auto_review` | 已拒绝 | 已拒绝 | 已拒绝 | +| Session 更新 `agent.model`、`reasoning.effort`、`service_tier` 和 `review: auto_review` | 在下一个根 Turn 应用;`reasoning.effort` 不能重置为 null | 已拒绝 | 已拒绝 | | Session 更新 `agent.tools` | 已拒绝 | 已拒绝 | 已拒绝 | | `compaction.async: true`、`experimental.time_awareness: true`,以及 `automations` 和 `computer_use` 工具 | 已拒绝 | 已拒绝 | 已拒绝 | | [公开 token 用量](sessions-events.md) | 测量计数器 | Null | Null | diff --git a/contracts/agents-api/zh/harness-onboarding.md b/contracts/agents-api/zh/harness-onboarding.md index 445119f64..79ab2813d 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: d2353e23d1424b76a2e8f9d171bcb06e5c5c70a7d56d49ff95b2b7a71cdc080b +source_hash: bfbacab5d0653177ea0c440454f85e5ac68dbea374d58e5119c00010665620d6 --- **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` 依据输入和该 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 恢复标志是连续性断言,而不是配置更改。提供的原生身份必须与保留的所有者匹配;当需要现有历史时,恢复绝不能启动新的根。配置冲突属于错误,而不是热切换。普通连接丢失会让其所有者和句柄退役;[计划性挂起](../../../docs/zh/runtime-protocol.md#preparation-and-execution-order) 则保留已结算的空闲所有者,直至精确匹配且已认证的恢复完成。旧计时器、输出和取消操作不能影响替代所有者。 +**绑定。** Runtime 将其 Executor 记录绑定到 Session、Environment、连接和不可变执行配置。恢复身份和先前 Turn 恢复标志是连续性断言,而不是配置更改。提供的原生身份必须与保留的所有者匹配;当需要现有历史时,恢复绝不能启动新的根。配置冲突属于错误,而不是热切换。唯一例外是声明标为可变的 `turn` 设置(`SessionUpdates.Model`、`ReasoningEffort`、`ReasoningEffortReset`、`ServiceTier`,以及 `Execution.Reviews` 非空时的 `review`):已保留的 Executor 接受 `turn` 在这些设置上不同的 prepare,`StartTurn` 会收到它。适配器要么在 Turn 输入之前以原生方式应用不同的 `turn`,要么不声明该变更。普通连接丢失会让其所有者和句柄退役;[计划性挂起](../../../docs/zh/runtime-protocol.md#preparation-and-execution-order) 则保留已结算的空闲所有者,直至精确匹配且已认证的恢复完成。旧计时器、输出和取消操作不能影响替代所有者。 **每 Turn 状态。** 每个 Turn 都会获得全新的包装器、输出通道和回执状态。引导和函数接口均属于该 Turn。原生回调必须在异步工作开始前捕获来源 Turn,因此迟到事件绝不会被归到当前活动的 Turn 上。原生进程、query 或传输连接、固定能力配置和原生 session 身份均属于 Executor。不要重置已完成的 `sync.Once` 值,也不要复用旧 Turn 对象。 @@ -190,7 +190,7 @@ Runtime 统一解析[状态目录](../../../docs/zh/configuration.md#runtime-res - `SessionUpdates`,即适配器是否在保留的 Executor 的根 Turn 之间原生应用 Session 更新:`Model`、`ReasoningEffort`、`ReasoningEffortReset`(null 强度)和 `ServiceTier`,各自为 `Supported` 或 `Unsupported`。Core 拒绝更改不支持设置的更新,对支持设置的更改会通过下一个 Turn 的设置到达适配器; - 可选的纯限制 `ValidateConfiguration`、`ValidateTools`、`ValidateMessageInput` 和 `ValidateFunctionResult`,在通用检查之外添加原生限制。`ValidateMessageInput` 仅对携带图像的消息运行,在 `MessageImages` 能力检查之后、任何存储之前执行,决定 Harness 接受哪些 `image_url`;Core 只检查引用不为空白。验证函数为 nil 表示通用检查已足够。它们不能查询业务数据、解密凭据或控制原生进程。配置限制最先执行,工具解码错误先于 `ValidateTools`。 -`harnessconfig.NewRegistry` 会验证每个声明,对无效或重复的声明触发 panic:kind 和 label 必须非空,放置位置必须至少列出一个受支持的位置,MCP 来源必须是非 nil 列表,未知或重复的选项以及没有对应放置位置的来源会被拒绝,bearer 支持要求存在 MCP 来源,`Execution` 必须声明文本详细程度 `medium`。共享调度检查能力组合,绝不检查引擎名称。 +`harnessconfig.NewRegistry` 会验证每个声明,对无效或重复的声明触发 panic:kind 和 label 必须非空,放置位置必须至少列出一个受支持的位置,MCP 来源必须是非 nil 列表,未知或重复的选项以及没有对应放置位置的来源会被拒绝,bearer 支持要求存在 MCP 来源,`SessionUpdates` 的每个字段都必须声明,`Execution.Reviews` 只能包含 `auto_review`,`Execution` 必须声明文本详细程度 `medium`。共享调度检查能力组合,绝不检查引擎名称。 `apps/daemon/internal/agent//declaration.go` 中的 Runtime 绑定内嵌同一份声明并添加 `Discover`;它负责原生可用性探测和工厂,并且只把声明的能力收窄到该安装已验证的范围。绑定测试 `TestDiscoveryOnlyNarrowsDeclaration` 检查发现结果不超出声明。`harnessconfig/harnesstest.Declaration` 为测试构造合成声明。 @@ -216,7 +216,7 @@ daemon 的 `clirunner` 让每个原生子进程在自己的 Unix 进程组中启 所属输出管道在主进程退出后仍可读取。消费者在调用 `Wait` 之前耗尽 stdout 和 stderr;`Wait` 会汇合缓存的进程结果并关闭读取器。`Done` 报告主进程回收和进程组清理信号;它不是原生执行回执,也不是历史已持久化的证据。SDK 适配器会结算每个 Turn,并在发布完成状态前耗尽其观察结果;Executor 关闭还会关闭 Query 并等待原生子进程。进程组用于生命周期监管,而不是隔离或遏制离开进程组的后代进程。 -适配器以启动用户的权限无人值守运行原生工具,Harness 从不询问人类。Codex 使用批准策略 `never` 和完全访问权限,在该策略下 Codex 自行处理 MCP elicitation,不会发给客户端;适配器还禁用其阻塞式 `request_user_input` 工具。Claude 通过适配器的工具回调运行,回调直接允许或拒绝、不会询问。MiniMax 绕过权限;适配器禁用 `askUser`、不声明 elicitation,并以 ACP `cancelled` 结果答复 `session/request_permission`,MiniMax 将其视为拒绝。原生权限或问题请求都不会到达 Core:需要人工介入时通过 function 工具完成,Session 读取 [`requires_action`](./sessions-events.md#session-status),由应用提交 function 结果。不要添加权限 profile、bubblewrap 包装器或原生 sandbox 设置;每种 Environment 来源都只有一条执行路径。资源路径属于操作员配置,而不是权限边界。 +适配器以启动用户的权限无人值守运行原生工具,Harness 从不询问人类。Codex 使用批准策略 `never` 和完全访问权限,在该策略下 Codex 自行处理 MCP elicitation,不会发给客户端;在 `auto_review` 下,每个 Turn 改为发送 `on-request` 和 `auto_review` 审查者,其拒绝留在 Codex 内部,仍到达客户端的请求会得到 JSON-RPC -32601,即拒绝;适配器还禁用其阻塞式 `request_user_input` 工具。Claude 通过适配器的工具回调运行,回调直接允许或拒绝、不会询问。MiniMax 绕过权限;适配器禁用 `askUser`、不声明 elicitation,并以 ACP `cancelled` 结果答复 `session/request_permission`,MiniMax 将其视为拒绝。原生权限或问题请求都不会到达 Core:需要人工介入时通过 function 工具完成,Session 读取 [`requires_action`](./sessions-events.md#session-status),由应用提交 function 结果。不要添加权限 profile、bubblewrap 包装器或原生 sandbox 设置;每种 Environment 来源都只有一条执行路径。资源路径属于操作员配置,而不是权限边界。 模型主机不可达时不能让 Turn 一直挂起。适配器通过原生设置让 Harness 自身的连接重试保持有界;Harness 以结构化数据报告最终连接错误时,该错误归为 `connection_failed`([原生失败分类](../../../docs/zh/runtime-protocol.md#native-failure-classification))。Core 不另设模型超时。各适配器指南写明其 Harness 的上限。 diff --git a/contracts/agents-api/zh/index.md b/contracts/agents-api/zh/index.md index c793a13fe..17e215621 100644 --- a/contracts/agents-api/zh/index.md +++ b/contracts/agents-api/zh/index.md @@ -1,7 +1,7 @@ --- title: "OpenAI 兼容性" source: contracts/agents-api/index.md -source_hash: 1bd8a9d1f30c1986d41a2f0dffe0dcd8bdf3570fbc62d777570ef6750c8a4022 +source_hash: 82331da66db2d0caf6f64c861a065948c1a1332a8306e99133dfc9af9a165e3d --- Core 实现下方固定版本的 OpenAI Agents API。本台账列出各项资源的覆盖情况、相对于官方服务的所有差异以及已知缺口。 @@ -84,8 +84,10 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh - Claude Code 在 Skill 展开时运行其内联 shell,因此该命令不产生工具观测。 - Claude Code 在 `environment: none` 上启用 live 搜索时只有 `WebSearch`,因此搜索只产生 `search` 动作,不会打开页面([网页搜索](./execution-tools.md#web-search-and-programmatic-tool-calling))。 - 未指定推理强度时会保持为 null,而不是采用模型的默认值。 +- `agent.model` 为空或只含空白时,Session 创建和更新返回 400;固定版本 schema 允许这样的值。 - Codex 把程序化工具调用映射到原生 `code_mode`,由模型编写的脚本调用其他工具;该功能在上游仍处于开发中。 - Codex 拒绝同时使用程序化工具调用和 `multi_agent`。 +- Codex 的 `review: auto_review` 只审查 Codex 自己升级的操作,例如强制 `rm` 或需要审批的 MCP 工具调用;Codex 在沙箱内拥有完整的文件系统权限,其他命令和文件修改不经审查。其原生 reviewer 通过 Session 的模型提供方调用 `codex-auto-review` 模型,不提供该模型的提供方会以自己的错误拒绝每个升级的操作。 - Codex 不启用原生 goals(`create_goal`、`get_goal` 和 `update_goal`),因为处于活动状态的 goal 会在 Turn 结束后自行启动新的 turn([Codex Runtime](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/codex/README.md))。 - Session 的 `agent.tools` 会省略 `tool_search` 声明。 - Core 拒绝 Session MCP 传输中的内联 `authorization`、`headers` 中的 `Authorization` 条目以及非空的 stdio `env`:凭据只来自 Vault(`credential_id`),环境值只来自 Environment(`env_vars`)([MCP](execution-tools.md#mcp))。 diff --git a/docs/runtime-protocol.md b/docs/runtime-protocol.md index 0a30d9411..0b70287bf 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, except the `turn` settings its Harness declares changeable | | 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 | @@ -220,9 +220,9 @@ 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. 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, and `review` when it is `auto_review`. A retained Executor accepts a different `turn` only in the settings its Harness declares changeable; any other difference is `configuration_conflict`. 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 | +| `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 Session's turn settings against the Harness declaration when the Session is created or updated and 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 0e1e26dec..c06569a0e 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: 1154cf99b5c9d5f2d3d1079b074df946946f2b490a617eac0074cb666ff0385b +source_hash: b5b774b66b84f5efd11650e8b5d6e7b5cd569804ffbc53487ba62bdb584ff3fe --- 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 之间保留,其 Harness 声明可变的 `turn` 设置除外 | | Preparation request ID | prepare、start、release 和 status 的 `Envelope.id`;与 Run 不同 | | Admission handle | Runtime 生成的预约,仅在接受它的连接上有效 | | Run ID | 一次执行尝试;输出、取消、活动输入和 function 的 `Envelope.id` | @@ -222,9 +222,9 @@ Executor 空闲到期属于 Runtime 资源策略,与 Core 的活动 Turn 并 | 字段 | Core 设置方式 | | --- | --- | -| `turn` | 始终设置:根 Turn 的 `model`,以及 Agent 设置时的 `reasoning_effort` 和非 `auto` 的 `service_tier`。只读工作区 preparation 发送空的 `turn` | +| `turn` | 始终设置:根 Turn 的 `model`,以及 Agent 设置时的 `reasoning_effort` 和非 `auto` 的 `service_tier`,`review` 为 `auto_review` 时还有 `review`。已保留的 Executor 只接受其 Harness 声明可变的 `turn` 设置发生变化;其他差异一律为 `configuration_conflict`。只读工作区 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` 设置;原生选项名称属于适配器 | +| `execution_controls` | 始终设置:解析后的 text verbosity(默认 `medium`),以及 Agent 设置的每个选项,包括 web search(mode、`context_size`、`allowed_domains`、`location`)、programmatic tool calling、reasoning summary,以及 `json_schema` 输出格式。Core 在创建或更新 Session 时以及分发前,按 Harness 声明校验这些值和 Session 的 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 b17a1708e..f24592280 100644 --- a/internal/agentdaemon/proto/outbound.go +++ b/internal/agentdaemon/proto/outbound.go @@ -83,6 +83,8 @@ 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/agentdaemon/proto/prototest/wire.go b/internal/agentdaemon/proto/prototest/wire.go index cf056af07..1eadc8936 100644 --- a/internal/agentdaemon/proto/prototest/wire.go +++ b/internal/agentdaemon/proto/prototest/wire.go @@ -35,7 +35,7 @@ const ( // 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"} +var Turn = proto.TurnSettings{Model: "fixture-model", ReasoningEffort: "high", Review: "auto_review"} // 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. diff --git a/internal/agentdaemon/proto/version.go b/internal/agentdaemon/proto/version.go index 8a1077e2c..e0b69c9aa 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.24.0" +const Version = "0.25.0" // VersionCompatible accepts only this contract. Patch drift, prerelease suffixes // and malformed versions do not select an implicit compatibility path. diff --git a/internal/harnessconfig/codex/declaration.go b/internal/harnessconfig/codex/declaration.go index 2aeb62a0f..07789d126 100644 --- a/internal/harnessconfig/codex/declaration.go +++ b/internal/harnessconfig/codex/declaration.go @@ -53,7 +53,8 @@ var Declaration = harnessconfig.Declaration{ // model_verbosity and features.code_mode. Native indexed search has no // official value. Official auto service tier means unset, and fast is Codex's // native alias of priority. Codex ignores a value the selected model does not - // support. + // support. Model, effort, tier and review also go on every turn/start, which + // applies them from that Turn on; Codex cannot clear an effort once set. Execution: harnessconfig.ExecutionOptions{ WebSearchModes: []string{"disabled", "cached", "live"}, WebSearchSettings: true, @@ -62,10 +63,11 @@ var Declaration = harnessconfig.Declaration{ ServiceTiers: []string{"default", "flex", "priority", "fast"}, ProgrammaticToolCalling: []bool{true, false}, TextVerbosities: []string{"low", "medium", "high"}, + Reviews: []string{"auto_review"}, }, Placements: []string{"none", "self_hosted", "openai_hosted"}, MCPOrigins: []string{"service", "environment"}, - SessionUpdates: harnessconfig.SessionUpdates{Model: proto.CapabilityUnsupported, ReasoningEffort: proto.CapabilityUnsupported, ReasoningEffortReset: proto.CapabilityUnsupported, ServiceTier: proto.CapabilityUnsupported}, + SessionUpdates: harnessconfig.SessionUpdates{Model: proto.CapabilitySupported, ReasoningEffort: proto.CapabilitySupported, ReasoningEffortReset: proto.CapabilityUnsupported, ServiceTier: proto.CapabilitySupported}, WhitespaceOnlyText: proto.CapabilitySupported, ValidateMessageInput: rejectRemoteImages, ValidateFunctionResult: func(result proto.FunctionResultPayload) error { diff --git a/internal/harnessconfig/harness.go b/internal/harnessconfig/harness.go index 1a1c063ed..73f4d8fd2 100644 --- a/internal/harnessconfig/harness.go +++ b/internal/harnessconfig/harness.go @@ -439,14 +439,14 @@ func (d Declaration) ValidateExecutionControls(controls *proto.ExecutionControls return nil } -// ValidateTurnSettings rejects an effort or tier the Harness does not +// 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}} { + }{{"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) } @@ -454,31 +454,22 @@ func (d Declaration) ValidateTurnSettings(turn proto.TurnSettings) error { return nil } -// ValidateReview rejects a review value the Harness does not declare. None is -// always accepted. -func (d Declaration) ValidateReview(review string) error { - if review != "" && review != "none" && !slices.Contains(d.Execution.Reviews, review) { - return fmt.Errorf("review %q is not supported by this Harness.", review) - } - return nil -} - -// ValidateSessionUpdate rejects a change of model, effort or tier that the -// Harness does not declare it applies natively between root Turns. Unchanged +// ValidateSessionUpdate rejects a change of model, effort, tier or review that +// the Harness does not declare it applies natively between root Turns. Unchanged // settings pass. func (d Declaration) ValidateSessionUpdate(before, after proto.TurnSettings) error { u := d.SessionUpdates - effort := u.ReasoningEffort + effort, effortChange := u.ReasoningEffort, "changed" if after.ReasoningEffort == "" { - effort = u.ReasoningEffortReset + effort, effortChange = u.ReasoningEffortReset, "reset to null" } for _, check := range []struct { - field string - changed bool - support proto.CapabilitySupport - }{{"agent.model", before.Model != after.Model, u.Model}, {"agent.reasoning.effort", before.ReasoningEffort != after.ReasoningEffort, effort}, {"agent.service_tier", before.ServiceTier != after.ServiceTier, u.ServiceTier}} { + field, change string + changed bool + support proto.CapabilitySupport + }{{"agent.model", "changed", before.Model != after.Model, u.Model}, {"agent.reasoning.effort", effortChange, before.ReasoningEffort != after.ReasoningEffort, effort}, {"agent.service_tier", "changed", before.ServiceTier != after.ServiceTier, u.ServiceTier}, {"review", "changed", before.Review != after.Review, proto.CapabilityFromBool(len(d.Execution.Reviews) > 0)}} { if check.changed && !check.support.IsSupported() { - return fmt.Errorf("%s cannot be changed on this Harness.", check.field) + return fmt.Errorf("%s cannot be %s on this Harness.", check.field, check.change) } } return nil diff --git a/services/core/deploy/codex/README.md b/services/core/deploy/codex/README.md index 35632cab4..04e1c01bd 100644 --- a/services/core/deploy/codex/README.md +++ b/services/core/deploy/codex/README.md @@ -25,7 +25,7 @@ The adapter applies Core's typed controls and opt-ins, described in the [Runtime - **`environment: none`.** The daemon forces `CODEX_EXEC_SERVER_URL=none` and confirms that Codex reports its `local` and `remote` environments as unknown before it starts or resumes a thread; a binary that cannot do this fails closed. The engine process runs on the bound device, which is not a user execution environment. - **Web search.** The control becomes Codex's `web_search` option (`disabled`, `cached` or `live`) with `tools.web_search` `context_size`, `allowed_domains` and `location`. - **Programmatic tool calling.** `enabled: true` turns on the native `code_mode` feature. An explicit disable turns off the native `code_mode`, `code_mode_only` and `code_mode_prewarm` features and checks managed requirements before a thread starts or resumes, rejecting a conflicting requirement. -- **Reasoning, service tier and verbosity.** `reasoning.effort` and `reasoning.summary` become `model_reasoning_effort` and `model_reasoning_summary`, a null `reasoning.summary`, the official default, becomes `none` because Codex's own default depends on the model, a non-`auto` service tier becomes `service_tier` and the text verbosity becomes `model_verbosity`. +- **Reasoning, service tier and verbosity.** `reasoning.effort` and `reasoning.summary` become `model_reasoning_effort` and `model_reasoning_summary`, a null `reasoning.summary`, the official default, becomes `none` because Codex's own default depends on the model, a non-`auto` service tier becomes `service_tier` and the text verbosity becomes `model_verbosity`. These are the launch values. Every `turn/start` also carries the root Turn's `model`, `effort` when set, `serviceTier` (the tier, or null for auto), `approvalPolicy` and `approvalsReviewer`, so a Session update applies from the next Turn and a cold `thread/resume` never restores stale values. Codex cannot clear an effort once set. `review` none sends `never` and `user`; `auto_review` sends `on-request` and `auto_review`, and an escalation that still reaches the client gets JSON-RPC -32601, which Codex turns into a denial. - **Subagents.** Disabling Subagents turns off both native multi-agent feature generations, `multi_agent` and `multi_agent_v2`, overriding the operator's feature preferences. ## MCP servers diff --git a/services/core/internal/db/queries/session_execution_configuration.sql b/services/core/internal/db/queries/session_execution_configuration.sql index 79bfc01e2..bd6221b5d 100644 --- a/services/core/internal/db/queries/session_execution_configuration.sql +++ b/services/core/internal/db/queries/session_execution_configuration.sql @@ -11,4 +11,4 @@ WHERE s.tenant_id = @tenant_id AND s.id = @session_id AND s.deleted_at IS NULL; -- name: MarkSessionModelSource :exec UPDATE session_execution_configuration SET configuration = jsonb_set(configuration, '{model,source}', '"session"') -WHERE session_id = $1; +WHERE session_id = sqlc.arg(session_id) AND sqlc.arg(before)::jsonb #> '{agent,model}' IS DISTINCT FROM sqlc.arg(after)::jsonb #> '{agent,model}'; diff --git a/services/core/internal/db/sqlc/session_execution_configuration.sql.go b/services/core/internal/db/sqlc/session_execution_configuration.sql.go index c9ca7a553..702b53934 100644 --- a/services/core/internal/db/sqlc/session_execution_configuration.sql.go +++ b/services/core/internal/db/sqlc/session_execution_configuration.sql.go @@ -45,11 +45,17 @@ func (q *Queries) GetSessionExecutionConfiguration(ctx context.Context, arg GetS const markSessionModelSource = `-- name: MarkSessionModelSource :exec UPDATE session_execution_configuration SET configuration = jsonb_set(configuration, '{model,source}', '"session"') -WHERE session_id = $1 +WHERE session_id = $1 AND $2::jsonb #> '{agent,model}' IS DISTINCT FROM $3::jsonb #> '{agent,model}' ` -func (q *Queries) MarkSessionModelSource(ctx context.Context, sessionID pgtype.UUID) error { - _, err := q.db.Exec(ctx, markSessionModelSource, sessionID) +type MarkSessionModelSourceParams struct { + SessionID pgtype.UUID `json:"session_id"` + Before []byte `json:"before"` + After []byte `json:"after"` +} + +func (q *Queries) MarkSessionModelSource(ctx context.Context, arg MarkSessionModelSourceParams) error { + _, err := q.db.Exec(ctx, markSessionModelSource, arg.SessionID, arg.Before, arg.After) return err } diff --git a/services/core/internal/execution/controls.go b/services/core/internal/execution/controls.go index cda042253..f1e5d4b10 100644 --- a/services/core/internal/execution/controls.go +++ b/services/core/internal/execution/controls.go @@ -26,7 +26,7 @@ func executionControls(agent v1.Agent, tools executionToolSet) *proto.ExecutionC } // turnSettings assembles the Runtime's settings from the settings a root Turn -// bound. Auto service tier means unset. +// bound. Auto service tier and review none mean unset. func turnSettings(bound sessions.TurnSettings) proto.TurnSettings { turn := proto.TurnSettings{Model: bound.Model} if bound.ReasoningEffort != nil { @@ -35,5 +35,8 @@ func turnSettings(bound sessions.TurnSettings) proto.TurnSettings { if bound.ServiceTier != "auto" { turn.ServiceTier = bound.ServiceTier } + if bound.Review == "auto_review" { + turn.Review = bound.Review + } return turn } diff --git a/services/core/internal/execution/harness_admission.go b/services/core/internal/execution/harness_admission.go index afea54a0c..392726549 100644 --- a/services/core/internal/execution/harness_admission.go +++ b/services/core/internal/execution/harness_admission.go @@ -63,13 +63,10 @@ func validateDeclaredConfiguration(declaration harnessconfig.Declaration, snapsh if err := declaration.ValidateExecutionControls(executionControls(snapshot.Agent, tools)); err != nil { return err } - bound := sessions.TurnSettings{Model: snapshot.Agent.Model, ReasoningEffort: snapshot.Agent.Reasoning.Effort, ServiceTier: snapshot.Agent.ServiceTier} + bound := sessions.TurnSettings{Model: snapshot.Agent.Model, ReasoningEffort: snapshot.Agent.Reasoning.Effort, ServiceTier: snapshot.Agent.ServiceTier, Review: snapshot.Review} if err := declaration.ValidateTurnSettings(turnSettings(bound)); err != nil { return err } - if err := declaration.ValidateReview(snapshot.Review); err != nil { - return err - } request := proto.PromptRequestPayload{ToolSearch: tools.Search, FunctionTools: tools.Functions} if err := request.ValidateToolSearch(declaration.Capabilities.ToolSearch.IsSupported()); err != nil { return err diff --git a/services/core/internal/persistence/postgres/sessionpg/session_writes.go b/services/core/internal/persistence/postgres/sessionpg/session_writes.go index 3404e5604..6dbf4eb9d 100644 --- a/services/core/internal/persistence/postgres/sessionpg/session_writes.go +++ b/services/core/internal/persistence/postgres/sessionpg/session_writes.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "reflect" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/auditpg" @@ -75,19 +74,9 @@ func (s *Store) UpdateSession(ctx context.Context, tenantID, sessionID string, e if err != nil { return err } - before, err := sessions.ExecutionModel(current.Configuration) - if err != nil { - return err - } - after, err := sessions.ExecutionModel(configuration) - if err != nil { + if err := q.MarkSessionModelSource(ctx, sqlc.MarkSessionModelSourceParams{SessionID: row.ID, Before: current.Configuration, After: configuration}); err != nil { return err } - if !reflect.DeepEqual(before, after) { - if err := q.MarkSessionModelSource(ctx, row.ID); err != nil { - return err - } - } if err := auditpg.RecordWriteAudit(ctx, q, tenantID, writeaudit.ActionUpdate, writeaudit.ResourceSession, uuid.UUID(row.ID.Bytes).String(), ""); err != nil { return err } diff --git a/services/core/tests/integration/session_turn_settings_test.go b/services/core/tests/integration/session_turn_settings_test.go index 95856e381..3208e4230 100644 --- a/services/core/tests/integration/session_turn_settings_test.go +++ b/services/core/tests/integration/session_turn_settings_test.go @@ -18,9 +18,11 @@ import ( func TestSessionUpdateBindsModelToLaterTurns(t *testing.T) { h := newDispatchHarness(t) ctx := context.Background() - declared := codex.Declaration.Clone() - declared.SessionUpdates.Model = proto.CapabilitySupported - supporting := execution.Policy{Declarations: harnessconfig.NewRegistry(declared)} + policy := func(model proto.CapabilitySupport) execution.Policy { + declared := codex.Declaration.Clone() + declared.SessionUpdates.Model = model + return execution.Policy{Declarations: harnessconfig.NewRegistry(declared)} + } update := func(policy execution.Policy, model string) error { _, err := sessionService(t, h.s).UpdateSession(ctx, sessions.UpdateSessionCommand{TenantID: h.tenant, SessionID: h.session.ID, Configure: func(engine string, stored json.RawMessage) (json.RawMessage, error) { var configuration map[string]any @@ -40,10 +42,10 @@ func TestSessionUpdateBindsModelToLaterTurns(t *testing.T) { return prompt } first := h.message("first", "Before the update") - if update(execution.Policy{}, "refused-model") == nil { + if update(policy(proto.CapabilityUnsupported), "refused-model") == nil { t.Fatal("a change the Harness does not declare was accepted") } - if err := update(supporting, "later-model"); err != nil { + if err := update(policy(proto.CapabilitySupported), "later-model"); err != nil { t.Fatal(err) } if got := prepared(first.TurnID).Turn.Model; got != "test-model" { diff --git a/services/core/tests/official_session_metadata.py b/services/core/tests/official_session_metadata.py index 64841a36e..966177bf1 100644 --- a/services/core/tests/official_session_metadata.py +++ b/services/core/tests/official_session_metadata.py @@ -117,14 +117,17 @@ def assert_metadata(session, expected): assert list(sessions.list(metadata={key: value, "absent": "x"})) == [] assert list(other.beta.agents.sessions.list(metadata={key: value})) == [] expect_error(BadRequestError, lambda: sessions.list(metadata={"k" * 65: "v"})) - # No built-in Harness applies a settings update natively, so a real change is a typed error; a same-value update succeeds. + # Codex applies model, effort, tier and review changes from the next root Turn; it cannot reset an effort. agent = current.agent - same = {"model": agent.model, "reasoning": {"effort": None}, "service_tier": None} assert agent.reasoning.effort is None and agent.service_tier == "auto" - assert sessions.update(first.id, agent=same, review="none") == current + assert sessions.update(first.id, agent={"model": agent.model, "reasoning": {"effort": None}, "service_tier": None}, review="none") == current assert sessions.update(first.id, agent={}) == current - for fields in [{"review": "auto_review"}, {"agent": {"model": "other"}}, {"agent": {"reasoning": {"effort": "high"}}}, {"agent": {"service_tier": "flex"}}]: - expect_error(BadRequestError, lambda: sessions.update(first.id, **fields)) + changed = sessions.update(first.id, agent={"model": "other", "reasoning": {"effort": "high"}, "service_tier": "flex"}, review="auto_review") + assert (changed.agent.model, changed.agent.reasoning.effort, changed.agent.service_tier, changed.review) == ("other", "high", "flex", "auto_review") + assert sessions.retrieve(first.id) == changed + expect_error(BadRequestError, lambda: sessions.update(first.id, agent={"reasoning": {"effort": None}})) + current = sessions.update(first.id, agent={"model": agent.model, "service_tier": None}, review="none") + assert (current.agent.model, current.agent.reasoning.effort, current.agent.service_tier, current.review) == (agent.model, "high", "auto", None) with httpx2.Client(trust_env=False, timeout=10) as raw: for tools in ([], [{"type": "tool_search"}]): response = raw.post(url, headers=headers, json={"agent": {"tools": tools}})