diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 16b29cccd..64373b8db 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -271,6 +271,10 @@ runtime cost control, and shared AI transport surfaces. backend/API execution boundary, does not mutate the user's persistent AI control setting, and prevents product-originated Patrol action context from becoming silent command authority. + The chat runtime must apply any request-local autonomous-mode override to + both the per-request `AgenticLoop` and the cloned `PulseToolExecutor`; + persistent autonomous settings must not leak into scoped approval-required + handoffs through executor state. The Assistant drawer may also render an attached context briefing for that handoff, but the briefing is runtime context visibility only: it must not mutate chat control settings, execute tools, or reveal raw command payloads. diff --git a/internal/ai/chat/service.go b/internal/ai/chat/service.go index eb2c3b5e7..27e05e224 100644 --- a/internal/ai/chat/service.go +++ b/internal/ai/chat/service.go @@ -568,6 +568,9 @@ func (s *Service) ExecuteStream(ctx context.Context, req ExecuteRequest, callbac if req.AutonomousMode != nil { autonomousMode = *req.AutonomousMode } + if executor != nil { + executor.SetAutonomousMode(autonomousMode) + } selectedModel = configuredModel if overrideModel != "" { selectedModel = overrideModel diff --git a/internal/ai/chat/service_execute_additional_test.go b/internal/ai/chat/service_execute_additional_test.go index 057aea79b..56d9078fb 100644 --- a/internal/ai/chat/service_execute_additional_test.go +++ b/internal/ai/chat/service_execute_additional_test.go @@ -3,9 +3,11 @@ package chat import ( "context" "strings" + "sync/atomic" "testing" "time" + "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" "github.com/rcourtman/pulse-go-rewrite/internal/ai/approval" "github.com/rcourtman/pulse-go-rewrite/internal/ai/providers" "github.com/rcourtman/pulse-go-rewrite/internal/ai/tools" @@ -19,6 +21,27 @@ type stubServiceProvider struct { streamFn func(ctx context.Context, req providers.ChatRequest, callback providers.StreamCallback) error } +type recordingAgentServer struct { + calls atomic.Int32 +} + +func (s *recordingAgentServer) GetConnectedAgents() []agentexec.ConnectedAgent { + return []agentexec.ConnectedAgent{{ + AgentID: "agent-1", + Hostname: "node-1", + }} +} + +func (s *recordingAgentServer) ExecuteCommand(ctx context.Context, agentID string, cmd agentexec.ExecuteCommandPayload) (*agentexec.CommandResultPayload, error) { + s.calls.Add(1) + return &agentexec.CommandResultPayload{ + RequestID: cmd.RequestID, + Success: true, + Stdout: "executed", + ExitCode: 0, + }, nil +} + type handoffUnifiedProvider struct { resources map[unifiedresources.ResourceType][]unifiedresources.Resource } @@ -182,6 +205,100 @@ func TestService_ExecuteStream_Success(t *testing.T) { } } +func TestService_ExecuteStream_RequestAutonomousOverrideUpdatesToolExecutor(t *testing.T) { + installTestApprovalStore(t, nil) + + tmpDir := t.TempDir() + store, err := NewSessionStore(tmpDir) + if err != nil { + t.Fatalf("failed to create session store: %v", err) + } + store.GetSessionFSM("sess-request-override").State = StateReading + + agentServer := &recordingAgentServer{} + executor := tools.NewPulseToolExecutor(tools.ExecutorConfig{ + Policy: agentexec.DefaultPolicy(), + AgentServer: agentServer, + ControlLevel: tools.ControlLevelAutonomous, + }) + executor.SetContext("agent", "agent-1", true) + + providerCallCount := 0 + provider := &stubServiceProvider{ + streamFn: func(ctx context.Context, req providers.ChatRequest, callback providers.StreamCallback) error { + providerCallCount++ + if providerCallCount == 1 { + callback(providers.StreamEvent{ + Type: "tool_start", + Data: providers.ToolStartEvent{ID: "call-restart", Name: "pulse_control"}, + }) + callback(providers.StreamEvent{ + Type: "done", + Data: providers.DoneEvent{ + ToolCalls: []providers.ToolCall{{ + ID: "call-restart", + Name: "pulse_control", + Input: map[string]interface{}{ + "type": "command", + "command": "systemctl restart nginx", + }, + }}, + }, + }) + return nil + } + callback(providers.StreamEvent{ + Type: "content", + Data: providers.ContentEvent{Text: "done"}, + }) + callback(providers.StreamEvent{ + Type: "done", + Data: providers.DoneEvent{InputTokens: 1, OutputTokens: 1}, + }) + return nil + }, + } + + svc := &Service{ + cfg: &config.AIConfig{ + ChatModel: "openai:test", + ControlLevel: config.ControlLevelAutonomous, + }, + sessions: store, + executor: executor, + provider: provider, + started: true, + autonomousMode: true, + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + approvalRequiredMode := false + approvalSeen := false + err = svc.ExecuteStream(ctx, ExecuteRequest{ + SessionID: "sess-request-override", + Prompt: "restart nginx", + AutonomousMode: &approvalRequiredMode, + MaxTurns: 2, + }, func(event StreamEvent) { + if event.Type == "approval_needed" { + approvalSeen = true + cancel() + } + }) + + if err == nil || !strings.Contains(err.Error(), "context canceled") { + t.Fatalf("expected request to stop while waiting for approval, got %v", err) + } + if !approvalSeen { + t.Fatal("expected approval_needed event from request-local approval mode") + } + if got := agentServer.calls.Load(); got != 0 { + t.Fatalf("expected command not to execute before approval, got %d executions", got) + } +} + func TestService_ExecuteStream_HandoffContextIsModelOnly(t *testing.T) { actionNow := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC) actionPlan := testHandoffActionPlan(actionNow) diff --git a/internal/ai/tools/executor.go b/internal/ai/tools/executor.go index e36de1117..16a7e6f49 100644 --- a/internal/ai/tools/executor.go +++ b/internal/ai/tools/executor.go @@ -747,6 +747,12 @@ func (e *PulseToolExecutor) SetContext(targetType, targetID string, autonomous b e.isAutonomous = autonomous } +// SetAutonomousMode updates only the execution mode, preserving any +// session-scoped target context already attached to this executor clone. +func (e *PulseToolExecutor) SetAutonomousMode(enabled bool) { + e.isAutonomous = enabled +} + // SetOrgID sets the org scope used when creating approval records. func (e *PulseToolExecutor) SetOrgID(orgID string) { e.orgID = normalizeExecutorOrgID(orgID) diff --git a/internal/ai/tools/executor_setters_test.go b/internal/ai/tools/executor_setters_test.go index b134a84cf..cf4fddeb9 100644 --- a/internal/ai/tools/executor_setters_test.go +++ b/internal/ai/tools/executor_setters_test.go @@ -38,6 +38,11 @@ func TestPulseToolExecutor_Setters(t *testing.T) { assert.Equal(t, "101", exec.targetID) assert.True(t, exec.isAutonomous) + exec.SetAutonomousMode(false) + assert.Equal(t, "vm", exec.targetType) + assert.Equal(t, "101", exec.targetID) + assert.False(t, exec.isAutonomous) + exec.SetControlLevel(ControlLevelControlled) assert.Equal(t, ControlLevelControlled, exec.controlLevel)