Bound Patrol evaluator finding writes

This commit is contained in:
rcourtman 2026-08-14 18:51:05 +01:00
parent 67bbed4c40
commit 7f80f29db7
14 changed files with 255 additions and 55 deletions

View file

@ -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.

View file

@ -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

View file

@ -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.

View file

@ -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.

View file

@ -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 {

View file

@ -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"}

View file

@ -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()

View file

@ -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)

View file

@ -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)

View file

@ -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()

View file

@ -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)
}

View file

@ -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.

View file

@ -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...),
}
}

View file

@ -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) {