Reject contradicted Patrol causal proposals

This commit is contained in:
rcourtman
2026-08-17 19:29:48 +01:00
parent da8537b8e6
commit 6ab45a900c
5 changed files with 200 additions and 1 deletions
@@ -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
+3
View File
@@ -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)
+122 -1
View File
@@ -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
@@ -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)
+13
View File
@@ -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)
}