mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
Keep Pulse runtime boundaries model-owned
This commit is contained in:
parent
251e8844dc
commit
dc0635606d
7 changed files with 179 additions and 18 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue