Files
pulse/internal/ai/chat/service.go
T
2026-05-17 18:24:13 +01:00

3088 lines
100 KiB
Go

package chat
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/approval"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/cost"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/modelboundary"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/providers"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/tools"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rcourtman/pulse-go-rewrite/pkg/reporting"
"github.com/rs/zerolog/log"
)
// StateProvider is a type alias for models.SnapshotProvider.
// Kept for local convenience; all new code should use models.SnapshotProvider directly.
type StateProvider = models.SnapshotProvider
// CommandPolicy evaluates command security
type CommandPolicy interface {
Evaluate(command string) agentexec.PolicyDecision
}
// AgentServer executes commands on agents
type AgentServer interface {
GetConnectedAgents() []agentexec.ConnectedAgent
ExecuteCommand(ctx context.Context, agentID string, cmd agentexec.ExecuteCommandPayload) (*agentexec.CommandResultPayload, error)
}
// MCP provider type aliases for external use
type (
MCPAlertProvider = tools.AlertProvider
MCPFindingsProvider = tools.FindingsProvider
MCPBaselineProvider = tools.BaselineProvider
MCPPatternProvider = tools.PatternProvider
MCPMetricsHistoryProvider = tools.MetricsHistoryProvider
MCPBackupProvider = tools.BackupProvider
MCPGuestConfigProvider = tools.GuestConfigProvider
MCPAppContainerConfigProvider = tools.AppContainerConfigProvider
MCPDiskHealthProvider = tools.DiskHealthProvider
MCPUpdatesProvider = tools.UpdatesProvider
MCPAppContainerActionProvider = tools.AppContainerActionProvider
MCPAppContainerReadProvider = tools.AppContainerReadProvider
AgentProfileManager = tools.AgentProfileManager
FindingsManager = tools.FindingsManager
MetadataUpdater = tools.MetadataUpdater
IncidentRecorderProvider = tools.IncidentRecorderProvider
EventCorrelatorProvider = tools.EventCorrelatorProvider
KnowledgeStoreProvider = tools.KnowledgeStoreProvider
MCPDiscoveryProvider = tools.DiscoveryProvider
MCPUnifiedResourceProvider = tools.UnifiedResourceProvider
)
// Config holds service configuration
type Config struct {
AIConfig *config.AIConfig
StateProvider StateProvider
ReadState unifiedresources.ReadState
Policy CommandPolicy
AgentServer AgentServer
DataDir string
OrgID string
// Optional: provides access to persisted recovery points (backups/snapshots).
RecoveryPointsProvider tools.RecoveryPointsProvider
// Optional: resolves the effective control level for current entitlements.
// Stored config may say autonomous, but runtime execution must use the
// entitlement-clamped level.
ControlLevelResolver func(*config.AIConfig) string
// Optional report-narration providers for the pulse_summarize tool.
// When the per-tenant AI service is configured the API layer passes
// it here for all three roles; when unconfigured the tool returns
// heuristic narrative.
ReportNarrator reporting.Narrator
ReportFleetNarrator reporting.FleetNarrator
ReportFindingsProvider reporting.FindingsProvider
// Optional cost store. When set, ExecuteStream records a
// cost.UsageEvent after each chat turn so user-chat token usage
// appears in the operator dashboard alongside patrol, discovery,
// and report-narrative spend. Absent value means "don't record"
// — useful for tests and for callers that bring their own ledger.
// ExecutePatrolStream intentionally does NOT record here; its
// caller (patrol_ai.go) records via its own helper so cost is
// never double-counted on the patrol-via-chat path.
CostStore *cost.Store
}
// Service provides direct AI chat without external sidecar
type Service struct {
mu sync.RWMutex
cfg *config.AIConfig
dataDir string
stateProvider StateProvider
readState unifiedresources.ReadState
agentServer AgentServer
executor *tools.PulseToolExecutor
unifiedResourceProvider tools.UnifiedResourceProvider
actionAuditStore unifiedresources.ResourceStore
sessions *SessionStore
agenticLoop *AgenticLoop
provider providers.StreamingProvider
providerFactory func(modelStr string) (providers.StreamingProvider, error)
patrolProviderFactory func(modelStr string) (providers.StreamingProvider, error)
started bool
autonomousMode bool
contextPrefetcher *ContextPrefetcher
budgetChecker func() error // Optional mid-run budget enforcement
orgID string
controlLevelResolver func(*config.AIConfig) string
// costStore receives a cost.UsageEvent after each ExecuteStream
// turn so user-chat token usage is visible in the operator
// dashboard. Nil means "don't record".
costStore *cost.Store
activeMu sync.RWMutex
activeExecutions map[string]map[*AgenticLoop]struct{}
questionExecutions map[string]*AgenticLoop
}
// SetPatrolProviderFactory installs a Patrol-only provider factory override.
// This lets Patrol resolve provider-scoped model strings without changing
// normal interactive chat provider selection in the same slice.
func (s *Service) SetPatrolProviderFactory(factory func(modelStr string) (providers.StreamingProvider, error)) {
s.mu.Lock()
defer s.mu.Unlock()
s.patrolProviderFactory = factory
}
// NewService creates a new chat service
func NewService(cfg Config) *Service {
// Create tool executor
var policy tools.CommandPolicy
var agentServer tools.AgentServer
if cfg.Policy != nil {
policy = &commandPolicyAdapter{cfg.Policy}
}
if cfg.AgentServer != nil {
agentServer = &agentServerAdapter{cfg.AgentServer}
}
execCfg := tools.ExecutorConfig{
StateProvider: cfg.StateProvider,
ReadState: cfg.ReadState,
Policy: policy,
AgentServer: agentServer,
RecoveryPointsProvider: cfg.RecoveryPointsProvider,
OrgID: cfg.OrgID,
ReportNarrator: cfg.ReportNarrator,
ReportFleetNarrator: cfg.ReportFleetNarrator,
ReportFindingsProvider: cfg.ReportFindingsProvider,
}
if cfg.AIConfig != nil {
execCfg.ControlLevel = resolveEffectiveControlLevel(cfg.ControlLevelResolver, cfg.AIConfig)
execCfg.ProtectedGuests = cfg.AIConfig.GetProtectedGuests()
}
executor := tools.NewPulseToolExecutor(execCfg)
// Set telemetry callback for strict resolution metrics
executor.SetTelemetryCallback(NewAIMetricsTelemetryCallback())
return &Service{
cfg: cfg.AIConfig,
dataDir: cfg.DataDir,
stateProvider: cfg.StateProvider,
readState: cfg.ReadState,
agentServer: cfg.AgentServer,
executor: executor,
orgID: strings.TrimSpace(cfg.OrgID),
controlLevelResolver: cfg.ControlLevelResolver,
costStore: cfg.CostStore,
activeExecutions: make(map[string]map[*AgenticLoop]struct{}),
questionExecutions: make(map[string]*AgenticLoop),
}
}
func resolveEffectiveControlLevel(
resolver func(*config.AIConfig) string,
cfg *config.AIConfig,
) tools.ControlLevel {
if resolver != nil {
if resolved := strings.TrimSpace(resolver(cfg)); config.IsValidControlLevel(resolved) {
return tools.ControlLevel(resolved)
}
}
if cfg == nil {
return tools.ControlLevelReadOnly
}
return tools.ControlLevel(cfg.GetControlLevel())
}
func controlLevelForRequestAutonomousMode(level tools.ControlLevel, requested *bool) tools.ControlLevel {
if requested == nil || *requested {
return level
}
if level == tools.ControlLevelAutonomous {
return tools.ControlLevelControlled
}
return level
}
func (s *Service) effectiveControlLevelLocked() tools.ControlLevel {
return resolveEffectiveControlLevel(s.controlLevelResolver, s.cfg)
}
func (s *Service) registerActiveLoop(sessionID string, loop *AgenticLoop) {
if s == nil || sessionID == "" || loop == nil {
return
}
s.activeMu.Lock()
defer s.activeMu.Unlock()
if s.activeExecutions == nil {
s.activeExecutions = make(map[string]map[*AgenticLoop]struct{})
}
loops := s.activeExecutions[sessionID]
if loops == nil {
loops = make(map[*AgenticLoop]struct{})
s.activeExecutions[sessionID] = loops
}
loops[loop] = struct{}{}
}
func (s *Service) unregisterActiveLoop(sessionID string, loop *AgenticLoop) {
if s == nil || sessionID == "" || loop == nil {
return
}
s.activeMu.Lock()
defer s.activeMu.Unlock()
if loops := s.activeExecutions[sessionID]; loops != nil {
delete(loops, loop)
if len(loops) == 0 {
delete(s.activeExecutions, sessionID)
}
}
for questionID, registeredLoop := range s.questionExecutions {
if registeredLoop == loop {
delete(s.questionExecutions, questionID)
}
}
}
// recordChatTurnCost records a cost.UsageEvent for a completed (or
// failed) user-chat agentic turn. Uses the totals accumulated by the
// loop's stream callbacks so the recorded numbers match what the
// frontend sees in the SSE done event. No-op when the cost store is
// not configured or when the loop consumed zero tokens (an early
// failure before the first turn completed). UseCase is "chat" — the
// canonical taxonomy noted on cost.UsageEvent.UseCase.
func (s *Service) recordChatTurnCost(loop *AgenticLoop, requestModel string) {
if s == nil || loop == nil {
return
}
s.mu.RLock()
store := s.costStore
s.mu.RUnlock()
if store == nil {
return
}
inputTokens := loop.GetTotalInputTokens()
outputTokens := loop.GetTotalOutputTokens()
if inputTokens == 0 && outputTokens == 0 {
return
}
providerName := ""
if requestModel != "" {
if idx := strings.Index(requestModel, ":"); idx > 0 {
providerName = strings.ToLower(requestModel[:idx])
}
}
store.Record(cost.UsageEvent{
Timestamp: time.Now(),
Provider: providerName,
RequestModel: requestModel,
UseCase: "chat",
InputTokens: inputTokens,
OutputTokens: outputTokens,
})
}
func (s *Service) registerQuestionLoop(questionID string, loop *AgenticLoop) {
if s == nil || questionID == "" || loop == nil {
return
}
s.activeMu.Lock()
defer s.activeMu.Unlock()
if s.questionExecutions == nil {
s.questionExecutions = make(map[string]*AgenticLoop)
}
s.questionExecutions[questionID] = loop
}
func (s *Service) findQuestionLoop(questionID string) *AgenticLoop {
if s == nil || questionID == "" {
return nil
}
s.activeMu.RLock()
defer s.activeMu.RUnlock()
return s.questionExecutions[questionID]
}
func (s *Service) getActiveLoops(sessionID string) []*AgenticLoop {
if s == nil || sessionID == "" {
return nil
}
s.activeMu.RLock()
defer s.activeMu.RUnlock()
loops := s.activeExecutions[sessionID]
if len(loops) == 0 {
return nil
}
active := make([]*AgenticLoop, 0, len(loops))
for loop := range loops {
active = append(active, loop)
}
return active
}
// Adapter types
type commandPolicyAdapter struct {
p CommandPolicy
}
func (a *commandPolicyAdapter) Evaluate(command string) agentexec.PolicyDecision {
return a.p.Evaluate(command)
}
type agentServerAdapter struct {
s AgentServer
}
func (a *agentServerAdapter) GetConnectedAgents() []agentexec.ConnectedAgent {
return a.s.GetConnectedAgents()
}
func (a *agentServerAdapter) ExecuteCommand(ctx context.Context, agentID string, cmd agentexec.ExecuteCommandPayload) (*agentexec.CommandResultPayload, error) {
return a.s.ExecuteCommand(ctx, agentID, cmd)
}
// Start initializes the service
func (s *Service) Start(ctx context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.started {
return nil
}
if s.cfg == nil {
return fmt.Errorf("Pulse Assistant config is nil")
}
s.applyChatContextSettings()
// Create session store
dataDir := s.dataDir
if dataDir == "" {
dataDir = "/tmp/pulse-ai"
s.dataDir = dataDir
}
store, err := NewSessionStore(dataDir)
if err != nil {
return fmt.Errorf("failed to create session store: %w", err)
}
s.sessions = store
if s.actionAuditStore == nil {
actionAuditStore, err := unifiedresources.NewSQLiteResourceStore(dataDir, s.orgID)
if err != nil {
log.Warn().
Err(err).
Str("orgID", s.orgID).
Str("data_dir", dataDir).
Msg("failed to initialize action audit store")
} else {
s.actionAuditStore = actionAuditStore
if s.executor != nil {
s.executor.SetActionAuditStore(actionAuditStore)
}
}
}
// Create provider
provider, err := s.createProvider()
if err != nil {
return fmt.Errorf("failed to create provider: %w", err)
}
// Create agentic loop
systemPrompt := s.buildSystemPrompt()
s.agenticLoop = NewAgenticLoop(provider, s.executor, systemPrompt)
s.agenticLoop.SetOrgID(s.orgID)
s.provider = provider
s.started = true
log.Info().
Str("model", s.cfg.GetChatModel()).
Str("data_dir", dataDir).
Msg("Pulse AI (direct) started")
return nil
}
// Stop stops the service
func (s *Service) Stop(ctx context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
s.started = false
s.provider = nil
if s.actionAuditStore != nil {
if err := s.actionAuditStore.Close(); err != nil {
log.Warn().Err(err).Msg("failed to close action audit store")
}
s.actionAuditStore = nil
}
log.Info().Msg("pulse AI (direct) stopped")
return nil
}
// Restart restarts the service with new config
func (s *Service) Restart(ctx context.Context, newCfg *config.AIConfig) error {
s.mu.Lock()
defer s.mu.Unlock()
if !s.started {
return fmt.Errorf("service not started")
}
if newCfg != nil {
s.cfg = newCfg
}
s.applyChatContextSettings()
// Update executor settings
if s.executor != nil && s.cfg != nil {
s.executor.SetControlLevel(s.effectiveControlLevelLocked())
s.executor.SetProtectedGuests(s.cfg.GetProtectedGuests())
}
// Recreate provider with new settings
provider, err := s.createProvider()
if err != nil {
return fmt.Errorf("failed to create provider: %w", err)
}
// Update agentic loop
systemPrompt := s.buildSystemPrompt()
s.agenticLoop = NewAgenticLoop(provider, s.executor, systemPrompt)
s.agenticLoop.SetOrgID(s.orgID)
s.provider = provider
log.Info().
Str("model", s.cfg.GetChatModel()).
Msg("Pulse AI restarted with new config")
return nil
}
// IsRunning returns whether the service is running
func (s *Service) IsRunning() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.started
}
// ExecuteStream sends a prompt and streams the response
func (s *Service) ExecuteStream(ctx context.Context, req ExecuteRequest, callback StreamCallback) error {
log.Debug().
Str("session_id", req.SessionID).
Int("prompt_len", len(req.Prompt)).
Msg("[ChatService] ExecuteStream called")
s.mu.RLock()
if !s.started {
s.mu.RUnlock()
log.Error().Msg("[ChatService] Service not started")
return fmt.Errorf("service not started")
}
sessions := s.sessions
agenticLoop := s.agenticLoop
s.mu.RUnlock()
log.Debug().
Str("session_id", req.SessionID).
Bool("has_sessions", sessions != nil).
Bool("has_agentic_loop", agenticLoop != nil).
Msg("[ChatService] Retrieved internal state")
// Ensure session exists
session, err := sessions.EnsureSession(req.SessionID)
if err != nil {
log.Error().Err(err).Str("session_id", req.SessionID).Msg("[ChatService] Failed to ensure session")
return fmt.Errorf("failed to ensure session: %w", err)
}
log.Debug().Str("session_id", session.ID).Msg("[ChatService] Session ensured")
handoffContext := strings.TrimSpace(req.HandoffContext)
handoffResources := normalizeHandoffResources(req.HandoffResources)
handoffActions := normalizeHandoffActions(req.HandoffActions)
handoffMetadata := NormalizeHandoffMetadata(req.HandoffMetadata)
handoffFindingID := strings.TrimSpace(req.FindingID)
hasRequestHandoffEnvelope := handoffFindingID != "" ||
handoffContext != "" ||
len(handoffResources) > 0 ||
len(handoffActions) > 0 ||
!handoffMetadataEmpty(handoffMetadata)
if hasRequestHandoffEnvelope {
if err := sessions.SetModelHandoffEnvelope(session.ID, handoffFindingID, handoffContext, handoffResources, handoffActions, handoffMetadata); err != nil {
log.Warn().Err(err).Str("session_id", session.ID).Msg("[ChatService] Failed to persist model handoff envelope")
}
} else {
storedHandoffResources, err := sessions.GetModelHandoffResources(session.ID)
if err != nil {
log.Warn().Err(err).Str("session_id", session.ID).Msg("[ChatService] Failed to load model handoff resources")
} else {
handoffResources = storedHandoffResources
}
storedHandoffActions, err := sessions.GetModelHandoffActions(session.ID)
if err != nil {
log.Warn().Err(err).Str("session_id", session.ID).Msg("[ChatService] Failed to load model handoff actions")
} else {
handoffActions = storedHandoffActions
}
storedHandoffContext, err := sessions.GetModelHandoffContext(session.ID)
if err != nil {
log.Warn().Err(err).Str("session_id", session.ID).Msg("[ChatService] Failed to load model handoff context")
} else {
handoffContext = storedHandoffContext
}
}
// Add user message
userMsg := Message{
ID: uuid.New().String(),
Role: "user",
Content: req.Prompt,
Timestamp: time.Now(),
}
if err := sessions.AddMessage(session.ID, userMsg); err != nil {
log.Warn().Err(err).Msg("failed to save user message")
}
// Get existing messages for context
messages, err := sessions.GetMessages(session.ID)
if err != nil {
log.Error().Err(err).Str("session_id", session.ID).Msg("[ChatService] Failed to get messages")
return fmt.Errorf("failed to get messages: %w", err)
}
log.Debug().
Str("session_id", session.ID).
Int("message_count", len(messages)).
Msg("[ChatService] Got messages, calling agentic loop")
handoffActions = refreshHandoffActionStatus(handoffActions, s.orgID, s.actionAuditStore)
if len(handoffActions) > 0 {
if err := sessions.SetModelHandoffActions(session.ID, handoffActions); err != nil {
log.Warn().Err(err).Str("session_id", session.ID).Msg("[ChatService] Failed to persist refreshed handoff action status")
}
}
s.mu.RLock()
handoffResourceProvider := s.unifiedResourceProvider
s.mu.RUnlock()
handoffContext = mergeHandoffResourcePolicyContext(handoffContext, handoffResources, handoffResourceProvider)
handoffContext = mergeHandoffResourceStateContext(handoffContext, handoffResources, handoffResourceProvider)
handoffContext = mergeHandoffResourceRelationshipContext(handoffContext, handoffResources, handoffResourceProvider)
handoffContext = mergeHandoffResourceTimelineContext(handoffContext, handoffResources, handoffResourceProvider, s.actionAuditStore, time.Now())
handoffContext = mergeHandoffActionContext(handoffContext, handoffActions)
handoffContext = sanitizeHandoffContextForResourcePolicy(handoffContext, handoffResources, handoffResourceProvider)
injectHandoffContextIntoLatestUserMessage(messages, handoffContext)
// Determine which model/loop to use for this request.
selectedModel := ""
configuredModel := ""
overrideModel := strings.TrimSpace(req.Model)
var executor *tools.PulseToolExecutor
autonomousMode := false
effectiveControlLevel := tools.ControlLevelReadOnly
s.mu.RLock()
baseExecutor := s.executor
unifiedResourceProvider := s.unifiedResourceProvider
autonomousMode = s.autonomousMode
effectiveControlLevel = s.effectiveControlLevelLocked()
if s.cfg != nil {
configuredModel = strings.TrimSpace(s.cfg.GetChatModel())
}
s.mu.RUnlock()
if baseExecutor != nil {
executor = baseExecutor.Clone()
executor.SetControlLevel(effectiveControlLevel)
}
s.hydrateHandoffResources(session.ID, handoffResources, sessions, unifiedResourceProvider)
// Per-request autonomous mode override (used by investigation to avoid
// mutating shared service state from concurrent goroutines).
if req.AutonomousMode != nil {
autonomousMode = *req.AutonomousMode
}
effectiveControlLevel = controlLevelForRequestAutonomousMode(effectiveControlLevel, req.AutonomousMode)
if executor != nil {
executor.SetControlLevel(effectiveControlLevel)
executor.SetAutonomousMode(autonomousMode)
}
selectedModel = configuredModel
if overrideModel != "" {
selectedModel = overrideModel
}
// Create a per-request AgenticLoop to ensure complete isolation between
// concurrent sessions. This prevents race conditions where concurrent
// ExecuteStream calls would overwrite each other's FSM, knowledge accumulator,
// autonomous mode, budget checker, and provider info on a shared loop.
var loop *AgenticLoop
if overrideModel != "" && overrideModel != configuredModel {
provider, err := s.createProviderForModel(overrideModel)
if err != nil {
return fmt.Errorf("failed to create provider for model override %q: %w", overrideModel, err)
}
systemPrompt := s.buildSystemPrompt()
loop = NewAgenticLoop(provider, executor, systemPrompt)
loop.SetOrgID(s.orgID)
} else {
// Create a fresh loop with the configured provider for this request
s.mu.RLock()
provider := s.provider
s.mu.RUnlock()
if provider == nil {
return fmt.Errorf("provider not initialized")
}
systemPrompt := s.buildSystemPrompt()
loop = NewAgenticLoop(provider, executor, systemPrompt)
loop.SetOrgID(s.orgID)
}
loop.SetAutonomousMode(autonomousMode)
loop.SetRequestSanitizer(modelboundary.RequestSanitizerForModel(selectedModel, unifiedResourceProvider))
s.registerActiveLoop(session.ID, loop)
defer s.unregisterActiveLoop(session.ID, loop)
// Proactively gather context for mentioned resources
s.mu.RLock()
prefetcher := s.contextPrefetcher
s.mu.RUnlock()
log.Info().
Bool("hasPrefetcher", prefetcher != nil).
Str("prompt", req.Prompt[:min(50, len(req.Prompt))]).
Msg("[ChatService] Checking prefetcher")
mentionsFound := false
if prefetcher != nil {
prefetchCtx := prefetcher.Prefetch(ctx, req.Prompt, req.Mentions)
if prefetchCtx != nil {
mentionsFound = len(prefetchCtx.Mentions) > 0
}
if prefetchCtx != nil && prefetchCtx.Summary != "" {
log.Info().
Int("mentions", len(prefetchCtx.Mentions)).
Int("discoveries", len(prefetchCtx.Discoveries)).
Msg("[ChatService] Injecting prefetched context")
// Mark mentioned resources as explicitly accessed for routing validation
// This ensures that if user says "@homepage-docker" and model targets the host,
// the routing validation will catch it.
resolvedCtx := sessions.GetResolvedContext(session.ID)
for _, mention := range prefetchCtx.Mentions {
// Build canonical resource ID: kind:scope:id
resourceID := strings.TrimSpace(mention.UnifiedResourceID)
if resourceID == "" {
switch mention.ResourceType {
case "system-container":
if mention.TargetID != "" {
resourceID = "system-container:" + mention.TargetID + ":" + mention.ResourceID
}
case "vm":
if mention.TargetID != "" {
resourceID = "vm:" + mention.TargetID + ":" + mention.ResourceID
}
case "app-container":
if mention.TargetHost != "" {
resourceID = "app-container:" + mention.TargetHost + ":" + mention.ResourceID
}
}
}
if resourceID != "" {
resolvedCtx.MarkExplicitAccess(resourceID)
log.Debug().
Str("resource_id", resourceID).
Str("mention", mention.Name).
Msg("[ChatService] Marked @mention as explicit access")
}
}
// Augment the user's message with prefetched context
// This is more reliable than a separate system message - the AI treats it as authoritative user-provided info
if len(messages) > 0 {
lastIdx := len(messages) - 1
if messages[lastIdx].Role == "user" {
augmentedContent := prefetchCtx.Summary + "\n\n---\nUser question: " + messages[lastIdx].Content
messages[lastIdx].Content = augmentedContent
}
}
}
if !mentionsFound {
s.injectRecentSessionContext(session.ID, messages, sessions)
}
} else {
s.injectRecentSessionContext(session.ID, messages, sessions)
}
// Set session-scoped resolved context on executor for resource validation.
// This ensures tools can only operate on resources discovered in this session.
if executor != nil {
resolvedCtx := sessions.GetResolvedContext(session.ID)
executor.SetResolvedContext(resolvedCtx)
log.Debug().
Str("session_id", session.ID).
Int("resolved_resources", len(resolvedCtx.Resources)).
Msg("[ChatService] Set resolved context on executor")
}
// Shared session state for the selected model's turn.
sessionFSM := sessions.GetSessionFSM(session.ID)
ka := sessions.GetKnowledgeAccumulator(session.ID)
// If the prefetcher resolved mentions, advance FSM past RESOLVING.
// The prefetched context already contains the resource details (type, VMID, node, host)
// so forcing the AI to redundantly call a read tool would be wasteful.
if mentionsFound && sessionFSM.State == StateResolving {
sessionFSM.State = StateReading
log.Info().
Str("session_id", session.ID).
Msg("[ChatService] Advanced FSM to READING — prefetched mentions count as resolution")
}
// Run agentic loop
filteredTools := s.toolsForExecutionMode(autonomousMode, false)
log.Debug().
Str("session_id", session.ID).
Int("tools_count", len(filteredTools)).
Msg("[ChatService] Prepared governed tool manifest, starting agentic loop")
// Set session-scoped FSM on agentic loop for workflow enforcement
// This ensures structural guarantees: discover before write, verify after write
loop.SetSessionFSM(sessionFSM)
// Set session-scoped knowledge accumulator for fact extraction across turns.
// For user chat, this persists across messages so facts accumulate during a conversation.
loop.SetKnowledgeAccumulator(ka)
log.Debug().
Str("session_id", session.ID).
Str("fsm_state", string(sessionFSM.State)).
Bool("wrote_this_episode", sessionFSM.WroteThisEpisode).
Msg("[ChatService] Set session FSM on agentic loop")
// Set mid-run budget checker if configured
if s.budgetChecker != nil {
loop.SetBudgetChecker(s.budgetChecker)
}
// Set provider info for telemetry
if selectedModel != "" {
parts := strings.SplitN(selectedModel, ":", 2)
if len(parts) == 2 {
loop.SetProviderInfo(parts[0], parts[1])
}
}
// Override max turns if the caller specified one (e.g. investigations use 15).
// Reset after the call to avoid affecting concurrent sessions on the shared loop.
if req.MaxTurns > 0 {
loop.SetMaxTurns(req.MaxTurns)
defer loop.SetMaxTurns(MaxAgenticTurns)
}
// Apply per-request autonomous mode to the loop. For investigation requests
// with AutonomousMode set, this uses the per-request value instead of
// mutating shared service state from concurrent goroutines.
loop.SetAutonomousMode(autonomousMode)
streamCallback := callback
if streamCallback == nil {
streamCallback = func(StreamEvent) {}
}
wrappedCallback := func(event StreamEvent) {
if event.Type == "question" {
var data QuestionData
if err := json.Unmarshal(event.Data, &data); err == nil && data.QuestionID != "" {
s.registerQuestionLoop(data.QuestionID, loop)
}
}
streamCallback(event)
}
resultMessages, err := loop.ExecuteWithTools(ctx, session.ID, messages, filteredTools, wrappedCallback)
log.Debug().
Str("session_id", session.ID).
Int("result_messages", len(resultMessages)).
Err(err).
Msg("[ChatService] Agentic loop returned")
// Record cost regardless of error — the operator was billed for
// whatever tokens the loop consumed before terminating. Without
// this, every user chat turn was invisible in the AI usage
// dashboard despite chat being the bulk of token spend.
// ExecutePatrolStream uses a separate tempLoop and its caller
// (patrol_ai.go) records cost via its own helper, so no double-
// recording on the patrol-via-chat path.
s.recordChatTurnCost(loop, selectedModel)
if err != nil {
// Still save any messages we got
for _, msg := range resultMessages {
if saveErr := sessions.AddMessage(session.ID, msg); saveErr != nil {
log.Warn().Err(saveErr).Msg("failed to save message after error")
}
}
return err
}
// Save result messages
for _, msg := range resultMessages {
// Skip user messages (already saved)
if msg.Role == "user" && msg.ToolResult == nil {
continue
}
if err := sessions.AddMessage(session.ID, msg); err != nil {
log.Warn().Err(err).Msg("failed to save message")
}
}
// Send done event with token usage for this request.
doneData, _ := json.Marshal(DoneData{
SessionID: session.ID,
InputTokens: loop.GetTotalInputTokens(),
OutputTokens: loop.GetTotalOutputTokens(),
})
streamCallback(StreamEvent{Type: "done", Data: doneData})
return nil
}
func injectHandoffContextIntoLatestUserMessage(messages []Message, handoffContext string) {
contextText := strings.TrimSpace(handoffContext)
if contextText == "" || len(messages) == 0 {
return
}
for idx := len(messages) - 1; idx >= 0; idx-- {
if messages[idx].Role != "user" {
continue
}
userText := strings.TrimSpace(messages[idx].Content)
if userText == "" {
messages[idx].Content = contextText
return
}
messages[idx].Content = contextText + "\n\n---\nUser message: " + userText
return
}
}
func sanitizeHandoffContextForResourcePolicy(handoffContext string, handoffResources []HandoffResource, provider tools.UnifiedResourceProvider) string {
contextText := strings.TrimSpace(handoffContext)
resources := normalizeHandoffResources(handoffResources)
if contextText == "" || len(resources) == 0 || provider == nil {
return contextText
}
redacted := contextText
for _, handoffResource := range resources {
resource, ok := tools.CanonicalHandoffUnifiedResource(provider, handoffResource.ID, handoffResource.Name, handoffResource.Type, handoffResource.Node)
if !ok {
continue
}
policy, aiSafeSummary := unifiedresources.CanonicalGovernanceMetadata(&resource)
if policy == nil {
continue
}
resource.Policy = policy
resource.AISafeSummary = aiSafeSummary
redacted = unifiedresources.ResourcePolicyRedactedTextWithReferences(redacted, resource, handoffResourcePolicyReferences(handoffResource, resource)...)
}
return strings.TrimSpace(redacted)
}
func handoffResourcePolicyReferences(handoffResource HandoffResource, resource unifiedresources.Resource) []unifiedresources.ResourcePolicyRedactionReference {
references := make([]unifiedresources.ResourcePolicyRedactionReference, 0, 16)
add := func(value string, hints ...unifiedresources.ResourceRedactionHint) {
references = append(references, unifiedresources.ResourcePolicyReference(value, hints...))
}
add(resource.Name,
unifiedresources.ResourceRedactionHostname,
unifiedresources.ResourceRedactionAlias,
)
add(resource.ID,
unifiedresources.ResourceRedactionPlatformID,
unifiedresources.ResourceRedactionAlias,
)
if resource.Canonical != nil {
add(resource.Canonical.DisplayName,
unifiedresources.ResourceRedactionHostname,
unifiedresources.ResourceRedactionAlias,
)
add(resource.Canonical.Hostname,
unifiedresources.ResourceRedactionHostname,
)
add(resource.Canonical.PlatformID,
unifiedresources.ResourceRedactionPlatformID,
)
add(resource.Canonical.PrimaryID,
unifiedresources.ResourceRedactionPlatformID,
)
for _, alias := range resource.Canonical.Aliases {
add(alias,
unifiedresources.ResourceRedactionAlias,
)
}
}
if resource.Proxmox != nil {
add(resource.Proxmox.NodeName,
unifiedresources.ResourceRedactionHostname,
unifiedresources.ResourceRedactionAlias,
)
add(resource.Proxmox.ClusterName,
unifiedresources.ResourceRedactionAlias,
)
}
if resource.Kubernetes != nil {
add(resource.Kubernetes.NodeName,
unifiedresources.ResourceRedactionHostname,
unifiedresources.ResourceRedactionAlias,
)
add(resource.Kubernetes.ClusterID,
unifiedresources.ResourceRedactionPlatformID,
)
add(resource.Kubernetes.ClusterName,
unifiedresources.ResourceRedactionAlias,
)
add(resource.Kubernetes.SourceName,
unifiedresources.ResourceRedactionAlias,
)
}
if resource.Storage != nil {
add(resource.Storage.Path,
unifiedresources.ResourceRedactionPath,
)
}
add(handoffResource.Name,
unifiedresources.ResourceRedactionHostname,
unifiedresources.ResourceRedactionAlias,
)
add(handoffResource.ID,
unifiedresources.ResourceRedactionPlatformID,
unifiedresources.ResourceRedactionAlias,
)
add(handoffResource.Node,
unifiedresources.ResourceRedactionHostname,
unifiedresources.ResourceRedactionPlatformID,
unifiedresources.ResourceRedactionAlias,
)
return references
}
func mergeHandoffResourcePolicyContext(handoffContext string, handoffResources []HandoffResource, provider tools.UnifiedResourceProvider) string {
policyContext := buildHandoffResourcePolicyContext(handoffResources, provider)
switch {
case strings.TrimSpace(handoffContext) == "":
return policyContext
case policyContext == "":
return strings.TrimSpace(handoffContext)
default:
return strings.TrimSpace(handoffContext) + "\n\n" + policyContext
}
}
func buildHandoffResourcePolicyContext(handoffResources []HandoffResource, provider tools.UnifiedResourceProvider) string {
resources := canonicalHandoffResources(handoffResources, provider)
if len(resources) == 0 {
return ""
}
var b strings.Builder
count := 0
for _, resource := range resources {
policy, aiSafeSummary := unifiedresources.CanonicalGovernanceMetadata(&resource)
if policy == nil && strings.TrimSpace(aiSafeSummary) == "" {
continue
}
if b.Len() == 0 {
b.WriteString("[Resource Policy Context]")
}
count++
label := unifiedresources.ResourcePolicyLabel(resource.Name, aiSafeSummary, policy)
if label == "" {
label = "resource"
}
resourceType := strings.TrimSpace(string(unifiedresources.ContractResourceType(resource)))
if resourceType == "" {
resourceType = strings.TrimSpace(string(resource.Type))
}
contextLabel := "Resource Policy"
if count > 1 {
contextLabel = fmt.Sprintf("Resource Policy %d", count)
}
appendHandoffContextLine(&b, contextLabel, label)
appendHandoffContextLine(&b, contextLabel+" Type", resourceType)
for _, line := range unifiedresources.ResourcePolicySummaryLines(policy) {
if line = strings.TrimSpace(line); line != "" {
if b.Len() > 0 {
b.WriteByte('\n')
}
b.WriteString(line)
}
}
if summary := strings.TrimSpace(aiSafeSummary); summary != "" && summary != label {
appendHandoffContextLine(&b, contextLabel+" AI-safe Summary", summary)
}
}
if count == 0 {
return ""
}
appendHandoffContextLine(&b, "Policy Boundary", "Resource policy is read-only data-handling context; local-only and redaction guidance must not be bypassed and does not grant approval or execution authority.")
return strings.TrimSpace(b.String())
}
func mergeHandoffResourceStateContext(handoffContext string, handoffResources []HandoffResource, provider tools.UnifiedResourceProvider) string {
stateContext := buildHandoffResourceStateContext(handoffResources, provider)
switch {
case strings.TrimSpace(handoffContext) == "":
return stateContext
case stateContext == "":
return strings.TrimSpace(handoffContext)
default:
return strings.TrimSpace(handoffContext) + "\n\n" + stateContext
}
}
func buildHandoffResourceStateContext(handoffResources []HandoffResource, provider tools.UnifiedResourceProvider) string {
resources := canonicalHandoffResources(handoffResources, provider)
if len(resources) == 0 {
return ""
}
var b strings.Builder
b.WriteString("[Resource State Context]")
for idx, resource := range resources {
labelPrefix := "Resource State"
if len(resources) > 1 {
labelPrefix = fmt.Sprintf("Resource State %d", idx+1)
}
policy, aiSafeSummary := unifiedresources.CanonicalGovernanceMetadata(&resource)
label := unifiedresources.ResourcePolicyLabel(resource.Name, aiSafeSummary, policy)
if label == "" {
label = "resource"
}
resourceType := strings.TrimSpace(string(unifiedresources.ContractResourceType(resource)))
if resourceType == "" {
resourceType = strings.TrimSpace(string(resource.Type))
}
appendHandoffContextLine(&b, labelPrefix, label)
appendHandoffContextLine(&b, labelPrefix+" ID", formatHandoffResourceID(resource, policy))
appendHandoffContextLine(&b, labelPrefix+" Type", resourceType)
appendHandoffContextLine(&b, labelPrefix+" Status", string(resource.Status))
appendHandoffContextLine(&b, labelPrefix+" Last Seen At", formatHandoffContextTime(resource.LastSeen))
appendHandoffContextLine(&b, labelPrefix+" Updated At", formatHandoffContextTime(resource.UpdatedAt))
appendHandoffContextLine(&b, labelPrefix+" Sources", formatHandoffResourceSources(resource))
appendHandoffContextLine(&b, labelPrefix+" Source Health", formatHandoffResourceSourceStatus(resource))
appendHandoffContextLine(&b, labelPrefix+" Metrics", formatHandoffResourceMetrics(resource.Metrics))
appendHandoffContextLine(&b, labelPrefix+" Parent", formatHandoffResourceParent(resource, policy))
if resource.ChildCount > 0 {
appendHandoffContextLine(&b, labelPrefix+" Child Count", fmt.Sprintf("%d", resource.ChildCount))
}
appendHandoffContextLine(&b, labelPrefix+" Incident Summary", formatHandoffResourceIncidentSummary(resource, policy))
appendHandoffContextLine(&b, labelPrefix+" Capabilities", formatHandoffResourceCapabilities(resource, policy, 4))
}
appendHandoffContextLine(&b, "Resource State Boundary", "Current resource state, source health, incidents, metrics, and capabilities are read-only canonical infrastructure context; they are not approval or execution authority.")
return strings.TrimSpace(b.String())
}
func mergeHandoffResourceRelationshipContext(handoffContext string, handoffResources []HandoffResource, provider tools.UnifiedResourceProvider) string {
relationshipContext := buildHandoffResourceRelationshipContext(handoffResources, provider)
switch {
case strings.TrimSpace(handoffContext) == "":
return relationshipContext
case relationshipContext == "":
return strings.TrimSpace(handoffContext)
default:
return strings.TrimSpace(handoffContext) + "\n\n" + relationshipContext
}
}
func buildHandoffResourceRelationshipContext(handoffResources []HandoffResource, provider tools.UnifiedResourceProvider) string {
resources := canonicalHandoffResources(handoffResources, provider)
if len(resources) == 0 {
return ""
}
var b strings.Builder
count := 0
for _, resource := range resources {
resource.Relationships = unifiedresources.ResourceRelationshipsWithCanonicalParent(resource)
relationshipContext := strings.TrimSpace(unifiedresources.FormatResourceRelationshipContext(&resource, 3))
if relationshipContext == "" {
continue
}
if b.Len() == 0 {
b.WriteString("[Resource Relationship Context]")
}
count++
policy, aiSafeSummary := unifiedresources.CanonicalGovernanceMetadata(&resource)
label := unifiedresources.ResourcePolicyLabel(resource.Name, aiSafeSummary, policy)
if label == "" {
label = "resource"
}
contextLabel := "Resource Relationships For"
if count > 1 {
contextLabel = fmt.Sprintf("Resource Relationships %d For", count)
}
appendHandoffContextLine(&b, contextLabel, label)
if b.Len() > 0 {
b.WriteByte('\n')
}
b.WriteString(relationshipContext)
}
if count == 0 {
return ""
}
appendHandoffContextLine(&b, "Relationship Boundary", "Relationships are read-only canonical topology context; they are not approval or execution authority.")
return strings.TrimSpace(b.String())
}
func mergeHandoffActionContext(handoffContext string, handoffActions []HandoffAction) string {
actionContext := buildHandoffActionContext(handoffActions)
switch {
case strings.TrimSpace(handoffContext) == "":
return actionContext
case actionContext == "":
return strings.TrimSpace(handoffContext)
default:
return strings.TrimSpace(handoffContext) + "\n\n" + actionContext
}
}
func mergeHandoffResourceTimelineContext(handoffContext string, handoffResources []HandoffResource, provider tools.UnifiedResourceProvider, store unifiedresources.ResourceStore, now time.Time) string {
timelineContext := buildHandoffResourceTimelineContext(handoffResources, provider, store, now)
switch {
case strings.TrimSpace(handoffContext) == "":
return timelineContext
case timelineContext == "":
return strings.TrimSpace(handoffContext)
default:
return strings.TrimSpace(handoffContext) + "\n\n" + timelineContext
}
}
func buildHandoffResourceTimelineContext(handoffResources []HandoffResource, provider tools.UnifiedResourceProvider, store unifiedresources.ResourceStore, now time.Time) string {
resourceIDs := canonicalHandoffTimelineResourceIDs(handoffResources, provider)
if len(resourceIDs) == 0 || store == nil {
return ""
}
if now.IsZero() {
now = time.Now()
}
const (
perResourceLimit = 5
totalLimit = 8
)
since := now.Add(-24 * time.Hour)
changes := make([]unifiedresources.ResourceChange, 0, len(resourceIDs)*perResourceLimit)
seen := make(map[string]struct{})
for _, resourceID := range resourceIDs {
recent, err := store.GetRecentChangesFiltered(resourceID, since, perResourceLimit, unifiedresources.ResourceChangeFilters{IncludeRelated: true})
if err != nil {
log.Debug().
Err(err).
Str("resource_id", resourceID).
Msg("[ChatService] Failed to load handoff resource timeline")
continue
}
for _, change := range recent {
key := handoffResourceTimelineChangeKey(change)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
changes = append(changes, change)
}
}
if len(changes) == 0 {
return ""
}
sort.SliceStable(changes, func(i, j int) bool {
return changes[i].ObservedAt.After(changes[j].ObservedAt)
})
if len(changes) > totalLimit {
changes = changes[:totalLimit]
}
contextText := strings.TrimSpace(unifiedresources.FormatResourceRecentChangesContext(changes, true, "###"))
if contextText == "" {
return ""
}
return contextText + "\nTimeline Boundary: Recent changes are read-only canonical resource timeline context; they are not approval or execution authority."
}
func canonicalHandoffTimelineResourceIDs(handoffResources []HandoffResource, provider tools.UnifiedResourceProvider) []string {
resources := normalizeHandoffResources(handoffResources)
if len(resources) == 0 {
return nil
}
ids := make([]string, 0, len(resources))
seen := make(map[string]struct{}, len(resources))
for _, handoffResource := range resources {
resourceID := strings.TrimSpace(handoffResource.ID)
if provider != nil {
if resource, ok := tools.CanonicalHandoffUnifiedResource(provider, handoffResource.ID, handoffResource.Name, handoffResource.Type, handoffResource.Node); ok {
if canonicalID := strings.TrimSpace(resource.ID); canonicalID != "" {
resourceID = canonicalID
}
}
}
if resourceID == "" {
continue
}
key := strings.ToLower(resourceID)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
ids = append(ids, resourceID)
}
if len(ids) == 0 {
return nil
}
return ids
}
func canonicalHandoffResources(handoffResources []HandoffResource, provider tools.UnifiedResourceProvider) []unifiedresources.Resource {
resources := normalizeHandoffResources(handoffResources)
if len(resources) == 0 || provider == nil {
return nil
}
out := make([]unifiedresources.Resource, 0, len(resources))
seen := make(map[string]struct{}, len(resources))
for _, handoffResource := range resources {
resource, ok := tools.CanonicalHandoffUnifiedResource(provider, handoffResource.ID, handoffResource.Name, handoffResource.Type, handoffResource.Node)
if !ok {
continue
}
key := strings.ToLower(strings.TrimSpace(string(resource.Type)) + "\x00" + strings.TrimSpace(resource.ID))
if key == "\x00" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, resource)
}
return out
}
func handoffResourceTimelineChangeKey(change unifiedresources.ResourceChange) string {
if id := strings.TrimSpace(change.ID); id != "" {
return id
}
return strings.ToLower(strings.TrimSpace(change.ResourceID) + "\x00" + string(change.Kind) + "\x00" + change.ObservedAt.UTC().Format(time.RFC3339Nano))
}
func buildHandoffActionContext(handoffActions []HandoffAction) string {
actions := normalizeHandoffActions(handoffActions)
if len(actions) == 0 {
return ""
}
var b strings.Builder
b.WriteString("[Action Context]")
for idx, action := range actions {
label := "Action Reference"
if len(actions) > 1 {
label = fmt.Sprintf("Action Reference %d", idx+1)
}
appendHandoffContextLine(&b, label+" Finding ID", action.FindingID)
appendHandoffContextLine(&b, label+" Record ID", action.RecordID)
appendHandoffContextLine(&b, label+" Approval ID", action.ApprovalID)
appendHandoffContextLine(&b, label+" Approval Status", action.ApprovalStatus)
appendHandoffContextLine(&b, label+" Approval Requested At", action.ApprovalRequestedAt)
appendHandoffContextLine(&b, label+" Approval Expires At", action.ApprovalExpiresAt)
appendHandoffContextLine(&b, label+" Approval Decided At", action.ApprovalDecidedAt)
if action.ApprovalConsumed {
appendHandoffContextLine(&b, label+" Approval Consumed", "true")
}
appendHandoffContextLine(&b, label+" Action ID", action.ActionID)
appendHandoffContextLine(&b, label+" Action State", action.ActionState)
appendHandoffContextLine(&b, label+" Action Updated At", action.ActionUpdatedAt)
appendHandoffContextLine(&b, label+" Action Requested By", action.ActionRequestedBy)
appendHandoffContextLine(&b, label+" Action Capability", action.ActionCapability)
appendHandoffContextLine(&b, label+" Action Approval Policy", action.ActionApprovalPolicy)
if action.ActionRequiresApproval {
appendHandoffContextLine(&b, label+" Action Requires Approval", "true")
}
appendHandoffContextLine(&b, label+" Action Plan Expires At", action.ActionPlanExpiresAt)
appendHandoffContextLine(&b, label+" Action Plan Message", action.ActionPlanMessage)
appendHandoffContextLine(&b, label+" Action Preflight", action.ActionPreflight)
appendHandoffContextLine(&b, label+" Action Dry Run Summary", action.ActionDryRunSummary)
appendHandoffContextLine(&b, label+" Action Result", action.ActionResult)
appendHandoffContextLine(&b, label+" Fix ID", action.FixID)
appendHandoffContextLine(&b, label+" Existing Action Artifact", action.Description)
appendHandoffContextLine(&b, label+" Risk", action.RiskLevel)
if action.Destructive {
appendHandoffContextLine(&b, label+" Destructive", "true")
}
appendHandoffContextLine(&b, label+" Target Host", action.TargetHost)
appendHandoffContextLine(&b, label+" Target Resource", formatHandoffActionResource(action))
}
appendHandoffContextLine(&b, "Action Boundary", "Review and approval must stay in governed approval/remediation flows; this reference is not command authority and must not be used to infer raw command text.")
return strings.TrimSpace(b.String())
}
func refreshHandoffActionApprovalStatus(handoffActions []HandoffAction, orgID string) []HandoffAction {
return refreshHandoffActionStatus(handoffActions, orgID, nil)
}
func refreshHandoffActionStatus(handoffActions []HandoffAction, orgID string, actionStore unifiedresources.ResourceStore) []HandoffAction {
actions := normalizeHandoffActions(handoffActions)
if len(actions) == 0 {
return nil
}
store := approval.GetStore()
normalizedOrgID := approval.NormalizeOrgID(orgID)
for idx := range actions {
clearHandoffActionStatus(&actions[idx])
approvalID := strings.TrimSpace(actions[idx].ApprovalID)
if approvalID != "" && store != nil {
req, ok := store.GetApproval(approvalID)
if ok && approval.BelongsToOrg(req, normalizedOrgID) {
HydrateHandoffActionFromApproval(&actions[idx], req)
}
}
hydrateHandoffActionAudit(&actions[idx], actionStore)
}
return normalizeHandoffActions(actions)
}
func clearHandoffActionStatus(action *HandoffAction) {
if action == nil {
return
}
action.ApprovalStatus = ""
action.ApprovalRequestedAt = ""
action.ApprovalExpiresAt = ""
action.ApprovalDecidedAt = ""
action.ApprovalConsumed = false
action.ActionState = ""
action.ActionUpdatedAt = ""
action.ActionRequestedBy = ""
action.ActionCapability = ""
action.ActionApprovalPolicy = ""
action.ActionRequiresApproval = false
action.ActionPlanExpiresAt = ""
action.ActionPlanMessage = ""
action.ActionPreflight = ""
action.ActionDryRunSummary = ""
action.ActionResult = ""
}
// HydrateHandoffActionFromApproval copies non-command approval metadata into an
// Assistant handoff action. It deliberately excludes ApprovalRequest.Command so
// Assistant context can explain governed state without becoming command replay.
func HydrateHandoffActionFromApproval(action *HandoffAction, req *approval.ApprovalRequest) {
if action == nil || req == nil {
return
}
if approvalID := strings.TrimSpace(req.ID); approvalID != "" {
action.ApprovalID = approvalID
}
action.ApprovalStatus = strings.TrimSpace(string(req.Status))
action.ApprovalRequestedAt = formatHandoffActionTime(req.RequestedAt)
action.ApprovalExpiresAt = formatHandoffActionTime(req.ExpiresAt)
if req.DecidedAt != nil {
action.ApprovalDecidedAt = formatHandoffActionTime(*req.DecidedAt)
}
action.ApprovalConsumed = req.Consumed
if requestedBy := strings.TrimSpace(approval.RequesterForRequest(req)); requestedBy != "" {
action.ActionRequestedBy = requestedBy
}
if strings.TrimSpace(action.RiskLevel) == "" {
action.RiskLevel = strings.TrimSpace(string(req.RiskLevel))
}
if strings.TrimSpace(action.TargetResourceID) == "" {
action.TargetResourceID = strings.TrimSpace(req.TargetID)
}
if strings.TrimSpace(action.TargetResourceName) == "" {
action.TargetResourceName = strings.TrimSpace(req.TargetName)
}
if strings.TrimSpace(action.TargetResourceType) == "" {
action.TargetResourceType = strings.TrimSpace(req.TargetType)
}
hydrateHandoffActionPlan(action, req.Plan)
}
func hydrateHandoffActionPlan(action *HandoffAction, plan *unifiedresources.ActionPlan) {
if action == nil || plan == nil {
return
}
if strings.TrimSpace(action.ActionID) == "" {
action.ActionID = strings.TrimSpace(plan.ActionID)
}
action.ActionRequiresApproval = plan.RequiresApproval
action.ActionApprovalPolicy = strings.TrimSpace(string(plan.ApprovalPolicy))
action.ActionPlanExpiresAt = formatHandoffActionTime(plan.ExpiresAt)
action.ActionPlanMessage = strings.TrimSpace(plan.Message)
if plan.Preflight != nil {
action.ActionPreflight = strings.TrimSpace(plan.Preflight.IntendedChange)
action.ActionDryRunSummary = strings.TrimSpace(plan.Preflight.DryRunSummary)
}
}
func hydrateHandoffActionAudit(action *HandoffAction, store unifiedresources.ResourceStore) {
if action == nil || store == nil {
return
}
actionID := strings.TrimSpace(action.ActionID)
if actionID == "" {
return
}
record, ok, err := store.GetActionAudit(actionID)
if err != nil {
log.Debug().
Err(err).
Str("action_id", actionID).
Msg("[ChatService] Failed to load handoff action audit")
return
}
if !ok {
return
}
action.ActionID = strings.TrimSpace(record.ID)
action.ActionState = strings.TrimSpace(string(record.State))
action.ActionUpdatedAt = formatHandoffActionTime(record.UpdatedAt)
action.ActionRequestedBy = strings.TrimSpace(record.Request.RequestedBy)
action.ActionCapability = strings.TrimSpace(record.Request.CapabilityName)
if strings.TrimSpace(action.TargetResourceID) == "" {
action.TargetResourceID = strings.TrimSpace(record.Request.ResourceID)
}
hydrateHandoffActionPlan(action, &record.Plan)
if record.Result != nil {
if record.Result.Success {
action.ActionResult = "success"
} else {
action.ActionResult = "failed"
}
}
}
func formatHandoffActionTime(value time.Time) string {
return formatHandoffContextTime(value)
}
func formatHandoffContextTime(value time.Time) string {
if value.IsZero() {
return ""
}
return value.UTC().Format(time.RFC3339)
}
func formatHandoffResourceSources(resource unifiedresources.Resource) string {
seen := make(map[string]struct{}, len(resource.Sources)+len(resource.SourceStatus))
sources := make([]string, 0, len(resource.Sources)+len(resource.SourceStatus))
for _, source := range resource.Sources {
name := strings.TrimSpace(string(source))
if name == "" {
continue
}
key := strings.ToLower(name)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
sources = append(sources, name)
}
for source := range resource.SourceStatus {
name := strings.TrimSpace(string(source))
if name == "" {
continue
}
key := strings.ToLower(name)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
sources = append(sources, name)
}
sort.Strings(sources)
return strings.Join(sources, ", ")
}
func formatHandoffResourceSourceStatus(resource unifiedresources.Resource) string {
if len(resource.SourceStatus) == 0 {
return ""
}
sources := make([]string, 0, len(resource.SourceStatus))
for source := range resource.SourceStatus {
if name := strings.TrimSpace(string(source)); name != "" {
sources = append(sources, name)
}
}
sort.Strings(sources)
parts := make([]string, 0, len(sources))
for _, sourceName := range sources {
status := resource.SourceStatus[unifiedresources.DataSource(sourceName)]
state := strings.TrimSpace(status.Status)
if state == "" && status.LastSeen.IsZero() {
continue
}
if state == "" {
state = "reported"
}
part := sourceName + ": " + state
if lastSeen := formatHandoffContextTime(status.LastSeen); lastSeen != "" {
part += " (last seen " + lastSeen + ")"
}
parts = append(parts, part)
}
return strings.Join(parts, "; ")
}
func formatHandoffResourceMetrics(metrics *unifiedresources.ResourceMetrics) string {
if metrics == nil {
return ""
}
values := []struct {
label string
value *unifiedresources.MetricValue
}{
{label: "cpu", value: metrics.CPU},
{label: "memory", value: metrics.Memory},
{label: "disk", value: metrics.Disk},
{label: "network in", value: metrics.NetIn},
{label: "network out", value: metrics.NetOut},
}
parts := make([]string, 0, len(values))
for _, item := range values {
if formatted := formatHandoffMetricValue(item.value); formatted != "" {
parts = append(parts, item.label+" "+formatted)
}
}
return strings.Join(parts, ", ")
}
func formatHandoffMetricValue(value *unifiedresources.MetricValue) string {
if value == nil {
return ""
}
if value.Percent > 0 {
return trimHandoffFloat(value.Percent) + "%"
}
unit := strings.TrimSpace(value.Unit)
if value.Value != 0 {
if unit != "" {
return trimHandoffFloat(value.Value) + " " + unit
}
return trimHandoffFloat(value.Value)
}
if value.Used != nil && value.Total != nil && *value.Total > 0 {
return fmt.Sprintf("%d/%d", *value.Used, *value.Total)
}
return ""
}
func trimHandoffFloat(value float64) string {
return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.1f", value), "0"), ".")
}
func formatHandoffResourceParent(resource unifiedresources.Resource, policy *unifiedresources.ResourcePolicy) string {
parent := strings.TrimSpace(resource.ParentName)
if parent != "" {
return unifiedresources.ResourcePolicyRedactedValue(parent, policy,
unifiedresources.ResourceRedactionHostname,
unifiedresources.ResourceRedactionAlias,
unifiedresources.ResourceRedactionPlatformID,
)
}
parentID := ""
if resource.ParentID != nil {
parentID = strings.TrimSpace(*resource.ParentID)
}
if parentID == "" {
return ""
}
return unifiedresources.ResourcePolicyRedactedValue(parentID, policy,
unifiedresources.ResourceRedactionAlias,
unifiedresources.ResourceRedactionPlatformID,
unifiedresources.ResourceRedactionPath,
)
}
func formatHandoffResourceID(resource unifiedresources.Resource, policy *unifiedresources.ResourcePolicy) string {
return unifiedresources.ResourcePolicyRedactedValue(resource.ID, policy,
unifiedresources.ResourceRedactionAlias,
unifiedresources.ResourceRedactionHostname,
unifiedresources.ResourceRedactionPlatformID,
unifiedresources.ResourceRedactionPath,
)
}
func formatHandoffResourceIncidentSummary(resource unifiedresources.Resource, policy *unifiedresources.ResourcePolicy) string {
parts := make([]string, 0, 4)
if resource.IncidentCount > 0 {
parts = append(parts, fmt.Sprintf("count %d", resource.IncidentCount))
}
if severity := strings.TrimSpace(string(resource.IncidentSeverity)); severity != "" {
parts = append(parts, "severity "+severity)
}
if summary := strings.TrimSpace(resource.IncidentSummary); summary != "" {
if safeSummary := formatHandoffResourceFreeText(summary, resource, policy); safeSummary != "" {
parts = append(parts, safeSummary)
}
}
if action := strings.TrimSpace(resource.IncidentAction); action != "" {
if safeAction := formatHandoffResourceFreeText(action, resource, policy); safeAction != "" {
parts = append(parts, "action "+safeAction)
}
}
for idx, incident := range resource.Incidents {
if idx >= 2 {
break
}
summary := strings.TrimSpace(incident.Summary)
if summary == "" && strings.TrimSpace(incident.Code) == "" {
continue
}
item := formatHandoffResourceFreeText(summary, resource, policy)
if item == "" {
item = strings.TrimSpace(incident.Code)
} else if code := strings.TrimSpace(incident.Code); code != "" {
item += " (" + code + ")"
}
if severity := strings.TrimSpace(string(incident.Severity)); severity != "" {
item += " severity " + severity
}
parts = append(parts, item)
}
return strings.Join(parts, "; ")
}
func formatHandoffResourceCapabilities(resource unifiedresources.Resource, policy *unifiedresources.ResourcePolicy, limit int) string {
if len(resource.Capabilities) == 0 {
return ""
}
if limit <= 0 {
limit = len(resource.Capabilities)
}
seen := make(map[string]struct{}, len(resource.Capabilities))
parts := make([]string, 0, min(limit, len(resource.Capabilities)))
for _, capability := range resource.Capabilities {
name := strings.TrimSpace(capability.Name)
if name == "" {
continue
}
key := strings.ToLower(name + "\x00" + strings.TrimSpace(string(capability.Type)))
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
qualifiers := make([]string, 0, 2)
if capabilityType := strings.TrimSpace(string(capability.Type)); capabilityType != "" {
qualifiers = append(qualifiers, capabilityType)
}
if approvalLevel := strings.TrimSpace(string(capability.MinimumApprovalLevel)); approvalLevel != "" {
qualifiers = append(qualifiers, "approval "+approvalLevel)
}
part := name
if len(qualifiers) > 0 {
part += " (" + strings.Join(qualifiers, "; ") + ")"
}
if description := strings.TrimSpace(capability.Description); description != "" {
if safeDescription := formatHandoffResourceFreeText(description, resource, policy); safeDescription != "" {
part += ": " + safeDescription
}
}
parts = append(parts, part)
if len(parts) >= limit {
break
}
}
if len(parts) == 0 {
return ""
}
if remaining := len(resource.Capabilities) - len(parts); remaining > 0 {
parts = append(parts, fmt.Sprintf("%d more", remaining))
}
return strings.Join(parts, "; ")
}
func formatHandoffResourceFreeText(value string, resource unifiedresources.Resource, policy *unifiedresources.ResourcePolicy) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
redacted := strings.TrimSpace(unifiedresources.ResourcePolicyRedactedText(value, resource))
if redacted == "" {
return ""
}
if unifiedresources.ResourcePolicyRequiresGovernedSummary(policy) && redacted == value {
return ""
}
return redacted
}
func appendHandoffContextLine(b *strings.Builder, label, value string) {
value = strings.TrimSpace(value)
if value == "" {
return
}
if b.Len() > 0 {
b.WriteByte('\n')
}
b.WriteString(label)
b.WriteString(": ")
b.WriteString(value)
}
func formatHandoffActionResource(action HandoffAction) string {
resourceName := strings.TrimSpace(action.TargetResourceName)
resourceType := strings.TrimSpace(action.TargetResourceType)
resourceID := strings.TrimSpace(action.TargetResourceID)
node := strings.TrimSpace(action.TargetNode)
parts := make([]string, 0, 4)
if resourceName != "" {
parts = append(parts, resourceName)
}
if resourceType != "" {
parts = append(parts, resourceType)
}
if resourceID != "" {
parts = append(parts, resourceID)
}
if node != "" {
parts = append(parts, "node "+node)
}
return strings.Join(parts, " / ")
}
func (s *Service) hydrateHandoffResources(sessionID string, handoffResources []HandoffResource, sessions *SessionStore, provider tools.UnifiedResourceProvider) {
if len(handoffResources) == 0 || sessions == nil || provider == nil {
return
}
resolvedCtx := sessions.GetResolvedContext(sessionID)
seen := make(map[string]struct{}, len(handoffResources))
for _, resource := range handoffResources {
key := strings.ToLower(strings.TrimSpace(resource.Type) + "\x00" + strings.TrimSpace(resource.ID) + "\x00" + strings.TrimSpace(resource.Name))
if key == "\x00\x00" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
reg, ok := tools.CanonicalHandoffResourceRegistration(provider, resource.ID, resource.Name, resource.Type, resource.Node)
if !ok {
log.Debug().
Str("session_id", sessionID).
Str("resource_id", resource.ID).
Str("resource_name", resource.Name).
Str("resource_type", resource.Type).
Msg("[ChatService] Skipped unresolved handoff resource")
continue
}
resolvedCtx.AddResolvedResource(reg)
if resolved, ok := resolvedCtx.GetResolvedResourceByAlias(reg.Name); ok {
resolvedCtx.MarkExplicitAccess(resolved.GetResourceID())
log.Debug().
Str("session_id", sessionID).
Str("resource_id", resolved.GetResourceID()).
Str("resource_name", reg.Name).
Msg("[ChatService] Hydrated handoff resource into resolved context")
}
}
}
// PatrolRequest represents a patrol execution request within the chat service
type PatrolRequest struct {
Prompt string `json:"prompt"`
SystemPrompt string `json:"system_prompt"`
SessionID string `json:"session_id,omitempty"`
ExecutionID string `json:"execution_id,omitempty"`
UseCase string `json:"use_case"`
MaxTurns int `json:"max_turns,omitempty"`
}
// PatrolResponse contains the results of a patrol execution
type PatrolResponse struct {
Content string `json:"content"`
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
}
// ExecutePatrolStream creates a temporary agentic loop for patrol execution.
// Unlike ExecuteStream (which uses the shared agentic loop with the chat system prompt),
// this creates an isolated loop with the patrol's own system prompt and model.
func (s *Service) ExecutePatrolStream(ctx context.Context, req PatrolRequest, callback StreamCallback) (*PatrolResponse, error) {
log.Debug().
Str("session_id", req.SessionID).
Int("prompt_len", len(req.Prompt)).
Msg("[ChatService] ExecutePatrolStream called")
s.mu.RLock()
if !s.started {
s.mu.RUnlock()
return nil, fmt.Errorf("service not started")
}
sessions := s.sessions
baseExecutor := s.executor
unifiedResourceProvider := s.unifiedResourceProvider
cfg := s.cfg
effectiveControlLevel := s.effectiveControlLevelLocked()
s.mu.RUnlock()
executor := baseExecutor
if baseExecutor != nil {
executor = baseExecutor.Clone()
executor.SetControlLevel(effectiveControlLevel)
}
// Determine model: use patrol model or fall back to chat model
patrolModel := ""
if cfg != nil {
patrolModel = cfg.GetPatrolModel()
if patrolModel == "" {
patrolModel = cfg.GetChatModel()
}
}
if patrolModel == "" {
return nil, fmt.Errorf("no patrol model configured")
}
// Create a temporary provider for the patrol model
provider, err := s.createPatrolProviderForModel(patrolModel)
if err != nil {
return nil, fmt.Errorf("failed to create patrol provider: %w", err)
}
// Create a temporary agentic loop with the patrol system prompt
systemPrompt := req.SystemPrompt
if systemPrompt == "" {
systemPrompt = s.buildSystemPrompt()
}
tempLoop := NewAgenticLoop(provider, executor, systemPrompt)
tempLoop.SetOrgID(s.orgID)
tempLoop.SetAutonomousMode(true) // Patrol runs without approval prompts
tempLoop.SetExecutionID(req.ExecutionID)
tempLoop.SetRequestSanitizer(modelboundary.RequestSanitizerForModel(patrolModel, unifiedResourceProvider))
if req.MaxTurns > 0 {
tempLoop.SetMaxTurns(req.MaxTurns)
}
// Set provider info for telemetry
parts := strings.SplitN(patrolModel, ":", 2)
if len(parts) == 2 {
tempLoop.SetProviderInfo(parts[0], parts[1])
}
// Ensure patrol session exists
sessionID := req.SessionID
if sessionID == "" {
sessionID = "patrol-main"
}
session, err := sessions.EnsureSession(sessionID)
if err != nil {
return nil, fmt.Errorf("failed to ensure patrol session: %w", err)
}
// Set resolved context on executor (same as ExecuteStream does)
if executor != nil {
resolvedCtx := sessions.GetResolvedContext(session.ID)
executor.SetResolvedContext(resolvedCtx)
}
// Set session FSM
sessionFSM := sessions.GetSessionFSM(session.ID)
tempLoop.SetSessionFSM(sessionFSM)
// Create a fresh knowledge accumulator for this patrol run.
// Unlike user chat (which reuses session-scoped KA across messages),
// patrol runs need a clean slate to avoid stale facts from prior runs
// (patrol-main reuses the same session ID across scheduled runs).
ka := sessions.NewKnowledgeAccumulatorForRun(session.ID)
tempLoop.SetKnowledgeAccumulator(ka)
// Set mid-run budget checker if configured
if s.budgetChecker != nil {
tempLoop.SetBudgetChecker(s.budgetChecker)
}
// Add user message to the session for forensics / audit trail.
userMsg := Message{
ID: uuid.New().String(),
Role: "user",
Content: req.Prompt,
Timestamp: time.Now(),
}
if err := sessions.AddMessage(session.ID, userMsg); err != nil {
log.Warn().Err(err).Msg("failed to save patrol user message")
}
// Patrol runs are stateless investigations. The "patrol-main" session
// reuses the same id across scheduled runs, so loading prior session
// history into the agentic loop accumulates broken state: when any
// run ends after the model emitted tool_calls but before all tool
// results landed (provider error, timeout, context cancellation),
// the orphan tool_calls persist and every subsequent run hits
// "An assistant message with 'tool_calls' must be followed by tool
// messages responding to each 'tool_call_id'." Patrol must see only
// this run's user prompt; the session is just a forensic log.
messages := []Message{userMsg}
// Get governed tools for the Patrol run.
filteredTools := s.toolsForExecutionMode(true, true)
// Run the agentic loop
resultMessages, err := tempLoop.ExecuteWithTools(ctx, session.ID, messages, filteredTools, callback)
if err != nil {
// Still save any messages we got
for _, msg := range resultMessages {
if saveErr := sessions.AddMessage(session.ID, msg); saveErr != nil {
log.Warn().Err(saveErr).Msg("failed to save patrol message after error")
}
}
return nil, err
}
// Save result messages
for _, msg := range resultMessages {
if msg.Role == "user" && msg.ToolResult == nil {
continue
}
if err := sessions.AddMessage(session.ID, msg); err != nil {
log.Warn().Err(err).Msg("failed to save patrol message")
}
}
// Patrol-main is reused across every scheduled run (the
// stateless-input contract means we never load it back into the
// agentic loop, but we still write to it for the Pulse Assistant
// sidebar's forensic view). Without a cap, the file grew to 16 MB /
// 3,593 messages within a month and every AddMessage rewrote the
// whole file to disk. Cap at the most recent ~two Patrol runs'
// worth of messages — enough for the sidebar to show recent
// activity, far short of unbounded growth. The canonical forensic
// log is the PatrolRunRecord history, not this session.
const maxPatrolSessionMessages = 200
if err := sessions.TrimMessages(session.ID, maxPatrolSessionMessages); err != nil {
log.Warn().Err(err).Msg("failed to trim patrol session messages")
}
// Collect content from result messages
var contentBuilder strings.Builder
for _, msg := range resultMessages {
if msg.Role == "assistant" && msg.Content != "" {
contentBuilder.WriteString(msg.Content)
}
}
// Send done event
doneData, _ := json.Marshal(DoneData{
SessionID: session.ID,
InputTokens: tempLoop.GetTotalInputTokens(),
OutputTokens: tempLoop.GetTotalOutputTokens(),
})
callback(StreamEvent{Type: "done", Data: doneData})
return &PatrolResponse{
Content: contentBuilder.String(),
InputTokens: tempLoop.GetTotalInputTokens(),
OutputTokens: tempLoop.GetTotalOutputTokens(),
}, nil
}
// createProviderForModel creates a streaming provider for a specific model string (provider:model format).
func (s *Service) createProviderForModel(modelStr string) (providers.StreamingProvider, error) {
if s.providerFactory != nil {
return s.providerFactory(modelStr)
}
if s.cfg == nil {
return nil, fmt.Errorf("no Pulse Assistant config")
}
parts := strings.SplitN(modelStr, ":", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid model format: %s (expected provider:model)", modelStr)
}
providerName := parts[0]
modelName := parts[1]
timeout := 5 * time.Minute
switch providerName {
case "anthropic":
if s.cfg.AnthropicAPIKey == "" {
return nil, fmt.Errorf("Anthropic API key not configured")
}
return providers.NewAnthropicClient(s.cfg.AnthropicAPIKey, modelName, timeout), nil
case "openai":
if s.cfg.OpenAIAPIKey == "" {
return nil, fmt.Errorf("OpenAI API key not configured")
}
return providers.NewOpenAIClient(s.cfg.OpenAIAPIKey, modelName, s.cfg.OpenAIBaseURL, timeout), nil
case "openrouter":
if s.cfg.OpenRouterAPIKey == "" {
return nil, fmt.Errorf("OpenRouter API key not configured")
}
return providers.NewOpenAIClient(s.cfg.OpenRouterAPIKey, modelName, s.cfg.GetBaseURLForProvider(config.AIProviderOpenRouter), timeout), nil
case "deepseek":
if s.cfg.DeepSeekAPIKey == "" {
return nil, fmt.Errorf("DeepSeek API key not configured")
}
return providers.NewOpenAIClient(s.cfg.DeepSeekAPIKey, modelName, "https://api.deepseek.com", timeout), nil
case "gemini":
if s.cfg.GeminiAPIKey == "" {
return nil, fmt.Errorf("Gemini API key not configured")
}
return providers.NewGeminiClient(s.cfg.GeminiAPIKey, modelName, "", timeout), nil
case "ollama":
baseURL := s.cfg.GetBaseURLForProvider(config.AIProviderOllama)
return providers.NewOllamaClient(modelName, baseURL, s.cfg.OllamaUsername, s.cfg.OllamaPassword, timeout)
case config.AIProviderQuickstart:
return nil, fmt.Errorf("quickstart provider is retired; configure a provider API key or Ollama")
default:
return nil, fmt.Errorf("unsupported provider: %s", providerName)
}
}
func (s *Service) createPatrolProviderForModel(modelStr string) (providers.StreamingProvider, error) {
if s.patrolProviderFactory != nil {
return s.patrolProviderFactory(modelStr)
}
return s.createProviderForModel(modelStr)
}
// ListAvailableTools returns tool names available for the given prompt.
func (s *Service) ListAvailableTools(ctx context.Context, prompt string) []string {
s.mu.RLock()
executor := s.executor
s.mu.RUnlock()
if executor == nil {
return nil
}
tools := s.toolsForExecutionMode(s.isAutonomousModeEnabled(), false)
names := make([]string, 0, len(tools))
for _, tool := range tools {
if tool.Name == "" {
continue
}
names = append(names, tool.Name)
}
sort.Strings(names)
return names
}
// ListSessions returns all sessions
func (s *Service) ListSessions(ctx context.Context) ([]Session, error) {
s.mu.RLock()
sessions := s.sessions
actionAuditStore := s.actionAuditStore
orgID := s.orgID
s.mu.RUnlock()
if sessions == nil {
return nil, fmt.Errorf("service not started")
}
refreshSessionHandoffActionSummaries(sessions, orgID, actionAuditStore)
return sessions.List()
}
func refreshSessionHandoffActionSummaries(sessions *SessionStore, orgID string, actionStore unifiedresources.ResourceStore) {
if sessions == nil {
return
}
list, err := sessions.List()
if err != nil {
log.Debug().Err(err).Msg("[ChatService] Failed to list sessions for handoff action refresh")
return
}
for _, session := range list {
if session.HandoffSummary == nil || session.HandoffSummary.ActionCount == 0 {
continue
}
actions, err := sessions.GetModelHandoffActions(session.ID)
if err != nil {
log.Debug().Err(err).Str("session_id", session.ID).Msg("[ChatService] Failed to load session handoff actions for summary refresh")
continue
}
refreshed := refreshHandoffActionStatus(actions, orgID, actionStore)
if handoffActionsEqual(actions, refreshed) {
continue
}
if err := sessions.SetModelHandoffActions(session.ID, refreshed); err != nil {
log.Debug().Err(err).Str("session_id", session.ID).Msg("[ChatService] Failed to persist refreshed session handoff action summary")
}
}
}
func handoffActionsEqual(a, b []HandoffAction) bool {
a = normalizeHandoffActions(a)
b = normalizeHandoffActions(b)
if len(a) != len(b) {
return false
}
for idx := range a {
if a[idx] != b[idx] {
return false
}
}
return true
}
// CreateSession creates a new session
func (s *Service) CreateSession(ctx context.Context) (*Session, error) {
s.mu.RLock()
sessions := s.sessions
s.mu.RUnlock()
if sessions == nil {
return nil, fmt.Errorf("service not started")
}
return sessions.Create()
}
// DeleteSession deletes a session
func (s *Service) DeleteSession(ctx context.Context, sessionID string) error {
s.mu.RLock()
sessions := s.sessions
s.mu.RUnlock()
if sessions == nil {
return fmt.Errorf("service not started")
}
return sessions.Delete(sessionID)
}
// GetMessages returns messages for a session
func (s *Service) GetMessages(ctx context.Context, sessionID string) ([]Message, error) {
s.mu.RLock()
sessions := s.sessions
s.mu.RUnlock()
if sessions == nil {
return nil, fmt.Errorf("service not started")
}
return sessions.GetMessages(sessionID)
}
// GetModelHandoffFindingID returns the session-scoped finding reference used to
// refresh model-only Patrol context on follow-up turns.
func (s *Service) GetModelHandoffFindingID(ctx context.Context, sessionID string) (string, error) {
s.mu.RLock()
sessions := s.sessions
s.mu.RUnlock()
if sessions == nil {
return "", fmt.Errorf("service not started")
}
return sessions.GetModelHandoffFindingID(sessionID)
}
// GetModelHandoffMetadata returns the session-scoped product handoff identity
// used to refresh model-only Patrol context on follow-up turns.
func (s *Service) GetModelHandoffMetadata(ctx context.Context, sessionID string) (HandoffMetadata, error) {
s.mu.RLock()
sessions := s.sessions
s.mu.RUnlock()
if sessions == nil {
return HandoffMetadata{}, fmt.Errorf("service not started")
}
return sessions.GetModelHandoffMetadata(sessionID)
}
// GetActionAuditStore returns the durable action-audit store used by the chat runtime.
func (s *Service) GetActionAuditStore() unifiedresources.ResourceStore {
if s == nil {
return nil
}
s.mu.RLock()
defer s.mu.RUnlock()
return s.actionAuditStore
}
// ClearModelHandoffContext invalidates product-originated model-only handoff
// state after its source record can no longer be resolved. Unpinned resolved
// resources are cleared with it so stale Patrol handoffs cannot remain action
// scope on follow-up turns.
func (s *Service) ClearModelHandoffContext(ctx context.Context, sessionID string) error {
s.mu.RLock()
sessions := s.sessions
s.mu.RUnlock()
if sessions == nil {
return fmt.Errorf("service not started")
}
err := sessions.ClearModelHandoffContext(sessionID)
sessions.ClearSessionState(sessionID, true)
return err
}
// AbortSession aborts an ongoing session
func (s *Service) AbortSession(ctx context.Context, sessionID string) error {
s.mu.RLock()
started := s.started
agenticLoop := s.agenticLoop
s.mu.RUnlock()
if !started {
return fmt.Errorf("service not started")
}
activeLoops := s.getActiveLoops(sessionID)
if len(activeLoops) > 0 {
for _, loop := range activeLoops {
loop.Abort(sessionID)
}
return nil
}
if agenticLoop == nil {
return fmt.Errorf("service not started")
}
agenticLoop.Abort(sessionID)
return nil
}
// AnswerQuestion provides answers to a pending question
func (s *Service) AnswerQuestion(ctx context.Context, questionID string, answers []QuestionAnswer) error {
s.mu.RLock()
started := s.started
agenticLoop := s.agenticLoop
s.mu.RUnlock()
if !started {
return fmt.Errorf("service not started")
}
if loop := s.findQuestionLoop(questionID); loop != nil {
return loop.AnswerQuestion(questionID, answers)
}
if agenticLoop == nil {
return fmt.Errorf("service not started")
}
return agenticLoop.AnswerQuestion(questionID, answers)
}
// Stub methods for features not yet implemented
// SummarizeSession compresses context
func (s *Service) SummarizeSession(ctx context.Context, sessionID string) (map[string]interface{}, error) {
return map[string]interface{}{"status": "not_implemented"}, nil
}
// GetSessionDiff returns file changes
func (s *Service) GetSessionDiff(ctx context.Context, sessionID string) (map[string]interface{}, error) {
return map[string]interface{}{"status": "not_implemented"}, nil
}
// ForkSession creates a branch
func (s *Service) ForkSession(ctx context.Context, sessionID string) (*Session, error) {
return nil, fmt.Errorf("not implemented")
}
// RevertSession reverts changes
func (s *Service) RevertSession(ctx context.Context, sessionID string) (map[string]interface{}, error) {
return map[string]interface{}{"status": "not_implemented"}, nil
}
// UnrevertSession restores reverted changes
func (s *Service) UnrevertSession(ctx context.Context, sessionID string) (map[string]interface{}, error) {
return map[string]interface{}{"status": "not_implemented"}, nil
}
// GetBaseURL returns empty since there's no sidecar
func (s *Service) GetBaseURL() string {
return ""
}
// GetExecutor returns the underlying tool executor so callers (e.g. patrol)
// can set session-scoped state like the PatrolFindingCreator before executing.
func (s *Service) GetExecutor() *tools.PulseToolExecutor {
s.mu.RLock()
defer s.mu.RUnlock()
return s.executor
}
// Provider setter methods
func (s *Service) SetAlertProvider(provider MCPAlertProvider) {
s.mu.Lock()
defer s.mu.Unlock()
if s.executor != nil {
s.executor.SetAlertProvider(provider)
}
}
func (s *Service) SetFindingsProvider(provider MCPFindingsProvider) {
s.mu.Lock()
defer s.mu.Unlock()
if s.executor != nil {
s.executor.SetFindingsProvider(provider)
}
}
func (s *Service) SetBaselineProvider(provider MCPBaselineProvider) {
s.mu.Lock()
defer s.mu.Unlock()
if s.executor != nil {
s.executor.SetBaselineProvider(provider)
}
}
func (s *Service) SetPatternProvider(provider MCPPatternProvider) {
s.mu.Lock()
defer s.mu.Unlock()
if s.executor != nil {
s.executor.SetPatternProvider(provider)
}
}
func (s *Service) SetMetricsHistory(provider MCPMetricsHistoryProvider) {
s.mu.Lock()
defer s.mu.Unlock()
if s.executor != nil {
s.executor.SetMetricsHistory(provider)
}
}
func (s *Service) SetBackupProvider(provider MCPBackupProvider) {
s.mu.Lock()
defer s.mu.Unlock()
if s.executor != nil {
s.executor.SetBackupProvider(provider)
}
}
func (s *Service) SetGuestConfigProvider(provider MCPGuestConfigProvider) {
s.mu.Lock()
defer s.mu.Unlock()
if s.executor != nil {
s.executor.SetGuestConfigProvider(provider)
}
}
func (s *Service) SetAppContainerConfigProvider(provider MCPAppContainerConfigProvider) {
s.mu.Lock()
defer s.mu.Unlock()
if s.executor != nil {
s.executor.SetAppContainerConfigProvider(provider)
}
}
func (s *Service) SetDiskHealthProvider(provider MCPDiskHealthProvider) {
s.mu.Lock()
defer s.mu.Unlock()
if s.executor != nil {
s.executor.SetDiskHealthProvider(provider)
}
}
func (s *Service) SetUpdatesProvider(provider MCPUpdatesProvider) {
s.mu.Lock()
defer s.mu.Unlock()
if s.executor != nil {
s.executor.SetUpdatesProvider(provider)
}
}
func (s *Service) SetAgentProfileManager(manager AgentProfileManager) {
s.mu.Lock()
defer s.mu.Unlock()
if s.executor != nil {
s.executor.SetAgentProfileManager(manager)
}
}
func (s *Service) SetFindingsManager(manager FindingsManager) {
s.mu.Lock()
defer s.mu.Unlock()
if s.executor != nil {
s.executor.SetFindingsManager(manager)
}
}
func (s *Service) SetMetadataUpdater(updater MetadataUpdater) {
s.mu.Lock()
defer s.mu.Unlock()
if s.executor != nil {
s.executor.SetMetadataUpdater(updater)
}
}
func (s *Service) SetIncidentRecorderProvider(provider IncidentRecorderProvider) {
s.mu.Lock()
defer s.mu.Unlock()
if s.executor != nil {
s.executor.SetIncidentRecorderProvider(provider)
}
}
func (s *Service) SetEventCorrelatorProvider(provider EventCorrelatorProvider) {
s.mu.Lock()
defer s.mu.Unlock()
if s.executor != nil {
s.executor.SetEventCorrelatorProvider(provider)
}
}
func (s *Service) SetKnowledgeStoreProvider(provider KnowledgeStoreProvider) {
s.mu.Lock()
defer s.mu.Unlock()
if s.executor != nil {
s.executor.SetKnowledgeStoreProvider(provider)
}
}
func (s *Service) SetUnifiedResourceProvider(provider tools.UnifiedResourceProvider) {
s.mu.Lock()
defer s.mu.Unlock()
s.unifiedResourceProvider = provider
if s.executor != nil {
s.executor.SetUnifiedResourceProvider(provider)
}
}
func (s *Service) SetAppContainerActionProvider(provider MCPAppContainerActionProvider) {
s.mu.Lock()
defer s.mu.Unlock()
if s.executor != nil {
s.executor.SetAppContainerActionProvider(provider)
}
}
func (s *Service) SetAppContainerReadProvider(provider MCPAppContainerReadProvider) {
s.mu.Lock()
defer s.mu.Unlock()
if s.executor != nil {
s.executor.SetAppContainerReadProvider(provider)
}
}
func (s *Service) SetDiscoveryProvider(provider MCPDiscoveryProvider) {
s.mu.Lock()
defer s.mu.Unlock()
if s.executor != nil {
s.executor.SetDiscoveryProvider(provider)
}
// Create/update context prefetcher with the discovery provider
if s.readState != nil && provider != nil {
s.contextPrefetcher = NewContextPrefetcher(s.readState, provider)
log.Info().Msg("[ChatService] Context prefetcher created with discovery provider")
} else {
log.Warn().
Bool("hasReadState", s.readState != nil).
Bool("hasDiscoveryProvider", provider != nil).
Msg("[ChatService] Cannot create context prefetcher - missing provider")
}
}
func (s *Service) UpdateControlSettings(cfg *config.AIConfig) {
if cfg == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.cfg = cfg
if s.executor != nil {
s.executor.SetControlLevel(s.effectiveControlLevelLocked())
s.executor.SetProtectedGuests(cfg.GetProtectedGuests())
}
}
// SetAutonomousMode enables or disables autonomous mode for investigations
// When enabled, read-only commands can be auto-approved without user confirmation
// SetBudgetChecker sets a function called after each agentic turn to enforce
// token spending limits. The checker is propagated to the agentic loop.
func (s *Service) SetBudgetChecker(fn func() error) {
s.mu.Lock()
defer s.mu.Unlock()
s.budgetChecker = fn
}
func (s *Service) SetAutonomousMode(enabled bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.autonomousMode = enabled
if s.executor != nil {
s.executor.SetContext("", "", enabled)
}
if s.agenticLoop != nil {
s.agenticLoop.SetAutonomousMode(enabled)
}
}
// ExecuteCommand executes a command directly via the tool executor (bypasses LLM)
// This is used for auto-executing fixes in full autonomy mode
func (s *Service) ExecuteCommand(ctx context.Context, command, targetHost string) (output string, exitCode int, err error) {
s.mu.RLock()
executor := s.executor
s.mu.RUnlock()
if executor == nil {
return "", -1, fmt.Errorf("executor not available")
}
// Build args for pulse_run_command
args := map[string]interface{}{
"command": command,
"run_on_host": true,
}
if targetHost != "" && targetHost != "local" {
args["target_host"] = targetHost
}
// Execute the command
result, toolErr := executor.ExecuteTool(ctx, "pulse_run_command", args)
if toolErr != nil {
return "", -1, toolErr
}
// Extract text from result content
var resultText string
for _, content := range result.Content {
if content.Type == "text" {
resultText += content.Text
}
}
// Parse the result
if result.IsError {
return resultText, 1, fmt.Errorf("command execution failed: %s", resultText)
}
// Check if approval was required (this shouldn't happen in autonomous mode, but just in case)
if strings.HasPrefix(resultText, "APPROVAL_REQUIRED:") {
return "", -1, fmt.Errorf("command requires approval (unexpected in autonomous mode)")
}
if strings.HasPrefix(resultText, "POLICY_BLOCKED:") {
return "", -1, fmt.Errorf("command blocked by security policy")
}
// Check for exit code in result
if strings.Contains(resultText, "Command failed (exit code") {
// Parse exit code from message like "Command failed (exit code 1):"
var code int
if n, err := fmt.Sscanf(resultText, "Command failed (exit code %d):", &code); n != 1 || err != nil {
// Some providers omit the trailing colon in this message shape.
if n, err := fmt.Sscanf(resultText, "Command failed (exit code %d)", &code); n != 1 || err != nil {
log.Debug().Str("result_text", resultText).Msg("failed to parse command exit code, defaulting to 1")
code = 1
}
}
return resultText, code, nil
}
return resultText, 0, nil
}
// createProvider creates an AI provider based on config
func (s *Service) createProvider() (providers.StreamingProvider, error) {
if s.cfg == nil {
return nil, fmt.Errorf("no Pulse Assistant config")
}
chatModel := s.cfg.GetChatModel()
if chatModel == "" {
return nil, fmt.Errorf("no chat model configured")
}
return s.createProviderForModel(chatModel)
}
func (s *Service) applyChatContextSettings() {
StatelessContext = DefaultStatelessContext
}
// buildSystemPrompt builds the base system prompt for the AI.
// Mode-specific context (autonomous vs controlled) is added dynamically by the AgenticLoop.
//
// Philosophy: This prompt provides identity, context, and tool policy. Tool
// selection remains model-owned; Pulse enforces safety after a model choice via
// tool policy, approvals, and FSM verification gates.
func (s *Service) buildSystemPrompt() string {
return `You are Pulse AI, a knowledgeable infrastructure assistant. You pair-program with the user on their homelab and infrastructure tasks.
` + s.buildToolGovernancePromptSection() + `
## INFRASTRUCTURE TOPOLOGY
- Resources are organized hierarchically: nodes → VMs/containers → Docker containers
- target_host specifies where commands run (host name, container name, or VM name)
- Commands execute inside the target: target_host="homepage-docker" runs inside that container
- For Docker containers inside system containers: target the container, then use docker commands
## DOCKER BIND MOUNTS
- Container files are often mapped to host paths via bind mounts
- To edit a container's config, find the bind mount and edit the host path
- Bind mount mappings may be available in Pulse discovery context or tools when needed
## TOOL SELECTION
- Tool action modes and approval policies are generated from Pulse's tool registry. Treat that manifest as the source of truth for whether a tool is read-only, mixed, or write-capable.
- pulse_control and pulse_file_edit are WRITE tools — they change infrastructure state.
- pulse_docker, pulse_kubernetes, pulse_alerts, and pulse_knowledge are MIXED tools — their read subactions are safe, but their write or decision-recording subactions require the governed path described in the manifest.
- Not every VM or container supports control. Some API-backed platforms are read-only even when the resource type is "vm" or "system-container".
- Write tools are allowed only when the user explicitly asks you to perform an action.
- Status checks and monitoring are read-oriented; do not change state unless the user asked for a state change.
- If you are missing critical information (target, risky choice, preference), pulse_question is available for structured clarification.
- pulse_question is not available in autonomous mode; proceed with safe defaults and clearly state assumptions instead.
## HOW TO RESPOND
You are like a colleague doing pair programming on infrastructure tasks. Tool calls are your internal investigation — the user sees your final synthesized response.
1. INVESTIGATE THOROUGHLY: Decide whether tool evidence is needed, then gather enough information to answer well. Don't stop after the first tool call if more context would help.
2. SYNTHESIZE YOUR FINDINGS: After using tools, explain what you learned and did. Don't just confirm "done" — provide context that helps the user understand the outcome.
3. SURFACE ISSUES PROACTIVELY: If you discover something during investigation that affects the user's goal (prerequisites missing, config issues, limitations), mention it. Don't hide problems.
4. SUGGEST NEXT STEPS: If there's something the user might need to do next, or if you noticed a potential improvement, mention it.
5. BE DIRECT: Acknowledge mistakes or complications honestly. If something won't work as the user expects, say so clearly.
## TASK COMPLETION
- After successful control actions, respond once you have enough evidence to explain the result.
- If a tool call is blocked, treat the result as a policy boundary, not a tool-routing instruction.
- Use the returned facts and policy boundary to decide whether more context, a different governed tool call, or user clarification is needed.`
}
func (s *Service) buildToolGovernancePromptSection() string {
manifest := []tools.ToolGovernanceDescriptor(nil)
if s != nil && s.executor != nil {
manifest = s.executor.ListToolGovernance()
}
if len(manifest) == 0 {
manifest = fallbackAssistantToolGovernance()
}
var b strings.Builder
b.WriteString("## AVAILABLE TOOL GOVERNANCE\n")
b.WriteString("This manifest is generated from Pulse's governed tool registry. Use only tools that are actually offered by the provider for the current turn.\n")
for _, tool := range manifest {
mode := tool.ActionMode
if mode == "" {
mode = tools.ToolActionRead
}
policy := strings.TrimSpace(tool.ApprovalPolicy)
if policy == "" {
policy = "no approval required"
}
summary := strings.TrimSpace(tool.Summary)
if summary == "" {
summary = firstPromptLine(tool.Description)
}
if summary != "" {
b.WriteString(fmt.Sprintf("- %s: mode=%s; approval=%s; %s\n", tool.Name, mode, policy, summary))
} else {
b.WriteString(fmt.Sprintf("- %s: mode=%s; approval=%s\n", tool.Name, mode, policy))
}
}
b.WriteString("- pulse_question: mode=interactive; approval=user answer required; asks the user for missing information using a structured prompt in interactive chat only.\n")
return strings.TrimRight(b.String(), "\n")
}
func fallbackAssistantToolGovernance() []tools.ToolGovernanceDescriptor {
return []tools.ToolGovernanceDescriptor{
{
Name: "pulse_query",
ActionMode: tools.ToolActionRead,
ApprovalPolicy: "no approval required",
Summary: "Resolves canonical infrastructure identity, topology, config, and health without changing state.",
},
{
Name: "pulse_read",
ActionMode: tools.ToolActionRead,
ApprovalPolicy: "no approval required; write-like commands are rejected",
Summary: "Runs read-only infrastructure inspection such as logs, file reads, tails, and safe exec.",
},
{
Name: "pulse_discovery",
ActionMode: tools.ToolActionMixed,
ApprovalPolicy: "no approval required; run uses read-only evidence collection and updates the discovery cache",
Summary: "Reads or refreshes discovered service paths, ports, and bind mounts for known resources.",
},
{
Name: "pulse_control",
ActionMode: tools.ToolActionWrite,
ApprovalPolicy: "hidden in read-only mode; approval required in controlled mode",
Summary: "Runs shared Pulse control actions and can control only resources that explicitly support shared Pulse actions.",
},
{
Name: "pulse_file_edit",
ActionMode: tools.ToolActionWrite,
ApprovalPolicy: "hidden in read-only mode; approval required in controlled mode",
Summary: "Changes files through the governed file-edit path.",
},
{
Name: "pulse_docker",
ActionMode: tools.ToolActionMixed,
ApprovalPolicy: "read/list actions are safe; control and update subactions require approval in controlled mode",
Summary: "Lists Docker state and performs governed Docker control/update subactions.",
},
{
Name: "pulse_kubernetes",
ActionMode: tools.ToolActionMixed,
ApprovalPolicy: "read/list/log actions are safe; scale, restart, delete, and exec subactions require approval in controlled mode",
Summary: "Reads Kubernetes topology and runs governed workload-control subactions.",
},
}
}
func firstPromptLine(description string) string {
description = strings.TrimSpace(description)
if description == "" {
return ""
}
if idx := strings.Index(description, "\n"); idx >= 0 {
description = strings.TrimSpace(description[:idx])
}
return strings.Join(strings.Fields(description), " ")
}
func (s *Service) injectRecentSessionContext(sessionID string, messages []Message, sessions *SessionStore) {
if sessions == nil {
return
}
resolvedCtx := sessions.GetResolvedContext(sessionID)
if resolvedCtx == nil {
return
}
recentIDs := resolvedCtx.GetRecentlyAccessedResourcesSorted(tools.RecentAccessWindow, 3)
if len(recentIDs) == 0 {
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
}
location := res.Node
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 != "" {
label = fmt.Sprintf("%s (%s)", label, kind)
}
lines = append(lines, "- "+label)
if primaryName == "" {
primaryName = res.Name
primaryResourceID = res.ResourceID
primaryTarget = res.TargetHost
primaryKind = kind
primaryAdapter = res.Adapter
primaryAllowsRoutingHint = policy == nil
if primaryName == "" {
primaryName = label
}
if primaryResourceID == "" {
primaryResourceID = resourceID
}
}
}
if len(lines) == 0 {
return
}
primary := strings.TrimPrefix(lines[0], "- ")
if primaryName == "" {
primaryName = primary
}
if primaryAllowsRoutingHint {
readHint := readRoutingHintForResolvedResource(primaryKind, primaryAdapter, primaryTarget, primaryName, primaryResourceID)
targetHint := readHint.targetFact()
if targetHint != "" {
lines[0] = lines[0] + "; " + targetHint
}
}
log.Debug().
Str("session_id", sessionID).
Strs("recent_resource_ids", recentIDs).
Msg("[ChatService] Injecting neutral recent session context")
if len(messages) == 0 {
return
}
lastIdx := len(messages) - 1
if messages[lastIdx].Role != "user" {
return
}
context := "Session context from earlier Assistant turns. Use only if relevant to the user's message; otherwise ignore it or ask a clarifying question.\n" + strings.Join(lines, "\n")
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)
// Patrol may be scoped by explicit product configuration, but never by
// prompt keyword inference. The selected Patrol model owns tool choice.
if patrolMode {
filtered := s.filterToolsForPatrol(providerTools)
log.Debug().
Int("total_tools", len(providerTools)).
Int("tool_manifest_count", len(filtered)).
Bool("patrol_scope_filter", true).
Msg("[toolsForExecutionMode] Built Patrol tool manifest from configured subsystem scope")
return filtered
}
filtered := append([]providers.Tool{}, providerTools...)
// pulse_question is interactive; exclude it for autonomous runs (Pulse Patrol).
if !autonomousMode {
filtered = append(filtered, userQuestionTool())
}
log.Debug().
Int("total_tools", len(providerTools)).
Int("tool_manifest_count", len(filtered)).
Bool("autonomous", autonomousMode).
Msg("[toolsForExecutionMode] Exposing governed tool manifest")
return filtered
}
// filterToolsForPatrol applies explicit Patrol subsystem scope settings. It
// must not inspect prompt text or infer which tool the model should use.
func (s *Service) filterToolsForPatrol(providerTools []providers.Tool) []providers.Tool {
s.mu.RLock()
cfg := s.cfg
s.mu.RUnlock()
// Determine which subsystems are enabled for patrol.
includeDocker := cfg != nil && cfg.PatrolAnalyzeDocker
includeStorage := cfg != nil && cfg.PatrolAnalyzeStorage
// K8s and PMG don't have dedicated top-level patrol flags.
includeK8s := true
includePMG := true
filtered := make([]providers.Tool, 0, len(providerTools))
for _, tool := range providerTools {
switch tool.Name {
case "pulse_docker":
if !includeDocker {
continue
}
case "pulse_storage":
if !includeStorage {
continue
}
case "pulse_kubernetes":
if !includeK8s {
continue
}
case "pulse_pmg":
if !includePMG {
continue
}
}
filtered = append(filtered, tool)
}
return filtered
}
func (s *Service) isAutonomousModeEnabled() bool {
s.mu.RLock()
defer s.mu.RUnlock()
if s.autonomousMode {
return true
}
return s.cfg != nil && s.cfg.IsAutonomous()
}
// ExecuteMCPTool executes an MCP tool directly by name with arguments
// This is used for executing investigation fixes that are MCP tool calls
func (s *Service) ExecuteMCPTool(ctx context.Context, toolName string, args map[string]interface{}) (string, error) {
s.mu.RLock()
executor := s.executor
s.mu.RUnlock()
if executor == nil {
return "", fmt.Errorf("executor not available")
}
log.Debug().
Str("tool", toolName).
Interface("args", args).
Msg("Executing MCP tool directly")
// Execute the tool
result, toolErr := executor.ExecuteTool(ctx, toolName, args)
if toolErr != nil {
return "", toolErr
}
// Extract text from result content
var resultText string
for _, content := range result.Content {
if content.Type == "text" {
resultText += content.Text
}
}
// Check for error
if result.IsError {
return resultText, fmt.Errorf("tool execution failed: %s", resultText)
}
// Check if approval was required
if strings.HasPrefix(resultText, "APPROVAL_REQUIRED:") {
return "", fmt.Errorf("tool requires approval (unexpected in fix execution)")
}
if strings.HasPrefix(resultText, "POLICY_BLOCKED:") {
return "", fmt.Errorf("tool blocked by security policy")
}
return resultText, nil
}
// Execute provides non-streaming execution (for compatibility)
func (s *Service) Execute(ctx context.Context, req ExecuteRequest) (map[string]interface{}, error) {
var lastContent string
err := s.ExecuteStream(ctx, req, func(event StreamEvent) {
if event.Type == "content" {
var data ContentData
if err := json.Unmarshal(event.Data, &data); err == nil {
lastContent += data.Text
}
}
})
if err != nil {
return nil, err
}
return map[string]interface{}{
"content": lastContent,
}, nil
}