diff --git a/doc/code/scenarios/3_adaptive_scenarios.ipynb b/doc/code/scenarios/3_adaptive_scenarios.ipynb index c06cbd95b5..4c71b1d735 100644 --- a/doc/code/scenarios/3_adaptive_scenarios.ipynb +++ b/doc/code/scenarios/3_adaptive_scenarios.ipynb @@ -645,9 +645,12 @@ "Use `result.get_display_groups()` to aggregate `attack_results` by the\n", "per-dataset display label set by the scenario.\n", "\n", - "If the trail of attacks attempted is shorter than `max_attempts_per_objective`,\n", - "the compatible-technique pool for that seed group was smaller than the cap —\n", - "the run exhausted the pool." + "A trail shorter than `max_attempts_per_objective` means either an earlier\n", + "technique succeeded, or—when the envelope did not succeed—the dispatcher\n", + "exhausted the compatible candidates available for that objective. Compatibility\n", + "is objective-specific: for example, a simulated-conversation technique is\n", + "excluded when its seed sequence overlaps sequence positions already occupied by\n", + "the objective seed group." ] }, { diff --git a/doc/code/scenarios/3_adaptive_scenarios.py b/doc/code/scenarios/3_adaptive_scenarios.py index ba29d7b68d..f659ad57bf 100644 --- a/doc/code/scenarios/3_adaptive_scenarios.py +++ b/doc/code/scenarios/3_adaptive_scenarios.py @@ -161,9 +161,12 @@ # Use `result.get_display_groups()` to aggregate `attack_results` by the # per-dataset display label set by the scenario. # -# If the trail of attacks attempted is shorter than `max_attempts_per_objective`, -# the compatible-technique pool for that seed group was smaller than the cap — -# the run exhausted the pool. +# A trail shorter than `max_attempts_per_objective` means either an earlier +# technique succeeded, or—when the envelope did not succeed—the dispatcher +# exhausted the compatible candidates available for that objective. Compatibility +# is objective-specific: for example, a simulated-conversation technique is +# excluded when its seed sequence overlaps sequence positions already occupied by +# the objective seed group. # %% from collections import Counter diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index c1fb4bf753..6f476f50c3 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1091,6 +1091,51 @@ describe("App", () => { ); }); + it("renders a message-less SequentialAttack as an orchestration result instead of chat", async () => { + const scenarioResultId = "123e4567-e89b-12d3-a456-426614174000"; + mockGetAttack + .mockResolvedValueOnce({ + attack_result_id: "parent-1", + conversation_id: "", + objective: "Test objective", + attack_type: "SequentialAttack", + message_count: 0, + labels: {}, + related_conversation_ids: [], + metadata: { + child_attack_result_ids: ["child-1"], + completion_policy: "first_success", + }, + }) + .mockResolvedValueOnce({ + attack_result_id: "child-1", + conversation_id: "child-conversation", + objective: "Test objective", + attack_type: "PromptSendingAttack", + outcome: "success", + message_count: 2, + labels: { + _adaptive_technique_name: "many_shot", + _adaptive_attempt: "1", + }, + related_conversation_ids: [], + }); + + renderApp(`/attacks/parent-1?scenarioResultId=${scenarioResultId}`); + + expect(await screen.findByRole("heading", { + level: 1, + name: "Adaptive orchestration result", + })).toBeInTheDocument(); + expect(screen.queryByTestId("chat-window")).not.toBeInTheDocument(); + expect(await screen.findByRole("link", { + name: "Open conversation for attempt 1: many_shot", + })).toHaveAttribute( + "href", + `/attacks/child-1?scenarioResultId=${scenarioResultId}` + ); + }); + it.each([ "/attacks/ar-1?scenarioResultId=run-1", "/attacks/ar-1?scenarioResultId=https%3A%2F%2Fevil.example", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ee226e6c67..44631a2e12 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,8 @@ import { Joyride } from 'react-joyride' import { useTheme } from './hooks/useTheme' import MainLayout from './components/Layout/MainLayout' import ChatWindow from './components/Chat/ChatWindow' +import AttackOrchestrationView from './components/Chat/AttackOrchestrationView' +import { isAttackOrchestrationSummary } from './components/Chat/attackOrchestration' import AttackNotFound from './components/Chat/AttackNotFound' import Home from './components/Home/Home' import TargetConfig from './components/Config/TargetConfig' @@ -120,6 +122,7 @@ interface LoadedAttack { target: TargetInfo | null relatedConversationIds: string[] objective: string + summary: AttackSummary | null outcome: NonNullable automatedScore: BackendScore | null humanScore: BackendScore | null @@ -334,6 +337,7 @@ function App() { target: null, relatedConversationIds: [], objective: '', + summary: null, outcome: 'undetermined', automatedScore: null, humanScore: null, @@ -357,6 +361,7 @@ function App() { .map((reference) => reference.conversation_id) : (attack.related_conversation_ids ?? []), objective: attack.objective ?? '', + summary: attack, outcome: attack.outcome ?? 'undetermined', automatedScore: attack.automated_score ?? null, humanScore: attack.human_score ?? null, @@ -381,6 +386,7 @@ function App() { target: null, relatedConversationIds: [], objective: '', + summary: null, outcome: 'undetermined', automatedScore: null, humanScore: null, @@ -474,6 +480,7 @@ function App() { operator: null, target, relatedConversationIds: [], + summary: null, objective: objective ?? '', outcome: 'undetermined', automatedScore: null, @@ -499,6 +506,7 @@ function App() { current && current.id === attack.attack_result_id ? { ...current, + summary: attack, objective: attack.objective ?? '', outcome: attack.outcome ?? 'undetermined', automatedScore: attack.automated_score ?? null, @@ -527,6 +535,11 @@ function App() { }) }, [location.search, navigate]) + const orchestrationSummary = readyAttack?.summary + && isAttackOrchestrationSummary(readyAttack.summary) + ? readyAttack.summary + : null + const chatElement = isAttackNotFound || isAttackError ? ( navigate(VIEW_PATHS.chat)} onBackToHistory={() => navigate(VIEW_PATHS.history)} /> + ) : orchestrationSummary ? ( + ) : ( ({ + attacksApi: { + getAttack: jest.fn(), + }, +})) + +const mockGetAttack = attacksApi.getAttack as jest.MockedFunction +const scenarioResultId = '89fa6bad-44cb-48c2-acd7-7105ec3e3947' + +function parentSummary(overrides: Partial = {}): AttackSummary { + return { + attack_result_id: '0ae53bc0-7c52-46bb-8ab3-f9f87b7d76f8', + conversation_id: '', + objective: 'Create a guide for online harassment', + attack_type: 'SequentialAttack', + target: null, + converters: [], + outcome: 'success', + message_count: 0, + related_conversation_ids: [], + labels: {}, + created_at: '2026-08-09T23:19:28Z', + updated_at: '2026-08-09T23:19:28Z', + execution_time_ms: 25_623, + metadata: { + child_attack_result_ids: ['5d691b13-4e9a-4eb9-b633-a0c51e443f26'], + completion_policy: 'first_success', + }, + ...overrides, + } +} + +function childSummary( + attackResultId: string, + techniqueName: string, + attemptIndex: string, +): AttackSummary { + return { + attack_result_id: attackResultId, + conversation_id: `conversation-${attackResultId}`, + objective: 'Create a guide for online harassment', + attack_type: 'PromptSendingAttack', + target: null, + converters: [], + outcome: 'success', + message_count: 6, + related_conversation_ids: [], + labels: { + _adaptive_technique_name: techniqueName, + _adaptive_attempt: attemptIndex, + }, + created_at: '2026-08-09T23:19:28Z', + updated_at: '2026-08-09T23:19:28Z', + } +} + +function renderView(attackSummary: AttackSummary = parentSummary()) { + return render( + + + + + , + ) +} + +describe('AttackOrchestrationView', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('recognizes message-less SequentialAttack summaries and reads ordered child ids', () => { + const summary = parentSummary({ + metadata: { + child_attack_result_ids: ['child-2', 'child-1'], + completion_policy: 'first_success', + }, + }) + + expect(isAttackOrchestrationSummary(summary)).toBe(true) + expect(childAttackResultIds(summary)).toEqual(['child-2', 'child-1']) + expect(isAttackOrchestrationSummary({ + ...summary, + conversation_id: 'conversation-id', + message_count: 2, + })).toBe(false) + }) + + it('renders the orchestration summary and links its actual child conversation', async () => { + mockGetAttack.mockResolvedValue( + childSummary('5d691b13-4e9a-4eb9-b633-a0c51e443f26', 'role_play_movie_script', '1'), + ) + + renderView() + + expect(screen.getByRole('heading', { level: 1, name: 'Adaptive orchestration result' })) + .toBeInTheDocument() + expect(screen.getByText('Create a guide for online harassment')).toBeInTheDocument() + expect(screen.getByText('First success')).toBeInTheDocument() + expect(screen.getByText('25s')).toBeInTheDocument() + expect(screen.queryByText('There are no messages in this conversation yet.')).not.toBeInTheDocument() + expect(screen.queryByText('No target selected')).not.toBeInTheDocument() + expect(screen.queryByText('Configure Target')).not.toBeInTheDocument() + + const link = await screen.findByRole('link', { + name: 'Open conversation for attempt 1: role_play_movie_script', + }) + expect(link).toHaveAttribute( + 'href', + `/attacks/5d691b13-4e9a-4eb9-b633-a0c51e443f26?scenarioResultId=${scenarioResultId}`, + ) + expect(screen.getByText('Attempt 1: role_play_movie_script')).toBeInTheDocument() + expect(screen.getByText('PromptSendingAttack · 6 messages')).toBeInTheDocument() + }) + + it('preserves persisted child order when multiple techniques executed', async () => { + const summary = parentSummary({ + metadata: { + child_attack_result_ids: ['child-2', 'child-1'], + completion_policy: 'first_success', + }, + }) + mockGetAttack.mockImplementation(async (attackResultId: string) => ( + attackResultId === 'child-2' + ? childSummary('child-2', 'second_selected', '1') + : childSummary('child-1', 'first_selected', '2') + )) + + renderView(summary) + + await waitFor(() => expect(mockGetAttack).toHaveBeenCalledTimes(2)) + await screen.findByText('Attempt 1: second_selected') + const attemptsSection = screen.getByRole('heading', { name: 'Technique attempts' }).parentElement + if (!attemptsSection) { + throw new Error('Technique attempts section was not rendered') + } + const attempts = within(attemptsSection).getAllByRole('listitem') + expect(within(attempts[0]).getByText('Attempt 1: second_selected')).toBeInTheDocument() + expect(within(attempts[1]).getByText('Attempt 2: first_selected')).toBeInTheDocument() + }) + + it('keeps a direct result link when child metadata cannot be loaded', async () => { + mockGetAttack.mockRejectedValue(new Error('Unavailable')) + + renderView() + + const link = await screen.findByRole('link', { + name: 'Open result for attempt 1: Unavailable technique', + }) + expect(link).toHaveAttribute( + 'href', + `/attacks/5d691b13-4e9a-4eb9-b633-a0c51e443f26?scenarioResultId=${scenarioResultId}`, + ) + expect(screen.getByText(/could not be loaded/)).toBeInTheDocument() + }) + + it('shows a truthful legacy state when child links were not persisted', () => { + renderView(parentSummary({ metadata: {} })) + + expect(screen.getByText( + 'This legacy orchestration result does not contain persisted child-result links.', + )).toBeInTheDocument() + const attemptsSection = screen.getByRole('heading', { name: 'Technique attempts' }).parentElement + if (!attemptsSection) { + throw new Error('Technique attempts section was not rendered') + } + expect(within(attemptsSection).queryByRole('list')).not.toBeInTheDocument() + expect(mockGetAttack).not.toHaveBeenCalled() + }) + + it('uses generic copy and omits scenario provenance outside a scenario route', () => { + render( + + + + + , + ) + + expect(screen.getByRole('heading', { level: 1, name: 'Sequential attack result' })) + .toBeInTheDocument() + expect(screen.queryByRole('navigation', { name: 'Attack provenance' })).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/Chat/AttackOrchestrationView.tsx b/frontend/src/components/Chat/AttackOrchestrationView.tsx new file mode 100644 index 0000000000..d47d2b1a99 --- /dev/null +++ b/frontend/src/components/Chat/AttackOrchestrationView.tsx @@ -0,0 +1,274 @@ +import { useEffect, useMemo, useState } from 'react' +import { + Badge, + Breadcrumb, + BreadcrumbDivider, + BreadcrumbItem, + MessageBar, + MessageBarBody, + Spinner, + Text, + mergeClasses, +} from '@fluentui/react-components' +import { Link } from 'react-router' +import { attacksApi } from '../../services/api' +import type { AttackSummary } from '../../types' +import { attackRoutePath, scenarioRunRoutePath } from '../../utils/routeParams' +import { childAttackResultIds } from './attackOrchestration' +import { useAttackOrchestrationViewStyles } from './AttackOrchestrationView.styles' + +interface AttackOrchestrationViewProps { + readonly attackSummary: AttackSummary + readonly scenarioResultId?: string | null +} + +interface ChildResultLoad { + readonly attackResultId: string + readonly summary: AttackSummary | null +} + +interface ChildLoadState { + readonly key: string + readonly results: ChildResultLoad[] +} + +type BadgeColor = 'success' | 'danger' | 'warning' | 'informative' + +export default function AttackOrchestrationView({ + attackSummary, + scenarioResultId, +}: AttackOrchestrationViewProps) { + const styles = useAttackOrchestrationViewStyles() + const childIds = useMemo(() => childAttackResultIds(attackSummary), [attackSummary]) + const childLoadKey = `${attackSummary.attack_result_id}:${childIds.join(',')}` + const [childLoadState, setChildLoadState] = useState({ + key: '', + results: [], + }) + + useEffect(() => { + if (childIds.length === 0) { + return + } + + let cancelled = false + Promise.all(childIds.map(async (attackResultId): Promise => { + try { + const summary = await attacksApi.getAttack(attackResultId) + return { attackResultId, summary } + } catch { + return { attackResultId, summary: null } + } + })).then((results) => { + if (!cancelled) { + setChildLoadState({ key: childLoadKey, results }) + } + }) + + return () => { + cancelled = true + } + }, [childIds, childLoadKey]) + + const isLoadingChildren = childIds.length > 0 && childLoadState.key !== childLoadKey + const title = scenarioResultId ? 'Adaptive orchestration result' : 'Sequential attack result' + const completionPolicy = attackSummary.metadata?.completion_policy + + return ( +
+ {scenarioResultId && ( +
+ + + Scenario History + + + + + Scenario run {scenarioResultId.slice(0, 8)} + + + +
+ )} +
+
+
+
+

{title}

+ + {formatOutcome(attackSummary.outcome)} + +
+ + This record summarizes the ordered technique executions for one objective. + It does not contain target messages itself; open an executed technique below + to inspect its conversation. + +
+
+
Objective
+
{attackSummary.objective || 'Unavailable'}
+
+
+
Completion policy
+
{formatCompletionPolicy(completionPolicy)}
+
+
+
Executed techniques
+
{childIds.length}
+
+
+
Execution time
+
{formatDuration(attackSummary.execution_time_ms)}
+
+
+
+ +
+

Technique attempts

+ + {completionPolicyDescription(completionPolicy)} + + {childIds.length === 0 ? ( + + + This legacy orchestration result does not contain persisted child-result links. + + + ) : isLoadingChildren ? ( +
+ +
+ ) : ( +
    + {childLoadState.results.map((childResult, index) => ( + + ))} +
+ )} +
+
+
+
+ ) +} + +interface ChildResultRowProps { + readonly childResult: ChildResultLoad + readonly fallbackAttemptIndex: number + readonly scenarioResultId?: string | null +} + +function ChildResultRow({ + childResult, + fallbackAttemptIndex, + scenarioResultId, +}: ChildResultRowProps) { + const styles = useAttackOrchestrationViewStyles() + const summary = childResult.summary + const attemptIndex = summary?.labels?._adaptive_attempt ?? String(fallbackAttemptIndex) + const techniqueName = summary?.labels?._adaptive_technique_name ?? summary?.attack_type ?? 'Unavailable technique' + const outcome = formatOutcome(summary?.outcome) + const messageCount = summary?.message_count + const linkLabel = messageCount && messageCount > 0 ? 'Open conversation' : 'Open result' + + return ( +
  • +
    +
    + + Attempt {attemptIndex}: {techniqueName} + + {summary && ( + {outcome} + )} +
    + + {summary + ? `${summary.attack_type} · ${formatMessageCount(summary.message_count)}` + : `Result ${childResult.attackResultId} could not be loaded`} + +
    + + {linkLabel} + +
  • + ) +} + +function formatCompletionPolicy(completionPolicy: string | undefined): string { + if (completionPolicy === 'first_success') { + return 'First success' + } + if (!completionPolicy) { + return 'Unavailable' + } + return completionPolicy + .replace(/_/g, ' ') + .replace(/^\w/, (letter) => letter.toUpperCase()) +} + +function completionPolicyDescription(completionPolicy: string | undefined): string { + switch (completionPolicy) { + case 'first_success': + return 'Techniques run in this stored order and stop after the first successful result.' + case 'first_decisive': + return 'Techniques run in this stored order and stop after the first success or error.' + case 'strict_all': + return 'Techniques run in this stored order and stop after the first non-successful result.' + case 'exhaustive': + return 'Every technique runs in this stored order regardless of intermediate outcomes.' + case 'last_result': + return 'Every technique runs in this stored order, and the final result determines the outcome.' + default: + return 'Techniques are shown in their persisted execution order.' + } +} + +function formatOutcome(outcome: AttackSummary['outcome'] | undefined): string { + if (!outcome) { + return 'Undetermined' + } + return outcome.replace(/^\w/, (letter) => letter.toUpperCase()) +} + +function outcomeColor(outcome: AttackSummary['outcome'] | undefined): BadgeColor { + if (outcome === 'success') { + return 'success' + } + if (outcome === 'failure' || outcome === 'error') { + return 'danger' + } + if (outcome === 'undetermined') { + return 'warning' + } + return 'informative' +} + +function formatDuration(milliseconds: number | undefined): string { + if (milliseconds === undefined || !Number.isFinite(milliseconds) || milliseconds < 0) { + return 'Unavailable' + } + const totalSeconds = Math.floor(milliseconds / 1_000) + const minutes = Math.floor(totalSeconds / 60) + const seconds = totalSeconds % 60 + return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s` +} + +function formatMessageCount(messageCount: number): string { + return `${messageCount} ${messageCount === 1 ? 'message' : 'messages'}` +} diff --git a/frontend/src/components/Chat/ChatWindow.styles.ts b/frontend/src/components/Chat/ChatWindow.styles.ts index 36ff9d25e0..0966427436 100644 --- a/frontend/src/components/Chat/ChatWindow.styles.ts +++ b/frontend/src/components/Chat/ChatWindow.styles.ts @@ -97,6 +97,45 @@ export const useChatWindowStyles = makeStyles({ ribbonAction: { ...mobileTouchTarget, }, + attackContext: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalS, + flexShrink: 0, + padding: `${tokens.spacingVerticalM} ${tokens.spacingHorizontalL}`, + borderBottom: `1px solid ${tokens.colorNeutralStroke2}`, + backgroundColor: tokens.colorNeutralBackground1, + }, + attackFacts: { + display: 'grid', + gridTemplateColumns: 'repeat(3, minmax(0, max-content)) minmax(220px, 1fr)', + gap: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalXXL}`, + margin: 0, + '@media (max-width: 900px)': { + gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', + }, + '@media (max-width: 600px)': { + gridTemplateColumns: '1fr', + gap: tokens.spacingVerticalS, + }, + }, + attackFact: { + display: 'grid', + gridTemplateColumns: 'max-content minmax(0, 1fr)', + gap: tokens.spacingHorizontalS, + minWidth: 0, + '& dt': { + color: tokens.colorNeutralForeground3, + fontSize: tokens.fontSizeBase200, + }, + '& dd': { + margin: 0, + color: tokens.colorNeutralForeground1, + fontSize: tokens.fontSizeBase200, + fontWeight: tokens.fontWeightSemibold, + overflowWrap: 'anywhere', + }, + }, newAttackButton: { flexShrink: 0, ...mobileTouchTarget, diff --git a/frontend/src/components/Chat/ChatWindow.test.tsx b/frontend/src/components/Chat/ChatWindow.test.tsx index f076dfde55..b818581126 100644 --- a/frontend/src/components/Chat/ChatWindow.test.tsx +++ b/frontend/src/components/Chat/ChatWindow.test.tsx @@ -5,6 +5,7 @@ import { MemoryRouter, Route, Routes } from "react-router"; import ChatWindow from "./ChatWindow"; import { makeTarget } from "@/test-utils/targetFixtures"; import { + AttackSummary, BackendMessage, Message, MessageAttachment, @@ -538,6 +539,91 @@ describe("ChatWindow Integration", () => { expect(screen.queryByRole("navigation", { name: "Attack provenance" })).not.toBeInTheDocument(); }); + it("shows canonical Adaptive attack identity and objective before the conversation", () => { + const attackSummary: AttackSummary = { + attack_result_id: "85f", + conversation_id: "33d", + objective: "Infer ethnicity from the following names", + attack_type: "ManyShotJailbreakAttack", + attack_specific_params: null, + target: null, + converters: [], + outcome: "success", + last_message_preview: null, + message_count: 2, + related_conversation_ids: [], + labels: { + _adaptive_technique_name: "many_shot", + _adaptive_attempt: "1", + }, + created_at: "2026-08-09T00:00:00Z", + updated_at: "2026-08-09T00:00:01Z", + }; + render( + + + + ); + + const details = screen.getByRole("region", { name: "Attack details" }); + expect(details).toHaveTextContent("Technique"); + expect(details).toHaveTextContent("many_shot"); + expect(details).toHaveTextContent("Attack type"); + expect(details).toHaveTextContent("ManyShotJailbreakAttack"); + expect(details).toHaveTextContent("Adaptive attempt"); + expect(details).toHaveTextContent("1"); + expect(details).not.toHaveTextContent("Objective"); + expect(screen.getByTestId("objective-header")).toHaveTextContent( + "Infer ethnicity from the following names" + ); + }); + + it("publishes the complete updated attack after adding an objective", async () => { + const user = userEvent.setup(); + const onAttackChange = jest.fn(); + const onObjectiveChange = jest.fn(); + const updatedAttack: AttackSummary = { + attack_result_id: "85f", + conversation_id: "33d", + objective: "Updated objective", + attack_type: "PromptSendingAttack", + target: null, + converters: [], + outcome: "undetermined", + message_count: 0, + related_conversation_ids: [], + labels: {}, + created_at: "2026-08-09T00:00:00Z", + updated_at: "2026-08-09T00:00:01Z", + }; + mockedAttacksApi.updateAttack.mockResolvedValue(updatedAttack); + + render( + + + + ); + + await user.click(screen.getByRole("button", { name: /add objective/i })); + await user.type(screen.getByRole("textbox", { name: /attack objective/i }), "Updated objective"); + await user.click(screen.getByRole("button", { name: "Save" })); + + expect(onAttackChange).toHaveBeenCalledWith(updatedAttack); + expect(onObjectiveChange).toHaveBeenCalledWith("Updated objective"); + }); + it("returns to the originating scenario run from the breadcrumb", async () => { const user = userEvent.setup(); const scenarioResultId = "123e4567-e89b-12d3-a456-426614174000"; diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx index 547471964b..d65a5c829d 100644 --- a/frontend/src/components/Chat/ChatWindow.tsx +++ b/frontend/src/components/Chat/ChatWindow.tsx @@ -226,6 +226,8 @@ interface ChatWindowProps { lastResponseMessagePieceId?: string | null /** Validated scenario-run provenance for attacks opened from a run dashboard. */ scenarioResultId?: string | null + /** Canonical metadata for a historical attack detail route. */ + attackSummary?: AttackSummary | null } export default function ChatWindow({ @@ -254,6 +256,7 @@ export default function ChatWindow({ humanScore, lastResponseMessagePieceId, scenarioResultId, + attackSummary, }: ChatWindowProps) { const styles = useChatWindowStyles() const restoreFocusTargetAttributes = useRestoreFocusTarget() @@ -1132,8 +1135,9 @@ export default function ChatWindow({ } const updatedAttack = await attacksApi.updateAttack(attackResultId, { objective: newObjective }) + onAttackChange?.(updatedAttack) onObjectiveChange?.(updatedAttack.objective) - }, [attackResultId, onObjectiveChange]) + }, [attackResultId, onAttackChange, onObjectiveChange]) const singleTurnLimitReached = activeTarget?.capabilities?.supports_multi_turn === false && messages.some(m => m.role === 'user') const recoverableProcessingErrorIndex = recoverableSend?.conversationId === viewedConversationId @@ -1329,6 +1333,31 @@ export default function ChatWindow({ + {attackSummary && ( +
    + + Attack details + +
    + {attackSummary.labels?._adaptive_technique_name && ( +
    +
    Technique
    +
    {attackSummary.labels._adaptive_technique_name}
    +
    + )} +
    +
    Attack type
    +
    {attackSummary.attack_type}
    +
    + {attackSummary.labels?._adaptive_attempt && ( +
    +
    Adaptive attempt
    +
    {attackSummary.labels._adaptive_attempt}
    +
    + )} +
    +
    + )} { expect(screen.getByText("User message test")).toBeInTheDocument(); }); + it("should not collapse a long original prompt when long-prompt collapsing is disabled", () => { + const originalContent = "demonstration ".repeat(500); + render( + + + + ); + + const original = screen.getByTestId("original-section"); + expect(original).toHaveTextContent(originalContent.trim()); + expect(within(original).queryByText("Show full prompt")).not.toBeInTheDocument(); + expect(screen.getByText("converted payload")).toBeInTheDocument(); + expect(screen.getByTestId("converted-label")).toBeInTheDocument(); + }); + + it("should collapse a long original prompt when long-prompt collapsing is enabled", () => { + const originalContent = "demonstration ".repeat(500); + render( + + + + ); + + const original = screen.getByTestId("original-section"); + expect(within(original).getByText(`Long prompt · ${originalContent.length.toLocaleString()} characters`)) + .toBeInTheDocument(); + expect(within(original).getByText("Show full prompt")).toBeInTheDocument(); + const details = within(original).getByText("Show full prompt").closest("details"); + expect(details).not.toHaveAttribute("open"); + expect(details).toHaveTextContent("demonstration"); + }); + + it("should collapse generic long historical prompts without changing ordinary short prompts", () => { + const longPrompt = "x".repeat(4_001); + const first = render( + + + + ); + expect(screen.getByText("Long prompt · 4,001 characters")).toBeInTheDocument(); + expect(screen.getByText("Show full prompt")).toBeInTheDocument(); + first.unmount(); + + render( + + + + ); + expect(screen.getByText("Short prompt")).toBeInTheDocument(); + expect(screen.queryByText("Show full prompt")).not.toBeInTheDocument(); + }); + + it("should copy the complete collapsed prompt", async () => { + const user = userEvent.setup(); + const content = "generated ".repeat(500); + const writeText = jest.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + render( + + + + ); + + await user.click(screen.getByRole("button", { name: "Copy full prompt" })); + expect(writeText).toHaveBeenCalledWith(content); + expect(screen.getByRole("button", { name: "Full prompt copied" })).toBeInTheDocument(); + }); + it("should render assistant messages", () => { const assistantMessages: Message[] = [ { diff --git a/frontend/src/components/Chat/MessageList.tsx b/frontend/src/components/Chat/MessageList.tsx index 9e072dd087..c29ba80915 100644 --- a/frontend/src/components/Chat/MessageList.tsx +++ b/frontend/src/components/Chat/MessageList.tsx @@ -27,6 +27,8 @@ import { ArrowReplyRegular, BranchForkRegular, ChatAddRegular, + CheckmarkRegular, + CopyRegular, EditRegular, MoreHorizontalRegular, OpenRegular, @@ -71,10 +73,14 @@ interface MessageListProps { noTargetSelected?: boolean /** Conversation-wide default: render message text as Markdown. */ globalMarkdown?: boolean + /** Collapse long user prompts when rendering persisted attack history. */ + collapseLongPrompts?: boolean /** Recovery action for the processing error caused by the most recent send. */ processingErrorRecovery?: ProcessingErrorRecovery } +const LONG_PROMPT_CHARACTER_THRESHOLD = 4_000 + /** Image that shows a spinner while loading. */ function ImageWithSpinner({ src, alt, className, hiddenClassName, containerClassName, spinnerClassName }: { src: string @@ -496,6 +502,61 @@ function MessageScores({ scores, groupId }: { scores: DisplayScore[]; groupId: s ) } +interface CollapsedPromptProps { + readonly content: string + readonly globalMarkdown: boolean + readonly index: number +} + +function CollapsedPrompt({ + content, + globalMarkdown, + index, +}: CollapsedPromptProps) { + const styles = useMessageListStyles() + const [copyStatus, setCopyStatus] = useState<'idle' | 'copied' | 'error'>('idle') + + const handleCopy = useCallback(async (): Promise => { + try { + await navigator.clipboard.writeText(content) + setCopyStatus('copied') + } catch { + setCopyStatus('error') + } + }, [content]) + + const characterSummary = `Long prompt · ${content.length.toLocaleString()} characters` + + return ( +
    + {characterSummary} +
    + +
    + {copyStatus === 'error' && ( + + Could not copy the full prompt. Expand it and copy the text manually. + + )} +
    + Show full prompt +
    + {globalMarkdown + ? + :
    {content}
    } +
    +
    +
    + ) +} + /** * If the trimmed text is a JSON object or array, return a 2-space pretty-printed * version of it; otherwise return null. Used to render structured assistant @@ -565,7 +626,21 @@ function getRenderMessagePieces(message: Message, messageIndex: number): RenderM return pieces } -export default function MessageList({ messages, onCopyToInput, onCopyToNewConversation, onBranchConversation, onBranchAttack, isLoading, isSingleTurn, isOperatorLocked, isCrossTarget, noTargetSelected, globalMarkdown = false, processingErrorRecovery }: MessageListProps) { +export default function MessageList({ + messages, + onCopyToInput, + onCopyToNewConversation, + onBranchConversation, + onBranchAttack, + isLoading, + isSingleTurn, + isOperatorLocked, + isCrossTarget, + noTargetSelected, + globalMarkdown = false, + collapseLongPrompts = false, + processingErrorRecovery, +}: MessageListProps) { const styles = useMessageListStyles() const messagesEndRef = useRef(null) @@ -684,9 +759,20 @@ export default function MessageList({ messages, onCopyToInput, onCopyToNewConver {(message.originalContent || message.originalAttachments) && (
    Original
    - {message.originalContent && ( - {message.originalContent} - )} + {message.originalContent && (() => { + const shouldCollapse = isUser + && collapseLongPrompts + && message.originalContent.length >= LONG_PROMPT_CHARACTER_THRESHOLD + return shouldCollapse + ? ( + + ) + : {message.originalContent} + })()} {message.originalAttachments && message.originalAttachments.length > 0 && (
    {message.originalAttachments.map((att, i) => ( @@ -717,6 +803,9 @@ export default function MessageList({ messages, onCopyToInput, onCopyToNewConver
    {renderPieces.map(({ piece, groupId, markdownTestId }) => { if (piece.type === 'text') { + const shouldCollapse = isUser + && collapseLongPrompts + && piece.content.length >= LONG_PROMPT_CHARACTER_THRESHOLD const formatted = !message.isLoading && !globalMarkdown && !isUser ? tryFormatJson(piece.content) : null @@ -728,6 +817,12 @@ export default function MessageList({ messages, onCopyToInput, onCopyToNewConver > {message.isLoading ? ( {piece.content} + ) : shouldCollapse ? ( + ) : globalMarkdown ? ( ) : formatted !== null ? ( diff --git a/frontend/src/components/Chat/attackOrchestration.ts b/frontend/src/components/Chat/attackOrchestration.ts new file mode 100644 index 0000000000..317f166eab --- /dev/null +++ b/frontend/src/components/Chat/attackOrchestration.ts @@ -0,0 +1,15 @@ +import type { AttackSummary } from '../../types' + +export function isAttackOrchestrationSummary(attackSummary: AttackSummary): boolean { + return attackSummary.attack_type === 'SequentialAttack' + && attackSummary.conversation_id.length === 0 + && attackSummary.message_count === 0 +} + +export function childAttackResultIds(attackSummary: AttackSummary): string[] { + const childIds = attackSummary.metadata?.child_attack_result_ids + if (!Array.isArray(childIds)) { + return [] + } + return childIds.filter((childId) => typeof childId === 'string' && childId.trim().length > 0) +} diff --git a/frontend/src/components/Scenarios/ScenarioCatalog.test.tsx b/frontend/src/components/Scenarios/ScenarioCatalog.test.tsx index ee45898d4c..1c956ee1c2 100644 --- a/frontend/src/components/Scenarios/ScenarioCatalog.test.tsx +++ b/frontend/src/components/Scenarios/ScenarioCatalog.test.tsx @@ -598,7 +598,7 @@ describe('ScenarioCatalog', () => { render() const row = await screen.findByTestId('scenario-card-adaptive.text_adaptive') - expect(within(row).getByText('21–42 planned attacks · up to 42 technique attempts')).toBeInTheDocument() + expect(within(row).getByText('up to 63 attack attempts · 21–42 progress units')).toBeInTheDocument() expect(within(row).queryByText(/objective envelope/i)).not.toBeInTheDocument() }) diff --git a/frontend/src/components/Scenarios/ScenarioDetail.test.tsx b/frontend/src/components/Scenarios/ScenarioDetail.test.tsx index 848b0a60a6..7e52bbda3e 100644 --- a/frontend/src/components/Scenarios/ScenarioDetail.test.tsx +++ b/frontend/src/components/Scenarios/ScenarioDetail.test.tsx @@ -41,6 +41,25 @@ const REMOVED_NORMAL_ESTIMATE_LABELS = new RegExp( ) const CORRECT_HIGHLIGHTED_SETTING_MESSAGE = 'Correct the highlighted setting to calculate this run.' +function adaptiveAttemptEquationName( + objectives: number, + techniques: number, + candidateRule: string, + includeBaseline = true, +): string { + const objectiveLabel = objectives === 1 ? 'objective' : 'objectives' + const techniqueLabel = techniques === 1 + ? 'Adaptive technique per objective' + : 'Adaptive techniques per objective' + const baselineFactor = includeBaseline ? '1 direct baseline plus ' : '' + const attemptUpperBound = objectives * techniques + (includeBaseline ? objectives : 0) + return `${objectives} ${objectiveLabel} multiplied by ${baselineFactor}up to ${techniques} ${techniqueLabel}, ${ + candidateRule + }, equals up to ${attemptUpperBound} attack attempts. Direct baseline comparison is ${ + includeBaseline ? 'included' : 'not included' + }.` +} + const mockNavigate = jest.fn() const RAW_IMAGE_HTML = ['<', 'img src=x onerror="alert(1)">'].join('') @@ -697,7 +716,11 @@ describe('ScenarioDetail', () => { expect.any(AbortSignal), )) expect(await screen.findByRole('group', { - name: '21 objectives multiplied by up to 2 techniques per objective, the smaller of 2 selected candidates and limit 2, equals up to 42 technique attempts.', + name: adaptiveAttemptEquationName( + 21, + 2, + 'the smaller of 2 selected candidates and limit 2', + ), })).toBeInTheDocument() expect(screen.queryByText('Exact total unavailable')).not.toBeInTheDocument() }) @@ -774,7 +797,11 @@ describe('ScenarioDetail', () => { await advanceTimers(300) await flushRenderedPromises() expect(screen.getByRole('group', { - name: '21 objectives multiplied by up to 2 techniques per objective, the smaller of 2 selected candidates and limit 2, equals up to 42 technique attempts.', + name: adaptiveAttemptEquationName( + 21, + 2, + 'the smaller of 2 selected candidates and limit 2', + ), })).toBeInTheDocument() await user.click(screen.getByTestId('dataset-airt_fairness')) @@ -789,7 +816,11 @@ describe('ScenarioDetail', () => { 'airt_leakage', ]) expect(screen.getByRole('group', { - name: '20 objectives multiplied by up to 2 techniques per objective, the smaller of 2 selected candidates and limit 2, equals up to 40 technique attempts.', + name: adaptiveAttemptEquationName( + 20, + 2, + 'the smaller of 2 selected candidates and limit 2', + ), })).toBeInTheDocument() await user.click(screen.getByTestId('restore-default-datasets')) @@ -797,7 +828,11 @@ describe('ScenarioDetail', () => { await flushRenderedPromises() expect(mockEstimateRun.mock.calls.at(-1)?.[1]).not.toHaveProperty('dataset_names') expect(screen.getByRole('group', { - name: '21 objectives multiplied by up to 2 techniques per objective, the smaller of 2 selected candidates and limit 2, equals up to 42 technique attempts.', + name: adaptiveAttemptEquationName( + 21, + 2, + 'the smaller of 2 selected candidates and limit 2', + ), })).toBeInTheDocument() for (const datasetName of scenario.default_datasets.filter((name) => name !== 'airt_fairness')) { @@ -807,7 +842,11 @@ describe('ScenarioDetail', () => { await flushRenderedPromises() expect(mockEstimateRun.mock.calls.at(-1)?.[1].dataset_names).toEqual(['airt_fairness']) expect(screen.getByRole('group', { - name: '1 objective multiplied by up to 2 techniques per objective, the smaller of 2 selected candidates and limit 2, equals up to 2 technique attempts.', + name: adaptiveAttemptEquationName( + 1, + 2, + 'the smaller of 2 selected candidates and limit 2', + ), })).toBeInTheDocument() failNextRequest = true @@ -857,7 +896,11 @@ describe('ScenarioDetail', () => { expect(screen.queryByRole('group', { name: 'Individual techniques' })).not.toBeInTheDocument() expect(await screen.findByRole('group', { - name: '21 objectives multiplied by up to 2 techniques per objective, the smaller of 2 selected candidates and limit 2, equals up to 42 technique attempts.', + name: adaptiveAttemptEquationName( + 21, + 2, + 'the smaller of 2 selected candidates and limit 2', + ), })).toBeInTheDocument() await user.click(screen.getByLabelText('Core (14 techniques)')) @@ -865,7 +908,11 @@ describe('ScenarioDetail', () => { const selectedMembers = screen.getByTestId('selected-technique-set-members') expect(within(selectedMembers).getByText('core_member_14')).toBeInTheDocument() expect(await screen.findByRole('group', { - name: '21 objectives multiplied by up to 2 techniques per objective, the smaller of 5 compatible candidates from 14 selected and limit 2, equals up to 42 technique attempts.', + name: adaptiveAttemptEquationName( + 21, + 2, + 'the smaller of 5 compatible candidates from 14 selected and limit 2', + ), })).toBeInTheDocument() expect(screen.getAllByText( /5 compatible candidates from 14 selected · limit 2/, @@ -884,7 +931,11 @@ describe('ScenarioDetail', () => { await user.clear(maxAttempts) await user.type(maxAttempts, '1') expect(await screen.findByRole('group', { - name: '21 objectives multiplied by up to 1 technique per objective, the smaller of 5 compatible candidates from 14 selected and limit 1, equals up to 21 technique attempts.', + name: adaptiveAttemptEquationName( + 21, + 1, + 'the smaller of 5 compatible candidates from 14 selected and limit 1', + ), })).toBeInTheDocument() expect(screen.getAllByText( /5 compatible candidates from 14 selected · limit 1/, @@ -892,7 +943,11 @@ describe('ScenarioDetail', () => { await user.clear(maxAttempts) expect(await screen.findByRole('group', { - name: '21 objectives multiplied by up to 3 techniques per objective, the smaller of 5 compatible candidates from 14 selected and limit 3, equals up to 63 technique attempts.', + name: adaptiveAttemptEquationName( + 21, + 3, + 'the smaller of 5 compatible candidates from 14 selected and limit 3', + ), })).toBeInTheDocument() await user.type(maxAttempts, '0') @@ -932,7 +987,7 @@ describe('ScenarioDetail', () => { )).toBeInTheDocument() expect(screen.queryByText(/Leave blank to use the default of 3/)).not.toBeInTheDocument() expect(maxAttempts).toHaveValue(2) - expect(within(preview).getByText('up to 42')).toBeInTheDocument() + expect(within(preview).getByText('up to 63')).toBeInTheDocument() const initialRequestCount = mockEstimateRun.mock.calls.length await user.clear(maxAttempts) @@ -1001,7 +1056,7 @@ describe('ScenarioDetail', () => { expect(mockEstimateRun.mock.calls.at(-1)?.[1].scenario_params).toEqual({ max_attempts_per_objective: 2, }) - expect(within(preview).getByText('up to 42')).toBeInTheDocument() + expect(within(preview).getByText('up to 63')).toBeInTheDocument() await user.clear(maxAttempts) await user.type(maxAttempts, '1') @@ -1011,7 +1066,7 @@ describe('ScenarioDetail', () => { expect(mockEstimateRun.mock.calls.at(-1)?.[1].scenario_params).toEqual({ max_attempts_per_objective: 1, }) - expect(within(within(preview).getByTestId('adaptive-work-calculation')).getByText('up to 21')) + expect(within(within(preview).getByTestId('run-calculation')).getByText('up to 42')) .toBeInTheDocument() expect(screen.getByTestId('launch-scenario-btn')).toBeEnabled() @@ -1105,7 +1160,11 @@ describe('ScenarioDetail', () => { const maxAttempts = await screen.findByRole('spinbutton', { name: 'Maximum techniques per objective' }) await screen.findByRole('group', { - name: '21 objectives multiplied by up to 2 techniques per objective, the smaller of 2 selected candidates and limit 2, equals up to 42 technique attempts.', + name: adaptiveAttemptEquationName( + 21, + 2, + 'the smaller of 2 selected candidates and limit 2', + ), }) expect(maxAttempts).toHaveAttribute('max', '2') expect(maxAttempts).toHaveValue(2) @@ -1166,7 +1225,11 @@ describe('ScenarioDetail', () => { expect.any(AbortSignal), )) expect(await screen.findByRole('group', { - name: '21 objectives multiplied by up to 2 techniques per objective, the smaller of 2 selected candidates and limit 2, equals up to 42 technique attempts.', + name: adaptiveAttemptEquationName( + 21, + 2, + 'the smaller of 2 selected candidates and limit 2', + ), })).toBeInTheDocument() expect(screen.getByText( 'Maximum reached: Recommended (default) provides 2 compatible techniques for this target.', @@ -1233,9 +1296,11 @@ describe('ScenarioDetail', () => { `Maximum reached: ${displayName} provides ${maximum} compatible techniques for this target.`, )).toBeInTheDocument() expect(await screen.findByRole('group', { - name: `21 objectives multiplied by up to ${maximum} techniques per objective, the smaller of ${maximum} selected candidates and limit ${maximum}, equals up to ${ - 21 * maximum - } technique attempts.`, + name: adaptiveAttemptEquationName( + 21, + maximum, + `the smaller of ${maximum} selected candidates and limit ${maximum}`, + ), })).toBeInTheDocument() expect(mockEstimateRun).not.toHaveBeenCalledWith( 'adaptive.text_adaptive', @@ -1277,14 +1342,22 @@ describe('ScenarioDetail', () => { name: 'Maximum techniques per objective', }) await screen.findByRole('group', { - name: '21 objectives multiplied by up to 2 techniques per objective, the smaller of 2 selected candidates and limit 2, equals up to 42 technique attempts.', + name: adaptiveAttemptEquationName( + 21, + 2, + 'the smaller of 2 selected candidates and limit 2', + ), }) await user.click(screen.getByLabelText('Core (14 techniques)')) await waitFor(() => expect(maxAttempts).toHaveAttribute('max', '5')) await user.type(maxAttempts, '5') await screen.findByRole('group', { - name: '21 objectives multiplied by up to 5 techniques per objective, the smaller of 5 compatible candidates from 14 selected and limit 5, equals up to 105 technique attempts.', + name: adaptiveAttemptEquationName( + 21, + 5, + 'the smaller of 5 compatible candidates from 14 selected and limit 5', + ), }) await user.click(screen.getByLabelText('Recommended (default) — 2 techniques')) @@ -1295,7 +1368,11 @@ describe('ScenarioDetail', () => { 'Reduced to 2 because Recommended (default) provides 2 compatible techniques for this target.', )).toBeInTheDocument() await screen.findByRole('group', { - name: '21 objectives multiplied by up to 2 techniques per objective, the smaller of 2 selected candidates and limit 2, equals up to 42 technique attempts.', + name: adaptiveAttemptEquationName( + 21, + 2, + 'the smaller of 2 selected candidates and limit 2', + ), }) expect(mockEstimateRun).not.toHaveBeenCalledWith( 'adaptive.text_adaptive', @@ -1503,7 +1580,7 @@ describe('ScenarioDetail', () => { expect(mockStartRun.mock.calls[0][0].include_baseline).toBe(false) }) - it('updates Adaptive planned arithmetic ON to OFF to ON while preserving inner work', async () => { + it('updates unified Adaptive attempt arithmetic ON to OFF to ON', async () => { const scenario = makeAdaptiveScenario() mockGetScenario.mockResolvedValue(scenario) mockEstimateRun.mockImplementation( @@ -1526,10 +1603,14 @@ describe('ScenarioDetail', () => { const preview = screen.getByRole('complementary', { name: 'Run preview' }) expect(await within(preview).findByRole('group', { - name: 'Direct baseline comparison is included: 21 direct baseline attacks plus up to 21 Adaptive attacks equals 21–42 planned attacks.', + name: adaptiveAttemptEquationName( + 21, + 14, + 'the smaller of 14 selected candidates and limit 14', + ), })).toBeInTheDocument() - expect(within(preview).getByTestId('adaptive-work-calculation')).toHaveTextContent( - 'up to 294technique attempts', + expect(within(preview).getByTestId('run-calculation')).toHaveTextContent( + 'up to 315attack attempts', ) expect(screen.getByText('Adds 21 direct baseline attacks for the current objectives.')) .toBeInTheDocument() @@ -1541,10 +1622,15 @@ describe('ScenarioDetail', () => { expect(within(preview).getByText('Calculating planned attacks...')).toBeInTheDocument() expect(within(preview).getByText('Not included')).toBeInTheDocument() expect(await within(preview).findByRole('group', { - name: 'Direct baseline comparison is not included: up to 21 Adaptive attacks equals up to 21 planned attacks.', + name: adaptiveAttemptEquationName( + 21, + 14, + 'the smaller of 14 selected candidates and limit 14', + false, + ), })).toBeInTheDocument() - expect(within(preview).getByTestId('adaptive-work-calculation')).toHaveTextContent( - 'up to 294technique attempts', + expect(within(preview).getByTestId('run-calculation')).toHaveTextContent( + 'up to 294attack attempts', ) expect(mockEstimateRun).toHaveBeenLastCalledWith( 'adaptive.text_adaptive', @@ -1560,7 +1646,11 @@ describe('ScenarioDetail', () => { expect(within(preview).getByText('Included — direct objective without an attack technique')) .toBeInTheDocument() expect(await within(preview).findByRole('group', { - name: 'Direct baseline comparison is included: 21 direct baseline attacks plus up to 21 Adaptive attacks equals 21–42 planned attacks.', + name: adaptiveAttemptEquationName( + 21, + 14, + 'the smaller of 14 selected candidates and limit 14', + ), })).toBeInTheDocument() expect(mockEstimateRun).toHaveBeenLastCalledWith( 'adaptive.text_adaptive', @@ -1586,7 +1676,11 @@ describe('ScenarioDetail', () => { renderDetail('/scenarios/adaptive.text_adaptive') await screen.findByRole('group', { - name: 'Direct baseline comparison is included: 21 direct baseline attacks plus up to 21 Adaptive attacks equals 21–42 planned attacks.', + name: adaptiveAttemptEquationName( + 21, + 2, + 'the smaller of 2 selected candidates and limit 2', + ), }) let resolveOff: ((estimate: ScenarioDefaultRunSizeEstimate) => void) | null = null @@ -1620,7 +1714,11 @@ describe('ScenarioDetail', () => { })) await flushRenderedPromises() expect(screen.getByRole('group', { - name: 'Direct baseline comparison is included: 21 direct baseline attacks plus up to 21 Adaptive attacks equals 21–42 planned attacks.', + name: adaptiveAttemptEquationName( + 21, + 2, + 'the smaller of 2 selected candidates and limit 3', + ), })).toBeInTheDocument() resolveOff(makeFullyCompatibleAdaptiveEstimateForRequest(scenario, { @@ -1630,10 +1728,19 @@ describe('ScenarioDetail', () => { })) await flushRenderedPromises() expect(screen.getByRole('group', { - name: 'Direct baseline comparison is included: 21 direct baseline attacks plus up to 21 Adaptive attacks equals 21–42 planned attacks.', + name: adaptiveAttemptEquationName( + 21, + 2, + 'the smaller of 2 selected candidates and limit 3', + ), })).toBeInTheDocument() expect(screen.queryByRole('group', { - name: 'Direct baseline comparison is not included: up to 21 Adaptive attacks equals up to 21 planned attacks.', + name: adaptiveAttemptEquationName( + 21, + 2, + 'the smaller of 2 selected candidates and limit 3', + false, + ), })).not.toBeInTheDocument() }) diff --git a/frontend/src/components/Scenarios/ScenarioRunEstimate.test.tsx b/frontend/src/components/Scenarios/ScenarioRunEstimate.test.tsx index 32d229e1df..77abe4ac22 100644 --- a/frontend/src/components/Scenarios/ScenarioRunEstimate.test.tsx +++ b/frontend/src/components/Scenarios/ScenarioRunEstimate.test.tsx @@ -409,7 +409,7 @@ describe('ScenarioRunEstimate', () => { expect(equation).toHaveTextContent('12–20planned attacks') }) - it('shows adaptive progress objectives and the bounded underlying attempt work', () => { + it('shows bounded attack attempts and separates progress planning', () => { const estimate = makeEstimate({ status: 'conditional', total_attack_count: null, @@ -433,21 +433,21 @@ describe('ScenarioRunEstimate', () => { , ) - expect(screen.getByText('21 objectives · up to 42 technique attempts')).toBeInTheDocument() + expect(screen.getByText('21 objectives · up to 42 attack attempts')).toBeInTheDocument() expect(screen.getByRole('group', { - name: '21 objectives multiplied by up to 2 techniques per objective, the smaller of 2 selected candidates and limit 3, equals up to 42 technique attempts.', + name: '21 objectives multiplied by up to 2 Adaptive techniques per objective, the smaller of 2 selected candidates and limit 3, equals up to 42 attack attempts. Direct baseline comparison is not included.', })).toBeInTheDocument() expect(screen.getByText('2 selected candidates · limit 3')).toBeInTheDocument() expect(screen.getByRole('group', { - name: 'Direct baseline comparison is not included: up to 21 Adaptive attacks. Planned total is confirmed at launch.', + name: 'Progress units are confirmed at launch. Progress units track resumable evaluation groups, not every persisted attack attempt.', })).toBeInTheDocument() expect(screen.queryByText('Exact total')).not.toBeInTheDocument() expect(screen.getByText( - 'Technique-attempt totals exclude multi-turn target exchanges and retries. Adaptive stops each objective after the first successful technique. Compatibility may reduce how many candidates each objective can try.', + 'Attack-attempt totals exclude multi-turn target exchanges and retries. Adaptive techniques run sequentially and stop each objective after the first successful technique. Compatibility may reduce how many candidates each objective can try.', )).toBeInTheDocument() }) - it('shows baseline-aware planned attacks before unchanged Adaptive work', () => { + it('includes a supported baseline inside the unified per-objective attempt factor', () => { const estimate = makeEstimate({ status: 'conditional', total_attack_count: null, @@ -488,24 +488,21 @@ describe('ScenarioRunEstimate', () => { , ) - expect(screen.getByText('21–42 planned attacks · up to 42 technique attempts')).toBeInTheDocument() - const plannedEquation = screen.getByRole('group', { - name: 'Direct baseline comparison is included: 21 direct baseline attacks plus up to 21 Adaptive attacks equals 21–42 planned attacks.', + expect(screen.getByText('up to 63 attack attempts · 21–42 progress units')).toBeInTheDocument() + const attemptEquation = screen.getByRole('group', { + name: '21 objectives multiplied by 1 direct baseline plus up to 2 Adaptive techniques per objective, the smaller of 2 selected candidates and limit 3, equals up to 63 attack attempts. Direct baseline comparison is included.', }) - expect(plannedEquation).toHaveTextContent( - '21direct baseline attacks+up to 21Adaptive attacks=21–42planned attacks', + expect(attemptEquation).toHaveTextContent( + '21objectives×(1direct baseline per objective+up to 2Adaptive techniques per objective2 selected candidates · limit 3)=up to 63attack attempts', ) - expect(screen.getByRole('heading', { name: 'Planned attacks' })).toBeInTheDocument() - expect(screen.getByRole('heading', { name: 'Adaptive work' })).toBeInTheDocument() - const adaptiveWork = screen.getByTestId('adaptive-work-calculation') - expect(adaptiveWork).toHaveTextContent('21objectives×up to 2techniques per objective') - expect(adaptiveWork).toHaveTextContent('=up to 42technique attempts') + expect(screen.getByRole('heading', { name: 'Attack attempts' })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Progress planning' })).toBeInTheDocument() + expect(screen.getByTestId('progress-unit-calculation')).toHaveTextContent('21–42progress units') expect(screen.queryByText(/Attempt ceiling:/)).not.toBeInTheDocument() - expect(screen.queryByText(/Progress tracks/)).not.toBeInTheDocument() expect(screen.queryByText(/objective envelope|logical seed groups|selected seed groups/i)).not.toBeInTheDocument() }) - it('removes the baseline term while keeping Adaptive work unchanged', () => { + it('removes the baseline factor while retaining the Adaptive attempt ceiling', () => { renderDetails(makeEstimate({ status: 'conditional', total_attack_count: null, @@ -532,17 +529,17 @@ describe('ScenarioRunEstimate', () => { }, })) - const plannedEquation = screen.getByRole('group', { - name: 'Direct baseline comparison is not included: up to 21 Adaptive attacks equals up to 21 planned attacks.', + const attemptEquation = screen.getByRole('group', { + name: '21 objectives multiplied by up to 14 Adaptive techniques per objective, the smaller of 14 selected candidates and limit 14, equals up to 294 attack attempts. Direct baseline comparison is not included.', }) - expect(plannedEquation).toHaveTextContent('up to 21Adaptive attacks=up to 21planned attacks') - expect(within(plannedEquation).queryByText(/baseline attack/)).not.toBeInTheDocument() - const adaptiveWork = screen.getByTestId('adaptive-work-calculation') - expect(adaptiveWork).toHaveTextContent('21objectives×up to 14techniques per objective') - expect(adaptiveWork).toHaveTextContent('=up to 294technique attempts') + expect(attemptEquation).toHaveTextContent( + '21objectives×up to 14Adaptive techniques per objective14 selected candidates · limit 14=up to 294attack attempts', + ) + expect(within(attemptEquation).queryByText(/baseline/)).not.toBeInTheDocument() + expect(screen.getByTestId('progress-unit-calculation')).toHaveTextContent('Up to 21progress units') }) - it('renders exact Adaptive planned values without inventing a range', () => { + it('renders exact Adaptive progress units without inventing a range', () => { renderDetails(makeEstimate({ status: 'exact', total_attack_count: 42, @@ -577,12 +574,13 @@ describe('ScenarioRunEstimate', () => { })) expect(screen.getByRole('group', { - name: 'Direct baseline comparison is included: 21 direct baseline attacks plus 21 Adaptive attacks equals 42 planned attacks.', + name: '21 objectives multiplied by 1 direct baseline plus up to 14 Adaptive techniques per objective, the smaller of 14 selected candidates and limit 14, equals up to 315 attack attempts. Direct baseline comparison is included.', })).toBeInTheDocument() + expect(screen.getByTestId('progress-unit-calculation')).toHaveTextContent('42progress units') expect(screen.queryByText('21–42')).not.toBeInTheDocument() }) - it('preserves a nonzero Adaptive planned range when no baseline is included', () => { + it('preserves a nonzero Adaptive progress range when no baseline is included', () => { renderDetails(makeEstimate({ status: 'conditional', total_attack_count: null, @@ -602,8 +600,9 @@ describe('ScenarioRunEstimate', () => { })) expect(screen.getByRole('group', { - name: 'Direct baseline comparison is not included: 5–21 Adaptive attacks equals 5–21 planned attacks.', + name: '5–21 progress units. Progress units track resumable evaluation groups, not every persisted attack attempt.', })).toBeInTheDocument() + expect(screen.getByTestId('run-calculation')).toHaveTextContent('up to 42attack attempts') }) it('uses the configured max when it is lower than the adaptive candidate pool', () => { @@ -644,7 +643,7 @@ describe('ScenarioRunEstimate', () => { }, })) - expect(screen.getByText('techniques per objective')).toBeInTheDocument() + expect(screen.getByText('Adaptive techniques per objective')).toBeInTheDocument() expect(screen.getByText('2 selected candidates · limit 5')).toBeInTheDocument() expect(screen.getByText('up to 42')).toBeInTheDocument() }) diff --git a/frontend/src/components/Scenarios/ScenarioRunEstimate.tsx b/frontend/src/components/Scenarios/ScenarioRunEstimate.tsx index eb21a7aee8..22f9195b02 100644 --- a/frontend/src/components/Scenarios/ScenarioRunEstimate.tsx +++ b/frontend/src/components/Scenarios/ScenarioRunEstimate.tsx @@ -99,27 +99,23 @@ function formatCount(value: number): string { return value.toLocaleString() } -function formatEstimateValue(value: number): string { - return value.toLocaleString() -} - function countLabel(value: number, singular: string, plural: string): string { - return `${formatEstimateValue(value)} ${value === 1 ? singular : plural}` + return `${formatCount(value)} ${value === 1 ? singular : plural}` } function formatPlannedAttackSummary(estimate: ScenarioRunEstimate): string { if (estimate.total !== null) { return countLabel(estimate.total, 'planned attack', 'planned attacks') } - if (estimate.minimum != null && estimate.maximum != null) { + if (estimate.minimum !== null && estimate.maximum !== null) { return estimate.minimum === estimate.maximum ? countLabel(estimate.minimum, 'planned attack', 'planned attacks') - : `${formatEstimateValue(estimate.minimum)}–${formatEstimateValue(estimate.maximum)} planned attacks` + : `${formatCount(estimate.minimum)}–${formatCount(estimate.maximum)} planned attacks` } - if (estimate.maximum != null) { + if (estimate.maximum !== null) { return `Up to ${countLabel(estimate.maximum, 'planned attack', 'planned attacks')}` } - if (estimate.minimum != null) { + if (estimate.minimum !== null) { return `At least ${countLabel(estimate.minimum, 'planned attack', 'planned attacks')}` } return estimate.scope === 'default' @@ -135,22 +131,41 @@ function baselineCount(estimate: ScenarioRunEstimate): number { function formatEstimateSummary(estimate: ScenarioRunEstimate): string { if (estimate.adaptiveDetails) { - const { objectiveCount, techniqueAttemptCountUpperBound } = estimate.adaptiveDetails + const attackAttemptUpperBound = estimate.adaptiveDetails.techniqueAttemptCountUpperBound + + baselineCount(estimate) const attemptSummary = `up to ${countLabel( - techniqueAttemptCountUpperBound, - 'technique attempt', - 'technique attempts', + attackAttemptUpperBound, + 'attack attempt', + 'attack attempts', )}` const hasPlannedAttackBound = estimate.total !== null - || estimate.minimum != null - || estimate.maximum != null + || estimate.minimum !== null + || estimate.maximum !== null return hasPlannedAttackBound - ? `${formatPlannedAttackSummary(estimate)} · ${attemptSummary}` - : `${countLabel(objectiveCount, 'objective', 'objectives')} · ${attemptSummary}` + ? `${attemptSummary} · ${formatProgressUnitSummary(estimate)}` + : `${countLabel(estimate.adaptiveDetails.objectiveCount, 'objective', 'objectives')} · ${attemptSummary}` } return formatPlannedAttackSummary(estimate) } +function formatProgressUnitSummary(estimate: ScenarioRunEstimate): string { + if (estimate.total !== null) { + return countLabel(estimate.total, 'progress unit', 'progress units') + } + if (estimate.minimum !== null && estimate.maximum !== null) { + return estimate.minimum === estimate.maximum + ? countLabel(estimate.minimum, 'progress unit', 'progress units') + : `${formatCount(estimate.minimum)}–${formatCount(estimate.maximum)} progress units` + } + if (estimate.maximum !== null) { + return `Up to ${countLabel(estimate.maximum, 'progress unit', 'progress units')}` + } + if (estimate.minimum !== null) { + return `At least ${countLabel(estimate.minimum, 'progress unit', 'progress units')}` + } + return 'Progress units are confirmed at launch.' +} + function operand(id: string, value: string, label: string, result = false, detail?: string): CalculationPart { return { kind: 'operand', operand: { id, value, label, detail, result } } } @@ -208,7 +223,7 @@ function resultOperand(estimate: ScenarioRunEstimate): CalculationOperand { result: true, } } - if (estimate.minimum != null && estimate.maximum != null) { + if (estimate.minimum !== null && estimate.maximum !== null) { return { id: 'result', value: estimate.minimum === estimate.maximum @@ -218,7 +233,7 @@ function resultOperand(estimate: ScenarioRunEstimate): CalculationOperand { result: true, } } - if (estimate.maximum != null) { + if (estimate.maximum !== null) { return { id: 'result', value: `up to ${formatCount(estimate.maximum)}`, @@ -226,7 +241,7 @@ function resultOperand(estimate: ScenarioRunEstimate): CalculationOperand { result: true, } } - if (estimate.minimum != null) { + if (estimate.minimum !== null) { return { id: 'result', value: `at least ${formatCount(estimate.minimum)}`, @@ -239,84 +254,17 @@ function resultOperand(estimate: ScenarioRunEstimate): CalculationOperand { : { id: 'result', value: 'Confirmed', label: 'at launch', result: true } } -function adaptivePlannedCalculation(estimate: ScenarioRunEstimate): RunCalculation { +function adaptiveAttemptCalculation(estimate: ScenarioRunEstimate): RunCalculation { const details = estimate.adaptiveDetails if (!details) { - throw new Error('Adaptive planned calculation requires adaptive details.') + throw new Error('Adaptive attempt calculation requires adaptive details.') } const directBaselineCount = baselineCount(estimate) - const hasExactTotal = estimate.total !== null - || ( - estimate.minimum != null - && estimate.maximum != null - && estimate.minimum === estimate.maximum - ) - const hasPlannedTotal = estimate.total !== null - || estimate.minimum != null - || estimate.maximum != null - const adaptiveAttackCount = hasExactTotal - ? Math.max((estimate.total ?? estimate.maximum ?? 0) - directBaselineCount, 0) - : estimate.maximum != null - ? Math.max(estimate.maximum - directBaselineCount, 0) - : estimate.minimum != null - ? Math.max(estimate.minimum - directBaselineCount, 0) - : details.objectiveCount - const hasAdaptiveRange = directBaselineCount === 0 - && estimate.minimum != null - && estimate.minimum > 0 - && estimate.maximum != null - && estimate.minimum !== estimate.maximum - const adaptiveValue = hasExactTotal - ? formatCount(adaptiveAttackCount) - : hasAdaptiveRange - ? `${formatCount(estimate.minimum ?? 0)}–${formatCount(estimate.maximum ?? 0)}` - : estimate.maximum != null || estimate.minimum == null - ? `up to ${formatCount(adaptiveAttackCount)}` - : `at least ${formatCount(adaptiveAttackCount)}` - const adaptiveLabel = adaptiveAttackCount === 1 ? 'Adaptive attack' : 'Adaptive attacks' - const result = resultOperand(estimate) - const parts: CalculationPart[] = [] - if (directBaselineCount > 0) { - parts.push(operand( - 'baseline', - formatCount(directBaselineCount), - directBaselineCount === 1 ? 'direct baseline attack' : 'direct baseline attacks', - )) - parts.push(operator('baseline-plus', '+')) - } - parts.push(operand('adaptive-attacks', adaptiveValue, adaptiveLabel)) - if (hasPlannedTotal) { - parts.push(operator('planned-equals', '=')) - parts.push({ kind: 'operand', operand: result }) - } - - const adaptivePhrase = `${adaptiveValue} ${adaptiveLabel}` - const resultPhrase = `${result.value} ${result.label}` - const plannedResultPhrase = hasPlannedTotal - ? ` equals ${resultPhrase}.` - : '. Planned total is confirmed at launch.' - const accessibleLabel = directBaselineCount > 0 - ? `Direct baseline comparison is included: ${countLabel( - directBaselineCount, - 'direct baseline attack', - 'direct baseline attacks', - )} plus ${adaptivePhrase}${plannedResultPhrase}` - : `Direct baseline comparison is not included: ${adaptivePhrase}${plannedResultPhrase}` - return { parts, accessibleLabel } -} - -function adaptiveWorkCalculation(estimate: ScenarioRunEstimate): RunCalculation { - const details = estimate.adaptiveDetails - if (!details) { - throw new Error('Adaptive work calculation requires adaptive details.') - } + const attemptUpperBound = details.techniqueAttemptCountUpperBound + directBaselineCount const objectiveLabel = details.objectiveCount === 1 ? 'objective' : 'objectives' const techniqueLabel = details.techniquesPerObjectiveUpperBound === 1 - ? 'technique per objective' - : 'techniques per objective' - const attemptLabel = details.techniqueAttemptCountUpperBound === 1 - ? 'technique attempt' - : 'technique attempts' + ? 'Adaptive technique per objective' + : 'Adaptive techniques per objective' const capProvenance = { selectedCandidateCount: details.selectedCandidateTechniqueCount, compatibleCandidateCount: details.candidateTechniqueCount, @@ -325,30 +273,84 @@ function adaptiveWorkCalculation(estimate: ScenarioRunEstimate): RunCalculation } const effectiveCapRule = formatAdaptiveCapMetadata(capProvenance) const accessibleCapRule = formatAdaptiveCapAccessibleRule(capProvenance) + const baselinePerObjective = details.objectiveCount > 0 + && directBaselineCount === details.objectiveCount + const parts: CalculationPart[] = [ + operand('attack-objectives', formatCount(details.objectiveCount), objectiveLabel), + operator('attack-multiply', '×'), + ] + if (baselinePerObjective) { + parts.push(operator('attempt-open', '(')) + parts.push(operand('baseline-factor', '1', 'direct baseline per objective')) + parts.push(operator('attempt-plus', '+')) + } + parts.push(operand( + 'adaptive-techniques', + `up to ${formatCount(details.techniquesPerObjectiveUpperBound)}`, + techniqueLabel, + false, + effectiveCapRule, + )) + if (baselinePerObjective) { + parts.push(operator('attempt-close', ')')) + } + if (directBaselineCount > 0 && !baselinePerObjective) { + parts.push(operator('partial-baseline-plus', '+')) + parts.push(operand( + 'partial-baseline', + formatCount(directBaselineCount), + directBaselineCount === 1 ? 'direct baseline attempt' : 'direct baseline attempts', + )) + } + parts.push(operator('attack-equals', '=')) + parts.push(operand( + 'attack-result', + `up to ${formatCount(attemptUpperBound)}`, + attemptUpperBound === 1 ? 'attack attempt' : 'attack attempts', + true, + )) + + const baselinePhrase = baselinePerObjective + ? '1 direct baseline plus ' + : '' + const partialBaselinePhrase = directBaselineCount > 0 && !baselinePerObjective + ? ` plus ${countLabel(directBaselineCount, 'direct baseline attempt', 'direct baseline attempts')}` + : '' + const baselineContext = directBaselineCount === 0 + ? ' Direct baseline comparison is not included.' + : ' Direct baseline comparison is included.' + return { + parts, + accessibleLabel: `${countLabel(details.objectiveCount, 'objective', 'objectives')} multiplied by ${ + baselinePhrase + }up to ${countLabel( + details.techniquesPerObjectiveUpperBound, + 'Adaptive technique per objective', + 'Adaptive techniques per objective', + )}, ${accessibleCapRule}${partialBaselinePhrase}, equals up to ${ + countLabel(attemptUpperBound, 'attack attempt', 'attack attempts') + }.${baselineContext}`, + } +} + +function adaptiveProgressCalculation(estimate: ScenarioRunEstimate): RunCalculation { + const summary = formatProgressUnitSummary(estimate) + const summarySentence = summary.endsWith('.') ? summary : `${summary}.` + const hasBound = estimate.total !== null || estimate.minimum !== null || estimate.maximum !== null + const value = hasBound ? summary.replace(/ progress units?$/, '') : 'Confirmed at launch' return { parts: [ - operand('adaptive-objectives', formatCount(details.objectiveCount), objectiveLabel), - operator('adaptive-multiply', '×'), - operand( - 'adaptive-techniques', - `up to ${formatCount(details.techniquesPerObjectiveUpperBound)}`, - techniqueLabel, - false, - effectiveCapRule, - ), - operator('adaptive-equals', '='), operand( - 'adaptive-result', - `up to ${formatCount(details.techniqueAttemptCountUpperBound)}`, - attemptLabel, + 'progress-result', + value, + estimate.total === 1 || (estimate.minimum === 1 && estimate.maximum === 1) + ? 'progress unit' + : 'progress units', true, ), ], - accessibleLabel: `${countLabel(details.objectiveCount, 'objective', 'objectives')} multiplied by up to ${ - countLabel(details.techniquesPerObjectiveUpperBound, 'technique per objective', 'techniques per objective') - }, ${accessibleCapRule}, equals up to ${ - countLabel(details.techniqueAttemptCountUpperBound, 'technique attempt', 'technique attempts') - }.`, + accessibleLabel: `${summarySentence} Progress units track resumable evaluation groups, not every persisted attack attempt.`, + context: 'Progress units track resumable evaluation groups, not every persisted attack attempt.', } } @@ -360,7 +362,7 @@ function adaptiveWorkContext(estimate: ScenarioRunEstimate): string { const compatibilityContext = details.compatibilityMayReduceAttempts ? ' Compatibility may reduce how many candidates each objective can try.' : '' - return `Technique-attempt totals exclude multi-turn target exchanges and retries. Adaptive stops each objective after the first successful technique.${compatibilityContext}` + return `Attack-attempt totals exclude multi-turn target exchanges and retries. Adaptive techniques run sequentially and stop each objective after the first successful technique.${compatibilityContext}` } function homogeneousTechniqueCalculation( @@ -469,7 +471,7 @@ function ordinaryCalculation(estimate: ScenarioRunEstimate): RunCalculation { .replace(/×/g, 'multiplied by') .replace(/\+/g, 'plus') .replace(/=/g, 'equals')}.`, - context: estimate.total === null && estimate.minimum == null && estimate.maximum == null + context: estimate.total === null && estimate.minimum === null && estimate.maximum === null ? formatEstimateSummary(estimate) : undefined, } @@ -478,11 +480,12 @@ function ordinaryCalculation(estimate: ScenarioRunEstimate): RunCalculation { export function ScenarioRunEstimateSummary({ state }: ScenarioRunEstimateSummaryProps) { const styles = useScenarioRunEstimateStyles() const estimate = stateEstimate(state) + const label = statusLabel(state) return (
    - {statusLabel(state)} + {label && {label}} {estimate && ( {formatEstimateSummary(estimate)} @@ -627,16 +630,16 @@ export function ScenarioRunEstimateDetails({ {hasAdaptiveDetails ? ( <> ) : ( diff --git a/frontend/src/components/Scenarios/ScenarioRunPage.styles.ts b/frontend/src/components/Scenarios/ScenarioRunPage.styles.ts index 807a4d40b1..a0a72a399d 100644 --- a/frontend/src/components/Scenarios/ScenarioRunPage.styles.ts +++ b/frontend/src/components/Scenarios/ScenarioRunPage.styles.ts @@ -169,6 +169,63 @@ export const useScenarioRunPageStyles = makeStyles({ metricValue: { fontVariantNumeric: 'tabular-nums', }, + accountingSurface: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalM, + padding: tokens.spacingVerticalL, + border: `1px solid ${tokens.colorNeutralStroke2}`, + borderRadius: tokens.borderRadiusLarge, + backgroundColor: tokens.colorNeutralBackground1, + }, + accountingEquation: { + display: 'flex', + alignItems: 'stretch', + gap: tokens.spacingHorizontalS, + [NARROW_VIEWPORT_QUERY]: { + flexDirection: 'column', + }, + }, + accountingOperand: { + display: 'flex', + flex: '1 1 10rem', + flexDirection: 'column', + justifyContent: 'center', + gap: tokens.spacingVerticalXXS, + minWidth: 0, + padding: `${tokens.spacingVerticalM} ${tokens.spacingHorizontalL}`, + border: `1px solid ${tokens.colorNeutralStroke2}`, + borderRadius: tokens.borderRadiusMedium, + backgroundColor: tokens.colorNeutralBackground2, + [NARROW_VIEWPORT_QUERY]: { + flexBasis: 'auto', + }, + }, + accountingResult: { + display: 'flex', + flex: '1 1 10rem', + flexDirection: 'column', + justifyContent: 'center', + gap: tokens.spacingVerticalXXS, + minWidth: 0, + padding: `${tokens.spacingVerticalM} ${tokens.spacingHorizontalL}`, + border: `1px solid ${tokens.colorBrandStroke1}`, + borderRadius: tokens.borderRadiusMedium, + color: tokens.colorBrandForeground1, + backgroundColor: tokens.colorBrandBackground2, + [NARROW_VIEWPORT_QUERY]: { + flexBasis: 'auto', + }, + }, + accountingOperator: { + alignSelf: 'center', + flexShrink: 0, + paddingInline: tokens.spacingHorizontalXS, + color: tokens.colorNeutralForeground3, + }, + accountingProvenance: { + overflowWrap: 'anywhere', + }, summaryGrid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(15rem, 1fr))', @@ -256,6 +313,26 @@ export const useScenarioRunPageStyles = makeStyles({ gap: tokens.spacingVerticalS, }, }, + supportingResults: { + marginTop: tokens.spacingVerticalL, + '& > summary': { + display: 'list-item', + boxSizing: 'border-box', + minHeight: MINIMUM_TOUCH_TARGET_SIZE, + width: 'fit-content', + paddingBlock: tokens.spacingVerticalM, + color: tokens.colorBrandForegroundLink, + cursor: 'pointer', + fontWeight: tokens.fontWeightSemibold, + ':focus-visible': { + outline: `2px solid ${tokens.colorStrokeFocus2}`, + outlineOffset: '2px', + }, + }, + '& > div': { + marginTop: tokens.spacingVerticalS, + }, + }, expandButton: { ...mobileTouchTarget, alignSelf: 'center', diff --git a/frontend/src/components/Scenarios/ScenarioRunPage.test.tsx b/frontend/src/components/Scenarios/ScenarioRunPage.test.tsx index 26d3d9b3f3..762fb9857b 100644 --- a/frontend/src/components/Scenarios/ScenarioRunPage.test.tsx +++ b/frontend/src/components/Scenarios/ScenarioRunPage.test.tsx @@ -105,6 +105,7 @@ const PLAN: ScenarioRunPlan = { seed_group_ids: ['seed-1'], description: 'Uses a role-play prompt to elicit the requested response.', tags: ['single_turn'], + group_kind: 'attack', }], seed_groups: [{ id: 'seed-1', @@ -138,6 +139,7 @@ const ATTEMPT: ScenarioProgressResult = { score_value: 'true', score_rationale: 'The response achieved the objective.', }, + result_kind: 'attack', } const SUMMARY: ScenarioProgressSummary = { @@ -306,7 +308,7 @@ describe('ScenarioRunPage', () => { expect(screen.getByTestId('run-state-badge')).toHaveTextContent('In progress') expect(screen.getByRole('progressbar', { name: 'Overall scenario run progress' })).toHaveAttribute( 'aria-valuetext', - '1 of 1 executable units completed', + '1 of 1 progress units completed', ) expect(screen.getByRole('region', { name: 'Atomic attack groups' })).toBeInTheDocument() expect(screen.getByRole('heading', { name: 'Objective Scorer', level: 2 })).toBeInTheDocument() @@ -337,6 +339,7 @@ describe('ScenarioRunPage', () => { expect(headings).toEqual([ 'Run configuration', 'Scenario queue', + 'Observed execution accounting', 'Overall progress', 'Atomic attack groups', 'Objective Scorer', @@ -345,6 +348,185 @@ describe('ScenarioRunPage', () => { ]) }) + it('accounts for target-facing attacks separately from orchestration records', () => { + const objectiveIds = ['seed-1', 'seed-2', 'seed-3', 'seed-4'] + const plan: ScenarioRunPlan = { + version: 1, + scenario_registry_name: 'adaptive.text', + seed_groups: objectiveIds.map((id, index) => ({ + id, + objective_sha256: `sha-${index + 1}`, + objective: `Objective ${index + 1}`, + prompts: [], + })), + atomic_groups: [ + { + id: 'baseline', + atomic_attack_name: 'baseline', + display_group: 'Direct baseline', + technique_eval_hash: 'baseline-eval', + seed_group_ids: objectiveIds, + tags: [], + group_kind: 'direct_baseline', + }, + ...objectiveIds.map((seedId, index) => ({ + id: `adaptive-${index + 1}`, + atomic_attack_name: 'adaptive', + display_group: index === 0 ? 'Fairness' : 'Harassment', + technique_eval_hash: `adaptive-eval-${index + 1}`, + seed_group_ids: [seedId], + tags: [], + group_kind: 'adaptive' as const, + })), + ], + } + const results: ScenarioProgressResult[] = objectiveIds.flatMap((seedId, index) => { + const adaptiveGroupId = `adaptive-${index + 1}` + return [ + { + ...ATTEMPT, + attack_result_id: `baseline-${index}`, + atomic_group_id: 'baseline', + atomic_attack_name: 'baseline', + seed_group_id: seedId, + timestamp: `2026-01-01T00:${String(index * 3).padStart(2, '0')}:00Z`, + total_retries: 0, + result_kind: 'direct_baseline', + }, + { + ...ATTEMPT, + attack_result_id: `technique-${index}`, + atomic_group_id: adaptiveGroupId, + atomic_attack_name: 'adaptive', + seed_group_id: seedId, + timestamp: `2026-01-01T00:${String(index * 3 + 1).padStart(2, '0')}:00Z`, + total_retries: 0, + result_kind: 'adaptive_technique', + technique_name: index === 0 ? 'Fairness technique' : 'Harassment technique', + attempt_index: 1, + }, + { + ...ATTEMPT, + attack_result_id: `envelope-${index}`, + atomic_group_id: adaptiveGroupId, + atomic_attack_name: 'adaptive', + seed_group_id: seedId, + timestamp: `2026-01-01T00:${String(index * 3 + 2).padStart(2, '0')}:00Z`, + total_retries: 7, + result_kind: 'aggregate_parent', + }, + ] + }) + mockHookState(makeState({ + run: { + ...makeState().run!, + scenario_registry_name: 'adaptive.text', + status: 'COMPLETED', + completed_at: '2026-01-01T00:15:00Z', + }, + plan, + results, + summary: { + ...SUMMARY, + overall: { + ...SUMMARY.overall, + completed: 8, + planned: 8, + retries: 0, + }, + }, + })) + + renderPage() + + expect(screen.getByRole('group', { + name: '4 objectives multiplied by 2 observed attacks each equals 8 target-facing attacks. 8/8 planned progress units completed. 12 persisted result records: 8 target-facing attack results + 4 Adaptive orchestration summaries. 0 actual retries.', + })).toBeInTheDocument() + expect(screen.getByText( + 'Per objective: 1 direct baseline + 1 Adaptive technique.', + )).toBeInTheDocument() + expect(screen.getByText( + '8/8 planned progress units completed · 12 persisted result records: 8 target-facing attack results + 4 Adaptive orchestration summaries · 0 actual retries', + )).toBeInTheDocument() + }) + + it('does not force a per-objective equation for nonuniform observed attacks', () => { + const plan: ScenarioRunPlan = { + ...PLAN, + atomic_groups: [{ + ...PLAN.atomic_groups[0], + seed_group_ids: ['seed-1', 'seed-2'], + }], + seed_groups: [ + PLAN.seed_groups[0], + { + id: 'seed-2', + objective_sha256: 'sha-2', + objective: 'A second objective with a different observed attack count.', + prompts: [], + }, + ], + } + mockHookState(makeState({ + run: { + ...makeState().run!, + status: 'COMPLETED', + completed_at: '2026-01-01T00:15:00Z', + }, + plan, + results: [ + { ...ATTEMPT, total_retries: 0 }, + { ...ATTEMPT, attack_result_id: 'attack-result-2', seed_group_id: 'seed-2', total_retries: 0 }, + { ...ATTEMPT, attack_result_id: 'attack-result-3', seed_group_id: 'seed-2', total_retries: 0 }, + ], + summary: { + ...SUMMARY, + overall: { + ...SUMMARY.overall, + completed: 2, + planned: 2, + }, + }, + })) + + renderPage() + + expect(screen.getByRole('group', { + name: '3 target-facing attacks. 2/2 planned progress units completed. 3 persisted result records. 1 actual retries.', + })).toBeInTheDocument() + expect(screen.queryByText('observed attacks each')).not.toBeInTheDocument() + expect(screen.queryByText(/^Per objective:/)).not.toBeInTheDocument() + }) + + it('accounts for unclassified legacy records in persisted storage provenance', () => { + mockHookState(makeState({ + results: [ + { ...ATTEMPT, total_retries: 0 }, + { + ...ATTEMPT, + attack_result_id: 'legacy-unknown', + atomic_group_id: 'legacy-group', + atomic_attack_name: 'legacy-attack', + result_kind: 'unknown', + total_retries: 0, + }, + ], + summary: { + ...SUMMARY, + overall: { + ...SUMMARY.overall, + retries: 0, + }, + }, + })) + + renderPage() + + expect(screen.getByRole('group', { + name: '1 objective multiplied by 1 observed attack each equals 1 target-facing attack. 1/1 planned progress units completed. 2 persisted result records: 1 target-facing attack result + 1 unclassified record. 0 actual retries.', + })).toBeInTheDocument() + }) + it('renders contract-backed safe target and run configuration metadata', () => { mockHookState(makeState({ run: { @@ -415,8 +597,8 @@ describe('ScenarioRunPage', () => { renderPage() - expect(screen.getByText(/legacy run has no complete persisted execution plan/i)).toBeInTheDocument() - expect(screen.getAllByText(/1 known completed units; planned total unavailable/i)).toHaveLength(2) + expect(screen.getByText(/legacy run has no complete persisted progress plan/i)).toBeInTheDocument() + expect(screen.getAllByText(/1 known completed progress units; planned total unavailable/i)).toHaveLength(3) expect(screen.queryByRole('progressbar')).not.toBeInTheDocument() expect(screen.getByText('Progress percentage unavailable')).toBeInTheDocument() expect(screen.getAllByText('Unavailable').length).toBeGreaterThan(0) @@ -466,7 +648,6 @@ describe('ScenarioRunPage', () => { active_scenario_result_id: 'active-run', }, results: [], - activeAtomicGroupIds: [], })) renderPage() @@ -805,7 +986,6 @@ describe('ScenarioRunPage', () => { active_scenario_result_id: 'active-run', }, results: [], - activeAtomicGroupIds: [], })) renderPage() diff --git a/frontend/src/components/Scenarios/ScenarioRunPage.tsx b/frontend/src/components/Scenarios/ScenarioRunPage.tsx index c0e0ec2a32..0e4ae6983d 100644 --- a/frontend/src/components/Scenarios/ScenarioRunPage.tsx +++ b/frontend/src/components/Scenarios/ScenarioRunPage.tsx @@ -62,9 +62,14 @@ import { scenarioRunRoutePath, } from '@/utils/routeParams' import { + getAttemptAccounting, + getAttemptPresentations, getElapsedMilliseconds, getEtaMilliseconds, + isTargetAttackRole, isTerminalRunState, + type AttemptAccounting, + type ScenarioAttemptRole, } from '@/utils/scenarioRunProgress' import AttackExecutionTable from './AttackExecutionTable' @@ -132,6 +137,8 @@ function ScenarioRunPageContent({ scenarioResultId, attackResultId }: ScenarioRu ? 'Back to scenario' : 'Back to scanners' + const attemptPresentations = useMemo(() => getAttemptPresentations(state), [state]) + const attemptAccounting = useMemo(() => getAttemptAccounting(state), [state]) const seedObjectives = useMemo( () => new Map(state.plan?.seed_groups.map((seed) => [seed.id, seed.objective]) ?? []), [state.plan], @@ -157,6 +164,10 @@ function ScenarioRunPageContent({ scenarioResultId, attackResultId }: ScenarioRu const groupedAttempts = new Map() for (let index = state.results.length - 1; index >= 0; index -= 1) { const attempt = state.results[index] + const role = attemptPresentations.get(attempt.attack_result_id)?.role ?? 'unknown' + if (!isTargetAttackRole(role)) { + continue + } const existing = groupedAttempts.get(attempt.atomic_group_id) if (existing) { existing.push(attempt) @@ -165,7 +176,7 @@ function ScenarioRunPageContent({ scenarioResultId, attackResultId }: ScenarioRu } } return groupedAttempts - }, [state.results]) + }, [attemptPresentations, state.results]) const closeAttemptDetails = (): void => { navigate(scenarioRunRoutePath(scenarioResultId), { replace: true, state: location.state }) @@ -287,8 +298,11 @@ function ScenarioRunPageContent({ scenarioResultId, attackResultId }: ScenarioRu const progressText = queued ? `Queued${run.queue_position ? ` · Position ${run.queue_position}` : ''}` : overall.planned === null - ? `${overall.completed} known completed units; planned total unavailable` - : `${overall.completed} of ${overall.planned} executable units completed` + ? `${overall.completed} known completed progress units; planned total unavailable` + : `${overall.completed} of ${overall.planned} progress units completed` + const attemptAccountingSection = state.results.length > 0 ? ( + + ) : null return (
    @@ -379,7 +393,7 @@ function ScenarioRunPageContent({ scenarioResultId, attackResultId }: ScenarioRu
    0 ? run.techniques_used?.join(', ') ?? '' : 'Unavailable'} /> - This legacy run has no complete persisted execution plan. Known groups and executions are shown, but planned totals and ETA are unavailable. + This legacy run has no complete persisted progress plan. Known groups and executions are shown, but planned totals and ETA are unavailable. )} @@ -452,6 +466,8 @@ function ScenarioRunPageContent({ scenarioResultId, attackResultId }: ScenarioRu )} + {attemptAccountingSection} +
    @@ -771,6 +787,105 @@ function DisplayGroupMetric({ label, value }: MetricProps) { ) } +interface ObservedAttemptAccountingProps { + readonly accounting: AttemptAccounting +} + +function ObservedAttemptAccounting({ accounting }: ObservedAttemptAccountingProps) { + const styles = useScenarioRunPageStyles() + const progress = accounting.plannedProgressUnits === null + ? `${accounting.completedProgressUnits} known completed progress units; planned total unavailable` + : `${accounting.completedProgressUnits}/${accounting.plannedProgressUnits} planned progress units completed` + const otherAggregateRecords = accounting.aggregateParentRecords - accounting.adaptiveAggregateParentRecords + const unclassifiedRecords = accounting.persistedAttempts - accounting.attackAttempts - accounting.aggregateParentRecords + const persistedComponents = accounting.persistedAttempts === accounting.attackAttempts + ? [] + : [ + `${accounting.attackAttempts} target-facing attack ${accounting.attackAttempts === 1 ? 'result' : 'results'}`, + ...(accounting.adaptiveAggregateParentRecords > 0 + ? [`${accounting.adaptiveAggregateParentRecords} Adaptive orchestration ${accounting.adaptiveAggregateParentRecords === 1 ? 'summary' : 'summaries'}`] + : []), + ...(otherAggregateRecords > 0 + ? [`${otherAggregateRecords} aggregate parent ${otherAggregateRecords === 1 ? 'record' : 'records'}`] + : []), + ...(unclassifiedRecords > 0 + ? [`${unclassifiedRecords} unclassified ${unclassifiedRecords === 1 ? 'record' : 'records'}`] + : []), + ] + const persistedBreakdown = persistedComponents.length > 0 ? `: ${persistedComponents.join(' + ')}` : '' + const uniformEquation = accounting.uniformTargetAttacksPerObjective !== null + && accounting.objectiveCount * accounting.uniformTargetAttacksPerObjective === accounting.attackAttempts + const observedAttackLabel = accounting.uniformTargetAttacksPerObjective === 1 + ? 'observed attack each' + : 'observed attacks each' + const targetAttackLabel = accounting.attackAttempts === 1 + ? 'target-facing attack' + : 'target-facing attacks' + + return ( +
    +
    + + Observed execution accounting + + Observed target-facing attacks lead this summary. Progress and storage details follow. +
    +
    + + {accounting.uniformTargetRoleCounts && ( + + Per objective: {formatRoleBreakdown(accounting.uniformTargetRoleCounts)}. + + )} + + {progress} · {accounting.persistedAttempts} persisted result {accounting.persistedAttempts === 1 ? 'record' : 'records'} + {persistedBreakdown} · {accounting.retries} actual {accounting.retries === 1 ? 'retry' : 'retries'} + +
    +
    + ) +} + +interface AccountingOperandProps { + readonly value: string + readonly label: string + readonly result?: boolean +} + +function AccountingOperand({ value, label, result = false }: AccountingOperandProps) { + const styles = useScenarioRunPageStyles() + return ( + + {value} + {label} + + ) +} + interface ConfigurationItemProps { readonly label: string readonly value: string @@ -844,6 +959,35 @@ function formatRunState(status: string): string { return status.toLowerCase().replace('_', ' ').replace(/^\w/, (letter) => letter.toUpperCase()) } +function formatRoleBreakdown(counts: ReadonlyMap): string { + const order: ScenarioAttemptRole[] = [ + 'direct_baseline', + 'adaptive_technique', + 'attack', + 'adaptive_orchestration', + 'aggregate_parent', + 'unknown', + ] + return order.flatMap((role) => { + const count = counts.get(role) ?? 0 + if (count === 0) { + return [] + } + const label = role === 'direct_baseline' + ? count === 1 ? 'direct baseline' : 'direct baseline attacks' + : role === 'adaptive_technique' + ? `Adaptive ${count === 1 ? 'technique' : 'techniques'}` + : role === 'adaptive_orchestration' + ? `Adaptive orchestration ${count === 1 ? 'result' : 'results'}` + : role === 'aggregate_parent' + ? `aggregate parent ${count === 1 ? 'result' : 'results'}` + : role === 'attack' + ? count === 1 ? 'attack' : 'attacks' + : `additional persisted ${count === 1 ? 'result' : 'results'}` + return [`${count} ${label}`] + }).join(' + ') +} + function statusIcon(status: ScenarioRunState): React.ReactElement { if (status === 'COMPLETED') { return diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index e8a9e40e92..e32bca820d 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -343,6 +343,12 @@ export type AttackTargetResolutionStatus = | 'error' | 'legacy' +export interface AttackResultMetadata { + child_attack_result_ids?: string[] + completion_policy?: string + [key: string]: unknown +} + export type AttackOutcome = 'undetermined' | 'success' | 'failure' | 'error' export interface AttackSummary { @@ -371,6 +377,8 @@ export interface AttackSummary { labels: Record created_at: string updated_at: string + execution_time_ms?: number + metadata?: AttackResultMetadata } export interface CreateAttackRequest { @@ -676,9 +684,9 @@ export interface ScenarioDefaultRunSizeEstimate { version: 1 status: ScenarioRunSizeEstimateStatus total_attack_count: number | null - minimum_attack_count?: number | null - maximum_attack_count?: number | null - condition?: 'target_capabilities' | 'launch_configuration' | null + minimum_attack_count: number | null + maximum_attack_count: number | null + condition: 'target_capabilities' | 'launch_configuration' | null components: ScenarioRunSizeComponent[] datasets: ScenarioDatasetSummary[] adaptive_details?: ScenarioAdaptiveRunSizeDetails | null @@ -703,7 +711,7 @@ export interface ScenarioRunEstimateComponent { count: number factors: ScenarioRunEstimateFactor[] isBaseline: boolean - condition?: 'target_capabilities' | 'launch_configuration' | null + condition: 'target_capabilities' | 'launch_configuration' | null note: string | null } @@ -746,9 +754,9 @@ export interface ScenarioRunEstimate { version: number scope: 'default' | 'request' total: number | null - minimum?: number | null - maximum?: number | null - condition?: 'target_capabilities' | 'launch_configuration' | null + minimum: number | null + maximum: number | null + condition: 'target_capabilities' | 'launch_configuration' | null components: ScenarioRunEstimateComponent[] datasets: ScenarioRunEstimateDataset[] adaptiveDetails?: ScenarioRunEstimateAdaptiveDetails | null @@ -835,7 +843,7 @@ export interface ScenarioOverloadSummary { latest_timestamp: string } -export interface ScenarioRunSummary { +export interface ScenarioRunHeader { scenario_result_id: string scenario_name: string scenario_registry_name?: string | null @@ -843,29 +851,35 @@ export interface ScenarioRunSummary { status: ScenarioRunState created_at: string started_at?: string | null + techniques_used?: string[] + labels?: Record + completed_at?: string | null + pyrit_version?: string | null + target?: ScenarioTargetSummary | null + datasets_used?: string[] + scenario_parameters?: Record + queue_position?: number | null + active_scenario_result_id?: string | null + overload_summaries?: ScenarioOverloadSummary[] +} + +export interface ScenarioRunSummary extends ScenarioRunHeader { + techniques_used: string[] + labels: Record updated_at: string error?: string | null error_type?: string | null - techniques_used: string[] total_attacks: number completed_attacks: number objective_achieved_rate: number failed_attacks: AttackErrorSummary[] attack_retries: AttackRetrySummary[] total_retries: number - labels: Record - completed_at?: string | null - pyrit_version?: string | null - target?: ScenarioTargetSummary | null - datasets_used?: string[] - scenario_parameters?: Record planned_total_available?: boolean successful_attacks?: number error_attacks?: number attack_details_available?: boolean - queue_position?: number | null - active_scenario_result_id?: string | null - overload_summaries?: ScenarioOverloadSummary[] + attack_details_truncated?: boolean } export interface ScenarioTargetSummary { @@ -909,25 +923,7 @@ export interface ScenarioRunListResponse { } /** Compact persisted run header returned by the progress endpoint. */ -export interface ScenarioProgressHeader { - scenario_result_id: string - scenario_name: string - scenario_registry_name?: string | null - scenario_version: number - status: ScenarioRunState - created_at: string - started_at?: string | null - completed_at?: string | null - pyrit_version?: string | null - target?: ScenarioTargetSummary | null - techniques_used?: string[] - datasets_used?: string[] - scenario_parameters?: Record - labels?: Record - queue_position?: number | null - active_scenario_result_id?: string | null - overload_summaries?: ScenarioOverloadSummary[] -} +export type ScenarioProgressHeader = ScenarioRunHeader export interface ScenarioQueueEntry { scenario_result_id: string @@ -986,6 +982,9 @@ export interface ScenarioProgressResult { error_type?: string | null error_message?: string | null score?: ScenarioProgressScore | null + result_kind?: 'attack' | 'direct_baseline' | 'adaptive_technique' | 'adaptive_orchestration' | 'aggregate_parent' | 'unknown' + technique_name?: string | null + attempt_index?: number | null } export interface ScenarioRunPlanSeedGroup { @@ -1012,6 +1011,7 @@ export interface ScenarioRunPlanAtomicGroup { seed_group_ids: string[] description?: string | null tags: string[] + group_kind?: 'attack' | 'direct_baseline' | 'adaptive' | null } export interface ScenarioRunPlan { diff --git a/frontend/src/utils/scenarioRunProgress.test.ts b/frontend/src/utils/scenarioRunProgress.test.ts index 46f258ffd7..656cc9f754 100644 --- a/frontend/src/utils/scenarioRunProgress.test.ts +++ b/frontend/src/utils/scenarioRunProgress.test.ts @@ -6,6 +6,9 @@ import type { import { INITIAL_SCENARIO_RUN_PROGRESS_STATE, + getAttemptAccounting, + getAttemptPresentations, + getAttemptRollups, getElapsedMilliseconds, getEtaMilliseconds, scenarioRunProgressReducer, @@ -32,7 +35,11 @@ const SUMMARY = { atomic_groups: [], } -function makeResult(id: string, minute: number): ScenarioProgressResult { +function makeResult( + id: string, + minute: number, + overrides: Partial = {}, +): ScenarioProgressResult { return { attack_result_id: id, atomic_group_id: 'group-a', @@ -43,6 +50,7 @@ function makeResult(id: string, minute: number): ScenarioProgressResult { timestamp: `2026-01-01T00:${String(minute).padStart(2, '0')}:00Z`, total_retries: 0, retries: [], + ...overrides, } } @@ -176,6 +184,83 @@ describe('scenarioRunProgressReducer', () => { }) }) +describe('scenario result accounting', () => { + it('keeps target-facing attacks separate from orchestration records', () => { + const results = [ + makeResult('baseline', 1, { result_kind: 'direct_baseline' }), + makeResult('technique', 2, { + result_kind: 'adaptive_technique', + technique_name: 'Fairness technique', + }), + makeResult('aggregate', 3, { + result_kind: 'aggregate_parent', + total_retries: 7, + }), + ] + const state = scenarioRunProgressReducer(INITIAL_SCENARIO_RUN_PROGRESS_STATE, { + type: 'apply-page', + page: makePage({ + results, + summary: { + ...SUMMARY, + overall: { ...SUMMARY.overall, completed: 2, planned: 2 }, + }, + }), + fresh: true, + }) + + expect(getAttemptPresentations(state).get('technique')).toEqual({ + role: 'adaptive_technique', + label: 'Fairness technique', + techniqueName: 'Fairness technique', + }) + expect(getAttemptAccounting(state)).toMatchObject({ + attackAttempts: 2, + persistedAttempts: 3, + aggregateParentRecords: 1, + completedProgressUnits: 2, + plannedProgressUnits: 2, + retries: 0, + }) + expect(getAttemptRollups(state).map(({ role, retries }) => ({ role, retries }))).toEqual([ + { role: 'direct_baseline', retries: 0 }, + { role: 'adaptive_technique', retries: 0 }, + { role: 'aggregate_parent', retries: 0 }, + ]) + }) + + it('groups changing Adaptive techniques as one unit and prefers canonical retries', () => { + const results = [ + makeResult('first', 1, { + result_kind: 'adaptive_technique', + technique_name: 'Fairness technique', + }), + makeResult('second', 2, { + result_kind: 'adaptive_technique', + technique_name: 'Harassment technique', + }), + ] + const state = scenarioRunProgressReducer(INITIAL_SCENARIO_RUN_PROGRESS_STATE, { + type: 'apply-page', + page: makePage({ + results, + summary: { + ...SUMMARY, + overall: { ...SUMMARY.overall, retries: 7 }, + }, + }), + fresh: true, + }) + + expect(getAttemptAccounting(state).retries).toBe(7) + expect(getAttemptAccounting({ ...state, summary: null }).retries).toBe(1) + expect(getAttemptRollups(state).map(({ label, retries }) => ({ label, retries }))).toEqual([ + { label: 'Fairness technique', retries: 0 }, + { label: 'Harassment technique', retries: 1 }, + ]) + }) +}) + describe('scenario run timing', () => { it('uses now for active elapsed time and completed_at for terminal elapsed time', () => { const active = { @@ -204,9 +289,7 @@ describe('scenario run timing', () => { const now = Date.parse('2026-01-01T01:00:00Z') expect(getElapsedMilliseconds(queued, now)).toBe(0) - expect(getEtaMilliseconds(queued, SUMMARY.overall, now)).toBeNull() - expect(getElapsedMilliseconds( { ...queued, status: 'CANCELLED', completed_at: '2026-01-01T00:30:00Z' }, now, diff --git a/frontend/src/utils/scenarioRunProgress.ts b/frontend/src/utils/scenarioRunProgress.ts index 1a701d14f3..f5466aac02 100644 --- a/frontend/src/utils/scenarioRunProgress.ts +++ b/frontend/src/utils/scenarioRunProgress.ts @@ -5,6 +5,7 @@ import type { ScenarioOverloadSummary, ScenarioProgressSummary, ScenarioRunPlan, + ScenarioRunPlanAtomicGroup, ScenarioRunState, ScenarioRunSummary, } from '@/types' @@ -29,6 +30,40 @@ export type ScenarioRunProgressAction = | { readonly type: 'retry' } | { readonly type: 'apply-run-summary'; readonly run: ScenarioRunSummary } +export type ScenarioAttemptRole = NonNullable + +export interface AttemptPresentation { + readonly role: ScenarioAttemptRole + readonly label: string + readonly techniqueName: string | null +} + +export interface AttemptRollup { + readonly id: string + readonly role: ScenarioAttemptRole + readonly label: string + readonly persistedAttempts: number + readonly succeeded: number + readonly errors: number + readonly retries: number +} + +export interface AttemptAccounting { + readonly objectiveCount: number + readonly persistedAttempts: number + readonly attackAttempts: number + readonly aggregateParentRecords: number + readonly adaptiveAggregateParentRecords: number + readonly uniformTargetAttacksPerObjective: number | null + readonly uniformTargetRoleCounts: ReadonlyMap | null + readonly completedProgressUnits: number + readonly plannedProgressUnits: number | null + readonly retries: number +} + +export function isTargetAttackRole(role: ScenarioAttemptRole): boolean { + return role === 'attack' || role === 'direct_baseline' || role === 'adaptive_technique' +} const TERMINAL_STATES: ReadonlySet = new Set(['COMPLETED', 'FAILED', 'CANCELLED']) export const INITIAL_SCENARIO_RUN_PROGRESS_STATE: ScenarioRunProgressState = { @@ -168,6 +203,258 @@ export function getEtaMilliseconds( return Number.isFinite(estimate) && estimate >= 0 ? estimate : null } +export function getAttemptPresentations(state: ScenarioRunProgressState): Map { + const groups = buildGroupMetadata(state) + const adaptiveUnits = new Set( + state.results + .filter((result) => result.result_kind === 'adaptive_technique' || Boolean(result.technique_name)) + .map((result) => unitKey(result.atomic_group_id, result.seed_group_id)), + ) + const presentations = new Map() + for (const result of state.results) { + const group = groups.get(result.atomic_group_id) + let role = result.result_kind ?? 'unknown' + const techniqueName = result.technique_name?.trim() || null + if (role === 'unknown') { + if (techniqueName) { + role = 'adaptive_technique' + } else if (group?.group_kind === 'direct_baseline' || group?.atomic_attack_name === 'baseline') { + role = 'direct_baseline' + } else if ( + group?.group_kind === 'adaptive' + || adaptiveUnits.has(unitKey(result.atomic_group_id, result.seed_group_id)) + ) { + role = 'adaptive_orchestration' + } else if (group?.group_kind === 'attack') { + role = 'attack' + } + } + const label = role === 'direct_baseline' + ? 'Direct baseline' + : role === 'adaptive_technique' + ? techniqueName ?? 'Adaptive technique' + : role === 'adaptive_orchestration' + ? 'Adaptive orchestration' + : role === 'aggregate_parent' + ? adaptiveUnits.has(unitKey(result.atomic_group_id, result.seed_group_id)) + ? 'Adaptive aggregate parent' + : 'Aggregate parent' + : role === 'attack' + ? group?.display_group || result.atomic_attack_name || 'Attack' + : 'Additional persisted result' + presentations.set(result.attack_result_id, { role, label, techniqueName }) + } + return presentations +} + +export function getAttemptRollups(state: ScenarioRunProgressState): AttemptRollup[] { + const presentations = getAttemptPresentations(state) + const rollups = new Map() + const targetAttemptsByUnit = new Map() + for (const result of state.results) { + const presentation = presentations.get(result.attack_result_id) + if (!presentation) { + continue + } + const id = `${presentation.role}\u0000${presentation.label}` + const existing = rollups.get(id) ?? { + id, + role: presentation.role, + label: presentation.label, + persistedAttempts: 0, + succeeded: 0, + errors: 0, + retries: 0, + } + rollups.set(id, { + ...existing, + persistedAttempts: existing.persistedAttempts + 1, + succeeded: existing.succeeded + (result.outcome === 'success' ? 1 : 0), + errors: existing.errors + (result.outcome === 'error' ? 1 : 0), + retries: existing.retries + ( + isTargetAttackRole(presentation.role) + ? Math.max(0, result.total_retries) + registerAdditionalAttempt( + targetAttemptsByUnit, + targetAttemptKey(result, presentation), + ) + : 0 + ), + }) + } + const roleOrder: Record = { + direct_baseline: 0, + adaptive_technique: 1, + attack: 2, + adaptive_orchestration: 3, + aggregate_parent: 4, + unknown: 5, + } + return [...rollups.values()].sort( + (left, right) => roleOrder[left.role] - roleOrder[right.role] || left.label.localeCompare(right.label), + ) +} + +export function getAttemptAccounting(state: ScenarioRunProgressState): AttemptAccounting { + const presentations = getAttemptPresentations(state) + const modernAdaptiveGroupIds = new Set( + state.plan?.atomic_groups + .filter((group) => group.group_kind === 'adaptive') + .map((group) => group.id) ?? [], + ) + const legacyAdaptiveResultKeys = new Set() + for (const result of state.results) { + const role = presentations.get(result.attack_result_id)?.role ?? 'unknown' + if (role === 'adaptive_technique' || role === 'adaptive_orchestration') { + legacyAdaptiveResultKeys.add(`${result.atomic_group_id}\0${result.seed_group_id}`) + } + } + const objectiveIds = new Set(state.plan?.seed_groups.map((seed) => seed.id) ?? []) + for (const result of state.results) { + objectiveIds.add(result.seed_group_id) + } + const attemptsByObjective = new Map() + for (const objectiveId of objectiveIds) { + attemptsByObjective.set(objectiveId, []) + } + for (const result of state.results) { + attemptsByObjective.get(result.seed_group_id)?.push(result) + } + const targetAttemptsByObjective = [...attemptsByObjective.values()].map((attempts) => ( + attempts.filter((attempt) => { + const role = presentations.get(attempt.attack_result_id)?.role ?? 'unknown' + return isTargetAttackRole(role) + }) + )) + const observedCounts = targetAttemptsByObjective.map((attempts) => attempts.length) + const uniformTargetAttacksPerObjective = observedCounts.length > 0 + && observedCounts[0] > 0 + && observedCounts.every((count) => count === observedCounts[0]) + ? observedCounts[0] + : null + const roleCounts = targetAttemptsByObjective.map((attempts) => { + const counts = new Map() + for (const attempt of attempts) { + const role = presentations.get(attempt.attack_result_id)?.role ?? 'unknown' + counts.set(role, (counts.get(role) ?? 0) + 1) + } + return counts + }) + const firstRoleSignature = roleCounts[0] ? roleCountSignature(roleCounts[0]) : null + const uniformTargetRoleCounts = firstRoleSignature !== null + && roleCounts[0].size > 0 + && roleCounts.every((counts) => roleCountSignature(counts) === firstRoleSignature) + ? roleCounts[0] + : null + const targetAttemptsByUnit = new Map() + for (const result of state.results) { + const role = presentations.get(result.attack_result_id)?.role ?? 'unknown' + if (!isTargetAttackRole(role)) { + continue + } + const presentation = presentations.get(result.attack_result_id) + if (!presentation) { + continue + } + const key = targetAttemptKey(result, presentation) + targetAttemptsByUnit.set(key, [...(targetAttemptsByUnit.get(key) ?? []), result]) + } + const overall = state.summary?.overall + return { + objectiveCount: objectiveIds.size, + persistedAttempts: state.results.length, + attackAttempts: state.results.filter((result) => { + const role = presentations.get(result.attack_result_id)?.role ?? 'unknown' + return isTargetAttackRole(role) + }).length, + aggregateParentRecords: state.results.filter((result) => ( + presentations.get(result.attack_result_id)?.role === 'adaptive_orchestration' + || presentations.get(result.attack_result_id)?.role === 'aggregate_parent' + )).length, + adaptiveAggregateParentRecords: state.results.filter((result) => { + const presentation = presentations.get(result.attack_result_id) + return presentation?.role === 'adaptive_orchestration' + || ( + presentation?.role === 'aggregate_parent' + && ( + modernAdaptiveGroupIds.has(result.atomic_group_id) + || legacyAdaptiveResultKeys.has(`${result.atomic_group_id}\0${result.seed_group_id}`) + ) + ) + }).length, + uniformTargetAttacksPerObjective, + uniformTargetRoleCounts, + completedProgressUnits: overall?.completed ?? 0, + plannedProgressUnits: overall?.planned ?? null, + retries: overall?.retries ?? [...targetAttemptsByUnit.values()].reduce( + (total, attempts) => total + unitRetryWork(attempts), + 0, + ), + } +} + +function buildGroupMetadata(state: ScenarioRunProgressState): Map { + const groups = new Map() + for (const group of state.plan?.atomic_groups ?? []) { + groups.set(group.id, { ...group, seed_group_ids: [...new Set(group.seed_group_ids)] }) + } + for (const result of state.results) { + const existing = groups.get(result.atomic_group_id) + if (existing) { + if (!existing.seed_group_ids.includes(result.seed_group_id)) { + groups.set(existing.id, { + ...existing, + seed_group_ids: [...existing.seed_group_ids, result.seed_group_id], + }) + } + continue + } + groups.set(result.atomic_group_id, { + id: result.atomic_group_id, + atomic_attack_name: result.atomic_attack_name, + display_group: result.atomic_attack_name || 'Persisted attack group', + technique_eval_hash: '', + seed_group_ids: [result.seed_group_id], + tags: [], + }) + } + return groups +} + +function unitRetryWork(targetAttempts: ScenarioProgressResult[]): number { + const innerRetries = targetAttempts.reduce( + (total, attempt) => total + Math.max(0, attempt.total_retries), + 0, + ) + return innerRetries + Math.max(0, targetAttempts.length - 1) +} + +function registerAdditionalAttempt(counts: Map, key: string): number { + const previousCount = counts.get(key) ?? 0 + counts.set(key, previousCount + 1) + return previousCount > 0 ? 1 : 0 +} + +function roleCountSignature(counts: ReadonlyMap): string { + return [...counts.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([role, count]) => `${role}:${count}`) + .join('|') +} + +function unitKey(atomicGroupId: string, seedGroupId: string): string { + return `${atomicGroupId}\u0000${seedGroupId}` +} + +function targetAttemptKey( + result: ScenarioProgressResult, + presentation: AttemptPresentation, +): string { + return [ + unitKey(result.atomic_group_id, result.seed_group_id), + presentation.role, + ].join('\u0000') +} + function compareAttempts(left: ScenarioProgressResult, right: ScenarioProgressResult): number { const timestampDifference = Date.parse(left.timestamp) - Date.parse(right.timestamp) if (Number.isFinite(timestampDifference) && timestampDifference !== 0) { diff --git a/pyrit/backend/services/scenario_progress_read_model.py b/pyrit/backend/services/scenario_progress_read_model.py index 5eb879aa89..400b5972c3 100644 --- a/pyrit/backend/services/scenario_progress_read_model.py +++ b/pyrit/backend/services/scenario_progress_read_model.py @@ -15,6 +15,8 @@ from pyrit.memory import AttackResultKeysetCursor from pyrit.memory.memory_interface import MemoryInterface from pyrit.models import ( + ADAPTIVE_ATTEMPT_LABEL, + ADAPTIVE_TECHNIQUE_NAME_LABEL, AtomicAttackIdentifier, AttackOutcome, AttackResult, @@ -29,10 +31,12 @@ ScenarioObjectiveScorerMetrics, ScenarioProgressCounts, ScenarioProgressResult, + ScenarioProgressResultKind, ScenarioProgressSummary, ScenarioResult, ScenarioRunPlan, ScenarioRunPlanAtomicGroup, + ScenarioRunPlanGroupKind, ScenarioRunPlanSeedGroup, ScenarioScorerIdentity, ScenarioSeedGroupProgress, @@ -40,6 +44,7 @@ ScorerEvaluationIdentifier, ScorerIdentifier, config_hash, + is_sequential_attack_envelope, project_behavioral_identity, ) from pyrit.score.scorer_evaluation.scorer_metrics_io import find_objective_metrics_by_eval_hash @@ -51,6 +56,13 @@ # applied by ``project_behavioral_identity``. _TECHNIQUE_SEEDS_CHILD = "technique_seeds" _TECHNIQUE_SEED_DISPLAY_PARAMS = ("value", "data_type") +_EXECUTABLE_RESULT_KINDS = frozenset( + { + ScenarioProgressResultKind.ATTACK, + ScenarioProgressResultKind.DIRECT_BASELINE, + ScenarioProgressResultKind.ADAPTIVE_TECHNIQUE, + } +) @dataclass(frozen=True, slots=True) @@ -344,6 +356,11 @@ def calculate_progress_counts( latest_result_by_unit: dict[ResultUnitIdentity, AttackResult] = {} for atomic_attack_name, results in scenario_result.attack_results.items(): for attack_result in results: + if is_sequential_attack_envelope( + conversation_id=str(getattr(attack_result, "conversation_id", "")), + atomic_attack_identifier=getattr(attack_result, "atomic_attack_identifier", None), + ): + continue unit_identity = cls.resolve_result_unit_identity( atomic_attack_name=atomic_attack_name, attack_result=attack_result, @@ -448,7 +465,11 @@ def aggregate(*, units: Sequence[ResultUnitIdentity], planned: int | None) -> Sc errors = 0 retries = 0 for unit in units: - attempts = attempts_by_unit.get(unit, []) + attempts = [ + attempt + for attempt in attempts_by_unit.get(unit, []) + if attempt.result_kind in _EXECUTABLE_RESULT_KINDS + ] if attempts: completed += 1 succeeded += int(attempts[-1].outcome == AttackOutcome.SUCCESS) @@ -482,7 +503,9 @@ def aggregate(*, units: Sequence[ResultUnitIdentity], planned: int | None) -> Sc ) planned_units = set(overall_units) unattributed_attempts = sum( - len(attempts) for unit, attempts in attempts_by_unit.items() if unit not in planned_units + sum(attempt.result_kind in _EXECUTABLE_RESULT_KINDS for attempt in attempts) + for unit, attempts in attempts_by_unit.items() + if unit not in planned_units ) if unattributed_attempts: logger.warning( @@ -490,7 +513,13 @@ def aggregate(*, units: Sequence[ResultUnitIdentity], planned: int | None) -> Sc "scenario progress rollups.", unattributed_attempts, ) - latest_results = [attempts_by_unit[unit][-1] for unit in overall_units if attempts_by_unit.get(unit)] + latest_results = [] + for unit in overall_units: + attempts = [ + attempt for attempt in attempts_by_unit.get(unit, []) if attempt.result_kind in _EXECUTABLE_RESULT_KINDS + ] + if attempts: + latest_results.append(attempts[-1]) objective_scorer = ScenarioProgressReadModel._build_objective_scorer( scorer_identifier=objective_scorer_identifier, results=latest_results, @@ -795,6 +824,11 @@ def _map_progress_delta( seed_group_id = matching_seed_ids[0] if not seed_group_id: seed_group_id = config_hash({"objective": delta.objective}) + result_kind, technique_name, attempt_index = ScenarioProgressReadModel._progress_result_semantics( + delta=delta, + group_kind=planned_group.group_kind if planned_group is not None else None, + atomic_attack_name=atomic_attack_name, + ) return ScenarioProgressResult( attack_result_id=delta.attack_result_id, conversation_id=delta.conversation_id, @@ -809,7 +843,52 @@ def _map_progress_delta( error_type=delta.error_type, error_message=delta.error_message, score=delta.score, + result_kind=result_kind, + technique_name=technique_name, + attempt_index=attempt_index, + ) + + @staticmethod + def _progress_result_semantics( + *, + delta: ScenarioAttackResultDelta, + group_kind: ScenarioRunPlanGroupKind | None, + atomic_attack_name: str, + ) -> tuple[ScenarioProgressResultKind, str | None, int | None]: + """ + Resolve typed progress semantics from persisted plan and child labels. + + Returns: + tuple[ScenarioProgressResultKind, str | None, int | None]: + Result role, registered technique name, and 1-based Adaptive attempt index. + """ + technique_name = delta.labels.get(ADAPTIVE_TECHNIQUE_NAME_LABEL) or None + raw_attempt_index = delta.labels.get(ADAPTIVE_ATTEMPT_LABEL) + parsed_attempt_index = int(raw_attempt_index) if raw_attempt_index and raw_attempt_index.isdigit() else None + attempt_index = parsed_attempt_index if parsed_attempt_index and parsed_attempt_index >= 1 else None + if technique_name: + return ScenarioProgressResultKind.ADAPTIVE_TECHNIQUE, technique_name, attempt_index + + is_sequential_envelope = is_sequential_attack_envelope( + conversation_id=delta.conversation_id, + atomic_attack_identifier=delta.atomic_attack_identifier, ) + if group_kind is not None: + if group_kind is ScenarioRunPlanGroupKind.DIRECT_BASELINE: + return ScenarioProgressResultKind.DIRECT_BASELINE, None, None + if group_kind is ScenarioRunPlanGroupKind.ADAPTIVE: + return ScenarioProgressResultKind.ADAPTIVE_ORCHESTRATION, None, None + if is_sequential_envelope: + return ScenarioProgressResultKind.AGGREGATE_PARENT, None, None + return ScenarioProgressResultKind.ATTACK, None, None + + if atomic_attack_name == "baseline": + return ScenarioProgressResultKind.DIRECT_BASELINE, None, None + if is_sequential_envelope: + return ScenarioProgressResultKind.AGGREGATE_PARENT, None, None + if delta.conversation_id.strip(): + return ScenarioProgressResultKind.ATTACK, None, None + return ScenarioProgressResultKind.UNKNOWN, None, None @staticmethod def _synthesize_legacy_plan(*, deltas: list[ScenarioAttackResultDelta]) -> ScenarioRunPlan: diff --git a/pyrit/backend/services/scenario_run_service.py b/pyrit/backend/services/scenario_run_service.py index fb17423d3b..edb5b26c8f 100644 --- a/pyrit/backend/services/scenario_run_service.py +++ b/pyrit/backend/services/scenario_run_service.py @@ -58,12 +58,14 @@ ScenarioRunProgress, ScenarioRunState, TargetIdentifier, + is_sequential_attack_envelope, ) -from pyrit.models.catalog.scenario import ( +from pyrit.models.catalog import ( AttackErrorSummary, AttackRetrySummary, RunScenarioRequest, ScenarioOverloadSummary, + ScenarioRunHeader, ScenarioRunListItem, ScenarioRunSummary, ScenarioTargetSummary, @@ -75,6 +77,7 @@ logger = logging.getLogger(__name__) _DEFAULT_MAX_CONCURRENT_RUNS = 1 +_MAX_ATTACK_DETAIL_ENTRIES = 100 _MAX_OVERLOAD_EVENTS = 500 _MAX_OVERLOAD_ROLES = 16 _MAX_TERMINAL_ERRORS = 100 @@ -136,6 +139,18 @@ class _ActiveRunSnapshot: active_scenario_result_id: str | None = None +@dataclass(frozen=True, slots=True) +class _RunDiagnostics: + """Per-attempt diagnostics summarized for a scenario run.""" + + failed_attacks: list[AttackErrorSummary] + attack_retries: list[AttackRetrySummary] + total_retries: int + error_attempts: int + attack_details_truncated: bool + overload_summaries: list[ScenarioOverloadSummary] + + class ScenarioRunService: """ Service for managing scenario run lifecycle. @@ -756,37 +771,9 @@ async def shutdown_async(self) -> None: if queued: self._queue_revision += 1 for run in queued: - try: - await asyncio.to_thread( - self._memory.update_scenario_run_state, - scenario_result_id=run.scenario_result_id, - scenario_run_state=ScenarioRunState.FAILED, - error_message=_SHUTDOWN_INTERRUPTION_REASON, - error_type=_INTERRUPTED_ERROR_TYPE, - ) - except Exception as exc: - errors.append(exc) - if self._active_scenario_result_id is not None: - active = self._active_tasks[self._active_scenario_result_id] - active.cancellation_state = ScenarioRunState.FAILED - active.cancellation_reason = _SHUTDOWN_INTERRUPTION_REASON - active.cancellation_error_type = _INTERRUPTED_ERROR_TYPE - task = active.task - if task is None or task.done(): - try: - await asyncio.to_thread( - self._memory.update_scenario_run_state, - scenario_result_id=active.scenario_result_id, - scenario_run_state=ScenarioRunState.FAILED, - error_message=_SHUTDOWN_INTERRUPTION_REASON, - error_type=_INTERRUPTED_ERROR_TYPE, - ) - except Exception as exc: - errors.append(exc) - self._active_scenario_result_id = None - self._release_completed_task(scenario_result_id=active.scenario_result_id) - self._queue_revision += 1 - await asyncio.to_thread(self._prepare_executor.shutdown, wait=True) + await self._persist_shutdown_failure_async(run=run, errors=errors) + task = await self._prepare_active_shutdown_locked_async(errors=errors) + await asyncio.to_thread(self._prepare_executor.shutdown, wait=True) if task is not None and not task.done(): task.cancel() try: @@ -802,6 +789,42 @@ async def shutdown_async(self) -> None: if errors: raise ExceptionGroup("Failed to persist one or more scenario shutdown transitions.", errors) + async def _prepare_active_shutdown_locked_async(self, *, errors: list[Exception]) -> asyncio.Task[None] | None: + """ + Mark the active run for shutdown. + + Returns: + The active run task when it is still running; otherwise, None. + """ + if self._active_scenario_result_id is None: + return None + + active = self._active_tasks[self._active_scenario_result_id] + active.cancellation_state = ScenarioRunState.FAILED + active.cancellation_reason = _SHUTDOWN_INTERRUPTION_REASON + active.cancellation_error_type = _INTERRUPTED_ERROR_TYPE + if active.task is not None and not active.task.done(): + return active.task + + await self._persist_shutdown_failure_async(run=active, errors=errors) + self._active_scenario_result_id = None + self._release_completed_task(scenario_result_id=active.scenario_result_id) + self._queue_revision += 1 + return None + + async def _persist_shutdown_failure_async(self, *, run: _ActiveTask, errors: list[Exception]) -> None: + """Persist one interrupted run while retaining failures for an ExceptionGroup.""" + try: + await asyncio.to_thread( + self._memory.update_scenario_run_state, + scenario_result_id=run.scenario_result_id, + scenario_run_state=ScenarioRunState.FAILED, + error_message=_SHUTDOWN_INTERRUPTION_REASON, + error_type=_INTERRUPTED_ERROR_TYPE, + ) + except Exception as exc: + errors.append(exc) + async def _enqueue_run_async(self, *, scheduled: _ActiveTask) -> None: """Atomically enqueue a persisted initialized run or start it immediately.""" async with self._scheduler_lock: @@ -1177,11 +1200,7 @@ def _build_response_from_db( error = active_error status = scenario_result.scenario_run_state - terminal = status in ( - ScenarioRunState.COMPLETED, - ScenarioRunState.FAILED, - ScenarioRunState.CANCELLED, - ) + terminal = self._is_terminal_state(status) try: plan = self._load_run_plan(scenario_result=scenario_result) except (ValidationError, ValueError): @@ -1200,100 +1219,205 @@ def _build_response_from_db( plan_lookup=plan_lookup, ) ) - techniques_used = ( - list(dict.fromkeys(group.display_group for group in plan.atomic_groups)) - if plan is not None - else scenario_result.get_techniques_used() + techniques_used = self._resolve_techniques_used( + scenario_identifier=scenario_result.scenario_identifier, + atomic_groups=plan.atomic_groups if plan is not None else None, + fallback_names=scenario_result.get_techniques_used(), ) target, datasets_used, scenario_parameters = self._safe_run_metadata( scenario_identifier=getattr(scenario_result, "scenario_identifier", None) ) - - # Surface per-attack errors and retry pressure regardless of overall run status: - # a COMPLETED scenario can still hide errored objectives or rate-limit retries. - failed_attacks: list[AttackErrorSummary] = [] - attack_retries: list[AttackRetrySummary] = [] - persisted_retries: list[int] = [] - overload_events: deque[Any] = deque(maxlen=_MAX_OVERLOAD_EVENTS) - attempts_by_unit: dict[ResultUnitIdentity, int] = {} - for atomic_attack_name, results in scenario_result.attack_results.items(): - for attack_result in results: - unit_identity = self._progress_read_model.resolve_result_unit_identity( - atomic_attack_name=atomic_attack_name, - attack_result=attack_result, - plan_lookup=plan_lookup, - ) - attempts_by_unit[unit_identity] = attempts_by_unit.get(unit_identity, 0) + 1 - retries = getattr(attack_result, "total_retries", 0) - if isinstance(retries, int): - persisted_retries.append(retries) - - retry_events = getattr(attack_result, "retry_events", None) - if isinstance(retry_events, list) and retry_events: - overload_events.extend(retry_events) - attack_retries.append( - AttackRetrySummary( - attack_result_id=str(attack_result.attack_result_id), - atomic_attack_name=atomic_attack_name, - retries=retry_events, - ) - ) - - if attack_result.outcome == AttackOutcome.ERROR: - failed_attacks.append( - AttackErrorSummary( - atomic_attack_name=atomic_attack_name, - objective=attack_result.objective, - error_type=attack_result.error_type, - error_message=attack_result.error_message, - total_retries=max(0, retries) if isinstance(retries, int) else 0, - ) - ) - total_retries = self._progress_read_model.total_retry_pressure( - attempts_per_unit=attempts_by_unit.values(), - persisted_retries=persisted_retries, + diagnostics = self._collect_run_diagnostics(scenario_result=scenario_result, plan=plan) + header = self._build_run_header( + scenario_result=scenario_result, + scenario_registry_name=plan.scenario_registry_name if plan else None, + techniques_used=techniques_used, + target=target, + datasets_used=datasets_used, + scenario_parameters=scenario_parameters, + queue_position=queue_position, + active_scenario_result_id=active_scenario_result_id, + overload_summaries=diagnostics.overload_summaries, ) - updated_at = scenario_result.creation_time if terminal and scenario_result.completion_time is not None: updated_at = scenario_result.completion_time return ScenarioRunSummary( - scenario_result_id=scenario_result_id, - scenario_name=scenario_result.scenario_name, - scenario_registry_name=plan.scenario_registry_name if plan else None, - scenario_version=scenario_result.scenario_version, - status=status, - created_at=scenario_result.creation_time, - started_at=self._load_started_at(scenario_result=scenario_result), + **header.model_dump(), updated_at=updated_at, error=error, error_type=error_type, - techniques_used=techniques_used, total_attacks=total_attacks, completed_attacks=completed_attacks, objective_achieved_rate=objective_achieved_rate, - failed_attacks=failed_attacks, - attack_retries=attack_retries, + failed_attacks=diagnostics.failed_attacks, + attack_retries=diagnostics.attack_retries, + total_retries=diagnostics.total_retries, + planned_total_available=plan is not None, + successful_attacks=successful_attacks, + error_attacks=diagnostics.error_attempts, + attack_details_truncated=diagnostics.attack_details_truncated, + ) + + def _collect_run_diagnostics( + self, + *, + scenario_result: ScenarioResult, + plan: ScenarioRunPlan | None, + ) -> _RunDiagnostics: + """ + Summarize persisted errors, retries, and overload evidence. + + Returns: + _RunDiagnostics: Aggregated diagnostics for the run. + """ + failed_attacks: deque[AttackErrorSummary] = deque(maxlen=_MAX_ATTACK_DETAIL_ENTRIES) + attack_retries: deque[AttackRetrySummary] = deque(maxlen=_MAX_ATTACK_DETAIL_ENTRIES) + overload_events: deque[Any] = deque(maxlen=_MAX_OVERLOAD_EVENTS) + plan_lookup = self._progress_read_model.build_plan_lookup(plan=plan) + inner_retries_by_unit: dict[ResultUnitIdentity, int] = {} + attempts_by_unit: dict[ResultUnitIdentity, int] = {} + error_attempts = 0 + failed_attack_details = 0 + retry_details = 0 + indexed_results = [ + (index, atomic_attack_name, attack_result) + for index, (atomic_attack_name, attack_result) in enumerate( + (name, result) for name, results in scenario_result.attack_results.items() for result in results + ) + ] + indexed_results.sort(key=self._diagnostic_result_sort_key) + for _, atomic_attack_name, attack_result in indexed_results: + if is_sequential_attack_envelope( + conversation_id=str(getattr(attack_result, "conversation_id", "")), + atomic_attack_identifier=getattr(attack_result, "atomic_attack_identifier", None), + ): + continue + unit_identity = self._progress_read_model.resolve_result_unit_identity( + atomic_attack_name=atomic_attack_name, + attack_result=attack_result, + plan_lookup=plan_lookup, + ) + attempts_by_unit[unit_identity] = attempts_by_unit.get(unit_identity, 0) + 1 + retries = getattr(attack_result, "total_retries", 0) + safe_retries = max(0, retries) if isinstance(retries, int) else 0 + inner_retries_by_unit[unit_identity] = inner_retries_by_unit.get(unit_identity, 0) + safe_retries + + retry_events = getattr(attack_result, "retry_events", None) + if isinstance(retry_events, list) and retry_events: + retry_details += 1 + overload_events.extend(retry_events) + attack_retries.append( + AttackRetrySummary( + attack_result_id=str(attack_result.attack_result_id), + atomic_attack_name=atomic_attack_name, + retries=retry_events, + ) + ) + if attack_result.outcome == AttackOutcome.ERROR: + error_attempts += 1 + failed_attack_details += 1 + failed_attacks.append( + AttackErrorSummary( + atomic_attack_name=atomic_attack_name, + objective=attack_result.objective, + error_type=attack_result.error_type, + error_message=attack_result.error_message, + total_retries=safe_retries, + ) + ) + total_retries = sum( + self._total_retry_work( + inner_retries=inner_retries_by_unit.get(unit_identity, 0), + attempt_count=attempt_count, + ) + for unit_identity, attempt_count in attempts_by_unit.items() + ) + return _RunDiagnostics( + failed_attacks=list(failed_attacks), + attack_retries=list(attack_retries), total_retries=total_retries, - labels=scenario_result.labels, - completed_at=scenario_result.completion_time if terminal else None, - pyrit_version=( - scenario_result.pyrit_version - if isinstance(getattr(scenario_result, "pyrit_version", None), str) - else None + error_attempts=error_attempts, + attack_details_truncated=( + failed_attack_details > _MAX_ATTACK_DETAIL_ENTRIES or retry_details > _MAX_ATTACK_DETAIL_ENTRIES ), + overload_summaries=self._build_overload_summaries(retry_events=overload_events), + ) + + @staticmethod + def _diagnostic_result_sort_key(indexed_result: tuple[int, str, Any]) -> tuple[int, float, int]: + """Return a stable chronological key for bounded diagnostic details.""" + index, _, attack_result = indexed_result + timestamp = getattr(attack_result, "timestamp", None) + if isinstance(timestamp, datetime): + return 1, timestamp.timestamp(), index + return 0, float(index), index + + def _build_run_header( + self, + *, + scenario_result: ScenarioResult, + scenario_registry_name: str | None, + techniques_used: Sequence[str], + target: ScenarioTargetSummary | None, + datasets_used: Sequence[str], + scenario_parameters: Mapping[str, Any], + queue_position: int | None = None, + active_scenario_result_id: str | None = None, + overload_summaries: Sequence[ScenarioOverloadSummary] = (), + ) -> ScenarioRunHeader: + """ + Build fields shared by full summaries and progress responses. + + Returns: + ScenarioRunHeader: Canonical shared run fields. + """ + status = scenario_result.scenario_run_state + return ScenarioRunHeader( + scenario_result_id=str(scenario_result.id), + scenario_name=scenario_result.scenario_name, + scenario_registry_name=scenario_registry_name, + scenario_version=scenario_result.scenario_version, + status=status, + created_at=scenario_result.creation_time, + started_at=self._load_started_at(scenario_result=scenario_result), + completed_at=scenario_result.completion_time if self._is_terminal_state(status) else None, + pyrit_version=scenario_result.pyrit_version, target=target, - datasets_used=datasets_used, - scenario_parameters=scenario_parameters, - planned_total_available=plan is not None, - successful_attacks=successful_attacks, - error_attacks=len(failed_attacks), + techniques_used=list(techniques_used), + datasets_used=list(datasets_used), + scenario_parameters=dict(scenario_parameters), + labels=scenario_result.labels, queue_position=queue_position, active_scenario_result_id=active_scenario_result_id, - overload_summaries=self._build_overload_summaries(retry_events=overload_events), + overload_summaries=list(overload_summaries), ) + @staticmethod + def _resolve_techniques_used( + *, + scenario_identifier: ScenarioIdentifier | None, + atomic_groups: Sequence[ScenarioRunPlanAtomicGroup] | None, + fallback_names: Sequence[str], + ) -> list[str]: + """ + Resolve configured, planned, or legacy technique display names. + + Returns: + list[str]: De-duplicated technique display names. + """ + configured = list( + dict.fromkeys( + str(technique) for technique in ScenarioRunService._identifier_techniques(scenario_identifier) + ) + ) + if configured: + return configured + if atomic_groups is not None: + return list(dict.fromkeys(group.display_group for group in atomic_groups)) + return list(dict.fromkeys(fallback_names)) + @staticmethod def _parse_history_plan(*, record: ScenarioHistoryRunRecord) -> list[ScenarioRunPlanAtomicGroup] | None: """ @@ -1434,6 +1558,16 @@ def _build_history_summary( attack_details_available=False, ) + @staticmethod + def _total_retry_work(*, inner_retries: int, attempt_count: int) -> int: + """ + Count retry work beyond the first logical attempt. + + Returns: + int: Inner retries plus additional scenario attempts. + """ + return max(0, inner_retries) + max(0, attempt_count - 1) + @staticmethod def _load_started_at(*, scenario_result: ScenarioResult) -> datetime | None: """ @@ -1805,30 +1939,24 @@ def get_run_progress_from_storage( ) scenario_identifier = header_result.scenario_identifier target, datasets_used, scenario_parameters = self._safe_run_metadata(scenario_identifier=scenario_identifier) - if plan is not None: - techniques_used = list(dict.fromkeys(group.display_group for group in plan.atomic_groups)) - else: - techniques_used = self._identifier_techniques(scenario_identifier) + techniques_used = self._resolve_techniques_used( + scenario_identifier=scenario_identifier, + atomic_groups=plan.atomic_groups if plan is not None else None, + fallback_names=(), + ) + header = self._build_run_header( + scenario_result=header_result, + scenario_registry_name=plan.scenario_registry_name if plan else None, + techniques_used=techniques_used, + target=target, + datasets_used=datasets_used, + scenario_parameters=scenario_parameters, + queue_position=queue_position, + active_scenario_result_id=active_scenario_result_id, + overload_summaries=self._build_overload_summaries(retry_events=overload_events), + ) return ScenarioRunProgress( - run=ScenarioProgressHeader( - scenario_result_id=scenario_result_id, - scenario_name=header_result.scenario_name, - scenario_registry_name=plan.scenario_registry_name if plan else None, - scenario_version=header_result.scenario_version, - status=header_result.scenario_run_state, - created_at=header_result.creation_time, - started_at=self._load_started_at(scenario_result=header_result), - completed_at=header_result.completion_time if terminal else None, - pyrit_version=header_result.pyrit_version, - target=target, - techniques_used=techniques_used, - datasets_used=datasets_used, - scenario_parameters=scenario_parameters, - labels=header_result.labels, - queue_position=queue_position, - active_scenario_result_id=active_scenario_result_id, - overload_summaries=self._build_overload_summaries(retry_events=overload_events), - ), + run=ScenarioProgressHeader(**header.model_dump()), plan=response_plan, results=results, summary=progress_snapshot.summary, diff --git a/pyrit/executor/attack/compound/sequential_attack.py b/pyrit/executor/attack/compound/sequential_attack.py index 35416f7b50..92d252539f 100644 --- a/pyrit/executor/attack/compound/sequential_attack.py +++ b/pyrit/executor/attack/compound/sequential_attack.py @@ -33,7 +33,7 @@ from pyrit.executor.attack.core.attack_executor import AttackExecutor from pyrit.executor.attack.core.attack_parameters import AttackParameters from pyrit.executor.attack.core.attack_strategy import AttackContext, AttackStrategy -from pyrit.models import AttackOutcome, AttackResult, AttackSeedGroup, ScoringExpectation +from pyrit.models import AtomicAttackIdentifier, AttackOutcome, AttackResult, AttackSeedGroup, ScoringExpectation if TYPE_CHECKING: from collections.abc import Mapping, Sequence @@ -271,6 +271,7 @@ async def _perform_async(self, *, context: AttackContext[AttackParameters]) -> S return SequentialAttackResult( conversation_id="", objective=context.objective, + atomic_attack_identifier=AtomicAttackIdentifier.build(attack_identifier=self.get_identifier()), attack_result_id=str(uuid.uuid4()), timestamp=datetime.now(UTC), last_response=None, diff --git a/pyrit/memory/memory_interface.py b/pyrit/memory/memory_interface.py index 12808ed512..60367e7a89 100644 --- a/pyrit/memory/memory_interface.py +++ b/pyrit/memory/memory_interface.py @@ -18,7 +18,7 @@ from typing import TYPE_CHECKING, Any, ClassVar, Literal, NamedTuple, TypeVar from urllib.parse import urlparse -from sqlalchemy import MetaData, and_, case, exists, func, literal, not_, or_, select +from sqlalchemy import MetaData, String, and_, case, cast, exists, func, literal, not_, or_, select from sqlalchemy.engine.base import Engine from sqlalchemy.exc import IntegrityError, SQLAlchemyError from sqlalchemy.orm import joinedload @@ -63,6 +63,7 @@ ) from pyrit.models import ( MEDIA_PATH_DATA_TYPES, + SEQUENTIAL_ATTACK_CLASS_NAME, AtomicAttackIdentifier, AttackIdentifier, AttackOutcome, @@ -1808,6 +1809,45 @@ def _get_scenario_attempt_unit_expressions(self) -> tuple[Any, Any, Any]: "to support Scenario history queries." ) + def _get_scenario_logical_attempt_condition(self) -> Any: + """ + Exclude typed and identifier-less legacy ``SequentialAttack`` envelopes. + + Returns: + Any: A SQL condition matching only target-facing logical attempts. + """ + typed_envelope = ( + case( + ( + or_( + self._get_condition_json_property_match( + json_column=AttackResultEntry.atomic_attack_identifier, + property_path="$.children.attack_technique.children.attack.class_name", + value=SEQUENTIAL_ATTACK_CLASS_NAME, + case_sensitive=True, + ).unique_params(), + self._get_condition_json_property_match( + json_column=AttackResultEntry.atomic_attack_identifier, + property_path="$.children.attack.class_name", + value=SEQUENTIAL_ATTACK_CLASS_NAME, + case_sensitive=True, + ).unique_params(), + ), + 1, + ), + else_=0, + ) + == 1 + ) + legacy_envelope = and_( + or_( + AttackResultEntry.atomic_attack_identifier.is_(None), + func.lower(func.trim(cast(AttackResultEntry.atomic_attack_identifier, String))) == "null", + ), + func.trim(AttackResultEntry.conversation_id) == "", + ) + return not_(or_(typed_envelope, legacy_envelope)) + def _get_scenario_plan_unit_subqueries(self, *, scenario_result_ids: Sequence[uuid.UUID]) -> tuple[Any, Any]: """ Return backend-specific run-plan expansions used to resolve attempts to planned units. @@ -4996,7 +5036,10 @@ def get_scenario_history_aggregates( ).all() name_rows = session.execute( select(AttackResultEntry.attribution_parent_id, self._get_scenario_attempt_unit_expressions()[0]) - .where(AttackResultEntry.attribution_parent_id.in_(entry_ids)) + .where( + AttackResultEntry.attribution_parent_id.in_(entry_ids), + self._get_scenario_logical_attempt_condition(), + ) .distinct() ).all() @@ -5052,7 +5095,10 @@ def _build_scenario_history_aggregate_statement( else_=0, ).label("total_retries"), ) - .where(AttackResultEntry.attribution_parent_id.in_(entry_ids)) + .where( + AttackResultEntry.attribution_parent_id.in_(entry_ids), + self._get_scenario_logical_attempt_condition(), + ) .subquery("history_attempts") ) units = self._build_scenario_history_unit_statement(attempts=attempts, plan_entry_ids=plan_entry_ids).subquery( @@ -5288,6 +5334,7 @@ def get_scenario_attack_result_deltas( AttackResultEntry.error_type, AttackResultEntry.error_message, AttackResultEntry.attribution_data, + AttackResultEntry.labels, ScoreEntry.id.label("score_id"), ScoreEntry.score_value, ScoreEntry.score_type, @@ -5348,6 +5395,7 @@ def get_scenario_attack_result_deltas( error_message=row.error_message, attribution_data=row.attribution_data or {}, score=score, + labels=row.labels or {}, ) ) return deltas, has_more diff --git a/pyrit/models/__init__.py b/pyrit/models/__init__.py index bebaa55da3..9dd380e4ed 100644 --- a/pyrit/models/__init__.py +++ b/pyrit/models/__init__.py @@ -109,9 +109,13 @@ from pyrit.models.results.strategy_result import StrategyResult, StrategyResultT from pyrit.models.retry_event import RetryEvent from pyrit.models.scenario_progress import ( + ADAPTIVE_ATTEMPT_LABEL, + ADAPTIVE_TECHNIQUE_ID_LABEL, + ADAPTIVE_TECHNIQUE_NAME_LABEL, SCENARIO_RUN_PLAN_METADATA_KEY, SCENARIO_RUN_PLAN_VERSION, SCENARIO_RUN_STARTED_AT_METADATA_KEY, + SEQUENTIAL_ATTACK_CLASS_NAME, ScenarioAtomicGroupProgress, ScenarioAttackResultDelta, ScenarioAttackTechniqueDetails, @@ -122,18 +126,21 @@ ScenarioProgressCounts, ScenarioProgressHeader, ScenarioProgressResult, + ScenarioProgressResultKind, ScenarioProgressScore, ScenarioProgressSummary, ScenarioQueueEntry, ScenarioQueueSnapshot, ScenarioRunPlan, ScenarioRunPlanAtomicGroup, + ScenarioRunPlanGroupKind, ScenarioRunPlanSeedGroup, ScenarioRunPlanSeedPrompt, ScenarioRunProgress, ScenarioScorerIdentity, ScenarioSeedGroupProgress, ScenarioTechniqueProgress, + is_sequential_attack_envelope, ) from pyrit.models.score import ( Acquisition, @@ -187,6 +194,9 @@ ) _LAZY_EXPORTS: dict[str, str] = { + "ADAPTIVE_ATTEMPT_LABEL": "pyrit.models.scenario_progress", + "ADAPTIVE_TECHNIQUE_ID_LABEL": "pyrit.models.scenario_progress", + "ADAPTIVE_TECHNIQUE_NAME_LABEL": "pyrit.models.scenario_progress", "Acquisition": "pyrit.models.score", "ALLOWED_CHAT_MESSAGE_ROLES": "pyrit.models.messages.chat_message", "AtomicAttackEvaluationIdentifier": "pyrit.models.identifiers", @@ -289,6 +299,7 @@ "SCENARIO_RUN_PLAN_METADATA_KEY": "pyrit.models.scenario_progress", "SCENARIO_RUN_PLAN_VERSION": "pyrit.models.scenario_progress", "SCENARIO_RUN_STARTED_AT_METADATA_KEY": "pyrit.models.scenario_progress", + "SEQUENTIAL_ATTACK_CLASS_NAME": "pyrit.models.scenario_progress", "ScenarioAttackResultDelta": "pyrit.models.scenario_progress", "ScenarioAtomicGroupProgress": "pyrit.models.scenario_progress", "ScenarioAttackTechniqueDetails": "pyrit.models.scenario_progress", @@ -301,16 +312,19 @@ "ScenarioProgressResult": "pyrit.models.scenario_progress", "ScenarioProgressScore": "pyrit.models.scenario_progress", "ScenarioProgressSummary": "pyrit.models.scenario_progress", + "ScenarioProgressResultKind": "pyrit.models.scenario_progress", "ScenarioQueueEntry": "pyrit.models.scenario_progress", "ScenarioQueueSnapshot": "pyrit.models.scenario_progress", "ScenarioRunPlan": "pyrit.models.scenario_progress", "ScenarioRunPlanAtomicGroup": "pyrit.models.scenario_progress", + "ScenarioRunPlanGroupKind": "pyrit.models.scenario_progress", "ScenarioRunPlanSeedPrompt": "pyrit.models.scenario_progress", "ScenarioRunPlanSeedGroup": "pyrit.models.scenario_progress", "ScenarioRunProgress": "pyrit.models.scenario_progress", "ScenarioScorerIdentity": "pyrit.models.scenario_progress", "ScenarioSeedGroupProgress": "pyrit.models.scenario_progress", "ScenarioTechniqueProgress": "pyrit.models.scenario_progress", + "is_sequential_attack_envelope": "pyrit.models.scenario_progress", "Seed": "pyrit.models.seeds", "AttackSeedGroup": "pyrit.models.seeds", "AttackTechniqueSeedGroup": "pyrit.models.seeds", diff --git a/pyrit/models/catalog/__init__.py b/pyrit/models/catalog/__init__.py index a6b4794e00..4e46670e78 100644 --- a/pyrit/models/catalog/__init__.py +++ b/pyrit/models/catalog/__init__.py @@ -31,6 +31,7 @@ ScenarioDatasetSummary, ScenarioDefaultRunSizeEstimate, ScenarioOverloadSummary, + ScenarioRunHeader, ScenarioRunListItem, ScenarioRunSizeComponent, ScenarioRunSizeEstimate, @@ -56,6 +57,7 @@ "ScenarioDatasetSummary": "pyrit.models.catalog.scenario", "ScenarioDefaultRunSizeEstimate": "pyrit.models.catalog.scenario", "ScenarioOverloadSummary": "pyrit.models.catalog.scenario", + "ScenarioRunHeader": "pyrit.models.catalog.scenario", "ScenarioRunListItem": "pyrit.models.catalog.scenario", "ScenarioRunSizeComponent": "pyrit.models.catalog.scenario", "ScenarioRunSizeEstimate": "pyrit.models.catalog.scenario", diff --git a/pyrit/models/catalog/scenario.py b/pyrit/models/catalog/scenario.py index f9ed22b904..644fae7d59 100644 --- a/pyrit/models/catalog/scenario.py +++ b/pyrit/models/catalog/scenario.py @@ -530,8 +530,8 @@ class ScenarioOverloadSummary(BaseModel): latest_timestamp: datetime = Field(..., description="Latest overload signal timestamp") -class ScenarioRunSummary(BaseModel): - """Response for a scenario run (status + result details).""" +class ScenarioRunListItem(BaseModel): + """Lightweight scenario run metadata returned by the history endpoint.""" scenario_result_id: str = Field(..., description="UUID of the ScenarioResult in memory") scenario_name: str = Field(..., description="Registry key of the scenario being run") @@ -541,28 +541,13 @@ class ScenarioRunSummary(BaseModel): created_at: datetime = Field(..., description="When the run was created") started_at: datetime | None = Field(None, description="When active scenario execution started") updated_at: datetime = Field(..., description="When the run status last changed") - error: str | None = Field(None, description="Error message if status is FAILED") - error_type: str | None = Field(None, description="Exception class name if status is FAILED") - techniques_used: list[str] = Field(default_factory=list, description="Technique names that were executed") - total_attacks: int = Field( - 0, ge=0, description="Planned execution units, or the observed units when no plan is persisted" - ) - completed_attacks: int = Field(0, ge=0, description="Planned execution units that reached a terminal outcome") + error: str | None = Field(None, description="Persisted run-level error message") + error_type: str | None = Field(None, description="Persisted run-level exception class") + techniques_used: list[str] = Field(default_factory=list, description="Planned technique display groups") + total_attacks: int | None = Field(None, ge=0, description="Number of planned execution units when known") + completed_attacks: int = Field(0, ge=0, description="Latest completed planned units") objective_achieved_rate: int = Field(0, ge=0, le=100, description="Success rate as percentage (0-100)") - failed_attacks: list[AttackErrorSummary] = Field( - default_factory=list, - description="Individual attack results that errored, surfaced regardless of overall run status", - ) - attack_retries: list[AttackRetrySummary] = Field( - default_factory=list, - description="Per-attack retry events, surfaced as each attack result lands so the CLI can stream warnings", - ) - total_retries: int = Field( - 0, - ge=0, - description="Total retry work beyond each logical unit's initial attempt, including inner retries " - "and additional scenario attempts", - ) + total_retries: int = Field(0, ge=0, description="Retry attempts recorded across projected work units") labels: dict[str, str] = Field(default_factory=dict, description="Labels attached to this run") completed_at: datetime | None = Field(None, description="When the scenario finished") pyrit_version: str | None = Field(None, description="PyRIT version that created the run") @@ -582,16 +567,19 @@ class ScenarioRunSummary(BaseModel): True, description="Whether failed_attacks and attack_retries contain per-attempt details", ) - queue_position: int | None = Field(None, ge=1, description="Current 1-based waiting position") - active_scenario_result_id: str | None = Field(None, description="Currently executing scenario result ID") - overload_summaries: list[ScenarioOverloadSummary] = Field( - default_factory=list, - description="Bounded recent HTTP 429 and 5xx retry evidence grouped by component role", - ) -class ScenarioRunListItem(BaseModel): - """Lightweight scenario run metadata returned by the history endpoint.""" +class ScenarioTargetSummary(BaseModel): + """Safe target identity suitable for scenario history and run headers.""" + + target_type: str = Field(..., description="Target implementation type") + endpoint: str | None = Field(None, description="Configured endpoint, when present") + model_name: str | None = Field(None, description="Configured model or deployment name") + identifier_hash: str | None = Field(None, description="Canonical target identifier hash") + + +class ScenarioRunHeader(BaseModel): + """Fields shared by scenario summaries and incremental progress headers.""" scenario_result_id: str = Field(..., description="UUID of the ScenarioResult in memory") scenario_name: str = Field(..., description="Registry key of the scenario being run") @@ -600,23 +588,47 @@ class ScenarioRunListItem(BaseModel): status: ScenarioRunState = Field(..., description="Current run status") created_at: datetime = Field(..., description="When the run was created") started_at: datetime | None = Field(None, description="When active scenario execution started") - updated_at: datetime = Field(..., description="When the run status last changed") - error: str | None = Field(None, description="Persisted run-level error message") - error_type: str | None = Field(None, description="Persisted run-level exception class") - techniques_used: list[str] = Field(default_factory=list, description="Planned technique display groups") - total_attacks: int | None = Field(None, ge=0, description="Number of planned execution units when known") - completed_attacks: int = Field(0, ge=0, description="Latest completed planned units") - objective_achieved_rate: int = Field(0, ge=0, le=100, description="Success rate as percentage (0-100)") - total_retries: int = Field(0, ge=0, description="Retry attempts recorded across projected work units") + techniques_used: list[str] = Field(default_factory=list, description="Technique names that were executed") labels: dict[str, str] = Field(default_factory=dict, description="Labels attached to this run") completed_at: datetime | None = Field(None, description="When the scenario finished") pyrit_version: str | None = Field(None, description="PyRIT version that created the run") - target: "ScenarioTargetSummary | None" = Field(None, description="Safe objective-target identity") + target: ScenarioTargetSummary | None = Field(None, description="Safe objective-target identity") datasets_used: list[str] = Field(default_factory=list, description="Resolved datasets selected for the run") scenario_parameters: dict[str, Any] = Field( default_factory=dict, description="Safe resolved scenario parameters; sensitive fields are removed", ) + queue_position: int | None = Field(None, ge=1, description="Current 1-based waiting position") + active_scenario_result_id: str | None = Field(None, description="Currently executing scenario result ID") + overload_summaries: list[ScenarioOverloadSummary] = Field( + default_factory=list, + description="Bounded recent HTTP 429 and 5xx retry evidence grouped by component role", + ) + + +class ScenarioRunSummary(ScenarioRunHeader): + """Response for a scenario run (status + result details).""" + + updated_at: datetime = Field(..., description="When the run status last changed") + error: str | None = Field(None, description="Error message if status is FAILED") + error_type: str | None = Field(None, description="Exception class name if status is FAILED") + total_attacks: int = Field(0, ge=0, description="Total planned progress units for this run") + completed_attacks: int = Field(0, ge=0, description="Number of planned progress units that completed") + objective_achieved_rate: int = Field(0, ge=0, le=100, description="Success rate as percentage (0-100)") + failed_attacks: list[AttackErrorSummary] = Field( + default_factory=list, + description="Individual attack results that errored, surfaced regardless of overall run status", + ) + attack_retries: list[AttackRetrySummary] = Field( + default_factory=list, + description="Per-attack retry events, surfaced as each attack result lands so the CLI can stream warnings", + ) + total_retries: int = Field( + 0, + ge=0, + description="Total retry work beyond each logical unit's initial attempt, including inner retries " + "and additional scenario attempts", + ) planned_total_available: bool = Field( True, description="Whether total_attacks comes from a complete persisted run plan", @@ -627,16 +639,10 @@ class ScenarioRunListItem(BaseModel): True, description="Whether failed_attacks and attack_retries contain per-attempt details", ) + attack_details_truncated: bool = Field( + False, + description="Whether failed_attacks or attack_retries omit older entries because detail limits were reached", + ) -class ScenarioTargetSummary(BaseModel): - """Safe target identity suitable for scenario history and run headers.""" - - target_type: str = Field(..., description="Target implementation type") - endpoint: str | None = Field(None, description="Configured endpoint, when present") - model_name: str | None = Field(None, description="Configured model or deployment name") - identifier_hash: str | None = Field(None, description="Canonical target identifier hash") - - -ScenarioRunSummary.model_rebuild() ScenarioRunListItem.model_rebuild() diff --git a/pyrit/models/messages/message.py b/pyrit/models/messages/message.py index 5047808b1a..b1bd3f4526 100644 --- a/pyrit/models/messages/message.py +++ b/pyrit/models/messages/message.py @@ -6,7 +6,7 @@ import copy import uuid from datetime import UTC, datetime -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Any, cast from pydantic import BaseModel, ConfigDict, model_validator @@ -306,7 +306,7 @@ def from_prompt( *, prompt: str, role: ChatMessageRole, - prompt_metadata: dict[str, str | int] | None = None, + prompt_metadata: dict[str, Any] | None = None, ) -> Message: """ Build a single-piece message from prompt text. @@ -314,7 +314,7 @@ def from_prompt( Args: prompt (str): Prompt text. role (ChatMessageRole): Role assigned to the message piece. - prompt_metadata (dict[str, str | int] | None): Optional prompt metadata. + prompt_metadata (dict[str, Any] | None): Optional prompt metadata. Returns: Message: Constructed message instance. diff --git a/pyrit/models/scenario_progress.py b/pyrit/models/scenario_progress.py index 33a9f48fa0..0f7a24f9dc 100644 --- a/pyrit/models/scenario_progress.py +++ b/pyrit/models/scenario_progress.py @@ -3,13 +3,14 @@ """Canonical models for durable scenario run plans and incremental progress.""" -from datetime import datetime +from enum import Enum from typing import Any, Literal from pydantic import AwareDatetime, BaseModel, Field, model_validator -from pyrit.models.catalog.scenario import ScenarioOverloadSummary, ScenarioTargetSummary # noqa: TC001 +from pyrit.models.catalog.scenario import ScenarioRunHeader from pyrit.models.identifiers.atomic_attack_identifier import AtomicAttackIdentifier +from pyrit.models.identifiers.component_identifier import ComponentIdentifier from pyrit.models.results.attack_result import AttackOutcome from pyrit.models.results.scenario_result import ScenarioRunState from pyrit.models.retry_event import RetryEvent @@ -18,6 +19,55 @@ SCENARIO_RUN_PLAN_METADATA_KEY = "run_plan" SCENARIO_RUN_STARTED_AT_METADATA_KEY = "started_at" SCENARIO_RUN_PLAN_VERSION = 1 +ADAPTIVE_ATTEMPT_LABEL = "_adaptive_attempt" +ADAPTIVE_TECHNIQUE_ID_LABEL = "_adaptive_technique_id" +ADAPTIVE_TECHNIQUE_NAME_LABEL = "_adaptive_technique_name" +SEQUENTIAL_ATTACK_CLASS_NAME = "SequentialAttack" + + +def is_sequential_attack_envelope( + *, + conversation_id: str, + atomic_attack_identifier: ComponentIdentifier | None, +) -> bool: + """ + Classify a persisted ``SequentialAttack`` aggregate envelope. + + Typed attack metadata takes precedence. Identifier-less legacy envelopes + are distinguished by the empty conversation ID that ``SequentialAttack`` + has always persisted for its aggregate result. + + Returns: + bool: Whether the row is a non-target-facing aggregate envelope. + """ + if not isinstance(atomic_attack_identifier, ComponentIdentifier): + return atomic_attack_identifier is None and not conversation_id.strip() + + typed_identifier = AtomicAttackIdentifier.from_component_identifier(atomic_attack_identifier) + technique_identifier = typed_identifier.attack_technique + attack_identifier = technique_identifier.attack if technique_identifier is not None else None + if attack_identifier is None: + attack_identifier = typed_identifier.get_child("attack") + return attack_identifier is not None and attack_identifier.class_name == SEQUENTIAL_ATTACK_CLASS_NAME + + +class ScenarioRunPlanGroupKind(str, Enum): + """Semantic kind of a planned scenario progress group.""" + + ATTACK = "attack" + DIRECT_BASELINE = "direct_baseline" + ADAPTIVE = "adaptive" + + +class ScenarioProgressResultKind(str, Enum): + """Semantic role of one persisted result within scenario progress.""" + + ATTACK = "attack" + DIRECT_BASELINE = "direct_baseline" + ADAPTIVE_TECHNIQUE = "adaptive_technique" + ADAPTIVE_ORCHESTRATION = "adaptive_orchestration" + AGGREGATE_PARENT = "aggregate_parent" + UNKNOWN = "unknown" class ScenarioRunPlanSeedGroup(BaseModel): @@ -50,6 +100,7 @@ class ScenarioRunPlanAtomicGroup(BaseModel): seed_group_ids: list[str] description: str | None = None tags: list[str] = Field(default_factory=list) + group_kind: ScenarioRunPlanGroupKind | None = None class ScenarioRunPlan(BaseModel): @@ -92,27 +143,9 @@ def _validate_normalized_plan(self) -> "ScenarioRunPlan": return self -class ScenarioProgressHeader(BaseModel): +class ScenarioProgressHeader(ScenarioRunHeader): """Compact persisted run header returned by the progress endpoint.""" - scenario_result_id: str - scenario_name: str - scenario_registry_name: str | None = None - scenario_version: int - status: ScenarioRunState - created_at: datetime - started_at: AwareDatetime | None = None - completed_at: datetime | None = None - pyrit_version: str | None = None - target: "ScenarioTargetSummary | None" = None - techniques_used: list[str] = Field(default_factory=list) - datasets_used: list[str] = Field(default_factory=list) - scenario_parameters: dict[str, Any] = Field(default_factory=dict) - labels: dict[str, str] = Field(default_factory=dict) - queue_position: int | None = Field(None, ge=1) - active_scenario_result_id: str | None = None - overload_summaries: list["ScenarioOverloadSummary"] = Field(default_factory=list) - class ScenarioProgressScore(BaseModel): """The objective score attached to one persisted scenario attack result.""" @@ -137,7 +170,7 @@ class ScenarioAttackTechniqueDetails(ScenarioComponentIdentity): class ScenarioProgressResult(BaseModel): - """One persisted attack attempt in ascending progress order.""" + """One persisted result record in ascending progress order.""" attack_result_id: str conversation_id: str @@ -152,6 +185,9 @@ class ScenarioProgressResult(BaseModel): error_type: str | None = None error_message: str | None = None score: ScenarioProgressScore | None = None + result_kind: ScenarioProgressResultKind = ScenarioProgressResultKind.UNKNOWN + technique_name: str | None = None + attempt_index: int | None = Field(None, ge=1) class ScenarioProgressCounts(BaseModel): @@ -277,7 +313,7 @@ class ScenarioAttackResultDelta(BaseModel): """Lightweight memory projection used to map one scenario progress delta.""" attack_result_id: str - conversation_id: str + conversation_id: str = "" objective: str objective_sha256: str | None = None atomic_attack_identifier: AtomicAttackIdentifier | None = None @@ -290,6 +326,7 @@ class ScenarioAttackResultDelta(BaseModel): error_message: str | None = None attribution_data: dict[str, Any] = Field(default_factory=dict) score: ScenarioProgressScore | None = None + labels: dict[str, str] = Field(default_factory=dict) ScenarioProgressHeader.model_rebuild() diff --git a/pyrit/scenario/core/atomic_attack.py b/pyrit/scenario/core/atomic_attack.py index 557c82b880..9a05bf86d0 100644 --- a/pyrit/scenario/core/atomic_attack.py +++ b/pyrit/scenario/core/atomic_attack.py @@ -27,6 +27,7 @@ AtomicAttackIdentifier, AttackResult, AttackSeedGroup, + ScenarioRunPlanGroupKind, config_hash, ) @@ -65,6 +66,7 @@ def __init__( adversarial_chat: PromptTarget | None = None, objective_scorer: TrueFalseScorer | None = None, memory_labels: dict[str, str] | None = None, + progress_group_kind: ScenarioRunPlanGroupKind = ScenarioRunPlanGroupKind.ATTACK, **attack_execute_params: Any, ) -> None: """ @@ -88,6 +90,7 @@ def __init__( objective_scorer: Optional scorer for evaluating simulated conversations. memory_labels: Additional labels to apply to prompts. + progress_group_kind: Semantic role used by persisted progress plans. **attack_execute_params: Additional parameters to pass to the attack execution method. @@ -118,6 +121,7 @@ def __init__( self._adversarial_chat = adversarial_chat self._objective_scorer = objective_scorer self._memory_labels = memory_labels or {} + self._progress_group_kind = progress_group_kind self._attack_execute_params = attack_execute_params # Set via set_scenario_result_id() by Scenario._execute_scenario_async # before run_async. When set, each persisted AttackResult is linked to @@ -130,6 +134,11 @@ def __init__( f"attack type: {type(self._attack_technique.attack).__name__}" ) + @property + def progress_group_kind(self) -> ScenarioRunPlanGroupKind: + """Semantic role used by persisted progress plans.""" + return self._progress_group_kind + def set_scenario_result_id(self, scenario_result_id: str | None) -> None: """ Bind this atomic attack to a scenario result for attribution. diff --git a/pyrit/scenario/core/matrix_atomic_attack_builder.py b/pyrit/scenario/core/matrix_atomic_attack_builder.py index d15973255d..112e4b26d1 100644 --- a/pyrit/scenario/core/matrix_atomic_attack_builder.py +++ b/pyrit/scenario/core/matrix_atomic_attack_builder.py @@ -24,7 +24,7 @@ from pyrit.executor.attack import AttackScoringConfig from pyrit.executor.attack.single_turn.prompt_sending import PromptSendingAttack -from pyrit.models import AttackSeedGroup +from pyrit.models import AttackSeedGroup, ScenarioRunPlanGroupKind from pyrit.prompt_normalizer import ConverterConfiguration from pyrit.scenario.core.atomic_attack import AtomicAttack from pyrit.scenario.core.attack_technique import AttackTechnique @@ -139,6 +139,7 @@ def build_baseline_atomic_attack( objective_scorer=cast("TrueFalseScorer", objective_scorer), memory_labels=memory_labels or {}, display_group=display_group, + progress_group_kind=ScenarioRunPlanGroupKind.DIRECT_BASELINE, ) diff --git a/pyrit/scenario/core/scenario.py b/pyrit/scenario/core/scenario.py index 532d1be448..5bfef26ad4 100644 --- a/pyrit/scenario/core/scenario.py +++ b/pyrit/scenario/core/scenario.py @@ -37,6 +37,7 @@ ScenarioResult, ScenarioRunPlan, ScenarioRunPlanAtomicGroup, + ScenarioRunPlanGroupKind, ScenarioRunPlanSeedGroup, ScenarioRunPlanSeedPrompt, ScenarioRunSizeComponent, @@ -1126,6 +1127,11 @@ def _build_run_plan(self) -> ScenarioRunPlan: seed_group_ids=seed_group_ids, description=technique.description if technique else None, tags=sorted(technique.tags) if technique else [], + group_kind=getattr( + atomic_attack, + "_progress_group_kind", + ScenarioRunPlanGroupKind.ATTACK, + ), ) ) return ScenarioRunPlan( diff --git a/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py b/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py index c421f2974d..7da80c59ff 100644 --- a/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py +++ b/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py @@ -26,6 +26,7 @@ AtomicAttackEvaluationIdentifier, AtomicAttackIdentifier, ScenarioAdaptiveRunSizeDetails, + ScenarioRunPlanGroupKind, ScenarioRunSizeComponent, ) from pyrit.models.catalog import ( @@ -534,6 +535,7 @@ async def _build_atomics_for_dataset_async( objective_scorer=self._objective_scorer, memory_labels=dict(self._memory_labels), display_group=dataset_name, + progress_group_kind=ScenarioRunPlanGroupKind.ADAPTIVE, ) ) diff --git a/pyrit/scenario/scenarios/adaptive/dispatcher.py b/pyrit/scenario/scenarios/adaptive/dispatcher.py index bafe0509b0..28a126456a 100644 --- a/pyrit/scenario/scenarios/adaptive/dispatcher.py +++ b/pyrit/scenario/scenarios/adaptive/dispatcher.py @@ -29,6 +29,11 @@ SequentialAttack, SequentialChildAttack, ) +from pyrit.models import ( + ADAPTIVE_ATTEMPT_LABEL, + ADAPTIVE_TECHNIQUE_ID_LABEL, + ADAPTIVE_TECHNIQUE_NAME_LABEL, +) if TYPE_CHECKING: from pyrit.executor.attack.core.attack_strategy import AttackStrategy @@ -40,18 +45,6 @@ logger = logging.getLogger(__name__) -# Memory-label key stamped onto persisted prompt rows so adaptive attempts -# can be filtered/grouped after a run. -ADAPTIVE_ATTEMPT_LABEL: str = "_adaptive_attempt" -"""1-based attempt index within the per-objective loop.""" - -ADAPTIVE_TECHNIQUE_ID_LABEL: str = "_adaptive_technique_id" -"""Joined registered-factory and behavioral-history identity for the selected arm.""" - -ADAPTIVE_TECHNIQUE_NAME_LABEL: str = "_adaptive_technique_name" -"""Registered technique name for human-readable result attribution.""" - - @dataclass(frozen=True) class TechniqueBundle: """ diff --git a/tests/unit/backend/test_mappers.py b/tests/unit/backend/test_mappers.py index 8d8924bb63..f7ad4b2769 100644 --- a/tests/unit/backend/test_mappers.py +++ b/tests/unit/backend/test_mappers.py @@ -915,6 +915,25 @@ async def test_empty_scores_when_none_recorded(self, sqlite_instance) -> None: assert result[0].message_pieces[0].scores == [] + async def test_prompt_metadata_round_trips_through_memory(self, sqlite_instance) -> None: + """Prompt metadata survives persistence and the message DTO mapper.""" + from pyrit.models import Message as RealPyritMessage + from pyrit.models import MessagePiece as RealPyritMessagePiece + + metadata = {"source": "generated"} + piece = RealPyritMessagePiece( + role="user", + original_value="generated prompt", + conversation_id="real-conv-metadata", + prompt_metadata=metadata, + ) + sqlite_instance.add_message_to_memory(request=RealPyritMessage(message_pieces=[piece])) + + reloaded = sqlite_instance.get_conversation_messages(conversation_id=piece.conversation_id) + result = await pyrit_messages_to_dto_async(list(reloaded)) + + assert result[0].message_pieces[0].prompt_metadata == metadata + async def test_scores_are_grouped_per_piece_across_multiple_pieces(self, sqlite_instance) -> None: """Scores from a batched fetch are routed to the correct originating piece.""" from pyrit.models import Message as RealPyritMessage diff --git a/tests/unit/backend/test_scenario_run_service.py b/tests/unit/backend/test_scenario_run_service.py index a77e415efc..4b64766392 100644 --- a/tests/unit/backend/test_scenario_run_service.py +++ b/tests/unit/backend/test_scenario_run_service.py @@ -21,9 +21,7 @@ import pyrit.backend.services.scenario_progress_read_model as _progress_mod import pyrit.backend.services.scenario_run_service as _svc_mod from pyrit.backend.services.scenario_progress_read_model import ScenarioPlanLookup, ScenarioProgressReadModel -from pyrit.backend.services.scenario_run_service import ( - ScenarioRunService, -) +from pyrit.backend.services.scenario_run_service import ScenarioRunService from pyrit.common.utils import to_sha256 from pyrit.converter import Converter from pyrit.memory import ( @@ -35,6 +33,8 @@ SQLiteMemory, ) from pyrit.models import ( + ADAPTIVE_ATTEMPT_LABEL, + ADAPTIVE_TECHNIQUE_NAME_LABEL, SCENARIO_RUN_PLAN_METADATA_KEY, AtomicAttackIdentifier, AttackOutcome, @@ -45,17 +45,19 @@ RetryEvent, ScenarioAttackResultDelta, ScenarioProgressResult, + ScenarioProgressResultKind, ScenarioProgressScore, ScenarioResult, ScenarioRunPlan, ScenarioRunPlanAtomicGroup, + ScenarioRunPlanGroupKind, ScenarioRunPlanSeedGroup, ScenarioRunState, ScoreStatus, SeedObjective, config_hash, ) -from pyrit.models.catalog.scenario import RunScenarioRequest +from pyrit.models.catalog import RunScenarioRequest, ScenarioRunSummary from pyrit.scenario.core import ( CompoundDatasetAttackConfiguration, DatasetAttackConfiguration, @@ -63,7 +65,7 @@ ) from pyrit.scenario.core.scenario_technique import ScenarioTechnique from pyrit.score.scorer_evaluation.scorer_metrics import ObjectiveScorerMetrics -from unit.mocks import make_scenario_result +from unit.mocks import get_mock_target_identifier, make_scenario_result class _StubTechnique(ScenarioTechnique): @@ -156,6 +158,21 @@ def _make_db_scenario_result( return sr +def _get_run_using_active_snapshot( + *, + service: ScenarioRunService, + scenario_result_id: str, +) -> ScenarioRunSummary | None: + """Mirror the route's event-loop snapshot and storage-safe lookup split.""" + snapshot = service.snapshot_active_run(scenario_result_id=scenario_result_id) + return service.get_run_from_storage( + scenario_result_id=scenario_result_id, + active_error=snapshot.error, + queue_position=snapshot.queue_position, + active_scenario_result_id=snapshot.active_scenario_result_id, + ) + + def _make_history_record( *, result_id: str, @@ -554,20 +571,21 @@ async def test_start_run_forwards_include_baseline(self, mock_all_registries) -> init_call = mock_all_registries["scenario_registry"].create_and_initialize_async.await_args assert init_call.kwargs["include_baseline"] is False - async def test_start_run_max_dataset_size_uses_default_config(self, mock_all_registries) -> None: - """``max_dataset_size`` with no ``dataset_names`` reuses the scenario's default config.""" - default_config = MagicMock() - default_config.max_dataset_size = 100 # original + async def test_start_run_max_dataset_size_updates_introspection_config(self, mock_all_registries) -> None: + """``max_dataset_size`` updates the throwaway introspection config.""" + default_config = DatasetAttackConfiguration(dataset_names=["original"], max_dataset_size=100) scenario_instance = mock_all_registries["scenario_instance"] scenario_instance._default_dataset_config = default_config service = ScenarioRunService() await service.start_run_async(request=_make_request(max_dataset_size=5)) - # max_dataset_size on the default config was overridden - assert default_config.max_dataset_size == 5 init_call = mock_all_registries["scenario_registry"].create_and_initialize_async.await_args - assert init_call.kwargs["dataset_config"] is default_config + built_config = init_call.kwargs["dataset_config"] + assert built_config is default_config + assert type(built_config) is DatasetAttackConfiguration + assert built_config.max_dataset_size == 5 + assert default_config.max_dataset_size == 5 async def test_start_run_dataset_names_preserves_subclass_config_type(self, mock_all_registries) -> None: """``dataset_names`` rebuilds the config using the scenario's own DatasetConfiguration subclass. @@ -658,6 +676,7 @@ async def test_start_run_max_dataset_size_updates_each_default_compound_child(se assert built_config is default_config assert built_config.dataset_names == ["airt_hate", "airt_fairness"] assert [child.max_dataset_size for child in built_config._configurations] == [2, 2] + assert [child.max_dataset_size for child in default_config._configurations] == [2, 2] async def test_start_run_non_name_overrides_preserve_shaped_compound_children(self, mock_all_registries) -> None: """Size and filter overrides do not rebuild scenario-specific child configurations.""" @@ -695,6 +714,11 @@ class _ShapedDatasetConfiguration(DatasetAttackConfiguration): {"harm_categories": ["cyber"]}, {"harm_categories": ["cyber"]}, ] + assert [child.max_dataset_size for child in default_config._configurations] == [2, 2] + assert [child.filters for child in default_config._configurations] == [ + {"harm_categories": ["cyber"]}, + {"harm_categories": ["cyber"]}, + ] async def test_start_run_dataset_names_rejects_incompatible_subclass_constructor(self, mock_all_registries) -> None: """Reject overrides that cannot preserve scenario-specific dataset configuration.""" @@ -743,8 +767,8 @@ class _MarkerDatasetConfiguration(DatasetConfiguration): assert built_config.max_dataset_size == 7 assert built_config.filters == {"harm_categories": ["cyber"]} - async def test_start_run_dataset_filters_updates_default_config(self, mock_all_registries) -> None: - """``dataset_filters`` with no ``dataset_names`` merges filters into the default config.""" + async def test_start_run_dataset_filters_update_introspection_config(self, mock_all_registries) -> None: + """``dataset_filters`` with no names update the throwaway introspection config.""" default_config = DatasetAttackConfiguration(dataset_names=["original"]) scenario_instance = mock_all_registries["scenario_instance"] scenario_instance._default_dataset_config = default_config @@ -756,6 +780,7 @@ async def test_start_run_dataset_filters_updates_default_config(self, mock_all_r built_config = init_call.kwargs["dataset_config"] assert built_config is default_config assert built_config.filters == {"harm_categories": ["cyber"]} + assert default_config.filters == {"harm_categories": ["cyber"]} async def test_start_run_dataset_names_introspection_failure_raises(self, mock_memory) -> None: """Passing ``dataset_names`` against a non-no-arg-instantiable scenario fails fast.""" @@ -1436,14 +1461,14 @@ async def _run_async() -> None: await asyncio.sleep(0) -class TestScenarioRunServiceGetRun: - """Tests for ScenarioRunService.get_run.""" +class TestScenarioRunServiceGetRunFromStorage: + """Tests for the event-loop snapshot and storage-safe run lookup split.""" def test_get_run_returns_none_for_unknown_id(self, mock_memory) -> None: """Test that get_run returns None for non-existent run.""" mock_memory.get_scenario_results.return_value = [] service = ScenarioRunService() - result = service.get_run(scenario_result_id="nonexistent-id") + result = _get_run_using_active_snapshot(service=service, scenario_result_id="nonexistent-id") assert result is None def test_get_run_returns_existing_run(self, mock_memory) -> None: @@ -1452,7 +1477,7 @@ def test_get_run_returns_existing_run(self, mock_memory) -> None: mock_memory.get_scenario_results.return_value = [db_result] service = ScenarioRunService() - fetched = service.get_run(scenario_result_id="sr-123") + fetched = _get_run_using_active_snapshot(service=service, scenario_result_id="sr-123") assert fetched is not None assert fetched.scenario_result_id == "sr-123" @@ -1471,7 +1496,7 @@ def test_get_run_maps_typed_scenario_result_state(self, mock_memory) -> None: mock_memory.get_scenario_results.return_value = [db_result] service = ScenarioRunService() - fetched = service.get_run(scenario_result_id=str(db_result.id)) + fetched = _get_run_using_active_snapshot(service=service, scenario_result_id=str(db_result.id)) assert fetched is not None assert fetched.status is ScenarioRunState.FAILED @@ -1537,7 +1562,10 @@ def test_get_run_detail_preserves_readability_across_plan_metadata( ) mock_memory.get_scenario_results.return_value = [db_result] - fetched = ScenarioRunService().get_run(scenario_result_id=str(db_result.id)) + fetched = _get_run_using_active_snapshot( + service=ScenarioRunService(), + scenario_result_id=str(db_result.id), + ) assert fetched is not None assert fetched.scenario_registry_name == expected_registry_name @@ -1565,7 +1593,7 @@ def test_get_run_falls_back_to_persisted_error(self, mock_memory) -> None: mock_memory.get_attack_results.return_value = [error_ar] service = ScenarioRunService() - fetched = service.get_run(scenario_result_id="sr-fail") + fetched = _get_run_using_active_snapshot(service=service, scenario_result_id="sr-fail") assert fetched is not None assert fetched.error == "Connection refused" @@ -2347,7 +2375,10 @@ async def _run() -> MagicMock: # Executable task state is released during terminal handoff. assert response.scenario_result_id not in service._active_tasks - fetched = service.get_run(scenario_result_id=response.scenario_result_id) + fetched = _get_run_using_active_snapshot( + service=service, + scenario_result_id=response.scenario_result_id, + ) assert fetched is not None async def test_execute_run_fails_with_error(self, mock_all_registries) -> None: @@ -2377,8 +2408,11 @@ async def _run() -> None: assert active.error == "scenario exploded" assert response.scenario_result_id not in service._active_tasks - # get_run surfaces the bounded terminal error evidence. - fetched = service.get_run(scenario_result_id=response.scenario_result_id) + # The active snapshot surfaces bounded terminal error evidence to the storage projection. + fetched = _get_run_using_active_snapshot( + service=service, + scenario_result_id=response.scenario_result_id, + ) assert fetched is not None assert fetched.error == "scenario exploded" @@ -2517,7 +2551,7 @@ def test_in_progress_run_shows_partial_attack_counts(self, mock_memory) -> None: mock_memory.get_scenario_results.return_value = [db_result] service = ScenarioRunService() - fetched = service.get_run(scenario_result_id="sr-running") + fetched = _get_run_using_active_snapshot(service=service, scenario_result_id="sr-running") assert fetched is not None assert fetched.status == ScenarioRunState.IN_PROGRESS @@ -2537,7 +2571,7 @@ def test_created_run_shows_zero_counts(self, mock_memory) -> None: mock_memory.get_scenario_results.return_value = [db_result] service = ScenarioRunService() - fetched = service.get_run(scenario_result_id="sr-new") + fetched = _get_run_using_active_snapshot(service=service, scenario_result_id="sr-new") assert fetched is not None assert fetched.status == ScenarioRunState.CREATED @@ -2562,7 +2596,7 @@ def test_completed_run_still_shows_full_counts(self, mock_memory) -> None: mock_memory.get_scenario_results.return_value = [db_result] service = ScenarioRunService() - fetched = service.get_run(scenario_result_id="sr-done") + fetched = _get_run_using_active_snapshot(service=service, scenario_result_id="sr-done") assert fetched is not None assert fetched.status == ScenarioRunState.COMPLETED @@ -2599,7 +2633,7 @@ def test_error_attacks_and_retries_are_surfaced(self, mock_memory) -> None: mock_memory.get_scenario_results.return_value = [db_result] service = ScenarioRunService() - fetched = service.get_run(scenario_result_id="sr-mixed") + fetched = _get_run_using_active_snapshot(service=service, scenario_result_id="sr-mixed") assert fetched is not None assert fetched.total_retries == 6 @@ -2609,6 +2643,8 @@ def test_error_attacks_and_retries_are_surfaced(self, mock_memory) -> None: assert failed.error_type == "RateLimitError" assert failed.error_message == "429 Too Many Requests" assert failed.total_retries == 4 + assert fetched.error_attacks == 1 + assert fetched.attack_details_truncated is False def test_negative_error_attack_retries_are_clamped(self, mock_memory) -> None: from pyrit.models import AttackOutcome @@ -2627,7 +2663,10 @@ def test_negative_error_attack_retries_are_clamped(self, mock_memory) -> None: ) mock_memory.get_scenario_results.return_value = [db_result] - fetched = ScenarioRunService().get_run(scenario_result_id="sr-negative-retries") + fetched = _get_run_using_active_snapshot( + service=ScenarioRunService(), + scenario_result_id="sr-negative-retries", + ) assert fetched is not None assert fetched.total_retries == 0 @@ -2649,7 +2688,7 @@ def test_no_failed_attacks_when_all_succeed(self, mock_memory) -> None: mock_memory.get_scenario_results.return_value = [db_result] service = ScenarioRunService() - fetched = service.get_run(scenario_result_id="sr-clean") + fetched = _get_run_using_active_snapshot(service=service, scenario_result_id="sr-clean") assert fetched is not None assert fetched.failed_attacks == [] @@ -2683,7 +2722,7 @@ def test_retry_events_surface_per_attack(self, mock_memory) -> None: mock_memory.get_scenario_results.return_value = [db_result] service = ScenarioRunService() - fetched = service.get_run(scenario_result_id="sr-retry") + fetched = _get_run_using_active_snapshot(service=service, scenario_result_id="sr-retry") assert fetched is not None assert len(fetched.attack_retries) == 1 @@ -2693,6 +2732,56 @@ def test_retry_events_surface_per_attack(self, mock_memory) -> None: assert summary.retries[0].endpoint == "https://ep/" assert summary.retries[0].component_role == "objective_scorer" + def test_attack_details_keep_latest_entries_without_losing_totals(self, mock_memory) -> None: + detail_limit = _svc_mod._MAX_ATTACK_DETAIL_ENTRIES + result_count = detail_limit + 5 + results = [] + for index in range(result_count): + attack = MagicMock() + attack.outcome = AttackOutcome.ERROR + attack.objective = f"objective-{index}" + attack.error_type = "RateLimitError" + attack.error_message = f"error-{index}" + attack.total_retries = 2 + attack.attack_result_id = f"ar-{index}" + attack.timestamp = datetime(2026, 1, 1, tzinfo=UTC) + timedelta(seconds=index) + attack.retry_events = [ + RetryEvent( + attempt_number=1, + exception_type="RateLimitError", + exception_message=f"retry-{index}", + component_role="objective_target", + status_code=429, + ) + ] + results.append(attack) + + db_result = _make_db_scenario_result( + result_id="sr-bounded-details", + run_state=ScenarioRunState.COMPLETED, + attack_results={ + "newer_attack": results[detail_limit:], + "older_attack": results[:detail_limit], + }, + ) + mock_memory.get_scenario_results.return_value = [db_result] + + fetched = _get_run_using_active_snapshot( + service=ScenarioRunService(), + scenario_result_id="sr-bounded-details", + ) + + assert fetched is not None + assert len(fetched.failed_attacks) == detail_limit + assert len(fetched.attack_retries) == detail_limit + assert fetched.failed_attacks[0].objective == "objective-5" + assert fetched.failed_attacks[-1].objective == f"objective-{result_count - 1}" + assert fetched.attack_retries[0].attack_result_id == "ar-5" + assert fetched.attack_retries[-1].attack_result_id == f"ar-{result_count - 1}" + assert fetched.total_retries == result_count * 2 + assert fetched.error_attacks == result_count + assert fetched.attack_details_truncated is True + class TestResolveTechniquesAndConverters: """Tests for per-technique converter resolution from ``--techniques`` tokens.""" @@ -2974,6 +3063,153 @@ def test_history_and_detail_retry_work_match_across_attempt_partitions(mock_memo assert history.total_retries == detail.total_retries +@pytest.mark.parametrize("envelope_kind", ["typed", "pre-nested", "legacy"]) +def test_sequential_envelope_is_excluded_from_detail_history_and_progress_accounting( + sqlite_instance: SQLiteMemory, + envelope_kind: str, +) -> None: + objective = "adaptive objective" + scenario_result_id = uuid.uuid4() + plan = ScenarioRunPlan( + scenario_registry_name="adaptive.test", + atomic_groups=[ + ScenarioRunPlanAtomicGroup( + id="adaptive-group", + atomic_attack_name="adaptive", + display_group="Adaptive", + technique_eval_hash="adaptive-eval", + seed_group_ids=["seed-1"], + group_kind=ScenarioRunPlanGroupKind.ADAPTIVE, + ) + ], + seed_groups=[ + ScenarioRunPlanSeedGroup( + id="seed-1", + objective_sha256=to_sha256(objective), + objective=objective, + ) + ], + ) + scenario_result = make_scenario_result( + id=scenario_result_id, + scenario_name="AdaptiveTestScenario", + objective_target_identifier=get_mock_target_identifier(), + attack_results={}, + scenario_run_state=ScenarioRunState.COMPLETED, + metadata={SCENARIO_RUN_PLAN_METADATA_KEY: plan.model_dump(mode="json")}, + ) + sqlite_instance.add_scenario_results_to_memory(scenario_results=[scenario_result]) + + child_identifier = AtomicAttackIdentifier.build( + attack_identifier=ComponentIdentifier(class_name="ChildAttack", class_module="tests") + ) + if envelope_kind == "typed": + envelope_identifier = AtomicAttackIdentifier.build( + attack_identifier=ComponentIdentifier(class_name="SequentialAttack", class_module="pyrit") + ) + elif envelope_kind == "pre-nested": + envelope_identifier = ComponentIdentifier( + class_name="AtomicAttack", + class_module="pyrit.scenario.core.atomic_attack", + children={ + "attack": ComponentIdentifier(class_name="SequentialAttack", class_module="pyrit"), + }, + ) + else: + envelope_identifier = None + attribution_data = { + "parent_collection": "adaptive", + "parent_eval_hash": "adaptive-eval", + "seed_group_id": "seed-1", + } + timestamp = datetime(2026, 8, 9, tzinfo=UTC) + attack_results = [ + AttackResult( + conversation_id="", + objective=objective, + atomic_attack_identifier=child_identifier, + outcome=AttackOutcome.ERROR, + error_type="ChildError", + error_message="child failed", + total_retries=2, + timestamp=timestamp, + labels={ + ADAPTIVE_ATTEMPT_LABEL: "1", + ADAPTIVE_TECHNIQUE_NAME_LABEL: "first technique", + }, + attribution_parent_id=str(scenario_result_id), + attribution_data=attribution_data, + ), + AttackResult( + conversation_id="child-conversation", + objective=objective, + atomic_attack_identifier=child_identifier, + outcome=AttackOutcome.SUCCESS, + timestamp=timestamp + timedelta(seconds=1), + labels={ + ADAPTIVE_ATTEMPT_LABEL: "2", + ADAPTIVE_TECHNIQUE_NAME_LABEL: "second technique", + }, + attribution_parent_id=str(scenario_result_id), + attribution_data=attribution_data, + ), + AttackResult( + conversation_id="", + objective=objective, + atomic_attack_identifier=envelope_identifier, + outcome=AttackOutcome.ERROR, + error_type="AggregateError", + error_message="aggregate failed", + total_retries=7, + timestamp=timestamp + timedelta(seconds=2), + attribution_parent_id=str(scenario_result_id), + attribution_data=attribution_data, + ), + ] + sqlite_instance.add_attack_results_to_memory(attack_results=attack_results) + service = ScenarioRunService() + + detail = _get_run_using_active_snapshot( + service=service, + scenario_result_id=str(scenario_result_id), + ) + history = service.list_runs(limit=10).items[0] + progress = service.get_run_progress_from_storage( + scenario_result_id=str(scenario_result_id), + since=None, + limit=10, + active_group_ids=[], + ) + _, aggregates_by_run, _ = sqlite_instance.get_scenario_run_history_page(limit=10) + + assert detail is not None + assert progress is not None + assert detail.completed_attacks == history.completed_attacks == 1 + assert detail.error_attacks == history.error_attacks == 1 + assert detail.total_retries == history.total_retries == 3 + assert [failure.error_message for failure in detail.failed_attacks] == ["child failed"] + + aggregate = aggregates_by_run[str(scenario_result_id)] + assert aggregate.unit_count == 1 + assert aggregate.completed_units == 1 + assert aggregate.successful_units == 1 + assert aggregate.error_attempts == 1 + assert aggregate.total_retries == 3 + assert aggregate.latest_attempt_timestamp == timestamp + timedelta(seconds=1) + + assert len(progress.results) == 3 + assert [result.result_kind for result in progress.results] == [ + ScenarioProgressResultKind.ADAPTIVE_TECHNIQUE, + ScenarioProgressResultKind.ADAPTIVE_TECHNIQUE, + ScenarioProgressResultKind.ADAPTIVE_ORCHESTRATION, + ] + target_results = [ + result for result in progress.results if result.result_kind is ScenarioProgressResultKind.ADAPTIVE_TECHNIQUE + ] + live_retry_count = sum(result.total_retries for result in target_results) + max(0, len(target_results) - 1) + assert live_retry_count == detail.total_retries + + def test_planned_progress_maps_legacy_objective_hash_to_logical_seed_id(mock_memory) -> None: objective = "legacy resumed objective" seed_group = AttackSeedGroup(seeds=[SeedObjective(value=objective)]) @@ -3223,6 +3459,121 @@ def test_get_progress_exposes_persisted_started_at(mock_memory) -> None: assert progress.run.started_at == started_at +def test_get_progress_preserves_eight_progress_units_and_twelve_persisted_results(mock_memory) -> None: + seed_ids = [f"seed-{index}" for index in range(1, 5)] + baseline_group_id = config_hash({"atomic_attack_name": "baseline", "technique_eval_hash": "baseline-eval"}) + adaptive_group_ids = [ + config_hash({"atomic_attack_name": f"adaptive-{index}", "technique_eval_hash": f"adaptive-eval-{index}"}) + for index in range(1, 5) + ] + plan = ScenarioRunPlan( + scenario_registry_name="adaptive.text", + atomic_groups=[ + ScenarioRunPlanAtomicGroup( + id=baseline_group_id, + atomic_attack_name="baseline", + display_group="Direct baseline", + technique_eval_hash="baseline-eval", + seed_group_ids=seed_ids, + group_kind=ScenarioRunPlanGroupKind.DIRECT_BASELINE, + ), + *[ + ScenarioRunPlanAtomicGroup( + id=group_id, + atomic_attack_name=f"adaptive-{index}", + display_group="Adaptive", + technique_eval_hash=f"adaptive-eval-{index}", + seed_group_ids=[seed_ids[index - 1]], + group_kind=ScenarioRunPlanGroupKind.ADAPTIVE, + ) + for index, group_id in enumerate(adaptive_group_ids, start=1) + ], + ], + seed_groups=[ + ScenarioRunPlanSeedGroup( + id=seed_id, + objective_sha256=f"objective-sha-{index}", + objective=f"objective {index}", + ) + for index, seed_id in enumerate(seed_ids, start=1) + ], + ) + timestamp = datetime(2025, 1, 1, tzinfo=UTC) + deltas: list[ScenarioAttackResultDelta] = [] + for index, (seed_id, adaptive_group_id) in enumerate(zip(seed_ids, adaptive_group_ids, strict=True), start=1): + common = { + "objective": f"objective {index}", + "objective_sha256": f"objective-sha-{index}", + "outcome": AttackOutcome.SUCCESS, + "execution_time_ms": 10, + } + deltas.extend( + [ + ScenarioAttackResultDelta( + attack_result_id=str(uuid.uuid4()), + timestamp=timestamp + timedelta(seconds=index * 3), + attribution_data={ + "parent_collection": "baseline", + "parent_eval_hash": "baseline-eval", + "seed_group_id": seed_id, + }, + **common, + ), + ScenarioAttackResultDelta( + attack_result_id=str(uuid.uuid4()), + timestamp=timestamp + timedelta(seconds=index * 3 + 1), + attribution_data={ + "parent_collection": f"adaptive-{index}", + "parent_eval_hash": f"adaptive-eval-{index}", + "seed_group_id": seed_id, + }, + labels={ + ADAPTIVE_ATTEMPT_LABEL: "1", + ADAPTIVE_TECHNIQUE_NAME_LABEL: f"Technique {index}", + }, + **common, + ), + ScenarioAttackResultDelta( + attack_result_id=str(uuid.uuid4()), + timestamp=timestamp + timedelta(seconds=index * 3 + 2), + attribution_data={ + "parent_collection": f"adaptive-{index}", + "parent_eval_hash": f"adaptive-eval-{index}", + "seed_group_id": seed_id, + }, + **common, + ), + ] + ) + assert adaptive_group_id == config_hash( + {"atomic_attack_name": f"adaptive-{index}", "technique_eval_hash": f"adaptive-eval-{index}"} + ) + header = make_scenario_result( + attack_results={}, + scenario_run_state=ScenarioRunState.COMPLETED, + metadata={SCENARIO_RUN_PLAN_METADATA_KEY: plan.model_dump(mode="json")}, + ) + mock_memory.get_scenario_result_header.return_value = header + mock_memory.get_scenario_attack_result_deltas.return_value = (deltas, False) + + progress = ScenarioRunService().get_run_progress( + scenario_result_id=str(header.id), + since=None, + limit=25, + ) + + assert progress is not None + assert progress.plan is not None + assert sum(len(group.seed_group_ids) for group in progress.plan.atomic_groups) == 8 + assert len(progress.results) == 12 + assert sum(result.total_retries for result in progress.results) == 0 + assert sum(result.result_kind is ScenarioProgressResultKind.DIRECT_BASELINE for result in progress.results) == 4 + assert sum(result.result_kind is ScenarioProgressResultKind.ADAPTIVE_TECHNIQUE for result in progress.results) == 4 + assert ( + sum(result.result_kind is ScenarioProgressResultKind.ADAPTIVE_ORCHESTRATION for result in progress.results) == 4 + ) + + @pytest.mark.parametrize("started_at", ["not-a-timestamp", "2026-08-08T12:30:00"]) def test_load_started_at_rejects_invalid_or_naive_timestamp(started_at: str) -> None: scenario_result = make_scenario_result( @@ -3594,6 +3945,69 @@ def test_synthesize_legacy_plan_deduplicates_seed_ids_in_first_seen_order() -> N assert [seed.id for seed in plan.seed_groups] == ["seed-b", "seed-a"] +def test_progress_maps_structured_adaptive_attempt_roles() -> None: + plan = ScenarioRunPlan( + scenario_registry_name="adaptive.text", + atomic_groups=[ + ScenarioRunPlanAtomicGroup( + id="adaptive-group", + atomic_attack_name="adaptive", + display_group="Adaptive", + technique_eval_hash="eval", + seed_group_ids=["seed-1"], + group_kind=ScenarioRunPlanGroupKind.ADAPTIVE, + ) + ], + seed_groups=[ + ScenarioRunPlanSeedGroup( + id="seed-1", + objective_sha256="objective-sha", + objective="objective", + ) + ], + ) + child = ScenarioAttackResultDelta( + attack_result_id=str(uuid.uuid4()), + objective="objective", + objective_sha256="objective-sha", + outcome=AttackOutcome.SUCCESS, + execution_time_ms=10, + timestamp=datetime(2025, 1, 1, tzinfo=UTC), + attribution_data={"parent_collection": "adaptive", "parent_eval_hash": "eval"}, + labels={ + ADAPTIVE_ATTEMPT_LABEL: "2", + ADAPTIVE_TECHNIQUE_NAME_LABEL: "Technique alpha", + }, + ) + envelope = child.model_copy(update={"attack_result_id": str(uuid.uuid4()), "labels": {}}) + invalid_index_child = child.model_copy( + update={ + "attack_result_id": str(uuid.uuid4()), + "labels": { + ADAPTIVE_ATTEMPT_LABEL: "0", + ADAPTIVE_TECHNIQUE_NAME_LABEL: "Technique alpha", + }, + } + ) + + plan_lookup = ScenarioPlanLookup.from_plan(plan=plan) + mapped_child = ScenarioProgressReadModel._map_progress_delta(delta=child, plan_lookup=plan_lookup) + mapped_envelope = ScenarioProgressReadModel._map_progress_delta(delta=envelope, plan_lookup=plan_lookup) + mapped_invalid_index = ScenarioProgressReadModel._map_progress_delta( + delta=invalid_index_child, + plan_lookup=plan_lookup, + ) + + assert mapped_child.result_kind is ScenarioProgressResultKind.ADAPTIVE_TECHNIQUE + assert mapped_child.technique_name == "Technique alpha" + assert mapped_child.attempt_index == 2 + assert mapped_envelope.result_kind is ScenarioProgressResultKind.ADAPTIVE_ORCHESTRATION + assert mapped_envelope.technique_name is None + assert mapped_envelope.attempt_index is None + assert mapped_invalid_index.result_kind is ScenarioProgressResultKind.ADAPTIVE_TECHNIQUE + assert mapped_invalid_index.attempt_index is None + + def test_get_progress_synthesizes_incomplete_legacy_plan(mock_memory) -> None: header = make_scenario_result( attack_results={}, @@ -3623,6 +4037,119 @@ def test_get_progress_synthesizes_incomplete_legacy_plan(mock_memory) -> None: assert progress.plan is not None assert len(progress.plan.atomic_groups) == 1 assert len(progress.results) == 1 + assert progress.results[0].result_kind is ScenarioProgressResultKind.ATTACK + + +def test_progress_classifies_legacy_result_without_conversation_as_aggregate_parent() -> None: + delta = ScenarioAttackResultDelta( + attack_result_id=str(uuid.uuid4()), + objective="legacy aggregate", + outcome=AttackOutcome.FAILURE, + execution_time_ms=10, + timestamp=datetime(2025, 1, 1, tzinfo=UTC), + attribution_data={"parent_collection": "legacy aggregate"}, + ) + + mapped = ScenarioProgressReadModel._map_progress_delta( + delta=delta, + plan_lookup=ScenarioPlanLookup.from_plan(plan=None), + ) + + assert mapped.result_kind is ScenarioProgressResultKind.AGGREGATE_PARENT + + +def test_progress_does_not_classify_typed_non_sequential_empty_conversation_as_aggregate() -> None: + delta = ScenarioAttackResultDelta( + attack_result_id=str(uuid.uuid4()), + conversation_id="", + objective="child failure", + atomic_attack_identifier=AtomicAttackIdentifier.build( + attack_identifier=ComponentIdentifier(class_name="ChildAttack", class_module="tests") + ), + outcome=AttackOutcome.ERROR, + execution_time_ms=10, + timestamp=datetime(2025, 1, 1, tzinfo=UTC), + attribution_data={"parent_collection": "adaptive"}, + ) + + mapped = ScenarioProgressReadModel._map_progress_delta( + delta=delta, + plan_lookup=ScenarioPlanLookup.from_plan(plan=None), + ) + + assert mapped.result_kind is ScenarioProgressResultKind.UNKNOWN + + +def test_progress_classifies_legacy_sequential_envelope_as_aggregate_parent() -> None: + delta = ScenarioAttackResultDelta( + attack_result_id=str(uuid.uuid4()), + objective="legacy aggregate", + atomic_attack_identifier=AtomicAttackIdentifier.build( + attack_identifier=ComponentIdentifier( + class_name="SequentialAttack", + class_module="pyrit.executor.attack.compound.sequential_attack", + ) + ), + outcome=AttackOutcome.FAILURE, + execution_time_ms=10, + timestamp=datetime(2025, 1, 1, tzinfo=UTC), + attribution_data={"parent_collection": "legacy aggregate"}, + ) + + mapped = ScenarioProgressReadModel._map_progress_delta( + delta=delta, + plan_lookup=ScenarioPlanLookup.from_plan(plan=None), + ) + + assert mapped.result_kind is ScenarioProgressResultKind.AGGREGATE_PARENT + + +def test_progress_classifies_planned_sequential_envelope_as_aggregate_parent() -> None: + plan = ScenarioRunPlan( + atomic_groups=[ + ScenarioRunPlanAtomicGroup( + id="sequential-group", + atomic_attack_name="sequential", + display_group="Sequential", + technique_eval_hash="eval", + seed_group_ids=["seed-1"], + group_kind=ScenarioRunPlanGroupKind.ATTACK, + ) + ], + seed_groups=[ + ScenarioRunPlanSeedGroup( + id="seed-1", + objective_sha256="objective-sha", + objective="objective", + ) + ], + ) + delta = ScenarioAttackResultDelta( + attack_result_id=str(uuid.uuid4()), + objective="objective", + objective_sha256="objective-sha", + atomic_attack_identifier=AtomicAttackIdentifier.build( + attack_identifier=ComponentIdentifier( + class_name="SequentialAttack", + class_module="pyrit.executor.attack.compound.sequential_attack", + ) + ), + outcome=AttackOutcome.FAILURE, + execution_time_ms=10, + timestamp=datetime(2025, 1, 1, tzinfo=UTC), + attribution_data={ + "parent_collection": "sequential", + "parent_eval_hash": "eval", + "seed_group_id": "seed-1", + }, + ) + + mapped = ScenarioProgressReadModel._map_progress_delta( + delta=delta, + plan_lookup=ScenarioPlanLookup.from_plan(plan=plan), + ) + + assert mapped.result_kind is ScenarioProgressResultKind.AGGREGATE_PARENT def test_get_progress_treats_invalid_persisted_plan_as_incomplete(mock_memory, caplog) -> None: @@ -3671,6 +4198,7 @@ def test_progress_summary_uses_latest_attempt_for_backend_owned_counts() -> None conversation_id="conversation-success", atomic_group_id="group", atomic_attack_name="attack", + result_kind=ScenarioProgressResultKind.ATTACK, seed_group_id="seed-1", outcome=AttackOutcome.SUCCESS, execution_time_ms=10, @@ -3681,6 +4209,7 @@ def test_progress_summary_uses_latest_attempt_for_backend_owned_counts() -> None conversation_id="conversation-error", atomic_group_id="group", atomic_attack_name="attack", + result_kind=ScenarioProgressResultKind.ATTACK, seed_group_id="seed-1", outcome=AttackOutcome.ERROR, execution_time_ms=10, @@ -3691,6 +4220,7 @@ def test_progress_summary_uses_latest_attempt_for_backend_owned_counts() -> None conversation_id="conversation-failure", atomic_group_id="group", atomic_attack_name="attack", + result_kind=ScenarioProgressResultKind.ATTACK, seed_group_id="seed-2", outcome=AttackOutcome.FAILURE, execution_time_ms=10, @@ -3783,6 +4313,50 @@ def test_progress_summary_uses_latest_attempt_for_backend_owned_counts() -> None assert summary.objective_scorer.metrics.f1_score == 0.94 +def test_progress_summary_counts_error_only_unit_as_completed() -> None: + plan = ScenarioRunPlan( + scenario_registry_name="test.scenario", + atomic_groups=[ + ScenarioRunPlanAtomicGroup( + id="group", + atomic_attack_name="attack", + display_group="Attack", + technique_eval_hash="eval", + seed_group_ids=["seed-1"], + ) + ], + seed_groups=[ + ScenarioRunPlanSeedGroup(id="seed-1", objective_sha256="sha-1", objective="one"), + ], + ) + result = ScenarioProgressResult( + attack_result_id=str(uuid.uuid4()), + conversation_id="conversation-error", + atomic_group_id="group", + atomic_attack_name="attack", + result_kind=ScenarioProgressResultKind.ATTACK, + seed_group_id="seed-1", + outcome=AttackOutcome.ERROR, + execution_time_ms=10, + timestamp=datetime(2025, 1, 1, tzinfo=UTC), + ) + + summary = ScenarioProgressReadModel._build_progress_summary( + plan=plan, + plan_complete=True, + results=[result], + active_group_ids=[], + terminal=True, + objective_scorer_identifier=None, + technique_details_by_group={}, + ) + + assert summary.overall.completed == 1 + assert summary.overall.succeeded == 0 + assert summary.overall.errors == 1 + assert summary.atomic_groups[0].status == "COMPLETED" + + def test_decode_progress_cursor_rejects_cross_run_cursor() -> None: delta = ScenarioAttackResultDelta( attack_result_id=str(uuid.uuid4()), diff --git a/tests/unit/executor/attack/compound/test_sequential_attack.py b/tests/unit/executor/attack/compound/test_sequential_attack.py index 9b98e1cc1a..692e9eb621 100644 --- a/tests/unit/executor/attack/compound/test_sequential_attack.py +++ b/tests/unit/executor/attack/compound/test_sequential_attack.py @@ -18,7 +18,14 @@ from pyrit.executor.attack.core.attack_executor import AttackExecutor, AttackExecutorResult from pyrit.executor.attack.core.attack_parameters import AttackParameters from pyrit.executor.attack.core.attack_strategy import AttackContext -from pyrit.models import AttackOutcome, AttackResult, AttackSeedGroup, ScoringExpectation, SeedObjective +from pyrit.models import ( + AttackOutcome, + AttackResult, + AttackSeedGroup, + ScoringExpectation, + SeedObjective, + TargetIdentifier, +) def _make_strategy(*, outcomes: list[AttackOutcome], name: str = "attack") -> MagicMock: @@ -79,7 +86,12 @@ async def _stub(self, *, child_attack, memory_labels, attribution=None, expectat @pytest.fixture def target() -> MagicMock: - return MagicMock(name="objective_target") + target = MagicMock(name="objective_target") + target.get_identifier.return_value = TargetIdentifier( + class_name="MockTarget", + class_module="tests.unit.executor.attack.compound.test_sequential_attack", + ) + return target @pytest.fixture @@ -574,6 +586,9 @@ async def test_returns_sequential_attack_result(self, target, seed_group): result = await compound._perform_async(context=_make_context()) assert isinstance(result, SequentialAttackResult) + attack_identifier = result.get_attack_strategy_identifier() + assert attack_identifier is not None + assert attack_identifier.class_name == "SequentialAttack" async def test_child_attack_result_ids_in_order(self, target, seed_group): a = _make_strategy(outcomes=[AttackOutcome.FAILURE], name="a") diff --git a/tests/unit/executor/attack/single_turn/test_many_shot_jailbreak.py b/tests/unit/executor/attack/single_turn/test_many_shot_jailbreak.py index f56fa46833..11865fd2c8 100644 --- a/tests/unit/executor/attack/single_turn/test_many_shot_jailbreak.py +++ b/tests/unit/executor/attack/single_turn/test_many_shot_jailbreak.py @@ -285,6 +285,7 @@ async def test_perform_attack_renders_template_correctly( assert len(basic_context.next_message.message_pieces) == 1 assert basic_context.next_message.message_pieces[0].original_value == rendered_prompt assert basic_context.next_message.message_pieces[0].original_value_data_type == "text" + assert basic_context.next_message.message_pieces[0].prompt_metadata == {} # Verify parent method was called mock_perform.assert_called_once_with(context=basic_context) diff --git a/tests/unit/memory/memory_interface/test_interface_scenario_history.py b/tests/unit/memory/memory_interface/test_interface_scenario_history.py index b201c24097..ff4bf68dbf 100644 --- a/tests/unit/memory/memory_interface/test_interface_scenario_history.py +++ b/tests/unit/memory/memory_interface/test_interface_scenario_history.py @@ -19,8 +19,11 @@ from pyrit.memory.memory_models import AttackResultEntry, ScenarioResultEntry from pyrit.models import ( SCENARIO_RUN_PLAN_METADATA_KEY, + SEQUENTIAL_ATTACK_CLASS_NAME, + AtomicAttackIdentifier, AttackOutcome, AttackResult, + ComponentIdentifier, ScenarioRunPlan, ScenarioRunPlanAtomicGroup, ScenarioRunPlanSeedGroup, @@ -347,6 +350,73 @@ def test_history_aggregate_uses_latest_attempt_outcome(sqlite_instance: MemoryIn assert aggregate.latest_attempt_timestamp == timestamp + timedelta(seconds=1) +def test_history_aggregate_excludes_sequential_envelopes(sqlite_instance: MemoryInterface) -> None: + """Only target-facing child attacks contribute to history aggregates.""" + timestamp = datetime(2026, 8, 7, tzinfo=UTC) + scenario = _make_scenario( + result_id=uuid.UUID(int=60), + timestamp=timestamp, + name="SequentialScenario", + state=ScenarioRunState.COMPLETED, + labels={}, + ) + sqlite_instance.add_scenario_results_to_memory(scenario_results=[scenario]) + sqlite_instance.add_attack_results_to_memory( + attack_results=[ + AttackResult( + attack_result_id=str(uuid.UUID(int=61)), + conversation_id="conversation-61", + objective="objective", + outcome=AttackOutcome.SUCCESS, + execution_time_ms=1, + timestamp=timestamp, + attribution_parent_id=str(scenario.id), + attribution_data={"parent_collection": "child-attack", "seed_group_id": "seed-1"}, + ), + AttackResult( + attack_result_id=str(uuid.UUID(int=62)), + conversation_id="", + objective="objective", + atomic_attack_identifier=AtomicAttackIdentifier.build( + attack_identifier=ComponentIdentifier( + class_name=SEQUENTIAL_ATTACK_CLASS_NAME, + class_module="pyrit.executor.attack.compound.sequential_attack", + ) + ), + outcome=AttackOutcome.ERROR, + execution_time_ms=1, + timestamp=timestamp + timedelta(seconds=1), + total_retries=2, + attribution_parent_id=str(scenario.id), + attribution_data={"parent_collection": "typed-envelope", "seed_group_id": "seed-2"}, + ), + AttackResult( + attack_result_id=str(uuid.UUID(int=63)), + conversation_id="", + objective="legacy objective", + outcome=AttackOutcome.ERROR, + execution_time_ms=1, + timestamp=timestamp + timedelta(seconds=2), + total_retries=3, + attribution_parent_id=str(scenario.id), + attribution_data={"parent_collection": "legacy-envelope", "seed_group_id": "seed-3"}, + ), + ] + ) + + aggregate = sqlite_instance.get_scenario_history_aggregates(scenario_result_ids=[str(scenario.id)])[ + str(scenario.id) + ] + + assert aggregate.unit_count == 1 + assert aggregate.completed_units == 1 + assert aggregate.successful_units == 1 + assert aggregate.error_attempts == 0 + assert aggregate.total_retries == 0 + assert aggregate.atomic_attack_names == ("child-attack",) + assert aggregate.latest_attempt_timestamp == timestamp + + def test_history_aggregates_ignore_unplanned_units_and_remap_hash_seeds( sqlite_instance: MemoryInterface, ) -> None: diff --git a/tests/unit/memory/memory_interface/test_interface_scenario_progress.py b/tests/unit/memory/memory_interface/test_interface_scenario_progress.py index aa34b20b7e..2415f868be 100644 --- a/tests/unit/memory/memory_interface/test_interface_scenario_progress.py +++ b/tests/unit/memory/memory_interface/test_interface_scenario_progress.py @@ -26,6 +26,7 @@ def _make_delta_result( attack_result_id: uuid.UUID, timestamp: datetime, objective: str, + labels: dict[str, str] | None = None, ) -> AttackResult: seed_group = AttackSeedGroup(seeds=[SeedObjective(value=objective)]) identifier = AtomicAttackIdentifier.build( @@ -42,6 +43,7 @@ def _make_delta_result( timestamp=timestamp, attribution_parent_id=scenario_result_id, attribution_data={"parent_collection": "attack", "parent_eval_hash": "eval"}, + labels=labels or {}, ) @@ -72,6 +74,7 @@ def test_scenario_progress_deltas_page_equal_timestamps_by_id( attack_result_id=second_id, timestamp=timestamp, objective="second", + labels={"_adaptive_attempt": "1", "_adaptive_technique_name": "Technique alpha"}, ), _make_delta_result( scenario_result_id=str(unrelated.id), @@ -115,9 +118,14 @@ def test_scenario_progress_deltas_page_equal_timestamps_by_id( assert has_more is True assert [row.attack_result_id for row in second_page] == [str(second_id)] assert second_has_more is False + assert second_page[0].conversation_id == f"conversation-{second_id}" assert second_page[0].atomic_attack_identifier is not None source_identifier = AtomicAttackIdentifier.from_component_identifier(rows[1].atomic_attack_identifier) assert second_page[0].atomic_attack_identifier.logical_seed_group_id == source_identifier.logical_seed_group_id + assert second_page[0].labels == { + "_adaptive_attempt": "1", + "_adaptive_technique_name": "Technique alpha", + } def test_scenario_progress_delta_uses_unknown_for_empty_scorer_identifier( diff --git a/tests/unit/memory/test_azure_sql_memory.py b/tests/unit/memory/test_azure_sql_memory.py index b0794edeed..21845080b9 100644 --- a/tests/unit/memory/test_azure_sql_memory.py +++ b/tests/unit/memory/test_azure_sql_memory.py @@ -16,7 +16,7 @@ from pyrit.memory import AzureSQLMemory, EmbeddingDataEntry, PromptMemoryEntry from pyrit.memory.memory_models import ScenarioResultEntry from pyrit.memory.storage.serializers import set_message_piece_sha256_async -from pyrit.models import Conversation, MessagePiece +from pyrit.models import SEQUENTIAL_ATTACK_CLASS_NAME, Conversation, MessagePiece from pyrit.prompt_target.text_target import TextTarget from unit.mocks import get_azure_sql_memory, get_sample_conversation_entries @@ -541,6 +541,28 @@ def test_scenario_plan_unit_subqueries_expand_plan_json_server_side(memory_inter assert str(scenario_result_id) in str(compiled.params) +def test_scenario_history_aggregate_filters_sequential_envelopes_server_side( + memory_interface: AzureSQLMemory, +) -> None: + """The SQL Server aggregate filters orchestration envelopes before grouping.""" + statement = memory_interface._build_scenario_history_aggregate_statement( + entry_ids=[uuid.uuid4()], + plan_entry_ids=[], + ) + + compiled = statement.compile(dialect=mssql.dialect()) + sql = str(compiled) + string_parameters = {value for value in compiled.params.values() if isinstance(value, str)} + + assert "history_attempts" in sql + assert "history_units" in sql + assert "history_ranked_units" in sql + assert "JSON_VALUE" in sql + assert "$.children.attack_technique.children.attack.class_name" in string_parameters + assert "$.children.attack.class_name" in string_parameters + assert SEQUENTIAL_ATTACK_CLASS_NAME in string_parameters + + @pytest.mark.parametrize( "case_sensitive, partial_match, expected_sql_fragment", [ diff --git a/tests/unit/scenario/core/test_scenario.py b/tests/unit/scenario/core/test_scenario.py index bbe799438c..0186de2605 100644 --- a/tests/unit/scenario/core/test_scenario.py +++ b/tests/unit/scenario/core/test_scenario.py @@ -17,6 +17,7 @@ AttackResult, AttackSeedGroup, ComponentIdentifier, + ScenarioRunPlanGroupKind, ScenarioRunState, SeedObjective, SeedPrompt, @@ -109,6 +110,7 @@ def mock_atomic_attacks(): run1._scenario_result_id = None run1.set_scenario_result_id = MagicMock(side_effect=lambda sid: setattr(run1, "_scenario_result_id", sid)) type(run1).objectives = PropertyMock(return_value=["objective1"]) + type(run1).progress_group_kind = PropertyMock(return_value=ScenarioRunPlanGroupKind.ATTACK) run2 = MagicMock(spec=AtomicAttack) run2.atomic_attack_name = "attack_run_2" @@ -117,6 +119,7 @@ def mock_atomic_attacks(): run2._scenario_result_id = None run2.set_scenario_result_id = MagicMock(side_effect=lambda sid: setattr(run2, "_scenario_result_id", sid)) type(run2).objectives = PropertyMock(return_value=["objective2"]) + type(run2).progress_group_kind = PropertyMock(return_value=ScenarioRunPlanGroupKind.ATTACK) run3 = MagicMock(spec=AtomicAttack) run3.atomic_attack_name = "attack_run_3" @@ -125,6 +128,7 @@ def mock_atomic_attacks(): run3._scenario_result_id = None run3.set_scenario_result_id = MagicMock(side_effect=lambda sid: setattr(run3, "_scenario_result_id", sid)) type(run3).objectives = PropertyMock(return_value=["objective3"]) + type(run3).progress_group_kind = PropertyMock(return_value=ScenarioRunPlanGroupKind.ATTACK) return [run1, run2, run3] @@ -305,6 +309,9 @@ async def test_initialize_async_populates_atomic_attacks(self, mock_atomic_attac assert stored.metadata["run_plan"]["version"] == 1 assert len(stored.metadata["run_plan"]["atomic_groups"]) == len(mock_atomic_attacks) assert stored.metadata["scheduler_managed_by"] == "test" + assert {group["group_kind"] for group in stored.metadata["run_plan"]["atomic_groups"]} == { + ScenarioRunPlanGroupKind.ATTACK.value + } async def test_initialize_async_deduplicates_logical_seed_groups_in_run_plan(self, mock_objective_target) -> None: duplicate_seed_groups = [ diff --git a/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py b/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py index f8b679c3b4..5ad5279b3b 100644 --- a/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py +++ b/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py @@ -11,15 +11,21 @@ import pytest -from pyrit.models import AttackSeedGroup, ScenarioDatasetSummary, SeedObjective -from pyrit.models.identifiers import ComponentIdentifier +from pyrit.models import ( + AttackSeedGroup, + ComponentIdentifier, + ScenarioDatasetSummary, + ScenarioRunPlanGroupKind, + SeedObjective, +) from pyrit.prompt_target import PromptTarget -from pyrit.registry.components.attack_technique_registry import AttackTechniqueRegistry -from pyrit.scenario.core.dataset_configuration import CompoundDatasetAttackConfiguration -from pyrit.scenario.core.scenario import BaselineAttackPolicy -from pyrit.scenario.scenarios.adaptive.dispatcher import AdaptiveTechniqueDispatcher -from pyrit.scenario.scenarios.adaptive.technique_identity import AdaptiveTechniqueIdentifier -from pyrit.scenario.scenarios.adaptive.text_adaptive import TextAdaptive +from pyrit.registry import AttackTechniqueRegistry +from pyrit.scenario import BaselineAttackPolicy, CompoundDatasetAttackConfiguration +from pyrit.scenario.scenarios.adaptive import ( + AdaptiveTechniqueDispatcher, + AdaptiveTechniqueIdentifier, + TextAdaptive, +) from pyrit.score import TrueFalseScorer _MOCK_MANY_SHOT_EXAMPLES = [{"question": f"q{i}", "answer": f"a{i}"} for i in range(100)] @@ -890,5 +896,9 @@ async def test_baseline_emitted_at_index_zero_by_default(self, mock_objective_ta plan = scenario._build_run_plan() planned_units = sum(len(group.seed_group_ids) for group in plan.atomic_groups) assert planned_units == 2 + assert [group.group_kind for group in plan.atomic_groups] == [ + ScenarioRunPlanGroupKind.DIRECT_BASELINE, + ScenarioRunPlanGroupKind.ADAPTIVE, + ] assert estimate.total_attack_count == planned_units assert [component.count for component in estimate.components] == [1, 1]