Remove Assistant pre-model context heuristics

This commit is contained in:
rcourtman 2026-05-15 16:28:17 +01:00
parent a50d9457bc
commit 482b84e219
11 changed files with 145 additions and 758 deletions

View file

@ -2956,7 +2956,7 @@
},
{
"id": "RA29",
"summary": "Interactive Pulse Assistant chat behaves like a governed LLM tool surface, not a Pulse-authored intent router: the operator's selected model receives the user turn and governed tools, decides whether tools are needed, and Pulse must not use prompt heuristics to force tool_choice, force a named tool, retry because an expected tool was not used, hide tools from the model by keyword detection, rewrite recent-context turns into Pulse-targeted instructions, or synthesize/prefill/auto-submit product-authored Assistant prompts from handoffs. Pulse only provides context and enforces approvals, resource resolution, FSM gates, and tool policy after that model choice. The chat surface must echo user messages before network/session creation finishes and must not render Pulse-owned explore or internal workflow status cards as assistant output.",
"summary": "Interactive Pulse Assistant chat behaves like a governed LLM tool surface, not a Pulse-authored intent router: the operator's selected model receives the user turn and governed tools, decides whether tools are needed, and Pulse must not use prompt heuristics to force tool_choice, force a named tool, retry because an expected tool was not used, hide tools from the model by keyword detection, rewrite recent-context turns into Pulse-targeted instructions, fuzzy-match plain chat text into resource context before the model acts, synthesize/prefill/auto-submit product-authored Assistant prompts from handoffs, or keyword-match prior remediation history into suggested fixes. Pulse only provides context and enforces approvals, resource resolution, FSM gates, and tool policy after that model choice. The chat surface must echo user messages before network/session creation finishes and must not render Pulse-owned explore or internal workflow status cards as assistant output.",
"kind": "invariant",
"blocking_level": "repo-ready",
"proof_type": "automated",
@ -3004,6 +3004,17 @@
"src/api/__tests__/aiChatEvents.test.ts"
]
},
{
"id": "assistant-context-prefetch-structured-mentions-tests",
"run": [
"go",
"test",
"./internal/ai/chat",
"-run",
"TestContextPrefetcher",
"-count=1"
]
},
{
"id": "assistant-neutral-session-context-tests",
"run": [
@ -3026,6 +3037,18 @@
"-count=1"
]
},
{
"id": "assistant-remediation-memory-context-tests",
"run": [
"go",
"test",
"./internal/ai",
"./internal/ai/memory",
"-run",
"TestService_Remediation|TestService_BuildRemediationContext_Empty|TestService_BuildSystemPrompt_FindingContextDoesNotForceLifecycleTool|TestRemediationLog_(LogAndRetrieve|Stats|RecentStatsBranches|FormatAndStats)",
"-count=1"
]
},
{
"id": "assistant-tool-policy-facts-tests",
"run": [
@ -3179,6 +3202,16 @@
"path": "internal/ai/chat/agentic_final.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/ai/chat/context_prefetch.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/ai/chat/context_prefetch_additional_test.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/ai/chat/read_routing_hints.go",
@ -3209,6 +3242,41 @@
"path": "internal/ai/chat/types.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/ai/memory/memory_coverage_test.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/ai/memory/memory_extended_test.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/ai/memory/memory_test.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/ai/memory/remediation.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/ai/memory/remediation_memory_regression_test.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/ai/service.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/ai/service_remediation_test.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/ai/tools/tools_file.go",

View file

@ -121,7 +121,7 @@ runtime cost control, and shared AI transport surfaces.
1. Update this contract when canonical AI runtime or transport entry points move, including transport-level provider request-shape changes such as OpenAI-compatible `tool_choice` handling, runtime-failure classification splits (for example separating tool-choice request rejection, no tool-capable endpoint, and generic model-level lack of tool support into distinct causes), Patrol-specific verification surfaces such as `POST /api/ai/patrol/preflight` that exercise the full chat-completions path with a minimal tool definition rather than only listing models, Patrol-preflight cache observability where the AI Service caches the most recent preflight outcome (success, soft warning, or classified failure) and the AI settings response surfaces it as `patrol_preflight` so the UI can hydrate a "last verified" indicator without forcing operators to re-run preflight on every page load, the auto-trigger contract on `HandleUpdateAISettings` where the save handler runs `TriggerPatrolPreflightAsync` only when the change actually moved Patrol transport (model swap, provider key for that model changed, or assistant just enabled with a Patrol model) so routine settings saves do not burn provider tokens, the startup-seed contract where the AI Service handler dispatches the same async preflight on Pulse boot when assistant is enabled and a Patrol model is configured so the cache is populated for the first `/api/settings/ai` poll after a restart instead of blanking back to "never verified", the readiness-integration contract where the `tools` check in the Patrol readiness payload consults the cached preflight and surfaces the classified evidence (success, soft warning, or failure with classified summary plus "last preflight <age>") for the configured provider+model when available (falling back to the static `PatrolToolReadinessForModel` classifier only when the cache is empty or holds a result for a different model), the stateless-Patrol-input contract where `ExecutePatrolStream` must pass only the current run's user prompt into the agentic loop rather than reloading the persisted `patrol-main` session history (so a prior run that ended with orphan `tool_calls` cannot poison every subsequent run with malformed conversation structure), and the deterministic-resolve-gate contract where the `patrol_resolve_finding` tool adapter rejects LLM-driven resolves of event/persistent category findings (`backup`, `reliability`, `security`, `general`) when a deterministic verifier exists for the finding's key and that verifier either still detects the failure signal **or returns an inconclusive result** — preventing the LLM from optimistically resolving a finding its current investigation simply didn't re-surface, which was the source of the "Backup failed" flap (detected → auto-resolved → re-detected ten times in a day before this gate). The fail-closed-on-inconclusive policy treats verifier errors (timeouts, executor unavailability, transport faults) as "we don't know" rather than "go ahead": resolution of an event/persistent finding is effectively permanent (next detection registers as a regression and inflates counters), so the safe default is to refuse and require either a successful re-verification or operator action, the assessment-recovery contract where the overall-health "Recent Patrol errors" coverage factor in `summarizeRecentPatrolCoverage` suppresses the score penalty once three consecutive trailing successful full Patrol runs exist at the most-recent end of the recent-runs window — so the grade reflects current reality after a Patrol-affecting bug is fixed rather than dragging stale failures forward for the ~9 hours it takes scheduled runs to age them out of the trailing-10 ratio, the orphan-tool-call-repair contract where `convertToProviderMessages` injects synthetic is_error tool result messages for any `tool_call_id` in an assistant message that has no matching downstream tool result, so a chat session that ended mid-tool-call (network drop, ctx timeout, browser crash) cannot poison its next message with the structural-violation error the provider rejects — the synthetic content is marked is_error=true and explains the interruption so the model can retry the call or proceed without the data, and the patrol-session-bound contract where `ExecutePatrolStream` calls `SessionStore.TrimMessages` after persisting each run's messages to cap the patrol-main session at 200 messages (roughly two recent runs' worth) — without the bound the file grew unbounded at every scheduled run, reaching 16 MB and 3,593 messages within a month and making every `AddMessage` rewrite linearly more expensive; the canonical Patrol forensic log is the `PatrolRunRecord` history surfaced at `/api/ai/patrol/runs`, not the chat-session-shaped file
2. Keep AI runtime and shared API proof routing aligned in `registry.json`
3. Preserve explicit coverage for chat, Patrol, remediation, and cost-control behavior when AI runtime changes. Interactive Assistant and Patrol tool selection must remain model-owned: Pulse may provide governed context, tools, approval state, resource-resolution facts, and safety policy, but it must not add prompt-keyword routers, expected-tool retries, auto-recovery tool calls, or Pulse-authored remediation/finding fallbacks that choose the next investigative or corrective action for the model.
3. Preserve explicit coverage for chat, Patrol, remediation, and cost-control behavior when AI runtime changes. Interactive Assistant and Patrol tool selection must remain model-owned: Pulse may provide governed context, tools, approval state, resource-resolution facts, safety policy, and neutral resource-scoped action history, but it must not add prompt-keyword routers, expected-tool retries, auto-recovery tool calls, keyword-matched prior-fix suggestions, or Pulse-authored remediation/finding fallbacks that choose the next investigative or corrective action for the model.
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,
@ -984,7 +984,16 @@ Session continuity context follows the same boundary: Pulse may provide
neutral recent-resource facts and explicit resource addressing facts, but it
must not use prompt-keyword or pronoun heuristics to rewrite a user message as
targeted, inject log-routing instructions, or tell the model which context is
the answer.
the answer. Pre-model context prefetch may only use structured resource
mentions explicitly selected by the operator; it must not scan plain chat text
for resource names, infer unresolved `@name` references, or inject lookup
results before the selected model chooses whether to use tools.
Legacy remediation memory follows the same boundary. Pulse may provide
resource-scoped prior action history as neutral context, but it must not
keyword-match the current problem against old fixes, label those fixes as
successful matches for the current issue, or use remediation memory to
recommend a command before the selected model has reasoned over current
evidence.
The same runtime ownership now includes the customer-facing AI usage and cost
surface. `frontend-modern/src/components/AI/AICostDashboard.tsx` is the

View file

@ -89,9 +89,9 @@ func resourceRequiresReadOnlyGuidance(resourceType string, supportsControl bool)
}
}
// Prefetch analyzes a user message and proactively gathers relevant context.
// When structuredMentions are provided (from the frontend @ autocomplete), they are used
// directly instead of fuzzy-matching resource names from the message text.
// Prefetch gathers context only for explicit structured mentions selected by
// the user. Plain chat text is left untouched so the selected model decides
// whether it needs tools or more context.
func (p *ContextPrefetcher) Prefetch(ctx context.Context, message string, structuredMentions []StructuredMention) *PrefetchedContext {
log.Info().
Bool("hasReadState", p.readState != nil).
@ -99,6 +99,11 @@ func (p *ContextPrefetcher) Prefetch(ctx context.Context, message string, struct
Int("structured_mentions", len(structuredMentions)).
Msg("[ContextPrefetch] Starting prefetch")
if len(structuredMentions) == 0 {
log.Debug().Msg("[ContextPrefetch] No structured mentions; skipping pre-model context lookup")
return nil
}
if p.readState == nil {
log.Warn().Msg("[ContextPrefetch] No ReadState, cannot prefetch")
return nil
@ -115,47 +120,14 @@ func (p *ContextPrefetcher) Prefetch(ctx context.Context, message string, struct
Int("dockerHosts", dockerHostsCount).
Msg("[ContextPrefetch] Got state for matching")
var mentions []ResourceMention
if len(structuredMentions) > 0 {
// Structured path: frontend already resolved the resources via autocomplete.
// Convert to ResourceMention using ResolveResource for full routing info.
mentions = p.resolveStructuredMentions(structuredMentions)
log.Info().
Int("structured_input", len(structuredMentions)).
Int("resolved", len(mentions)).
Msg("[ContextPrefetch] Resolved structured mentions")
} else {
// Fallback: fuzzy-match resource names from the message text.
// Used when the user types @name manually without selecting from autocomplete.
mentions = p.extractResourceMentions(message)
}
mentions := p.resolveStructuredMentions(structuredMentions)
log.Info().
Int("structured_input", len(structuredMentions)).
Int("resolved", len(mentions)).
Msg("[ContextPrefetch] Resolved structured mentions")
if len(mentions) == 0 {
log.Info().Str("message", message[:min(50, len(message))]).Msg("[ContextPrefetch] No resource mentions found")
// Check for explicit @ mentions that didn't resolve to any resource.
// If the user tagged something with @ but we couldn't find it, tell the AI
// so it doesn't waste tool calls searching for it.
unresolvedMentions := extractExplicitAtMentions(message)
if len(unresolvedMentions) > 0 {
log.Info().
Strs("unresolved", unresolvedMentions).
Msg("[ContextPrefetch] Found unresolved @ mentions")
var sb strings.Builder
sb.WriteString("=== RESOURCE LOOKUP RESULT ===\n")
for _, name := range unresolvedMentions {
sb.WriteString(fmt.Sprintf("'%s' was NOT found in Pulse monitoring. It is not a tracked VM, container, Docker container, or host.\n", name))
}
sb.WriteString("Pulse discovery cannot resolve these names as monitored resources.\n")
sb.WriteString("If their location is already known, command-capable investigation may still apply; otherwise ask the user for the location.\n")
return &PrefetchedContext{
Summary: sb.String(),
}
}
log.Info().Str("message", message[:min(50, len(message))]).Msg("[ContextPrefetch] No structured mentions resolved")
return nil
}
@ -194,356 +166,8 @@ func (p *ContextPrefetcher) Prefetch(ctx context.Context, message string, struct
}
}
// extractResourceMentions finds resource names mentioned in the message
// It supports two modes:
// 1. Explicit @ mentions: @homepage, @influxdb (high confidence, exact match)
// 2. Fuzzy name matching: "homepage" matches "homepage-docker" (fallback)
func (p *ContextPrefetcher) extractResourceMentions(message string) []ResourceMention {
rs := p.readState
messageLower := strings.ToLower(message)
var mentions []ResourceMention
seen := make(map[string]bool) // Deduplicate by name
// Extract words from message (3+ chars) for partial matching
messageWords := extractWords(messageLower)
// Check VMs (via ReadState)
if rs != nil {
for _, vm := range rs.VMs() {
if vm == nil {
continue
}
name := vm.Name()
nameLower := strings.ToLower(name)
if nameLower != "" && len(nameLower) >= 3 && matchesResource(messageLower, messageWords, nameLower) {
if !seen[nameLower] {
seen[nameLower] = true
resolved := unifiedresources.ResolveResourceContext(rs, name)
policy, aiSafeSummary := resourceMentionGovernance(resolved.Resource)
resourceID := ""
if vmid := vm.VMID(); vmid > 0 {
resourceID = strconv.Itoa(vmid)
}
targetID := strings.TrimSpace(vm.Node())
resourceID = firstNonEmptyTrimmed(resourceID, mentionResourceIDFromResolved("vm", resolved.Resource))
targetID = firstNonEmptyTrimmed(targetID, mentionTargetIDFromResolved("vm", resolved.Resource))
mentions = append(mentions, ResourceMention{
Name: name,
ResourceType: "vm",
ResourceID: resourceID,
TargetID: targetID,
Adapter: mentionAdapterFromResolved(resolved.Resource),
MatchedText: name,
Policy: policy,
AISafeSummary: aiSafeSummary,
UnifiedResourceID: resolvedUnifiedResourceID(resolved.Resource),
SupportsControl: resourceSupportsControl("vm", resolved.Resource),
})
}
}
}
}
// Check system containers (LXC via ReadState)
if rs != nil {
for _, ct := range rs.Containers() {
if ct == nil {
continue
}
name := ct.Name()
nameLower := strings.ToLower(name)
if nameLower != "" && len(nameLower) >= 3 && matchesResource(messageLower, messageWords, nameLower) {
if !seen[nameLower] {
seen[nameLower] = true
resolved := unifiedresources.ResolveResourceContext(rs, name)
policy, aiSafeSummary := resourceMentionGovernance(resolved.Resource)
resourceID := ""
if vmid := ct.VMID(); vmid > 0 {
resourceID = strconv.Itoa(vmid)
}
targetID := strings.TrimSpace(ct.Node())
resourceID = firstNonEmptyTrimmed(resourceID, mentionResourceIDFromResolved("system-container", resolved.Resource))
targetID = firstNonEmptyTrimmed(targetID, mentionTargetIDFromResolved("system-container", resolved.Resource))
mentions = append(mentions, ResourceMention{
Name: name,
ResourceType: "system-container",
ResourceID: resourceID,
TargetID: targetID,
Adapter: mentionAdapterFromResolved(resolved.Resource),
MatchedText: name,
Policy: policy,
AISafeSummary: aiSafeSummary,
UnifiedResourceID: resolvedUnifiedResourceID(resolved.Resource),
SupportsControl: resourceSupportsControl("system-container", resolved.Resource),
})
}
}
}
}
// Check Docker containers (via ReadState) - use ResolveResource for authoritative location
if rs != nil {
// Build a map from unified resource ID → source host ID so container
// ParentID (unified) can be resolved to the original models.DockerHost.ID
// that discovery and other subsystems expect.
dockerHostSourceIDs := make(map[string]string)
for _, dh := range rs.DockerHosts() {
if dh == nil {
continue
}
dockerHostSourceIDs[dh.ID()] = dh.HostSourceID()
}
for _, container := range rs.DockerContainers() {
if container == nil {
continue
}
name := container.Name()
nameLower := strings.ToLower(name)
if nameLower != "" && len(nameLower) >= 3 && matchesResource(messageLower, messageWords, nameLower) {
if !seen[nameLower] {
seen[nameLower] = true
// Capture bind mounts
var mounts []MountInfo
for _, m := range container.Mounts() {
if m.Source != "" && m.Destination != "" {
mounts = append(mounts, MountInfo{
Source: m.Source,
Destination: m.Destination,
})
}
}
// Resolve parent ID to original source host ID
hostID := dockerHostSourceIDs[container.ParentID()]
if hostID == "" {
hostID = container.ParentID()
}
// Use the authoritative unified-resource resolver for routing plus policy.
resolved := unifiedresources.ResolveResourceContext(rs, name)
policy, aiSafeSummary := resourceMentionGovernance(resolved.Resource)
loc := resolved.Location
mentions = append(mentions, ResourceMention{
Name: name,
ResourceType: "app-container",
ResourceID: container.ContainerID(),
TargetID: hostID,
Adapter: mentionAdapterFromResolved(resolved.Resource),
MatchedText: name,
Policy: policy,
AISafeSummary: aiSafeSummary,
UnifiedResourceID: resolvedUnifiedResourceID(resolved.Resource),
BindMounts: mounts,
DockerHostName: loc.DockerHostName,
DockerHostType: loc.DockerHostType,
DockerHostVMID: loc.DockerHostVMID,
NodeName: loc.Node,
TargetHost: loc.TargetHost,
})
}
}
}
}
// Check canonical storage resources (including VMware datastores) via ReadState.
if rs != nil {
for _, storage := range rs.StoragePools() {
if storage == nil {
continue
}
name := storage.Name()
nameLower := strings.ToLower(name)
if nameLower != "" && len(nameLower) >= 3 && matchesResource(messageLower, messageWords, nameLower) {
if !seen[nameLower] {
seen[nameLower] = true
resolved := unifiedresources.ResolveResourceContext(rs, name)
policy, aiSafeSummary := resourceMentionGovernance(resolved.Resource)
mentions = append(mentions, ResourceMention{
Name: name,
ResourceType: "storage",
ResourceID: firstNonEmptyTrimmed(strings.TrimSpace(storage.SourceID()), mentionResourceIDFromResolved("storage", resolved.Resource)),
TargetID: firstNonEmptyTrimmed(strings.TrimSpace(storage.Node()), strings.TrimSpace(storage.Instance()), mentionTargetIDFromResolved("storage", resolved.Resource)),
Adapter: mentionAdapterFromResolved(resolved.Resource),
MatchedText: name,
Policy: policy,
AISafeSummary: aiSafeSummary,
UnifiedResourceID: resolvedUnifiedResourceID(resolved.Resource),
SupportsControl: resourceSupportsControl("storage", resolved.Resource),
})
}
}
}
}
// Check Proxmox nodes (via ReadState)
if rs != nil {
for _, node := range rs.Nodes() {
if node == nil {
continue
}
name := node.Name()
nameLower := strings.ToLower(name)
if nameLower != "" && len(nameLower) >= 3 && matchesResource(messageLower, messageWords, nameLower) {
if !seen[nameLower] {
seen[nameLower] = true
resolved := unifiedresources.ResolveResourceContext(rs, name)
policy, aiSafeSummary := resourceMentionGovernance(resolved.Resource)
loc := resolved.Location
mentions = append(mentions, ResourceMention{
Name: name,
ResourceType: "node",
ResourceID: name,
TargetID: name,
MatchedText: name,
Policy: policy,
AISafeSummary: aiSafeSummary,
UnifiedResourceID: resolvedUnifiedResourceID(resolved.Resource),
TargetHost: loc.TargetHost,
})
}
}
}
}
// Check generic Hosts (Windows/Linux via Pulse Unified Agent, via ReadState)
if rs != nil {
for _, host := range rs.Hosts() {
if host == nil {
continue
}
hostname := host.Hostname()
nameLower := strings.ToLower(hostname)
if nameLower != "" && len(nameLower) >= 3 && matchesResource(messageLower, messageWords, nameLower) {
if !seen[nameLower] {
seen[nameLower] = true
resolved := unifiedresources.ResolveResourceContext(rs, hostname)
policy, aiSafeSummary := resourceMentionGovernance(resolved.Resource)
loc := resolved.Location
// Use AgentID which maps to the original models.Host.ID
hostID := host.AgentID()
mentions = append(mentions, ResourceMention{
Name: hostname,
ResourceType: "agent",
ResourceID: hostID,
TargetID: hostID,
Adapter: mentionAdapterFromResolved(resolved.Resource),
MatchedText: hostname,
Policy: policy,
AISafeSummary: aiSafeSummary,
UnifiedResourceID: resolvedUnifiedResourceID(resolved.Resource),
SupportsControl: resourceSupportsControl("agent", resolved.Resource),
TargetHost: loc.TargetHost,
})
}
}
}
}
// Check Kubernetes clusters, pods, and deployments (via ReadState)
if rs != nil {
// Build map from unified resource ID → source cluster ID so pods/deployments
// can resolve TargetID to the original models.KubernetesCluster.ID.
k8sClusterSourceIDs := make(map[string]string)
for _, cluster := range rs.K8sClusters() {
if cluster == nil {
continue
}
k8sClusterSourceIDs[cluster.ID()] = cluster.ClusterID()
clusterName := cluster.Name()
clusterLower := strings.ToLower(clusterName)
if clusterLower != "" && len(clusterLower) >= 3 && matchesResource(messageLower, messageWords, clusterLower) {
if !seen[clusterLower] {
seen[clusterLower] = true
resolved := unifiedresources.ResolveResourceContext(rs, clusterName)
policy, aiSafeSummary := resourceMentionGovernance(resolved.Resource)
loc := resolved.Location
clusterSourceID := cluster.ClusterID()
mentions = append(mentions, ResourceMention{
Name: clusterName,
ResourceType: "k8s-cluster",
ResourceID: clusterSourceID,
TargetID: clusterSourceID,
MatchedText: clusterName,
Policy: policy,
AISafeSummary: aiSafeSummary,
TargetHost: loc.TargetHost,
})
}
}
}
// Check pods (flat list, linked to cluster via ParentID)
for _, pod := range rs.Pods() {
if pod == nil {
continue
}
podName := pod.Name()
podLower := strings.ToLower(podName)
if podLower != "" && len(podLower) >= 3 && matchesResource(messageLower, messageWords, podLower) {
if !seen[podLower] {
seen[podLower] = true
resolved := unifiedresources.ResolveResourceContext(rs, podName)
policy, aiSafeSummary := resourceMentionGovernance(resolved.Resource)
loc := resolved.Location
hostID := k8sClusterSourceIDs[pod.ParentID()]
if hostID == "" {
hostID = pod.ParentID()
}
mentions = append(mentions, ResourceMention{
Name: podName,
ResourceType: "k8s-pod",
ResourceID: podName,
TargetID: hostID,
MatchedText: podName,
Policy: policy,
AISafeSummary: aiSafeSummary,
TargetHost: loc.TargetHost,
})
}
}
}
// Check deployments (flat list, linked to cluster via ParentID)
for _, deploy := range rs.K8sDeployments() {
if deploy == nil {
continue
}
deployName := deploy.Name()
deployLower := strings.ToLower(deployName)
if deployLower != "" && len(deployLower) >= 3 && matchesResource(messageLower, messageWords, deployLower) {
if !seen[deployLower] {
seen[deployLower] = true
resolved := unifiedresources.ResolveResourceContext(rs, deployName)
policy, aiSafeSummary := resourceMentionGovernance(resolved.Resource)
loc := resolved.Location
hostID := k8sClusterSourceIDs[deploy.ParentID()]
if hostID == "" {
hostID = deploy.ParentID()
}
mentions = append(mentions, ResourceMention{
Name: deployName,
ResourceType: "k8s-deployment",
ResourceID: deployName,
TargetID: hostID,
MatchedText: deployName,
Policy: policy,
AISafeSummary: aiSafeSummary,
TargetHost: loc.TargetHost,
})
}
}
}
}
return mentions
}
// resolveStructuredMentions converts frontend StructuredMention objects into ResourceMention
// objects with full routing info. This is the preferred path — no fuzzy matching needed.
// objects with full routing info.
func (p *ContextPrefetcher) resolveStructuredMentions(structured []StructuredMention) []ResourceMention {
var mentions []ResourceMention
rs := p.readState
@ -1208,86 +832,3 @@ func (p *ContextPrefetcher) formatContextSummary(mentions []ResourceMention, dis
return sb.String()
}
// extractWords extracts words (3+ characters) from a message for matching
func extractWords(message string) []string {
// Split on common delimiters and filter short words
var words []string
current := ""
for _, r := range message {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
current += string(r)
} else {
if len(current) >= 3 {
words = append(words, current)
}
current = ""
}
}
if len(current) >= 3 {
words = append(words, current)
}
return words
}
// extractExplicitAtMentions finds @name patterns in a message that look like
// explicit resource mentions typed by the user. Returns the names without the @ prefix.
func extractExplicitAtMentions(message string) []string {
var mentions []string
seen := make(map[string]bool)
for i := 0; i < len(message); i++ {
if message[i] != '@' {
continue
}
// @ must be at start or preceded by whitespace
if i > 0 && message[i-1] != ' ' && message[i-1] != '\t' && message[i-1] != '\n' {
continue
}
// Extract the word after @
start := i + 1
end := start
for end < len(message) && message[end] != ' ' && message[end] != '\t' && message[end] != '\n' {
end++
}
if end > start {
name := message[start:end]
nameLower := strings.ToLower(name)
if len(nameLower) >= 2 && !seen[nameLower] {
seen[nameLower] = true
mentions = append(mentions, name)
}
}
}
return mentions
}
// matchesResource checks if a resource name matches the message using fuzzy matching
// Handles cases like "homepage" matching "homepage-docker"
func matchesResource(messageLower string, messageWords []string, resourceName string) bool {
// Direct containment: message contains full resource name
if strings.Contains(messageLower, resourceName) {
return true
}
// Check if any message word is a significant prefix of the resource name
// e.g., "homepage" matches "homepage-docker"
for _, word := range messageWords {
// Word must be at least 4 chars to avoid false positives
if len(word) >= 4 {
// Check if resource name starts with this word
if strings.HasPrefix(resourceName, word) {
return true
}
// Check if any hyphenated part matches
parts := strings.Split(resourceName, "-")
for _, part := range parts {
if part == word {
return true
}
}
}
}
return false
}

View file

@ -78,16 +78,16 @@ func TestContextPrefetcher_NoReadState(t *testing.T) {
}
}
func TestContextPrefetcher_UnresolvedMention(t *testing.T) {
func TestContextPrefetcher_UnresolvedPlainTextMentionDoesNotInjectContext(t *testing.T) {
state := models.StateSnapshot{}
prefetcher := NewContextPrefetcher(newTestReadState(state), nil)
ctx := prefetcher.Prefetch(context.Background(), "check @missing", nil)
if ctx == nil || !strings.Contains(ctx.Summary, "NOT found") {
t.Fatalf("expected unresolved mention summary, got %#v", ctx)
if ctx != nil {
t.Fatalf("expected plain-text mentions to stay model-owned, got %#v", ctx)
}
}
func TestContextPrefetcher_ExtractResourceMentions(t *testing.T) {
func TestContextPrefetcher_PlainTextResourceNamesDoNotPrefetch(t *testing.T) {
state := models.StateSnapshot{
Nodes: []models.Node{{ID: "node1", Name: "node1"}},
VMs: []models.VM{{ID: "vm-1", Name: "alpha", VMID: 101, Node: "node1"}},
@ -123,25 +123,9 @@ func TestContextPrefetcher_ExtractResourceMentions(t *testing.T) {
rs := newTestReadState(state)
prefetcher := NewContextPrefetcher(rs, nil)
mentions := prefetcher.extractResourceMentions("alpha beta homepage node1 host1 pod1 dep1")
if len(mentions) == 0 {
t.Fatalf("expected mentions to be detected")
}
foundAppContainer := false
for _, m := range mentions {
if m.ResourceType == "app-container" && m.Name == "homepage" {
foundAppContainer = true
if m.TargetHost == "" {
t.Fatalf("expected app-container mention to have target host")
}
if len(m.BindMounts) == 0 {
t.Fatalf("expected app-container bind mounts to be captured")
}
}
}
if !foundAppContainer {
t.Fatalf("expected app-container mention")
ctx := prefetcher.Prefetch(context.Background(), "alpha beta homepage node1 host1 pod1 dep1", nil)
if ctx != nil {
t.Fatalf("expected plain resource names to stay model-owned, got %#v", ctx)
}
}
@ -633,22 +617,6 @@ func TestContextPrefetcher_FormatContextSummary_TrueNASAppUsesNativeResourceRead
}
}
func TestContextPrefetcher_ExtractHelpers(t *testing.T) {
words := extractWords("hello-123 world")
if len(words) != 3 {
t.Fatalf("expected 3 words, got %d", len(words))
}
mentions := extractExplicitAtMentions("ping @alpha and @beta")
if len(mentions) != 2 {
t.Fatalf("expected 2 mentions, got %d", len(mentions))
}
if !matchesResource("homepage docker", []string{"homepage"}, "homepage-docker") {
t.Fatalf("expected fuzzy match to succeed")
}
}
func TestContextPrefetcher_PrefetchStructuredMentions(t *testing.T) {
state := models.StateSnapshot{
VMs: []models.VM{{ID: "vm-1", Name: "alpha", VMID: 101, Node: "node1"}},

View file

@ -947,38 +947,19 @@ func TestNewRemediationLog_LoadError(t *testing.T) {
}
}
func TestRemediationLog_SimilarAndStatsBranches(t *testing.T) {
func TestRemediationLog_RecentStatsBranches(t *testing.T) {
log := NewRemediationLog(RemediationLogConfig{})
if matches := log.GetSimilar("a b c", 5); matches != nil {
t.Fatalf("expected nil for no keywords")
}
_ = log.Log(RemediationRecord{Problem: "memory issue", Action: "a1", Outcome: OutcomePartial})
_ = log.Log(RemediationRecord{Problem: "memory issue", Action: "a2", Outcome: OutcomeFailed})
success := log.GetSuccessfulRemediations("memory issue", 5)
if len(success) != 1 || success[0].Outcome != OutcomePartial {
t.Fatalf("expected partial to be included")
}
_ = log.Log(RemediationRecord{Problem: "unknown", Action: "a3", Outcome: OutcomeUnknown})
stats := log.GetRecentRemediationStats(time.Now().Add(-1 * time.Hour))
if stats["unknown"] == 0 {
t.Fatalf("expected unknown outcome to be counted")
}
}
func TestRemediationLog_GetSuccessfulRemediations_Limit(t *testing.T) {
log := NewRemediationLog(RemediationLogConfig{})
_ = log.Log(RemediationRecord{Problem: "disk full", Action: "a1", Outcome: OutcomeResolved})
_ = log.Log(RemediationRecord{Problem: "disk full", Action: "a2", Outcome: OutcomePartial})
results := log.GetSuccessfulRemediations("disk full", 1)
if len(results) != 1 {
t.Fatalf("expected limited results, got %d", len(results))
}
}
func TestRemediationLog_FormatAndStats(t *testing.T) {
log := NewRemediationLog(RemediationLogConfig{})
_ = log.Log(RemediationRecord{

View file

@ -395,45 +395,6 @@ func TestTruncateOutput(t *testing.T) {
}
}
func TestExtractKeywords(t *testing.T) {
tests := []struct {
input string
minKeywords int
}{
{"High memory usage causing OOM", 3},
{"CPU spike detected", 2},
{"", 0},
{"a b c", 0}, // Short words ignored
}
for _, tt := range tests {
result := extractKeywords(tt.input)
if len(result) < tt.minKeywords {
t.Errorf("extractKeywords(%q) returned %d keywords, expected at least %d", tt.input, len(result), tt.minKeywords)
}
}
}
func TestCountMatches(t *testing.T) {
tests := []struct {
a []string
b []string
expected int
}{
{[]string{"a", "b", "c"}, []string{"b", "c", "d"}, 2},
{[]string{"a", "b"}, []string{"c", "d"}, 0},
{[]string{}, []string{"a"}, 0},
{[]string{"a"}, []string{}, 0},
}
for _, tt := range tests {
result := countMatches(tt.a, tt.b)
if result != tt.expected {
t.Errorf("countMatches(%v, %v) = %d, want %d", tt.a, tt.b, result, tt.expected)
}
}
}
func TestChangeDetector_TrimChanges(t *testing.T) {
d := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 3})

View file

@ -144,58 +144,6 @@ func TestRemediationLog_LogAndRetrieve(t *testing.T) {
}
}
func TestRemediationLog_GetSimilar(t *testing.T) {
r := NewRemediationLog(RemediationLogConfig{MaxRecords: 100})
// Log some remediations
_ = r.Log(RemediationRecord{
ResourceID: "vm-100",
Problem: "High memory usage causing OOM",
Action: "Restart service",
Outcome: OutcomeResolved,
})
_ = r.Log(RemediationRecord{
ResourceID: "vm-200",
Problem: "Memory leak detected",
Action: "Cleared cache",
Outcome: OutcomePartial,
})
_ = r.Log(RemediationRecord{
ResourceID: "vm-300",
Problem: "CPU spike from backup",
Action: "Rescheduled backup",
Outcome: OutcomeResolved,
})
// Search for similar memory issues
similar := r.GetSimilar("High memory usage causing slowdown", 5)
if len(similar) < 1 {
t.Errorf("Expected at least 1 similar record")
}
}
func TestRemediationLog_GetSuccessfulRemediations(t *testing.T) {
r := NewRemediationLog(RemediationLogConfig{MaxRecords: 100})
_ = r.Log(RemediationRecord{
Problem: "Memory usage high",
Action: "Restart service",
Outcome: OutcomeResolved,
})
_ = r.Log(RemediationRecord{
Problem: "Memory usage high",
Action: "Kill process",
Outcome: OutcomeFailed,
})
successful := r.GetSuccessfulRemediations("Memory usage issue", 5)
for _, rec := range successful {
if rec.Outcome != OutcomeResolved && rec.Outcome != OutcomePartial {
t.Errorf("Got unsuccessful remediation in successful list: %s", rec.Outcome)
}
}
}
func TestRemediationLog_Stats(t *testing.T) {
r := NewRemediationLog(RemediationLogConfig{MaxRecords: 100})

View file

@ -172,58 +172,6 @@ func (r *RemediationLog) GetForFinding(findingID string, limit int) []Remediatio
return result
}
// GetSimilar finds remediation records with similar problems
func (r *RemediationLog) GetSimilar(problem string, limit int) []RemediationRecord {
r.mu.RLock()
defer r.mu.RUnlock()
// Simple keyword-based matching
keywords := extractKeywords(problem)
if len(keywords) == 0 {
return nil
}
type scored struct {
record RemediationRecord
score int
}
var matches []scored
for _, record := range r.records {
recordKeywords := extractKeywords(record.Problem)
score := countMatches(keywords, recordKeywords)
if score > 0 {
matches = append(matches, scored{record: record, score: score})
}
}
// Sort by score descending
sort.Slice(matches, func(i, j int) bool {
return matches[i].score > matches[j].score
})
var result []RemediationRecord
for i := 0; i < len(matches) && len(result) < limit; i++ {
result = append(result, matches[i].record)
}
return result
}
// GetSuccessfulRemediations returns successful remediations for similar problems
func (r *RemediationLog) GetSuccessfulRemediations(problem string, limit int) []RemediationRecord {
similar := r.GetSimilar(problem, limit*3) // Get more to filter
var result []RemediationRecord
for _, rec := range similar {
if rec.Outcome == OutcomeResolved || rec.Outcome == OutcomePartial {
result = append(result, rec)
if len(result) >= limit {
break
}
}
}
return result
}
// GetRecentRemediations returns the most recent remediations
func (r *RemediationLog) GetRecentRemediations(limit int, since time.Time) []RemediationRecord {
r.mu.RLock()
@ -417,44 +365,6 @@ func truncateOutput(output string, maxLen int) string {
return output[:maxLen-3] + "..."
}
func extractKeywords(text string) []string {
// Simple keyword extraction - split by common delimiters
// In production, this could use NLP or embeddings
var keywords []string
var current string
for _, c := range text {
if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' {
current += string(c)
} else {
if len(current) > 3 { // Only keep words > 3 chars
keywords = append(keywords, current)
}
current = ""
}
}
if len(current) > 3 {
keywords = append(keywords, current)
}
return keywords
}
func countMatches(a, b []string) int {
bSet := make(map[string]bool)
for _, s := range b {
bSet[s] = true
}
count := 0
for _, s := range a {
if bSet[s] {
count++
}
}
return count
}
// GetByID returns a remediation record by its ID.
func (r *RemediationLog) GetByID(id string) (*RemediationRecord, bool) {
r.mu.RLock()

View file

@ -42,7 +42,7 @@ func TestRemediationLogMemoryStability(t *testing.T) {
}
}
_ = log.GetForResource("vm-01", 5)
_ = log.GetSimilar("memory spike", 5)
_ = log.GetRecentRemediations(5, time.Now().Add(-24*time.Hour))
}
for i := 0; i < warmupCycles; i++ {

View file

@ -4197,22 +4197,21 @@ Latest version: https://api.github.com/repos/rcourtman/Pulse/releases/latest`, c
prompt += fmt.Sprintf("\n\n## Current Focus\nYou are analyzing %s '%s'", req.TargetType, req.TargetID)
}
// Add past remediation history for this resource
prompt += s.buildRemediationContext(req.TargetID, req.Prompt)
// Add prior action history for this resource as neutral context.
prompt += s.buildRemediationContext(req.TargetID)
}
// If we're helping fix a patrol finding, tell the AI the finding ID so it can resolve or dismiss it
// If we're helping with a patrol finding, provide the finding ID and the
// available lifecycle tools without forcing a tool choice.
if req.FindingID != "" {
prompt += fmt.Sprintf("\n\n## Patrol Finding Context\n"+
"You are helping with patrol finding **%s**.\n\n"+
"**After investigating, use ONE of these tools:**\n"+
"- `resolve_finding` - Use when you've actually FIXED the underlying issue\n"+
"- `dismiss_finding` - Use when the finding is a FALSE POSITIVE or EXPECTED BEHAVIOR\n\n"+
"**Examples:**\n"+
"- Issue fixed: `resolve_finding(finding_id=\"%s\", resolution_note=\"Restarted service\")`\n"+
"- False positive: `dismiss_finding(finding_id=\"%s\", reason=\"expected_behavior\", note=\"Storage restricted to specific node is intentional\")`\n",
req.FindingID, req.FindingID, req.FindingID)
"Lifecycle tools are available when current evidence supports them:\n"+
"- `resolve_finding` records that the underlying issue has actually been fixed.\n"+
"- `dismiss_finding` records a false positive or expected behavior.\n"+
"Only call a lifecycle tool when that is the model's conclusion from the current evidence.\n",
req.FindingID)
}
// Add any provided context in a structured way
@ -4695,8 +4694,10 @@ func (s *Service) Reload() error {
return nil
}
// buildRemediationContext adds past remediation history to help AI learn from previous fixes
func (s *Service) buildRemediationContext(resourceID, currentProblem string) string {
// buildRemediationContext adds resource-scoped remediation history as neutral context.
// Pulse must not keyword-match the current problem to old fixes or recommend a
// prior command; the selected model owns diagnosis and remediation reasoning.
func (s *Service) buildRemediationContext(resourceID string) string {
s.mu.RLock()
patrol := s.patrolService
s.mu.RUnlock()
@ -4710,28 +4711,12 @@ func (s *Service) buildRemediationContext(resourceID, currentProblem string) str
return ""
}
var context string
// Get similar past remediations based on the current problem
if currentProblem != "" {
successful := remLog.GetSuccessfulRemediations(currentProblem, 3)
if len(successful) > 0 {
context += "\n\n## Past Successful Fixes for Similar Issues\n"
context += "These actions worked for similar problems before:\n"
for _, rec := range successful {
context += fmt.Sprintf("- **%s**: `%s` (%s)\n",
truncateString(rec.Problem, 60),
truncateString(rec.Action, 80),
rec.Outcome)
}
}
}
// Get history for this specific resource
if resourceID != "" {
history := remLog.GetForResource(resourceID, 5)
if len(history) > 0 {
context += "\n\n## Remediation History for This Resource\n"
var context string
context += "\n\n## Prior Actions Recorded For This Resource\n"
context += "These are historical actions only. Decide from current evidence whether they are relevant.\n"
for _, rec := range history {
ago := time.Since(rec.Timestamp)
agoStr := formatDuration(ago)
@ -4741,10 +4726,11 @@ func (s *Service) buildRemediationContext(resourceID, currentProblem string) str
truncateString(rec.Action, 60),
rec.Outcome)
}
return context
}
}
return context
return ""
}
// buildIncidentContext adds alert-scoped incident memory first, then the

View file

@ -44,18 +44,18 @@ func TestService_Remediation(t *testing.T) {
}
// Test buildRemediationContext
ctx := svc.buildRemediationContext("vm-101", "High CPU")
if !containsString(ctx, "Remediation History for This Resource") {
ctx := svc.buildRemediationContext("vm-101")
if !containsString(ctx, "Prior Actions Recorded For This Resource") {
t.Error("Expected remediation history section in context")
}
if !containsString(ctx, "systemctl restart myservice") {
t.Error("Expected logged action in context")
}
// Test with similar problem
ctx2 := svc.buildRemediationContext("other-vm", "High CPU")
if !containsString(ctx2, "Past Successful Fixes for Similar Issues") {
t.Error("Expected successful fixes section in context for similar problem")
// Similar problems on other resources must not inject recommended fixes.
ctx2 := svc.buildRemediationContext("other-vm")
if ctx2 != "" {
t.Error("Expected no keyword-matched remediation context for another resource")
}
}
@ -63,12 +63,27 @@ func TestService_BuildRemediationContext_Empty(t *testing.T) {
svc := NewService(nil, nil)
// With no remediationLog set, should return empty
ctx := svc.buildRemediationContext("unknown", "Unknown problem")
ctx := svc.buildRemediationContext("unknown")
if ctx != "" {
t.Error("Expected empty context when no remediation log")
}
}
func TestService_BuildSystemPrompt_FindingContextDoesNotForceLifecycleTool(t *testing.T) {
svc := NewService(nil, nil)
prompt := svc.buildSystemPrompt(ExecuteRequest{FindingID: "finding-123"}, "")
if containsString(prompt, "use ONE of these tools") {
t.Fatalf("finding context must not force a lifecycle tool choice:\n%s", prompt)
}
if containsString(prompt, "Past Successful Fixes for Similar Issues") {
t.Fatalf("finding context must not include keyword-matched remediation suggestions:\n%s", prompt)
}
if !containsString(prompt, "Lifecycle tools are available when current evidence supports them") {
t.Fatalf("expected neutral lifecycle-tool context, got:\n%s", prompt)
}
}
// ============================================================================
// String Utility Tests
// ============================================================================