package api import ( "context" "encoding/json" "net/http" "strconv" "strings" "sync" "sync/atomic" "time" "fmt" "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" airuntime "github.com/rcourtman/pulse-go-rewrite/internal/ai" "github.com/rcourtman/pulse-go-rewrite/internal/ai/approval" "github.com/rcourtman/pulse-go-rewrite/internal/ai/chat" "github.com/rcourtman/pulse-go-rewrite/internal/ai/tools" "github.com/rcourtman/pulse-go-rewrite/internal/ai/unified" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" recoverymanager "github.com/rcourtman/pulse-go-rewrite/internal/recovery/manager" "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" "github.com/rcourtman/pulse-go-rewrite/pkg/aicontracts" "github.com/rs/zerolog/log" ) // AIPersistence interface for loading/saving AI config type AIPersistence interface { LoadAIConfig() (*config.AIConfig, error) DataDir() string } // AIService interface for the AI chat service - enables mocking in tests type AIService interface { Start(ctx context.Context) error Stop(ctx context.Context) error Restart(ctx context.Context, newCfg *config.AIConfig) error IsRunning() bool Execute(ctx context.Context, req chat.ExecuteRequest) (map[string]interface{}, error) ExecuteStream(ctx context.Context, req chat.ExecuteRequest, callback chat.StreamCallback) error ListSessions(ctx context.Context) ([]chat.Session, error) CreateSession(ctx context.Context) (*chat.Session, error) DeleteSession(ctx context.Context, sessionID string) error GetMessages(ctx context.Context, sessionID string) ([]chat.Message, error) GetModelHandoffFindingID(ctx context.Context, sessionID string) (string, error) GetModelHandoffMetadata(ctx context.Context, sessionID string) (chat.HandoffMetadata, error) ClearModelHandoffContext(ctx context.Context, sessionID string) error AbortSession(ctx context.Context, sessionID string) error SummarizeSession(ctx context.Context, sessionID string) (map[string]interface{}, error) GetSessionDiff(ctx context.Context, sessionID string) (map[string]interface{}, error) ForkSession(ctx context.Context, sessionID string) (*chat.Session, error) RevertSession(ctx context.Context, sessionID string) (map[string]interface{}, error) UnrevertSession(ctx context.Context, sessionID string) (map[string]interface{}, error) AnswerQuestion(ctx context.Context, questionID string, answers []chat.QuestionAnswer) error SetAlertProvider(provider chat.MCPAlertProvider) SetFindingsProvider(provider chat.MCPFindingsProvider) SetBaselineProvider(provider chat.MCPBaselineProvider) SetPatternProvider(provider chat.MCPPatternProvider) SetMetricsHistory(provider chat.MCPMetricsHistoryProvider) SetAgentProfileManager(manager chat.AgentProfileManager) SetGuestConfigProvider(provider chat.MCPGuestConfigProvider) SetAppContainerConfigProvider(provider chat.MCPAppContainerConfigProvider) SetBackupProvider(provider chat.MCPBackupProvider) SetDiskHealthProvider(provider chat.MCPDiskHealthProvider) SetUpdatesProvider(provider chat.MCPUpdatesProvider) SetFindingsManager(manager chat.FindingsManager) SetMetadataUpdater(updater chat.MetadataUpdater) SetKnowledgeStoreProvider(provider chat.KnowledgeStoreProvider) SetIncidentRecorderProvider(provider chat.IncidentRecorderProvider) SetEventCorrelatorProvider(provider chat.EventCorrelatorProvider) SetDiscoveryProvider(provider chat.MCPDiscoveryProvider) SetUnifiedResourceProvider(provider chat.MCPUnifiedResourceProvider) SetAppContainerActionProvider(provider chat.MCPAppContainerActionProvider) SetAppContainerReadProvider(provider chat.MCPAppContainerReadProvider) UpdateControlSettings(cfg *config.AIConfig) GetBaseURL() string } type patrolRunHandoffProvider func(context.Context, string) (airuntime.PatrolRunRecord, bool) // AIHandler handles all AI endpoints using direct AI integration type AIHandler struct { stateMu sync.RWMutex approvalStoreMu sync.Mutex mtPersistence *config.MultiTenantPersistence mtMonitor *monitoring.MultiTenantMonitor defaultConfig *config.Config defaultPersistence AIPersistence hostedMode bool defaultService AIService agentServer *agentexec.Server services map[string]AIService servicesMu sync.RWMutex serviceInitMu sync.RWMutex serviceInit func(ctx context.Context, svc AIService) defaultMonitor *monitoring.Monitor unifiedStoreMu sync.RWMutex unifiedStore *unified.UnifiedStore unifiedStores map[string]*unified.UnifiedStore readState unifiedresources.ReadState recoveryManager *recoverymanager.Manager approvalStore *approval.Store approvalStoreDir string approvalStoreStop context.CancelFunc // approvalCreatedCallback is registered via SetApprovalCreatedCallback // before the first approval store is built. ensureApprovalStore // re-installs it on every freshly created store so multi-tenant // re-keying or data-dir changes keep the agent SSE stream wired. approvalCreatedCallback func(*approval.ApprovalRequest) controlLevelResolver func(context.Context, *config.AIConfig) string patrolRunProvider patrolRunHandoffProvider } // newChatService is the factory function for creating the AI service. // Can be swapped in tests for mocking. var newChatService = func(cfg chat.Config) AIService { return chat.NewService(cfg) } // NewAIHandler creates a new AI handler func NewAIHandler(mtp *config.MultiTenantPersistence, mtm *monitoring.MultiTenantMonitor, agentServer *agentexec.Server) *AIHandler { var defaultConfig *config.Config var defaultPersistence AIPersistence if mtm != nil { if m, err := mtm.GetMonitor("default"); err == nil && m != nil { defaultConfig = m.GetConfig() } } if mtp != nil { if p, err := mtp.GetPersistence("default"); err == nil { defaultPersistence = p } } return &AIHandler{ mtPersistence: mtp, mtMonitor: mtm, defaultConfig: defaultConfig, defaultPersistence: defaultPersistence, hostedMode: hostedModeEnabledFromEnv(), agentServer: agentServer, services: make(map[string]AIService), unifiedStores: make(map[string]*unified.UnifiedStore), } } // SetPatrolRunHandoffProvider wires the Patrol history owner into Assistant // chat handoffs without making the browser reconstruct model context. func (h *AIHandler) SetPatrolRunHandoffProvider(provider patrolRunHandoffProvider) { h.stateMu.Lock() defer h.stateMu.Unlock() h.patrolRunProvider = provider } func (h *AIHandler) getPatrolRunForHandoff(ctx context.Context, runID string) (airuntime.PatrolRunRecord, bool) { runID = strings.TrimSpace(runID) if runID == "" { return airuntime.PatrolRunRecord{}, false } h.stateMu.RLock() provider := h.patrolRunProvider h.stateMu.RUnlock() if provider == nil { return airuntime.PatrolRunRecord{}, false } return provider(ctx, runID) } func (h *AIHandler) stateRefs() ( *config.MultiTenantPersistence, *monitoring.MultiTenantMonitor, *config.Config, AIPersistence, unifiedresources.ReadState, *recoverymanager.Manager, ) { h.stateMu.RLock() defer h.stateMu.RUnlock() return h.mtPersistence, h.mtMonitor, h.defaultConfig, h.defaultPersistence, h.readState, h.recoveryManager } func (h *AIHandler) getDefaultService() AIService { if h == nil { return nil } h.servicesMu.RLock() defer h.servicesMu.RUnlock() return h.defaultService } func normalizeAIChatOrgID(orgID string) string { orgID = strings.TrimSpace(orgID) if orgID == "" { return "default" } return orgID } // SetUnifiedStore sets the unified store for finding context lookup in the default org "Discuss" flow. func (h *AIHandler) SetUnifiedStore(store *unified.UnifiedStore) { h.SetUnifiedStoreForOrg("default", store) } // SetUnifiedStoreForOrg sets the unified store for finding context lookup in an org-specific "Discuss" flow. func (h *AIHandler) SetUnifiedStoreForOrg(orgID string, store *unified.UnifiedStore) { orgID = normalizeAIChatOrgID(orgID) h.unifiedStoreMu.Lock() if h.unifiedStores == nil { h.unifiedStores = make(map[string]*unified.UnifiedStore) } if store == nil { delete(h.unifiedStores, orgID) } else { h.unifiedStores[orgID] = store } if orgID == "default" { h.unifiedStore = store } h.unifiedStoreMu.Unlock() } // GetUnifiedStoreForOrg returns the unified store for finding context lookup for a specific org. func (h *AIHandler) GetUnifiedStoreForOrg(orgID string) *unified.UnifiedStore { if h == nil { return nil } orgID = normalizeAIChatOrgID(orgID) h.unifiedStoreMu.RLock() if h.unifiedStores != nil { if store := h.unifiedStores[orgID]; store != nil { h.unifiedStoreMu.RUnlock() return store } } store := h.unifiedStore h.unifiedStoreMu.RUnlock() if orgID == "default" { return store } return nil } // SetReadState stores a unified read-state provider for injection into newly created chat services. func (h *AIHandler) SetReadState(rs unifiedresources.ReadState) { if h == nil { return } h.stateMu.Lock() defer h.stateMu.Unlock() h.readState = rs } // SetRecoveryManager stores a recovery manager for injection into newly created chat services. func (h *AIHandler) SetRecoveryManager(manager *recoverymanager.Manager) { if h == nil { return } h.stateMu.Lock() defer h.stateMu.Unlock() h.recoveryManager = manager } func (h *AIHandler) applyServiceInitializer(ctx context.Context, svc AIService) { if h == nil || svc == nil { return } h.serviceInitMu.RLock() initializer := h.serviceInit h.serviceInitMu.RUnlock() if initializer == nil { return } if ctx == nil { ctx = context.Background() } initializer(ctx, svc) } // SetServiceInitializer configures an initializer that runs whenever a chat // service is returned or created, allowing router-level org-specific wiring. func (h *AIHandler) SetServiceInitializer(initializer func(ctx context.Context, svc AIService)) { if h == nil { return } h.serviceInitMu.Lock() h.serviceInit = initializer h.serviceInitMu.Unlock() if initializer == nil { return } orgServices := make(map[string]AIService) h.servicesMu.RLock() defaultSvc := h.defaultService for orgID, svc := range h.services { if svc != nil { orgServices[orgID] = svc } } h.servicesMu.RUnlock() if defaultSvc != nil { defaultCtx := context.WithValue(context.Background(), OrgIDContextKey, "default") initializer(defaultCtx, defaultSvc) } for orgID, svc := range orgServices { ctx := context.WithValue(context.Background(), OrgIDContextKey, orgID) initializer(ctx, svc) } } // SetControlLevelResolver configures the entitlement-aware control-level // resolver used by chat services when applying Assistant tool permissions. func (h *AIHandler) SetControlLevelResolver( resolver func(context.Context, *config.AIConfig) string, ) { if h == nil { return } h.stateMu.Lock() defer h.stateMu.Unlock() h.controlLevelResolver = resolver } func (h *AIHandler) resolveControlLevel(ctx context.Context, cfg *config.AIConfig) string { if h == nil { if cfg == nil { return config.ControlLevelReadOnly } return cfg.GetControlLevel() } h.stateMu.RLock() resolver := h.controlLevelResolver h.stateMu.RUnlock() if resolver != nil { if level := strings.TrimSpace(resolver(ctx, cfg)); config.IsValidControlLevel(level) { return level } } if cfg == nil { return config.ControlLevelReadOnly } return cfg.GetControlLevel() } // GetService returns the AI service for the current context func (h *AIHandler) GetService(ctx context.Context) AIService { orgID := GetOrgID(ctx) if orgID == "default" || orgID == "" { svc := h.getDefaultService() if svc != nil { defaultCtx := ctx if strings.TrimSpace(GetOrgID(defaultCtx)) == "" { defaultCtx = context.WithValue(context.Background(), OrgIDContextKey, "default") } h.applyServiceInitializer(defaultCtx, svc) } return svc } h.servicesMu.RLock() svc, exists := h.services[orgID] h.servicesMu.RUnlock() if exists { h.applyServiceInitializer(ctx, svc) return svc } h.servicesMu.Lock() defer h.servicesMu.Unlock() // Double check if svc, exists = h.services[orgID]; exists { return svc } // Create and start service for this tenant svc = h.initTenantService(ctx, orgID) if svc != nil { h.applyServiceInitializer(ctx, svc) h.services[orgID] = svc } return svc } // RemoveTenantService stops and removes the AI service for a specific tenant. // This should be called when a tenant is offboarded to free resources. func (h *AIHandler) RemoveTenantService(ctx context.Context, orgID string) error { orgID = normalizeAIChatOrgID(orgID) if orgID == "default" { return nil // Don't remove the default-org service. } // Clear org-scoped finding context store even if the chat service was never created. h.SetUnifiedStoreForOrg(orgID, nil) h.servicesMu.Lock() defer h.servicesMu.Unlock() svc, exists := h.services[orgID] if !exists { return nil // Nothing to remove } if svc != nil { if err := svc.Stop(ctx); err != nil { log.Warn().Str("orgID", orgID).Err(err).Msg("Error stopping AI service for removed tenant") } } delete(h.services, orgID) log.Info().Str("orgID", orgID).Msg("Removed AI service for tenant") return nil } func (h *AIHandler) initTenantService(ctx context.Context, orgID string) AIService { mtPersistence, mtMonitor, _, _, _, recoveryManager := h.stateRefs() if mtPersistence == nil { return nil } tenantCtx := context.WithValue(backgroundContext(ctx), OrgIDContextKey, orgID) persistence, err := mtPersistence.GetPersistence(orgID) if err != nil { log.Warn().Str("orgID", orgID).Err(err).Msg("Failed to get persistence for AI service") return nil } // Tenant chat startup must use the same hosted-aware config path as // /api/settings/ai so hosted orgs do not race into a synthetic disabled/default config. aiCfg := h.loadAIConfig(tenantCtx) if aiCfg == nil { log.Info().Str("orgID", orgID).Msg("AI config is nil for tenant service initialization") return nil } if !aiCfg.Enabled { log.Info().Str("orgID", orgID).Bool("enabled", aiCfg.Enabled).Msg("AI is disabled in tenant config") return nil } dataDir := h.getDataDir(aiCfg, persistence.DataDir()) // Create chat config chatCfg := chat.Config{ AIConfig: aiCfg, DataDir: dataDir, AgentServer: h.agentServer, ReadState: h.readStateForOrg(orgID), OrgID: orgID, ControlLevelResolver: func(next *config.AIConfig) string { return h.resolveControlLevel(tenantCtx, next) }, } if recoveryManager != nil { chatCfg.RecoveryPointsProvider = tools.NewRecoveryPointsMCPAdapter(recoveryManager, orgID) } // Get monitor for state provider if mtMonitor != nil { if m, err := mtMonitor.GetMonitor(orgID); err == nil && m != nil { chatCfg.StateProvider = m } } svc := newChatService(chatCfg) if err := svc.Start(ctx); err != nil { log.Error().Str("orgID", orgID).Err(err).Msg("Failed to start AI service for tenant") return nil } return svc } func (h *AIHandler) getDataDir(aiCfg *config.AIConfig, baseDir string) string { dataDir := baseDir if dataDir == "" { dataDir = "data" } return dataDir } func (h *AIHandler) readStateForOrg(orgID string) unifiedresources.ReadState { if h == nil { return nil } _, mtMonitor, _, _, fallbackReadState, _ := h.stateRefs() orgID = strings.TrimSpace(orgID) if orgID == "" { orgID = "default" } if mtMonitor != nil { if monitor, err := mtMonitor.GetMonitor(orgID); err == nil && monitor != nil { if readState := monitor.GetUnifiedReadState(); readState != nil { return readState } } if orgID != "default" { // Security: never fall back to default-org read state for non-default orgs. return nil } } return fallbackReadState } func (h *AIHandler) getConfig(ctx context.Context) *config.Config { _, mtMonitor, defaultConfig, _, _, _ := h.stateRefs() orgID := strings.TrimSpace(GetOrgID(ctx)) if orgID == "" || orgID == "default" { return defaultConfig } if mtMonitor != nil { if m, err := mtMonitor.GetMonitor(orgID); err == nil && m != nil { return m.GetConfig() } // Security: never fall back to default config for non-default orgs. return nil } return defaultConfig } func (h *AIHandler) getPersistence(ctx context.Context) AIPersistence { mtPersistence, _, _, defaultPersistence, _, _ := h.stateRefs() orgID := strings.TrimSpace(GetOrgID(ctx)) if orgID == "" || orgID == "default" { return defaultPersistence } if mtPersistence != nil { if p, err := mtPersistence.GetPersistence(orgID); err == nil && p != nil { return p } // Security: never fall back to default persistence for non-default orgs. return nil } return defaultPersistence } // loadAIConfig loads AI config for the current context func (h *AIHandler) loadAIConfig(ctx context.Context) *config.AIConfig { p := h.getPersistence(ctx) if p == nil { return nil } if persistence, ok := p.(*config.ConfigPersistence); ok { billingBaseDir := persistence.DataDir() orgID := strings.TrimSpace(GetOrgID(ctx)) if orgID == "" { orgID = "default" } if h.mtPersistence != nil { billingBaseDir = h.mtPersistence.BaseDataDir() } cfg, err := loadHostedAwareAIConfig(h.hostedMode, billingBaseDir, orgID, persistence) if err == nil { return cfg } log.Warn().Err(err).Str("org_id", orgID).Msg("Failed to load hosted-aware Pulse Assistant config") } cfg, err := p.LoadAIConfig() if err != nil { return nil } return cfg } // SetMultiTenantPersistence updates the persistence manager func (h *AIHandler) SetMultiTenantPersistence(mtp *config.MultiTenantPersistence) { h.stateMu.Lock() defer h.stateMu.Unlock() h.mtPersistence = mtp } // SetMultiTenantMonitor updates the monitor manager func (h *AIHandler) SetMultiTenantMonitor(mtm *monitoring.MultiTenantMonitor) { h.stateMu.Lock() defer h.stateMu.Unlock() h.mtMonitor = mtm } // SetApprovalCreatedCallback registers a fire-and-forget callback that // runs after every successful approval creation. The callback is // installed on the active approval store and re-installed on any // future store rebuilt by ensureApprovalStore. Pass nil to clear. // // This is the seam the router uses to bridge approval creation into // the agent SSE stream — see AgentEventBroadcaster.PublishApprovalPending. func (h *AIHandler) SetApprovalCreatedCallback(cb func(*approval.ApprovalRequest)) { h.approvalStoreMu.Lock() defer h.approvalStoreMu.Unlock() h.approvalCreatedCallback = cb if h.approvalStore != nil { h.approvalStore.SetOnApprovalCreated(cb) } } func (h *AIHandler) ensureApprovalStore(dataDir string) { if strings.TrimSpace(dataDir) == "" { return } h.approvalStoreMu.Lock() defer h.approvalStoreMu.Unlock() if h.approvalStore != nil && h.approvalStoreDir == dataDir { approval.SetStore(h.approvalStore) return } if h.approvalStoreStop != nil { h.approvalStoreStop() h.approvalStoreStop = nil } approvalStore, err := approval.NewStore(approval.StoreConfig{ DataDir: dataDir, DefaultTimeout: 5 * time.Minute, MaxApprovals: 100, }) if err != nil { h.approvalStore = nil h.approvalStoreDir = "" approval.SetStore(nil) log.Warn().Err(err).Msg("Failed to create approval store, approvals will not be persisted") return } cleanupCtx, cleanupCancel := context.WithCancel(context.Background()) approvalStore.StartCleanup(cleanupCtx) approval.SetStore(approvalStore) h.approvalStore = approvalStore h.approvalStoreDir = dataDir h.approvalStoreStop = cleanupCancel if h.approvalCreatedCallback != nil { approvalStore.SetOnApprovalCreated(h.approvalCreatedCallback) } log.Info().Str("data_dir", dataDir).Msg("Approval store initialized") } func (h *AIHandler) clearApprovalStore() { h.approvalStoreMu.Lock() defer h.approvalStoreMu.Unlock() if h.approvalStoreStop != nil { h.approvalStoreStop() h.approvalStoreStop = nil } h.approvalStore = nil h.approvalStoreDir = "" approval.SetStore(nil) } // Start initializes and starts the AI chat service. // The monitor parameter provides state snapshots to the chat service (satisfies chat.StateProvider). func (h *AIHandler) Start(ctx context.Context, monitor *monitoring.Monitor) error { log.Info().Msg("AIHandler.Start called") aiCfg := h.loadAIConfig(ctx) if aiCfg == nil { log.Info().Msg("AI config is nil, AI is disabled") return nil } if !aiCfg.Enabled { log.Info().Bool("enabled", aiCfg.Enabled).Msg("AI is disabled in config") return nil } // Determine data directory persistence := h.getPersistence(ctx) dataDir := h.getDataDir(aiCfg, persistence.DataDir()) orgID := GetOrgID(ctx) if orgID == "" { orgID = "default" } serviceCtx := context.WithValue(backgroundContext(ctx), OrgIDContextKey, orgID) // Cache the monitor for use by Restart(). h.stateMu.Lock() h.defaultMonitor = monitor h.stateMu.Unlock() // Create chat config chatCfg := chat.Config{ AIConfig: aiCfg, DataDir: dataDir, StateProvider: monitor, AgentServer: h.agentServer, ReadState: h.readStateForOrg(orgID), OrgID: orgID, ControlLevelResolver: func(next *config.AIConfig) string { return h.resolveControlLevel(serviceCtx, next) }, } _, _, _, _, _, recoveryManager := h.stateRefs() if recoveryManager != nil { chatCfg.RecoveryPointsProvider = tools.NewRecoveryPointsMCPAdapter(recoveryManager, orgID) } svc := newChatService(chatCfg) if err := svc.Start(ctx); err != nil { return fmt.Errorf("start AI chat service: %w", err) } h.servicesMu.Lock() h.defaultService = svc h.servicesMu.Unlock() h.applyServiceInitializer(serviceCtx, svc) // Initialize approval store for command approval workflow. h.ensureApprovalStore(dataDir) log.Info().Msg("Pulse AI started (direct integration)") return nil } // Stop stops the AI chat service func (h *AIHandler) Stop(ctx context.Context) error { defer h.clearApprovalStore() if svc := h.getDefaultService(); svc != nil { return svc.Stop(ctx) } return nil } // Restart restarts the AI chat service with updated configuration // Call this when model or other settings change func (h *AIHandler) Restart(ctx context.Context) error { // Load fresh config from persistence to get latest settings newCfg := h.loadAIConfig(ctx) svc := h.getDefaultService() if newCfg == nil || !newCfg.Enabled { h.clearApprovalStore() if svc == nil { return nil } return svc.Restart(ctx, newCfg) } // If enabled but not started yet, recover the monitor and bootstrap now. if svc == nil { log.Info().Msg("Starting AI service via restart trigger") h.stateMu.RLock() m := h.defaultMonitor mtm := h.mtMonitor h.stateMu.RUnlock() if m == nil && mtm != nil { m, _ = mtm.GetMonitor("default") } return h.Start(ctx, m) } if !svc.IsRunning() { log.Info().Msg("Starting AI service via restart trigger") // Recover the monitor: prefer cached default-org monitor, fall back to mtMonitor. h.stateMu.RLock() m := h.defaultMonitor mtm := h.mtMonitor h.stateMu.RUnlock() if m == nil && mtm != nil { m, _ = mtm.GetMonitor("default") } // Reuse start logic return h.Start(ctx, m) } if err := svc.Restart(ctx, newCfg); err != nil { return err } persistence := h.getPersistence(ctx) if persistence == nil { return nil } dataDir := h.getDataDir(newCfg, persistence.DataDir()) h.ensureApprovalStore(dataDir) return nil } // IsRunning returns whether AI is running // GetAIConfig returns the current AI configuration func (h *AIHandler) GetAIConfig(ctx context.Context) *config.AIConfig { return h.loadAIConfig(ctx) } // IsRunning returns true if the AI chat service is running func (h *AIHandler) IsRunning(ctx context.Context) bool { svc := h.GetService(ctx) return svc != nil && svc.IsRunning() } // ChatMention represents a resource tagged via @ mention in the chat UI type ChatMention struct { ID string `json:"id"` Name string `json:"name"` Type string `json:"type"` Node string `json:"node,omitempty"` } func canonicalizeChatMentionType(raw string) string { normalized := normalizeAITransportResourceType(raw) switch normalized { case "vm", "node", "agent", "system-container", "app-container", "docker-host", "k8s-cluster", "k8s-node", "k8s-pod", "k8s-deployment", "storage", "disk", "pbs", "pmg", "proxmox", "ceph", "oci-container": return normalized default: return "" } } // ChatRequest represents a chat request type ChatRequest struct { Prompt string `json:"prompt"` SessionID string `json:"session_id,omitempty"` Model string `json:"model,omitempty"` Mentions []ChatMention `json:"mentions,omitempty"` FindingID string `json:"finding_id,omitempty"` HandoffContext string `json:"handoff_context,omitempty"` HandoffResources []chat.HandoffResource `json:"handoff_resources,omitempty"` HandoffActions []chat.HandoffAction `json:"handoff_actions,omitempty"` HandoffMetadata chat.HandoffMetadata `json:"handoff_metadata,omitempty"` AutonomousMode *bool `json:"autonomous_mode,omitempty"` } const ( chatRequestHandoffContextMaxBytes = 16 * 1024 chatRequestHandoffResourceLimit = 8 chatRequestHandoffActionLimit = 4 ) func chatAutonomousModeForScopedHandoff(requested *bool, handoffContext string, handoffResources []chat.HandoffResource, handoffActions []chat.HandoffAction, handoffMetadata chat.HandoffMetadata) *bool { if strings.TrimSpace(handoffContext) == "" && len(handoffResources) == 0 && len(handoffActions) == 0 && chat.NormalizeHandoffMetadata(handoffMetadata) == (chat.HandoffMetadata{}) { return requested } return chatApprovalRequiredAutonomousMode() } func chatApprovalRequiredAutonomousMode() *bool { approvalRequired := false return &approvalRequired } func chatAutonomousModeForFindingHandoff(requested *bool, findingID, handoffContext string, handoffResources []chat.HandoffResource, handoffActions []chat.HandoffAction, handoffMetadata chat.HandoffMetadata) *bool { if strings.TrimSpace(findingID) != "" { return chatApprovalRequiredAutonomousMode() } return chatAutonomousModeForScopedHandoff(requested, handoffContext, handoffResources, handoffActions, handoffMetadata) } func normalizeChatRequestHandoffContext(raw string) string { context := strings.TrimSpace(raw) if len(context) <= chatRequestHandoffContextMaxBytes { return context } return strings.TrimSpace(context[:chatRequestHandoffContextMaxBytes]) + "\n[Handoff Context Truncated]" } func normalizeChatRequestHandoffResources(raw []chat.HandoffResource) []chat.HandoffResource { resources := make([]chat.HandoffResource, 0, min(len(raw), chatRequestHandoffResourceLimit)) for _, resource := range raw { if len(resources) >= chatRequestHandoffResourceLimit { break } id := trimChatHandoffField(resource.ID, 256) name := trimChatHandoffField(resource.Name, 256) resourceType := canonicalizeChatMentionType(resource.Type) node := trimChatHandoffField(resource.Node, 256) if id == "" && name == "" { continue } resources = append(resources, chat.HandoffResource{ ID: id, Name: name, Type: resourceType, Node: node, }) } return resources } func normalizeChatRequestHandoffActions(raw []chat.HandoffAction) []chat.HandoffAction { actions := make([]chat.HandoffAction, 0, min(len(raw), chatRequestHandoffActionLimit)) for _, action := range raw { if len(actions) >= chatRequestHandoffActionLimit { break } normalized := chat.HandoffAction{ FindingID: trimChatHandoffField(action.FindingID, 256), RecordID: trimChatHandoffField(action.RecordID, 256), ApprovalID: trimChatHandoffField(action.ApprovalID, 256), ApprovalStatus: trimChatHandoffField(action.ApprovalStatus, 64), ApprovalRequestedAt: trimChatHandoffField(action.ApprovalRequestedAt, 64), ApprovalExpiresAt: trimChatHandoffField(action.ApprovalExpiresAt, 64), ApprovalDecidedAt: trimChatHandoffField(action.ApprovalDecidedAt, 64), ApprovalConsumed: action.ApprovalConsumed, ActionID: trimChatHandoffField(action.ActionID, 256), ActionState: trimChatHandoffField(action.ActionState, 64), ActionUpdatedAt: trimChatHandoffField(action.ActionUpdatedAt, 64), ActionRequestedBy: trimChatHandoffField(action.ActionRequestedBy, 128), ActionCapability: trimChatHandoffField(action.ActionCapability, 128), ActionApprovalPolicy: trimChatHandoffField(action.ActionApprovalPolicy, 64), ActionRequiresApproval: action.ActionRequiresApproval, ActionPlanExpiresAt: trimChatHandoffField(action.ActionPlanExpiresAt, 64), ActionPlanMessage: trimChatHandoffField(action.ActionPlanMessage, 512), ActionPreflight: trimChatHandoffField(action.ActionPreflight, 512), ActionDryRunSummary: trimChatHandoffField(action.ActionDryRunSummary, 512), ActionResult: trimChatHandoffField(action.ActionResult, 64), FixID: trimChatHandoffField(action.FixID, 256), Description: trimChatHandoffField(action.Description, 512), RiskLevel: trimChatHandoffField(action.RiskLevel, 64), Destructive: action.Destructive, TargetHost: trimChatHandoffField(action.TargetHost, 256), TargetResourceID: trimChatHandoffField(action.TargetResourceID, 256), TargetResourceName: trimChatHandoffField(action.TargetResourceName, 256), TargetResourceType: canonicalizeChatMentionType(action.TargetResourceType), TargetNode: trimChatHandoffField(action.TargetNode, 256), } if handoffActionHasBriefingValue(normalized) || strings.TrimSpace(normalized.FindingID) != "" || strings.TrimSpace(normalized.TargetResourceID) != "" { actions = append(actions, normalized) } } return actions } func trimChatHandoffField(raw string, limit int) string { value := strings.TrimSpace(raw) if len(value) <= limit { return value } return strings.TrimSpace(value[:limit]) } const findingChatContextListLimit = 5 type unifiedFindingLookup interface { Get(findingID string) *unified.UnifiedFinding } type unifiedRelatedFindingContext struct { Label string Finding *unified.UnifiedFinding } func buildUnifiedFindingChatContext(f *unified.UnifiedFinding, lookup unifiedFindingLookup, handoffActions []chat.HandoffAction) string { if f == nil { return "" } var b strings.Builder appendUnifiedFindingOperatorBriefingContext(&b, f, handoffActions) appendChatContextLine(&b, "[Finding Context]", "") appendChatContextLine(&b, "ID", f.ID) appendChatContextLine(&b, "Title", f.Title) appendChatContextLine(&b, "Finding Status", unifiedFindingChatStatus(f, time.Now())) appendChatContextLine(&b, "Source", string(f.Source)) appendChatContextLine(&b, "Severity", string(f.Severity)) appendChatContextLine(&b, "Category", string(f.Category)) appendChatContextLine(&b, "Resource", formatChatResource(f.ResourceName, f.ResourceType)) appendChatContextLine(&b, "Resource ID", f.ResourceID) if !f.DetectedAt.IsZero() { appendChatContextLine(&b, "Finding Detected At", f.DetectedAt.Format(time.RFC3339)) } if !f.LastSeenAt.IsZero() { appendChatContextLine(&b, "Finding Last Seen At", f.LastSeenAt.Format(time.RFC3339)) } if f.ResolvedAt != nil { appendChatContextLine(&b, "Finding Resolved At", f.ResolvedAt.Format(time.RFC3339)) } if f.SnoozedUntil != nil { appendChatContextLine(&b, "Finding Snoozed Until", f.SnoozedUntil.Format(time.RFC3339)) } if strings.TrimSpace(f.DismissedReason) != "" { appendChatContextLine(&b, "Finding Dismissed Reason", f.DismissedReason) } if f.Suppressed { appendChatContextLine(&b, "Finding Suppressed", "true") } if f.TimesRaised > 0 { appendChatContextLine(&b, "Finding Times Raised", strconv.Itoa(f.TimesRaised)) } appendChatContextLine(&b, "Description", f.Description) appendChatContextLine(&b, "Recommendation", f.Recommendation) appendChatContextLine(&b, "Evidence", f.Evidence) appendChatContextLine(&b, "AI Context", f.AIContext) if f.AIConfidence > 0 { appendChatContextLine(&b, "AI Confidence", fmt.Sprintf("%.2f", f.AIConfidence)) } if f.AIEnhancedAt != nil { appendChatContextLine(&b, "AI Enhanced At", f.AIEnhancedAt.Format(time.RFC3339)) } appendChatContextLine(&b, "Root Cause ID", f.RootCauseID) appendStringListChatContext(&b, "Correlated Finding", f.CorrelatedIDs) appendUnifiedFindingRelatedContext(&b, f, lookup) appendChatContextLine(&b, "Remediation ID", f.RemediationID) appendChatContextLine(&b, "Investigation Status", f.InvestigationStatus) appendChatContextLine(&b, "Investigation Outcome", f.InvestigationOutcome) if f.LastInvestigatedAt != nil { appendChatContextLine(&b, "Last Investigated At", f.LastInvestigatedAt.Format(time.RFC3339)) } if f.InvestigationAttempts > 0 { appendChatContextLine(&b, "Investigation Attempts", strconv.Itoa(f.InvestigationAttempts)) } appendChatContextLine(&b, "Loop State", f.LoopState) if f.RegressionCount > 0 { appendChatContextLine(&b, "Regression Count", strconv.Itoa(f.RegressionCount)) } if f.LastRegressionAt != nil { appendChatContextLine(&b, "Last Regression At", f.LastRegressionAt.Format(time.RFC3339)) } // Surface the prior successful fix as operational memory so Assistant // and any AI investigation can reason about what worked previously // instead of treating each regression as a blank-slate diagnosis. if strings.TrimSpace(f.PreviousResolvedFixSummary) != "" { appendChatContextLine(&b, "Previous Resolved Fix", f.PreviousResolvedFixSummary) } appendChatContextLine(&b, "User Note", f.UserNote) if f.AcknowledgedAt != nil { appendChatContextLine(&b, "Finding Acknowledged At", f.AcknowledgedAt.Format(time.RFC3339)) } appendChatContextLine(&b, "Node", f.Node) appendUnifiedFindingLifecycleEventContext(&b, f.Lifecycle) appendInvestigationRecordChatContext(&b, f.InvestigationRecord) return b.String() } func mergeUnifiedFindingRequestHandoffContext(canonicalContext, requestContext, findingID string) string { canonicalContext = strings.TrimSpace(canonicalContext) requestContext = safePatrolFindingRequestHandoffContext(requestContext, findingID) switch { case canonicalContext == "": return requestContext case requestContext == "": return canonicalContext default: var b strings.Builder b.WriteString(canonicalContext) appendChatContextLine(&b, "", "") appendChatContextLine(&b, "[Product Handoff Context]", "") b.WriteString(requestContext) if !strings.HasSuffix(requestContext, "\n") { b.WriteByte('\n') } appendChatContextLine(&b, "Product Handoff Boundary", "This product-originated Patrol handoff is secondary to backend-refreshed canonical finding context; use it for explanation and operator review only, not approval or execution authority.") return strings.TrimSpace(b.String()) } } func safePatrolFindingRequestHandoffContext(raw, findingID string) string { raw = strings.TrimSpace(raw) findingID = strings.TrimSpace(findingID) if raw == "" || findingID == "" { return "" } lines := strings.Split(raw, "\n") safeLines := make([]string, 0, len(lines)) hasPatrolFindingHeader := false hasPatrolFindingSource := false matchesFinding := false for _, line := range lines { line = strings.TrimSpace(line) if line == "" { continue } if patrolFindingRequestHandoffLineHasRawCommandPayload(line) { continue } switch { case line == "[Patrol Finding Context]": hasPatrolFindingHeader = true safeLines = append(safeLines, line) case line == "Source: Pulse Patrol finding handoff": hasPatrolFindingSource = true safeLines = append(safeLines, line) case isSafePatrolFindingRequestHandoffLine(line): if strings.HasPrefix(line, "Finding ID:") && strings.EqualFold(strings.TrimSpace(strings.TrimPrefix(line, "Finding ID:")), findingID) { matchesFinding = true } safeLines = append(safeLines, line) } } if !hasPatrolFindingHeader || !hasPatrolFindingSource || !matchesFinding || len(safeLines) == 0 { return "" } return strings.Join(safeLines, "\n") } func patrolFindingRequestHandoffLineHasRawCommandPayload(line string) bool { normalized := strings.ToLower(strings.TrimSpace(line)) if normalized == "" { return false } for _, marker := range []string{ "systemctl restart ", "systemctl stop ", "systemctl start ", "systemctl reload ", "sudo systemctl ", "kubectl delete ", "kubectl apply ", "rm -rf ", } { if strings.Contains(normalized, marker) { return true } } return false } func isSafePatrolFindingRequestHandoffLine(line string) bool { label, _, ok := strings.Cut(line, ":") if !ok { return false } label = strings.TrimSpace(label) if label == "" { return false } if strings.HasPrefix(label, "Evidence ") || strings.HasPrefix(label, "Verification ") { return true } switch label { case "Finding", "Finding ID", "Subject", "Resource", "Status", "Detected At", "Last Seen At", "Recurrence", "Description", "Attention", "Investigation Record", "Investigation Status", "Investigation Outcome", "Investigation Confidence", "Conclusion", "Recommended Action", "Tools Used", "Approval", "Approval Status", "Approval Risk", "Approval Target", "Approval Requested At", "Approval Expires At", "Approval Policy", "Approval Plan Expires At", "Action Plan Summary", "Action Preflight", "Dry-Run Posture", "Proposed Fix", "Operator Decision", "Command Boundary", "Operator Boundary": return true default: return false } } func mergeUnifiedFindingRequestHandoffResources(f *unified.UnifiedFinding, canonicalResources, requestResources []chat.HandoffResource) []chat.HandoffResource { merged := append([]chat.HandoffResource{}, canonicalResources...) for _, resource := range requestResources { if !requestHandoffResourceMatchesUnifiedFinding(f, resource) { continue } if handoffResourceExists(merged, resource) { continue } merged = append(merged, resource) } return merged } func requestHandoffResourceMatchesUnifiedFinding(f *unified.UnifiedFinding, resource chat.HandoffResource) bool { if f == nil { return false } resourceID := strings.TrimSpace(resource.ID) if resourceID == "" { return false } return strings.EqualFold(resourceID, strings.TrimSpace(f.ResourceID)) } func handoffResourceExists(resources []chat.HandoffResource, candidate chat.HandoffResource) bool { candidateID := strings.TrimSpace(candidate.ID) for _, resource := range resources { if candidateID != "" && strings.EqualFold(strings.TrimSpace(resource.ID), candidateID) { return true } } return false } func mergeUnifiedFindingRequestHandoffActions(f *unified.UnifiedFinding, canonicalActions, requestActions []chat.HandoffAction) []chat.HandoffAction { merged := append([]chat.HandoffAction{}, canonicalActions...) for _, action := range requestActions { if !requestHandoffActionMatchesUnifiedFinding(f, action) || !handoffActionHasBriefingValue(action) { continue } if idx := matchingHandoffActionIndex(merged, action); idx >= 0 { merged[idx] = mergeHandoffActionSafeFields(merged[idx], action) continue } merged = append(merged, action) } return merged } func requestHandoffActionMatchesUnifiedFinding(f *unified.UnifiedFinding, action chat.HandoffAction) bool { if f == nil { return false } actionFindingID := strings.TrimSpace(action.FindingID) if actionFindingID == "" { return false } return strings.EqualFold(actionFindingID, strings.TrimSpace(f.ID)) } func matchingHandoffActionIndex(actions []chat.HandoffAction, candidate chat.HandoffAction) int { for idx, action := range actions { if sameNonEmptyHandoffActionID(action.ApprovalID, candidate.ApprovalID) || sameNonEmptyHandoffActionID(action.ActionID, candidate.ActionID) || sameNonEmptyHandoffActionID(action.FixID, candidate.FixID) { return idx } } return -1 } func sameNonEmptyHandoffActionID(left, right string) bool { left = strings.TrimSpace(left) right = strings.TrimSpace(right) return left != "" && right != "" && strings.EqualFold(left, right) } func mergeHandoffActionSafeFields(canonical, requested chat.HandoffAction) chat.HandoffAction { fillString := func(target *string, value string) { if strings.TrimSpace(*target) == "" { *target = value } } fillBool := func(target *bool, value bool) { if !*target { *target = value } } fillString(&canonical.FindingID, requested.FindingID) fillString(&canonical.RecordID, requested.RecordID) fillString(&canonical.ApprovalID, requested.ApprovalID) fillString(&canonical.ApprovalStatus, requested.ApprovalStatus) fillString(&canonical.ApprovalRequestedAt, requested.ApprovalRequestedAt) fillString(&canonical.ApprovalExpiresAt, requested.ApprovalExpiresAt) fillString(&canonical.ApprovalDecidedAt, requested.ApprovalDecidedAt) fillString(&canonical.ActionID, requested.ActionID) fillString(&canonical.ActionState, requested.ActionState) fillString(&canonical.ActionUpdatedAt, requested.ActionUpdatedAt) fillString(&canonical.ActionRequestedBy, requested.ActionRequestedBy) fillString(&canonical.ActionCapability, requested.ActionCapability) fillString(&canonical.ActionApprovalPolicy, requested.ActionApprovalPolicy) fillString(&canonical.ActionPlanExpiresAt, requested.ActionPlanExpiresAt) fillString(&canonical.ActionPlanMessage, requested.ActionPlanMessage) fillString(&canonical.ActionPreflight, requested.ActionPreflight) fillString(&canonical.ActionDryRunSummary, requested.ActionDryRunSummary) fillString(&canonical.ActionResult, requested.ActionResult) fillString(&canonical.FixID, requested.FixID) fillString(&canonical.Description, requested.Description) fillString(&canonical.RiskLevel, requested.RiskLevel) fillString(&canonical.TargetHost, requested.TargetHost) fillString(&canonical.TargetResourceID, requested.TargetResourceID) fillString(&canonical.TargetResourceName, requested.TargetResourceName) fillString(&canonical.TargetResourceType, requested.TargetResourceType) fillString(&canonical.TargetNode, requested.TargetNode) fillBool(&canonical.ApprovalConsumed, requested.ApprovalConsumed) fillBool(&canonical.ActionRequiresApproval, requested.ActionRequiresApproval) fillBool(&canonical.Destructive, requested.Destructive) return canonical } func appendUnifiedFindingRelatedContext(b *strings.Builder, f *unified.UnifiedFinding, lookup unifiedFindingLookup) { if b == nil || f == nil || lookup == nil { return } related := resolveUnifiedFindingRelatedContext(f, lookup) if len(related) == 0 { return } appendChatContextLine(b, "", "") appendChatContextLine(b, "[Related Finding Context]", "") for _, item := range related { if item.Finding == nil { continue } appendChatContextLine(b, item.Label, formatUnifiedRelatedFindingSummary(item.Finding)) } appendChatContextLine(b, "Related Finding Boundary", "Related findings are current unified finding context for explanation only; they do not grant approval or execution authority.") } func resolveUnifiedFindingRelatedContext(f *unified.UnifiedFinding, lookup unifiedFindingLookup) []unifiedRelatedFindingContext { if f == nil || lookup == nil { return nil } related := make([]unifiedRelatedFindingContext, 0, findingChatContextListLimit) seen := map[string]struct{}{ strings.ToLower(strings.TrimSpace(f.ID)): {}, } add := func(label, findingID string) { if len(related) >= findingChatContextListLimit { return } findingID = strings.TrimSpace(findingID) if findingID == "" { return } key := strings.ToLower(findingID) if _, ok := seen[key]; ok { return } seen[key] = struct{}{} relatedFinding := lookup.Get(findingID) if relatedFinding == nil { return } related = append(related, unifiedRelatedFindingContext{ Label: label, Finding: relatedFinding, }) } add("Root Cause Finding", f.RootCauseID) correlatedCount := 0 for _, findingID := range f.CorrelatedIDs { correlatedCount++ add(fmt.Sprintf("Correlated Finding %d", correlatedCount), findingID) if len(related) >= findingChatContextListLimit { break } } return related } func formatUnifiedRelatedFindingSummary(f *unified.UnifiedFinding) string { if f == nil { return "" } parts := make([]string, 0, 5) if id := strings.TrimSpace(f.ID); id != "" { parts = append(parts, id) } if finding := formatUnifiedFindingBriefingFinding(f); finding != "" { parts = append(parts, finding) } if resource := formatUnifiedFindingBriefingResource(f); resource != "" { parts = append(parts, "resource "+resource) } if recency := formatUnifiedFindingBriefingRecency(f); recency != "" { parts = append(parts, "recency "+recency) } if investigation := formatUnifiedFindingBriefingInvestigation(f); investigation != "" { parts = append(parts, "investigation "+investigation) } if conclusion := unifiedFindingBriefingConclusion(f); conclusion != "" { parts = append(parts, "conclusion "+conclusion) } return strings.Join(parts, " | ") } func appendUnifiedFindingOperatorBriefingContext(b *strings.Builder, f *unified.UnifiedFinding, handoffActions []chat.HandoffAction) { if b == nil || f == nil { return } briefingSource := "Product structured finding" if f.Source == unified.SourceAIPatrol { briefingSource = "Pulse Patrol structured finding" } appendChatContextLine(b, "[Operator Briefing]", "") appendChatContextLine(b, "Briefing Source", briefingSource) appendChatContextLine(b, "Operator Role", "Explain the investigation, highlight current risk, and guide the operator through evidence, approval, or continued monitoring.") appendChatContextLine(b, "Finding", formatUnifiedFindingBriefingFinding(f)) appendChatContextLine(b, "Resource", formatUnifiedFindingBriefingResource(f)) appendChatContextLine(b, "Priority", formatUnifiedFindingBriefingPriority(f)) if attention := unifiedFindingBriefingAttention(f); attention != "" { appendChatContextLine(b, "Attention Reason", attention) } appendChatContextLine(b, "Recency", formatUnifiedFindingBriefingRecencyFacts(f)) appendChatContextLine(b, "Investigation", formatUnifiedFindingBriefingInvestigation(f)) if evidence := unifiedFindingBriefingEvidence(f); evidence != "" { appendChatContextLine(b, "Evidence Snapshot", evidence) } if verification := unifiedFindingBriefingVerification(f); verification != "" { appendChatContextLine(b, "Verification", verification) } if latestLifecycle := formatUnifiedFindingLatestLifecycleEvent(f.Lifecycle); latestLifecycle != "" { appendChatContextLine(b, "Latest Lifecycle Event", latestLifecycle) } appendChatContextLine(b, "Current Conclusion", unifiedFindingBriefingConclusion(f)) appendChatContextLine(b, "Recommended Next Step", unifiedFindingBriefingNextStep(f)) if decision := unifiedFindingBriefingOperatorDecision(f, handoffActions); decision != "" { appendChatContextLine(b, "Operator Decision", decision) } appendChatContextLine(b, "Action Posture", unifiedFindingBriefingActionPosture(f, handoffActions)) appendChatContextLine(b, "Operator Boundary", "Treat Patrol data as product context for explanation and review; do not invent commands, expose raw command text, or treat chat as approval or execution authority.") appendChatContextLine(b, "", "") } func formatUnifiedFindingBriefingFinding(f *unified.UnifiedFinding) string { if f == nil { return "" } title := strings.TrimSpace(f.Title) if title == "" { title = strings.TrimSpace(f.ID) } qualifiers := nonEmptyStrings( string(f.Severity), string(f.Category), unifiedFindingChatStatus(f, time.Now()), ) if len(qualifiers) == 0 { return title } if title == "" { return strings.Join(qualifiers, ", ") } return title + " (" + strings.Join(qualifiers, ", ") + ")" } func formatUnifiedFindingBriefingResource(f *unified.UnifiedFinding) string { if f == nil { return "" } resource := formatChatResource(f.ResourceName, f.ResourceType) resourceID := strings.TrimSpace(f.ResourceID) if resourceID != "" { if resource == "" { resource = resourceID } else { resource += " [" + resourceID + "]" } } if node := strings.TrimSpace(f.Node); node != "" { if resource == "" { resource = "node " + node } else { resource += " on " + node } } return resource } func formatUnifiedFindingBriefingPriority(f *unified.UnifiedFinding) string { if f == nil { return "" } parts := nonEmptyStrings( strings.TrimSpace(string(f.Severity)+" "+string(f.Category)), "status "+unifiedFindingChatStatus(f, time.Now()), ) if loopState := strings.TrimSpace(f.LoopState); loopState != "" { parts = append(parts, "loop "+loopState) } if f.TimesRaised > 0 { parts = append(parts, fmt.Sprintf("raised %d times", f.TimesRaised)) } if f.RegressionCount > 0 { parts = append(parts, fmt.Sprintf("regressed %d times", f.RegressionCount)) } return strings.Join(parts, "; ") } func unifiedFindingBriefingAttention(f *unified.UnifiedFinding) string { if f == nil { return "" } now := time.Now() parts := make([]string, 0, 8) status := unifiedFindingChatStatus(f, now) switch status { case "active": if severity := strings.TrimSpace(string(f.Severity)); severity != "" { parts = append(parts, "active "+severity+" finding") } else { parts = append(parts, "active finding") } case "resolved": if f.RegressionCount > 0 { parts = append(parts, "resolved after prior regression") } else { parts = append(parts, "resolved finding") } case "snoozed": parts = append(parts, "snoozed finding") case "dismissed": parts = append(parts, "dismissed finding") case "suppressed": parts = append(parts, "suppressed finding") } if f.RegressionCount > 0 { parts = append(parts, fmt.Sprintf("regressed %d times", f.RegressionCount)) } else if f.TimesRaised > 1 { parts = append(parts, fmt.Sprintf("raised %d times", f.TimesRaised)) } if f.LastRegressionAt != nil { parts = append(parts, "last regression "+f.LastRegressionAt.Format(time.RFC3339)) } if loopState := strings.TrimSpace(f.LoopState); loopState != "" { parts = append(parts, "loop "+loopState) } rec := f.InvestigationRecord outcome := strings.TrimSpace(f.InvestigationOutcome) if rec != nil { outcome = strings.TrimSpace(string(rec.Outcome)) if approvalID := strings.TrimSpace(rec.ApprovalID); approvalID != "" { parts = append(parts, "approval "+approvalID) } if rec.ProposedFix != nil && rec.ProposedFix.Destructive { parts = append(parts, "destructive proposed fix") } } switch aicontracts.InvestigationOutcome(outcome) { case aicontracts.OutcomeFixQueued: parts = append(parts, "fix queued for governed review") case aicontracts.OutcomeFixExecuted: parts = append(parts, "fix executed awaiting verification") case aicontracts.OutcomeFixFailed: parts = append(parts, "fix failed") case aicontracts.OutcomeFixVerificationFailed: parts = append(parts, "verification failed") case aicontracts.OutcomeFixVerificationUnknown: parts = append(parts, "verification inconclusive") case aicontracts.OutcomeNeedsAttention: parts = append(parts, "needs operator attention") case aicontracts.OutcomeCannotFix: parts = append(parts, "Patrol cannot safely fix") case aicontracts.OutcomeTimedOut: parts = append(parts, "Patrol timed out") } return formatBriefingStringList(parts, 8, "attention facts") } func formatUnifiedFindingBriefingRecency(f *unified.UnifiedFinding) string { if f == nil { return "" } parts := nonEmptyStrings(formatUnifiedFindingBriefingRecencyFacts(f)) if latestLifecycle := formatUnifiedFindingLatestLifecycleEvent(f.Lifecycle); latestLifecycle != "" { parts = append(parts, "latest lifecycle "+latestLifecycle) } return strings.Join(parts, "; ") } func formatUnifiedFindingBriefingRecencyFacts(f *unified.UnifiedFinding) string { if f == nil { return "" } parts := make([]string, 0, 8) if !f.DetectedAt.IsZero() { parts = append(parts, "detected "+f.DetectedAt.Format(time.RFC3339)) } if !f.LastSeenAt.IsZero() { parts = append(parts, "last seen "+f.LastSeenAt.Format(time.RFC3339)) } if f.ResolvedAt != nil { parts = append(parts, "resolved "+f.ResolvedAt.Format(time.RFC3339)) } if f.SnoozedUntil != nil { parts = append(parts, "snoozed until "+f.SnoozedUntil.Format(time.RFC3339)) } if strings.TrimSpace(f.DismissedReason) != "" { parts = append(parts, "dismissed "+strings.TrimSpace(f.DismissedReason)) } if f.Suppressed { parts = append(parts, "suppressed") } if f.TimesRaised > 1 { parts = append(parts, fmt.Sprintf("raised %d times", f.TimesRaised)) } if f.RegressionCount > 0 { parts = append(parts, fmt.Sprintf("regressed %d times", f.RegressionCount)) } if f.LastRegressionAt != nil { parts = append(parts, "last regression "+f.LastRegressionAt.Format(time.RFC3339)) } if f.AcknowledgedAt != nil { parts = append(parts, "acknowledged "+f.AcknowledgedAt.Format(time.RFC3339)) } return strings.Join(parts, "; ") } func formatUnifiedFindingBriefingInvestigation(f *unified.UnifiedFinding) string { if f == nil { return "" } rec := f.InvestigationRecord parts := make([]string, 0, 5) if rec != nil { parts = append(parts, nonEmptyStrings( string(rec.Status), briefingLabelValue("outcome", string(rec.Outcome)), briefingLabelValue("confidence", string(rec.Confidence)), )...) } else { parts = append(parts, nonEmptyStrings( f.InvestigationStatus, briefingLabelValue("outcome", f.InvestigationOutcome), )...) } if f.InvestigationAttempts > 0 { parts = append(parts, fmt.Sprintf("attempts %d", f.InvestigationAttempts)) } if f.AIConfidence > 0 && (rec == nil || strings.TrimSpace(string(rec.Confidence)) == "") { parts = append(parts, fmt.Sprintf("ai confidence %.2f", f.AIConfidence)) } return strings.Join(parts, "; ") } func unifiedFindingBriefingConclusion(f *unified.UnifiedFinding) string { if f == nil { return "" } if f.InvestigationRecord != nil { if conclusion := strings.TrimSpace(f.InvestigationRecord.Conclusion); conclusion != "" { return conclusion } } for _, value := range []string{f.AIContext, f.Description, f.Evidence} { if normalized := strings.TrimSpace(value); normalized != "" { return normalized } } return "" } func unifiedFindingBriefingEvidence(f *unified.UnifiedFinding) string { if f == nil { return "" } if f.InvestigationRecord != nil { if evidence := formatInvestigationRecordEvidenceBriefing(f.InvestigationRecord.Evidence, 2); evidence != "" { return evidence } } return strings.TrimSpace(f.Evidence) } func unifiedFindingBriefingVerification(f *unified.UnifiedFinding) string { if f == nil || f.InvestigationRecord == nil { return "" } return formatBriefingStringList(f.InvestigationRecord.Verification, 2, "verification") } func unifiedFindingBriefingNextStep(f *unified.UnifiedFinding) string { if f == nil { return "" } if unifiedFindingChatStatus(f, time.Now()) == "resolved" { return "Explain the resolution and any monitoring follow-up; do not propose execution unless a new active finding is raised." } if f.InvestigationRecord != nil { if next := strings.TrimSpace(f.InvestigationRecord.RecommendedAction); next != "" { return next } if f.InvestigationRecord.ProposedFix != nil { if description := strings.TrimSpace(f.InvestigationRecord.ProposedFix.Description); description != "" { return "Review proposed fix: " + description } } } if recommendation := strings.TrimSpace(f.Recommendation); recommendation != "" { return recommendation } return "Explain what Patrol knows and identify the next evidence to verify before remediation." } func unifiedFindingBriefingOperatorDecision(f *unified.UnifiedFinding, handoffActions []chat.HandoffAction) string { if f == nil { return "" } if unifiedFindingChatStatus(f, time.Now()) == "resolved" { return "Finding is resolved; explain the resolution and monitoring follow-up without proposing execution." } if action, ok := unifiedFindingPrimaryHandoffAction(f, handoffActions); ok { if decision := unifiedFindingHandoffActionOperatorDecision(action); decision != "" { return decision } } rec := f.InvestigationRecord if rec != nil { if approvalID := strings.TrimSpace(rec.ApprovalID); approvalID != "" { parts := []string{"review governed approval " + approvalID + " before execution"} if rec.ProposedFix != nil { if fixID := strings.TrimSpace(rec.ProposedFix.ID); fixID != "" { parts = append(parts, "proposed fix "+fixID) } else if description := strings.TrimSpace(rec.ProposedFix.Description); description != "" { parts = append(parts, "proposed fix recorded") } if risk := strings.TrimSpace(rec.ProposedFix.RiskLevel); risk != "" { parts = append(parts, "risk "+risk) } if rec.ProposedFix.Destructive { parts = append(parts, "destructive true") } } return strings.Join(parts, "; ") } switch rec.Outcome { case aicontracts.OutcomeFixQueued: return "Review the proposed fix in the governed approval or remediation flow before execution." case aicontracts.OutcomeFixExecuted: return "Verify the execution result before closing or resolving the finding." case aicontracts.OutcomeFixFailed, aicontracts.OutcomeFixVerificationFailed: return "Review failed remediation evidence before retrying or escalating." case aicontracts.OutcomeFixVerificationUnknown: return "Gather verification evidence before closing or retrying remediation." case aicontracts.OutcomeNeedsAttention, aicontracts.OutcomeCannotFix: return "Operator intervention is required; use the evidence to choose the next manual step." case aicontracts.OutcomeTimedOut: return "Patrol timed out; rerun investigation or gather more evidence before remediation." } switch rec.Status { case aicontracts.InvestigationStatusPending, aicontracts.InvestigationStatusRunning: return "Wait for Patrol to finish the investigation before approving remediation." case aicontracts.InvestigationStatusFailed: return "Review the Patrol investigation failure and gather evidence before remediation." case aicontracts.InvestigationStatusNeedsAttention: return "Operator intervention is required; use the evidence to choose the next manual step." } } if remediationID := strings.TrimSpace(f.RemediationID); remediationID != "" { return "Review governed remediation " + remediationID + " before execution." } loopState := strings.ToLower(strings.TrimSpace(f.LoopState)) switch { case strings.Contains(loopState, "approval"): return "Review the governed approval flow before execution." case strings.Contains(loopState, "investigat"): return "Wait for Patrol to finish the investigation before approving remediation." } if unifiedFindingChatStatus(f, time.Now()) == "active" { return "Continue investigation or monitoring; no governed action reference is ready." } return "" } func formatInvestigationRecordEvidenceBriefing(evidence []aicontracts.InvestigationRecordEvidence, limit int) string { if limit <= 0 || len(evidence) == 0 { return "" } parts := make([]string, 0, limit+1) total := 0 for _, item := range evidence { summaryParts := make([]string, 0, 3) if kind := strings.TrimSpace(item.Kind); kind != "" { summaryParts = append(summaryParts, kind) } if id := strings.TrimSpace(item.ID); id != "" { summaryParts = append(summaryParts, id) } if summary := strings.TrimSpace(item.Summary); summary != "" { summaryParts = append(summaryParts, summary) } if len(summaryParts) == 0 { continue } total++ if len(parts) < limit { parts = append(parts, strings.Join(summaryParts, ": ")) } } if len(parts) == 0 { return "" } if remaining := total - len(parts); remaining > 0 { parts = append(parts, fmt.Sprintf("%d more evidence items", remaining)) } return strings.Join(parts, "; ") } func formatBriefingStringList(values []string, limit int, itemName string) string { if limit <= 0 || len(values) == 0 { return "" } parts := make([]string, 0, limit+1) total := 0 for _, value := range values { if normalized := strings.TrimSpace(value); normalized != "" { total++ if len(parts) < limit { parts = append(parts, normalized) } } } if len(parts) == 0 { return "" } if remaining := total - len(parts); remaining > 0 { itemName = strings.TrimSpace(itemName) if itemName == "" { itemName = "items" } parts = append(parts, fmt.Sprintf("%d more %s", remaining, itemName)) } return strings.Join(parts, "; ") } func unifiedFindingBriefingActionPosture(f *unified.UnifiedFinding, handoffActions []chat.HandoffAction) string { if f == nil { return "" } if action, ok := unifiedFindingPrimaryHandoffAction(f, handoffActions); ok { if posture := unifiedFindingHandoffActionPosture(action); posture != "" { parts := []string{posture} if remediationID := strings.TrimSpace(f.RemediationID); remediationID != "" { parts = append(parts, "remediation "+remediationID) } return strings.Join(parts, "; ") } } rec := f.InvestigationRecord parts := make([]string, 0, 5) if rec != nil { if approvalID := strings.TrimSpace(rec.ApprovalID); approvalID != "" { parts = append(parts, "approval "+approvalID) } if rec.ProposedFix != nil { if fixID := strings.TrimSpace(rec.ProposedFix.ID); fixID != "" { parts = append(parts, "proposed fix "+fixID) } else if description := strings.TrimSpace(rec.ProposedFix.Description); description != "" { parts = append(parts, "proposed fix recorded") } if risk := strings.TrimSpace(rec.ProposedFix.RiskLevel); risk != "" { parts = append(parts, "risk "+risk) } if rec.ProposedFix.Destructive { parts = append(parts, "destructive true") } } } if remediationID := strings.TrimSpace(f.RemediationID); remediationID != "" { parts = append(parts, "remediation "+remediationID) } if len(parts) == 0 { return "No governed action is ready; keep the response investigative." } return strings.Join(parts, "; ") } func unifiedFindingPrimaryHandoffAction(f *unified.UnifiedFinding, handoffActions []chat.HandoffAction) (chat.HandoffAction, bool) { if len(handoffActions) == 0 { return chat.HandoffAction{}, false } findingID := "" if f != nil { findingID = strings.TrimSpace(f.ID) } for _, action := range handoffActions { if !handoffActionHasBriefingValue(action) { continue } actionFindingID := strings.TrimSpace(action.FindingID) if findingID == "" || strings.EqualFold(actionFindingID, findingID) { return action, true } } return chat.HandoffAction{}, false } func handoffActionHasBriefingValue(action chat.HandoffAction) bool { for _, value := range []string{ action.ApprovalID, action.ApprovalStatus, action.ActionID, action.ActionState, action.FixID, action.Description, } { if strings.TrimSpace(value) != "" { return true } } return action.Destructive || action.ApprovalConsumed || action.ActionRequiresApproval } func unifiedFindingHandoffActionOperatorDecision(action chat.HandoffAction) string { parts := make([]string, 0, 6) if approvalID := strings.TrimSpace(action.ApprovalID); approvalID != "" { parts = append(parts, "review governed approval "+approvalID+" before execution") } else if actionID := strings.TrimSpace(action.ActionID); actionID != "" { parts = append(parts, "review governed action "+actionID+" before execution") } else if strings.TrimSpace(action.FixID) != "" || strings.TrimSpace(action.Description) != "" { parts = append(parts, "review the proposed fix before execution") } if status := strings.TrimSpace(action.ApprovalStatus); status != "" { parts = append(parts, "approval status "+status) } if expiresAt := strings.TrimSpace(action.ApprovalExpiresAt); expiresAt != "" { parts = append(parts, "approval expires "+expiresAt) } if action.ApprovalConsumed { parts = append(parts, "approval consumed true") } if actionID := strings.TrimSpace(action.ActionID); actionID != "" && strings.TrimSpace(action.ApprovalID) != "" { parts = append(parts, "action "+actionID) } if state := strings.TrimSpace(action.ActionState); state != "" { parts = append(parts, "action state "+state) } if requestedBy := strings.TrimSpace(action.ActionRequestedBy); requestedBy != "" { parts = append(parts, "requested by "+requestedBy) } if policy := strings.TrimSpace(action.ActionApprovalPolicy); policy != "" { parts = append(parts, "approval policy "+policy) } if planExpiresAt := strings.TrimSpace(action.ActionPlanExpiresAt); planExpiresAt != "" { parts = append(parts, "plan expires "+planExpiresAt) } if fixID := strings.TrimSpace(action.FixID); fixID != "" { parts = append(parts, "proposed fix "+fixID) } else if description := strings.TrimSpace(action.Description); description != "" { parts = append(parts, "proposed fix recorded") } if risk := strings.TrimSpace(action.RiskLevel); risk != "" { parts = append(parts, "risk "+risk) } if action.Destructive { parts = append(parts, "destructive true") } return strings.Join(parts, "; ") } func unifiedFindingHandoffActionPosture(action chat.HandoffAction) string { parts := make([]string, 0, 8) if approvalID := strings.TrimSpace(action.ApprovalID); approvalID != "" { parts = append(parts, "approval "+approvalID) } if status := strings.TrimSpace(action.ApprovalStatus); status != "" { parts = append(parts, "approval status "+status) } if requestedAt := strings.TrimSpace(action.ApprovalRequestedAt); requestedAt != "" { parts = append(parts, "approval requested "+requestedAt) } if expiresAt := strings.TrimSpace(action.ApprovalExpiresAt); expiresAt != "" { parts = append(parts, "approval expires "+expiresAt) } if decidedAt := strings.TrimSpace(action.ApprovalDecidedAt); decidedAt != "" { parts = append(parts, "approval decided "+decidedAt) } if action.ApprovalConsumed { parts = append(parts, "approval consumed true") } if actionID := strings.TrimSpace(action.ActionID); actionID != "" { parts = append(parts, "action "+actionID) } if state := strings.TrimSpace(action.ActionState); state != "" { parts = append(parts, "action state "+state) } if requestedBy := strings.TrimSpace(action.ActionRequestedBy); requestedBy != "" { parts = append(parts, "requested by "+requestedBy) } if policy := strings.TrimSpace(action.ActionApprovalPolicy); policy != "" { parts = append(parts, "approval policy "+policy) } if action.ActionRequiresApproval { parts = append(parts, "action requires approval true") } if planExpiresAt := strings.TrimSpace(action.ActionPlanExpiresAt); planExpiresAt != "" { parts = append(parts, "plan expires "+planExpiresAt) } if fixID := strings.TrimSpace(action.FixID); fixID != "" { parts = append(parts, "proposed fix "+fixID) } else if description := strings.TrimSpace(action.Description); description != "" { parts = append(parts, "proposed fix recorded") } if risk := strings.TrimSpace(action.RiskLevel); risk != "" { parts = append(parts, "risk "+risk) } if action.Destructive { parts = append(parts, "destructive true") } return strings.Join(parts, "; ") } func unifiedFindingChatStatus(f *unified.UnifiedFinding, now time.Time) string { if f == nil { return "" } if f.ResolvedAt != nil { return "resolved" } if f.SnoozedUntil != nil && (now.IsZero() || now.Before(*f.SnoozedUntil)) { return "snoozed" } if f.Suppressed { return "suppressed" } if strings.TrimSpace(f.DismissedReason) != "" { return "dismissed" } return "active" } func appendUnifiedFindingLifecycleEventContext(b *strings.Builder, events []unified.UnifiedFindingLifecycleEvent) { if len(events) == 0 { return } start := 0 if len(events) > findingChatContextListLimit { start = len(events) - findingChatContextListLimit } type formattedLifecycleEvent struct { Label string Summary string } formatted := make([]formattedLifecycleEvent, 0, findingChatContextListLimit) for idx, event := range events[start:] { summary := formatUnifiedFindingLifecycleEvent(event) if summary == "" { continue } formatted = append(formatted, formattedLifecycleEvent{ Label: fmt.Sprintf("Lifecycle Event %d", idx+1), Summary: summary, }) } if len(formatted) == 0 { return } appendChatContextLine(b, "", "") appendChatContextLine(b, "[Finding Lifecycle Context]", "") for _, event := range formatted { appendChatContextLine(b, event.Label, event.Summary) } if start > 0 { appendChatContextLine(b, "Lifecycle Additional Count", strconv.Itoa(start)) } appendChatContextLine(b, "Lifecycle Boundary", "Finding lifecycle events are current Patrol review context only; they do not grant approval or execution authority.") } func formatUnifiedFindingLatestLifecycleEvent(events []unified.UnifiedFindingLifecycleEvent) string { for idx := len(events) - 1; idx >= 0; idx-- { if summary := formatUnifiedFindingLifecycleEvent(events[idx]); summary != "" { return summary } } return "" } func formatUnifiedFindingLifecycleEvent(event unified.UnifiedFindingLifecycleEvent) string { parts := make([]string, 0, 4) if !event.At.IsZero() { parts = append(parts, event.At.Format(time.RFC3339)) } eventType := strings.TrimSpace(event.Type) if eventType != "" { parts = append(parts, eventType) } message := strings.TrimSpace(event.Message) if message != "" { parts = append(parts, message) } from := strings.TrimSpace(event.From) to := strings.TrimSpace(event.To) if from != "" || to != "" { parts = append(parts, from+" -> "+to) } return strings.Join(parts, " | ") } func appendInvestigationRecordChatContext(b *strings.Builder, rec *aicontracts.InvestigationRecord) { if rec == nil { return } appendChatContextLine(b, "", "") appendChatContextLine(b, "[Investigation Record]", "") appendChatContextLine(b, "Record ID", rec.ID) appendChatContextLine(b, "Session ID", rec.SessionID) appendChatContextLine(b, "Status", string(rec.Status)) appendChatContextLine(b, "Outcome", string(rec.Outcome)) appendChatContextLine(b, "Confidence", string(rec.Confidence)) appendChatContextLine(b, "Subject Resource", formatChatResource(rec.Subject.ResourceName, rec.Subject.ResourceType)) appendChatContextLine(b, "Subject Resource ID", rec.Subject.ResourceID) appendChatContextLine(b, "Subject Node", rec.Subject.Node) appendChatContextLine(b, "Conclusion", rec.Conclusion) appendChatContextLine(b, "Recommended Action", rec.RecommendedAction) appendChatContextLine(b, "Trigger", rec.Trigger.Title) appendChatContextLine(b, "Trigger Description", rec.Trigger.Description) if !rec.Trigger.DetectedAt.IsZero() { appendChatContextLine(b, "Detected At", rec.Trigger.DetectedAt.Format(time.RFC3339)) } if !rec.StartedAt.IsZero() { appendChatContextLine(b, "Investigation Started At", rec.StartedAt.Format(time.RFC3339)) } if rec.CompletedAt != nil { appendChatContextLine(b, "Investigation Completed At", rec.CompletedAt.Format(time.RFC3339)) } appendChatContextLine(b, "Approval ID", rec.ApprovalID) appendChatContextLine(b, "Error", rec.Error) appendInvestigationRecordEvidenceContext(b, rec.Evidence) appendStringListChatContext(b, "Verification", rec.Verification) appendStringListChatContext(b, "Tools Used", rec.ToolsUsed) if rec.ProposedFix != nil { appendChatContextLine(b, "Proposed Fix", rec.ProposedFix.Description) appendChatContextLine(b, "Proposed Fix Risk", rec.ProposedFix.RiskLevel) appendChatContextLine(b, "Proposed Fix Target Host", rec.ProposedFix.TargetHost) appendChatContextLine(b, "Proposed Fix Rationale", rec.ProposedFix.Rationale) if rec.ProposedFix.Destructive { appendChatContextLine(b, "Proposed Fix Destructive", "true") } switch len(rec.ProposedFix.Commands) { case 0: case 1: appendChatContextLine(b, "Proposed Fix Commands", "1 command recorded for approval context") default: appendChatContextLine(b, "Proposed Fix Commands", fmt.Sprintf("%d commands recorded for approval context", len(rec.ProposedFix.Commands))) } } } func buildUnifiedFindingHandoffResources(f *unified.UnifiedFinding, lookup unifiedFindingLookup) []chat.HandoffResource { if f == nil { return nil } resources := make([]chat.HandoffResource, 0, 2+findingChatContextListLimit) add := func(resource chat.HandoffResource) { resource.ID = strings.TrimSpace(resource.ID) resource.Name = strings.TrimSpace(resource.Name) resource.Type = strings.TrimSpace(resource.Type) resource.Node = strings.TrimSpace(resource.Node) if resource.ID == "" && resource.Name == "" { return } key := strings.ToLower(resource.Type + "\x00" + resource.ID + "\x00" + resource.Name + "\x00" + resource.Node) for _, existing := range resources { existingKey := strings.ToLower(strings.TrimSpace(existing.Type) + "\x00" + strings.TrimSpace(existing.ID) + "\x00" + strings.TrimSpace(existing.Name) + "\x00" + strings.TrimSpace(existing.Node)) if existingKey == key { return } } resources = append(resources, resource) } add(chat.HandoffResource{ ID: f.ResourceID, Name: f.ResourceName, Type: f.ResourceType, Node: f.Node, }) if f.InvestigationRecord != nil { add(chat.HandoffResource{ ID: f.InvestigationRecord.Subject.ResourceID, Name: f.InvestigationRecord.Subject.ResourceName, Type: f.InvestigationRecord.Subject.ResourceType, Node: f.InvestigationRecord.Subject.Node, }) } for _, related := range resolveUnifiedFindingRelatedContext(f, lookup) { relatedFinding := related.Finding if relatedFinding == nil { continue } add(chat.HandoffResource{ ID: relatedFinding.ResourceID, Name: relatedFinding.ResourceName, Type: relatedFinding.ResourceType, Node: relatedFinding.Node, }) if relatedFinding.InvestigationRecord != nil { add(chat.HandoffResource{ ID: relatedFinding.InvestigationRecord.Subject.ResourceID, Name: relatedFinding.InvestigationRecord.Subject.ResourceName, Type: relatedFinding.InvestigationRecord.Subject.ResourceType, Node: relatedFinding.InvestigationRecord.Subject.Node, }) } } return resources } func buildUnifiedFindingHandoffActions(f *unified.UnifiedFinding, orgID string) []chat.HandoffAction { if f == nil { return nil } rec := f.InvestigationRecord liveApproval := livePatrolApprovalForFinding(f.ID, orgID) if rec == nil && liveApproval == nil { return nil } action := chat.HandoffAction{ FindingID: f.ID, TargetResourceID: f.ResourceID, TargetResourceName: f.ResourceName, TargetResourceType: f.ResourceType, TargetNode: f.Node, } if rec != nil { action.RecordID = rec.ID action.ApprovalID = rec.ApprovalID action.TargetResourceID = firstNonEmptyString(rec.Subject.ResourceID, action.TargetResourceID) action.TargetResourceName = firstNonEmptyString(rec.Subject.ResourceName, action.TargetResourceName) action.TargetResourceType = firstNonEmptyString(rec.Subject.ResourceType, action.TargetResourceType) action.TargetNode = firstNonEmptyString(rec.Subject.Node, action.TargetNode) } if liveApproval != nil { targetResourceIDBeforeApproval := strings.TrimSpace(action.TargetResourceID) targetResourceTypeBeforeApproval := strings.TrimSpace(action.TargetResourceType) chat.HydrateHandoffActionFromApproval(&action, liveApproval) if liveTargetID := strings.TrimSpace(liveApproval.TargetID); liveTargetID != "" && strings.EqualFold(liveTargetID, strings.TrimSpace(f.ID)) { if targetResourceIDBeforeApproval == "" && strings.EqualFold(strings.TrimSpace(action.TargetResourceID), liveTargetID) { action.TargetResourceID = "" } if targetResourceTypeBeforeApproval == "" && strings.EqualFold(strings.TrimSpace(action.TargetResourceType), strings.TrimSpace(liveApproval.TargetType)) { action.TargetResourceType = "" } } } if rec != nil && rec.ProposedFix != nil { action.FixID = rec.ProposedFix.ID action.Description = rec.ProposedFix.Description if strings.TrimSpace(action.RiskLevel) == "" { action.RiskLevel = rec.ProposedFix.RiskLevel } action.Destructive = rec.ProposedFix.Destructive action.TargetHost = rec.ProposedFix.TargetHost } if strings.TrimSpace(action.ApprovalID) == "" && strings.TrimSpace(action.FixID) == "" && strings.TrimSpace(action.Description) == "" { return nil } return []chat.HandoffAction{action} } func livePatrolApprovalForFinding(findingID, orgID string) *approval.ApprovalRequest { findingID = strings.TrimSpace(findingID) if findingID == "" { return nil } store := approval.GetStore() if store == nil { return nil } var selected *approval.ApprovalRequest for _, req := range store.GetPendingApprovalsForOrg(orgID) { if req == nil { continue } if !strings.EqualFold(strings.TrimSpace(req.ToolID), "investigation_fix") { continue } if strings.TrimSpace(req.TargetID) != findingID { continue } if selected == nil || req.RequestedAt.After(selected.RequestedAt) || (req.RequestedAt.Equal(selected.RequestedAt) && strings.TrimSpace(req.ID) > strings.TrimSpace(selected.ID)) { selected = req } } return selected } func firstNonEmptyString(values ...string) string { for _, value := range values { if normalized := strings.TrimSpace(value); normalized != "" { return normalized } } return "" } func formatChatResource(name, resourceType string) string { name = strings.TrimSpace(name) resourceType = strings.TrimSpace(resourceType) switch { case name != "" && resourceType != "": return fmt.Sprintf("%s (%s)", name, resourceType) case name != "": return name case resourceType != "": return resourceType default: return "" } } func appendInvestigationRecordEvidenceContext(b *strings.Builder, evidence []aicontracts.InvestigationRecordEvidence) { if len(evidence) == 0 { return } count := 0 for _, item := range evidence { if count >= findingChatContextListLimit { break } parts := make([]string, 0, 3) if strings.TrimSpace(item.Kind) != "" { parts = append(parts, strings.TrimSpace(item.Kind)) } if strings.TrimSpace(item.ID) != "" { parts = append(parts, strings.TrimSpace(item.ID)) } if strings.TrimSpace(item.Summary) != "" { parts = append(parts, strings.TrimSpace(item.Summary)) } if len(parts) == 0 { continue } count++ appendChatContextLine(b, fmt.Sprintf("Evidence %d", count), strings.Join(parts, ": ")) } if remaining := len(evidence) - count; remaining > 0 { appendChatContextLine(b, "Evidence Additional Count", fmt.Sprintf("%d", remaining)) } } func appendStringListChatContext(b *strings.Builder, label string, values []string) { count := 0 for _, value := range values { normalized := strings.TrimSpace(value) if normalized == "" { continue } if count >= findingChatContextListLimit { break } count++ appendChatContextLine(b, fmt.Sprintf("%s %d", label, count), normalized) } if remaining := len(values) - count; remaining > 0 { appendChatContextLine(b, fmt.Sprintf("%s Additional Count", label), fmt.Sprintf("%d", remaining)) } } func nonEmptyStrings(values ...string) []string { out := make([]string, 0, len(values)) for _, value := range values { if normalized := strings.TrimSpace(value); normalized != "" { out = append(out, normalized) } } return out } func briefingLabelValue(label, value string) string { value = strings.TrimSpace(value) if value == "" { return "" } label = strings.TrimSpace(label) if label == "" { return value } return label + " " + value } func appendChatContextLine(b *strings.Builder, label string, value string) { label = strings.TrimSpace(label) value = strings.TrimSpace(value) if label == "" && value == "" { b.WriteByte('\n') return } if label == "" { b.WriteString(value) b.WriteByte('\n') return } if value == "" { b.WriteString(label) b.WriteByte('\n') return } b.WriteString(label) b.WriteString(": ") b.WriteString(value) b.WriteByte('\n') } // HandleChat handles POST /api/ai/chat - streaming chat func (h *AIHandler) HandleChat(w http.ResponseWriter, r *http.Request) { // CORS if cfg := h.getConfig(r.Context()); cfg != nil { applyConfiguredCORSHeaders( w, r.Header.Get("Origin"), cfg.AllowedOrigins, "POST, OPTIONS", "Content-Type, Accept, Cookie", ) } if r.Method == http.MethodOptions { w.WriteHeader(http.StatusOK) return } if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Auth already handled by RequireAuth wrapper - no need to check again ctx := r.Context() if !h.IsRunning(ctx) { http.Error(w, "Pulse Assistant is not running", http.StatusServiceUnavailable) return } svc := h.GetService(ctx) if svc == nil { http.Error(w, "Pulse Assistant service not available", http.StatusServiceUnavailable) return } // Parse request var req ChatRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request", http.StatusBadRequest) return } preview := req.Prompt if len(preview) > 100 { preview = preview[:100] + "..." } log.Info(). Str("sessionId", req.SessionID). Str("prompt_preview", preview). Msg("AIHandler: Received chat request") // Set up SSE w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") w.Header().Set("X-Accel-Buffering", "no") w.Header().Set("Transfer-Encoding", "identity") flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "Streaming not supported", http.StatusInternalServerError) return } // Disable timeouts rc := http.NewResponseController(w) _ = rc.SetWriteDeadline(time.Time{}) _ = rc.SetReadDeadline(time.Time{}) flusher.Flush() // Keep assistant execution bound to the client request so disconnects cancel // backend work instead of letting it continue until the hard timeout expires. ctx, cancel := context.WithTimeout(r.Context(), 15*time.Minute) defer cancel() // Heartbeat heartbeatDone := make(chan struct{}) var clientDisconnected atomic.Bool go func() { ticker := time.NewTicker(5 * time.Second) defer ticker.Stop() for { select { case <-ctx.Done(): clientDisconnected.Store(true) return case <-ticker.C: _ = rc.SetWriteDeadline(time.Now().Add(10 * time.Second)) _, err := w.Write([]byte(": heartbeat\n\n")) if err != nil { clientDisconnected.Store(true) cancel() return } flusher.Flush() case <-heartbeatDone: return } } }() defer close(heartbeatDone) // Write helper writeEvent := func(event chat.StreamEvent) { if clientDisconnected.Load() { return } data, err := json.Marshal(event) if err != nil { return } _ = rc.SetWriteDeadline(time.Now().Add(10 * time.Second)) _, err = w.Write([]byte("data: " + string(data) + "\n\n")) if err != nil { clientDisconnected.Store(true) cancel() return } flusher.Flush() } // Convert API mentions to chat mentions var chatMentions []chat.StructuredMention for _, m := range req.Mentions { mentionType := canonicalizeChatMentionType(m.Type) if mentionType == "" { log.Warn(). Str("mention_type", m.Type). Str("mention_name", m.Name). Msg("Ignoring unsupported chat mention type") continue } chatMentions = append(chatMentions, chat.StructuredMention{ ID: m.ID, Name: m.Name, Type: mentionType, Node: m.Node, }) } // Build model-only finding context when discussing a specific finding. The // chat service injects this into the current model turn without persisting it // as the user's authored prompt, so conversation history stays readable. handoffContext := normalizeChatRequestHandoffContext(req.HandoffContext) handoffResources := normalizeChatRequestHandoffResources(req.HandoffResources) handoffActions := normalizeChatRequestHandoffActions(req.HandoffActions) handoffMetadata := chat.NormalizeHandoffMetadata(req.HandoffMetadata) requestHandoffContext := handoffContext requestHandoffResources := handoffResources requestHandoffActions := handoffActions findingID := strings.TrimSpace(req.FindingID) if findingID == "" && strings.TrimSpace(req.SessionID) != "" { if storedFindingID, err := svc.GetModelHandoffFindingID(ctx, req.SessionID); err != nil { log.Debug().Err(err).Str("session_id", req.SessionID).Msg("Unable to load stored Assistant finding handoff reference") } else { findingID = strings.TrimSpace(storedFindingID) } } if findingID == "" && handoffMetadata == (chat.HandoffMetadata{}) && strings.TrimSpace(req.SessionID) != "" { if storedMetadata, err := svc.GetModelHandoffMetadata(ctx, req.SessionID); err != nil { log.Debug().Err(err).Str("session_id", req.SessionID).Msg("Unable to load stored Assistant handoff metadata") } else { handoffMetadata = chat.NormalizeHandoffMetadata(storedMetadata) } } if findingID == "" && handoffMetadata.Kind == "patrol_run" { handoffContext = "" handoffResources = nil handoffActions = nil if run, ok := h.getPatrolRunForHandoff(ctx, handoffMetadata.RunID); ok { runHandoff := airuntime.BuildPatrolRunAssistantHandoff(run) handoffContext = runHandoff.Context handoffResources = runHandoff.Resources handoffMetadata = chat.NormalizeHandoffMetadata(runHandoff.Metadata) } } if findingID != "" { findingResolved := false orgID := GetOrgID(ctx) store := h.GetUnifiedStoreForOrg(orgID) if store != nil { if f := store.Get(findingID); f != nil { findingResolved = true handoffActions = mergeUnifiedFindingRequestHandoffActions(f, buildUnifiedFindingHandoffActions(f, orgID), requestHandoffActions) handoffContext = mergeUnifiedFindingRequestHandoffContext(buildUnifiedFindingChatContext(f, store, handoffActions), requestHandoffContext, f.ID) handoffResources = mergeUnifiedFindingRequestHandoffResources(f, buildUnifiedFindingHandoffResources(f, store), requestHandoffResources) handoffMetadata = chat.HandoffMetadata{} } } if !findingResolved { if sessionID := strings.TrimSpace(req.SessionID); sessionID != "" { if err := svc.ClearModelHandoffContext(ctx, sessionID); err != nil { log.Debug().Err(err).Str("session_id", sessionID).Str("finding_id", findingID).Msg("Unable to clear stale Assistant finding handoff context") } } findingID = "" handoffContext = "" handoffResources = nil handoffActions = nil handoffMetadata = chat.HandoffMetadata{} } } // Stream from AI chat service serviceSentDone := false err := svc.ExecuteStream(ctx, chat.ExecuteRequest{ Prompt: req.Prompt, SessionID: req.SessionID, Model: req.Model, Mentions: chatMentions, FindingID: findingID, HandoffContext: handoffContext, HandoffResources: handoffResources, HandoffActions: handoffActions, HandoffMetadata: handoffMetadata, AutonomousMode: chatAutonomousModeForFindingHandoff(req.AutonomousMode, findingID, handoffContext, handoffResources, handoffActions, handoffMetadata), }, func(event chat.StreamEvent) { if event.Type == "done" { serviceSentDone = true } writeEvent(event) }) if err != nil { log.Error().Err(err).Msg("Chat stream error") errData, _ := json.Marshal(chat.ErrorData{Message: "An error occurred while processing your request"}) writeEvent(chat.StreamEvent{Type: "error", Data: errData}) } // Send done if !serviceSentDone { writeEvent(chat.StreamEvent{Type: "done", Data: nil}) } } // HandleSessions handles GET /api/ai/sessions - list sessions func (h *AIHandler) HandleSessions(w http.ResponseWriter, r *http.Request) { ctx := r.Context() if !h.IsRunning(ctx) { http.Error(w, "Pulse Assistant is not running", http.StatusServiceUnavailable) return } svc := h.GetService(ctx) if svc == nil { http.Error(w, "Pulse Assistant service not available", http.StatusServiceUnavailable) return } sessions, err := svc.ListSessions(ctx) if err != nil { http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError) return } // Optional limit parameter (for relay proxy clients with body size constraints) if limitStr := r.URL.Query().Get("limit"); limitStr != "" { if limit, err := strconv.Atoi(limitStr); err == nil && limit > 0 && limit < len(sessions) { sessions = sessions[:limit] } } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(sessions) } // HandleCreateSession handles POST /api/ai/sessions - create session func (h *AIHandler) HandleCreateSession(w http.ResponseWriter, r *http.Request) { ctx := r.Context() if !h.IsRunning(ctx) { http.Error(w, "Pulse Assistant is not running", http.StatusServiceUnavailable) return } svc := h.GetService(ctx) if svc == nil { http.Error(w, "Pulse Assistant service not available", http.StatusServiceUnavailable) return } session, err := svc.CreateSession(ctx) if err != nil { http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(session) } // HandleDeleteSession handles DELETE /api/ai/sessions/{id} func (h *AIHandler) HandleDeleteSession(w http.ResponseWriter, r *http.Request, sessionID string) { ctx := r.Context() if !h.IsRunning(ctx) { http.Error(w, "Pulse Assistant is not running", http.StatusServiceUnavailable) return } svc := h.GetService(ctx) if svc == nil { http.Error(w, "Pulse Assistant service not available", http.StatusServiceUnavailable) return } if err := svc.DeleteSession(ctx, sessionID); err != nil { http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } // HandleMessages handles GET /api/ai/sessions/{id}/messages func (h *AIHandler) HandleMessages(w http.ResponseWriter, r *http.Request, sessionID string) { ctx := r.Context() if !h.IsRunning(ctx) { http.Error(w, "Pulse Assistant is not running", http.StatusServiceUnavailable) return } svc := h.GetService(ctx) if svc == nil { http.Error(w, "Pulse Assistant service not available", http.StatusServiceUnavailable) return } messages, err := svc.GetMessages(ctx, sessionID) if err != nil { http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError) return } // Optional limit parameter — returns the LAST N messages (most recent). // Used by relay proxy clients with body size constraints. if limitStr := r.URL.Query().Get("limit"); limitStr != "" { if limit, err := strconv.Atoi(limitStr); err == nil && limit > 0 && limit < len(messages) { messages = messages[len(messages)-limit:] } } for i := range messages { messages[i] = messages[i].NormalizeCollections() } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(messages) } // HandleAbort handles POST /api/ai/sessions/{id}/abort func (h *AIHandler) HandleAbort(w http.ResponseWriter, r *http.Request, sessionID string) { ctx := r.Context() if !h.IsRunning(ctx) { http.Error(w, "Pulse Assistant is not running", http.StatusServiceUnavailable) return } svc := h.GetService(ctx) if svc == nil { http.Error(w, "Pulse Assistant service not available", http.StatusServiceUnavailable) return } if err := svc.AbortSession(ctx, sessionID); err != nil { http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) } // HandleStatus handles GET /api/ai/status func (h *AIHandler) HandleStatus(w http.ResponseWriter, r *http.Request) { status := map[string]interface{}{ "running": h.IsRunning(r.Context()), "engine": "direct", } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(status) } // HandleSummarize handles POST /api/ai/sessions/{id}/summarize // Compresses context when nearing model limits func (h *AIHandler) HandleSummarize(w http.ResponseWriter, r *http.Request, sessionID string) { ctx := r.Context() if !h.IsRunning(ctx) { http.Error(w, "Pulse Assistant is not running", http.StatusServiceUnavailable) return } svc := h.GetService(ctx) if svc == nil { http.Error(w, "Pulse Assistant service not available", http.StatusServiceUnavailable) return } result, err := svc.SummarizeSession(ctx, sessionID) if err != nil { http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(result) } // HandleDiff handles GET /api/ai/sessions/{id}/diff // Returns file changes made during the session func (h *AIHandler) HandleDiff(w http.ResponseWriter, r *http.Request, sessionID string) { ctx := r.Context() if !h.IsRunning(ctx) { http.Error(w, "Pulse Assistant is not running", http.StatusServiceUnavailable) return } svc := h.GetService(ctx) if svc == nil { http.Error(w, "Pulse Assistant service not available", http.StatusServiceUnavailable) return } diff, err := svc.GetSessionDiff(ctx, sessionID) if err != nil { http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(diff) } // HandleFork handles POST /api/ai/sessions/{id}/fork // Creates a branch point in the conversation func (h *AIHandler) HandleFork(w http.ResponseWriter, r *http.Request, sessionID string) { ctx := r.Context() if !h.IsRunning(ctx) { http.Error(w, "Pulse Assistant is not running", http.StatusServiceUnavailable) return } svc := h.GetService(ctx) if svc == nil { http.Error(w, "Pulse Assistant service not available", http.StatusServiceUnavailable) return } session, err := svc.ForkSession(ctx, sessionID) if err != nil { http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(session) } // HandleRevert handles POST /api/ai/sessions/{id}/revert // Reverts file changes from the session func (h *AIHandler) HandleRevert(w http.ResponseWriter, r *http.Request, sessionID string) { ctx := r.Context() if !h.IsRunning(ctx) { http.Error(w, "Pulse Assistant is not running", http.StatusServiceUnavailable) return } svc := h.GetService(ctx) if svc == nil { http.Error(w, "Pulse Assistant service not available", http.StatusServiceUnavailable) return } result, err := svc.RevertSession(ctx, sessionID) if err != nil { http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(result) } // HandleUnrevert handles POST /api/ai/sessions/{id}/unrevert // Restores previously reverted changes func (h *AIHandler) HandleUnrevert(w http.ResponseWriter, r *http.Request, sessionID string) { ctx := r.Context() if !h.IsRunning(ctx) { http.Error(w, "Pulse Assistant is not running", http.StatusServiceUnavailable) return } svc := h.GetService(ctx) if svc == nil { http.Error(w, "Pulse Assistant service not available", http.StatusServiceUnavailable) return } result, err := svc.UnrevertSession(ctx, sessionID) if err != nil { http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(result) } // AnswerQuestionRequest represents a request to answer a question type AnswerQuestionRequest struct { Answers []struct { ID string `json:"id"` Value string `json:"value"` } `json:"answers"` } // HandleAnswerQuestion handles POST /api/ai/question/{questionID}/answer func (h *AIHandler) HandleAnswerQuestion(w http.ResponseWriter, r *http.Request, questionID string) { ctx := r.Context() if !h.IsRunning(ctx) { http.Error(w, "Pulse Assistant is not running", http.StatusServiceUnavailable) return } var req AnswerQuestionRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } // Convert to chat.QuestionAnswer answers := make([]chat.QuestionAnswer, len(req.Answers)) for i, a := range req.Answers { answers[i] = chat.QuestionAnswer{ ID: a.ID, Value: a.Value, } } log.Info(). Str("questionID", questionID). Int("answers_count", len(answers)). Msg("AIHandler: Received answer to question") svc := h.GetService(ctx) if svc == nil { http.Error(w, "Pulse Assistant service not available", http.StatusServiceUnavailable) return } if err := svc.AnswerQuestion(ctx, questionID, answers); err != nil { log.Error().Err(err).Str("questionID", questionID).Msg("Failed to answer question") http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) } // SetAlertProvider sets the alert provider for MCP tools func (h *AIHandler) SetAlertProvider(provider chat.MCPAlertProvider) { if svc := h.getDefaultService(); svc != nil { svc.SetAlertProvider(provider) } } // SetFindingsProvider sets the findings provider for MCP tools func (h *AIHandler) SetFindingsProvider(provider chat.MCPFindingsProvider) { if svc := h.getDefaultService(); svc != nil { svc.SetFindingsProvider(provider) } } // SetBaselineProvider sets the baseline provider for MCP tools func (h *AIHandler) SetBaselineProvider(provider chat.MCPBaselineProvider) { if svc := h.getDefaultService(); svc != nil { svc.SetBaselineProvider(provider) } } // SetPatternProvider sets the pattern provider for MCP tools func (h *AIHandler) SetPatternProvider(provider chat.MCPPatternProvider) { if svc := h.getDefaultService(); svc != nil { svc.SetPatternProvider(provider) } } // SetMetricsHistory sets the metrics history provider for MCP tools func (h *AIHandler) SetMetricsHistory(provider chat.MCPMetricsHistoryProvider) { if svc := h.getDefaultService(); svc != nil { svc.SetMetricsHistory(provider) } } // SetAgentProfileManager sets the agent profile manager for MCP tools func (h *AIHandler) SetAgentProfileManager(manager chat.AgentProfileManager) { if svc := h.getDefaultService(); svc != nil { svc.SetAgentProfileManager(manager) } } // SetGuestConfigProvider sets the guest config provider for MCP tools func (h *AIHandler) SetGuestConfigProvider(provider chat.MCPGuestConfigProvider) { if svc := h.getDefaultService(); svc != nil { svc.SetGuestConfigProvider(provider) } } // SetAppContainerConfigProvider sets the native app-container config provider for MCP tools func (h *AIHandler) SetAppContainerConfigProvider(provider chat.MCPAppContainerConfigProvider) { if svc := h.getDefaultService(); svc != nil { svc.SetAppContainerConfigProvider(provider) } } // SetBackupProvider sets the backup provider for MCP tools func (h *AIHandler) SetBackupProvider(provider chat.MCPBackupProvider) { if svc := h.getDefaultService(); svc != nil { svc.SetBackupProvider(provider) } } // SetDiskHealthProvider sets the disk health provider for MCP tools func (h *AIHandler) SetDiskHealthProvider(provider chat.MCPDiskHealthProvider) { if svc := h.getDefaultService(); svc != nil { svc.SetDiskHealthProvider(provider) } } // SetUpdatesProvider sets the updates provider for MCP tools func (h *AIHandler) SetUpdatesProvider(provider chat.MCPUpdatesProvider) { if svc := h.getDefaultService(); svc != nil { svc.SetUpdatesProvider(provider) } } // SetFindingsManager sets the findings manager for MCP tools func (h *AIHandler) SetFindingsManager(manager chat.FindingsManager) { if svc := h.getDefaultService(); svc != nil { svc.SetFindingsManager(manager) } } // SetMetadataUpdater sets the metadata updater for MCP tools func (h *AIHandler) SetMetadataUpdater(updater chat.MetadataUpdater) { if svc := h.getDefaultService(); svc != nil { svc.SetMetadataUpdater(updater) } } // SetUnifiedResourceProvider sets the unified resource provider for MCP tools func (h *AIHandler) SetUnifiedResourceProvider(provider chat.MCPUnifiedResourceProvider) { if svc := h.getDefaultService(); svc != nil { svc.SetUnifiedResourceProvider(provider) } } // UpdateControlSettings updates control settings in the service func (h *AIHandler) UpdateControlSettings(cfg *config.AIConfig) { if svc := h.getDefaultService(); svc != nil { svc.UpdateControlSettings(cfg) } }