mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-20 02:42:11 +00:00
8c8affc041
Review of 3073a5061 found the remaining registration hole: Register
rejected canonical descriptor overrides but still accepted a canonical
NAME with a nil override, inheriting the canonical descriptor while
replacing the governed handler in the map - an extension could re-register
pulse_read and bypass its execution-intent enforcement.
Registration authority is now split. registerBuiltin is the unexported
construction-time path for canonical Pulse tools: shared descriptor
mandatory, overrides rejected. RegisterExtension - the only path exposed
through PulseToolExecutor.RegisterTool - rejects every canonical tool
name outright and requires the extension to declare its own descriptor.
Both paths are append-only: a name registers exactly once, so no later
registration can swap out an already-governed handler.
Proofs cover the exact bypass (canonical name, nil override), extension
and builtin duplicate rejection, builtin override rejection, and
descriptor-less extension rejection. Tests that previously swapped
handlers by re-registering now use fresh executors per scenario.
Contract prose and source pins updated.
322 lines
12 KiB
Go
322 lines
12 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities"
|
|
"github.com/rcourtman/pulse-go-rewrite/pkg/reporting"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// registerSummarizeTools registers the pulse_summarize tool which
|
|
// exposes the reporting synthesis engine to chat sessions as a
|
|
// retrospective question-answering capability. The tool wraps the
|
|
// engine's NarrativeFor and FleetNarrativeFor entry points so
|
|
// operators can ask "what's hot on pve1 this week" or "where should
|
|
// I look across my fleet" without round-tripping through report
|
|
// generation. v1 returns heuristic narrative (the same deterministic
|
|
// observations the report PDF carries when AI is unconfigured); a
|
|
// follow-up commit will thread the per-tenant AI narrator through
|
|
// the chat session so this tool can return AI-generated synthesis
|
|
// in the same shape.
|
|
func (e *PulseToolExecutor) registerSummarizeTools() {
|
|
e.registry.registerBuiltin(RegisteredTool{
|
|
Definition: Tool{
|
|
Name: agentcapabilities.PulseSummarizeToolName,
|
|
Description: `Generate a retrospective summary of one resource or a fleet across a time window. Use this when the operator asks questions like "what's been happening with pve1 this week" or "where should I look across my fleet" — answers grounded in metric stats, alerts, storage state, disk health, and Patrol findings within the window.
|
|
|
|
Two modes via the 'action' parameter:
|
|
- "resource": summarises a single resource. Required: resource_type, resource_id.
|
|
- "fleet": summarises a fleet across multiple resources. Required: resource_ids (list).
|
|
|
|
Time window defaults to the last 7 days; supported ranges: 24h, 7d, 30d.`,
|
|
InputSchema: InputSchema{
|
|
Type: "object",
|
|
Properties: map[string]PropertySchema{
|
|
"action": {
|
|
Type: "string",
|
|
Description: "Summary scope: resource (single) or fleet (multi-resource).",
|
|
Enum: []string{"resource", "fleet"},
|
|
},
|
|
"resource_type": {
|
|
Type: "string",
|
|
Description: "For action=resource: canonical resource type (node, vm, system-container, oci-container, app-container, docker-host, storage, agent, k8s, disk, pbs, pmg). For action=fleet: optional default type when resource_ids omit per-entry type.",
|
|
},
|
|
"resource_id": {
|
|
Type: "string",
|
|
Description: "For action=resource: the resource identifier (e.g. instance:node:vmid).",
|
|
},
|
|
"resource_ids": {
|
|
Type: "string",
|
|
Description: "For action=fleet: comma-separated list of resource identifiers to include (e.g. \"instance:pve1:101,instance:pve1:102\"). Resource enumeration may be needed first if the full set is not already known.",
|
|
},
|
|
"range": {
|
|
Type: "string",
|
|
Description: "Time window: 24h, 7d, or 30d. Defaults to 7d.",
|
|
Enum: []string{"24h", "7d", "30d"},
|
|
},
|
|
},
|
|
Required: []string{"action"},
|
|
},
|
|
},
|
|
Handler: func(ctx context.Context, exec *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
|
|
return exec.executeSummarize(ctx, args)
|
|
},
|
|
Governance: ToolGovernance{
|
|
ActionMode: ToolActionRead,
|
|
ApprovalPolicy: ToolApprovalScopeOnly,
|
|
ApprovalSummary: "no approval required; pure read of metrics history and findings store.",
|
|
Summary: "Returns a retrospective synthesis (observations, recommendations, outliers, period comparison) for one resource or a fleet within a time window.",
|
|
},
|
|
})
|
|
}
|
|
|
|
// summarizeRangeWindow maps the operator-facing range token to the
|
|
// reporting catalog's supported windows. Unknown values fall back to
|
|
// the catalog default rather than erroring — the model is more
|
|
// forgiving than the API handler is required to be.
|
|
func summarizeRangeWindow(raw string) time.Duration {
|
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
|
case "24h":
|
|
return 24 * time.Hour
|
|
case "30d":
|
|
return 30 * 24 * time.Hour
|
|
case "7d", "":
|
|
return 7 * 24 * time.Hour
|
|
default:
|
|
// Unknown values are normalized to the catalog default rather
|
|
// than rejected — the chat model is the caller, and forcing it
|
|
// to retry over a typo is worse UX than silently coercing to
|
|
// the standard window.
|
|
return reporting.DescribePerformanceReport().DefaultRangeDuration()
|
|
}
|
|
}
|
|
|
|
func (e *PulseToolExecutor) executeSummarize(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
|
engine := reporting.GetEngine()
|
|
if engine == nil {
|
|
return NewErrorResult(fmt.Errorf("reporting engine not initialized")), nil
|
|
}
|
|
|
|
action, _ := args["action"].(string)
|
|
action = strings.TrimSpace(strings.ToLower(action))
|
|
|
|
rangeRaw, _ := args["range"].(string)
|
|
window := summarizeRangeWindow(rangeRaw)
|
|
end := time.Now()
|
|
start := end.Add(-window)
|
|
|
|
switch action {
|
|
case "resource":
|
|
return e.summarizeResource(ctx, engine, args, start, end)
|
|
case "fleet":
|
|
return e.summarizeFleet(ctx, engine, args, start, end)
|
|
case "":
|
|
return NewErrorResult(fmt.Errorf("'action' is required: use 'resource' or 'fleet'")), nil
|
|
default:
|
|
return NewErrorResult(fmt.Errorf("unknown action %q: use 'resource' or 'fleet'", action)), nil
|
|
}
|
|
}
|
|
|
|
type summarizeResourceResponse struct {
|
|
OK bool `json:"ok"`
|
|
Action string `json:"action"`
|
|
ResourceType string `json:"resource_type"`
|
|
ResourceID string `json:"resource_id"`
|
|
WindowStart time.Time `json:"window_start"`
|
|
WindowEnd time.Time `json:"window_end"`
|
|
NarrativeSource string `json:"narrative_source"`
|
|
HealthStatus string `json:"health_status,omitempty"`
|
|
HealthMessage string `json:"health_message,omitempty"`
|
|
Observations []reporting.NarrativeBullet `json:"observations,omitempty"`
|
|
Recommendations []string `json:"recommendations,omitempty"`
|
|
Disclaimer string `json:"disclaimer,omitempty"`
|
|
}
|
|
|
|
func (e *PulseToolExecutor) summarizeResource(
|
|
_ context.Context,
|
|
engine reporting.Engine,
|
|
args map[string]interface{},
|
|
start, end time.Time,
|
|
) (CallToolResult, error) {
|
|
resourceTypeRaw, _ := args["resource_type"].(string)
|
|
resourceID, _ := args["resource_id"].(string)
|
|
resourceTypeRaw = strings.TrimSpace(resourceTypeRaw)
|
|
resourceID = strings.TrimSpace(resourceID)
|
|
if resourceTypeRaw == "" {
|
|
return NewErrorResult(fmt.Errorf("resource_type is required for action=resource")), nil
|
|
}
|
|
if resourceID == "" {
|
|
return NewErrorResult(fmt.Errorf("resource_id is required for action=resource")), nil
|
|
}
|
|
canonicalType := reporting.CanonicalResourceType(resourceTypeRaw)
|
|
if canonicalType == "" {
|
|
return NewErrorResult(fmt.Errorf("unsupported resource_type %q", resourceTypeRaw)), nil
|
|
}
|
|
|
|
req := reporting.MetricReportRequest{
|
|
ResourceType: canonicalType,
|
|
ResourceID: resourceID,
|
|
Start: start,
|
|
End: end,
|
|
Narrator: e.reportNarrator,
|
|
FindingsProvider: e.reportFindingsProvider,
|
|
}
|
|
narrative, err := engine.NarrativeFor(req)
|
|
if err != nil {
|
|
return NewErrorResult(fmt.Errorf("narrative generation failed: %w", err)), nil
|
|
}
|
|
if narrative == nil {
|
|
return NewErrorResult(fmt.Errorf("narrative generation produced no result")), nil
|
|
}
|
|
|
|
// Telemetry: structured event line per summarize invocation so
|
|
// chat-side reporting usage can be audited alongside report-PDF
|
|
// generation. Same event-name convention as the API handlers.
|
|
log.Info().
|
|
Str("event", "reporting.summarize.invoked").
|
|
Str("org_id", e.orgID).
|
|
Str("action", "resource").
|
|
Str("resource_type", canonicalType).
|
|
Str("narrative_source", narrative.Source).
|
|
Bool("ai_configured", e.reportNarrator != nil).
|
|
Bool("findings_configured", e.reportFindingsProvider != nil).
|
|
Time("window_start", start).
|
|
Time("window_end", end).
|
|
Msg("Reporting: pulse_summarize invoked")
|
|
|
|
return NewJSONResult(summarizeResourceResponse{
|
|
OK: true,
|
|
Action: "resource",
|
|
ResourceType: canonicalType,
|
|
ResourceID: resourceID,
|
|
WindowStart: start,
|
|
WindowEnd: end,
|
|
NarrativeSource: narrative.Source,
|
|
HealthStatus: narrative.HealthStatus,
|
|
HealthMessage: narrative.HealthMessage,
|
|
Observations: narrative.Observations,
|
|
Recommendations: narrative.Recommendations,
|
|
Disclaimer: narrative.Disclaimer,
|
|
}), nil
|
|
}
|
|
|
|
type summarizeFleetResponse struct {
|
|
OK bool `json:"ok"`
|
|
Action string `json:"action"`
|
|
ResourceIDs []string `json:"resource_ids"`
|
|
WindowStart time.Time `json:"window_start"`
|
|
WindowEnd time.Time `json:"window_end"`
|
|
NarrativeSource string `json:"narrative_source"`
|
|
HealthStatus string `json:"health_status,omitempty"`
|
|
HealthMessage string `json:"health_message,omitempty"`
|
|
Outliers []reporting.FleetOutlier `json:"outliers,omitempty"`
|
|
Patterns []reporting.NarrativeBullet `json:"patterns,omitempty"`
|
|
Recommendations []string `json:"recommendations,omitempty"`
|
|
Disclaimer string `json:"disclaimer,omitempty"`
|
|
}
|
|
|
|
// summarizeFleetMaxResources caps fleet inputs so a single tool call
|
|
// can't query unbounded resources. Matches the reporting catalog's
|
|
// MultiResourceMax so the tool's contract aligns with the API's.
|
|
const summarizeFleetMaxResources = 50
|
|
|
|
func (e *PulseToolExecutor) summarizeFleet(
|
|
_ context.Context,
|
|
engine reporting.Engine,
|
|
args map[string]interface{},
|
|
start, end time.Time,
|
|
) (CallToolResult, error) {
|
|
defaultType, _ := args["resource_type"].(string)
|
|
defaultType = strings.TrimSpace(defaultType)
|
|
canonicalDefault := ""
|
|
if defaultType != "" {
|
|
canonicalDefault = reporting.CanonicalResourceType(defaultType)
|
|
if canonicalDefault == "" {
|
|
return NewErrorResult(fmt.Errorf("unsupported resource_type %q", defaultType)), nil
|
|
}
|
|
}
|
|
|
|
rawIDs, _ := args["resource_ids"].(string)
|
|
rawIDs = strings.TrimSpace(rawIDs)
|
|
if rawIDs == "" {
|
|
return NewErrorResult(fmt.Errorf("resource_ids (comma-separated) is required for action=fleet")), nil
|
|
}
|
|
if canonicalDefault == "" {
|
|
return NewErrorResult(fmt.Errorf("resource_type is required for action=fleet")), nil
|
|
}
|
|
parts := strings.Split(rawIDs, ",")
|
|
if len(parts) > summarizeFleetMaxResources {
|
|
return NewErrorResult(fmt.Errorf("fleet summarize accepts at most %d resources; got %d", summarizeFleetMaxResources, len(parts))), nil
|
|
}
|
|
resources := make([]reporting.MetricReportRequest, 0, len(parts))
|
|
ids := make([]string, 0, len(parts))
|
|
seen := make(map[string]struct{}, len(parts))
|
|
for _, raw := range parts {
|
|
s := strings.TrimSpace(raw)
|
|
if s == "" {
|
|
continue
|
|
}
|
|
if _, dup := seen[s]; dup {
|
|
continue
|
|
}
|
|
seen[s] = struct{}{}
|
|
resources = append(resources, reporting.MetricReportRequest{
|
|
ResourceType: canonicalDefault,
|
|
ResourceID: s,
|
|
})
|
|
ids = append(ids, s)
|
|
}
|
|
if len(resources) == 0 {
|
|
return NewErrorResult(fmt.Errorf("resource_ids parsed to zero non-empty identifiers")), nil
|
|
}
|
|
|
|
req := reporting.MultiReportRequest{
|
|
Title: "Fleet summary",
|
|
Start: start,
|
|
End: end,
|
|
Resources: resources,
|
|
FleetNarrator: e.reportFleetNarrator,
|
|
Narrator: e.reportNarrator,
|
|
FindingsProvider: e.reportFindingsProvider,
|
|
}
|
|
narrative, err := engine.FleetNarrativeFor(req)
|
|
if err != nil {
|
|
return NewErrorResult(fmt.Errorf("fleet narrative generation failed: %w", err)), nil
|
|
}
|
|
if narrative == nil {
|
|
return NewErrorResult(fmt.Errorf("fleet narrative generation produced no result")), nil
|
|
}
|
|
|
|
log.Info().
|
|
Str("event", "reporting.summarize.invoked").
|
|
Str("org_id", e.orgID).
|
|
Str("action", "fleet").
|
|
Str("resource_type", canonicalDefault).
|
|
Int("resource_count", len(ids)).
|
|
Str("narrative_source", narrative.Source).
|
|
Bool("ai_configured", e.reportFleetNarrator != nil).
|
|
Bool("findings_configured", e.reportFindingsProvider != nil).
|
|
Time("window_start", start).
|
|
Time("window_end", end).
|
|
Msg("Reporting: pulse_summarize invoked")
|
|
|
|
return NewJSONResult(summarizeFleetResponse{
|
|
OK: true,
|
|
Action: "fleet",
|
|
ResourceIDs: ids,
|
|
WindowStart: start,
|
|
WindowEnd: end,
|
|
NarrativeSource: narrative.Source,
|
|
HealthStatus: narrative.HealthStatus,
|
|
HealthMessage: narrative.HealthMessage,
|
|
Outliers: narrative.Outliers,
|
|
Patterns: narrative.Patterns,
|
|
Recommendations: narrative.Recommendations,
|
|
Disclaimer: narrative.Disclaimer,
|
|
}), nil
|
|
}
|