mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Reject contradicted Patrol causal proposals
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user