fix(patrol): preserve refusals and expose measured summary evidence

A provider policy refusal was classified as a connection failure, while
summary tools mixed high-utilisation heuristics with unchecked health claims.
Preserve explicit refusals before tool recovery and provide retained metrics
with source scope, observation timestamps and bucket extrema for diagnosis.

Record the live qualification limits and the shared temporal tier-query gap.
This commit is contained in:
rcourtman
2026-09-05 22:29:48 +01:00
parent 7e33b2d37c
commit 5288b64d40
16 changed files with 635 additions and 187 deletions
@@ -244,3 +244,70 @@ and collapse, output scrolling, final-answer pixels, Escape, session history
and reload. The actual cached readiness result and actual HTTP 409 gate were
verified after the final source changes. Local receipts bind source hashes,
transcripts and screenshots under the homelab resource-browser artifact set.
## Evidence interpretation continuation, 2026-09-05
The provider refusal is now a first-class `provider_refusal` diagnostic. The
subscription transport preserves the terminal refusal before either structured
completion or a preceding tool call can be recovered. Both process exit paths
are covered. Saved legacy evidence is corrected only from a complete terminal
JSON envelope with `stop_reason=refusal`, preserving the original time and probe
counts. This produces clear policy/support guidance without a new provider
request, weakened CLI restrictions or a verified Patrol mode. The real saved
result was inspected in desktop/mobile settings and the manual endpoint still
returned HTTP 409.
The model-facing summary no longer invokes the report narrator. Its canonical
`MetricEvidenceProvider` contract reads the report engine's retained data and
returns units, extrema, means, latest values, point counts and observation
timestamps. Extrema include recorded bucket ranges and identify their buckets.
Means and latest values can be bucket averages. The response names alerts,
findings, disk health, backups and topology as not queried, requiring their own
tools before health or causal conclusions. A 92% memory reading is returned as
evidence without a heuristic critical or healthy label. Existing export/report
narratives and health cards remain separate behavior and have not been qualified
or repaired by this change.
The first live summary investigation correctly separated high utilisation from
proven memory pressure and disclosed the short, stale retained window. It still
dismissed a recorded historical temperature maximum because the current sensor
reading was lower. That is an incorrect inference, not proof that the recorded
peak is an artefact. The summary now explicitly distinguishes preserved bucket
extrema from bucket averages and includes the extrema's bucket timestamps.
Unsupported workload-change inference and assumptions about a previously
working agent channel remain model-reasoning concerns.
A separate local fixture reproduced the shared history query coverage defect.
It wrote CPU=10 in the minute tier at 21:37 and CPU=20 in raw data at 22:16.
Both 24-hour `Store.Query` and `Store.QueryAll` returned only the older point.
A two-hour query returned the newer raw point. The preferred non-empty tier
therefore hides newer evidence. Temporal tier reconciliation, including batch
queries and aggregation semantics, is recorded in the performance-and-scalability
contract under the existing outcome gap. The evidence tool exposes its returned
coverage and cannot make the underlying query complete. This is separate from
missing observations that were never collected.
The final live read-only investigation completed in 195 seconds with ten visible
tool controls. It resolved the named resource, preserved the extrema bucket
timestamps, explained the difference between extrema and plotted averages,
disclosed the 83-point / 82-minute returned span and roughly 45-minute age,
and distinguished high utilisation from demonstrated memory pressure. The
failed host pressure read was correctly treated as unavailable evidence.
However, the answer still speculated about a sensor/startup artefact, inferred
monitoring restart from coincident timestamps, claimed continuous coverage from
minute spacing, and called the returned span a retention limit. None of those
claims was established by the tool results. Recommending simply waiting a day
is insufficient given the independently reproduced tier query defect. This
is a bounded evidence-contract pass, with diagnosis qualification still open.
Final browser inspection covered `/patrol` and
`/settings/pulse-intelligence/patrol` at 1440x1000 and 390x1000. The interaction
matrix included Assistant submission/completion, keyboard and pointer expansion
and collapse, scrollable tool outputs through their final entries, final answer
pixels, Escape, session-history selection and reload. It also covered the actual
cached provider-refusal settings and blocked manual execution, with no repeated
provider readiness probe. The final saved-session pixel pass followed a correction
to the tool's descriptive governance metadata. Runtime source hashes, transcript
and screenshots are retained in the local homelab evidence-trust artifact set.
These receipts do not qualify autonomous Patrol or infrastructure mutations.
+32 -6
View File
@@ -5648,11 +5648,15 @@
"status": "partial",
"completion": {
"state": "bounded-residual",
"summary": "Performance is at the current release floor, but additional scalability headroom and non-blocking performance polish remain post-RC follow-up.",
"summary": "Performance is at the current release floor with bounded residuals. Retained queries still require temporal tier reconciliation so an existing aggregate does not hide newer raw evidence. Scalability headroom and performance follow-up remain tracked separately.",
"tracking": [
{
"kind": "lane-followup",
"id": "performance-post-rc-headroom"
},
{
"kind": "lane-followup",
"id": "retained-metric-query-coverage"
}
]
},
@@ -9726,6 +9730,19 @@
"pulse-pro"
],
"subsystem_ids": []
},
{
"id": "retained-metric-query-coverage",
"summary": "Repair temporal coverage across retained metric tiers. A 24-hour Query or QueryAll currently returns an older non-empty aggregate tier even when a newer raw point exists. Reconcile bucket precedence, extrema and downsampling consistently across single-series, all-series and batch reads. The model-facing evidence tool reports the returned timestamps but does not make this shared-store query complete. Required for trustworthy Patrol and Assistant history, tracked with patrol-assistant-customer-outcome-qualification.",
"owner": "project-owner",
"status": "planned",
"recorded_at": "2026-09-05",
"lane_ids": [
"L10"
],
"subsystem_ids": [
"performance-and-scalability"
]
}
],
"coverage_gaps": [
@@ -10201,17 +10218,19 @@
},
{
"id": "patrol-assistant-customer-outcome-qualification",
"summary": "The 2026-09-05 product review found that Explain with Assistant opened a blank generic conversation and the attention workbench discarded richer canonical finding context. The explicit explanation dispatcher and canonical handoff repair address that frontend break, with scripted browser proof and regression tests. They do not qualify model reasoning, real infrastructure actions, or repeated customer value. Latest-report, monitoring-active, multi-ping telemetry excluding dev and deployment proof contained 127 paid installs, 71 with Patrol enabled and 23 with Assistant calls. Fourteen reported verified resolutions were concentrated in one install. Paid includes all non-free tiers, and activity cooccurrence is not a linked successful journey. Schema 17 outcome/provider/cost fields had no adoption in that review. Three real jobs remain to qualify across named provider/model/version configurations: unhealthy service diagnosis, backup or capacity risk, and supported VM/LXC plan, approval and verified result. Repeatability must be established across independent paid customer environments without pooling free-tier or ineligible installs. The same-day real maintainer homelab evaluation with Claude Opus 5 exposed missing Proxmox temperature-tool evidence, contradictory summary health, incomplete visible prose, alert-count confusion and an inaccurately classified provider refusal during Patrol readiness. The shared temperature projection and premature within-window evidence compaction are repaired, while the documented reasoning, completion, readiness and end-to-end outcome failures remain unqualified. The continuation repaired canonical resource lookup, operation-versus-disk filtering, Proxmox CPU topology and retained resource-scoped history across backend restarts. Unscoped summary/baseline modernization, wear semantics, disk risk explanation and complete retained-window coverage remain open. A named-resource repeat exposed overly restrictive current_resource recovery guidance, now corrected without weakening the attachment boundary. Live testing also exposed warning-based API execution after an incomplete provider check. Runtime and API now enforce the selected-mode verdict, including saved incomplete results, and the repaired live endpoint returns HTTP 409. One read-only test run started before the gate correction and was stopped, so it is not qualification evidence.",
"summary": "The 2026-09-05 product review found that Explain with Assistant opened a blank generic conversation and the attention workbench discarded richer canonical finding context. The explicit explanation dispatcher and canonical handoff repair address that frontend break, with scripted browser proof and regression tests. They do not qualify model reasoning, real infrastructure actions, or repeated customer value. Latest-report, monitoring-active, multi-ping telemetry excluding dev and deployment proof contained 127 paid installs, 71 with Patrol enabled and 23 with Assistant calls. Fourteen reported verified resolutions were concentrated in one install. Paid includes all non-free tiers, and activity cooccurrence is not a linked successful journey. Schema 17 outcome/provider/cost fields had no adoption in that review. Three real jobs remain to qualify across named provider/model/version configurations: unhealthy service diagnosis, backup or capacity risk, and supported VM/LXC plan, approval and verified result. Repeatability must be established across independent paid customer environments without pooling free-tier or ineligible installs. The same-day real maintainer homelab evaluation with Claude Opus 5 exposed missing Proxmox temperature-tool evidence, contradictory summary health, incomplete visible prose, alert-count confusion and an inaccurately classified provider refusal during Patrol readiness. The shared temperature projection and premature within-window evidence compaction are repaired, while the documented reasoning, completion, readiness and end-to-end outcome failures remain unqualified. The continuation repaired canonical resource lookup, operation-versus-disk filtering, Proxmox CPU topology and retained resource-scoped history across backend restarts. Unscoped summary/baseline modernization, wear semantics, disk risk explanation and complete retained-window coverage remain open. A named-resource repeat exposed overly restrictive current_resource recovery guidance, now corrected without weakening the attachment boundary. Live testing also exposed warning-based API execution after an incomplete provider check. Runtime and API now enforce the selected-mode verdict, including saved incomplete results, and the repaired live endpoint returns HTTP 409. One read-only test run started before the gate correction and was stopped, so it is not qualification evidence. The evidence interpretation continuation replaces model-facing heuristic summaries with retained statistics, explicit source scope, real observation coverage and preserved bucket extrema/timestamps. A typed provider_refusal diagnosis now survives CLI exit and legacy cache decoding without granting readiness or repeating the refused request. The final real summary distinguished utilisation from demonstrated pressure and explained extrema versus bucket averages, but still speculated about sensor artefacts and monitoring restarts, inferred continuous coverage from minute spacing, and conflated returned history with retained history. Reasoning remains unqualified. A local fixture confirmed that 24-hour Query and QueryAll can hide newer raw points when a preferred aggregate tier is non-empty. Shared temporal tier reconciliation, including batch queries, is required in L10 before claiming current requested-window history.",
"owner": "project-owner",
"status": "planned",
"recorded_at": "2026-09-05",
"lane_ids": [
"L6"
"L6",
"L10"
],
"subsystem_ids": [
"ai-runtime",
"api-contracts",
"patrol-intelligence"
"patrol-intelligence",
"performance-and-scalability"
],
"proposed_resolution": "lane-expansion",
"coverage_impact": 4,
@@ -10221,6 +10240,11 @@
"path": "docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md",
"kind": "file"
},
{
"repo": "pulse",
"path": "docs/release-control/v6/internal/subsystems/performance-and-scalability.md",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/ai/qualification",
@@ -10358,7 +10382,8 @@
"recorded_at": "2026-09-05",
"target_id": "v6-product-lane-expansion",
"current_lane_ids": [
"L6"
"L6",
"L10"
],
"coverage_gap_ids": [
"patrol-assistant-customer-outcome-qualification"
@@ -10366,7 +10391,8 @@
"subsystem_ids": [
"ai-runtime",
"api-contracts",
"patrol-intelligence"
"patrol-intelligence",
"performance-and-scalability"
],
"demand_evidence": [
"user-direction: 2026-09-05 prioritise dependable Patrol and Assistant outcomes as core Pulse Pro value",
@@ -36,8 +36,8 @@ metrics target. The retained SQLite metrics store supplies the requested window
across backend restarts. An available store error remains an error, with no
silent downgrade to a shorter in-memory history. The in-memory-only adapter
remains a compatibility boundary for installations without a retained store.
The unscoped summary and baseline paths still use their existing providers and
are a modernization residual, not proof of retained-history coverage.
Unscoped `pulse_metrics` performance and baseline paths still use their existing
providers and are a modernization residual, not proof of retained-history coverage.
`TestPerformanceMetricsRetainedAcrossRestart` verifies native store coordinates,
canonical response identity, node/agent/guest families, restart retention and
store failure. The `node` get alias resolves to canonical `agent`, whose CPU
@@ -50,8 +50,17 @@ suitability unassessed. Completed tool/context evidence remains available, and
an explicit provider-error guard prevents authorizing Patrol. Provider errors
remain distinct from measured latency failures. The exact legacy unfinished-probe latency verdict is corrected when persisted
evidence is decoded, preserving its timestamp, provider cause and completed
checks without a new provider request or granting a verified mode. Provider refusal taxonomy and
supported subscription readiness remain unresolved qualification work.
checks without a new provider request or granting a verified mode.
An explicit Claude terminal `stop_reason=refusal` becomes the typed provider
refusal error before any tool call or final answer can be recovered, on either
successful or unsuccessful process exit. Runtime and connection diagnostics
project `provider_refusal` with policy/support guidance rather than network,
latency or billing guidance. A saved legacy CLI envelope is reclassified only
when its complete terminal JSON contains that explicit signal. Initial probe
counts and the original evaluation time survive, the CLI dump is removed from
the displayed refusal detail, and no provider request or authorization is
created. Supported subscription Patrol execution remains unqualified.
Within an active investigation, tool observations are preserved across provider
turns while the full request fits the model context window. Turn age is not a
@@ -689,6 +698,8 @@ cheap local detection into model-owned diagnosis and governed action.
## Canonical Files
- `pkg/reporting/evidence.go`: measured retained-history evidence contract for model-facing summaries, with no report narrator or heuristic health judgement.
1. `internal/ai/`
1g. `internal/ai/patrol_objectives.go`
1e. `internal/ai/attention.go`
@@ -7084,14 +7095,12 @@ narrative honestly retrospective on Patrol's work and prevents
silent shadow-classification competing with Patrol's detection
rules.
The same reporting synthesis layer is now exposed to Pulse
Assistant as a first-class chat tool, `pulse_summarize`. The tool
wraps the engine's `NarrativeFor` and `FleetNarrativeFor` entry
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.
`pulse_summarize` is the model-facing retained-metric evidence tool. It uses
`pkg/reporting.MetricEvidenceProvider`, implemented by `ReportEngine` in
`pkg/reporting/evidence.go`, to read the same retained store as reports without
invoking a narrator. The capable model in the active conversation owns synthesis.
Report-only narratives and PDF health cards are separate reporting behavior,
not evidence of machine health or a substitute for an investigation.
The tool is self-targeting: `action=fleet` with `resource_ids`
omitted enumerates the known fleet from the executor's unified
@@ -7123,40 +7132,27 @@ 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
returns heuristic narrative; the AI narrator wiring through the
chat session is a focused follow-up that adds `Narrator`,
`FleetNarrator`, and `FindingsProvider` plumbing to the executor
configuration so the tool inherits the same per-tenant AI service
the report PDF endpoint already uses. Reporting therefore expands
from an export-shaped feature into a first-class capability
Assistant can compose with — the underlying engine surface stays
unchanged.
the compile-time `resourceMetricsTargetResolver` pin on the monitor adapter
hold this behavior.
That follow-up has now landed. `chat.Config` carries three optional
fields (`ReportNarrator`, `ReportFleetNarrator`,
`ReportFindingsProvider`) which are threaded through to
`tools.ExecutorConfig` and stored on `PulseToolExecutor`. The
`pulse_summarize` tool reads them when building requests so the
engine sees a populated narrator when the tenant's AI service is
configured. The router installs a `SetReportNarratorResolver`
closure on the chat handler that mirrors the reporting handler's
pattern: it asks the AISettingsHandler for the per-tenant
`ai.Service` and, when that service has `Enabled=true`, returns it
as the implementation for all three roles (Service satisfies
`reporting.Narrator`, `reporting.FleetNarrator`, and
`reporting.FindingsProvider` already). An unconfigured tenant still
sees the heuristic fallback — the tool never errors on missing AI,
matching the report PDF's graceful-degradation posture. AI-narrated
chat synthesis therefore uses the same provider, sanitizer, model
selection, cost ledger (report_narrative / report_narrative_fleet
use-cases), and budget gate the report PDF endpoint already
enforces — there is exactly one canonical synthesis path for both
surfaces.
Both modes return statistics with units, retained point counts, first/latest
observation timestamps and the largest gap between returned points. Means are
unweighted means of retained values, which may themselves be retention
aggregates. Extrema retain bucket minima/maxima. Neither the requested range
nor the first-to-last span proves continuous coverage. Empty metrics remain an
empty evidence map. Store errors and unavailable evidence capabilities return
errors, never a healthy verdict or heuristic fallback. Fleet reads fail visibly
if a selected resource query fails rather than silently dropping that resource.
The response explicitly names alerts, findings, disk health, backups and topology
as not queried. Models must use their corresponding tools to collect those
sources. The evidence tool does not emit health labels, scored outliers,
recommendations or a second model's conclusions. Report narrator dependencies
remain available for report generation but are not invoked by `pulse_summarize`.
`TestSummarizeToolReturnsHighUtilizationAsEvidenceWithoutDiagnosis` verifies that
92% memory remains a measured reading and is not labelled critical or healthy.
`TestMetricEvidenceRetainsObservedCoverageAcrossRestart` verifies retained
coordinates, sparse timestamps, statistics, empty results and query failures.
The same canonical AI runtime now also records user-chat token
usage to the cost ledger. `chat.Service.ExecuteStream` was a
@@ -15,6 +15,19 @@
## Purpose
The open `patrol-assistant-customer-outcome-qualification` gap includes retained
query coverage in `pkg/metrics/store.go`. A 24-hour `Query` returns the first
non-empty resolution tier, and `QueryAll` fills missing metric names rather than
missing times. A fixture with a minute-tier CPU point at 21:37 and a raw point
at 22:16 returns only 21:37 for both 24-hour APIs, while a two-hour query returns
22:16. The new model-facing evidence contract discloses the returned timestamps
but does not repair this shared-store defect. Canonical follow-up must reconcile
temporal coverage across tiers for single-series, all-series and batch queries,
with explicit bucket precedence, extrema and downsampling semantics. Do not
claim complete or current requested-window coverage from the present fallback
behavior. This belongs to metrics-store qualification, not model prompting.
Resource-scoped Assistant performance reads query retained CPU, memory and
disk series using the registry target, then retain the existing 120-point
output limit. A backend restart does not define the requested history window.
@@ -2063,6 +2063,7 @@
"pkg/aicontracts/investigation.go",
"pkg/aicontracts/orchestrator_deps.go",
"pkg/extensions/ai_autofix.go",
"pkg/reporting/evidence.go",
"scripts/generate-pulse-intelligence-docs.go"
],
"verification": {
@@ -2071,6 +2072,19 @@
"exact_files": [],
"require_explicit_path_policy_coverage": true,
"path_policies": [
{
"id": "retained-metric-evidence",
"label": "retained metric evidence and observation coverage proof",
"match_prefixes": [],
"match_files": [
"pkg/reporting/evidence.go"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"pkg/reporting/evidence_test.go"
]
},
{
"id": "patrol-qualification",
"label": "Patrol independent-ground-truth qualification, replay, and publication proof",
+39
View File
@@ -331,6 +331,39 @@ func (s *Service) loadPatrolModelReadiness() {
persisted.Result.PatrolCapable = false
persisted.Result.MaxVerifiedMode = ""
}
// Older subscription transports persisted the CLI's terminal envelope in
// the error detail. Recover only its explicit refusal signal, not prose or
// incidental rate-limit events, without spending another provider request.
if persisted.Result.Provider == config.AIProviderClaudeSubscription && persisted.Result.Cause == PatrolFailureCauseProviderConnection {
for i, detail := range persisted.Result.Details {
if !strings.HasPrefix(detail, "Multi-turn continuation probe failed: claude subscription agent failed:") {
continue
}
start := strings.Index(detail, `{"type":"result"`)
if start < 0 {
continue
}
var terminal struct {
StopReason string `json:"stop_reason"`
}
if json.NewDecoder(strings.NewReader(detail[start:])).Decode(&terminal) != nil || terminal.StopReason != "refusal" {
continue
}
failure := patrolRuntimeFailureFromError(providers.ErrProviderRequestRefused)
persisted.Result.Cause = failure.Cause
persisted.Result.Summary = failure.Summary
persisted.Result.Recommendation = failure.Recommendation
persisted.Result.Details[i] = "Multi-turn continuation probe failed: " + failure.Detail
persisted.Result.Dimensions.ToolProtocol.Summary = failure.Summary
if tool := &persisted.Result.Dimensions.ToolProtocol; tool.Attempts > 0 && tool.Passed == tool.Attempts {
tool.Summary = "Initial tool use passed. The provider refused the continuation request."
}
persisted.Result.Modes.Monitor = PatrolModeSuitability{Status: PatrolModeNotAssessed, Summary: failure.Description}
persisted.Result.Success = false
persisted.Result.PatrolCapable = false
persisted.Result.MaxVerifiedMode = ""
}
}
persisted.Result.CacheKey = persisted.CacheKey
s.patrolModelReadinessCache.result = clonePatrolModelReadinessResult(&persisted.Result)
s.patrolModelReadinessCache.recordedAt = persisted.RecordedAt
@@ -786,6 +819,9 @@ func runPatrolModelReadinessWithProvider(ctx context.Context, cfg *config.AIConf
failure := patrolRuntimeFailureFromErrorCtx(ctx, probeErr)
probeFailure = &failure
toolSummary = failure.Summary
if toolPassed == len(scenarios) && failure.Cause == PatrolFailureCauseProviderRefusal {
toolSummary = "Initial tool use passed. The provider refused the continuation request."
}
}
// A mid-run cancellation invalidates nothing the model already proved and
// proves nothing about what it never attempted: keep completed per-scenario
@@ -893,6 +929,9 @@ func runPatrolModelReadinessWithProvider(ctx context.Context, cfg *config.AIConf
result.Cause = probeFailure.Cause
result.Summary = probeFailure.Summary
result.Recommendation = probeFailure.Recommendation
if probeFailure.Cause == PatrolFailureCauseProviderRefusal {
result.Modes.Monitor.Summary = probeFailure.Description
}
}
if interrupted {
result.Status = PatrolModelReadinessNotAssessed
@@ -474,3 +474,52 @@ func TestPatrolReadinessCacheCorrectsLegacyIncompleteLatency(t *testing.T) {
t.Fatalf("recorded evidence changed: %+v", cached)
}
}
func TestPatrolReadinessProviderRefusalRemainsBlocked(t *testing.T) {
cfg := readinessTestConfig()
result := runPatrolModelReadinessWithProvider(context.Background(), cfg, config.AIProviderOllama, "test-model", "ollama:test-model", &scriptedReadinessProvider{contextWindow: 32768, continuationErr: providers.ErrProviderRequestRefused})
if result.Cause != PatrolFailureCauseProviderRefusal || result.Success || result.PatrolCapable || result.MaxVerifiedMode != "" {
t.Fatalf("refusal verdict = %+v", result)
}
if result.Dimensions.ToolProtocol.Passed != 3 || result.Dimensions.ContextQuality.Passed != 2 || result.Dimensions.Latency.Status != PatrolModelReadinessNotAssessed {
t.Fatalf("refusal lost completed probe evidence: %+v", result.Dimensions)
}
if !strings.Contains(result.Modes.Monitor.Summary, "explicitly refused") || !strings.Contains(result.Recommendation, "permits this workflow") {
t.Fatalf("refusal lacks an actionable explanation: %+v", result)
}
}
func TestPatrolReadinessCacheRecoversExplicitLegacyRefusal(t *testing.T) {
for _, stopReason := range []string{"refusal", "end_turn"} {
t.Run(stopReason, func(t *testing.T) {
persistence := config.NewConfigPersistence(t.TempDir())
cfg := readinessTestConfig()
cfg.Model = "claude-subscription:test-model"
cfg.PatrolModel = cfg.Model
service := NewService(persistence, nil)
service.cfg = cfg
result := emptyPatrolModelReadinessResult()
result.Provider, result.Model = config.AIProviderClaudeSubscription, "test-model"
result.Cause = PatrolFailureCauseProviderConnection
result.Details = []string{`Multi-turn continuation probe failed: claude subscription agent failed: exit status 1: {"type":"result","stop_reason":"` + stopReason + `","result":"private diagnostic with refusal in prose"}`}
result.Dimensions.ToolProtocol = PatrolModelReadinessDimension{Status: PatrolModelReadinessPass, Passed: 3, Attempts: 3}
result.Dimensions.ContextQuality = PatrolModelReadinessDimension{Status: PatrolModelReadinessPass, Passed: 2, Attempts: 2}
result.CacheKey = service.patrolModelReadinessCacheKey(cfg, result.Provider, result.Model)
at := time.Now().Add(-time.Hour)
service.recordPatrolModelReadiness(result, at)
reloaded := NewService(persistence, nil)
reloaded.cfg = cfg
cached, recordedAt := reloaded.CachedPatrolModelReadiness()
if cached == nil || !recordedAt.Equal(at) || cached.Dimensions.ToolProtocol.Passed != 3 || cached.Dimensions.ContextQuality.Passed != 2 {
t.Fatalf("saved evidence changed: %+v at %s", cached, recordedAt)
}
if stopReason == "refusal" {
if cached.Cause != PatrolFailureCauseProviderRefusal || strings.Contains(cached.Details[0], "private diagnostic") || reloaded.PatrolRuntimeReadiness().Ready {
t.Fatalf("legacy refusal was lost or authorized: %+v", cached)
}
} else if cached.Cause != PatrolFailureCauseProviderConnection {
t.Fatalf("incidental refusal prose changed the cause: %+v", cached)
}
})
}
}
+1
View File
@@ -36,6 +36,7 @@ const (
PatrolFailureCauseProviderRateLimited PatrolFailureCause = "provider_rate_limited"
PatrolFailureCauseProviderAuth PatrolFailureCause = "provider_auth"
PatrolFailureCauseProviderConnection PatrolFailureCause = "provider_connection"
PatrolFailureCauseProviderRefusal PatrolFailureCause = "provider_refusal"
// PatrolFailureCauseInterrupted marks a run that was cancelled mid-flight
// (operator cancel or a dropped client connection). It is deliberately not
// a provider fault: an interrupted run carries no evidence about the
+12
View File
@@ -173,6 +173,11 @@ func ClassifyProviderConnectionFailure(err error) PatrolRuntimeFailureDiagnostic
}
switch failure.Cause {
case PatrolFailureCauseProviderRefusal:
diagnostic.Title = failure.Summary
diagnostic.Summary = failure.Summary
diagnostic.Description = failure.Description
diagnostic.Recommendation = failure.Recommendation
case PatrolFailureCauseInterrupted:
diagnostic.Title = "Connection test interrupted"
diagnostic.Summary = "Connection test interrupted"
@@ -311,6 +316,13 @@ func patrolRuntimeFailureFromErrorCtx(ctx context.Context, err error) patrolRunt
failure.Cause = PatrolFailureCauseProviderNotConfigured
failure.Description = "Pulse Patrol cannot use the local " + setup.displayName + " subscription because its CLI executable or login is unavailable to the operating-system account running Pulse."
failure.Recommendation = setup.recommendation
case errors.Is(err, providers.ErrProviderRequestRefused):
failure.Title = "Pulse Patrol: Provider refused this request"
failure.Summary = "Provider refused this request"
failure.Cause = PatrolFailureCauseProviderRefusal
failure.Description = "The provider explicitly refused this request under its usage policy. Patrol could not complete the evaluation."
failure.Recommendation = "Review the provider's usage policy or contact its support. Use a provider integration that permits this workflow, then verify Patrol again."
failure.Detail = failure.Description
case patrolMalformedToolHistory(lower):
failure.Title = "Pulse Patrol: Malformed tool-call conversation history"
failure.Summary = "Malformed tool-call conversation history"
@@ -42,6 +42,25 @@ func TestPatrolRuntimeFailureFromError_PopulatesImpactForAllCauses(t *testing.T)
}
}
func TestPatrolRuntimeFailurePreservesProviderRefusal(t *testing.T) {
failure := patrolRuntimeFailureFromError(providers.ErrProviderRequestRefused)
if failure.Cause != PatrolFailureCauseProviderRefusal || failure.Summary != "Provider refused this request" {
t.Fatalf("refusal misclassified: %+v", failure)
}
connection := ClassifyProviderConnectionFailure(providers.ErrProviderRequestRefused)
if connection.Cause != failure.Cause || connection.Recommendation != failure.Recommendation {
t.Fatalf("connection diagnostic lost refusal: %+v", connection)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if interrupted := patrolRuntimeFailureFromErrorCtx(ctx, providers.ErrProviderRequestRefused); interrupted.Cause != PatrolFailureCauseInterrupted {
t.Fatalf("operator cancellation lost precedence: %+v", interrupted)
}
if generic := patrolRuntimeFailureFromError(errors.New("connection refused")); generic.Cause != PatrolFailureCauseProviderConnection {
t.Fatalf("socket failure was misclassified as refusal: %+v", generic)
}
}
func TestPatrolRuntimeFailureFromError_ClassifiesNoToolCapableEndpoint(t *testing.T) {
// OpenRouter surfaces this when account-level provider/data filters
// exclude every tool-capable route for the selected model.
@@ -22,6 +22,10 @@ import (
// continues to own and execute every infrastructure tool call.
type SubscriptionAgent string
// ErrProviderRequestRefused preserves an explicit provider refusal independently
// of transport failures. It must not be recovered as a completed tool turn.
var ErrProviderRequestRefused = errors.New("provider refused this request under its usage policy")
type SubscriptionAgentSetupIssue string
const (
@@ -108,6 +112,7 @@ type claudePrintResponse struct {
Result string `json:"result"`
PermissionDenials []json.RawMessage `json:"permission_denials"`
TerminalReason string `json:"terminal_reason,omitempty"`
StopReason string `json:"stop_reason,omitempty"`
NumTurns int `json:"num_turns,omitempty"`
Usage struct {
InputTokens int `json:"input_tokens"`
@@ -457,6 +462,11 @@ func (c *SubscriptionAgentClient) run(ctx context.Context, name string, args []s
if ctx.Err() != nil {
return nil, fmt.Errorf("%s subscription agent timed out: %w", name, ctx.Err())
}
if name == "claude" {
if terminal, ok := decodeClaudeTerminalResponse(stdout.buffer.Bytes()); ok && terminal.StopReason == "refusal" {
return nil, ErrProviderRequestRefused
}
}
message := strings.TrimSpace(stderr.buffer.String())
if message == "" {
message = strings.TrimSpace(stdout.buffer.String())
@@ -809,6 +819,9 @@ func decodeSubscriptionAgentTurn(agent SubscriptionAgent, raw []byte) (subscript
if err := json.Unmarshal(raw, &wrapper); err != nil {
return turn, fmt.Errorf("decode Claude subscription response: %w", err)
}
if wrapper.StopReason == "refusal" {
return turn, ErrProviderRequestRefused
}
if len(wrapper.PermissionDenials) > 0 {
return turn, errors.New("Claude subscription agent attempted a denied built-in tool")
}
@@ -898,6 +911,9 @@ func decodeClaudeSubscriptionAgentResponse(req ChatRequest, raw []byte) (subscri
if !terminalFound {
return subscriptionAgentTurn{}, errors.New("Claude subscription stream did not contain a terminal result")
}
if terminal.StopReason == "refusal" {
return subscriptionAgentTurn{}, ErrProviderRequestRefused
}
if len(terminal.PermissionDenials) > 0 {
return subscriptionAgentTurn{}, errors.New("Claude subscription agent attempted a denied built-in tool")
}
@@ -914,6 +930,9 @@ func decodeClaudeSubscriptionAgentResponse(req ChatRequest, raw []byte) (subscri
func decodeClaudePrintResponse(wrapper claudePrintResponse) (subscriptionAgentTurn, error) {
var turn subscriptionAgentTurn
if wrapper.StopReason == "refusal" {
return turn, ErrProviderRequestRefused
}
if len(wrapper.PermissionDenials) > 0 {
return turn, errors.New("Claude subscription agent attempted a denied built-in tool")
}
@@ -88,6 +88,33 @@ func TestCappedBufferBoundsChildOutput(t *testing.T) {
}
}
func TestSubscriptionAgentPreservesExplicitRefusalAcrossProcessExit(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("fake subscription CLI uses a POSIX shell script")
}
for _, exitCode := range []string{"0", "1"} {
t.Run("exit_"+exitCode, func(t *testing.T) {
binDir := t.TempDir()
// Even a preceding declared call or apparently successful envelope
// must not be routed once the terminal provider verdict is refusal.
writeExecutable(t, filepath.Join(binDir, "claude"), `#!/bin/sh
printf '%s\n' '{"type":"assistant","message":{"content":[{"type":"tool_use","id":"read-1","name":"pulse_read","input":{}}]}}'
printf '%s\n' '{"type":"result","subtype":"success","is_error":true,"stop_reason":"refusal","terminal_reason":"api_error","result":"private provider detail","structured_output":{"content":"done","stop_reason":"end_turn","tool_calls":[]}}'
exit `+exitCode+"\n")
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
client := NewSubscriptionAgentClient(SubscriptionAgentClaude, "test-model", time.Second)
var events int
err := client.ChatStream(context.Background(), ChatRequest{Tools: []Tool{{Name: "pulse_read"}}}, func(StreamEvent) { events++ })
if !errors.Is(err, ErrProviderRequestRefused) || events != 0 {
t.Fatalf("refusal = %v, emitted events = %d", err, events)
}
if strings.Contains(err.Error(), "private provider detail") {
t.Fatal("raw CLI envelope escaped the transport")
}
})
}
}
func TestSubscriptionAgentRequestTimeout(t *testing.T) {
if got := subscriptionAgentRequestTimeout(30 * time.Second); got != SubscriptionAgentMinimumRequestTimeout {
t.Fatalf("short configured timeout = %s, want %s", got, SubscriptionAgentMinimumRequestTimeout)
+87 -106
View File
@@ -12,28 +12,21 @@ import (
"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.
// registerSummarizeTools exposes retained evidence for model-owned synthesis.
// Report narratives remain available to report consumers through reporting.Engine.
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.
Description: `Read retained metric evidence for one resource or a fleet over 24h, 7d, or 30d. Returns measured statistics, units, actual first/latest observation times, retained point counts and largest gaps. The requested window does not imply complete or fresh coverage. Means are unweighted means of returned retained points, which may already be retention aggregates.
Two modes via the 'action' parameter:
- "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.
This tool reads metrics only. Alerts, findings, disk health, backup coverage and topology are not queried. Use the relevant tools for those sources before drawing conclusions about health or causes.
Time window defaults to the last 7 days; supported ranges: 24h, 7d, 30d.`,
Actions:
- resource: resource_id is required, resource_type is only needed for IDs unknown to the registry.
- fleet: omit resource_ids to enumerate the known fleet, or provide comma-separated IDs or names. resource_type optionally filters enumeration.
Default window: 7d. Interpret the evidence in the current conversation. No separate model or heuristic diagnosis runs inside this tool.`,
InputSchema: InputSchema{
Type: "object",
Properties: map[string]PropertySchema{
@@ -69,8 +62,8 @@ Time window defaults to the last 7 days; supported ranges: 24h, 7d, 30d.`,
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.",
ApprovalSummary: "no approval required; pure read of retained metrics.",
Summary: "Returns retained metric statistics and observation coverage for one resource or a fleet within a time window.",
},
})
}
@@ -97,9 +90,9 @@ func summarizeRangeWindow(raw string) time.Duration {
}
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
engine, ok := reporting.GetEngine().(reporting.MetricEvidenceProvider)
if !ok || engine == nil {
return NewErrorResult(fmt.Errorf("retained metric evidence is unavailable from the reporting engine")), nil
}
action, _ := args["action"].(string)
@@ -122,24 +115,36 @@ func (e *PulseToolExecutor) executeSummarize(ctx context.Context, args map[strin
}
}
// EvidenceScope states what was collected, independently from an empty result.
// In particular, an empty metrics map says nothing about alert or disk health.
type summarizeEvidenceScope struct {
Source string `json:"source"`
NotQueried []string `json:"not_queried"`
Aggregation string `json:"aggregation"`
}
func retainedSummaryScope() summarizeEvidenceScope {
return summarizeEvidenceScope{
Source: "retained_metrics",
NotQueried: []string{"alerts", "findings", "disk_health", "backups", "topology"},
Aggregation: "Mean and latest describe retained point values, which may be bucket averages. Min and max preserve recorded bucket extrema, with their bucket timestamps, so peaks can exceed the plotted averages. The mean is unweighted. First and last timestamps do not prove continuous coverage.",
}
}
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"`
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"`
Scope summarizeEvidenceScope `json:"scope"`
Evidence *reporting.MetricEvidence `json:"evidence"`
}
func (e *PulseToolExecutor) summarizeResource(
_ context.Context,
engine reporting.Engine,
ctx context.Context,
engine reporting.MetricEvidenceProvider,
args map[string]interface{},
start, end time.Time,
) (CallToolResult, error) {
@@ -182,14 +187,12 @@ func (e *PulseToolExecutor) summarizeResource(
}
req.Start = start
req.End = end
req.Narrator = e.reportNarrator
req.FindingsProvider = e.reportFindingsProvider
narrative, err := engine.NarrativeFor(req)
evidence, err := engine.MetricEvidenceFor(ctx, req)
if err != nil {
return NewErrorResult(fmt.Errorf("narrative generation failed: %w", err)), nil
return NewErrorResult(fmt.Errorf("metric evidence query failed: %w", err)), nil
}
if narrative == nil {
return NewErrorResult(fmt.Errorf("narrative generation produced no result")), nil
if evidence == nil {
return NewErrorResult(fmt.Errorf("metric evidence query produced no result")), nil
}
// Telemetry: structured event line per summarize invocation so
@@ -200,45 +203,34 @@ func (e *PulseToolExecutor) summarizeResource(
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).
Str("evidence_source", "retained_metrics").
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,
OK: true,
Action: "resource",
ResourceType: canonicalType,
ResourceID: resourceID,
WindowStart: start,
WindowEnd: end,
Scope: retainedSummaryScope(),
Evidence: evidence,
}), nil
}
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"`
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"`
OK bool `json:"ok"`
Action string `json:"action"`
ResourceIDs []string `json:"resource_ids"`
Resources []summarizeFleetEntry `json:"resources"`
Enumerated bool `json:"enumerated,omitempty"`
Note string `json:"note,omitempty"`
WindowStart time.Time `json:"window_start"`
WindowEnd time.Time `json:"window_end"`
Scope summarizeEvidenceScope `json:"scope"`
Evidence []*reporting.MetricEvidence `json:"evidence"`
}
// summarizeFleetEntry names one fleet member in the response so the model can
@@ -431,8 +423,8 @@ func (e *PulseToolExecutor) buildSummarizeCandidateIndex() *summarizeCandidateIn
const summarizeFleetMaxResources = 50
func (e *PulseToolExecutor) summarizeFleet(
_ context.Context,
engine reporting.Engine,
ctx context.Context,
engine reporting.MetricEvidenceProvider,
args map[string]interface{},
start, end time.Time,
) (CallToolResult, error) {
@@ -523,21 +515,17 @@ func (e *PulseToolExecutor) summarizeFleet(
entries = append(entries, summarizeFleetEntry{ID: cand.id, Type: cand.reportType, Name: cand.name})
}
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
evidence := make([]*reporting.MetricEvidence, 0, len(resources))
for _, req := range resources {
req.Start, req.End = start, end
item, err := engine.MetricEvidenceFor(ctx, req)
if err != nil {
return NewErrorResult(fmt.Errorf("metric evidence query failed for %s: %w", req.ResourceID, err)), nil
}
if item == nil {
return NewErrorResult(fmt.Errorf("metric evidence query produced no result for %s", req.ResourceID)), nil
}
evidence = append(evidence, item)
}
log.Info().
@@ -547,28 +535,21 @@ func (e *PulseToolExecutor) summarizeFleet(
Str("resource_type", canonicalDefault).
Bool("enumerated", enumerated).
Int("resource_count", len(ids)).
Str("narrative_source", narrative.Source).
Bool("ai_configured", e.reportFleetNarrator != nil).
Bool("findings_configured", e.reportFindingsProvider != nil).
Str("evidence_source", "retained_metrics").
Time("window_start", start).
Time("window_end", end).
Msg("Reporting: pulse_summarize invoked")
return NewJSONResult(summarizeFleetResponse{
OK: true,
Action: "fleet",
ResourceIDs: ids,
Resources: entries,
Enumerated: enumerated,
Note: note,
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,
OK: true,
Action: "fleet",
ResourceIDs: ids,
Resources: entries,
Enumerated: enumerated,
Note: note,
WindowStart: start,
WindowEnd: end,
Scope: retainedSummaryScope(),
Evidence: evidence,
}), nil
}
+59 -30
View File
@@ -124,11 +124,7 @@ func TestSummarizeTool_ResourceReturnsHeuristicNarrative(t *testing.T) {
}
_ = engine
// Write metrics via the same store. The engine was constructed with
// MetricsStore so we need to reach back into the store; instead, rely
// on writing via package-level access through engine internals.
// Simpler: skip data and accept that the heuristic narrator returns
// "insufficient data" — which is itself a valid narrative we can assert.
// No retained samples must remain an explicit empty evidence result.
res, err := exec.executeSummarize(context.Background(), map[string]interface{}{
"action": "resource",
"resource_type": "node",
@@ -153,15 +149,15 @@ func TestSummarizeTool_ResourceReturnsHeuristicNarrative(t *testing.T) {
if parsed.Action != "resource" {
t.Errorf("Action = %q, want resource", parsed.Action)
}
if parsed.NarrativeSource != reporting.NarrativeSourceHeuristic {
t.Errorf("NarrativeSource = %q, want heuristic (v1 always heuristic)", parsed.NarrativeSource)
if parsed.Scope.Source != "retained_metrics" || parsed.Evidence == nil || len(parsed.Evidence.Metrics) != 0 {
t.Fatalf("expected empty retained evidence, got %+v", parsed)
}
if parsed.HealthStatus == "" {
t.Error("expected HealthStatus populated")
}
if len(parsed.Observations) == 0 {
t.Error("expected at least one observation from the heuristic narrator")
for _, field := range []string{"health_status", "health_message", "observations", "recommendations"} {
if strings.Contains(res.Content[0].Text, `"`+field+`"`) {
t.Fatalf("empty evidence invented %s: %s", field, res.Content[0].Text)
}
}
}
func TestSummarizeTool_FleetParsesCommaSeparatedIDs(t *testing.T) {
@@ -192,8 +188,8 @@ func TestSummarizeTool_FleetParsesCommaSeparatedIDs(t *testing.T) {
t.Errorf("ResourceIDs[%d] = %q, want %q", i, parsed.ResourceIDs[i], w)
}
}
if parsed.NarrativeSource != reporting.NarrativeSourceHeuristic {
t.Errorf("NarrativeSource = %q, want heuristic", parsed.NarrativeSource)
if parsed.Scope.Source != "retained_metrics" || len(parsed.Evidence) != 3 {
t.Fatalf("expected retained evidence for all resources: %+v", parsed)
}
}
@@ -294,25 +290,23 @@ func TestSummarizeTool_UsesReportNarratorWhenConfigured(t *testing.T) {
if res.IsError {
t.Fatalf("unexpected error: %+v", res.Content)
}
if !narrator.called {
t.Fatal("expected narrator to be invoked")
if narrator.called {
t.Fatal("metric evidence must not invoke a second model")
}
var parsed summarizeResourceResponse
if err := json.Unmarshal([]byte(res.Content[0].Text), &parsed); err != nil {
t.Fatalf("decode response: %v", err)
}
if parsed.NarrativeSource != reporting.NarrativeSourceAI {
t.Errorf("NarrativeSource = %q, want ai", parsed.NarrativeSource)
if parsed.Scope.Source != "retained_metrics" || parsed.Evidence == nil {
t.Fatalf("expected measured evidence, got %+v", parsed)
}
if parsed.HealthMessage != "AI says fine" {
t.Errorf("HealthMessage = %q, want AI says fine", parsed.HealthMessage)
}
if len(parsed.Observations) != 1 || parsed.Observations[0].Text != "AI bullet" {
t.Errorf("Observations = %#v", parsed.Observations)
if strings.Contains(res.Content[0].Text, "AI says fine") || strings.Contains(res.Content[0].Text, "health_status") {
t.Fatal("narrator judgment leaked into metric evidence")
}
}
func TestSummarizeTool_FleetUsesFleetNarratorWhenConfigured(t *testing.T) {
func TestSummarizeTool_FleetDoesNotInvokeNestedNarrator(t *testing.T) {
dir := t.TempDir()
store, err := metrics.NewStore(metrics.StoreConfig{
DBPath: filepath.Join(dir, "metrics.db"),
@@ -359,19 +353,20 @@ func TestSummarizeTool_FleetUsesFleetNarratorWhenConfigured(t *testing.T) {
if res.IsError {
t.Fatalf("unexpected error: %+v", res.Content)
}
if !fleet.called {
t.Fatal("expected fleet narrator to be invoked")
if fleet.called {
t.Fatal("fleet evidence must not invoke a second model")
}
var parsed summarizeFleetResponse
if err := json.Unmarshal([]byte(res.Content[0].Text), &parsed); err != nil {
t.Fatalf("decode response: %v", err)
}
if parsed.NarrativeSource != reporting.NarrativeSourceAI {
t.Errorf("NarrativeSource = %q, want ai", parsed.NarrativeSource)
if parsed.Scope.Source != "retained_metrics" || len(parsed.Evidence) != 2 {
t.Fatalf("expected both resources as evidence, got %+v", parsed)
}
if len(parsed.Outliers) != 1 || parsed.Outliers[0].ResourceName != "alpha" {
t.Errorf("Outliers = %#v", parsed.Outliers)
if strings.Contains(res.Content[0].Text, "Memory creeping up") || strings.Contains(res.Content[0].Text, "outliers") {
t.Fatal("fleet judgment leaked into metric evidence")
}
}
// The production unified provider (the monitor adapter) must satisfy the
@@ -633,3 +628,37 @@ func TestSummarizeRangeWindow(t *testing.T) {
}
}
}
func TestSummarizeToolReturnsHighUtilizationAsEvidenceWithoutDiagnosis(t *testing.T) {
store, err := metrics.NewStore(metrics.DefaultConfig(t.TempDir()))
if err != nil {
t.Fatal(err)
}
defer store.Close()
at := time.Now().Add(-time.Hour).Truncate(time.Minute)
store.Write("node", "delly-node-id", "memory", 92, at)
store.Flush()
previous := reporting.GetEngine()
reporting.SetEngine(reporting.NewReportEngine(reporting.EngineConfig{MetricsStore: store}))
defer reporting.SetEngine(previous)
exec := NewPulseToolExecutor(ExecutorConfig{})
exec.SetUnifiedResourceProvider(newSummarizeStubProvider())
for _, args := range []map[string]interface{}{
{"action": "resource", "resource_id": "delly", "range": "24h"},
{"action": "fleet", "resource_ids": "delly", "range": "24h"},
} {
result, err := exec.executeSummarize(context.Background(), args)
if err != nil || result.IsError {
t.Fatalf("evidence = %+v, %v", result, err)
}
text := result.Content[0].Text
for _, absent := range []string{"health_status", "HEALTHY", "CRITICAL", "pressure", "recommendations"} {
if strings.Contains(text, absent) {
t.Fatalf("unexpected diagnosis %q in %s", absent, text)
}
}
if !strings.Contains(text, `"mean":92`) || !strings.Contains(text, `"retained_points":1`) || !strings.Contains(text, `"disk_health"`) || !strings.Contains(text, `"alerts"`) {
t.Fatalf("reading or unqueried source scope missing: %s", text)
}
}
}
+89
View File
@@ -0,0 +1,89 @@
package reporting
import (
"context"
"fmt"
"time"
)
// MetricEvidenceProvider exposes the same retained metrics as reports without
// invoking a narrator or assigning health, urgency, or recommended actions.
type MetricEvidenceProvider interface {
MetricEvidenceFor(context.Context, MetricReportRequest) (*MetricEvidence, error)
}
type MetricEvidence struct {
ResourceID string `json:"resource_id"`
ResourceType string `json:"resource_type"`
GeneratedAt time.Time `json:"generated_at"`
WindowStart time.Time `json:"window_start"`
WindowEnd time.Time `json:"window_end"`
Metrics map[string]RetainedMetricStats `json:"metrics"`
}
// RetainedMetricStats describes returned points, which may already be retention
// aggregates. Their count and time span do not prove continuous observation.
type RetainedMetricStats struct {
Unit string `json:"unit,omitempty"`
RetainedPoints int `json:"retained_points"`
Min float64 `json:"min"`
Max float64 `json:"max"`
Mean float64 `json:"mean"`
Latest float64 `json:"latest"`
MinBucketAt time.Time `json:"min_bucket_at"`
MaxBucketAt time.Time `json:"max_bucket_at"`
FirstAt time.Time `json:"first_at"`
LastAt time.Time `json:"last_at"`
MaxGapSeconds float64 `json:"max_gap_seconds"`
}
func (e *ReportEngine) MetricEvidenceFor(ctx context.Context, req MetricReportRequest) (*MetricEvidence, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if e.getMetricsStore() == nil {
return nil, fmt.Errorf("metrics store not initialized")
}
data, err := e.queryMetrics(req)
if err != nil {
return nil, err
}
if err := ctx.Err(); err != nil {
return nil, err
}
evidence := &MetricEvidence{
ResourceID: data.ResourceID, ResourceType: data.ResourceType,
GeneratedAt: data.GeneratedAt, WindowStart: data.Start, WindowEnd: data.End,
Metrics: make(map[string]RetainedMetricStats, len(data.Metrics)),
}
for name, points := range data.Metrics {
if len(points) == 0 {
continue
}
stats := data.Summary.ByMetric[name]
entry := RetainedMetricStats{
Unit: GetMetricUnit(name), RetainedPoints: len(points),
Min: points[0].Value, Max: points[0].Value, Mean: stats.Avg, Latest: stats.Current,
MinBucketAt: points[0].Timestamp, MaxBucketAt: points[0].Timestamp,
FirstAt: points[0].Timestamp, LastAt: points[len(points)-1].Timestamp,
}
for i, point := range points {
// Retention buckets preserve extrema separately from their mean.
low, high := point.Value, point.Value
if point.Min <= point.Value && point.Max >= point.Value {
low, high = point.Min, point.Max
}
if low < entry.Min {
entry.Min, entry.MinBucketAt = low, point.Timestamp
}
if high > entry.Max {
entry.Max, entry.MaxBucketAt = high, point.Timestamp
}
if i > 0 {
entry.MaxGapSeconds = max(entry.MaxGapSeconds, point.Timestamp.Sub(points[i-1].Timestamp).Seconds())
}
}
evidence.Metrics[name] = entry
}
return evidence, nil
}
+67
View File
@@ -0,0 +1,67 @@
package reporting
import (
"context"
"database/sql"
"errors"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/pkg/metrics"
"github.com/stretchr/testify/require"
)
func TestMetricEvidenceRetainsObservedCoverageAcrossRestart(t *testing.T) {
cfg := metrics.DefaultConfig(t.TempDir())
cfg.RetentionRaw = 8 * 24 * time.Hour
store, err := metrics.NewStore(cfg)
require.NoError(t, err)
start := time.Now().Add(-6 * time.Hour).Truncate(time.Minute)
for i, delta := range []time.Duration{0, 2 * time.Minute, time.Hour} {
store.Write("node", "native-node", "memory", float64(88+i), start.Add(delta))
}
require.NoError(t, store.Close())
// A retained point can carry a mean and separately recorded extrema.
// Preserve both the peak and its timestamp rather than using its mean.
db, err := sql.Open("sqlite", cfg.DBPath)
require.NoError(t, err)
_, err = db.Exec("UPDATE metrics SET min_value = 80, max_value = 100 WHERE timestamp = ?", start.Add(2*time.Minute).Unix())
require.NoError(t, err)
require.NoError(t, db.Close())
store, err = metrics.NewStore(cfg)
require.NoError(t, err)
defer store.Close()
engine := NewReportEngine(EngineConfig{MetricsStore: store})
narrator := &stubNarrator{err: errors.New("must not run")}
req := MetricReportRequest{ResourceID: "canonical-agent", MetricsResourceID: "native-node", ResourceType: "node", Start: start.Add(-18 * time.Hour), End: start.Add(6 * time.Hour), Narrator: narrator}
evidence, err := engine.MetricEvidenceFor(context.Background(), req)
require.NoError(t, err)
require.Equal(t, "canonical-agent", evidence.ResourceID)
reading := evidence.Metrics["memory"]
require.Equal(t, 3, reading.RetainedPoints)
require.Equal(t, "%", reading.Unit)
require.Equal(t, 89.0, reading.Mean)
require.Equal(t, 90.0, reading.Latest)
require.Equal(t, 80.0, reading.Min)
require.Equal(t, 100.0, reading.Max)
require.True(t, reading.MinBucketAt.Equal(start.Add(2*time.Minute)))
require.True(t, reading.MaxBucketAt.Equal(start.Add(2*time.Minute)))
require.True(t, reading.FirstAt.Equal(start))
require.True(t, reading.LastAt.Equal(start.Add(time.Hour)))
require.Equal(t, (58 * time.Minute).Seconds(), reading.MaxGapSeconds)
require.Empty(t, narrator.seen.ResourceID, "evidence must not invoke report judgment")
req.MetricsResourceID = "unobserved-node"
empty, err := engine.MetricEvidenceFor(context.Background(), req)
require.NoError(t, err)
require.NotNil(t, empty.Metrics)
require.Empty(t, empty.Metrics)
require.NoError(t, store.Close())
_, err = engine.MetricEvidenceFor(context.Background(), req)
require.Error(t, err, "query errors must not become empty evidence")
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err = engine.MetricEvidenceFor(ctx, req)
require.ErrorIs(t, err, context.Canceled)
}