From 7f80f29db79c8bbd71c66b8e1d7bcfa5ebd89f05 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 14 Aug 2026 18:51:05 +0100 Subject: [PATCH] Bound Patrol evaluator finding writes --- .../v6/internal/subsystems/agent-lifecycle.md | 5 +- .../v6/internal/subsystems/ai-runtime.md | 7 + .../v6/internal/subsystems/api-contracts.md | 11 +- .../internal/subsystems/storage-recovery.md | 11 +- internal/ai/chat/agentic.go | 37 ++++- internal/ai/chat/agentic_additional_test.go | 130 ++++++++++++++++++ internal/ai/chat/agentic_control.go | 9 ++ internal/ai/chat/service.go | 18 ++- .../ai/chat/service_patrol_additional_test.go | 14 +- internal/ai/patrol_ai.go | 15 +- internal/ai/patrol_ai_eval_additional_test.go | 6 + internal/ai/service.go | 15 +- internal/api/chat_service_adapter.go | 15 +- internal/api/contract_test.go | 17 +-- 14 files changed, 255 insertions(+), 55 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index d2dfc3fd6..0c1bf1d5c 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -6296,8 +6296,9 @@ sessions, fleet policy, or update state. ### Patrol follow-up manifests do not confer agent authority `internal/api/chat_service_adapter.go` now forwards a structured tool-name -allowlist for bounded Patrol continuations. This list is AI-runtime metadata -that can only reduce the already projected detection manifest. It does not +allowlist and successful finding-report cap for bounded Patrol continuations. +These are AI-runtime metadata that can only reduce the already projected +detection manifest and model-owned Pulse-state writes. They do not enroll an agent, select an agent identity, enable commands, grant a command session, alter fleet policy, or bypass agent-side preflight and action admission. Agent lifecycle and command authority remain unchanged. diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 5ce704cbb..ba8e487bd 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -7166,6 +7166,13 @@ complete snapshot exists it receives only `patrol_get_findings` plus `patrol_assess_finding`. These are structured call-site allowlists applied after profile projection and may only reduce authority. An unavailable or unknown requested tool fails closed. Prompt text never selects the manifest. +The evaluation pass also carries a typed successful-report budget equal to the +number of unmatched signals it was given. Accepted `patrol_report_finding` +writes consume that budget; once it is exhausted, report authority is removed +and the next provider turn is a tool-free bounded summary. Capped same-turn +report batches execute in provider order, and any excess call fails before +persistence. Ordinary Watch runs carry no report cap, so model-owned discovery +of an open-ended number of independent problems remains intact. Each Patrol invocation, including the main pass and both continuations, receives a fresh infrastructure workflow FSM and resolved-resource context. Its stable session ID remains a forensic-log key only and cannot carry a prior run's read diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 16f406582..b47e2f14a 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -9469,10 +9469,13 @@ the boundary and its published shape. ### Internal Patrol bridge preserves bounded follow-up authority `internal/api/chat_service_adapter.go` forwards the AI runtime's structured -`allowed_tool_names` field across the internal Patrol-to-chat boundary without -deriving it from prompts or widening it. The chat service applies the list only -after the Patrol detection profile has projected its canonical tool manifest; -the list can remove tools but cannot add one, and unknown names fail closed. +`allowed_tool_names` and `max_finding_reports` fields across the internal +Patrol-to-chat boundary without deriving either from prompts or widening them. +The chat service applies the list only after the Patrol detection profile has +projected its canonical tool manifest; the list can remove tools but cannot add +one, and unknown names fail closed. A positive report cap bounds successful +`patrol_report_finding` persistence for the invocation and removes report +authority when reached; zero preserves the ordinary uncapped Watch contract. This is an internal execution contract, not a new HTTP field or client-granted capability. Existing Patrol routes, authorization, tenant binding, payloads, and action approval semantics are unchanged. diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index c2d32252a..28fed51a4 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -5239,8 +5239,9 @@ and authorized recovery behavior. ### Patrol follow-up manifests do not confer storage authority -The structured bounded-tool allowlist forwarded by -`internal/api/chat_service_adapter.go` is an AI-runtime authority reduction. -It adds no storage, backup, snapshot, restore, retention, or recovery tool and -cannot turn a detection pass into a mutation path. Storage and recovery state, -evidence freshness, persistence, and admission contracts remain unchanged. +The structured bounded-tool allowlist and successful finding-report cap +forwarded by `internal/api/chat_service_adapter.go` are AI-runtime authority +reductions. They add no storage, backup, snapshot, restore, retention, or +recovery tool and cannot turn a detection pass into a mutation path. Storage +and recovery state, evidence freshness, persistence, and admission contracts +remain unchanged. diff --git a/internal/ai/chat/agentic.go b/internal/ai/chat/agentic.go index b40aa0dfb..4aba78053 100644 --- a/internal/ai/chat/agentic.go +++ b/internal/ai/chat/agentic.go @@ -386,6 +386,15 @@ func isPatrolFindingLifecycleWrite(toolName string) bool { } } +func containsPatrolFindingReport(toolCalls []providers.ToolCall) bool { + for _, tc := range toolCalls { + if strings.TrimSpace(tc.Name) == agentcapabilities.PatrolReportFindingToolName { + return true + } + } + return false +} + // requiresOrderedPatrolFindingLifecycleExecution identifies a same-turn // read-before-write dependency. Independent reads and independent finding // writes may still run in parallel, but a lifecycle write cannot race the @@ -670,6 +679,7 @@ type AgenticLoop struct { baseSystemPrompt string // Base prompt without mode context maxTurns int maxEvidenceCalls int + maxFindingReports int orgID string executionID string streamIdleTimeout time.Duration @@ -795,6 +805,7 @@ func (a *AgenticLoop) executeWithTools(ctx context.Context, sessionID string, me // before calling ExecuteWithTools, and this avoids races with concurrent sessions. a.mu.Lock() maxTurns := a.maxTurns + maxFindingReports := a.maxFindingReports suppressProviderErrorEvents := a.suppressProviderErrorEvents a.aborted[sessionID] = false a.mu.Unlock() @@ -827,6 +838,7 @@ func (a *AgenticLoop) executeWithTools(ctx context.Context, sessionID string, me patrolFindingRepairAttempted := false // The repair-only extension is bounded to one provider turn toolBlockedLastTurn := false // When true, request final text after budget/loop block investigationProposalCompleted := false + acceptedFindingReports := 0 // Loop detection: track identical tool calls (name + serialized input). // After maxIdenticalCalls identical invocations, the next one is blocked. @@ -1953,7 +1965,11 @@ func (a *AgenticLoop) executeWithTools(ctx context.Context, sessionID string, me emitWorkflowState(callback, "execute", executeMessage, sessionFSMState(fsm), workflowTool) } - orderedPatrolLifecycle := requiresOrderedPatrolFindingLifecycleExecution(toolCalls) + // A capped evaluator must apply its accepted-report budget between + // sibling calls. Keep those batches ordered so a same-turn excess can + // never race past the persistence boundary. + orderedPatrolLifecycle := requiresOrderedPatrolFindingLifecycleExecution(toolCalls) || + (maxFindingReports > 0 && containsPatrolFindingReport(toolCalls)) if len(pendingExec) > 1 && !orderedPatrolLifecycle { log.Info(). Int("tool_count", len(pendingExec)). @@ -1982,8 +1998,22 @@ func (a *AgenticLoop) executeWithTools(ctx context.Context, sessionID string, me Msg("[AgenticLoop] Executing ordered Patrol finding lifecycle batch") } for j, pe := range pendingExec { + findingReport := strings.TrimSpace(pe.tc.Name) == agentcapabilities.PatrolReportFindingToolName + if findingReport && + maxFindingReports > 0 && acceptedFindingReports >= maxFindingReports { + execResults[j] = parallelToolResult{Result: agentcapabilities.NewToolJSONResultWithIsError(map[string]interface{}{ + "error": map[string]interface{}{ + "code": "PATROL_FINDING_REPORT_BUDGET_EXHAUSTED", + "message": fmt.Sprintf("This evaluation has already accepted its maximum of %d finding reports. Summarize the accepted results without another report call.", maxFindingReports), + }, + }, true)} + continue + } r, e := a.executeToolSafely(ctx, pe.tc.ID, pe.tc.Name, pe.tc.Input) execResults[j] = parallelToolResult{Result: r, Err: e} + if findingReport && e == nil && !r.IsError { + acceptedFindingReports++ + } } } @@ -2293,9 +2323,10 @@ func (a *AgenticLoop) executeWithTools(ctx context.Context, sessionID string, me if patrolFindingLifecycleSucceededThisTurn { if a.currentExecutionProfile().NonInteractive() { patrolFindingSummaryPending = true - if patrolFindingLifecycleFailedThisTurn && !patrolFindingRepairTurn && !patrolFindingRepairAttempted { + reportBudgetExhausted := maxFindingReports > 0 && acceptedFindingReports >= maxFindingReports + if patrolFindingLifecycleFailedThisTurn && !reportBudgetExhausted && !patrolFindingRepairTurn && !patrolFindingRepairAttempted { patrolFindingRepairPending = true - } else if !patrolFindingRepairTurn { + } else if !reportBudgetExhausted && !patrolFindingRepairTurn { patrolFindingContinuationPending = true } } else { diff --git a/internal/ai/chat/agentic_additional_test.go b/internal/ai/chat/agentic_additional_test.go index 3d08d1b3e..46d4c091e 100644 --- a/internal/ai/chat/agentic_additional_test.go +++ b/internal/ai/chat/agentic_additional_test.go @@ -45,6 +45,10 @@ func TestAgenticLoop_Setters(t *testing.T) { if loop.maxEvidenceCalls != 5 { t.Fatalf("expected maxEvidenceCalls=5, got %d", loop.maxEvidenceCalls) } + loop.SetMaxFindingReports(3) + if loop.maxFindingReports != 3 { + t.Fatalf("expected maxFindingReports=3, got %d", loop.maxFindingReports) + } loop.SetProviderInfo("provider", "model") if loop.providerName != "provider" || loop.modelName != "model" { @@ -928,6 +932,132 @@ func TestAgenticLoopAllowsSequentialIndependentWatchFindings(t *testing.T) { } } +func TestAgenticLoopStopsFindingContinuationAtAcceptedReportBudget(t *testing.T) { + provider := &stubStreamingProvider{} + executor := tools.NewPulseToolExecutor(tools.ExecutorConfig{}) + executor.ApplyExecutionProfile(tools.ProfilePatrolDetection) + creator := &repairTestPatrolFindingCreator{checked: true} + executor.SetPatrolFindingCreator(creator) + loop := NewAgenticLoop(provider, executor, "focused Patrol evaluator prompt") + loop.SetExecutionProfile(tools.ProfilePatrolDetection) + loop.SetMaxTurns(5) + loop.SetMaxFindingReports(2) + + report := func(resourceID, resourceName, title string) map[string]interface{} { + return map[string]interface{}{ + "key": "container-health-failed", + "severity": "warning", + "category": "reliability", + "resource_id": resourceID, + "resource_name": resourceName, + "resource_type": "app-container", + "title": title, + "description": "Container is running but its health check is unhealthy.", + "recommendation": "Inspect the health endpoint and recent logs.", + "evidence": "Provider state reports running and unhealthy with zero restarts.", + } + } + + providerCalls := 0 + provider.chatStream = func(_ context.Context, req providers.ChatRequest, callback providers.StreamCallback) error { + providerCalls++ + switch providerCalls { + case 1: + call := providers.ToolCall{ID: "report-api", Name: agentcapabilities.PatrolReportFindingToolName, Input: report("app-container-api", "api", "API health check failing")} + callback(providers.StreamEvent{Type: "done", Data: providers.DoneEvent{ToolCalls: []providers.ToolCall{call}}}) + case 2: + if req.System != patrolFindingLifecycleContinuationSystemPrompt || len(req.Tools) != 1 { + t.Fatalf("second evaluator request = %+v, want report-only continuation", req) + } + call := providers.ToolCall{ID: "report-worker", Name: agentcapabilities.PatrolReportFindingToolName, Input: report("app-container-worker", "worker", "Worker health check failing")} + callback(providers.StreamEvent{Type: "done", Data: providers.DoneEvent{ToolCalls: []providers.ToolCall{call}}}) + case 3: + if req.System != patrolFindingLifecycleSummarySystemPrompt || len(req.Tools) != 0 || req.ToolChoice != nil { + t.Fatalf("post-budget evaluator request = %+v, want tool-free bounded summary", req) + } + callback(providers.StreamEvent{Type: "content", Data: providers.ContentEvent{Text: "Two findings recorded."}}) + callback(providers.StreamEvent{Type: "done", Data: providers.DoneEvent{}}) + default: + t.Fatalf("unexpected provider call %d; report authority survived its accepted-write budget", providerCalls) + } + return nil + } + + available := []providers.Tool{{Name: agentcapabilities.PatrolReportFindingToolName}} + result, err := loop.ExecuteWithTools(context.Background(), "bounded-finding-evaluator", []Message{{Role: "user", Content: "Evaluate two unmatched signals."}}, available, func(StreamEvent) {}) + if err != nil { + t.Fatalf("bounded finding evaluator failed: %v", err) + } + if providerCalls != 3 { + t.Fatalf("provider calls = %d, want two reports and one summary", providerCalls) + } + if len(creator.created) != 2 || creator.created[0].ResourceID != "app-container-api" || creator.created[1].ResourceID != "app-container-worker" { + t.Fatalf("created findings = %+v, want exactly the two budgeted reports", creator.created) + } + if len(result) == 0 || result[len(result)-1].Content != "Two findings recorded." { + t.Fatalf("final result = %+v", result) + } +} + +func TestAgenticLoopRejectsSameTurnFindingReportsBeyondBudget(t *testing.T) { + provider := &stubStreamingProvider{} + executor := tools.NewPulseToolExecutor(tools.ExecutorConfig{}) + executor.ApplyExecutionProfile(tools.ProfilePatrolDetection) + creator := &repairTestPatrolFindingCreator{checked: true} + executor.SetPatrolFindingCreator(creator) + loop := NewAgenticLoop(provider, executor, "focused Patrol evaluator prompt") + loop.SetExecutionProfile(tools.ProfilePatrolDetection) + loop.SetMaxTurns(2) + loop.SetMaxFindingReports(1) + + report := func(id string) map[string]interface{} { + return map[string]interface{}{ + "key": "container-health-failed", "severity": "warning", "category": "reliability", + "resource_id": id, "resource_name": id, "resource_type": "app-container", + "title": "Health check failing", "description": "Current health check is failing.", + "recommendation": "Inspect the health endpoint.", "evidence": "Provider reports unhealthy.", + } + } + providerCalls := 0 + provider.chatStream = func(_ context.Context, req providers.ChatRequest, callback providers.StreamCallback) error { + providerCalls++ + if providerCalls == 1 { + callback(providers.StreamEvent{Type: "done", Data: providers.DoneEvent{ToolCalls: []providers.ToolCall{ + {ID: "report-accepted", Name: agentcapabilities.PatrolReportFindingToolName, Input: report("app-container-api")}, + {ID: "report-excess", Name: agentcapabilities.PatrolReportFindingToolName, Input: report("app-container-worker")}, + }}}) + return nil + } + if len(req.Tools) != 0 || req.System != patrolFindingLifecycleSummarySystemPrompt { + t.Fatalf("post-budget request = %+v, want tool-free summary", req) + } + callback(providers.StreamEvent{Type: "content", Data: providers.ContentEvent{Text: "One bounded finding recorded."}}) + callback(providers.StreamEvent{Type: "done", Data: providers.DoneEvent{}}) + return nil + } + + var toolEnds []ToolEndData + _, err := loop.ExecuteWithTools(context.Background(), "same-turn-report-budget", []Message{{Role: "user", Content: "Evaluate one unmatched signal."}}, []providers.Tool{{Name: agentcapabilities.PatrolReportFindingToolName}}, func(event StreamEvent) { + if event.Type != "tool_end" { + return + } + var data ToolEndData + if err := json.Unmarshal(event.Data, &data); err != nil { + t.Fatalf("decode tool_end: %v", err) + } + toolEnds = append(toolEnds, data) + }) + if err != nil { + t.Fatalf("same-turn report budget run failed: %v", err) + } + if len(creator.created) != 1 || creator.created[0].ResourceID != "app-container-api" { + t.Fatalf("persisted reports = %+v, want only first budgeted call", creator.created) + } + if len(toolEnds) != 2 || !toolEnds[0].Success || toolEnds[1].Success || !strings.Contains(toolEnds[1].Output, "PATROL_FINDING_REPORT_BUDGET_EXHAUSTED") { + t.Fatalf("tool results = %+v, want accepted first report and fail-closed excess", toolEnds) + } +} + func TestEnsureFinalTextResponse(t *testing.T) { provider := &stubStreamingProvider{} loop := &AgenticLoop{provider: provider, baseSystemPrompt: "prompt"} diff --git a/internal/ai/chat/agentic_control.go b/internal/ai/chat/agentic_control.go index 2782e9378..7c320482d 100644 --- a/internal/ai/chat/agentic_control.go +++ b/internal/ai/chat/agentic_control.go @@ -107,6 +107,15 @@ func (a *AgenticLoop) SetMaxEvidenceCalls(n int) { a.mu.Unlock() } +// SetMaxFindingReports bounds successful patrol_report_finding writes for a +// focused Patrol invocation. A non-positive value leaves ordinary Watch runs +// uncapped; evaluator passes set the exact number of unmatched signals. +func (a *AgenticLoop) SetMaxFindingReports(n int) { + a.mu.Lock() + a.maxFindingReports = n + a.mu.Unlock() +} + // SetProviderInfo sets the provider/model info for telemetry. func (a *AgenticLoop) SetProviderInfo(provider, model string) { a.mu.Lock() diff --git a/internal/ai/chat/service.go b/internal/ai/chat/service.go index a5784838a..363bdf6b4 100644 --- a/internal/ai/chat/service.go +++ b/internal/ai/chat/service.go @@ -2783,13 +2783,14 @@ func (s *Service) hydrateHandoffResources(sessionID string, handoffResources []H // PatrolRequest represents a patrol execution request within the chat service type PatrolRequest struct { - Prompt string `json:"prompt"` - SystemPrompt string `json:"system_prompt"` - SessionID string `json:"session_id,omitempty"` - ExecutionID string `json:"execution_id,omitempty"` - UseCase string `json:"use_case"` - MaxTurns int `json:"max_turns,omitempty"` - AllowedToolNames []string `json:"allowed_tool_names,omitempty"` + Prompt string `json:"prompt"` + SystemPrompt string `json:"system_prompt"` + SessionID string `json:"session_id,omitempty"` + ExecutionID string `json:"execution_id,omitempty"` + UseCase string `json:"use_case"` + MaxTurns int `json:"max_turns,omitempty"` + MaxFindingReports int `json:"max_finding_reports,omitempty"` + AllowedToolNames []string `json:"allowed_tool_names,omitempty"` } // PatrolResponse contains the results of a patrol execution @@ -2865,6 +2866,9 @@ func (s *Service) ExecutePatrolStream(ctx context.Context, req PatrolRequest, ca if req.MaxTurns > 0 { tempLoop.SetMaxTurns(req.MaxTurns) } + if req.MaxFindingReports > 0 { + tempLoop.SetMaxFindingReports(req.MaxFindingReports) + } // Set provider info for telemetry parts := strings.SplitN(patrolModel, ":", 2) diff --git a/internal/ai/chat/service_patrol_additional_test.go b/internal/ai/chat/service_patrol_additional_test.go index bb7730483..f5a3bc8fc 100644 --- a/internal/ai/chat/service_patrol_additional_test.go +++ b/internal/ai/chat/service_patrol_additional_test.go @@ -286,7 +286,7 @@ func TestService_ExecutePatrolStream_UsesFreshFSMAndAcceptsCoreValidatedFindingW } providerCalls := 0 service.providerFactory = func(string) (providers.StreamingProvider, error) { - return &mockStreamingProvider{chatStreamFunc: func(_ context.Context, _ providers.ChatRequest, callback providers.StreamCallback) error { + return &mockStreamingProvider{chatStreamFunc: func(_ context.Context, req providers.ChatRequest, callback providers.StreamCallback) error { providerCalls++ if providerCalls == 1 { callback(providers.StreamEvent{Type: "done", Data: providers.DoneEvent{ @@ -310,6 +310,9 @@ func TestService_ExecutePatrolStream_UsesFreshFSMAndAcceptsCoreValidatedFindingW }}) return nil } + if req.System != patrolFindingLifecycleSummarySystemPrompt || len(req.Tools) != 0 || req.ToolChoice != nil { + t.Fatalf("post-budget service request = %+v, want tool-free Patrol summary", req) + } callback(providers.StreamEvent{Type: "content", Data: providers.ContentEvent{Text: "Finding recorded."}}) callback(providers.StreamEvent{Type: "done", Data: providers.DoneEvent{InputTokens: 4, OutputTokens: 2}}) return nil @@ -317,10 +320,11 @@ func TestService_ExecutePatrolStream_UsesFreshFSMAndAcceptsCoreValidatedFindingW } resp, err := service.ExecutePatrolStream(context.Background(), PatrolRequest{ - Prompt: "Record the validated signal.", - SessionID: "patrol-eval", - MaxTurns: 3, - AllowedToolNames: []string{agentcapabilities.PatrolReportFindingToolName}, + Prompt: "Record the validated signal.", + SessionID: "patrol-eval", + MaxTurns: 3, + MaxFindingReports: 1, + AllowedToolNames: []string{agentcapabilities.PatrolReportFindingToolName}, }, func(StreamEvent) {}) if err != nil { t.Fatalf("ExecutePatrolStream failed: %v", err) diff --git a/internal/ai/patrol_ai.go b/internal/ai/patrol_ai.go index 5c7c69eb2..569256705 100644 --- a/internal/ai/patrol_ai.go +++ b/internal/ai/patrol_ai.go @@ -1363,13 +1363,14 @@ func (p *PatrolService) runEvaluationPass(ctx context.Context, adapter *patrolFi trace := newPatrolFollowupTraceCollector("evaluation") resp, err := cs.ExecutePatrolStream(ctx, PatrolExecuteRequest{ - Prompt: userPrompt, - SystemPrompt: systemPrompt, - SessionID: "patrol-eval", - ExecutionID: executionID, - UseCase: "patrol", - MaxTurns: 5, - AllowedToolNames: allowedToolNames, + Prompt: userPrompt, + SystemPrompt: systemPrompt, + SessionID: "patrol-eval", + ExecutionID: executionID, + UseCase: "patrol", + MaxTurns: 5, + MaxFindingReports: len(unmatchedSignals), + AllowedToolNames: allowedToolNames, }, trace.callback) if resp != nil { resp.ToolCalls = trace.records() diff --git a/internal/ai/patrol_ai_eval_additional_test.go b/internal/ai/patrol_ai_eval_additional_test.go index 5afcd6842..be6dc19f3 100644 --- a/internal/ai/patrol_ai_eval_additional_test.go +++ b/internal/ai/patrol_ai_eval_additional_test.go @@ -129,6 +129,9 @@ func TestRunEvaluationPass(t *testing.T) { if !reflect.DeepEqual(captured.AllowedToolNames, wantTools) { t.Fatalf("evaluation tools = %v, want %v", captured.AllowedToolNames, wantTools) } + if captured.MaxFindingReports != 1 { + t.Fatalf("evaluation finding report budget = %d, want one unmatched signal", captured.MaxFindingReports) + } } func TestRunEvaluationPassReusesEstablishedFindingSnapshot(t *testing.T) { @@ -153,6 +156,9 @@ func TestRunEvaluationPassReusesEstablishedFindingSnapshot(t *testing.T) { if !reflect.DeepEqual(captured.AllowedToolNames, []string{agentcapabilities.PatrolReportFindingToolName}) { t.Fatalf("evaluation tools = %v, want report only", captured.AllowedToolNames) } + if captured.MaxFindingReports != 1 { + t.Fatalf("evaluation finding report budget = %d, want one unmatched signal", captured.MaxFindingReports) + } if !strings.Contains(captured.Prompt, "finding-1") || !strings.Contains(captured.SystemPrompt, "already included") { t.Fatalf("evaluation did not reuse established snapshot: system=%q prompt=%q", captured.SystemPrompt, captured.Prompt) } diff --git a/internal/ai/service.go b/internal/ai/service.go index 8c45f21d7..db9d1a58b 100644 --- a/internal/ai/service.go +++ b/internal/ai/service.go @@ -200,13 +200,14 @@ type ChatToolResult = agentcapabilities.ProviderToolResult // PatrolExecuteRequest represents a patrol execution request via the chat service type PatrolExecuteRequest struct { - Prompt string `json:"prompt"` - SystemPrompt string `json:"system_prompt"` - SessionID string `json:"session_id,omitempty"` - ExecutionID string `json:"execution_id,omitempty"` - UseCase string `json:"use_case"` // "patrol" — for model selection - MaxTurns int `json:"max_turns,omitempty"` - AllowedToolNames []string `json:"allowed_tool_names,omitempty"` + Prompt string `json:"prompt"` + SystemPrompt string `json:"system_prompt"` + SessionID string `json:"session_id,omitempty"` + ExecutionID string `json:"execution_id,omitempty"` + UseCase string `json:"use_case"` // "patrol" — for model selection + MaxTurns int `json:"max_turns,omitempty"` + MaxFindingReports int `json:"max_finding_reports,omitempty"` + AllowedToolNames []string `json:"allowed_tool_names,omitempty"` } // QuickAnalysisRequest represents a lightweight single-turn analysis request. diff --git a/internal/api/chat_service_adapter.go b/internal/api/chat_service_adapter.go index 2e48a8f57..26e795bb3 100644 --- a/internal/api/chat_service_adapter.go +++ b/internal/api/chat_service_adapter.go @@ -54,13 +54,14 @@ func (a *chatServiceAdapter) ExecutePatrolStream(ctx context.Context, req ai.Pat func adaptPatrolExecuteRequest(req ai.PatrolExecuteRequest) chat.PatrolRequest { return chat.PatrolRequest{ - Prompt: req.Prompt, - SystemPrompt: req.SystemPrompt, - SessionID: req.SessionID, - ExecutionID: req.ExecutionID, - UseCase: req.UseCase, - MaxTurns: req.MaxTurns, - AllowedToolNames: append([]string(nil), req.AllowedToolNames...), + Prompt: req.Prompt, + SystemPrompt: req.SystemPrompt, + SessionID: req.SessionID, + ExecutionID: req.ExecutionID, + UseCase: req.UseCase, + MaxTurns: req.MaxTurns, + MaxFindingReports: req.MaxFindingReports, + AllowedToolNames: append([]string(nil), req.AllowedToolNames...), } } diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 58369851c..6c5e861d1 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -73,15 +73,16 @@ func TestContractPatrolInternalBridgePreservesBoundedToolAuthority(t *testing.T) agentcapabilities.PatrolReportFindingToolName, } got := adaptPatrolExecuteRequest(ai.PatrolExecuteRequest{ - Prompt: "evaluate", - SystemPrompt: "bounded", - SessionID: "patrol-eval", - ExecutionID: "run-1", - UseCase: "patrol", - MaxTurns: 5, - AllowedToolNames: allowed, + Prompt: "evaluate", + SystemPrompt: "bounded", + SessionID: "patrol-eval", + ExecutionID: "run-1", + UseCase: "patrol", + MaxTurns: 5, + MaxFindingReports: 2, + AllowedToolNames: allowed, }) - if got.Prompt != "evaluate" || got.SystemPrompt != "bounded" || got.SessionID != "patrol-eval" || got.ExecutionID != "run-1" || got.UseCase != "patrol" || got.MaxTurns != 5 { + if got.Prompt != "evaluate" || got.SystemPrompt != "bounded" || got.SessionID != "patrol-eval" || got.ExecutionID != "run-1" || got.UseCase != "patrol" || got.MaxTurns != 5 || got.MaxFindingReports != 2 { t.Fatalf("Patrol bridge lost execution metadata: %+v", got) } if !reflect.DeepEqual(got.AllowedToolNames, allowed) {