Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1109,6 +1109,13 @@ describe("App", () => {
conversation_id: "conv-main",
labels: {},
related_conversation_ids: ["conv-related"],
related_conversations: [
{
conversation_id: "conv-related",
conversation_type: "pruned",
description: "Previous main conversation",
},
],
});
renderApp("/attacks/ar-1/conversations/conv-related");

Expand All @@ -1132,6 +1139,27 @@ describe("App", () => {
);
});

it("does not activate a preparation conversation from a deep link", async () => {
mockGetAttack.mockResolvedValue({
attack_result_id: "ar-1",
conversation_id: "conv-main",
labels: {},
related_conversation_ids: ["conv-preparation"],
related_conversations: [
{
conversation_id: "conv-preparation",
conversation_type: "preparation",
description: "Simulated preparation",
},
],
});
renderApp("/attacks/ar-1/conversations/conv-preparation");

await waitFor(() =>
expect(screen.getByTestId("active-conversation-id")).toHaveTextContent("conv-main")
);
});

it("retains validated provenance while canonicalizing an unknown conversation route", async () => {
const scenarioResultId = "123e4567-e89b-12d3-a456-426614174000";
mockGetAttack.mockResolvedValue({
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,11 @@ function App() {
labels: attack.labels ?? {},
operator: attack.operator ?? null,
target: attack.target ?? null,
relatedConversationIds: attack.related_conversation_ids ?? [],
relatedConversationIds: attack.related_conversations
? attack.related_conversations
.filter((reference) => reference.conversation_type === 'pruned')
.map((reference) => reference.conversation_id)
: (attack.related_conversation_ids ?? []),
objective: attack.objective ?? '',
status: 'success',
})
Expand Down
24 changes: 23 additions & 1 deletion frontend/src/components/History/AttackTable.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { render, screen, fireEvent, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { FluentProvider, webLightTheme } from '@fluentui/react-components'
import AttackTable from './AttackTable'
Expand Down Expand Up @@ -259,6 +259,28 @@ describe('AttackTable', () => {
expect(screen.getByText('2')).toBeInTheDocument()
})

it('should count pruned and preparation conversations but not adversarial conversations', () => {
const attack: AttackSummary = {
...sampleAttacks[0],
attack_result_id: 'ar-related-types',
related_conversation_ids: ['pruned-1', 'preparation-1', 'adversarial-1'],
related_conversations: [
{ conversation_id: 'pruned-1', conversation_type: 'pruned' },
{ conversation_id: 'preparation-1', conversation_type: 'preparation' },
{ conversation_id: 'adversarial-1', conversation_type: 'adversarial' },
],
}

render(
<TestWrapper>
<AttackTable {...defaultProps} attacks={[attack]} />
</TestWrapper>
)

const row = screen.getByTestId('attack-row-ar-related-types')
expect(within(row).getByText('3')).toBeInTheDocument()
})

it('should show converter badges', () => {
render(
<TestWrapper>
Expand Down
11 changes: 10 additions & 1 deletion frontend/src/components/History/AttackTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ const OUTCOME_COLORS: Record<string, 'success' | 'danger' | 'informative' | 'war
undetermined: 'informative',
}

function getHistoryConversationCount(attack: AttackSummary): number {
const relatedCount = attack.related_conversations
? attack.related_conversations.filter(
reference => reference.conversation_type === 'pruned' || reference.conversation_type === 'preparation'
).length
: attack.related_conversation_ids.length
return relatedCount + 1
}

interface AttackTableProps {
attacks: AttackSummary[]
onOpenAttack: (attackResultId: string) => void
Expand Down Expand Up @@ -113,7 +122,7 @@ export default function AttackTable({ attacks, onOpenAttack, formatDate }: Attac
<Text size={200}>{attack.message_count}</Text>
</TableCell>
<TableCell>
<Text size={200}>{(attack.related_conversation_ids?.length ?? 0) + 1}</Text>
<Text size={200}>{getHistoryConversationCount(attack)}</Text>
</TableCell>
<TableCell>
{attack.converters.length > 0 ? (
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,11 @@ export interface AttackSummary {
related_conversation_ids: string[]
operator?: string | null
operation?: string | null
related_conversations?: Array<{
conversation_id: string
conversation_type: 'adversarial' | 'preparation' | 'pruned' | 'score' | 'converter'
description?: string | null
}>
labels: Record<string, string>
created_at: string
updated_at: string
Expand Down
6 changes: 6 additions & 0 deletions pyrit/backend/services/attack_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -619,6 +619,11 @@ async def update_main_conversation_async(
for ref in ar.related_conversations
if ref.conversation_id != target_conv_id and ref.conversation_type == ConversationType.ADVERSARIAL
]
updated_preparation = [
ref.conversation_id
for ref in ar.related_conversations
if ref.conversation_id != target_conv_id and ref.conversation_type == ConversationType.PREPARATION
Comment thread
richlundeen marked this conversation as resolved.
]
# The old main becomes a pruned related conversation so it remains
# visible in the GUI and fetchable via get_conversation_messages.
updated_pruned.append(ar.conversation_id)
Expand All @@ -631,6 +636,7 @@ async def update_main_conversation_async(
"conversation_id": target_conv_id,
"pruned_conversation_ids": updated_pruned if updated_pruned else None,
"adversarial_chat_conversation_ids": updated_adversarial if updated_adversarial else None,
"preparation_conversation_ids": updated_preparation if updated_preparation else None,
"timestamp": now,
},
)
Expand Down
16 changes: 12 additions & 4 deletions pyrit/executor/attack/core/attack_parameters.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, TypeVar

from pyrit.models import AttackSeedGroup, Message, SeedGroup
from pyrit.models import AttackSeedGroup, ConversationReference, Message, SeedGroup

if TYPE_CHECKING:
from pyrit.models import SeedUnion
Expand Down Expand Up @@ -46,6 +46,9 @@ class AttackParameters:
# seeds. Stamped onto the produced AttackResult.
targeted_harm_categories: list[str] = field(default_factory=list)

# Conversations used to prepare this attack before its context was created.
source_conversations: frozenset[ConversationReference] = field(default_factory=frozenset)

def __str__(self) -> str:
"""Return a nicely formatted string representation of the attack parameters."""
lines = [f"{self.__class__.__name__}:"]
Expand Down Expand Up @@ -112,7 +115,7 @@ async def from_seed_group_async(
"""
# Import here to avoid circular imports
from pyrit.executor.attack.multi_turn.simulated_conversation import (
generate_simulated_conversation_async,
_generate_simulated_conversation_result_async,
)

if not isinstance(seed_group, AttackSeedGroup):
Expand Down Expand Up @@ -146,6 +149,9 @@ async def from_seed_group_async(
if "targeted_harm_categories" in valid_fields:
params["targeted_harm_categories"] = list(seed_group.harm_categories)

if "source_conversations" in valid_fields:
params["source_conversations"] = frozenset()

# Determine which group to use for extracting prepended_conversation/next_message
extraction_group: SeedGroup = seed_group

Expand All @@ -159,8 +165,7 @@ async def from_seed_group_async(
if objective_scorer is None:
raise ValueError("objective_scorer is required when seed_group has a simulated conversation config")

# Generate the simulated conversation - returns list[SeedPrompt]
simulated_prompts = await generate_simulated_conversation_async(
simulated_result = await _generate_simulated_conversation_result_async(
objective=seed_group.objective.value,
adversarial_chat=adversarial_chat,
objective_scorer=objective_scorer,
Expand All @@ -170,6 +175,9 @@ async def from_seed_group_async(
simulated_target_system_prompt_path=simulated_conversation_config.simulated_target_system_prompt_path,
next_message_system_prompt_path=simulated_conversation_config.next_message_system_prompt_path,
)
simulated_prompts = simulated_result.seed_prompts
if "source_conversations" in valid_fields:
params["source_conversations"] = frozenset(simulated_result.related_conversations)

# Merge simulated prompts with existing static prompts from the seed_group
all_prompts: list[SeedUnion] = [*seed_group.prompts, *simulated_prompts]
Expand Down
21 changes: 20 additions & 1 deletion pyrit/executor/attack/core/attack_strategy.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ class AttackContext(StrategyContext, ABC, Generic[AttackParamsT]):
_prepended_conversation_override: list[Message] | None = None
_memory_labels_override: dict[str, str] | None = None
_error_result_persistence_error: Exception | None = field(default=None, init=False, repr=False)
_persist_attack_result: bool = field(default=True, init=False, repr=False, compare=False)
_objective_target_conversation_lifecycle: _ObjectiveTargetConversationLifecycle | None = field(
default=None,
init=False,
Expand All @@ -204,6 +205,10 @@ class AttackContext(StrategyContext, ABC, Generic[AttackParamsT]):
# for ad-hoc/direct attack execution outside any orchestrator.
_attribution: AttackResultAttribution | None = None

def __post_init__(self) -> None:
"""Copy preparation-time conversation references into mutable execution state."""
self.related_conversations.update(getattr(self.params, "source_conversations", ()))

# Convenience properties that delegate to params or overrides
@property
def objective(self) -> str:
Expand Down Expand Up @@ -374,6 +379,7 @@ async def _on_post_execute_async(
# Stamp attribution onto the result before persistence so the
# AttackResultEntry row records its lineage. Outside an orchestrator
# _attribution is None and both attribution fields stay None.
event_data.result.related_conversations.update(event_data.context.related_conversations)
self._apply_attribution(context=event_data.context, result=event_data.result)
self._apply_targeted_harm_categories(context=event_data.context, result=event_data.result)

Expand Down Expand Up @@ -484,6 +490,9 @@ async def _on_error_async(
context = event_data.context
if not error or not context:
return
if not context._persist_attack_result:
self._logger.error(f"Attack failed with {type(error).__name__}: {error}")
return

# Collect retry events (visible via inherited ContextVar copy)
collector = get_retry_collector()
Expand Down Expand Up @@ -824,7 +833,8 @@ async def execute_with_context_async(self, *, context: AttackStrategyContextT) -
finally:
context._objective_target_conversation_lifecycle = None

self._default_event_handler._persist_result(result=result)
if context._persist_attack_result:
self._default_event_handler._persist_result(result=result)
return result

@overload
Expand All @@ -835,6 +845,7 @@ async def execute_async(
next_message: Message | None = None,
prepended_conversation: list[Message] | None = None,
memory_labels: dict[str, str] | None = None,
persist_attack_result: bool = True,
**kwargs: Any,
) -> AttackStrategyResultT: ...

Expand All @@ -861,14 +872,21 @@ async def execute_async(
next_message (Message | None): Message to send to the target.
prepended_conversation (list[Message] | None): Conversation to prepend.
memory_labels (dict[str, str] | None): Memory labels for the attack context.
persist_attack_result (bool): Whether to persist the completed or error attack result.
Messages produced during execution are persisted independently.
**kwargs: Additional context-specific parameters (conversation_id, metadata, etc.).

Returns:
AttackStrategyResultT: The result of the attack execution.

Raises:
TypeError: If ``persist_attack_result`` is not a boolean.
ValueError: If required parameters are missing or if unsupported parameters are provided.
"""
persist_attack_result = kwargs.pop("persist_attack_result", True)
if not isinstance(persist_attack_result, bool):
raise TypeError("persist_attack_result must be a bool")

# Get valid field names for params and context
params_fields = {f.name for f in dataclasses.fields(self._params_type)}
context_fields = {f.name for f in dataclasses.fields(self._context_type)} - {"params"}
Expand Down Expand Up @@ -907,5 +925,6 @@ async def execute_async(
# Note: We use cast here because the type checker doesn't know that _context_type
# (which is AttackContext or a subclass) always accepts 'params' as a keyword argument.
context = self._context_type(params=params, **context_kwargs)
context._persist_attack_result = persist_attack_result

return await self.execute_with_context_async(context=context)
Loading
Loading