mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-22 03:35:52 +00:00
f530c76d17
Extend the patrol_report_finding LLM tool schema with an optional impact parameter and propagate it through PatrolFindingInput, patrolFindingCreatorAdapter.CreateFinding, and into Finding.Impact so LLM-authored Patrol findings carry consequence-if-ignored copy at detection time alongside the curated catalogs already in place for runtime failures and threshold alerts. Updates the patrol system prompt with an "Authoring Impact" section that instructs the LLM to write concrete operational consequences (named workloads, jobs, recovery windows) rather than echoing severity or category, and to leave impact empty rather than fabricate a consequence when one is genuinely unknown. The eval-pass prompt gets a shorter version of the same guidance. Adds two unit tests: one covering an authored impact passing through to PatrolFindingInput.Impact, and one covering the omitted-impact case where the contract is honored verbatim with no synthesized default. Updates the ai-runtime contract to pin the tool-schema authoring rule and the no-fabrication invariant.
311 lines
11 KiB
Go
311 lines
11 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// registerPatrolTools registers the three patrol-specific tools.
|
|
// These tools are only functional during a patrol run when patrolFindingCreator is set.
|
|
func (e *PulseToolExecutor) registerPatrolTools() {
|
|
// patrol_report_finding — LLM calls this to create a finding
|
|
e.registry.Register(RegisteredTool{
|
|
Definition: Tool{
|
|
Name: "patrol_report_finding",
|
|
Description: `Report an infrastructure finding discovered during patrol investigation.
|
|
|
|
Call this tool to create a structured finding after you have gathered sufficient evidence via investigation tools.
|
|
The finding will be validated against current metrics and deduplicated automatically.
|
|
|
|
Returns: {"ok": true, "finding_id": "...", "is_new": true/false} on success.`,
|
|
InputSchema: InputSchema{
|
|
Type: "object",
|
|
Properties: map[string]PropertySchema{
|
|
"key": {
|
|
Type: "string",
|
|
Description: "Stable issue key for deduplication (e.g. high-cpu, high-memory, high-disk, backup-stale, backup-never, storage-high-usage, node-offline, restart-loop, pbs-job-failed)",
|
|
},
|
|
"severity": {
|
|
Type: "string",
|
|
Description: "Finding severity level. Only report actionable issues — observations and minor notes belong in your analysis text, not as findings.",
|
|
Enum: []string{"critical", "warning"},
|
|
},
|
|
"category": {
|
|
Type: "string",
|
|
Description: "Finding category",
|
|
Enum: []string{"performance", "capacity", "reliability", "backup", "security", "general"},
|
|
},
|
|
"resource_id": {
|
|
Type: "string",
|
|
Description: "Resource ID (e.g. node/pve1, vm/100, ct/101)",
|
|
},
|
|
"resource_name": {
|
|
Type: "string",
|
|
Description: "Human-readable resource name",
|
|
},
|
|
"resource_type": {
|
|
Type: "string",
|
|
Description: "Canonical v6 resource type",
|
|
Enum: []string{"node", "vm", "system-container", "app-container", "storage", "physical_disk", "agent", "k8s-cluster", "pbs"},
|
|
},
|
|
"title": {
|
|
Type: "string",
|
|
Description: "Brief finding title (1 sentence)",
|
|
},
|
|
"description": {
|
|
Type: "string",
|
|
Description: "Detailed description of the issue found",
|
|
},
|
|
"impact": {
|
|
Type: "string",
|
|
Description: "Operator-facing consequence-if-ignored statement: what specifically happens, and to which workloads or jobs, if the operator does nothing. Concrete operational consequences only — do not echo severity or category. Leave empty if the consequence is genuinely unknown rather than fabricating one.",
|
|
},
|
|
"recommendation": {
|
|
Type: "string",
|
|
Description: "Specific actionable recommendation for the operator",
|
|
},
|
|
"evidence": {
|
|
Type: "string",
|
|
Description: "Specific data/metrics/commands that support this finding",
|
|
},
|
|
},
|
|
Required: []string{"key", "severity", "category", "resource_id", "resource_name", "resource_type", "title", "description"},
|
|
},
|
|
},
|
|
Handler: handlePatrolReportFinding,
|
|
Governance: ToolGovernance{
|
|
ActionMode: ToolActionWrite,
|
|
ApprovalPolicy: "patrol-only; records a governed finding after evidence collection",
|
|
Summary: "Creates a structured patrol finding during autonomous investigation.",
|
|
},
|
|
})
|
|
|
|
// patrol_resolve_finding — LLM calls this to resolve an active finding
|
|
e.registry.Register(RegisteredTool{
|
|
Definition: Tool{
|
|
Name: "patrol_resolve_finding",
|
|
Description: `Resolve an active patrol finding after verifying the issue is no longer present.
|
|
|
|
Call this after investigating an existing finding and confirming the issue has been resolved.
|
|
Returns: {"ok": true, "resolved": true} on success.`,
|
|
InputSchema: InputSchema{
|
|
Type: "object",
|
|
Properties: map[string]PropertySchema{
|
|
"finding_id": {
|
|
Type: "string",
|
|
Description: "The ID of the finding to resolve",
|
|
},
|
|
"reason": {
|
|
Type: "string",
|
|
Description: "Brief explanation of why this finding is being resolved (e.g. 'CPU has returned to 35%, well below threshold')",
|
|
},
|
|
},
|
|
Required: []string{"finding_id", "reason"},
|
|
},
|
|
},
|
|
Handler: handlePatrolResolveFinding,
|
|
Governance: ToolGovernance{
|
|
ActionMode: ToolActionWrite,
|
|
ApprovalPolicy: "patrol-only; resolves a finding after verification",
|
|
Summary: "Marks a patrol finding resolved after current evidence supports closure.",
|
|
},
|
|
})
|
|
|
|
// patrol_get_findings — LLM calls this to check existing active findings
|
|
e.registry.Register(RegisteredTool{
|
|
Definition: Tool{
|
|
Name: "patrol_get_findings",
|
|
Description: `Get currently active patrol findings. Use this to check what findings already exist before reporting new ones (avoids duplicates) and to identify findings that may need resolution.
|
|
|
|
Returns a list of active findings with their IDs, severity, resource, and title.`,
|
|
InputSchema: InputSchema{
|
|
Type: "object",
|
|
Properties: map[string]PropertySchema{
|
|
"resource_id": {
|
|
Type: "string",
|
|
Description: "Filter by resource ID (optional)",
|
|
},
|
|
"severity": {
|
|
Type: "string",
|
|
Description: "Minimum severity to include (optional, default: info)",
|
|
Enum: []string{"info", "watch", "warning", "critical"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
Handler: handlePatrolGetFindings,
|
|
Governance: ToolGovernance{
|
|
ActionMode: ToolActionRead,
|
|
ApprovalPolicy: "no approval required",
|
|
Summary: "Reads active patrol findings for deduplication and investigation context.",
|
|
},
|
|
})
|
|
}
|
|
|
|
func handlePatrolReportFinding(_ context.Context, e *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
|
|
creator := e.GetPatrolFindingCreator()
|
|
if creator == nil {
|
|
return NewTextResult("patrol_report_finding is only available during a patrol run."), nil
|
|
}
|
|
if checker, ok := creator.(PatrolFindingsChecker); ok && !checker.HasCheckedFindings() {
|
|
return NewErrorResult(fmt.Errorf("call patrol_get_findings before reporting a finding")), nil
|
|
}
|
|
|
|
// Extract required fields
|
|
key, _ := args["key"].(string)
|
|
severity, _ := args["severity"].(string)
|
|
category, _ := args["category"].(string)
|
|
resourceID, _ := args["resource_id"].(string)
|
|
resourceName, _ := args["resource_name"].(string)
|
|
resourceType, _ := args["resource_type"].(string)
|
|
resourceType = canonicalPatrolResourceType(resourceType)
|
|
title, _ := args["title"].(string)
|
|
description, _ := args["description"].(string)
|
|
|
|
// Validate required fields
|
|
var missing []string
|
|
if key == "" {
|
|
missing = append(missing, "key")
|
|
}
|
|
if severity == "" {
|
|
missing = append(missing, "severity")
|
|
}
|
|
if category == "" {
|
|
missing = append(missing, "category")
|
|
}
|
|
if resourceID == "" {
|
|
missing = append(missing, "resource_id")
|
|
}
|
|
if resourceName == "" {
|
|
missing = append(missing, "resource_name")
|
|
}
|
|
if resourceType == "" {
|
|
missing = append(missing, "resource_type")
|
|
}
|
|
if title == "" {
|
|
missing = append(missing, "title")
|
|
}
|
|
if description == "" {
|
|
missing = append(missing, "description")
|
|
}
|
|
if len(missing) > 0 {
|
|
return NewErrorResult(fmt.Errorf("missing required fields: %s", strings.Join(missing, ", "))), nil
|
|
}
|
|
if isUnsupportedLegacyResourceTypeToken(resourceType) {
|
|
return NewErrorResult(fmt.Errorf("unsupported resource_type %q", resourceType)), nil
|
|
}
|
|
validResourceTypes := map[string]bool{
|
|
"node": true,
|
|
"vm": true,
|
|
"system-container": true,
|
|
"app-container": true,
|
|
"storage": true,
|
|
"physical_disk": true,
|
|
"agent": true,
|
|
"k8s-cluster": true,
|
|
"pbs": true,
|
|
}
|
|
if !validResourceTypes[resourceType] {
|
|
return NewErrorResult(fmt.Errorf("unsupported resource_type %q: use node, vm, system-container, app-container, storage, physical_disk, agent, k8s-cluster, or pbs", resourceType)), nil
|
|
}
|
|
|
|
// Validate enums
|
|
validSeverities := map[string]bool{"critical": true, "warning": true}
|
|
if !validSeverities[severity] {
|
|
return NewErrorResult(fmt.Errorf("invalid severity %q: must be critical or warning. For minor observations, include them in your analysis text instead of creating a finding", severity)), nil
|
|
}
|
|
validCategories := map[string]bool{"performance": true, "capacity": true, "reliability": true, "backup": true, "security": true, "general": true}
|
|
if !validCategories[category] {
|
|
return NewErrorResult(fmt.Errorf("invalid category %q: must be performance, capacity, reliability, backup, security, or general", category)), nil
|
|
}
|
|
|
|
// Extract optional fields
|
|
impact, _ := args["impact"].(string)
|
|
recommendation, _ := args["recommendation"].(string)
|
|
evidence, _ := args["evidence"].(string)
|
|
|
|
input := PatrolFindingInput{
|
|
Key: key,
|
|
Severity: severity,
|
|
Category: category,
|
|
ResourceID: resourceID,
|
|
ResourceName: resourceName,
|
|
ResourceType: resourceType,
|
|
Title: title,
|
|
Description: description,
|
|
Impact: impact,
|
|
Recommendation: recommendation,
|
|
Evidence: evidence,
|
|
}
|
|
|
|
findingID, isNew, err := creator.CreateFinding(input)
|
|
if err != nil {
|
|
return NewErrorResult(fmt.Errorf("failed to create finding: %w", err)), nil
|
|
}
|
|
|
|
result := map[string]interface{}{
|
|
"ok": true,
|
|
"finding_id": findingID,
|
|
"is_new": isNew,
|
|
}
|
|
b, _ := json.Marshal(result)
|
|
return NewTextResult(string(b)), nil
|
|
}
|
|
|
|
func handlePatrolResolveFinding(_ context.Context, e *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
|
|
creator := e.GetPatrolFindingCreator()
|
|
if creator == nil {
|
|
return NewTextResult("patrol_resolve_finding is only available during a patrol run."), nil
|
|
}
|
|
if checker, ok := creator.(PatrolFindingsChecker); ok && !checker.HasCheckedFindings() {
|
|
return NewErrorResult(fmt.Errorf("call patrol_get_findings before resolving a finding")), nil
|
|
}
|
|
|
|
findingID, _ := args["finding_id"].(string)
|
|
reason, _ := args["reason"].(string)
|
|
|
|
if findingID == "" {
|
|
return NewErrorResult(fmt.Errorf("missing required field: finding_id")), nil
|
|
}
|
|
if reason == "" {
|
|
return NewErrorResult(fmt.Errorf("missing required field: reason")), nil
|
|
}
|
|
|
|
if err := creator.ResolveFinding(findingID, reason); err != nil {
|
|
return NewErrorResult(fmt.Errorf("failed to resolve finding: %w", err)), nil
|
|
}
|
|
|
|
result := map[string]interface{}{
|
|
"ok": true,
|
|
"resolved": true,
|
|
}
|
|
b, _ := json.Marshal(result)
|
|
return NewTextResult(string(b)), nil
|
|
}
|
|
|
|
func handlePatrolGetFindings(_ context.Context, e *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
|
|
creator := e.GetPatrolFindingCreator()
|
|
if creator == nil {
|
|
return NewTextResult("patrol_get_findings is only available during a patrol run."), nil
|
|
}
|
|
|
|
resourceID, _ := args["resource_id"].(string)
|
|
minSeverity, _ := args["severity"].(string)
|
|
|
|
findings := creator.GetActiveFindings(resourceID, minSeverity)
|
|
|
|
result := map[string]interface{}{
|
|
"ok": true,
|
|
"count": len(findings),
|
|
"findings": findings,
|
|
}
|
|
b, _ := json.Marshal(result)
|
|
return NewTextResult(string(b)), nil
|
|
}
|
|
|
|
func canonicalPatrolResourceType(resourceType string) string {
|
|
return strings.ToLower(strings.TrimSpace(resourceType))
|
|
}
|