Attach Patrol finding context to Assistant handoffs

This commit is contained in:
rcourtman
2026-05-07 11:41:08 +01:00
parent c5362438c1
commit 32eccdab3e
7 changed files with 301 additions and 57 deletions
@@ -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
@@ -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
@@ -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,
@@ -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<FindingsPanelProps> = (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) => {
@@ -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', () => {
@@ -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();
@@ -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<AIChatContext, 'initialPrompt'>;
}
export interface PatrolAssistantFindingHandoff {
prompt: string;
context: Omit<AIChatContext, 'initialPrompt'>;
}
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<string, AIChatHandoffResource>,
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<PatrolAssistantApprovalBriefingInput>,
): string[] {