diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index d71095526..6424fa6c0 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -148,6 +148,17 @@ runtime cost control, and shared AI transport surfaces. repeated model attempts must not waive post-write verification or allow a new state-changing tool before the model has supplied current verification evidence through an allowed read/resolve path. + Assistant restored-session and recent-session context is also model-bound + context, not an identity-policy bypass: when the referenced unified resource + is governed or redacted, backend context builders must use the resource + policy label or redacted value, suppress exact location/routing metadata + such as `target_host`, and avoid exposing raw provider IDs, aliases, IPs, or + hostnames to the model unless policy allows them. + Patrol deterministic triage signals are prioritized evidence seeds for the + configured model; they must not be described as a Pulse-authored final + diagnosis, proof that unflagged resources are healthy, or a reason to + prohibit the model from choosing governed tools for adjacent evidence + relevant to its own findings. Patrol runtime failures are part of that runtime contract: provider, model, tool-calling, auth, quota, rate-limit, context-window, and connectivity failures must be classified in `internal/ai/` before they reach operators, diff --git a/internal/ai/chat/fsm.go b/internal/ai/chat/fsm.go index 63d28a2b3..59a257315 100644 --- a/internal/ai/chat/fsm.go +++ b/internal/ai/chat/fsm.go @@ -69,7 +69,8 @@ type SessionFSM struct { ReadAfterWrite bool `json:"read_after_write"` // ConsecutiveVerifyBlocks counts consecutive write attempts blocked in VERIFYING. - // After 3 blocks without a successful read, verification is waived to prevent stuck models. + // Repeated model attempts are telemetry only; they must never waive the + // post-write verification requirement. ConsecutiveVerifyBlocks int `json:"consecutive_verify_blocks,omitempty"` // LastWriteTool records the last write tool for debugging/telemetry @@ -185,14 +186,6 @@ func (fsm *SessionFSM) CanExecuteTool(kind ToolKind, toolName string) error { // In VERIFYING, state-changing tools wait for current verification evidence. if kind == ToolKindWrite { fsm.ConsecutiveVerifyBlocks++ - if fsm.ConsecutiveVerifyBlocks >= 3 { - // Model is stuck - waive verification to prevent infinite blocking. - // Transition back to READING so the write can proceed normally. - fsm.State = StateReading - fsm.ConsecutiveVerifyBlocks = 0 - fsm.ReadAfterWrite = false - return nil // Allow this write to proceed - } return &FSMBlockedError{ State: fsm.State, ToolName: toolName, diff --git a/internal/ai/chat/fsm_test.go b/internal/ai/chat/fsm_test.go index 2566b34b9..3adea738a 100644 --- a/internal/ai/chat/fsm_test.go +++ b/internal/ai/chat/fsm_test.go @@ -200,6 +200,38 @@ func TestFSM_WriteBlockedInVerifyingWithoutRead(t *testing.T) { } } +func TestFSM_VerificationGateDoesNotWaiveAfterRepeatedWriteBlocks(t *testing.T) { + fsm := NewSessionFSM() + fsm.OnToolSuccess(ToolKindResolve, "pulse_query") + fsm.OnToolSuccess(ToolKindWrite, "pulse_control") + + for i := 0; i < 5; i++ { + err := fsm.CanExecuteTool(ToolKindWrite, "pulse_docker") + if err == nil { + t.Fatalf("write attempt %d should stay blocked until a verification read", i+1) + } + if fsm.State != StateVerifying { + t.Fatalf("write attempt %d changed state to %s", i+1, fsm.State) + } + if fsm.ReadAfterWrite { + t.Fatalf("write attempt %d should not mark verification as complete", i+1) + } + } + if fsm.ConsecutiveVerifyBlocks != 5 { + t.Fatalf("expected repeated blocks to be counted for telemetry, got %d", fsm.ConsecutiveVerifyBlocks) + } + + fsm.OnToolSuccess(ToolKindRead, "pulse_metrics") + if fsm.ConsecutiveVerifyBlocks != 0 { + t.Fatalf("verification read should reset repeated block counter, got %d", fsm.ConsecutiveVerifyBlocks) + } + fsm.CompleteVerification() + + if err := fsm.CanExecuteTool(ToolKindWrite, "pulse_docker"); err != nil { + t.Fatalf("write should be allowed after verification evidence, got %v", err) + } +} + func TestFSM_Reset(t *testing.T) { fsm := NewSessionFSM() diff --git a/internal/ai/chat/service.go b/internal/ai/chat/service.go index 955bb220d..76aa35a17 100644 --- a/internal/ai/chat/service.go +++ b/internal/ai/chat/service.go @@ -2806,21 +2806,38 @@ func (s *Service) injectRecentSessionContext(sessionID string, messages []Messag return } + s.mu.RLock() + resourceProvider := s.unifiedResourceProvider + s.mu.RUnlock() + var lines []string primaryName := "" primaryResourceID := "" primaryTarget := "" primaryKind := "" primaryAdapter := "" + primaryAllowsRoutingHint := true for _, resourceID := range recentIDs { res, ok := resolvedCtx.GetResourceByID(resourceID) if !ok || res == nil { continue } + policy, aiSafeSummary := recentSessionResourceGovernance(resourceProvider, resourceID, res) label := res.Name if label == "" { label = resourceID } + if policy != nil || strings.TrimSpace(aiSafeSummary) != "" { + if safeLabel := unifiedresources.ResourcePolicyLabel(label, aiSafeSummary, policy); safeLabel != "" { + label = safeLabel + } else { + label = unifiedresources.ResourcePolicyRedactedValue(label, policy, + unifiedresources.ResourceRedactionAlias, + unifiedresources.ResourceRedactionHostname, + unifiedresources.ResourceRedactionPlatformID, + ) + } + } kind := res.Kind if kind == "" { kind = res.ResourceType @@ -2829,6 +2846,13 @@ func (s *Service) injectRecentSessionContext(sessionID string, messages []Messag if location == "" { location = res.Scope.HostName } + if policy != nil && location != "" { + location = unifiedresources.ResourcePolicyRedactedValue(location, policy, + unifiedresources.ResourceRedactionHostname, + unifiedresources.ResourceRedactionAlias, + unifiedresources.ResourceRedactionPlatformID, + ) + } if kind != "" && location != "" { label = fmt.Sprintf("%s (%s on %s)", label, kind, location) } else if kind != "" { @@ -2841,6 +2865,7 @@ func (s *Service) injectRecentSessionContext(sessionID string, messages []Messag primaryTarget = res.TargetHost primaryKind = kind primaryAdapter = res.Adapter + primaryAllowsRoutingHint = policy == nil if primaryName == "" { primaryName = label } @@ -2858,10 +2883,12 @@ func (s *Service) injectRecentSessionContext(sessionID string, messages []Messag if primaryName == "" { primaryName = primary } - readHint := readRoutingHintForResolvedResource(primaryKind, primaryAdapter, primaryTarget, primaryName, primaryResourceID) - targetHint := readHint.targetFact() - if targetHint != "" { - lines[0] = lines[0] + "; " + targetHint + if primaryAllowsRoutingHint { + readHint := readRoutingHintForResolvedResource(primaryKind, primaryAdapter, primaryTarget, primaryName, primaryResourceID) + targetHint := readHint.targetFact() + if targetHint != "" { + lines[0] = lines[0] + "; " + targetHint + } } log.Debug(). @@ -2881,6 +2908,40 @@ func (s *Service) injectRecentSessionContext(sessionID string, messages []Messag messages[lastIdx].Content = context + "\n\n---\nUser message:\n" + messages[lastIdx].Content } +func recentSessionResourceGovernance(provider tools.UnifiedResourceProvider, resourceID string, res *ResolvedResource) (*unifiedresources.ResourcePolicy, string) { + if provider == nil || res == nil { + return nil, "" + } + resourceType := res.Kind + if resourceType == "" { + resourceType = res.ResourceType + } + node := res.Node + if node == "" { + node = res.Scope.HostName + } + resource, ok := tools.CanonicalHandoffUnifiedResource(provider, firstNonEmptyRecentString(resourceID, res.ResourceID, res.ProviderUID), res.Name, resourceType, node) + if !ok && res.ResourceID != "" && res.ResourceID != resourceID { + resource, ok = tools.CanonicalHandoffUnifiedResource(provider, res.ResourceID, res.Name, resourceType, node) + } + if !ok && res.ProviderUID != "" { + resource, ok = tools.CanonicalHandoffUnifiedResource(provider, res.ProviderUID, res.Name, resourceType, node) + } + if !ok { + return nil, "" + } + return unifiedresources.CanonicalGovernanceMetadata(&resource) +} + +func firstNonEmptyRecentString(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} + func (s *Service) toolsForExecutionMode(autonomousMode bool, patrolMode bool) []providers.Tool { mcpTools := s.executor.ListTools() providerTools := ConvertMCPToolsToProvider(mcpTools) diff --git a/internal/ai/chat/service_recent_context_test.go b/internal/ai/chat/service_recent_context_test.go index b33b960d5..1583603a8 100644 --- a/internal/ai/chat/service_recent_context_test.go +++ b/internal/ai/chat/service_recent_context_test.go @@ -4,6 +4,8 @@ import ( "strings" "testing" "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" ) func TestInjectRecentSessionContext_InjectsNeutralResourceFacts(t *testing.T) { @@ -62,6 +64,62 @@ func TestInjectRecentSessionContext_InjectsNeutralResourceFacts(t *testing.T) { } } +func TestInjectRecentSessionContext_RedactsGovernedResourceFacts(t *testing.T) { + store, err := NewSessionStore(t.TempDir()) + if err != nil { + t.Fatalf("failed to create session store: %v", err) + } + session, err := store.Create() + if err != nil { + t.Fatalf("failed to create session: %v", err) + } + + provider := handoffUnifiedProvider{resources: map[unifiedresources.ResourceType][]unifiedresources.Resource{ + unifiedresources.ResourceTypeVM: {{ + ID: "vm-100", + Type: unifiedresources.ResourceTypeVM, + Name: "finance-vm", + Status: unifiedresources.StatusWarning, + Tags: []string{"pii"}, + Canonical: &unifiedresources.CanonicalIdentity{ + DisplayName: "finance-vm", + Hostname: "finance-vm", + PlatformID: "vm-100", + Aliases: []string{"finance-payroll"}, + }, + Identity: unifiedresources.ResourceIdentity{ + Hostnames: []string{"finance-vm"}, + IPAddresses: []string{"10.0.0.40"}, + }, + Proxmox: &unifiedresources.ProxmoxData{NodeName: "pve-secret"}, + }}, + }} + + resolved := store.GetResolvedContext(session.ID) + resolved.AddResourceWithExplicitAccess("finance-vm", &ResolvedResource{ + ResourceID: "vm:pve-secret:vm-100", + Name: "finance-vm", + Kind: "vm", + ResourceType: "vm", + Node: "pve-secret", + TargetHost: "pve-secret", + }) + + messages := []Message{{Role: "user", Content: "what happened?"}} + service := &Service{unifiedResourceProvider: provider} + service.injectRecentSessionContext(session.ID, messages, store) + + content := messages[0].Content + if !strings.Contains(content, "virtual machine resource; status warning; local-only context") { + t.Fatalf("expected governed safe summary in recent context, got: %s", content) + } + for _, forbidden := range []string{"finance-vm", "vm-100", "pve-secret", "finance-payroll", "10.0.0.40", "target_host="} { + if strings.Contains(content, forbidden) { + t.Fatalf("recent context leaked governed resource fact %q: %s", forbidden, content) + } + } +} + func TestInjectRecentSessionContext_NoRecentDoesNotModify(t *testing.T) { store, err := NewSessionStore(t.TempDir()) if err != nil { diff --git a/internal/ai/patrol_ai.go b/internal/ai/patrol_ai.go index 5200e3600..d66319fa8 100644 --- a/internal/ai/patrol_ai.go +++ b/internal/ai/patrol_ai.go @@ -968,15 +968,15 @@ Always: You are in observation mode. Read-only diagnostic evidence is available, but do not modify anything. Report findings with clear recommendations for the user to review and action manually.` } -const triageSystemPreamble = `You are Pulse Patrol, an autonomous infrastructure analysis agent. +const triageSystemPreamble = `You are Pulse Patrol, a model-owned infrastructure analysis agent. -Deterministic triage has already scanned all resources against thresholds, baselines, backup schedules, disk health, and connectivity. The flagged items are listed in your seed context under "Deterministic Triage Results". +Pulse has assembled deterministic evidence before this turn. The flagged items are listed in your seed context under "Deterministic Triage Results" as prioritized context, not as a final diagnosis and not as proof that unflagged resources are healthy. -Your job is to assess each flagged item. Available evidence sources include historical metrics, logs, backup/replication/RAID details, and resource configuration. +Your job is to assess the provided evidence and decide which items, if any, require attention. Available evidence sources include historical metrics, logs, backup/replication/RAID details, and resource configuration. After investigation, report confirmed issues via patrol_report_finding and resolve any active findings that are no longer problems. -Do NOT re-scan healthy resources. Triage already verified they are within normal parameters. Focus your turns exclusively on the flagged items.` +Use the triage context to avoid broad routine inventory scans, but do not treat the absence of a flag as conclusive. If surrounding evidence or an active finding makes another resource relevant, choose the governed tools you need and explain the model-owned conclusion.` func (p *PatrolService) getPatrolSystemPromptForTriage() string { fullPrompt := p.getPatrolSystemPrompt() diff --git a/internal/ai/patrol_triage_integration_test.go b/internal/ai/patrol_triage_integration_test.go index 991e95b64..1d2615dcc 100644 --- a/internal/ai/patrol_triage_integration_test.go +++ b/internal/ai/patrol_triage_integration_test.go @@ -141,9 +141,15 @@ func TestGetPatrolSystemPromptForTriage(t *testing.T) { }, nil) prompt := ps.getPatrolSystemPromptForTriage() - if !strings.Contains(prompt, "Deterministic triage has already scanned all resources") { + if !strings.Contains(prompt, "Pulse has assembled deterministic evidence before this turn") { t.Fatalf("expected triage preamble in prompt, got:\n%s", prompt) } + if !strings.Contains(prompt, "prioritized context, not as a final diagnosis") { + t.Fatalf("expected triage prompt to preserve model-owned assessment boundary, got:\n%s", prompt) + } + if strings.Contains(prompt, "Triage already verified") || strings.Contains(prompt, "Focus your turns exclusively") { + t.Fatalf("triage prompt must not present deterministic pre-pass as a Pulse-authored judgment boundary, got:\n%s", prompt) + } if !strings.Contains(prompt, "## Investigation Tools") || !strings.Contains(prompt, "pulse_query") { t.Fatalf("expected tool descriptions from base prompt, got:\n%s", prompt) }