diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 7262f8cd4..c16ef506b 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -472,9 +472,10 @@ the canonical monitored-system blocked payload. bounded metadata only so Assistant starts by reviewing approval status, risk, dry-run posture, proposed-fix posture, and safest next step without receiving raw command or execution payloads; finding-level handoffs may also send one - bounded `handoff_actions` entry for that governed approval or proposed fix so - the Assistant runtime can refresh action posture from IDs and safe summaries - instead of relying on pasted chat text + bounded model-only `handoff_context`, one `handoff_resources` target + reference, and one `handoff_actions` entry for that governed approval or + proposed fix so the Assistant runtime can refresh finding and action posture + from IDs and safe summaries instead of relying on pasted chat text and the dedicated `frontend-modern/src/stores/aiIntelligenceSummaryModel.ts` owner, so recent-change counts and governed policy-posture fallbacks normalize once at the shared store boundary instead of as Patrol-hook-local payload repair and the shared `frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx` card, so learned correlations and correlation context stay rendered through one governed frontend card instead of separate page-local list loops and the same shared correlation card's ordering and truncation rule, so callers pass raw correlations instead of encoding their own top-N sort behavior diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index f815e2026..38ad32b4d 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -823,6 +823,7 @@ frontend primitive boundary. references into visible action labels, safety notes, and approval-aware suggested prompts or first-turn prompt emphasis for assessment and finding-level handoffs; finding-level drawer opens may also pass one bounded + model-only finding context, one target resource reference, and one `handoff_actions` reference for a live approval or proposed fix. It must not expose raw command or execution payloads. The diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 26ce1dc02..b6da291fe 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -165,10 +165,13 @@ Patrol-specific presentation helpers. governed approval or proposed-fix review instruction when safe metadata is attached, so the operator starts from approval status, risk, dry-run posture, and safest-next-step review instead of generic incident discussion. Finding - handoffs may also attach a bounded model-only action reference for that same - live approval or proposed fix, carrying approval ID/status/risk, action plan - identity/policy/expiry, dry-run posture, proposed-fix summary, and target - resource references without raw command payloads. Inline + handoffs must be assembled through the Patrol-owned handoff model so the + prompt, visible briefing, model-only finding context, resource reference, + bounded action reference, and request-local approval-required posture stay in + sync. The model-only context may include current finding status, recurrence, + investigation record facts, evidence, verification, approval posture, + dry-run posture, proposed-fix summary, and target resource references without + raw command payloads. Inline Patrol approval actions that open Assistant must follow the same rule: pass approval ID/status/risk/target plus safe summary/count metadata as review context, force the request-local approval-required mode, diff --git a/frontend-modern/src/components/AI/FindingsPanel.tsx b/frontend-modern/src/components/AI/FindingsPanel.tsx index cf789d54b..e9b76f131 100644 --- a/frontend-modern/src/components/AI/FindingsPanel.tsx +++ b/frontend-modern/src/components/AI/FindingsPanel.tsx @@ -18,10 +18,8 @@ import { aiIntelligenceStore, type UnifiedFinding } from '@/stores/aiIntelligenc import { notificationStore } from '@/stores/notifications'; import { aiChatStore } from '@/stores/aiChat'; import { - buildPatrolAssistantFindingBriefing, - buildPatrolAssistantFindingHandoffActions, - buildPatrolAssistantFindingPrompt, buildPatrolAssistantApprovalBriefingInput, + buildPatrolAssistantFindingHandoff, buildPatrolAssistantProposedFixBriefingInput, buildPatrolRemediationPlanAssistantBriefing, buildPatrolRemediationPlanAssistantPrompt, @@ -489,61 +487,30 @@ export const FindingsPanel: Component = (props) => { ); const proposedFix = latestInvestigationProposedFix || buildLiveApprovalProposedFixBriefing(pendingApproval); - const prompt = buildPatrolAssistantFindingPrompt({ + const handoff = buildPatrolAssistantFindingHandoff({ + id: finding.id, title, subject, description: finding.description, - investigationOutcome: finding.investigationOutcome, - remediationId: finding.remediationPlanId, - pendingApproval: pendingApprovalBriefing, - proposedFix, - investigationRecord: finding.investigationRecord, - }); - const briefing = buildPatrolAssistantFindingBriefing({ - title, - subject, severity: finding.severity, findingStatus: finding.status, + investigationStatus: finding.investigationStatus, investigationOutcome: finding.investigationOutcome, loopState: finding.loopState, timesRaised: finding.timesRaised, regressionCount: finding.regressionCount, lastRegressionAt: finding.lastRegressionAt, remediationId: finding.remediationPlanId, - pendingApproval: pendingApprovalBriefing, - proposedFix, - investigationRecord: finding.investigationRecord, - }); - const handoffActions = buildPatrolAssistantFindingHandoffActions({ - id: finding.id, - title, - description: finding.description, - severity: finding.severity, - status: finding.status, resourceId: finding.resourceId, resourceName: finding.resourceName, resourceType: finding.resourceType, - investigationStatus: finding.investigationStatus, - investigationOutcome: finding.investigationOutcome, - loopState: finding.loopState, + detectedAt: finding.detectedAt, + lastSeenAt: finding.lastSeenAt, pendingApproval: pendingApprovalBriefing, proposedFix, investigationRecord: finding.investigationRecord, }); - const requiresApprovalMode = patrolAssistantFindingHandoffRequiresApprovalMode({ - investigationOutcome: finding.investigationOutcome, - remediationId: finding.remediationPlanId, - pendingApproval: pendingApprovalBriefing, - investigationRecord: finding.investigationRecord, - }); - aiChatStore.openWithPrompt(prompt, { - targetType: finding.resourceType, - targetId: finding.resourceId, - findingId: finding.id, - briefing, - handoffActions: handoffActions.length > 0 ? handoffActions : undefined, - autonomousMode: requiresApprovalMode ? false : undefined, - }); + aiChatStore.openWithPrompt(handoff.prompt, handoff.context); }; const handleOpenPlanInAssistant = (finding: UnifiedFinding, plan: RemediationPlan, e: Event) => { diff --git a/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts b/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts index a09dd6e3a..a58e75aba 100644 --- a/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts +++ b/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts @@ -44,25 +44,23 @@ const patrolWorkspaceSource = readFileSync( describe('FindingsPanel assistant handoff', () => { it('routes Patrol investigation records into the Assistant briefing context', () => { - expect(findingsPanelSource).toContain('buildPatrolAssistantFindingBriefing'); - expect(findingsPanelSource).toContain('buildPatrolAssistantFindingHandoffActions'); + expect(findingsPanelSource).toContain('buildPatrolAssistantFindingHandoff'); expect(findingsPanelSource).toContain('buildPatrolAssistantApprovalBriefingInput'); expect(findingsPanelSource).toContain('buildPatrolAssistantProposedFixBriefingInput'); - expect(findingsPanelSource).toContain('briefing,'); + expect(findingsPanelSource).toContain( + 'aiChatStore.openWithPrompt(handoff.prompt, handoff.context)', + ); expect(findingsPanelSource).toContain('investigationOutcome: finding.investigationOutcome'); + expect(findingsPanelSource).toContain('investigationStatus: finding.investigationStatus'); expect(findingsPanelSource).toContain('remediationId: finding.remediationPlanId'); + expect(findingsPanelSource).toContain('resourceId: finding.resourceId'); + expect(findingsPanelSource).toContain('detectedAt: finding.detectedAt'); + expect(findingsPanelSource).toContain('lastSeenAt: finding.lastSeenAt'); expect(findingsPanelSource).toContain('investigationRecord: finding.investigationRecord'); expect(findingsPanelSource).toContain('pendingApproval: pendingApprovalBriefing'); expect(findingsPanelSource).toContain('proposedFix,'); - expect(findingsPanelSource).toContain( - 'handoffActions: handoffActions.length > 0 ? handoffActions : undefined', - ); expect(findingsPanelSource).toContain('AIAPI.getInvestigation(finding.id)'); expect(findingsPanelSource).toContain('await aiIntelligenceStore.loadPendingApprovals()'); - expect(findingsPanelSource).toContain('patrolAssistantFindingHandoffRequiresApprovalMode'); - expect(findingsPanelSource).toContain( - 'autonomousMode: requiresApprovalMode ? false : undefined', - ); }); it('routes remediation plan handoffs through the command-free Patrol handoff model', () => { diff --git a/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts b/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts index eb66ec541..1b5cb2777 100644 --- a/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts +++ b/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts @@ -4,6 +4,7 @@ import type { RemediationPlan } from '@/api/ai'; import { buildPatrolAssessmentAssistantHandoff, buildPatrolAssistantFindingBriefing, + buildPatrolAssistantFindingHandoff, buildPatrolAssistantFindingHandoffActions, buildPatrolAssistantFindingPrompt, buildPatrolAssistantProposedFixBriefingInput, @@ -580,6 +581,76 @@ describe('patrolInvestigationContextModel', () => { ).toEqual([]); }); + it('builds finding-level Assistant handoff context, resources, and actions together', () => { + const handoff = buildPatrolAssistantFindingHandoff({ + id: 'finding-1', + title: 'Nginx down', + subject: 'node-1', + description: 'The service stopped responding.', + severity: 'warning', + findingStatus: 'active', + investigationStatus: 'completed', + investigationOutcome: 'fix_queued', + loopState: 'awaiting_approval', + timesRaised: 3, + regressionCount: 1, + lastRegressionAt: '2026-05-06T11:59:00Z', + remediationId: 'remediation-1', + resourceId: 'agent-1', + resourceName: 'node-1', + resourceType: 'agent', + detectedAt: '2026-05-06T11:50:00Z', + lastSeenAt: '2026-05-06T12:00:00Z', + pendingApproval: { + id: 'approval-1', + status: 'pending', + riskLevel: 'high', + requestedAt: '2026-05-06T12:00:00Z', + expiresAt: '2026-05-06T12:10:00Z', + targetName: 'node-1', + actionId: 'restart-nginx', + actionApprovalPolicy: 'operator', + actionPlanMessage: 'Restart nginx after validating load balancer drain.', + actionPreflight: 'Would restart nginx on node-1.', + actionDryRunSummary: 'One service restart would be attempted.', + }, + proposedFix: buildPatrolAssistantProposedFixBriefingInput({ + description: 'Restart nginx', + commands: ['systemctl restart nginx'], + riskLevel: 'high', + targetHost: 'node-1', + }), + }); + + expect(handoff.prompt).toContain('Start by reviewing governed approval approval-1'); + expect(handoff.context).toMatchObject({ + targetType: 'agent', + targetId: 'agent-1', + findingId: 'finding-1', + autonomousMode: false, + handoffResources: [{ id: 'agent-1', name: 'node-1', type: 'agent' }], + context: { + source: 'pulse-patrol-finding', + findingId: 'finding-1', + resourceId: 'agent-1', + resourceName: 'node-1', + resourceType: 'agent', + pendingApprovalId: 'approval-1', + actionReferenceCount: 1, + }, + }); + expect(handoff.context.handoffContext).toContain('[Patrol Finding Context]'); + expect(handoff.context.handoffContext).toContain('Approval: approval-1'); + expect(handoff.context.handoffContext).toContain( + 'Dry-Run Posture: One service restart would be attempted.', + ); + expect(handoff.context.handoffContext).toContain( + 'Command Boundary: Command details stay in governed approval or remediation context', + ); + expect(handoff.context.handoffActions).toHaveLength(1); + expect(JSON.stringify(handoff)).not.toContain('systemctl restart nginx'); + }); + it('builds a drawer briefing for Assistant handoff without exposing raw commands', () => { const approvalRequestedAt = new Date(Date.now() - 60_000).toISOString(); const approvalExpiresAt = new Date(Date.now() + 10 * 60_000).toISOString(); diff --git a/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts b/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts index 1e618aa81..eeea82969 100644 --- a/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts +++ b/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts @@ -120,6 +120,30 @@ export interface PatrolAssistantFindingBriefingInput { investigationRecord?: InvestigationRecord | null; } +export interface PatrolAssistantFindingHandoffInput { + id?: string | null; + title: string; + subject: string; + description?: string | null; + severity?: string | null; + findingStatus?: string | null; + investigationStatus?: string | null; + investigationOutcome?: string | null; + loopState?: string | null; + timesRaised?: number | null; + regressionCount?: number | null; + lastRegressionAt?: string | null; + remediationId?: string | null; + resourceId?: string | null; + resourceName?: string | null; + resourceType?: string | null; + detectedAt?: string | null; + lastSeenAt?: string | null; + pendingApproval?: PatrolAssistantApprovalBriefingInput | null; + proposedFix?: PatrolAssistantProposedFixBriefingInput | null; + investigationRecord?: InvestigationRecord | null; +} + export interface PatrolAssistantFindingModeInput { investigationOutcome?: string | null; remediationId?: string | null; @@ -206,6 +230,11 @@ export interface PatrolAssessmentAssistantHandoff { context: Omit; } +export interface PatrolAssistantFindingHandoff { + prompt: string; + context: Omit; +} + const MAX_ASSESSMENT_FINDINGS = 5; const MAX_ASSESSMENT_RECENT_CHANGES = 3; const MAX_ASSESSMENT_CORRELATIONS = 3; @@ -454,6 +483,68 @@ export function buildPatrolAssessmentAssistantHandoff( }; } +export function buildPatrolAssistantFindingHandoff( + input: PatrolAssistantFindingHandoffInput, +): PatrolAssistantFindingHandoff { + const findingId = normalizeText(input.id) || normalizeText(input.investigationRecord?.finding_id); + const resource = buildPatrolFindingHandoffResource(input); + const handoffResources = resource ? [resource] : []; + const handoffActions = buildPatrolAssistantFindingHandoffActions(input); + const requiresApprovalMode = patrolAssistantFindingHandoffRequiresApprovalMode({ + investigationOutcome: input.investigationOutcome, + remediationId: input.remediationId, + pendingApproval: input.pendingApproval, + investigationRecord: input.investigationRecord, + }); + + return { + prompt: buildPatrolAssistantFindingPrompt({ + title: input.title, + subject: input.subject, + description: normalizeText(input.description), + investigationOutcome: input.investigationOutcome, + remediationId: input.remediationId, + pendingApproval: input.pendingApproval, + proposedFix: input.proposedFix, + investigationRecord: input.investigationRecord, + }), + context: { + targetType: resource?.type, + targetId: resource?.id, + findingId: findingId || undefined, + autonomousMode: requiresApprovalMode ? false : undefined, + handoffContext: buildPatrolAssistantFindingModelContext(input), + handoffResources: handoffResources.length > 0 ? handoffResources : undefined, + handoffActions: handoffActions.length > 0 ? handoffActions : undefined, + briefing: buildPatrolAssistantFindingBriefing({ + title: input.title, + subject: input.subject, + severity: input.severity, + findingStatus: input.findingStatus, + investigationOutcome: input.investigationOutcome, + loopState: input.loopState, + timesRaised: input.timesRaised, + regressionCount: input.regressionCount, + lastRegressionAt: input.lastRegressionAt, + remediationId: input.remediationId, + pendingApproval: input.pendingApproval, + proposedFix: input.proposedFix, + investigationRecord: input.investigationRecord, + }), + context: { + source: 'pulse-patrol-finding', + findingId: findingId || undefined, + investigationRecordId: normalizeText(input.investigationRecord?.id) || undefined, + resourceId: resource?.id, + resourceName: resource?.name, + resourceType: resource?.type, + pendingApprovalId: normalizeApprovalBriefing(input.pendingApproval).id || undefined, + actionReferenceCount: handoffActions.length, + }, + }, + }; +} + export function buildPatrolAssistantFindingHandoffActions( finding: PatrolAssessmentAssistantFindingInput, ): AIChatHandoffAction[] { @@ -762,6 +853,25 @@ function buildPatrolAssessmentHandoffResources( return Array.from(resources.values()); } +function buildPatrolFindingHandoffResource( + input: PatrolAssistantFindingHandoffInput, +): AIChatHandoffResource | undefined { + const subject = input.investigationRecord?.subject; + const id = normalizeText(input.resourceId) || normalizeText(subject?.resource_id); + if (!id) return undefined; + + return { + id, + name: + normalizeText(input.resourceName) || + normalizeText(subject?.resource_name) || + normalizeText(input.subject) || + undefined, + type: normalizeText(input.resourceType) || normalizeText(subject?.resource_type) || undefined, + node: normalizeText(subject?.node) || undefined, + }; +} + function addAssessmentHandoffResource( resources: Map, resource: AIChatHandoffResource, @@ -1243,6 +1353,99 @@ function formatAssessmentFindingContextLine( return `Finding ${index}: ${parts.join('; ')}`; } +function buildPatrolAssistantFindingModelContext( + input: PatrolAssistantFindingHandoffInput, +): string { + const title = normalizeText(input.title) || 'Patrol finding'; + const subject = normalizeText(input.subject) || 'affected resource'; + const record = buildPatrolInvestigationRecordPresentation(input.investigationRecord); + const pendingApproval = normalizeApprovalBriefing(input.pendingApproval); + const proposedFix = record.proposedFix || normalizeProposedFixBriefing(input.proposedFix); + const resource = buildPatrolFindingHandoffResource(input); + const findingId = normalizeText(input.id) || normalizeText(input.investigationRecord?.finding_id); + const statusParts = [ + formatIdentifierLabel(input.severity), + formatIdentifierLabel(input.findingStatus), + formatIdentifierLabel(input.investigationStatus), + formatIdentifierLabel(input.investigationOutcome || input.investigationRecord?.outcome), + formatIdentifierLabel(input.loopState), + ].filter(isNonEmptyString); + const raisedParts = [ + normalizeNonNegativeCount(input.timesRaised) > 1 + ? `raised ${normalizeNonNegativeCount(input.timesRaised)} times` + : undefined, + normalizeNonNegativeCount(input.regressionCount) > 0 + ? `regressed ${normalizeNonNegativeCount(input.regressionCount)} time${ + normalizeNonNegativeCount(input.regressionCount) === 1 ? '' : 's' + }` + : undefined, + normalizeText(input.lastRegressionAt) + ? `last regression ${normalizeText(input.lastRegressionAt)}` + : undefined, + ].filter(isNonEmptyString); + const attentionReason = buildPatrolAssistantAttentionReason(input, record); + const operatorDecision = buildPatrolAssistantOperatorDecision(input); + const proposedFixFacts = proposedFix + ? formatBriefingStringList( + [ + proposedFix.description, + proposedFix.targetHost ? `target ${proposedFix.targetHost}` : undefined, + proposedFix.riskLabel ? `${proposedFix.riskLabel.toLowerCase()} risk` : undefined, + proposedFix.commandSummary, + proposedFix.destructive ? 'destructive proposed fix' : undefined, + proposedFix.rationale ? `rationale ${proposedFix.rationale}` : undefined, + ], + 6, + 'proposed-fix facts', + ) + : undefined; + + return [ + '[Patrol Finding Context]', + 'Source: Pulse Patrol finding handoff', + formatContextLine('Finding', title), + formatContextLine('Finding ID', findingId), + formatContextLine('Subject', subject), + formatContextLine('Resource', resource ? formatAssessmentResourceLabel(resource) : undefined), + formatContextLine('Status', statusParts.join(' ยท ')), + formatContextLine('Detected At', input.detectedAt), + formatContextLine('Last Seen At', input.lastSeenAt), + formatContextLine('Recurrence', raisedParts.join('; ')), + formatContextLine('Description', input.description), + formatContextLine('Attention', attentionReason), + formatContextLine('Investigation Record', input.investigationRecord?.id), + formatContextLine('Investigation Status', record.statusLabel), + formatContextLine('Investigation Outcome', record.outcomeLabel), + formatContextLine('Investigation Confidence', record.confidenceLabel), + formatContextLine('Conclusion', record.conclusion), + formatContextLine('Recommended Action', record.recommendedAction), + ...record.evidenceSummaries.map((summary, index) => + formatContextLine(`Evidence ${index + 1}`, summary), + ), + ...record.verificationSummaries.map((summary, index) => + formatContextLine(`Verification ${index + 1}`, summary), + ), + formatContextLine('Tools Used', record.toolsUsed.join(', ')), + formatContextLine('Approval', pendingApproval.id), + formatContextLine('Approval Status', pendingApproval.status), + formatContextLine('Approval Risk', pendingApproval.riskLevel), + formatContextLine('Approval Target', pendingApproval.targetName), + formatContextLine('Approval Requested At', pendingApproval.requestedAt), + formatContextLine('Approval Expires At', pendingApproval.expiresAt), + formatContextLine('Approval Policy', pendingApproval.actionApprovalPolicy), + formatContextLine('Approval Plan Expires At', pendingApproval.actionPlanExpiresAt), + formatContextLine('Action Plan Summary', pendingApproval.actionPlanMessage), + formatContextLine('Action Preflight', pendingApproval.actionPreflight), + formatContextLine('Dry-Run Posture', pendingApproval.actionDryRunSummary), + formatContextLine('Proposed Fix', proposedFixFacts), + formatContextLine('Operator Decision', operatorDecision), + 'Command Boundary: Command details stay in governed approval or remediation context; this model-only handoff may include command counts but not raw command text.', + 'Operator Boundary: This Patrol finding handoff is model-only context for explanation and review. Diagnostics, remediation, and command execution require explicit governed approval.', + ] + .filter(isNonEmptyString) + .join('\n'); +} + function formatAssessmentPendingApprovalContextParts( approval: Required, ): string[] {