From 6ab45a900cd5ec822a3e5e05df28c4b6dd0836fe Mon Sep 17 00:00:00 2001 From: rcourtman Date: Mon, 17 Aug 2026 19:29:48 +0100 Subject: [PATCH] Reject contradicted Patrol causal proposals --- .../v6/internal/subsystems/ai-runtime.md | 8 ++ internal/ai/chat/agentic.go | 3 + internal/ai/tools/proposal_capture.go | 123 +++++++++++++++++- internal/ai/tools/proposal_capture_test.go | 54 ++++++++ internal/ai/tools/tools_propose.go | 13 ++ 5 files changed, 200 insertions(+), 1 deletion(-) diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index b668230fe..637b64203 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -82,6 +82,14 @@ identities: the action target and the exact canonical causal resource established by collected evidence. They may be equal, but a cross-resource diagnosis must preserve the peer or dependency as the causal identity and the proposal reason must preserve its observed state and causal chain. The +request-local proposal boundary retains a minimal canonical resource graph from +successful structured query results (resource ID, name, state, and health-check +targets only). When that graph uniquely resolves a claimed causal resource's +health-check target to a different unavailable resource, a proposal that still +names the affected resource as causal is rejected before capture and returned +to the investigation for correction. Ambiguous names and non-structured or +failed evidence never create a causal assertion; core must not guess among +possible resources or retain arbitrary log text at this boundary. The tool-free completion turn receives that accepted record as an evidence checkpoint. Before persistence, core reconciles the Root Cause section against the checkpoint and restores the exact causal identity and recorded basis if the diff --git a/internal/ai/chat/agentic.go b/internal/ai/chat/agentic.go index 99517069a..2e60cb7d7 100644 --- a/internal/ai/chat/agentic.go +++ b/internal/ai/chat/agentic.go @@ -2426,6 +2426,9 @@ agenticLoop: } successfulInvestigationEvidence := isPatrolInvestigationExecution(a.currentExecutionProfile()) && isSuccessfulInvestigationEvidenceResult(tc.Name, resultText, isError) + if successfulInvestigationEvidence && a.executor != nil { + a.executor.RecordProposalEvidence(tc.Name, resultText) + } // Track pending recovery for strict resolution blocks // (FSM blocks are tracked above; strict resolution blocks come from the executor) diff --git a/internal/ai/tools/proposal_capture.go b/internal/ai/tools/proposal_capture.go index fef7c6fe9..fd46cd46a 100644 --- a/internal/ai/tools/proposal_capture.go +++ b/internal/ai/tools/proposal_capture.go @@ -11,6 +11,7 @@ import ( "sync" "github.com/rcourtman/pulse-go-rewrite/internal/actionplanner" + "github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities" unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" ) @@ -113,6 +114,14 @@ type ProposalCapture struct { proposal *CapturedProposal fingerprint string failedAttempts int + evidence map[string]proposalEvidenceResource +} + +type proposalEvidenceResource struct { + ID string + Name string + Status string + HealthcheckTargets []string } func (i ProposalIdentity) clone() ProposalIdentity { @@ -124,7 +133,119 @@ func (i ProposalIdentity) clone() ProposalIdentity { // capability catalog used for validation. The identity is deep-cloned so // later caller-side mutation cannot alter captured correlation. func NewProposalCapture(identity ProposalIdentity, catalog ProposalCatalog) *ProposalCapture { - return &ProposalCapture{identity: identity.clone(), catalog: catalog} + return &ProposalCapture{ + identity: identity.clone(), + catalog: catalog, + evidence: make(map[string]proposalEvidenceResource), + } +} + +// RecordEvidence retains only the small canonical resource graph needed to +// validate a later causal-resource claim. Raw evidence and arbitrary log text +// are never retained here. The graph is populated from successful structured +// query results after the model has observed them, so proposal validation can +// reject a conclusion that contradicts the investigation's own evidence. +func (c *ProposalCapture) RecordEvidence(toolName, content string) { + if c == nil || strings.TrimSpace(toolName) != agentcapabilities.PulseQueryToolName { + return + } + var decoded interface{} + if err := json.Unmarshal([]byte(content), &decoded); err != nil { + return + } + observed := make([]proposalEvidenceResource, 0) + collectProposalEvidenceResources(decoded, &observed) + if len(observed) == 0 { + return + } + + c.mu.Lock() + defer c.mu.Unlock() + if c.evidence == nil { + c.evidence = make(map[string]proposalEvidenceResource) + } + for _, resource := range observed { + resource.ID = unified.CanonicalResourceID(resource.ID) + resource.Name = strings.TrimSpace(resource.Name) + if resource.ID == "" || resource.Name == "" { + continue + } + previous := c.evidence[resource.ID] + if resource.Status == "" { + resource.Status = previous.Status + } + if len(resource.HealthcheckTargets) == 0 { + resource.HealthcheckTargets = previous.HealthcheckTargets + } + c.evidence[resource.ID] = resource + } +} + +func collectProposalEvidenceResources(value interface{}, resources *[]proposalEvidenceResource) { + switch typed := value.(type) { + case []interface{}: + for _, item := range typed { + collectProposalEvidenceResources(item, resources) + } + case map[string]interface{}: + id, _ := typed["id"].(string) + name, _ := typed["name"].(string) + if strings.TrimSpace(id) != "" && strings.TrimSpace(name) != "" { + status, _ := typed["status"].(string) + if strings.TrimSpace(status) == "" { + status, _ = typed["state"].(string) + } + resource := proposalEvidenceResource{ID: id, Name: name, Status: strings.TrimSpace(status)} + if targets, ok := typed["healthcheck_targets"].([]interface{}); ok { + for _, target := range targets { + if text, ok := target.(string); ok && strings.TrimSpace(text) != "" { + resource.HealthcheckTargets = append(resource.HealthcheckTargets, strings.TrimSpace(text)) + } + } + } + *resources = append(*resources, resource) + } + for _, child := range typed { + collectProposalEvidenceResources(child, resources) + } + } +} + +func proposalEvidenceUnavailable(status string) bool { + switch strings.ToLower(strings.TrimSpace(status)) { + case "dead", "exited", "failed", "inactive", "not running", "offline", "stopped": + return true + default: + return false + } +} + +func (c *ProposalCapture) validateCausalResource(causalResourceID string) error { + causalResourceID = unified.CanonicalResourceID(causalResourceID) + c.mu.Lock() + defer c.mu.Unlock() + + claimed, ok := c.evidence[causalResourceID] + if !ok || len(claimed.HealthcheckTargets) == 0 { + return nil + } + for _, target := range claimed.HealthcheckTargets { + matches := make([]proposalEvidenceResource, 0, 1) + for _, candidate := range c.evidence { + if strings.EqualFold(candidate.Name, target) { + matches = append(matches, candidate) + } + } + if len(matches) != 1 || !proposalEvidenceUnavailable(matches[0].Status) { + continue + } + dependency := matches[0] + return fmt.Errorf( + "causal_resource_id %q conflicts with collected canonical evidence: health-check target %q is resource %q with status %q; investigate the implicated dependency and use its advertised capabilities before proposing bounded recovery on the affected resource", + causalResourceID, target, dependency.ID, dependency.Status, + ) + } + return nil } // Capabilities resolves the current advertised action contract for a diff --git a/internal/ai/tools/proposal_capture_test.go b/internal/ai/tools/proposal_capture_test.go index f602ed54d..d75362bec 100644 --- a/internal/ai/tools/proposal_capture_test.go +++ b/internal/ai/tools/proposal_capture_test.go @@ -146,6 +146,60 @@ func TestFailedAttemptsWithoutSuccessAreATypedError(t *testing.T) { assert.Equal(t, 0, failed) } +func TestProposalRejectsCausalResourceContradictedByObservedHealthDependency(t *testing.T) { + catalog := func(_ context.Context, resourceID string) ([]unified.ResourceCapability, error) { + switch resourceID { + case "app-container-client", "app-container-dependency": + return []unified.ResourceCapability{{Name: "restart", MinimumApprovalLevel: unified.ApprovalAdmin}}, nil + default: + return nil, nil + } + } + capture := NewProposalCapture(ProposalIdentity{InvestigationID: "inv-1"}, catalog) + exec := newInvestigationExecutor(t, capture) + + exec.RecordProposalEvidence(agentcapabilities.PulseQueryToolName, `{ + "id":"app-container-client", + "name":"client", + "status":"running", + "health":"unhealthy", + "healthcheck_targets":["dependency"] + }`) + exec.RecordProposalEvidence(agentcapabilities.PulseQueryToolName, `{ + "docker":{"hosts":[{"containers":[ + {"id":"app-container-client","name":"client","state":"running","health":"unhealthy","healthcheck_targets":["dependency"]}, + {"id":"app-container-dependency","name":"dependency","state":"exited","health":"unhealthy"} + ]}]} + }`) + + wrong := map[string]interface{}{ + "resource_id": "app-container-client", + "causal_resource_id": "app-container-client", + "capability_name": "restart", + "reason": "restart the unhealthy client", + } + rejected := executePropose(t, exec, "call-wrong", wrong) + assert.True(t, rejected.IsError) + assert.Contains(t, rejected.Content[0].Text, "app-container-dependency") + assert.Contains(t, rejected.Content[0].Text, `status "exited"`) + + corrected := map[string]interface{}{ + "resource_id": "app-container-dependency", + "causal_resource_id": "app-container-dependency", + "capability_name": "restart", + "reason": "restart the exited dependency required by the unhealthy client", + } + accepted := executePropose(t, exec, "call-corrected", corrected) + assert.False(t, accepted.IsError) + + proposal, failed, err := capture.Outcome() + require.NoError(t, err) + require.NotNil(t, proposal) + assert.Equal(t, "app-container-dependency", proposal.ResourceID) + assert.Equal(t, "app-container-dependency", proposal.CausalResourceID) + assert.Equal(t, 1, failed) +} + func TestSensitiveProposalParamsRejectedWithoutEcho(t *testing.T) { capture := NewProposalCapture(ProposalIdentity{}, testProposalCatalog()) exec := newInvestigationExecutor(t, capture) diff --git a/internal/ai/tools/tools_propose.go b/internal/ai/tools/tools_propose.go index 7f0ef7c17..c04f259d1 100644 --- a/internal/ai/tools/tools_propose.go +++ b/internal/ai/tools/tools_propose.go @@ -168,6 +168,10 @@ func (e *PulseToolExecutor) executeProposeAction(ctx context.Context, args map[s capture.RecordFailedAttempt() return NewErrorResult(fmt.Errorf("resource_id, causal_resource_id, capability_name, and reason are required")), nil } + if err := capture.validateCausalResource(causalResourceID); err != nil { + capture.RecordFailedAttempt() + return NewErrorResult(err), nil + } if err := validateProposalAgainstCatalog(ctx, capture.catalog, resourceID, capabilityName, params); err != nil { capture.RecordFailedAttempt() @@ -249,3 +253,12 @@ func stringArg(args map[string]interface{}, key string) string { func (e *PulseToolExecutor) SetProposalCapture(capture *ProposalCapture) { e.proposalCapture = capture } + +// RecordProposalEvidence updates the request-local proposal validator with a +// successful structured evidence result. It is a no-op outside investigations. +func (e *PulseToolExecutor) RecordProposalEvidence(toolName, content string) { + if e == nil || e.proposalCapture == nil { + return + } + e.proposalCapture.RecordEvidence(toolName, content) +}