From 9ea8cb2aa120df5fc4a0be654c4e1350e92019c5 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Tue, 14 Jul 2026 19:23:36 +0100 Subject: [PATCH] Clarify Patrol scope identities and finding reads --- .../v6/internal/subsystems/ai-runtime.md | 6 ++++++ internal/ai/patrol_ai.go | 19 ++++++++++--------- internal/ai/patrol_ai_more_test.go | 1 + internal/ai/patrol_context_additional_test.go | 15 +++++++++------ internal/ai/patrol_triage_integration_test.go | 2 +- internal/ai/tools/tools_patrol.go | 2 +- internal/ai/tools/tools_patrol_test.go | 6 ++++++ 7 files changed, 34 insertions(+), 17 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 570eb559e..8d2cd1668 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -5705,6 +5705,12 @@ read-only host response when the identity resolves, and returns a successful typed `not_found` result when it does not; a model following the advertised schema must not incur a failed tool call because the executor used a narrower legacy type switch. +Scoped Patrol prompt context must describe canonical/source IDs and aliases as +identity aliases, not count each value as a distinct infrastructure resource. +The exact scoped inventory is the authoritative resource set. Patrol must call +`patrol_get_findings` once near the beginning of a run and reuse that snapshot +for all lifecycle decisions, avoiding duplicate reads and their extra provider +turns on both healthy and faulted runs. Persisted Patrol tool inputs must retain complete structured finding calls up to the bounded 16 KiB record limit so normal evidence-rich findings remain deterministically replayable. If any captured input is nevertheless incomplete diff --git a/internal/ai/patrol_ai.go b/internal/ai/patrol_ai.go index d9d5726be..a0fe5b410 100644 --- a/internal/ai/patrol_ai.go +++ b/internal/ai/patrol_ai.go @@ -1201,13 +1201,13 @@ The seed context includes service identity (from discovery) and reachability dat A direct provider-reported failed health check, failed backup, or broken replication state is already confirmed evidence of an operational symptom. Report that symptom even when logs or command execution are unavailable. Use warning/reliability for a failed health check unless the evidence establishes a critical consequence. State that the root cause is unknown and recommend the next safe diagnostic step; never invent a root cause. Missing optional root-cause evidence must not suppress a confirmed symptom-level finding. -**Step 3 — Report or assess findings.** Report new confirmed issues with patrol_report_finding. For every active finding returned by patrol_get_findings, call patrol_assess_finding exactly once with present, resolved, or uncertain and current evidence. Do not silently skip a known finding: omission is not evidence that it cleared. patrol_resolve_finding remains available for compatibility, but patrol_assess_finding is the complete existing-finding verdict. -Always call patrol_get_findings before reporting, assessing, or resolving findings. +**Step 3 — Report or assess findings.** Report new confirmed issues with patrol_report_finding. Call patrol_get_findings exactly once near the beginning of the run and reuse that result; do not call it again before the final summary. For every active finding it returned, call patrol_assess_finding exactly once with present, resolved, or uncertain and current evidence. Do not silently skip a known finding: omission is not evidence that it cleared. patrol_resolve_finding remains available for compatibility, but patrol_assess_finding is the complete existing-finding verdict. The snapshot eliminates routine data gathering. When a notable signal needs current or historical confirmation, gather enough evidence to distinguish real problems from noise before reporting it. ## Efficiency Rules - Do NOT call the same tool with the same parameters twice in a single patrol run. +- In particular, call patrol_get_findings once and reuse its result for all finding lifecycle decisions in the run. - Keep track of what you've already checked. If you've already retrieved metrics for a resource, use the data you have. - Once direct resource evidence confirms an actionable symptom, report it before pursuing optional root-cause detail. - If a tool reports that a resource lacks the required agent or native capability, do not retry that capability or replace it with a broad inventory scan. Continue with the evidence already available. @@ -1642,7 +1642,7 @@ func (p *PatrolService) assembleSeedWithinBudget(sections []seedSection, budgetT return sb.String() } -func buildScopeSection(scope *PatrolScope, effectiveScopeIDs []string) string { +func buildScopeSection(scope *PatrolScope, effectiveIdentityAliases []string) string { if scope == nil { return "" } @@ -1656,16 +1656,17 @@ func buildScopeSection(scope *PatrolScope, effectiveScopeIDs []string) string { sb.WriteString(fmt.Sprintf("Context: %s\n", scope.Context)) } if len(scope.ResourceIDs) > 0 { - sb.WriteString(fmt.Sprintf("Requested resources: %s\n", strings.Join(scope.ResourceIDs, ", "))) + sb.WriteString(fmt.Sprintf("Resolved requested identity aliases: %s\n", strings.Join(scope.ResourceIDs, ", "))) } if len(scope.ResourceTypes) > 0 { sb.WriteString(fmt.Sprintf("Requested resource types: %s\n", strings.Join(scope.ResourceTypes, ", "))) } - if len(effectiveScopeIDs) > 0 { - sb.WriteString(fmt.Sprintf("Effective scope: %d %s (%s)\n", - len(effectiveScopeIDs), - seedCountLabel(len(effectiveScopeIDs), "resource", "resources"), - seedTruncateOutlierList(effectiveScopeIDs, 8))) + if len(effectiveIdentityAliases) > 0 { + sb.WriteString(fmt.Sprintf("Model-context identity aliases: %d %s (%s)\n", + len(effectiveIdentityAliases), + seedCountLabel(len(effectiveIdentityAliases), "alias", "aliases"), + seedTruncateOutlierList(effectiveIdentityAliases, 8))) + sb.WriteString("Identity aliases are not separate infrastructure resources. Multiple aliases can identify the same scoped resource; do not query each alias. Use the exact scoped inventory rows below as the authoritative resource set.\n") } if scope.AlertIdentifier != "" { sb.WriteString(fmt.Sprintf("Alert Identifier: %s\n", scope.AlertIdentifier)) diff --git a/internal/ai/patrol_ai_more_test.go b/internal/ai/patrol_ai_more_test.go index e5802b773..45d557485 100644 --- a/internal/ai/patrol_ai_more_test.go +++ b/internal/ai/patrol_ai_more_test.go @@ -327,6 +327,7 @@ func TestGetPatrolSystemPrompt_IncludesTrustScaffoldingGuidance(t *testing.T) { "Authoring Impact", "Authoring Evidence", "trust anchor", + "Call patrol_get_findings exactly once", } for _, want := range required { if !strings.Contains(prompt, want) { diff --git a/internal/ai/patrol_context_additional_test.go b/internal/ai/patrol_context_additional_test.go index 9bedecd99..6d5adf726 100644 --- a/internal/ai/patrol_context_additional_test.go +++ b/internal/ai/patrol_context_additional_test.go @@ -57,14 +57,17 @@ func TestPatrolService_buildSeedContext_ScopeSection(t *testing.T) { if !strings.Contains(seed, "Context: CPU alert") { t.Fatalf("expected context in scope section, got:\n%s", seed) } - if !strings.Contains(seed, "Requested resources: node-1") { - t.Fatalf("expected resource IDs in scope section, got:\n%s", seed) + if !strings.Contains(seed, "Resolved requested identity aliases: node-1") { + t.Fatalf("expected resolved identity aliases in scope section, got:\n%s", seed) } if !strings.Contains(seed, "Requested resource types: node") { t.Fatalf("expected resource types in scope section, got:\n%s", seed) } - if !strings.Contains(seed, "Effective scope: 1 resource (node-1)") { - t.Fatalf("expected effective scope in scope section, got:\n%s", seed) + if !strings.Contains(seed, "Model-context identity aliases: 1 alias (node-1)") { + t.Fatalf("expected model-context aliases in scope section, got:\n%s", seed) + } + if !strings.Contains(seed, "Identity aliases are not separate infrastructure resources") { + t.Fatalf("expected identity/resource boundary in scope section, got:\n%s", seed) } if !strings.Contains(seed, "Alert Identifier: alert-123") { t.Fatalf("expected alert identifier in scope section, got:\n%s", seed) @@ -92,7 +95,7 @@ func TestPatrolService_buildSeedContext_TypeScopedEffectiveScopeSection(t *testi } seed, _ := ps.buildSeedContextState(patrolRuntimeStateForTest(ps, state), scope, nil) - if !strings.Contains(seed, "Effective scope: 2 resources (node-1, node-2)") { - t.Fatalf("expected type-scoped effective scope section, got:\n%s", seed) + if !strings.Contains(seed, "Model-context identity aliases: 2 aliases (node-1, node-2)") { + t.Fatalf("expected type-scoped model-context aliases, got:\n%s", seed) } } diff --git a/internal/ai/patrol_triage_integration_test.go b/internal/ai/patrol_triage_integration_test.go index 8e8579458..654e0a323 100644 --- a/internal/ai/patrol_triage_integration_test.go +++ b/internal/ai/patrol_triage_integration_test.go @@ -106,7 +106,7 @@ func TestBuildTriageSeedContext_UnionsExplicitScopeWithFlags(t *testing.T) { t.Fatalf("did not expect unrelated resource %q in triage seed, got:\n%s", unexpected, seed) } } - if !strings.Contains(seed, "Effective scope: 2 resources (qemu/102, qemu/107)") { + if !strings.Contains(seed, "Model-context identity aliases: 2 aliases (qemu/102, qemu/107)") { t.Fatalf("expected scope section to describe the complete evidence set, got:\n%s", seed) } } diff --git a/internal/ai/tools/tools_patrol.go b/internal/ai/tools/tools_patrol.go index 11b91094b..2062fc37d 100644 --- a/internal/ai/tools/tools_patrol.go +++ b/internal/ai/tools/tools_patrol.go @@ -169,7 +169,7 @@ Returns: {"ok": true, "resolved": true} on success.`, e.registry.registerBuiltin(RegisteredTool{ Definition: Tool{ Name: agentcapabilities.PatrolGetFindingsToolName, - Description: `Get currently active patrol findings. Use this to check what findings already exist before reporting new ones (avoids duplicates) and to identify findings that may need resolution. + Description: `Get currently active patrol findings. Call this exactly once near the beginning of a Patrol run, then reuse the result to check what findings already exist before reporting new ones (avoids duplicates) and to identify findings that may need resolution. Do not call it again before the final summary. Returns a list of active findings with their IDs, severity, resource, and title.`, InputSchema: InputSchema{ diff --git a/internal/ai/tools/tools_patrol_test.go b/internal/ai/tools/tools_patrol_test.go index add50a5cc..01cad737e 100644 --- a/internal/ai/tools/tools_patrol_test.go +++ b/internal/ai/tools/tools_patrol_test.go @@ -758,6 +758,7 @@ func TestPatrolToolsRegistered(t *testing.T) { found := map[string]bool{} var resolveTool Tool var reportTool Tool + var getFindingsTool Tool for _, tool := range tools { if tool.Name == "patrol_report_finding" || tool.Name == "patrol_assess_finding" || tool.Name == "patrol_resolve_finding" || tool.Name == "patrol_get_findings" { found[tool.Name] = true @@ -768,6 +769,9 @@ func TestPatrolToolsRegistered(t *testing.T) { if tool.Name == "patrol_report_finding" { reportTool = tool } + if tool.Name == "patrol_get_findings" { + getFindingsTool = tool + } } assert.True(t, found["patrol_report_finding"], "patrol_report_finding should be registered") @@ -782,6 +786,8 @@ func TestPatrolToolsRegistered(t *testing.T) { require.NotEmpty(t, reportTool.Name) assert.Contains(t, reportTool.Description, "failed health check") assert.Contains(t, reportTool.Description, "root cause is unknown") + require.NotEmpty(t, getFindingsTool.Name) + assert.Contains(t, getFindingsTool.Description, "exactly once") } func TestPatrolToolsAvailability(t *testing.T) {