diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 247bb26c2..ffa7c2757 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -1803,10 +1803,13 @@ deriving an older display status from `workflowStatusHistory`. discovery data before choosing any tool. Drawer handoffs route their attached resources through the same context-prefetch path as explicit `@`-mentions, so on a cloud turn the resource's cloud-safe operational context (access pattern, - config/data/log paths, ports) reaches the model exactly as it does for an - `@`-mention; identifying fields (hostname, IP, alias) stay redacted at the - model boundary. The handoff path must not withhold operational context that - the `@`-mention path delivers. + config/data/log paths, ports, and discovery freshness/age) reaches the model + exactly as it does for an `@`-mention; identifying fields (hostname, IP, alias) + stay redacted at the model boundary. The cloud-safe context must carry the + discovery's age (a non-identifying timestamp) when known so the model can + caveat staleness rather than presenting cached operational detail as current. + The handoff path must not withhold operational context that the `@`-mention + path delivers. Patrol deterministic triage signals are prioritized evidence seeds for the configured model; they must not be described as a Pulse-authored final diagnosis, proof that unflagged resources are healthy, or a reason to diff --git a/internal/ai/chat/context_prefetch.go b/internal/ai/chat/context_prefetch.go index 9f5b1ee1f..134833dd8 100644 --- a/internal/ai/chat/context_prefetch.go +++ b/internal/ai/chat/context_prefetch.go @@ -977,6 +977,9 @@ func cloudSafeOperationalContext(d *tools.ResourceDiscoveryInfo) string { DataPaths: d.DataPaths, LogPaths: d.LogPaths, Ports: ports, + // Carry freshness so the cloud-safe context can tell the model how old + // the discovery is (a timestamp is non-identifying). + UpdatedAt: d.UpdatedAt, }) } diff --git a/internal/ai/chat/context_prefetch_cloud_context_test.go b/internal/ai/chat/context_prefetch_cloud_context_test.go index ae41ff3bc..c1f2231fb 100644 --- a/internal/ai/chat/context_prefetch_cloud_context_test.go +++ b/internal/ai/chat/context_prefetch_cloud_context_test.go @@ -3,6 +3,7 @@ package chat import ( "strings" "testing" + "time" "github.com/rcourtman/pulse-go-rewrite/internal/ai/modelboundary" "github.com/rcourtman/pulse-go-rewrite/internal/ai/providers" @@ -145,6 +146,22 @@ func TestPrefetcherCloudContext_LocalRoutingUnaffected(t *testing.T) { } } +func TestCloudSafeOperationalContext_CarriesDiscoveryFreshness(t *testing.T) { + // The push-path conversion must carry UpdatedAt so the cloud-safe context + // can tell the model how old the discovery is. + d := homeAssistantDiscovery() + d.UpdatedAt = time.Now().Add(-2 * 24 * time.Hour) + + out := cloudSafeOperationalContext(d) + if !strings.Contains(out, "Last discovered: 2 days ago") { + t.Fatalf("cloud-safe operational context must carry discovery freshness, got:\n%s", out) + } + // Freshness is non-identifying; PII still must not appear. + if strings.Contains(out, "delly-ha-host") || strings.Contains(out, "192.168.0.101") { + t.Fatalf("freshness must not bring PII into the cloud-safe context, got:\n%s", out) + } +} + func TestCloudContextPolicy_SharesOnCloudRoutingOnly(t *testing.T) { if !(CloudContextPolicy{CloudRouting: true}).sharesCloudOperationalContext() { t.Fatal("cloud routing must share PII-free operational context") diff --git a/internal/servicediscovery/formatters.go b/internal/servicediscovery/formatters.go index 2fb85aeb7..5cb23df92 100644 --- a/internal/servicediscovery/formatters.go +++ b/internal/servicediscovery/formatters.go @@ -99,6 +99,13 @@ func FormatCloudSafeContext(d *ResourceDiscovery) string { } } + // Freshness: tell the model how old this discovery is so it can caveat + // staleness ("based on discovery from 2 days ago") instead of presenting + // cached operational detail as current. A timestamp is non-identifying. + if !d.UpdatedAt.IsZero() { + sb.WriteString("Last discovered: " + FormatDiscoveryAge(d) + "\n") + } + return strings.TrimSpace(sb.String()) } diff --git a/internal/servicediscovery/formatters_test.go b/internal/servicediscovery/formatters_test.go index 456e72259..d8c0ae9c2 100644 --- a/internal/servicediscovery/formatters_test.go +++ b/internal/servicediscovery/formatters_test.go @@ -315,4 +315,17 @@ func TestFormatCloudSafeContext(t *testing.T) { t.Errorf("cloud-safe context leaked PII %q\n--- output ---\n%s", bad, out) } } + + // No freshness line when the discovery carries no timestamp. + if strings.Contains(out, "Last discovered:") { + t.Errorf("cloud-safe context must omit freshness when UpdatedAt is zero\n--- output ---\n%s", out) + } + + // Freshness lets the model caveat staleness when the timestamp is known. + fresh := *d + fresh.UpdatedAt = time.Now().Add(-3 * time.Hour) + freshOut := FormatCloudSafeContext(&fresh) + if !strings.Contains(freshOut, "Last discovered: 3 hours ago") { + t.Errorf("cloud-safe context must report discovery age\n--- output ---\n%s", freshOut) + } }