package tools import ( "context" "fmt" "strconv" "strings" ) // registerDiscoveryTools registers the pulse_discovery tool func (e *PulseToolExecutor) registerDiscoveryTools() { e.registry.Register(RegisteredTool{ Definition: Tool{ Name: "pulse_discovery", Description: `Get AI-discovered service details (log paths, config locations, ports). action="get" triggers discovery for a resource (requires resource_type, resource_id, host_id). action="list" searches existing discoveries. Use pulse_query action="search" first to find resource details.`, InputSchema: InputSchema{ Type: "object", Properties: map[string]PropertySchema{ "action": { Type: "string", Description: "Discovery action: get or list", Enum: []string{"get", "list"}, }, "resource_type": { Type: "string", Description: "For get: resource type (vm, lxc, docker, host)", Enum: []string{"vm", "lxc", "docker", "host"}, }, "resource_id": { Type: "string", Description: "For get: resource identifier (VMID, container name, hostname)", }, "host_id": { Type: "string", Description: "For get: node/host where resource runs", }, "type": { Type: "string", Description: "For list: filter by resource type", Enum: []string{"vm", "lxc", "docker", "host"}, }, "host": { Type: "string", Description: "For list: filter by host/node ID", }, "service_type": { Type: "string", Description: "For list: filter by service type (e.g., frigate, postgresql)", }, "limit": { Type: "integer", Description: "For list: maximum results (default: 50)", }, }, Required: []string{"action"}, }, }, Handler: func(ctx context.Context, exec *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) { return exec.executeDiscovery(ctx, args) }, }) } // executeDiscovery routes to the appropriate discovery handler based on action func (e *PulseToolExecutor) executeDiscovery(ctx context.Context, args map[string]interface{}) (CallToolResult, error) { action, _ := args["action"].(string) switch action { case "get": return e.executeGetDiscovery(ctx, args) case "list": return e.executeListDiscoveries(ctx, args) default: return NewErrorResult(fmt.Errorf("unknown action: %s. Use: get, list", action)), nil } } // getCommandContext returns information about how to run commands on a resource. // This helps the AI understand what commands to use with pulse_control. type CommandContext struct { // How to run commands: "direct" (agent on resource), "via_host" (agent on parent host) Method string `json:"method"` // The target_host value to use with pulse_control TargetHost string `json:"target_host"` // Example command pattern (what to pass to pulse_control) Example string `json:"example"` // For containers running inside this resource (e.g., Docker in LXC) NestedExample string `json:"nested_example,omitempty"` } // getCLIAccessPattern returns context about the resource type. // Does NOT prescribe how to access - the AI should determine that based on available agents. func getCLIAccessPattern(resourceType, hostID, resourceID string) string { switch resourceType { case "lxc": return fmt.Sprintf("LXC container on Proxmox node '%s' (VMID %s)", hostID, resourceID) case "vm": return fmt.Sprintf("VM on Proxmox node '%s' (VMID %s)", hostID, resourceID) case "docker": return fmt.Sprintf("Docker container '%s' on host '%s'", resourceID, hostID) case "host": return fmt.Sprintf("Host '%s'", hostID) default: return "" } } // commonServicePaths contains typical log/config paths for well-known services // These are fallbacks when discovery doesn't find specific paths var commonServicePaths = map[string]struct { LogPaths []string ConfigPaths []string DataPaths []string }{ "jellyfin": { LogPaths: []string{"/var/log/jellyfin/", "/config/log/"}, ConfigPaths: []string{"/etc/jellyfin/", "/config/"}, DataPaths: []string{"/var/lib/jellyfin/", "/config/data/"}, }, "plex": { LogPaths: []string{"/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Logs/"}, ConfigPaths: []string{"/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/"}, DataPaths: []string{"/var/lib/plexmediaserver/"}, }, "sonarr": { LogPaths: []string{"/config/logs/"}, ConfigPaths: []string{"/config/"}, DataPaths: []string{"/config/"}, }, "radarr": { LogPaths: []string{"/config/logs/"}, ConfigPaths: []string{"/config/"}, DataPaths: []string{"/config/"}, }, "prowlarr": { LogPaths: []string{"/config/logs/"}, ConfigPaths: []string{"/config/"}, DataPaths: []string{"/config/"}, }, "lidarr": { LogPaths: []string{"/config/logs/"}, ConfigPaths: []string{"/config/"}, DataPaths: []string{"/config/"}, }, "postgresql": { LogPaths: []string{"/var/log/postgresql/", "/var/lib/postgresql/data/log/"}, ConfigPaths: []string{"/etc/postgresql/", "/var/lib/postgresql/data/"}, DataPaths: []string{"/var/lib/postgresql/data/"}, }, "mysql": { LogPaths: []string{"/var/log/mysql/", "/var/lib/mysql/"}, ConfigPaths: []string{"/etc/mysql/"}, DataPaths: []string{"/var/lib/mysql/"}, }, "mariadb": { LogPaths: []string{"/var/log/mysql/", "/var/lib/mysql/"}, ConfigPaths: []string{"/etc/mysql/"}, DataPaths: []string{"/var/lib/mysql/"}, }, "nginx": { LogPaths: []string{"/var/log/nginx/"}, ConfigPaths: []string{"/etc/nginx/"}, DataPaths: []string{"/var/www/"}, }, "homeassistant": { LogPaths: []string{"/config/home-assistant.log"}, ConfigPaths: []string{"/config/"}, DataPaths: []string{"/config/"}, }, "frigate": { LogPaths: []string{"/config/logs/"}, ConfigPaths: []string{"/config/"}, DataPaths: []string{"/media/frigate/"}, }, "redis": { LogPaths: []string{"/var/log/redis/"}, ConfigPaths: []string{"/etc/redis/"}, DataPaths: []string{"/var/lib/redis/"}, }, "mongodb": { LogPaths: []string{"/var/log/mongodb/"}, ConfigPaths: []string{"/etc/mongod.conf"}, DataPaths: []string{"/var/lib/mongodb/"}, }, "grafana": { LogPaths: []string{"/var/log/grafana/"}, ConfigPaths: []string{"/etc/grafana/"}, DataPaths: []string{"/var/lib/grafana/"}, }, "prometheus": { LogPaths: []string{"/var/log/prometheus/"}, ConfigPaths: []string{"/etc/prometheus/"}, DataPaths: []string{"/var/lib/prometheus/"}, }, "influxdb": { LogPaths: []string{"/var/log/influxdb/"}, ConfigPaths: []string{"/etc/influxdb/"}, DataPaths: []string{"/var/lib/influxdb/"}, }, } // getCommonServicePaths returns fallback paths for a service type func getCommonServicePaths(serviceType string) (logPaths, configPaths, dataPaths []string) { // Normalize service type (lowercase, remove version numbers) normalized := strings.ToLower(serviceType) // Try to match against known services for key, paths := range commonServicePaths { if strings.Contains(normalized, key) { return paths.LogPaths, paths.ConfigPaths, paths.DataPaths } } return nil, nil, nil } func (e *PulseToolExecutor) executeGetDiscovery(ctx context.Context, args map[string]interface{}) (CallToolResult, error) { if e.discoveryProvider == nil { return NewTextResult("Discovery service not available."), nil } resourceType, _ := args["resource_type"].(string) resourceID, _ := args["resource_id"].(string) hostID, _ := args["host_id"].(string) if resourceType == "" { return NewErrorResult(fmt.Errorf("resource_type is required")), nil } if resourceID == "" { return NewErrorResult(fmt.Errorf("resource_id is required")), nil } if hostID == "" { return NewErrorResult(fmt.Errorf("host_id is required - use the 'node' field from search or get_resource results")), nil } // For LXC and VM types, resourceID should be a numeric VMID. // If a name was passed, try to resolve it to a VMID. if (resourceType == "lxc" || resourceType == "vm") && e.stateProvider != nil { if _, err := strconv.Atoi(resourceID); err != nil { // Not a number - try to resolve the name to a VMID state := e.stateProvider.GetState() resolved := false if resourceType == "lxc" { for _, c := range state.Containers { if strings.EqualFold(c.Name, resourceID) && nodeMatchesHostID(c.Node, hostID) { resourceID = fmt.Sprintf("%d", c.VMID) resolved = true break } } } else if resourceType == "vm" { for _, vm := range state.VMs { if strings.EqualFold(vm.Name, resourceID) && nodeMatchesHostID(vm.Node, hostID) { resourceID = fmt.Sprintf("%d", vm.VMID) resolved = true break } } } if !resolved { return NewErrorResult(fmt.Errorf("could not resolve resource name '%s' to a VMID on host '%s'", resourceID, hostID)), nil } } } // First try to get existing discovery discovery, err := e.discoveryProvider.GetDiscoveryByResource(resourceType, hostID, resourceID) if err != nil { return NewErrorResult(fmt.Errorf("failed to get discovery: %w", err)), nil } // Compute CLI access pattern (always useful, even if discovery fails) cliAccess := getCLIAccessPattern(resourceType, hostID, resourceID) // If no discovery exists, trigger one if discovery == nil { discovery, err = e.discoveryProvider.TriggerDiscovery(ctx, resourceType, hostID, resourceID) if err != nil { // Distinguish transient errors (rate limits, timeouts) from genuine not-found. // Transient errors must surface as IsError so the model stops retrying. if isTransientError(err) { return CallToolResult{ Content: []Content{{ Type: "text", Text: fmt.Sprintf("Discovery temporarily unavailable: %v. Do NOT retry this call. Use pulse_control or a different approach to investigate the resource.", err), }}, IsError: true, }, nil } // Genuine failure (e.g. resource doesn't exist) — keep existing behavior return NewJSONResult(map[string]interface{}{ "found": false, "resource_type": resourceType, "resource_id": resourceID, "host_id": hostID, "cli_access": cliAccess, "message": fmt.Sprintf("Discovery failed: %v", err), "hint": "Use pulse_control with type='command' to investigate. Try checking /var/log/ for logs.", }), nil } } if discovery == nil { // No discovery but provide cli_access for manual investigation return NewJSONResult(map[string]interface{}{ "found": false, "resource_type": resourceType, "resource_id": resourceID, "host_id": hostID, "cli_access": cliAccess, "message": "Discovery returned no data. The resource may not be accessible.", "hint": "Use pulse_control with type='command' to investigate. Try listing /var/log/ or checking running processes.", }), nil } // Use fallback cli_access if discovery didn't provide one responseCLIAccess := discovery.CLIAccess if responseCLIAccess == "" { responseCLIAccess = cliAccess } // Use fallback paths for known services if discovery didn't find specific ones responseConfigPaths := discovery.ConfigPaths responseDataPaths := discovery.DataPaths var responseLogPaths []string if discovery.ServiceType != "" { fallbackLogPaths, fallbackConfigPaths, fallbackDataPaths := getCommonServicePaths(discovery.ServiceType) if len(responseConfigPaths) == 0 && len(fallbackConfigPaths) > 0 { responseConfigPaths = fallbackConfigPaths } if len(responseDataPaths) == 0 && len(fallbackDataPaths) > 0 { responseDataPaths = fallbackDataPaths } if len(fallbackLogPaths) > 0 { responseLogPaths = fallbackLogPaths } } // Return the discovery information response := map[string]interface{}{ "found": true, "id": discovery.ID, "resource_type": discovery.ResourceType, "resource_id": discovery.ResourceID, "host_id": discovery.HostID, "hostname": discovery.Hostname, "service_type": discovery.ServiceType, "service_name": discovery.ServiceName, "service_version": discovery.ServiceVersion, "category": discovery.Category, "cli_access": responseCLIAccess, "config_paths": responseConfigPaths, "data_paths": responseDataPaths, "confidence": discovery.Confidence, "discovered_at": discovery.DiscoveredAt, "updated_at": discovery.UpdatedAt, } // Add log paths if we have them (from fallback or discovery) if len(responseLogPaths) > 0 { response["log_paths"] = responseLogPaths } // Add facts if present if len(discovery.Facts) > 0 { facts := make([]map[string]string, 0, len(discovery.Facts)) for _, f := range discovery.Facts { facts = append(facts, map[string]string{ "category": f.Category, "key": f.Key, "value": f.Value, }) } response["facts"] = facts } // Add user notes if present if discovery.UserNotes != "" { response["user_notes"] = discovery.UserNotes } // Add AI reasoning for context if discovery.AIReasoning != "" { response["ai_reasoning"] = discovery.AIReasoning } // Add listening ports if present if len(discovery.Ports) > 0 { ports := make([]map[string]interface{}, 0, len(discovery.Ports)) for _, p := range discovery.Ports { port := map[string]interface{}{ "port": p.Port, "protocol": p.Protocol, } if p.Process != "" { port["process"] = p.Process } if p.Address != "" { port["address"] = p.Address } ports = append(ports, port) } response["ports"] = ports } return NewJSONResult(response), nil } // isTransientError checks whether an error is a transient API/infrastructure error // (rate limit, timeout, temporary unavailability) rather than a genuine "not found". // When true, the caller should return IsError:true so the model doesn't retry. func isTransientError(err error) bool { if err == nil { return false } msg := strings.ToLower(err.Error()) transientPatterns := []string{ "429", "503", "rate_limit", "rate limit", "ratelimit", "too many requests", "timeout", "context deadline exceeded", "failed after", // "failed after N retries" "temporarily", // "temporarily unavailable" "server overloaded", "service unavailable", "connection refused", "connection reset", "broken pipe", "i/o timeout", "network unreachable", } for _, pattern := range transientPatterns { if strings.Contains(msg, pattern) { return true } } return false } // nodeMatchesHostID checks if a node name matches a host_id which may be // a plain node name ("delly") or a composite instance-node ID ("homelab-delly"). func nodeMatchesHostID(nodeName, hostID string) bool { if strings.EqualFold(nodeName, hostID) { return true } // Check if hostID is a composite "instance-node" format ending with the node name if strings.HasSuffix(strings.ToLower(hostID), "-"+strings.ToLower(nodeName)) { return true } return false } func (e *PulseToolExecutor) executeListDiscoveries(_ context.Context, args map[string]interface{}) (CallToolResult, error) { if e.discoveryProvider == nil { return NewTextResult("Discovery service not available."), nil } filterType, _ := args["type"].(string) filterHost, _ := args["host"].(string) filterServiceType, _ := args["service_type"].(string) limit := intArg(args, "limit", 50) var discoveries []*ResourceDiscoveryInfo var err error // Get discoveries based on filters if filterType != "" { discoveries, err = e.discoveryProvider.ListDiscoveriesByType(filterType) } else if filterHost != "" { discoveries, err = e.discoveryProvider.ListDiscoveriesByHost(filterHost) } else { discoveries, err = e.discoveryProvider.ListDiscoveries() } if err != nil { return NewErrorResult(fmt.Errorf("failed to list discoveries: %w", err)), nil } // Filter by service type if specified if filterServiceType != "" { filtered := make([]*ResourceDiscoveryInfo, 0) filterLower := strings.ToLower(filterServiceType) for _, d := range discoveries { if strings.Contains(strings.ToLower(d.ServiceType), filterLower) || strings.Contains(strings.ToLower(d.ServiceName), filterLower) { filtered = append(filtered, d) } } discoveries = filtered } // Apply limit if len(discoveries) > limit { discoveries = discoveries[:limit] } // Build response results := make([]map[string]interface{}, 0, len(discoveries)) for _, d := range discoveries { result := map[string]interface{}{ "id": d.ID, "resource_type": d.ResourceType, "resource_id": d.ResourceID, "host_id": d.HostID, "hostname": d.Hostname, "service_type": d.ServiceType, "service_name": d.ServiceName, "service_version": d.ServiceVersion, "category": d.Category, "cli_access": d.CLIAccess, "confidence": d.Confidence, "updated_at": d.UpdatedAt, } // Add key facts count if len(d.Facts) > 0 { result["facts_count"] = len(d.Facts) } // Add ports count if len(d.Ports) > 0 { result["ports_count"] = len(d.Ports) } results = append(results, result) } response := map[string]interface{}{ "discoveries": results, "total": len(results), } if filterType != "" { response["filter_type"] = filterType } if filterHost != "" { response["filter_host"] = filterHost } if filterServiceType != "" { response["filter_service_type"] = filterServiceType } return NewJSONResult(response), nil }