feat(ai): make pulse_summarize self-target and forbid identifier elicitation

A fresh install's natural first question ("how is my machine doing?",
observed live with Ollama qwen3:8b) made the model call pulse_summarize
action=fleet without resource_ids; the tool errored and the agentic loop
surfaced a structured question asking a first-run user for "the
comma-separated list of resource IDs" — pure jargon, funnel-killing
(GitHub discussion #1042). Two root fixes:

pulse_summarize self-targets. action=fleet with resource_ids omitted now
enumerates the known fleet from the executor's unified resource provider
(infrastructure parents first, then guests, then storage; deduped;
bounded at the existing 50-resource cap with a truncation note). Both
modes resolve what models actually pass — canonical unified IDs and
unambiguous names — onto the reporting request shape the way the API
path's resolveReportSubject does: the canonical ID stays ResourceID
(findings/recovery keying) and the resolved metrics target rides
MetricsResourceID, so store queries find data instead of silently
returning zero points. Reporting types are classified from the unified
resource (agent-backed hosts "agent", pure Proxmox nodes "node" — the
documented target-type exception — Docker hosts "docker-host");
resource_type becomes an optional filter/default. Remaining error paths
instruct the model to enumerate or retry and forbid asking the operator
for resource IDs.

Ask-user policy covers tool-argument recovery. The system prompt's
resolve-before-asking section (945e2198's target policy) now extends to
failed tool calls: missing/invalid arguments are self-recovered
(enumerate, retry), and internal identifiers are never valid questions.
The pulse_question description carries the same prohibition so the
structured clarification surface cannot become an identifier elicitation
channel on small local models.

ai-runtime and api-contracts contracts updated in-commit (the
subsystem_lookup line pin follows the api-contracts insertion); full
./internal/ai/... and ./internal/agentcapabilities/... suites green.
This commit is contained in:
rcourtman 2026-07-17 23:46:27 +01:00
parent b5d852a279
commit a3f8b18bfb
9 changed files with 633 additions and 49 deletions

View file

@ -4081,6 +4081,21 @@ target. `TestBuildSystemPrompt_CurrentResourceRequiresResourceHandoff` pins
the boundary strings; the full Extension-Points entry sits beside the
model-owned tool-manifest rule.
Resolve-before-asking extends to tool-argument recovery: when a tool call
fails because an argument is missing or invalid, the prompt directs the model
to recover itself — enumerate the missing values with read-only tools or
retry with the arguments the error names — rather than converting the
failure into a user question. Internal identifiers (resource IDs, UUIDs,
metric keys) are never valid things to ask the operator for; the
`pulse_question` provider-tool description carries the same prohibition so
the structured clarification surface cannot be used as an identifier
elicitation channel (observed on small local models: a first-run
`pulse_summarize` failure became a "comma-separated resource IDs" question
to a brand-new user). Questions stay reserved for preferences, risky
choices, and genuine ambiguity between named options. The same prompt-pin
test and `TestNewPulseQuestionProviderToolBuildsSharedAssistantTool` hold
both strings.
The per-turn Assistant system prompt carries the current wall-clock time (the
Pulse server clock) so the Assistant answers "what time/date is it" directly
instead of deflecting ("I don't have access to a real-time clock") or demanding
@ -6268,7 +6283,40 @@ points (single-resource and fleet modes selected by an `action`
parameter) so an operator can ask "what's been happening with
pve1 this week" or "where should I look across my fleet" and get
a structured retrospective answer in chat rather than having to
generate, download, and read a PDF. The tool is read-only (no
generate, download, and read a PDF.
The tool is self-targeting: `action=fleet` with `resource_ids`
omitted enumerates the known fleet from the executor's unified
resource provider itself (infrastructure parents first, then
guests, then storage; deduped; bounded by the same 50-resource cap
as the API, with an explicit truncation note in the response)
instead of erroring. This exists because the natural first-session
question — "how is my machine doing?" on a fresh install with a
small local model — produced a `resource_ids is required` failure
that the agentic loop converted into a jargon elicitation asking
the operator for "comma-separated resource IDs" (the GitHub
discussion #1042 funnel-killer). Both modes also resolve the
references models actually pass: canonical unified IDs and plain
resource names (unambiguous, case-insensitive) are translated onto
the reporting request shape the way the API report path's
`resolveReportSubject` does — the canonical ID stays the request
`ResourceID` (Patrol findings and recovery points key on it) while
the resolved metrics target rides `MetricsResourceID`, so the
engine's store queries actually find data instead of silently
returning zero points. Reporting types are classified from the
unified resource (agent-backed hosts as `agent`, pure Proxmox
nodes as `node` — the documented exception where the metrics
target labels the agent family but node metrics live under the
`node` store type — pure Docker hosts as `docker-host`), and the
resolved target's type wins elsewhere. `resource_type` is now an
optional filter/default for fleet mode and only required for
`action=resource` when the identifier is not a known resource.
Every remaining error path in the tool tells the model to
enumerate or retry and explicitly forbids asking the operator for
resource IDs. `TestSummarizeTool_FleetEnumeratesWhenIDsOmitted`,
`TestSummarizeTool_FleetResolvesNamesAndTranslatesMetricsIDs`, and
the compile-time `summarizeMetricsTargetResolver` pin on the
monitor adapter hold this behavior. The tool is read-only (no
approval gate, no control-level requirement) and returns a JSON
envelope carrying the narrative source, health status, observations
or outliers, recommendations, and provenance disclaimer. v1 always

View file

@ -467,7 +467,15 @@ than a UI or adapter inference. `internal/agentcapabilities.SurfaceToolContract`
and `ProjectPulseIntelligenceSurfaceToolContracts` must describe Pulse
Assistant tools as `assistant_registry` sourced provider tools, splitting
registry-backed tools from Assistant-native interaction tools such as
`pulse_question`, while manifest-declared external-adapter tools are
`pulse_question`. The shared `pulse_question` declaration
(`NewPulseQuestionProviderTool`) must keep its scope boundary in the tool
description itself: structured clarification is for preferences, risky
choices, and ambiguity between named options, and must never be used to ask
the operator for internal identifiers (resource IDs, UUIDs) that read-only
tools can resolve — small local models otherwise convert
missing-required-argument tool failures into identifier elicitations aimed
at first-run users (`TestNewPulseQuestionProviderToolBuildsSharedAssistantTool`
pins the prohibition string). Manifest-declared external-adapter tools are
`capability_manifest` sourced request/response capabilities projected from
`Manifest.Capabilities` by `ProjectManifestSurfaceToolContracts` and published
on `Manifest.SurfaceToolContracts`. External surfaces, including Pulse MCP,

View file

@ -226,7 +226,7 @@ func NormalizeProviderToolCallsForExecution(calls []ProviderToolCall) []Provider
func NewPulseQuestionProviderTool() ProviderTool {
return ProviderTool{
Name: PulseQuestionToolName,
Description: "Ask the user for missing information using a structured prompt. Use this when you must clarify before proceeding (e.g., choose a target, confirm a risky action, or select among options).",
Description: "Ask the user for missing information using a structured prompt. Use this when you must clarify before proceeding (e.g., choose a target, confirm a risky action, or select among options). Never use it to ask for internal identifiers such as resource IDs or UUIDs — resolve those yourself with read-only tools and retry.",
InputSchema: PulseQuestionProviderInputSchema(),
}.NormalizeCollections()
}

View file

@ -629,6 +629,9 @@ func TestNewPulseQuestionProviderToolBuildsSharedAssistantTool(t *testing.T) {
if !strings.Contains(tool.Description, "Ask the user for missing information") {
t.Fatalf("question provider tool description = %q", tool.Description)
}
if !strings.Contains(tool.Description, "Never use it to ask for internal identifiers") {
t.Fatalf("question provider tool description must forbid asking for internal identifiers, got %q", tool.Description)
}
if tool.InputSchema["type"] != "object" {
t.Fatalf("question provider input_schema type = %v, want object", tool.InputSchema["type"])
}

View file

@ -3813,6 +3813,7 @@ func (s *Service) buildSystemPromptWithToolGovernance(toolGovernance string) str
- Status checks and monitoring are read-oriented; do not change state unless the user asked for a state change.
- Resolve a missing target yourself before asking: use read-only query/topology tools to see which hosts, VMs, or containers exist. If exactly one plausible target matches the request (a single connected host, or one resource by that name), run read-only diagnostics against it and name the target in your answer instead of asking.
- Ask for the target only when several plausible targets remain after looking, or when the action changes state. If a structured clarification tool is offered, use it for missing critical information (target, risky choice, preference); otherwise ask in normal assistant text.
- The same rule covers tool arguments: when a tool call fails because an argument is missing or invalid, recover yourself enumerate the missing values with read-only tools (or retry the tool with the arguments it says it accepts) instead of asking the user. Never ask the user for internal identifiers such as resource IDs, UUIDs, or metric keys; users do not know them, and questions are reserved for preferences, risky choices, and genuine ambiguity between named options.
- Never guess a target you did not resolve. Do not attempt a tool call with current_resource or another placeholder as a stand-in for a missing target in autonomous mode the same rules apply: resolve with read-only tools first, then ask in normal assistant text if genuine ambiguity remains.
## HOW TO RESPOND

View file

@ -373,6 +373,11 @@ func TestBuildSystemPrompt_CurrentResourceRequiresResourceHandoff(t *testing.T)
"Ask for the target only when several plausible targets remain after looking, or when the action changes state",
"Never guess a target you did not resolve",
"Do not attempt a tool call with current_resource or another placeholder",
// Tool-argument recovery: a missing/invalid-argument tool failure is
// self-recovered (enumerate, then retry), never converted into a user
// question about internal identifiers a first-run operator cannot know.
"when a tool call fails because an argument is missing or invalid, recover yourself",
"Never ask the user for internal identifiers such as resource IDs, UUIDs, or metric keys",
} {
if !strings.Contains(prompt, expected) {
t.Fatalf("expected current_resource boundary %q in system prompt, got %q", expected, prompt)

View file

@ -7,6 +7,7 @@ import (
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rcourtman/pulse-go-rewrite/pkg/reporting"
"github.com/rs/zerolog/log"
)
@ -29,8 +30,8 @@ func (e *PulseToolExecutor) registerSummarizeTools() {
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).
- "resource": summarises a single resource. Required: resource_id (ID or name); resource_type only when the ID is not a known resource.
- "fleet": summarises a fleet across multiple resources. resource_ids is optional omit it and the tool enumerates the known fleet itself (infrastructure first, bounded). Never ask the operator for resource IDs.
Time window defaults to the last 7 days; supported ranges: 24h, 7d, 30d.`,
InputSchema: InputSchema{
@ -43,15 +44,15 @@ Time window defaults to the last 7 days; supported ranges: 24h, 7d, 30d.`,
},
"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.",
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 filter (when enumerating) or default type for unrecognized resource_ids entries.",
},
"resource_id": {
Type: "string",
Description: "For action=resource: the resource identifier (e.g. instance:node:vmid).",
Description: "For action=resource: the resource identifier or name (e.g. instance:node:vmid, or a host name).",
},
"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.",
Description: "For action=fleet: optional comma-separated list of resource identifiers or names to include (e.g. \"instance:pve1:101,instance:pve1:102\"). Omit to summarize every known resource (bounded).",
},
"range": {
Type: "string",
@ -146,25 +147,43 @@ func (e *PulseToolExecutor) summarizeResource(
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,
// Resolve the reference against the unified registry first: models pass
// canonical IDs and plain names, both of which need translation onto the
// metrics-store ID space before the engine can find any data points.
var req reporting.MetricReportRequest
canonicalType := ""
if cand, ok := e.buildSummarizeCandidateIndex().lookup(resourceID); ok {
canonicalType = cand.reportType
req = reporting.MetricReportRequest{
ResourceType: cand.reportType,
ResourceID: cand.id,
MetricsResourceID: cand.metricsID,
}
resourceID = cand.id
if cand.name != "" {
req.Resource = &reporting.ResourceInfo{Name: cand.name, Status: cand.status}
}
} else {
if resourceTypeRaw == "" {
return NewErrorResult(fmt.Errorf("resource_type is required for action=resource when resource_id %q does not match a known resource", resourceID)), nil
}
canonicalType = reporting.CanonicalResourceType(resourceTypeRaw)
if canonicalType == "" {
return NewErrorResult(fmt.Errorf("unsupported resource_type %q", resourceTypeRaw)), nil
}
req = reporting.MetricReportRequest{
ResourceType: canonicalType,
ResourceID: resourceID,
}
}
req.Start = start
req.End = end
req.Narrator = e.reportNarrator
req.FindingsProvider = e.reportFindingsProvider
narrative, err := engine.NarrativeFor(req)
if err != nil {
return NewErrorResult(fmt.Errorf("narrative generation failed: %w", err)), nil
@ -208,6 +227,9 @@ type summarizeFleetResponse struct {
OK bool `json:"ok"`
Action string `json:"action"`
ResourceIDs []string `json:"resource_ids"`
Resources []summarizeFleetEntry `json:"resources,omitempty"`
Enumerated bool `json:"enumerated,omitempty"`
Note string `json:"note,omitempty"`
WindowStart time.Time `json:"window_start"`
WindowEnd time.Time `json:"window_end"`
NarrativeSource string `json:"narrative_source"`
@ -219,6 +241,210 @@ type summarizeFleetResponse struct {
Disclaimer string `json:"disclaimer,omitempty"`
}
// summarizeFleetEntry names one fleet member in the response so the model can
// narrate outliers with human-readable identities instead of opaque IDs.
type summarizeFleetEntry struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name,omitempty"`
}
// summarizeMetricsTargetResolver is the optional capability the unified
// resource provider exposes for translating canonical resource IDs into
// metrics-store query targets. The production provider (the monitor's
// unified adapter) implements it; lightweight fixtures may not.
type summarizeMetricsTargetResolver interface {
MetricsTargetForResource(resourceID string) *unifiedresources.MetricsTarget
}
// summarizeFleetCandidate is a unified resource resolved into the reporting
// engine's request shape: a reporting resource type, the canonical resource
// ID (which keys Patrol findings and recovery points), and the metrics-store
// query ID when the resource's metrics target differs from the canonical ID.
type summarizeFleetCandidate struct {
reportType string
id string
name string
status string
metricsID string
}
// summarizeFleetEnumerationOrder lists the unified resource types included in
// a self-enumerated fleet summary, highest-signal first: infrastructure
// parents, then guests, then storage. The order decides what survives the
// summarizeFleetMaxResources cap.
var summarizeFleetEnumerationOrder = []unifiedresources.ResourceType{
unifiedresources.ResourceTypeAgent,
unifiedresources.ResourceTypePBS,
unifiedresources.ResourceTypePMG,
unifiedresources.ResourceTypeK8sCluster,
unifiedresources.ResourceTypeVM,
unifiedresources.ResourceTypeSystemContainer,
unifiedresources.ResourceTypeAppContainer,
unifiedresources.ResourceTypeStorage,
}
// summarizeReportTypeForResource maps a unified resource to the reporting
// engine's canonical resource-type vocabulary. Host-flavored resources
// (unified type "agent") are classified by their richest data source the same
// way the platform pages type them: agent-backed hosts report as "agent",
// pure Proxmox nodes as "node", pure Docker hosts as "docker-host".
func summarizeReportTypeForResource(res unifiedresources.Resource) string {
switch unifiedresources.CanonicalResourceType(res.Type) {
case unifiedresources.ResourceTypeAgent:
switch {
case res.Agent != nil:
return "agent"
case res.Proxmox != nil:
return "node"
case res.Docker != nil:
return "docker-host"
default:
return "agent"
}
case unifiedresources.ResourceTypeVM:
return "vm"
case unifiedresources.ResourceTypeSystemContainer:
return "system-container"
case unifiedresources.ResourceTypeAppContainer:
return "app-container"
case unifiedresources.ResourceTypeK8sCluster:
return "k8s"
case unifiedresources.ResourceTypeStorage:
return "storage"
case unifiedresources.ResourceTypePBS:
return "pbs"
case unifiedresources.ResourceTypePMG:
return "pmg"
case unifiedresources.ResourceTypePhysicalDisk:
return "disk"
case unifiedresources.ResourceTypePod:
return "pod"
default:
return ""
}
}
// summarizeMetricsTarget resolves the metrics-store target for a unified
// resource, asking the provider's on-demand resolver first (registry targets
// are computed lazily; the struct field is only populated by fixtures).
func (e *PulseToolExecutor) summarizeMetricsTarget(res unifiedresources.Resource) *unifiedresources.MetricsTarget {
if resolver, ok := e.unifiedResourceProvider.(summarizeMetricsTargetResolver); ok && resolver != nil {
if target := resolver.MetricsTargetForResource(res.ID); target != nil {
return target
}
}
return res.MetricsTarget
}
// summarizeFleetCandidateFor projects a unified resource into a fleet
// candidate, mirroring the API report path's resolveReportSubject: the
// canonical ID stays the request ResourceID (findings and recovery points key
// on it) while the metrics target rides MetricsResourceID. When the resolved
// target's type is one reporting understands, it wins over the static
// classification — merged host resources advertise the store family their
// metrics are actually written under. Pure Proxmox nodes are the documented
// exception: their metrics live under the "node" store type while the target
// labels the agent family, so the node classification is kept there.
func (e *PulseToolExecutor) summarizeFleetCandidateFor(res unifiedresources.Resource) (summarizeFleetCandidate, bool) {
id := strings.TrimSpace(res.ID)
reportType := summarizeReportTypeForResource(res)
if id == "" || reportType == "" {
return summarizeFleetCandidate{}, false
}
cand := summarizeFleetCandidate{
reportType: reportType,
id: id,
name: strings.TrimSpace(res.Name),
status: string(res.Status),
}
if target := e.summarizeMetricsTarget(res); target != nil {
cand.metricsID = strings.TrimSpace(target.ResourceID)
if reportType != "node" {
if canonical := reporting.CanonicalResourceType(target.ResourceType); canonical != "" {
cand.reportType = canonical
}
}
}
return cand, true
}
// enumerateSummarizeFleet walks the unified resource provider in priority
// order and returns deduped fleet candidates, optionally filtered to one
// reporting type. Returns nil when no provider is wired.
func (e *PulseToolExecutor) enumerateSummarizeFleet(filterType string) []summarizeFleetCandidate {
if e.unifiedResourceProvider == nil {
return nil
}
seen := make(map[string]struct{})
var out []summarizeFleetCandidate
for _, resourceType := range summarizeFleetEnumerationOrder {
for _, res := range e.unifiedResourceProvider.GetByType(resourceType) {
cand, ok := e.summarizeFleetCandidateFor(res)
if !ok {
continue
}
if filterType != "" && cand.reportType != filterType {
continue
}
if _, dup := seen[cand.id]; dup {
continue
}
seen[cand.id] = struct{}{}
out = append(out, cand)
}
}
return out
}
// summarizeCandidateIndex indexes every enumerable resource by canonical ID
// and, when unambiguous, by lowercased name — operators and models refer to
// resources by name far more readily than by internal identifier. ID matches
// always win over name matches.
type summarizeCandidateIndex struct {
byID map[string]summarizeFleetCandidate
byName map[string]summarizeFleetCandidate
}
func (idx *summarizeCandidateIndex) lookup(ref string) (summarizeFleetCandidate, bool) {
if idx == nil {
return summarizeFleetCandidate{}, false
}
if cand, ok := idx.byID[ref]; ok {
return cand, true
}
cand, ok := idx.byName[strings.ToLower(ref)]
return cand, ok
}
func (e *PulseToolExecutor) buildSummarizeCandidateIndex() *summarizeCandidateIndex {
candidates := e.enumerateSummarizeFleet("")
if len(candidates) == 0 {
return nil
}
idx := &summarizeCandidateIndex{
byID: make(map[string]summarizeFleetCandidate, len(candidates)),
byName: make(map[string]summarizeFleetCandidate, len(candidates)),
}
ambiguous := make(map[string]struct{})
for _, cand := range candidates {
idx.byID[cand.id] = cand
name := strings.ToLower(cand.name)
if name == "" {
continue
}
if _, dup := idx.byName[name]; dup {
ambiguous[name] = struct{}{}
continue
}
idx.byName[name] = cand
}
for name := range ambiguous {
delete(idx.byName, name)
}
return idx
}
// 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.
@ -242,36 +468,79 @@ func (e *PulseToolExecutor) summarizeFleet(
rawIDs, _ := args["resource_ids"].(string)
rawIDs = strings.TrimSpace(rawIDs)
var candidates []summarizeFleetCandidate
enumerated := false
note := ""
if rawIDs == "" {
return NewErrorResult(fmt.Errorf("resource_ids (comma-separated) is required for action=fleet. Do not ask the operator for identifiers: enumerate resources yourself first (e.g. pulse_query) and retry with their ids, or use action=resource for a single resource")), nil
}
if canonicalDefault == "" {
return NewErrorResult(fmt.Errorf("resource_type is required for action=fleet. Use the type shared by the listed resources (e.g. vm, node, docker-host); enumerate resources yourself if unsure — do not ask the operator")), 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
// No explicit list: enumerate the known fleet ourselves. The
// natural first-session question ("how is my machine doing?")
// must succeed without the model knowing any internal IDs.
candidates = e.enumerateSummarizeFleet(canonicalDefault)
if len(candidates) == 0 {
if canonicalDefault != "" {
return NewErrorResult(fmt.Errorf("no monitored resources of type %q found to summarize; retry without resource_type to enumerate the whole fleet — do not ask the operator for resource IDs", canonicalDefault)), nil
}
return NewErrorResult(fmt.Errorf("no monitored resources found to summarize: Pulse is not monitoring anything yet, so tell the user to connect a node or agent first — do not ask the operator for resource IDs")), nil
}
if _, dup := seen[s]; dup {
continue
enumerated = true
if len(candidates) > summarizeFleetMaxResources {
note = fmt.Sprintf("fleet truncated to the first %d of %d known resources (infrastructure first)", summarizeFleetMaxResources, len(candidates))
candidates = candidates[:summarizeFleetMaxResources]
}
} else {
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
}
index := e.buildSummarizeCandidateIndex()
seen := make(map[string]struct{}, len(parts))
var unresolved []string
for _, raw := range parts {
s := strings.TrimSpace(raw)
if s == "" {
continue
}
cand, ok := index.lookup(s)
if !ok {
if canonicalDefault == "" {
unresolved = append(unresolved, s)
continue
}
// Unrecognized reference with an explicit default type:
// pass it through untranslated — the caller may be
// addressing the metrics store's native ID space.
cand = summarizeFleetCandidate{reportType: canonicalDefault, id: s}
}
if _, dup := seen[cand.id]; dup {
continue
}
seen[cand.id] = struct{}{}
candidates = append(candidates, cand)
}
if len(unresolved) > 0 {
return NewErrorResult(fmt.Errorf("could not resolve %s to known resources; retry with action=fleet and no resource_ids to enumerate the fleet automatically, or add resource_type for identifiers from the metrics ID space — do not ask the operator for resource IDs", strings.Join(unresolved, ", "))), nil
}
if len(candidates) == 0 {
return NewErrorResult(fmt.Errorf("resource_ids parsed to zero non-empty identifiers")), nil
}
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
resources := make([]reporting.MetricReportRequest, 0, len(candidates))
ids := make([]string, 0, len(candidates))
entries := make([]summarizeFleetEntry, 0, len(candidates))
for _, cand := range candidates {
req := reporting.MetricReportRequest{
ResourceType: cand.reportType,
ResourceID: cand.id,
MetricsResourceID: cand.metricsID,
}
if cand.name != "" {
req.Resource = &reporting.ResourceInfo{Name: cand.name, Status: cand.status}
}
resources = append(resources, req)
ids = append(ids, cand.id)
entries = append(entries, summarizeFleetEntry{ID: cand.id, Type: cand.reportType, Name: cand.name})
}
req := reporting.MultiReportRequest{
@ -296,6 +565,7 @@ func (e *PulseToolExecutor) summarizeFleet(
Str("org_id", e.orgID).
Str("action", "fleet").
Str("resource_type", canonicalDefault).
Bool("enumerated", enumerated).
Int("resource_count", len(ids)).
Str("narrative_source", narrative.Source).
Bool("ai_configured", e.reportFleetNarrator != nil).
@ -308,6 +578,9 @@ func (e *PulseToolExecutor) summarizeFleet(
OK: true,
Action: "fleet",
ResourceIDs: ids,
Resources: entries,
Enumerated: enumerated,
Note: note,
WindowStart: start,
WindowEnd: end,
NarrativeSource: narrative.Source,

View file

@ -3,11 +3,14 @@ package tools
import (
"context"
"encoding/json"
"fmt"
"path/filepath"
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rcourtman/pulse-go-rewrite/pkg/metrics"
"github.com/rcourtman/pulse-go-rewrite/pkg/reporting"
)
@ -371,6 +374,249 @@ func TestSummarizeTool_FleetUsesFleetNarratorWhenConfigured(t *testing.T) {
}
}
// The production unified provider (the monitor adapter) must satisfy the
// on-demand metrics-target resolver, or fleet enumeration silently degrades
// to fixture-populated targets only.
var _ summarizeMetricsTargetResolver = (*unifiedresources.MonitorAdapter)(nil)
// stubSummarizeResourceProvider implements UnifiedResourceProvider plus the
// on-demand metrics-target resolver the production monitor adapter exposes.
type stubSummarizeResourceProvider struct {
byType map[unifiedresources.ResourceType][]unifiedresources.Resource
targets map[string]*unifiedresources.MetricsTarget
}
func (s *stubSummarizeResourceProvider) GetByType(t unifiedresources.ResourceType) []unifiedresources.Resource {
return s.byType[t]
}
func (s *stubSummarizeResourceProvider) MetricsTargetForResource(resourceID string) *unifiedresources.MetricsTarget {
return s.targets[resourceID]
}
func newSummarizeStubProvider() *stubSummarizeResourceProvider {
return &stubSummarizeResourceProvider{
byType: map[unifiedresources.ResourceType][]unifiedresources.Resource{
unifiedresources.ResourceTypeAgent: {
{
ID: "host-abc123",
Type: unifiedresources.ResourceTypeAgent,
Name: "delly",
Status: unifiedresources.StatusOnline,
Proxmox: &unifiedresources.ProxmoxData{},
},
},
unifiedresources.ResourceTypeVM: {
{
ID: "vm-def456",
Type: unifiedresources.ResourceTypeVM,
Name: "media-server",
Status: unifiedresources.StatusOnline,
},
},
},
targets: map[string]*unifiedresources.MetricsTarget{
"host-abc123": {ResourceType: "agent", ResourceID: "delly-node-id"},
"vm-def456": {ResourceType: "vm", ResourceID: "pve1:node:101"},
},
}
}
func TestSummarizeTool_FleetEnumeratesWhenIDsOmitted(t *testing.T) {
exec, cleanup := newSummarizeTestEnvironment(t)
defer cleanup()
exec.SetUnifiedResourceProvider(newSummarizeStubProvider())
res, err := exec.executeSummarize(context.Background(), map[string]interface{}{
"action": "fleet",
})
if err != nil {
t.Fatalf("executeSummarize: %v", err)
}
if res.IsError {
t.Fatalf("expected omitted resource_ids to self-enumerate, got error: %+v", res.Content)
}
var parsed summarizeFleetResponse
if err := json.Unmarshal([]byte(res.Content[0].Text), &parsed); err != nil {
t.Fatalf("decode response: %v", err)
}
if !parsed.Enumerated {
t.Error("expected enumerated=true")
}
if len(parsed.ResourceIDs) != 2 {
t.Fatalf("expected 2 enumerated resources, got %v", parsed.ResourceIDs)
}
// Infrastructure enumerates before guests.
if parsed.ResourceIDs[0] != "host-abc123" || parsed.ResourceIDs[1] != "vm-def456" {
t.Errorf("ResourceIDs = %v, want infrastructure first", parsed.ResourceIDs)
}
if len(parsed.Resources) != 2 || parsed.Resources[0].Name != "delly" || parsed.Resources[1].Type != "vm" {
t.Errorf("Resources = %+v", parsed.Resources)
}
// Pure Proxmox node classification keeps the "node" reporting type even
// though the metrics target labels the agent family.
if parsed.Resources[0].Type != "node" {
t.Errorf("host entry type = %q, want node", parsed.Resources[0].Type)
}
}
func TestSummarizeTool_FleetEnumerationHonorsTypeFilter(t *testing.T) {
exec, cleanup := newSummarizeTestEnvironment(t)
defer cleanup()
exec.SetUnifiedResourceProvider(newSummarizeStubProvider())
res, err := exec.executeSummarize(context.Background(), map[string]interface{}{
"action": "fleet",
"resource_type": "vm",
})
if err != nil {
t.Fatalf("executeSummarize: %v", err)
}
if res.IsError {
t.Fatalf("unexpected error: %+v", res.Content)
}
var parsed summarizeFleetResponse
if err := json.Unmarshal([]byte(res.Content[0].Text), &parsed); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(parsed.ResourceIDs) != 1 || parsed.ResourceIDs[0] != "vm-def456" {
t.Errorf("ResourceIDs = %v, want only the VM", parsed.ResourceIDs)
}
}
func TestSummarizeTool_FleetEnumerationCapsAndNotes(t *testing.T) {
exec, cleanup := newSummarizeTestEnvironment(t)
defer cleanup()
provider := newSummarizeStubProvider()
var vms []unifiedresources.Resource
for i := 0; i < summarizeFleetMaxResources+10; i++ {
vms = append(vms, unifiedresources.Resource{
ID: fmt.Sprintf("vm-%03d", i),
Type: unifiedresources.ResourceTypeVM,
Name: fmt.Sprintf("guest-%03d", i),
})
}
provider.byType[unifiedresources.ResourceTypeVM] = vms
exec.SetUnifiedResourceProvider(provider)
res, err := exec.executeSummarize(context.Background(), map[string]interface{}{
"action": "fleet",
})
if err != nil {
t.Fatalf("executeSummarize: %v", err)
}
if res.IsError {
t.Fatalf("unexpected error: %+v", res.Content)
}
var parsed summarizeFleetResponse
if err := json.Unmarshal([]byte(res.Content[0].Text), &parsed); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(parsed.ResourceIDs) != summarizeFleetMaxResources {
t.Errorf("expected cap at %d resources, got %d", summarizeFleetMaxResources, len(parsed.ResourceIDs))
}
if parsed.Note == "" {
t.Error("expected truncation note when the enumeration exceeds the cap")
}
}
func TestSummarizeTool_FleetEnumerationEmptyFleetErrorForbidsAskingOperator(t *testing.T) {
exec, cleanup := newSummarizeTestEnvironment(t)
defer cleanup()
res, err := exec.executeSummarize(context.Background(), map[string]interface{}{
"action": "fleet",
})
if err != nil {
t.Fatalf("executeSummarize: %v", err)
}
if !res.IsError {
t.Fatal("expected error when no resources are known")
}
if len(res.Content) == 0 || !strings.Contains(res.Content[0].Text, "do not ask the operator") {
t.Errorf("empty-fleet error must steer the model away from operator questions, got %+v", res.Content)
}
}
func TestSummarizeTool_FleetResolvesNamesAndTranslatesMetricsIDs(t *testing.T) {
exec, cleanup := newSummarizeTestEnvironment(t)
defer cleanup()
exec.SetUnifiedResourceProvider(newSummarizeStubProvider())
fleet := &stubFleetReportNarrator{
response: reporting.FleetNarrative{Source: reporting.NarrativeSourceAI},
}
exec.reportFleetNarrator = fleet
res, err := exec.executeSummarize(context.Background(), map[string]interface{}{
"action": "fleet",
"resource_ids": "delly, media-server",
})
if err != nil {
t.Fatalf("executeSummarize: %v", err)
}
if res.IsError {
t.Fatalf("unexpected error: %+v", res.Content)
}
var parsed summarizeFleetResponse
if err := json.Unmarshal([]byte(res.Content[0].Text), &parsed); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(parsed.ResourceIDs) != 2 || parsed.ResourceIDs[0] != "host-abc123" || parsed.ResourceIDs[1] != "vm-def456" {
t.Errorf("names should resolve to canonical IDs, got %v", parsed.ResourceIDs)
}
if parsed.Enumerated {
t.Error("explicit resource_ids must not report enumerated=true")
}
}
func TestSummarizeTool_FleetUnresolvedIDsWithoutTypeErrorsWithRecoveryPath(t *testing.T) {
exec, cleanup := newSummarizeTestEnvironment(t)
defer cleanup()
exec.SetUnifiedResourceProvider(newSummarizeStubProvider())
res, err := exec.executeSummarize(context.Background(), map[string]interface{}{
"action": "fleet",
"resource_ids": "no-such-thing",
})
if err != nil {
t.Fatalf("executeSummarize: %v", err)
}
if !res.IsError {
t.Fatal("expected error for unresolvable IDs without a resource_type")
}
if !strings.Contains(res.Content[0].Text, "no resource_ids to enumerate the fleet automatically") {
t.Errorf("error must point at the self-enumeration path, got %q", res.Content[0].Text)
}
}
func TestSummarizeTool_ResourceResolvesNameWithoutType(t *testing.T) {
exec, cleanup := newSummarizeTestEnvironment(t)
defer cleanup()
exec.SetUnifiedResourceProvider(newSummarizeStubProvider())
res, err := exec.executeSummarize(context.Background(), map[string]interface{}{
"action": "resource",
"resource_id": "delly",
})
if err != nil {
t.Fatalf("executeSummarize: %v", err)
}
if res.IsError {
t.Fatalf("known resource name should not require resource_type, got %+v", res.Content)
}
var parsed summarizeResourceResponse
if err := json.Unmarshal([]byte(res.Content[0].Text), &parsed); err != nil {
t.Fatalf("decode response: %v", err)
}
if parsed.ResourceID != "host-abc123" {
t.Errorf("ResourceID = %q, want canonical host-abc123", parsed.ResourceID)
}
if parsed.ResourceType != "node" {
t.Errorf("ResourceType = %q, want node", parsed.ResourceType)
}
}
func TestSummarizeRangeWindow(t *testing.T) {
cases := map[string]time.Duration{
"24h": 24 * time.Hour,

View file

@ -2970,7 +2970,7 @@ class SubsystemLookupTest(unittest.TestCase):
{
"heading": "## Shared Boundaries",
"path": "internal/api/access_control_handlers.go",
"line": 1225,
"line": 1233,
"heading_line": 144,
}
],