From d9331d39e4efdbbe3de8c4909d24ab98f7418fa6 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 7 May 2026 21:58:06 +0100 Subject: [PATCH] Move Patrol run Assistant handoffs server-side - rehydrate Patrol run context from Patrol history for chat requests and follow-up sessions - send only browser-safe run metadata from the frontend - classify and redact provider runtime failures in handoff prompts and briefings --- .../v6/internal/subsystems/agent-lifecycle.md | 6 + .../v6/internal/subsystems/ai-runtime.md | 10 +- .../v6/internal/subsystems/api-contracts.md | 9 +- .../subsystems/frontend-primitives.md | 7 +- .../subsystems/patrol-intelligence.md | 15 +- .../subsystems/performance-and-scalability.md | 6 + .../internal/subsystems/storage-recovery.md | 6 + .../src/components/patrol/RunHistoryEntry.tsx | 5 +- .../patrol/__tests__/RunHistoryEntry.test.tsx | 22 +- .../patrolInvestigationContextModel.test.ts | 14 +- .../patrol/patrolInvestigationContextModel.ts | 64 +-- .../src/utils/__tests__/patrolFormat.test.ts | 44 ++ frontend-modern/src/utils/patrolFormat.ts | 103 ++++- internal/ai/chat/service.go | 14 + internal/ai/chat/session.go | 16 + internal/ai/chat/session_additional_test.go | 14 + internal/ai/patrol_assistant_handoff.go | 379 ++++++++++++++++++ internal/ai/patrol_assistant_handoff_test.go | 72 ++++ internal/ai/patrol_runtime_failure.go | 58 +++ internal/api/ai_handler.go | 46 +++ .../api/ai_handler_recovery_wiring_test.go | 3 + internal/api/ai_handler_test.go | 176 ++++++++ internal/api/contract_test.go | 48 ++- internal/api/router.go | 19 + .../router_routes_ai_test_connection_test.go | 22 +- .../router_routes_ai_test_provider_test.go | 14 +- 26 files changed, 1093 insertions(+), 99 deletions(-) create mode 100644 internal/ai/patrol_assistant_handoff.go create mode 100644 internal/ai/patrol_assistant_handoff_test.go diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 91111efd7..6985d5e34 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -1028,6 +1028,12 @@ approval lifecycle metadata, action plan identity, approval policy, or dry-run posture, those fields remain Assistant review context and must not be treated as an agent command grant, enrollment state, update readiness, or fleet-control decision. +Patrol run handoffs through that same shared handler and `internal/api/router.go` +remain metadata-driven Assistant review context only. The router may expose a +run-history lookup so AI runtime can rebuild model-only run context, but the +resulting scoped resources, runtime failure summaries, and follow-up session +metadata must not become agent command authorization, reachability proof, +install state, profile assignment, or fleet lifecycle evidence. That same shared dependency now also assumes hosted cloud handoff authorizes tenant org access before browser lifecycle continues. Lifecycle-adjacent opens into hosted workspaces may depend on `internal/api/cloud_handoff_handlers.go`, diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 16f634aab..852940761 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -217,7 +217,15 @@ runtime cost control, and shared AI transport surfaces. preserving the user's authored prompt as the persisted conversation message; the model-only handoff may persist as session metadata so same-session follow-up turns keep the Patrol finding context without - mutating saved user messages. When the handoff identifies a resource, the + mutating saved user messages. Patrol run-history handoffs follow the same + backend-owned context rule: the browser may seed only safe `patrol_run` + metadata such as run ID/type/status/runtime-failure posture, while + `/api/ai/chat` must rehydrate model-only run context, scoped resources, and + safe failure detail from the current Patrol run record before model + execution and again on same-session follow-up turns. If the Patrol run no + longer resolves, browser-authored run context, resources, and actions must + be dropped rather than used as fallback provider context. When the handoff + identifies a resource, the runtime may also seed the session's resolved-resource scope, but only through canonical unified-resource tool registration so allowed actions, executors, and explicit-access checks stay governed. Structured handoff resource diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 5353916c1..4430c7786 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -215,7 +215,14 @@ product API routes free of maintainer commercial analytics. collapsed into one finding because a safe action reference contains a finding ID. `patrol_configuration_failure` may carry only the runtime-failure boolean needed for drawer/session presentation, and - run-specific fields remain reserved for `patrol_run`. + run-specific fields remain reserved for `patrol_run`. Chat requests that + carry `handoff_metadata.kind=patrol_run` are identity envelopes, not + browser-authored model context: the API handler must resolve the run ID + through the backend Patrol service, rebuild the model-only run context and + resources server-side, and ignore request-side run context, resources, or + actions when the run cannot be resolved. Stored session metadata must be + readable by the handler so follow-up turns can rehydrate the same backend + context without asking the browser to resend provider-bound payloads. 34. `internal/api/ai_handlers.go` shared with `ai-runtime`: AI settings and remediation handlers are both an AI runtime control surface and a canonical API payload contract boundary. Provider test responses from `/api/ai/test` and provider-specific `/api/ai/test/{provider}` preflight responses must return one safe diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 1ea154ec4..d89f166a6 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -192,7 +192,12 @@ work extends shared components instead of creating new local variants. action context rather than pending approval. A restored Patrol run summary must remain visibly sourced to Pulse Patrol, restore a `patrol-run` target plus run ID/type/status/runtime-failure presentation only, and must not - rehydrate model-only runtime failure detail into browser context. Restored + rehydrate model-only runtime failure detail into browser context. New Patrol + run requests follow the same drawer boundary: source-owned prompt text and + briefing copy may show classified, redacted failure summaries for operator + review, but `handoffContext`, `handoffResources`, and `handoffActions` for + run-history context must stay absent from the browser request so the backend + can rebuild model-bound context from the stored Patrol run. Restored Patrol assessment and Patrol configuration-failure sessions follow the same safe-summary rule: the drawer may restore source label, title, target type, status badge, action label, and suggested prompts from `handoff_summary`, but diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 24a115edd..664f7a4c6 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -257,14 +257,17 @@ Patrol-specific presentation helpers. names a finding. Patrol run-history entries may also open Assistant for a selected run, but that handoff must flow through the same Patrol-owned investigation-context - model rather than a row-local prompt. The handoff may pass bounded run - history facts such as run ID/type/status, trigger, timing, coverage, scope, - findings snapshot availability, outcome counts, tool/triage effort, - `error_summary`, `error_detail`, sanitized Patrol analysis, and scoped - resource references as model-only context. It must also send safe + model rather than a row-local prompt. The browser-visible prompt and drawer + briefing may include only classified, redacted runtime-failure summaries and + safe run identity facts. The browser request must send safe `handoff_metadata` for the saved-session identity envelope: kind `patrol_run`, run ID, safe run type/status, and a runtime-failure boolean, - never runtime failure detail or analysis text. It must force request-local + never provider-bound runtime failure detail, analysis text, tool traces, raw + provider payloads, or scoped resource context. Backend Assistant runtime owns + the model-only run context: it resolves the run ID from Patrol history, + rebuilds bounded run facts, scoped resource references, sanitized analysis, + and classified failure detail server-side, and rehydrates the same context + from stored metadata on follow-up turns. It must force request-local approval-required mode, present a source-named visible drawer briefing, and frame Assistant as explanation and next-step review rather than execution or automatic retry authority. diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index a4e67b531..21dcd1597 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -174,6 +174,12 @@ regression protection. the same bounded-work rule: the callback may copy the already-materialized durable record into unified findings, but it must not add broad resource scans, model calls, or persistence walks to protected request setup paths. + Patrol run Assistant handoff wiring in `internal/api/router.go` follows the + same protected hot-path rule: the shared callback may resolve one requested + Patrol run ID from the already-owned Patrol service and strip tool traces + before returning it to AI runtime, but it must not scan run history broadly, + hydrate resource inventories, call models, or perform persistence fan-out as + part of router setup or generic request admission. Retiring self-hosted trial acquisition follows that same rule: removing `/auth/trial-activate` and `POST /api/license/trial/start` from public-path and CSRF inventories must stay as constant-time route-table absence rather diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index bda2bf167..e48ec5f67 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -416,6 +416,12 @@ bypass the API fail-closed execution gate. model-only context remains secondary to backend-refreshed finding context and must not become storage freshness, restore eligibility, recovery execution authority, or a storage-local approval shortcut. + Patrol run chat handoffs through that same shared handler follow the same + adjacent-boundary rule. A run ID may let AI runtime rebuild model-only run + context from Patrol history, but scoped storage resources or runtime failure + details attached to that briefing are review context only and must not + become backup freshness evidence, restore eligibility, storage health truth, + or recovery execution authority. Alert, incident, and Patrol assessment Assistant handoffs that send bounded model-only `handoff_context`, `handoff_resources`, or `handoff_actions` through `/api/ai/chat` without a `finding_id` stay on that same adjacent diff --git a/frontend-modern/src/components/patrol/RunHistoryEntry.tsx b/frontend-modern/src/components/patrol/RunHistoryEntry.tsx index 5cd819767..9d8e518d8 100644 --- a/frontend-modern/src/components/patrol/RunHistoryEntry.tsx +++ b/frontend-modern/src/components/patrol/RunHistoryEntry.tsx @@ -8,6 +8,7 @@ import { aiChatStore } from '@/stores/aiChat'; import { RunToolCallTrace } from './RunToolCallTrace'; import { formatDurationMs, + formatPatrolRuntimeFailureDetail, formatTriggerReason, formatScope, getCanonicalScopeResourceIds, @@ -162,8 +163,8 @@ export function RunHistoryEntry(props: RunHistoryEntryProps) { run.error_count, hasFindingsSnapshot, ); - const runErrorSummary = String(run.error_summary || '').trim(); - const runErrorDetail = String(run.error_detail || '').trim(); + const runErrorSummary = formatPatrolRuntimeFailureDetail(run.error_summary); + const runErrorDetail = formatPatrolRuntimeFailureDetail(run.error_detail); const hasRunErrorDetail = run.error_count > 0 && (runErrorSummary || runErrorDetail); const primaryAction = getPatrolRunPrimaryActionPresentation(run); const handleDiscussRun = (event: Event) => { diff --git a/frontend-modern/src/components/patrol/__tests__/RunHistoryEntry.test.tsx b/frontend-modern/src/components/patrol/__tests__/RunHistoryEntry.test.tsx index 272fd6261..fbdc25622 100644 --- a/frontend-modern/src/components/patrol/__tests__/RunHistoryEntry.test.tsx +++ b/frontend-modern/src/components/patrol/__tests__/RunHistoryEntry.test.tsx @@ -223,7 +223,9 @@ describe('RunHistoryEntry', () => { )); expect(screen.getByText('Selected model does not support Patrol tools')).toBeInTheDocument(); - expect(screen.getByText(/tool_choice/)).toBeInTheDocument(); + expect(screen.getByText(/Provider rejected Patrol tool calls/)).toBeInTheDocument(); + expect(screen.queryByText(/tool_choice/)).toBeNull(); + expect(screen.queryByText(/No endpoints found/)).toBeNull(); expect(screen.getByText('error')).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'Open Patrol provider settings' })).toHaveAttribute( 'href', @@ -280,11 +282,20 @@ describe('RunHistoryEntry', () => { const [prompt, context] = openWithPromptMock.mock.calls[0]; expect(prompt).toContain('Discuss this Pulse Patrol run'); expect(prompt).toContain('Start by explaining the Patrol runtime failure'); + expect(prompt).toContain('Provider rejected Patrol tool calls'); + expect(prompt).not.toContain('tool_choice'); + expect(prompt).not.toContain('No endpoints found'); expect(context).toMatchObject({ targetType: 'patrol-run', targetId: 'run-runtime-error', autonomousMode: false, - handoffResources: [{ id: 'vm-100', type: 'vm' }], + handoffMetadata: { + kind: 'patrol_run', + runId: 'run-runtime-error', + runType: 'Scoped run', + runStatus: 'error', + runtimeFailure: true, + }, context: { source: 'pulse-patrol-run', runId: 'run-runtime-error', @@ -292,13 +303,14 @@ describe('RunHistoryEntry', () => { handoffResourceCount: 1, }, }); - expect(context.handoffContext).toContain('[Patrol Run Context]'); - expect(context.handoffContext).toContain('Runtime Failure: Selected model'); - expect(context.handoffContext).toContain('tool_choice'); + expect(context.handoffContext).toBeUndefined(); + expect(context.handoffResources).toBeUndefined(); expect(context.briefing).toMatchObject({ title: 'Patrol run attached', actionLabel: 'Review Patrol runtime failure', }); + expect(JSON.stringify(context)).not.toContain('tool_choice'); + expect(JSON.stringify(context)).not.toContain('No endpoints found'); }); it('keeps zero-coverage scoped runs on the shared coverage narrative', () => { diff --git a/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts b/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts index 38d26d1ac..f5a4990dc 100644 --- a/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts +++ b/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts @@ -413,6 +413,9 @@ describe('patrolInvestigationContextModel', () => { expect(handoff.prompt).toContain('Discuss this Pulse Patrol run'); expect(handoff.prompt).toContain('Start by explaining the Patrol runtime failure'); + expect(handoff.prompt).toContain('Provider rejected Patrol tool calls'); + expect(handoff.prompt).not.toContain('tool_choice'); + expect(handoff.prompt).not.toContain('No endpoints found'); expect(handoff.context.autonomousMode).toBe(false); expect(handoff.context).toMatchObject({ targetType: 'patrol-run', @@ -427,7 +430,7 @@ describe('patrolInvestigationContextModel', () => { handoffResourceCount: 1, }, }); - expect(handoff.context.handoffResources).toEqual([{ id: 'vm-100', type: 'vm' }]); + expect(handoff.context.handoffResources).toBeUndefined(); expect(handoff.context.handoffMetadata).toEqual({ kind: 'patrol_run', runId: 'run-runtime-error', @@ -435,12 +438,7 @@ describe('patrolInvestigationContextModel', () => { runStatus: 'error', runtimeFailure: true, }); - expect(handoff.context.handoffContext).toContain('[Patrol Run Context]'); - expect(handoff.context.handoffContext).toContain('Source: Pulse Patrol run history'); - expect(handoff.context.handoffContext).toContain('Run Type: Scoped run'); - expect(handoff.context.handoffContext).toContain('Runtime Failure: Selected model'); - expect(handoff.context.handoffContext).toContain('tool_choice'); - expect(handoff.context.handoffContext).toContain('Patrol Analysis: Visible runtime summary.'); + expect(handoff.context.handoffContext).toBeUndefined(); expect(handoff.context.briefing).toMatchObject({ sourceLabel: 'Pulse Patrol', title: 'Patrol run attached', @@ -452,6 +450,8 @@ describe('patrolInvestigationContextModel', () => { ], }); expect(JSON.stringify(handoff)).not.toContain('provider trace'); + expect(JSON.stringify(handoff)).not.toContain('tool_choice'); + expect(JSON.stringify(handoff)).not.toContain('No endpoints found'); }); it('builds a model-only Assistant handoff for a Patrol configuration failure', () => { diff --git a/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts b/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts index d86ff2922..ffa548a5d 100644 --- a/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts +++ b/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts @@ -24,6 +24,7 @@ import { } from '@/utils/resourceCorrelationPresentation'; import { formatDurationMs, + formatPatrolRuntimeFailureSummary, formatScope, formatTriggerReason, getCanonicalScopeResourceIds, @@ -611,8 +612,6 @@ export function buildPatrolRunAssistantHandoff(run: PatrolRunRecord): PatrolRunA targetType: 'patrol-run', targetId: runId || undefined, autonomousMode: false, - handoffContext: buildPatrolRunAssistantModelContext(run, kindLabel, statusLabel), - handoffResources: handoffResources.length > 0 ? handoffResources : undefined, handoffMetadata: { kind: 'patrol_run', runId: runId || undefined, @@ -993,41 +992,6 @@ function buildPatrolRunAssistantPrompt( .join('\n\n'); } -function buildPatrolRunAssistantModelContext( - run: PatrolRunRecord, - kindLabel: string, - statusLabel: string, -): string { - const scope = formatScope(run); - const coverage = formatPatrolRunCoverage(run); - const runtimeFailure = formatPatrolRunRuntimeFailure(run); - const outcomes = formatPatrolRunOutcomes(run); - const timing = formatPatrolRunTiming(run); - const effort = formatPatrolRunEffort(run); - const analysis = truncateContextText(sanitizeAnalysis(run.ai_analysis), 500); - - return [ - '[Patrol Run Context]', - 'Source: Pulse Patrol run history', - formatContextLine('Run ID', run.id), - formatContextLine('Run Type', kindLabel), - formatContextLine('Status', statusLabel), - formatContextLine('Trigger', formatTriggerReason(run.trigger_reason)), - formatContextLine('Timing', timing), - formatContextLine('Coverage', coverage), - formatContextLine('Scope', scope), - formatContextLine('Findings Snapshot', formatPatrolRunFindingsSnapshot(run)), - formatContextLine('Outcomes', outcomes), - formatContextLine('Runtime Failure', runtimeFailure), - formatContextLine('Effort', effort), - formatContextLine('Findings Summary', run.findings_summary), - formatContextLine('Patrol Analysis', analysis), - 'Operator Boundary: This Patrol run handoff is model-only context for explanation and review. Configuration changes, diagnostics, remediation, and command execution require explicit governed operator action.', - ] - .filter(isNonEmptyString) - .join('\n'); -} - function buildPatrolRunAssistantBriefing( run: PatrolRunRecord, kindLabel: string, @@ -1184,19 +1148,11 @@ function buildPatrolRunHandoffResources(run: PatrolRunRecord): AIChatHandoffReso } function formatPatrolRunRuntimeFailure(run: PatrolRunRecord): string | undefined { - const summary = normalizeText(run.error_summary); - const detail = normalizeText(run.error_detail); - if (summary && detail && summary !== detail) { - return `${summary}: ${truncateContextText(detail, 260)}`; - } - if (summary || detail) { - return summary || detail; - } - const errorCount = normalizeNonNegativeCount(run.error_count); - if (errorCount > 0) { - return `${errorCount} Patrol runtime error${errorCount === 1 ? '' : 's'} recorded`; - } - return undefined; + return formatPatrolRuntimeFailureSummary({ + errorSummary: run.error_summary, + errorDetail: run.error_detail, + errorCount: run.error_count, + }); } function formatPatrolRunCoverage(run: PatrolRunRecord): string | undefined { @@ -1235,14 +1191,6 @@ function formatPatrolRunCoverage(run: PatrolRunRecord): string | undefined { ); } -function formatPatrolRunFindingsSnapshot(run: PatrolRunRecord): string { - if (run.finding_ids === undefined) { - return 'unavailable for this run'; - } - const count = run.finding_ids.length; - return `${count} finding ID${count === 1 ? '' : 's'} captured`; -} - function formatPatrolRunOutcomes(run: PatrolRunRecord): string | undefined { return formatBriefingStringList( [ diff --git a/frontend-modern/src/utils/__tests__/patrolFormat.test.ts b/frontend-modern/src/utils/__tests__/patrolFormat.test.ts index 4452600d6..6c88654c2 100644 --- a/frontend-modern/src/utils/__tests__/patrolFormat.test.ts +++ b/frontend-modern/src/utils/__tests__/patrolFormat.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest'; import { formatDurationMs, + formatPatrolRuntimeFailureDetail, + formatPatrolRuntimeFailureSummary, formatTriggerReason, formatScope, getCanonicalScopeResourceIds, @@ -179,6 +181,48 @@ describe('patrolFormat', () => { }); }); + describe('formatPatrolRuntimeFailureSummary', () => { + it('classifies provider billing payloads without exposing raw account details', () => { + const summary = formatPatrolRuntimeFailureSummary({ + errorSummary: 'Provider billing or quota issue', + errorDetail: + 'API error (402): {"error":{"message":"This request requires more credits. Visit https://openrouter.ai/settings/keys"},"user_id":"user_2rLolwcxpOSbgPEKJZuIxdW76pi"}', + errorCount: 1, + }); + + expect(summary).toContain('Provider billing or quota issue'); + expect(summary).toContain('insufficient credits or token budget'); + expect(summary).not.toContain('openrouter.ai/settings/keys'); + expect(summary).not.toContain('user_2rLolwcxpOSbgPEKJZuIxdW76pi'); + }); + + it('classifies tool-call failures without leaking provider routing payloads', () => { + const summary = formatPatrolRuntimeFailureSummary({ + errorSummary: 'Selected model does not support Patrol tools', + errorDetail: "No endpoints found that support the provided 'tool_choice' value.", + errorCount: 1, + }); + + expect(summary).toContain('Selected model does not support Patrol tools'); + expect(summary).toContain('Provider rejected Patrol tool calls'); + expect(summary).not.toContain('tool_choice'); + expect(summary).not.toContain('No endpoints found'); + }); + + it('redacts unknown provider details defensively', () => { + const detail = formatPatrolRuntimeFailureDetail( + 'provider returned Authorization: Bearer sk-live-secret and url https://example.invalid/path?token=abc for user_abc123', + ); + + expect(detail).toContain('[redacted-url]'); + expect(detail).toContain('Bearer [redacted-secret]'); + expect(detail).toContain('[redacted-user]'); + expect(detail).not.toContain('sk-live-secret'); + expect(detail).not.toContain('token=abc'); + expect(detail).not.toContain('user_abc123'); + }); + }); + describe('groupModelsByProvider', () => { it('groups models by provider prefix', () => { const models = [ diff --git a/frontend-modern/src/utils/patrolFormat.ts b/frontend-modern/src/utils/patrolFormat.ts index 655981ce7..058ce08f5 100644 --- a/frontend-modern/src/utils/patrolFormat.ts +++ b/frontend-modern/src/utils/patrolFormat.ts @@ -15,9 +15,7 @@ interface PartialRunRecord { type?: string; } -export function getCanonicalScopeResourceIds( - run?: PartialRunRecord | null, -): string[] | undefined { +export function getCanonicalScopeResourceIds(run?: PartialRunRecord | null): string[] | undefined { if (!run) return undefined; if (run.effective_scope_resource_ids !== undefined) { return run.effective_scope_resource_ids; @@ -75,6 +73,105 @@ export function sanitizeAnalysis(text: string | undefined): string { .trim(); } +export function formatPatrolRuntimeFailureDetail(text: string | undefined): string { + const raw = String(text || '') + .trim() + .replace(/\s+/g, ' '); + if (!raw) return ''; + + const lower = raw.toLowerCase(); + if ( + [ + 'provider billing or quota issue', + 'selected model does not support patrol tools', + 'provider rate limited', + 'provider authentication issue', + 'provider connection issue', + 'provider not ready', + 'selected model unavailable', + 'selected model context window too small', + ].includes(lower) + ) { + return raw; + } + + if ( + lower.includes('tool_choice') || + lower.includes('tool calling') || + lower.includes('tools are not supported') || + (lower.includes('no endpoints found') && lower.includes('tool')) + ) { + return 'Provider rejected Patrol tool calls. Choose a Patrol model and endpoint with tool-call support.'; + } + if ( + lower.includes('insufficient balance') || + lower.includes('402') || + lower.includes('payment required') || + lower.includes('quota') || + lower.includes('credit') || + lower.includes('max_tokens') + ) { + return 'Provider reported insufficient credits or token budget for the requested Patrol analysis.'; + } + if ( + lower.includes('rate limit') || + lower.includes('429') || + lower.includes('too many requests') + ) { + return 'Provider rate limit reached. Wait for capacity or adjust provider limits before retrying.'; + } + if ( + lower.includes('401') || + lower.includes('403') || + lower.includes('unauthorized') || + lower.includes('forbidden') || + lower.includes('api key') + ) { + return 'Provider authentication failed. Check the configured provider key and account access.'; + } + if ( + lower.includes('failed to connect') || + lower.includes('connection refused') || + lower.includes('no such host') || + lower.includes('i/o timeout') || + lower.includes('context deadline exceeded') || + lower.includes('timeout') || + lower.includes('returned status 5') + ) { + return 'Provider connection failed. Check provider reachability before retrying Patrol.'; + } + + return raw + .replace(/https?:\/\/[^\s"')]+/gi, '[redacted-url]') + .replace(/\buser_[A-Za-z0-9_-]+\b/g, '[redacted-user]') + .replace(/\bsk-[A-Za-z0-9_-]{8,}\b/g, '[redacted-secret]') + .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [redacted-secret]') + .replace( + /"((?:api[_-]?key|apikey|access[_-]?token|token|authorization|x-api-key|user[_-]?id))"\s*:\s*"[^"]+"/gi, + '"$1":"[redacted]"', + ) + .trim(); +} + +export function formatPatrolRuntimeFailureSummary(input: { + errorSummary?: string; + errorDetail?: string; + errorCount?: number; +}): string | undefined { + const summary = formatPatrolRuntimeFailureDetail(input.errorSummary); + const detail = formatPatrolRuntimeFailureDetail(input.errorDetail); + if (summary && detail && summary !== detail) { + return `${summary}: ${detail}`; + } + if (summary || detail) return summary || detail; + + const errorCount = Math.max(0, input.errorCount || 0); + if (errorCount > 0) { + return `${errorCount} Patrol runtime error${errorCount === 1 ? '' : 's'} recorded`; + } + return undefined; +} + export function groupModelsByProvider(models: ModelInfo[]): Map { const groups = new Map(); for (const model of models) { diff --git a/internal/ai/chat/service.go b/internal/ai/chat/service.go index f3b1dd75d..45b230f00 100644 --- a/internal/ai/chat/service.go +++ b/internal/ai/chat/service.go @@ -2165,6 +2165,20 @@ func (s *Service) GetModelHandoffFindingID(ctx context.Context, sessionID string return sessions.GetModelHandoffFindingID(sessionID) } +// GetModelHandoffMetadata returns the session-scoped product handoff identity +// used to refresh model-only Patrol context on follow-up turns. +func (s *Service) GetModelHandoffMetadata(ctx context.Context, sessionID string) (HandoffMetadata, error) { + s.mu.RLock() + sessions := s.sessions + s.mu.RUnlock() + + if sessions == nil { + return HandoffMetadata{}, fmt.Errorf("service not started") + } + + return sessions.GetModelHandoffMetadata(sessionID) +} + // ClearModelHandoffContext invalidates product-originated model-only handoff // state after its source record can no longer be resolved. Unpinned resolved // resources are cleared with it so stale Patrol handoffs cannot remain action diff --git a/internal/ai/chat/session.go b/internal/ai/chat/session.go index 4e5cac534..653e51031 100644 --- a/internal/ai/chat/session.go +++ b/internal/ai/chat/session.go @@ -775,6 +775,22 @@ func (s *SessionStore) GetModelHandoffFindingID(id string) (string, error) { return strings.TrimSpace(data.ModelContext.HandoffFindingID), nil } +// GetModelHandoffMetadata returns the browser-safe handoff identity stored for +// a session. +func (s *SessionStore) GetModelHandoffMetadata(id string) (HandoffMetadata, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + data, err := s.readSession(id) + if err != nil { + return HandoffMetadata{}, err + } + if data.ModelContext == nil { + return HandoffMetadata{}, nil + } + return NormalizeHandoffMetadata(data.ModelContext.HandoffMetadata), nil +} + // GetModelHandoffContext returns model-only handoff context for a session. func (s *SessionStore) GetModelHandoffContext(id string) (string, error) { s.mu.RLock() diff --git a/internal/ai/chat/session_additional_test.go b/internal/ai/chat/session_additional_test.go index 1d32773f8..ab1ee3d6f 100644 --- a/internal/ai/chat/session_additional_test.go +++ b/internal/ai/chat/session_additional_test.go @@ -452,6 +452,20 @@ func TestSessionStore_ListIncludesSafePatrolRunHandoffSummary(t *testing.T) { t.Fatalf("SetModelHandoffEnvelope failed: %v", err) } + metadata, err := store.GetModelHandoffMetadata(session.ID) + if err != nil { + t.Fatalf("GetModelHandoffMetadata failed: %v", err) + } + if metadata != (HandoffMetadata{ + Kind: sessionHandoffKindPatrolRun, + RunID: "run-runtime-error", + RunType: "Scoped run", + RunStatus: "error", + RuntimeFailure: true, + }) { + t.Fatalf("handoff metadata = %#v, want normalized Patrol run identity", metadata) + } + sessions, err := store.List() if err != nil { t.Fatalf("List failed: %v", err) diff --git a/internal/ai/patrol_assistant_handoff.go b/internal/ai/patrol_assistant_handoff.go new file mode 100644 index 000000000..a0523abef --- /dev/null +++ b/internal/ai/patrol_assistant_handoff.go @@ -0,0 +1,379 @@ +package ai + +import ( + "fmt" + "regexp" + "strings" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/ai/chat" +) + +const maxPatrolRunAssistantHandoffResources = 8 + +var patrolRunDSMLTracePattern = regexp.MustCompile(`<|DSML|[^>]*>[\s\S]*?]*>|<|DSML|[^>]*>`) + +// PatrolRunAssistantHandoff is the backend-owned, model-only Assistant context +// for a persisted Patrol run. The fields are safe to store as chat session +// metadata, but still carry no approval or execution authority. +type PatrolRunAssistantHandoff struct { + Context string + Resources []chat.HandoffResource + Metadata chat.HandoffMetadata +} + +// BuildPatrolRunAssistantHandoff converts a durable Patrol run record into the +// canonical Assistant handoff envelope. Frontend callers should pass only the +// run identity; the backend rehydrates this context from Patrol history. +func BuildPatrolRunAssistantHandoff(run PatrolRunRecord) PatrolRunAssistantHandoff { + run = normalizePatrolRunRecord(run) + runID := strings.TrimSpace(run.ID) + runType := patrolRunKindLabel(run.Type) + status := patrolRunStatusLabel(run) + runtimeFailure := patrolRunRuntimeFailureSummary(run) + resources := patrolRunAssistantHandoffResources(run) + + return PatrolRunAssistantHandoff{ + Context: buildPatrolRunAssistantContext(run, runType, status, runtimeFailure), + Resources: resources, + Metadata: chat.HandoffMetadata{ + Kind: "patrol_run", + RunID: runID, + RunType: runType, + RunStatus: status, + RuntimeFailure: runtimeFailure != "", + }, + } +} + +func buildPatrolRunAssistantContext(run PatrolRunRecord, runType, status, runtimeFailure string) string { + lines := []string{ + "[Patrol Run Context]", + "Source: Pulse Patrol run history", + formatPatrolRunContextLine("Run ID", run.ID), + formatPatrolRunContextLine("Run Type", runType), + formatPatrolRunContextLine("Status", status), + formatPatrolRunContextLine("Trigger", patrolTriggerReasonLabel(run.TriggerReason)), + formatPatrolRunContextLine("Timing", patrolRunTimingSummary(run)), + formatPatrolRunContextLine("Coverage", patrolRunCoverageSummary(run)), + formatPatrolRunContextLine("Scope", patrolRunScopeSummary(run)), + formatPatrolRunContextLine("Findings Snapshot", patrolRunFindingsSnapshot(run)), + formatPatrolRunContextLine("Outcomes", patrolRunOutcomeSummary(run)), + formatPatrolRunContextLine("Runtime Failure", runtimeFailure), + formatPatrolRunContextLine("Effort", patrolRunEffortSummary(run)), + formatPatrolRunContextLine("Findings Summary", run.FindingsSummary), + formatPatrolRunContextLine("Patrol Analysis", truncatePatrolRunContextText(sanitizePatrolRunAnalysis(run.AIAnalysis), 500)), + "Operator Boundary: This Patrol run handoff is model-only context for explanation and review. Configuration changes, diagnostics, remediation, and command execution require explicit governed operator action.", + } + + filtered := make([]string, 0, len(lines)) + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" { + filtered = append(filtered, line) + } + } + return strings.Join(filtered, "\n") +} + +func patrolRunAssistantHandoffResources(run PatrolRunRecord) []chat.HandoffResource { + ids := run.EffectiveScopeResourceIDs + if ids == nil { + ids = run.ScopeResourceIDs + } + if len(ids) == 0 { + return nil + } + + resourceType := "" + if len(run.ScopeResourceTypes) == 1 { + resourceType = strings.TrimSpace(run.ScopeResourceTypes[0]) + } + + resources := make([]chat.HandoffResource, 0, min(len(ids), maxPatrolRunAssistantHandoffResources)) + seen := make(map[string]struct{}, len(ids)) + for _, id := range ids { + id = strings.TrimSpace(id) + if id == "" { + continue + } + key := strings.ToLower(id) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + resources = append(resources, chat.HandoffResource{ + ID: id, + Type: resourceType, + }) + if len(resources) >= maxPatrolRunAssistantHandoffResources { + break + } + } + if len(resources) == 0 { + return nil + } + return resources +} + +func patrolRunRuntimeFailureSummary(run PatrolRunRecord) string { + summary := strings.TrimSpace(redactPatrolRuntimeFailureDetail(run.ErrorSummary)) + detail := strings.TrimSpace(summarizePatrolRuntimeFailureDetail(run.ErrorDetail)) + if summary != "" && detail != "" && summary != detail { + return summary + ": " + truncatePatrolRunContextText(detail, 260) + } + if summary != "" { + return summary + } + if detail != "" { + return truncatePatrolRunContextText(detail, 260) + } + if run.ErrorCount > 0 { + suffix := "" + if run.ErrorCount != 1 { + suffix = "s" + } + return fmt.Sprintf("%d Patrol runtime error%s recorded", run.ErrorCount, suffix) + } + return "" +} + +func patrolRunKindLabel(runType string) string { + switch strings.ToLower(strings.TrimSpace(runType)) { + case "scoped": + return "Scoped run" + case "verification": + return "Verification check" + case "", "patrol", "full", "scheduled": + return "Full patrol" + default: + return "Patrol run" + } +} + +func patrolRunStatusLabel(run PatrolRunRecord) string { + status := strings.ToLower(strings.TrimSpace(run.Status)) + if run.ErrorCount > 0 && (status == "" || status == "healthy" || status == "completed") { + status = "error" + } + switch status { + case "issues_found": + return "issues found" + case "critical", "error", "healthy": + return status + case "": + return "unknown" + default: + return strings.ReplaceAll(status, "_", " ") + } +} + +func patrolTriggerReasonLabel(reason string) string { + switch strings.TrimSpace(reason) { + case "scheduled": + return "Scheduled" + case "manual": + return "Manual" + case "startup": + return "Startup" + case "alert_fired": + return "Alert fired" + case "alert_cleared": + return "Alert cleared" + case "anomaly": + return "Anomaly" + case "user_action": + return "User action" + case "config_changed": + return "Config change" + case "": + return "" + default: + return strings.ReplaceAll(strings.TrimSpace(reason), "_", " ") + } +} + +func patrolRunTimingSummary(run PatrolRunRecord) string { + parts := []string{} + if !run.StartedAt.IsZero() { + parts = append(parts, "started "+run.StartedAt.UTC().Format(time.RFC3339)) + } + if !run.CompletedAt.IsZero() { + parts = append(parts, "completed "+run.CompletedAt.UTC().Format(time.RFC3339)) + } + durationMs := run.DurationMs + if durationMs == 0 && run.Duration > 0 { + durationMs = run.Duration.Milliseconds() + } + if formatted := formatPatrolRunDurationMs(durationMs); formatted != "" { + parts = append(parts, "duration "+formatted) + } + return strings.Join(parts, "; ") +} + +func formatPatrolRunDurationMs(ms int64) string { + if ms <= 0 { + return "" + } + if ms < 1000 { + return fmt.Sprintf("%dms", ms) + } + seconds := (ms + 500) / 1000 + if seconds < 60 { + return fmt.Sprintf("%ds", seconds) + } + minutes := (seconds + 30) / 60 + return fmt.Sprintf("%dm", minutes) +} + +func patrolRunCoverageSummary(run PatrolRunRecord) string { + resourcesChecked := max(run.ResourcesChecked, 0) + scopedResourceCount := len(run.EffectiveScopeResourceIDs) + if run.EffectiveScopeResourceIDs == nil { + scopedResourceCount = len(run.ScopeResourceIDs) + } + if scopedResourceCount > 0 { + if resourcesChecked > 0 && resourcesChecked < scopedResourceCount { + return fmt.Sprintf("Checked %d of %d scoped resources", resourcesChecked, scopedResourceCount) + } + if resourcesChecked > 0 { + return fmt.Sprintf("Checked %s", formatPatrolRunResourceCount(resourcesChecked, "scoped")) + } + } + if resourcesChecked > 0 { + return fmt.Sprintf("Checked %s", formatPatrolRunResourceCount(resourcesChecked, "")) + } + + return joinPatrolRunContextParts([]string{ + countPatrolRunFact(run.NodesChecked, "nodes"), + countPatrolRunFact(run.GuestsChecked, "VMs"), + countPatrolRunFact(run.DockerChecked, "containers"), + countPatrolRunFact(run.StorageChecked, "storage resources"), + countPatrolRunFact(run.HostsChecked, "agents"), + countPatrolRunFact(run.TrueNASChecked, "TrueNAS systems"), + countPatrolRunFact(run.KubernetesChecked, "Kubernetes resources"), + }) +} + +func formatPatrolRunResourceCount(count int, qualifier string) string { + label := "resources" + if count == 1 { + label = "resource" + } + if strings.TrimSpace(qualifier) != "" { + return fmt.Sprintf("%d %s %s", count, qualifier, label) + } + return fmt.Sprintf("%d %s", count, label) +} + +func patrolRunScopeSummary(run PatrolRunRecord) string { + ids := run.EffectiveScopeResourceIDs + if ids == nil { + ids = run.ScopeResourceIDs + } + if len(ids) > 0 { + suffix := "" + if len(ids) != 1 { + suffix = "s" + } + return fmt.Sprintf("Scoped to %d resource%s", len(ids), suffix) + } + if len(run.ScopeResourceTypes) > 0 { + return "Scoped to " + strings.Join(run.ScopeResourceTypes, ", ") + } + if strings.EqualFold(strings.TrimSpace(run.Type), "scoped") { + return "Scoped" + } + return "" +} + +func patrolRunFindingsSnapshot(run PatrolRunRecord) string { + count := len(run.FindingIDs) + suffix := "" + if count != 1 { + suffix = "s" + } + return fmt.Sprintf("%d finding ID%s captured", count, suffix) +} + +func patrolRunOutcomeSummary(run PatrolRunRecord) string { + return joinPatrolRunContextParts([]string{ + countPatrolRunSingularFact(run.NewFindings, "new finding"), + countPatrolRunSingularFact(run.ExistingFindings, "existing finding"), + countPatrolRunSingularFact(run.ResolvedFindings, "resolved finding"), + countPatrolRunSingularFact(run.RejectedFindings, "rejected finding"), + countPatrolRunSingularFact(run.AutoFixCount, "auto-remediation"), + countPatrolRunSingularFact(run.ErrorCount, "error"), + }) +} + +func patrolRunEffortSummary(run PatrolRunRecord) string { + tokenCount := max(run.InputTokens, 0) + max(run.OutputTokens, 0) + parts := []string{ + countPatrolRunSingularFact(run.ToolCallCount, "tool call"), + countPatrolRunSingularFact(run.TriageFlags, "triage flag"), + } + if run.TriageSkippedLLM { + parts = append(parts, "LLM skipped for deterministic triage") + } + if tokenCount > 0 { + parts = append(parts, fmt.Sprintf("%d tokens", tokenCount)) + } + return joinPatrolRunContextParts(parts) +} + +func countPatrolRunFact(value int, label string) string { + if value <= 0 { + return "" + } + return fmt.Sprintf("%d %s", value, label) +} + +func countPatrolRunSingularFact(value int, singular string) string { + if value <= 0 { + return "" + } + suffix := "" + if value != 1 { + suffix = "s" + } + return fmt.Sprintf("%d %s%s", value, singular, suffix) +} + +func joinPatrolRunContextParts(parts []string) string { + filtered := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + filtered = append(filtered, part) + } + } + return strings.Join(filtered, "; ") +} + +func formatPatrolRunContextLine(label, value string) string { + value = truncatePatrolRunContextText(value, 500) + if value == "" { + return "" + } + return label + ": " + value +} + +func sanitizePatrolRunAnalysis(text string) string { + if strings.TrimSpace(text) == "" { + return "" + } + analysis := patrolRunDSMLTracePattern.ReplaceAllString(text, "") + return strings.TrimSpace(redactPatrolRuntimeFailureDetail(analysis)) +} + +func truncatePatrolRunContextText(value string, limit int) string { + value = strings.Join(strings.Fields(strings.TrimSpace(value)), " ") + if value == "" || len(value) <= limit { + return value + } + if limit <= 3 { + return strings.TrimSpace(value[:limit]) + } + return strings.TrimSpace(value[:limit-3]) + "..." +} diff --git a/internal/ai/patrol_assistant_handoff_test.go b/internal/ai/patrol_assistant_handoff_test.go new file mode 100644 index 000000000..7cc425eef --- /dev/null +++ b/internal/ai/patrol_assistant_handoff_test.go @@ -0,0 +1,72 @@ +package ai + +import ( + "strings" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/ai/chat" +) + +func TestBuildPatrolRunAssistantHandoffUsesBackendSafeRunContext(t *testing.T) { + run := PatrolRunRecord{ + ID: "run-runtime-error", + StartedAt: time.Date(2026, 5, 7, 12, 0, 0, 0, time.UTC), + CompletedAt: time.Date(2026, 5, 7, 12, 0, 3, 0, time.UTC), + DurationMs: 3000, + Type: "scoped", + TriggerReason: "alert_fired", + EffectiveScopeResourceIDs: []string{"vm-100"}, + ScopeResourceTypes: []string{"vm"}, + ResourcesChecked: 1, + GuestsChecked: 1, + FindingsSummary: "Runtime failure prevented analysis.", + FindingIDs: []string{}, + ErrorCount: 1, + ErrorSummary: "Selected model does not support Patrol tools", + ErrorDetail: `API error: No endpoints found that support the provided tool_choice value. Authorization: Bearer sk-live-secret`, + Status: "error", + ToolCallCount: 1, + AIAnalysis: `<|DSML|trace>provider traceVisible runtime summary. {"api_key":"sk-json-secret"}`, + } + + handoff := BuildPatrolRunAssistantHandoff(run) + + if handoff.Metadata != (chat.HandoffMetadata{ + Kind: "patrol_run", + RunID: "run-runtime-error", + RunType: "Scoped run", + RunStatus: "error", + RuntimeFailure: true, + }) { + t.Fatalf("metadata = %+v", handoff.Metadata) + } + if got, want := handoff.Resources, []chat.HandoffResource{{ID: "vm-100", Type: "vm"}}; len(got) != len(want) || got[0] != want[0] { + t.Fatalf("resources = %+v, want %+v", got, want) + } + + for _, required := range []string{ + "[Patrol Run Context]", + "Source: Pulse Patrol run history", + "Run ID: run-runtime-error", + "Run Type: Scoped run", + "Trigger: Alert fired", + "Runtime Failure: Selected model does not support Patrol tools", + "Provider rejected Patrol tool calls", + "Patrol Analysis: Visible runtime summary.", + "Operator Boundary:", + } { + if !strings.Contains(handoff.Context, required) { + t.Fatalf("context missing %q in:\n%s", required, handoff.Context) + } + } + if strings.Contains(handoff.Context, "provider trace") { + t.Fatalf("context leaked provider trace: %s", handoff.Context) + } + if strings.Contains(handoff.Context, "sk-live-secret") || strings.Contains(handoff.Context, "sk-json-secret") { + t.Fatalf("context leaked secret-shaped provider detail: %s", handoff.Context) + } + if strings.Contains(handoff.Context, "tool_choice") || strings.Contains(handoff.Context, "No endpoints found") { + t.Fatalf("context leaked raw provider routing detail: %s", handoff.Context) + } +} diff --git a/internal/ai/patrol_runtime_failure.go b/internal/ai/patrol_runtime_failure.go index 02c92f6fd..1a271edab 100644 --- a/internal/ai/patrol_runtime_failure.go +++ b/internal/ai/patrol_runtime_failure.go @@ -30,6 +30,22 @@ var patrolRuntimeFailureDetailRedactors = []struct { pattern: regexp.MustCompile(`(?i)(https?://)[^\s/@:]+:[^\s/@]+@`), replacement: `${1}[redacted]@`, }, + { + pattern: regexp.MustCompile(`(?i)https?://[^\s"')]+`), + replacement: `[redacted-url]`, + }, + { + pattern: regexp.MustCompile(`\buser_[A-Za-z0-9_-]+\b`), + replacement: `[redacted-user]`, + }, + { + pattern: regexp.MustCompile(`\bsk-[A-Za-z0-9_-]{8,}\b`), + replacement: `[redacted-secret]`, + }, + { + pattern: regexp.MustCompile(`(?i)("(?:user[_-]?id)"\s*:\s*")[^"]+`), + replacement: `${1}[redacted]`, + }, } type patrolRuntimeFailure struct { @@ -171,6 +187,48 @@ func redactPatrolRuntimeFailureDetail(raw string) string { return redacted } +func summarizePatrolRuntimeFailureDetail(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + lower := strings.ToLower(raw) + switch { + case strings.Contains(lower, "tool_choice") || + strings.Contains(lower, "tool calling") || + strings.Contains(lower, "tools are not supported") || + strings.Contains(lower, "no endpoints found") && strings.Contains(lower, "tool"): + return "Provider rejected Patrol tool calls. Choose a Patrol model and endpoint with tool-call support." + case strings.Contains(lower, "insufficient balance") || + strings.Contains(lower, "402") || + strings.Contains(lower, "payment required") || + strings.Contains(lower, "quota") || + strings.Contains(lower, "credit") || + strings.Contains(lower, "max_tokens"): + return "Provider reported insufficient credits or token budget for the requested Patrol analysis." + case strings.Contains(lower, "rate limit") || + strings.Contains(lower, "429") || + strings.Contains(lower, "too many requests"): + return "Provider rate limit reached. Wait for capacity or adjust provider limits before retrying." + case strings.Contains(lower, "401") || + strings.Contains(lower, "403") || + strings.Contains(lower, "unauthorized") || + strings.Contains(lower, "forbidden") || + strings.Contains(lower, "api key"): + return "Provider authentication failed. Check the configured provider key and account access." + case strings.Contains(lower, "failed to connect") || + strings.Contains(lower, "connection refused") || + strings.Contains(lower, "no such host") || + strings.Contains(lower, "i/o timeout") || + strings.Contains(lower, "context deadline exceeded") || + strings.Contains(lower, "timeout") || + strings.Contains(lower, "returned status 5"): + return "Provider connection failed. Check provider reachability before retrying Patrol." + default: + return strings.TrimSpace(redactPatrolRuntimeFailureDetail(raw)) + } +} + func newPatrolRuntimeFailureFinding(failure patrolRuntimeFailure, now time.Time) *Finding { return &Finding{ ID: generateFindingID(patrolRuntimeResourceID, "reliability", patrolRuntimeFindingKey), diff --git a/internal/api/ai_handler.go b/internal/api/ai_handler.go index 43b20e8ef..f1e47d045 100644 --- a/internal/api/ai_handler.go +++ b/internal/api/ai_handler.go @@ -13,6 +13,7 @@ import ( "fmt" "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" + airuntime "github.com/rcourtman/pulse-go-rewrite/internal/ai" "github.com/rcourtman/pulse-go-rewrite/internal/ai/approval" "github.com/rcourtman/pulse-go-rewrite/internal/ai/chat" "github.com/rcourtman/pulse-go-rewrite/internal/ai/tools" @@ -44,6 +45,7 @@ type AIService interface { DeleteSession(ctx context.Context, sessionID string) error GetMessages(ctx context.Context, sessionID string) ([]chat.Message, error) GetModelHandoffFindingID(ctx context.Context, sessionID string) (string, error) + GetModelHandoffMetadata(ctx context.Context, sessionID string) (chat.HandoffMetadata, error) ClearModelHandoffContext(ctx context.Context, sessionID string) error AbortSession(ctx context.Context, sessionID string) error SummarizeSession(ctx context.Context, sessionID string) (map[string]interface{}, error) @@ -76,6 +78,8 @@ type AIService interface { GetBaseURL() string } +type patrolRunHandoffProvider func(context.Context, string) (airuntime.PatrolRunRecord, bool) + // AIHandler handles all AI endpoints using direct AI integration type AIHandler struct { stateMu sync.RWMutex @@ -101,6 +105,7 @@ type AIHandler struct { approvalStoreDir string approvalStoreStop context.CancelFunc controlLevelResolver func(context.Context, *config.AIConfig) string + patrolRunProvider patrolRunHandoffProvider } // newChatService is the factory function for creating the AI service. @@ -137,6 +142,29 @@ func NewAIHandler(mtp *config.MultiTenantPersistence, mtm *monitoring.MultiTenan } } +// SetPatrolRunHandoffProvider wires the Patrol history owner into Assistant +// chat handoffs without making the browser reconstruct model context. +func (h *AIHandler) SetPatrolRunHandoffProvider(provider patrolRunHandoffProvider) { + h.stateMu.Lock() + defer h.stateMu.Unlock() + h.patrolRunProvider = provider +} + +func (h *AIHandler) getPatrolRunForHandoff(ctx context.Context, runID string) (airuntime.PatrolRunRecord, bool) { + runID = strings.TrimSpace(runID) + if runID == "" { + return airuntime.PatrolRunRecord{}, false + } + + h.stateMu.RLock() + provider := h.patrolRunProvider + h.stateMu.RUnlock() + if provider == nil { + return airuntime.PatrolRunRecord{}, false + } + return provider(ctx, runID) +} + func (h *AIHandler) stateRefs() ( *config.MultiTenantPersistence, *monitoring.MultiTenantMonitor, @@ -2521,6 +2549,24 @@ func (h *AIHandler) HandleChat(w http.ResponseWriter, r *http.Request) { findingID = strings.TrimSpace(storedFindingID) } } + if findingID == "" && handoffMetadata == (chat.HandoffMetadata{}) && strings.TrimSpace(req.SessionID) != "" { + if storedMetadata, err := svc.GetModelHandoffMetadata(ctx, req.SessionID); err != nil { + log.Debug().Err(err).Str("session_id", req.SessionID).Msg("Unable to load stored Assistant handoff metadata") + } else { + handoffMetadata = chat.NormalizeHandoffMetadata(storedMetadata) + } + } + if findingID == "" && handoffMetadata.Kind == "patrol_run" { + handoffContext = "" + handoffResources = nil + handoffActions = nil + if run, ok := h.getPatrolRunForHandoff(ctx, handoffMetadata.RunID); ok { + runHandoff := airuntime.BuildPatrolRunAssistantHandoff(run) + handoffContext = runHandoff.Context + handoffResources = runHandoff.Resources + handoffMetadata = chat.NormalizeHandoffMetadata(runHandoff.Metadata) + } + } if findingID != "" { findingResolved := false orgID := GetOrgID(ctx) diff --git a/internal/api/ai_handler_recovery_wiring_test.go b/internal/api/ai_handler_recovery_wiring_test.go index 32b4e3f5f..315d588e1 100644 --- a/internal/api/ai_handler_recovery_wiring_test.go +++ b/internal/api/ai_handler_recovery_wiring_test.go @@ -46,6 +46,9 @@ func (s *capturingAIService) GetMessages(ctx context.Context, sessionID string) func (s *capturingAIService) GetModelHandoffFindingID(ctx context.Context, sessionID string) (string, error) { return "", nil } +func (s *capturingAIService) GetModelHandoffMetadata(ctx context.Context, sessionID string) (chat.HandoffMetadata, error) { + return chat.HandoffMetadata{}, nil +} func (s *capturingAIService) ClearModelHandoffContext(ctx context.Context, sessionID string) error { return nil } diff --git a/internal/api/ai_handler_test.go b/internal/api/ai_handler_test.go index b38eaf159..550cf44c9 100644 --- a/internal/api/ai_handler_test.go +++ b/internal/api/ai_handler_test.go @@ -12,6 +12,7 @@ import ( "time" "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" + airuntime "github.com/rcourtman/pulse-go-rewrite/internal/ai" "github.com/rcourtman/pulse-go-rewrite/internal/ai/approval" "github.com/rcourtman/pulse-go-rewrite/internal/ai/chat" "github.com/rcourtman/pulse-go-rewrite/internal/ai/unified" @@ -93,6 +94,19 @@ func (m *MockAIService) GetModelHandoffFindingID(ctx context.Context, sessionID return "", nil } +func (m *MockAIService) GetModelHandoffMetadata(ctx context.Context, sessionID string) (chat.HandoffMetadata, error) { + for _, call := range m.ExpectedCalls { + if call.Method == "GetModelHandoffMetadata" { + args := m.Called(ctx, sessionID) + if args.Get(0) == nil { + return chat.HandoffMetadata{}, args.Error(1) + } + return args.Get(0).(chat.HandoffMetadata), args.Error(1) + } + } + return chat.HandoffMetadata{}, nil +} + func (m *MockAIService) ClearModelHandoffContext(ctx context.Context, sessionID string) error { for _, call := range m.ExpectedCalls { if call.Method == "ClearModelHandoffContext" { @@ -662,6 +676,168 @@ func TestHandleChat_PassesPatrolRunHandoffMetadata(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) } +func TestHandleChat_DropsBrowserPatrolRunHandoffContextWhenRunUnavailable(t *testing.T) { + cfg := &config.Config{} + h := newTestAIHandler(cfg, nil, nil) + mockSvc := new(MockAIService) + h.defaultService = mockSvc + + mockSvc.On("IsRunning").Return(true) + mockSvc. + On("ExecuteStream", mock.Anything, mock.Anything, mock.Anything). + Return(nil). + Run(func(args mock.Arguments) { + reqArg := args.Get(1).(chat.ExecuteRequest) + assert.Equal(t, "discuss run", reqArg.Prompt) + assert.Empty(t, reqArg.HandoffContext) + assert.Empty(t, reqArg.HandoffResources) + assert.Empty(t, reqArg.HandoffActions) + assert.Equal(t, chat.HandoffMetadata{ + Kind: "patrol_run", + RunID: "run-missing", + RunType: "Scoped run", + RunStatus: "error", + RuntimeFailure: true, + }, reqArg.HandoffMetadata) + }) + + body := `{"prompt":"discuss run","handoff_context":"[Patrol Run Context]\nSource: browser-authored stale context\nRuntime Failure: leaked provider detail","handoff_resources":[{"id":"storage-999","type":"storage"}],"handoff_actions":[{"description":"stale action","target_resource_id":"vm-1"}],"handoff_metadata":{"kind":"patrol_run","run_id":"run-missing","run_type":"Scoped run","run_status":"error","runtime_failure":true}}` + req := httptest.NewRequest("POST", "/api/ai/chat", strings.NewReader(body)) + w := httptest.NewRecorder() + + h.HandleChat(w, req) + + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestHandleChat_RehydratesPatrolRunHandoffContextFromBackend(t *testing.T) { + cfg := &config.Config{} + h := newTestAIHandler(cfg, nil, nil) + mockSvc := new(MockAIService) + h.defaultService = mockSvc + h.SetPatrolRunHandoffProvider(func(ctx context.Context, runID string) (airuntime.PatrolRunRecord, bool) { + assert.Equal(t, "run-runtime-error", runID) + return airuntime.PatrolRunRecord{ + ID: "run-runtime-error", + StartedAt: time.Date(2026, 5, 7, 12, 0, 0, 0, time.UTC), + CompletedAt: time.Date(2026, 5, 7, 12, 0, 3, 0, time.UTC), + DurationMs: 3000, + Type: "scoped", + TriggerReason: "alert_fired", + EffectiveScopeResourceIDs: []string{"vm-100"}, + ScopeResourceTypes: []string{"vm"}, + ResourcesChecked: 1, + GuestsChecked: 1, + FindingsSummary: "Runtime failure prevented analysis.", + FindingIDs: []string{}, + ErrorCount: 1, + ErrorSummary: "Selected model does not support Patrol tools", + ErrorDetail: "No endpoints found that support tool_choice.", + Status: "error", + AIAnalysis: "<|DSML|trace>provider traceVisible runtime summary.", + ToolCallCount: 1, + }, true + }) + + mockSvc.On("IsRunning").Return(true) + mockSvc. + On("ExecuteStream", mock.Anything, mock.Anything, mock.Anything). + Return(nil). + Run(func(args mock.Arguments) { + reqArg := args.Get(1).(chat.ExecuteRequest) + assert.Equal(t, "discuss run", reqArg.Prompt) + assert.Equal(t, "", reqArg.FindingID) + assert.Contains(t, reqArg.HandoffContext, "[Patrol Run Context]") + assert.Contains(t, reqArg.HandoffContext, "Source: Pulse Patrol run history") + assert.Contains(t, reqArg.HandoffContext, "Run ID: run-runtime-error") + assert.Contains(t, reqArg.HandoffContext, "Runtime Failure: Selected model does not support Patrol tools") + assert.Contains(t, reqArg.HandoffContext, "Provider rejected Patrol tool calls") + assert.Contains(t, reqArg.HandoffContext, "Patrol Analysis: Visible runtime summary.") + assert.NotContains(t, reqArg.HandoffContext, "browser-authored stale context") + assert.NotContains(t, reqArg.HandoffContext, "provider trace") + assert.NotContains(t, reqArg.HandoffContext, "tool_choice") + assert.NotContains(t, reqArg.HandoffContext, "No endpoints found") + assert.Equal(t, []chat.HandoffResource{{ID: "vm-100", Type: "vm"}}, reqArg.HandoffResources) + assert.Equal(t, chat.HandoffMetadata{ + Kind: "patrol_run", + RunID: "run-runtime-error", + RunType: "Scoped run", + RunStatus: "error", + RuntimeFailure: true, + }, reqArg.HandoffMetadata) + if assert.NotNil(t, reqArg.AutonomousMode) { + assert.False(t, *reqArg.AutonomousMode) + } + }) + + body := `{"prompt":"discuss run","autonomous_mode":true,"handoff_context":"[Patrol Run Context]\nSource: browser-authored stale context","handoff_resources":[{"id":"storage-999","type":"storage"}],"handoff_metadata":{"kind":"patrol_run","run_id":"run-runtime-error","run_type":"Wrong type","run_status":"healthy","runtime_failure":false}}` + req := httptest.NewRequest("POST", "/api/ai/chat", strings.NewReader(body)) + w := httptest.NewRecorder() + + h.HandleChat(w, req) + + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestHandleChat_RehydratesStoredPatrolRunHandoffMetadataForFollowUp(t *testing.T) { + cfg := &config.Config{} + h := newTestAIHandler(cfg, nil, nil) + mockSvc := new(MockAIService) + h.defaultService = mockSvc + h.SetPatrolRunHandoffProvider(func(ctx context.Context, runID string) (airuntime.PatrolRunRecord, bool) { + assert.Equal(t, "run-stored", runID) + return airuntime.PatrolRunRecord{ + ID: "run-stored", + StartedAt: time.Date(2026, 5, 7, 13, 0, 0, 0, time.UTC), + CompletedAt: time.Date(2026, 5, 7, 13, 0, 5, 0, time.UTC), + DurationMs: 5000, + Type: "verification", + TriggerReason: "user_action", + EffectiveScopeResourceIDs: []string{"storage-1"}, + ScopeResourceTypes: []string{"storage"}, + ResourcesChecked: 1, + StorageChecked: 1, + FindingsSummary: "Verification completed.", + Status: "healthy", + }, true + }) + + mockSvc.On("IsRunning").Return(true) + mockSvc. + On("GetModelHandoffFindingID", mock.Anything, "session-run"). + Return("", nil) + mockSvc. + On("GetModelHandoffMetadata", mock.Anything, "session-run"). + Return(chat.HandoffMetadata{ + Kind: "patrol_run", + RunID: "run-stored", + }, nil) + mockSvc. + On("ExecuteStream", mock.Anything, mock.Anything, mock.Anything). + Return(nil). + Run(func(args mock.Arguments) { + reqArg := args.Get(1).(chat.ExecuteRequest) + assert.Equal(t, "what changed?", reqArg.Prompt) + assert.Contains(t, reqArg.HandoffContext, "Run ID: run-stored") + assert.Contains(t, reqArg.HandoffContext, "Run Type: Verification check") + assert.Equal(t, []chat.HandoffResource{{ID: "storage-1", Type: "storage"}}, reqArg.HandoffResources) + assert.Equal(t, chat.HandoffMetadata{ + Kind: "patrol_run", + RunID: "run-stored", + RunType: "Verification check", + RunStatus: "healthy", + }, reqArg.HandoffMetadata) + }) + + body := `{"prompt":"what changed?","session_id":"session-run"}` + req := httptest.NewRequest("POST", "/api/ai/chat", strings.NewReader(body)) + w := httptest.NewRecorder() + + h.HandleChat(w, req) + + assert.Equal(t, http.StatusOK, w.Code) +} + func TestHandleChat_IncludesInvestigationRecordContext(t *testing.T) { cfg := &config.Config{} h := newTestAIHandler(cfg, nil, nil) diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index f2274e111..f217abfc0 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -178,6 +178,10 @@ func TestContract_AssistantFindingContextUsesModelOnlyHandoff(t *testing.T) { if err != nil { t.Fatalf("read chat types: %v", err) } + patrolHandoffSource, err := os.ReadFile(filepath.Clean("../ai/patrol_assistant_handoff.go")) + if err != nil { + t.Fatalf("read Patrol Assistant handoff model: %v", err) + } toolsQuerySource, err := os.ReadFile(filepath.Clean("../ai/tools/tools_query.go")) if err != nil { t.Fatalf("read AI tools query runtime: %v", err) @@ -186,11 +190,14 @@ func TestContract_AssistantFindingContextUsesModelOnlyHandoff(t *testing.T) { handlerText := string(handlerSource) for _, required := range []string{ `svc.GetModelHandoffFindingID(ctx, req.SessionID)`, + `svc.GetModelHandoffMetadata(ctx, req.SessionID)`, `svc.ClearModelHandoffContext(ctx, sessionID)`, `HandoffActions []chat.HandoffAction ` + "`json:\"handoff_actions,omitempty\"`", `HandoffMetadata chat.HandoffMetadata ` + "`json:\"handoff_metadata,omitempty\"`", "handoffActions := normalizeChatRequestHandoffActions(req.HandoffActions)", "handoffMetadata := chat.NormalizeHandoffMetadata(req.HandoffMetadata)", + "h.getPatrolRunForHandoff(ctx, handoffMetadata.RunID)", + "airuntime.BuildPatrolRunAssistantHandoff(run)", "chatRequestHandoffActionLimit", "requestHandoffContext := handoffContext", "requestHandoffResources := handoffResources", @@ -262,6 +269,20 @@ func TestContract_AssistantFindingContextUsesModelOnlyHandoff(t *testing.T) { t.Fatal("ai_handler.go must not prepend finding context into the persisted prompt") } + patrolHandoffText := string(patrolHandoffSource) + for _, required := range []string{ + "func BuildPatrolRunAssistantHandoff(run PatrolRunRecord) PatrolRunAssistantHandoff", + "[Patrol Run Context]", + "Source: Pulse Patrol run history", + "patrolRunAssistantHandoffResources", + "sanitizePatrolRunAnalysis", + "Operator Boundary", + } { + if !strings.Contains(patrolHandoffText, required) { + t.Fatalf("Patrol runtime must own safe Assistant run handoff context: missing %q", required) + } + } + chatServiceText := string(chatServiceSource) for _, required := range []string{ "handoffContext := strings.TrimSpace(req.HandoffContext)", @@ -272,6 +293,7 @@ func TestContract_AssistantFindingContextUsesModelOnlyHandoff(t *testing.T) { "ClearModelHandoffContext", "handoffResources := normalizeHandoffResources(req.HandoffResources)", "GetModelHandoffFindingID", + "GetModelHandoffMetadata", "sessions.GetModelHandoffContext(session.ID)", "sessions.GetModelHandoffResources(session.ID)", "sessions.SetModelHandoffActions(session.ID, handoffActions)", @@ -316,6 +338,7 @@ func TestContract_AssistantFindingContextUsesModelOnlyHandoff(t *testing.T) { "SetModelHandoffFindingID", "SetModelHandoffEnvelope", "GetModelHandoffFindingID", + "GetModelHandoffMetadata", "ClearModelHandoffContext", "HandoffFindingID string", "SetModelHandoffContext", @@ -1060,11 +1083,26 @@ func TestContract_AISettingsUpdateProviderResolutionJSONSnapshot(t *testing.T) { "ollama_configured":true, "ollama_base_url":%q, "ollama_password_set":false, - "configured_providers":["ollama"], - "control_level":"read_only", - "protected_guests":[], - "discovery_enabled":false - }`, ollama.URL) + "configured_providers":["ollama"], + "control_level":"read_only", + "protected_guests":[], + "discovery_enabled":false, + "patrol_readiness":{ + "status":"warning", + "ready":true, + "cause":"model_tool_support_unverified", + "summary":"Ollama connectivity alone does not prove tool support. Use an Ollama model that returns tool_calls for Patrol verification.", + "provider":"ollama", + "model":"ollama:llama3:latest", + "checks":[{ + "id":"configuration", + "status":"warning", + "cause":"model_tool_support_unverified", + "label":"Patrol configuration", + "message":"Ollama connectivity alone does not prove tool support. Use an Ollama model that returns tool_calls for Patrol verification." + }] + } + }`, ollama.URL) assertJSONSnapshot(t, rec.Body.Bytes(), want) } diff --git a/internal/api/router.go b/internal/api/router.go index ca79853f3..8c2c2b7df 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -609,6 +609,25 @@ func (r *Router) setupRoutes() { }) // Wire AI handler to profile handler for AI-assisted suggestions r.configProfileHandler.SetAIHandler(r.aiHandler) + r.aiHandler.SetPatrolRunHandoffProvider(func(ctx context.Context, runID string) (ai.PatrolRunRecord, bool) { + if r.aiSettingsHandler == nil { + return ai.PatrolRunRecord{}, false + } + aiService := r.aiSettingsHandler.GetAIService(ctx) + if aiService == nil { + return ai.PatrolRunRecord{}, false + } + patrol := aiService.GetPatrolService() + if patrol == nil { + return ai.PatrolRunRecord{}, false + } + run, ok := patrol.GetRunByID(runID) + if !ok { + return ai.PatrolRunRecord{}, false + } + run.ToolCalls = nil + return run, true + }) // Wire chat handler to AI settings handler for investigation orchestration r.aiSettingsHandler.SetChatHandler(r.aiHandler) // Wire license checker for alert manager Pro features (Update Alerts) diff --git a/internal/api/router_routes_ai_test_connection_test.go b/internal/api/router_routes_ai_test_connection_test.go index e55a315cb..174f05d65 100644 --- a/internal/api/router_routes_ai_test_connection_test.go +++ b/internal/api/router_routes_ai_test_connection_test.go @@ -81,12 +81,18 @@ func TestRouteTestConnection_ConnectionFailure(t *testing.T) { require.Equal(t, http.StatusOK, rec.Code) var resp struct { - Success bool `json:"success"` - Message string `json:"message"` + Success bool `json:"success"` + Message string `json:"message"` + Cause string `json:"cause"` + Recommendation string `json:"recommendation"` + Action string `json:"action"` } require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) assert.False(t, resp.Success) - assert.Equal(t, "Connection test failed", resp.Message) + assert.Equal(t, "Provider connection issue", resp.Message) + assert.Equal(t, "provider_connection", resp.Cause) + assert.Contains(t, resp.Recommendation, "provider reachability") + assert.Equal(t, "open_provider_settings", resp.Action) } // TestRouteTestConnection_MethodNotAllowed verifies that non-POST methods @@ -156,10 +162,14 @@ func TestRouteTestConnection_NoConfig(t *testing.T) { require.Equal(t, http.StatusOK, rec.Code) var resp struct { - Success bool `json:"success"` - Message string `json:"message"` + Success bool `json:"success"` + Message string `json:"message"` + Cause string `json:"cause"` + Recommendation string `json:"recommendation"` } require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) assert.False(t, resp.Success) - assert.Equal(t, "Connection test failed", resp.Message) + assert.Equal(t, "Provider not ready", resp.Message) + assert.Equal(t, "provider_not_configured", resp.Cause) + assert.Contains(t, resp.Recommendation, "provider settings") } diff --git a/internal/api/router_routes_ai_test_provider_test.go b/internal/api/router_routes_ai_test_provider_test.go index a809963d3..c8f0fe8f0 100644 --- a/internal/api/router_routes_ai_test_provider_test.go +++ b/internal/api/router_routes_ai_test_provider_test.go @@ -117,14 +117,20 @@ func TestRouteTestProvider_ConnectionFailure(t *testing.T) { require.Equal(t, http.StatusOK, rec.Code) var resp struct { - Success bool `json:"success"` - Message string `json:"message"` - Provider string `json:"provider"` + Success bool `json:"success"` + Message string `json:"message"` + Provider string `json:"provider"` + Cause string `json:"cause"` + Recommendation string `json:"recommendation"` + Action string `json:"action"` } require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) assert.False(t, resp.Success) - assert.Equal(t, "Connection test failed", resp.Message) + assert.Equal(t, "Provider connection issue", resp.Message) assert.Equal(t, "ollama", resp.Provider) + assert.Equal(t, "provider_connection", resp.Cause) + assert.Contains(t, resp.Recommendation, "provider reachability") + assert.Equal(t, "open_provider_settings", resp.Action) } // TestRouteTestProvider_MethodNotAllowed verifies that GET (and other