Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
4 changes: 2 additions & 2 deletions doc/code/framework.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,8 +314,8 @@ The below talks about responsibilities of most modules in the PyRIT library

**Responsibility**: Reshape prompts and conversations so components and targets can interoperate. There are two distinct modules:

- **`prompt_normalizer`** applies converters and dispatches individual prompts to a `PromptTarget` (handling batching and memory persistence). It is the single component that writes each request and response to memory; targets never persist on their own. `NormalizerRequest` and `ConverterConfiguration` describe what to send and which converters to apply.
- **`message_normalizer`** reshapes multi-message conversation payloads into the structure a given model expects — for example, handling system-message behavior (keep / squash / ignore), history squashing, and tokenizer chat templates.
- **`prompt_normalizer`** applies converters and dispatches individual prompts to a `PromptTarget` (handling batching and memory persistence). It is the single component that writes each request and response to memory; targets never persist on their own. `NormalizerRequest` and `ConverterConfiguration` describe what to send and which converters to apply. Prepended history remains role-structured in memory; when a target cannot edit history, the prompt normalizer passes its formatter to the target for the first live send.
- **`message_normalizer`** reshapes multi-message conversation payloads into the structure a given model expects — for example, handling system-message behavior (keep / squash / ignore), history squashing, prepended-history adaptation, and tokenizer chat templates. These target-specific views are ephemeral and do not replace the logical conversation in memory.

## [Output](./output/0_output)

Expand Down
190 changes: 78 additions & 112 deletions pyrit/executor/attack/component/conversation_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
PrependedConversationConfig,
)
from pyrit.memory import CentralMemory
from pyrit.message_normalizer import ConversationContextNormalizer, GenericSystemSquashNormalizer
from pyrit.message_normalizer import ConversationContextNormalizer
from pyrit.models import (
ChatMessageRole,
ComponentIdentifier,
Expand Down Expand Up @@ -275,17 +275,12 @@ async def initialize_context_async(

This is the primary method for setting up an attack context. It:
1. Merges memory_labels from attack strategy with context labels
2. Processes prepended_conversation based on target type and config
2. Persists prepended_conversation structurally with role-scoped converters
3. Updates context.executed_turns for multi-turn attacks
4. Sets context.next_message if there's an unanswered user message

For chat-capable PromptTarget:
- Adds prepended messages to memory with simulated_assistant role
- All messages get new UUIDs

For non-chat PromptTarget:
- Normalizes the prepended conversation to a string and prepends it to
``context.next_message`` (using ``config.message_normalizer`` when provided).
For all PromptTarget types, prepended messages are added to memory with
simulated_assistant roles and new UUIDs. Targets without editable history receive
a one-shot formatter that combines this structured history with the first live request.

Args:
context: The attack context to initialize.
Expand All @@ -300,8 +295,7 @@ async def initialize_context_async(
ConversationState with turn_count and last_assistant_message_scores.

Raises:
ValueError: If conversation_id is empty, or if prepended_conversation
requires a chat-capable PromptTarget but target is not one.
ValueError: If conversation_id is empty.
"""
if not conversation_id:
raise ValueError("conversation_id cannot be empty")
Expand All @@ -316,96 +310,17 @@ async def initialize_context_async(
logger.debug(f"No prepended conversation for context initialization: {conversation_id}")
return state

# Targets that don't natively support editable history cannot consume a
# prepended multi-message conversation as-is — route them to the
# single-string fallback path via capability-based routing.
is_chat_target = target.configuration.includes(capability=CapabilityName.EDITABLE_HISTORY)
if not is_chat_target:
return await self._handle_non_chat_target_async(
context=context,
prepended_conversation=prepended_conversation,
config=prepended_conversation_config,
)

# Process prepended conversation for objective target
return await self._process_prepended_for_chat_target_async(
return await self._process_prepended_conversation_async(
context=context,
prepended_conversation=prepended_conversation,
conversation_id=conversation_id,
request_converters=request_converters,
prepended_conversation_config=prepended_conversation_config,
max_turns=max_turns,
target_identifier=target.get_identifier(),
target=target,
)

async def _handle_non_chat_target_async(
self,
*,
context: AttackContext[Any],
prepended_conversation: list[Message],
config: PrependedConversationConfig | None,
) -> ConversationState:
"""
Handle prepended conversation for non-chat targets.

Args:
context: The attack context.
prepended_conversation: Messages to prepend.
config: Configuration for non-chat target behavior.

Returns:
Empty ConversationState (non-chat targets don't track turns).
"""
if config is None:
config = PrependedConversationConfig()

normalizer = config.get_message_normalizer()
messages_to_normalize = prepended_conversation
if isinstance(normalizer, ConversationContextNormalizer):
messages_to_normalize = await GenericSystemSquashNormalizer().normalize_async(prepended_conversation)

normalized_context = await normalizer.normalize_string_async(messages_to_normalize)

next_message = context.next_message
if next_message is None:
next_message = Message.from_prompt(prompt=context.objective, role="user")
context.next_message = next_message

if normalized_context:
# Find an existing text piece to prepend to
text_piece = None
for piece in next_message.message_pieces:
if piece.original_value_data_type == "text":
text_piece = piece
break

if text_piece:
# Prepend context to the existing text piece
context_prefix = f"{normalized_context}\n\n"
if text_piece.original_value != normalized_context and not text_piece.original_value.startswith(
context_prefix
):
text_piece.original_value = f"{context_prefix}{text_piece.original_value}"
if text_piece.converted_value != normalized_context and not text_piece.converted_value.startswith(
context_prefix
):
text_piece.converted_value = f"{context_prefix}{text_piece.converted_value}"
else:
# No text piece found (multimodal message), add a new text piece at the beginning
context_piece = MessagePiece(
id=uuid.uuid4(),
role="user",
original_value=normalized_context,
converted_value=normalized_context,
original_value_data_type="text",
converted_value_data_type="text",
)
# Create a new message with the context piece prepended
context.next_message = Message(message_pieces=[context_piece] + list(next_message.message_pieces))

logger.debug(f"Normalized prepended conversation for non-chat target: {len(normalized_context)} characters")
return ConversationState()

async def add_prepended_conversation_to_memory_async(
self,
*,
Expand All @@ -415,9 +330,10 @@ async def add_prepended_conversation_to_memory_async(
prepended_conversation_config: PrependedConversationConfig | None = None,
max_turns: int | None = None,
target_identifier: ComponentIdentifier | None = None,
target: PromptTarget | None = None,
) -> int:
"""
Add prepended conversation messages to memory for a chat target.
Add prepended conversation messages to memory for a target.

This is a lower-level method that handles adding messages to memory without
modifying any attack context state. It can be called directly by attacks
Expand All @@ -437,6 +353,8 @@ async def add_prepended_conversation_to_memory_async(
max_turns: If provided, validates that turn count doesn't exceed this limit.
target_identifier (ComponentIdentifier | None): The target the conversation is held
with, if known. Recorded once per conversation.
target (PromptTarget | None): Target that will receive the first live request. When it
lacks editable history, its target-normalization path receives the configured formatter.

Returns:
The number of turns (assistant messages) added.
Expand All @@ -449,13 +367,19 @@ async def add_prepended_conversation_to_memory_async(
if not valid_messages:
return 0

if target and target_identifier is None:
target_identifier = target.get_identifier()

self._memory.add_conversation_to_memory(
conversation=Conversation(conversation_id=conversation_id, target_identifier=target_identifier)
)

# Get roles that should have converters applied
apply_to_roles = (
prepended_conversation_config.apply_converters_to_roles if prepended_conversation_config else None
# Assistant history represents simulated target output, so the absent-config
# path must use the same safe role default as an explicit default config.
config = prepended_conversation_config or PrependedConversationConfig()
apply_to_roles = config.apply_converters_to_roles
requires_prepended_adaptation = bool(
target and not target.configuration.includes(capability=CapabilityName.EDITABLE_HISTORY)
)

turn_count = 0
Expand Down Expand Up @@ -485,14 +409,25 @@ async def add_prepended_conversation_to_memory_async(
request_converters=request_converters,
apply_to_roles=apply_to_roles,
)
if requires_prepended_adaptation:
self._validate_flattenable_converter_output(
source_message=message,
converted_message=message_copy,
)

# Add to memory
self._memory.add_message_to_memory(request=message_copy)
logger.debug(f"Added prepended message {i + 1}/{len(valid_messages)} to memory")

if requires_prepended_adaptation:
self._prompt_normalizer.register_prepended_conversation_normalizer(
conversation_id=conversation_id,
message_normalizer=config.get_message_normalizer(),
)

return turn_count

async def _process_prepended_for_chat_target_async(
async def _process_prepended_conversation_async(
self,
*,
context: AttackContext[Any],
Expand All @@ -502,9 +437,10 @@ async def _process_prepended_for_chat_target_async(
prepended_conversation_config: PrependedConversationConfig | None,
max_turns: int | None,
target_identifier: ComponentIdentifier | None = None,
target: PromptTarget,
) -> ConversationState:
"""
Process prepended conversation for a chat target.
Process prepended conversation for a target.

Adds messages to memory with:
- New UUIDs for all pieces
Expand All @@ -520,6 +456,7 @@ async def _process_prepended_for_chat_target_async(
max_turns: Maximum turns for validation.
target_identifier (ComponentIdentifier | None): The objective target the
conversation is held with, if known.
target: The objective target that will receive the conversation.

Returns:
ConversationState with turn_count and scores.
Expand All @@ -540,6 +477,7 @@ async def _process_prepended_for_chat_target_async(
prepended_conversation_config=prepended_conversation_config,
max_turns=max_turns,
target_identifier=target_identifier,
target=target,
)

# Update context for multi-turn attacks to reflect prepended_conversation
Expand Down Expand Up @@ -570,29 +508,57 @@ async def _process_prepended_for_chat_target_async(

return state

@staticmethod
def _validate_flattenable_converter_output(
*,
source_message: Message,
converted_message: Message,
) -> None:
"""
Reject non-text output produced by this prepended conversion pass.

Raises:
ValueError: If an applied converter produced non-text prepended history.
"""
output_types = {
converted_piece.converted_value_data_type
for source_piece, converted_piece in zip(
source_message.message_pieces,
converted_message.message_pieces,
strict=True,
)
if len(converted_piece.converter_identifiers) > len(source_piece.converter_identifiers)
and converted_piece.converted_value_data_type != "text"
}
if output_types:
raise ValueError(
"Cannot flatten prepended conversation for a target without editable history after "
f"request converters produced non-text output types {sorted(output_types)}. Prepended "
"conversion must produce text."
)

async def _apply_converters_async(
self,
*,
message: Message,
request_converters: list[ConverterConfiguration],
apply_to_roles: list[ChatMessageRole] | None,
apply_to_roles: list[ChatMessageRole],
) -> None:
"""
Apply converters to message pieces.

Args:
message: The message containing pieces to convert.
request_converters: Converter configurations to apply.
apply_to_roles: If provided, only apply to pieces with these roles.
If None, apply to all roles.
apply_to_roles: Only apply to pieces with these roles.
"""
for piece in message.message_pieces:
# Filter by role if specified
if apply_to_roles is not None and piece.api_role not in apply_to_roles:
continue

temp_message = Message(message_pieces=[piece])
await self._prompt_normalizer.convert_values_async(
message=temp_message,
converter_configurations=request_converters,
)
if message.api_role not in apply_to_roles:
return

# Apply to the complete message so ConverterConfiguration.indexes_to_apply remains relative
# to the original piece list. Converting one temporary piece at a time would reset every
# selected piece to index zero.
await self._prompt_normalizer.convert_values_async(
message=message,
converter_configurations=request_converters,
)
26 changes: 15 additions & 11 deletions pyrit/executor/attack/component/prepended_conversation_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@
from __future__ import annotations

from dataclasses import dataclass, field
from typing import get_args
from typing import TYPE_CHECKING

from pyrit.message_normalizer import (
ConversationContextNormalizer,
MessageStringNormalizer,
)
from pyrit.models import ChatMessageRole

if TYPE_CHECKING:
from pyrit.models import ChatMessageRole


@dataclass
Expand All @@ -21,21 +23,23 @@ class PrependedConversationConfig:

This class provides control over:
- Which message roles should have request converters applied
- How to normalize conversation history for non-chat objective targets
- How targets without editable history format prepended messages on the first live send

Non-chat objective targets always normalize the prepended conversation into the
first turn (via ``message_normalizer``; default: ConversationContextNormalizer).
Prepended messages remain role-structured in memory. Request converters are applied to
configured roles before a target without editable history renders that history into the
first live request (via ``message_normalizer``; default: ConversationContextNormalizer).
Those converters must produce text because string normalization cannot preserve converted
image, audio, or other non-text output.
"""

# Roles for which request converters should be applied to prepended messages.
# By default, converters are applied to all roles.
# Example: ["user"] to apply converters only to user messages.
apply_converters_to_roles: list[ChatMessageRole] = field(default_factory=lambda: list(get_args(ChatMessageRole)))
# Request converters default to prepended user messages only. Assistant history is
# simulated target output and must be explicitly opted in with ["assistant"].
apply_converters_to_roles: list[ChatMessageRole] = field(default_factory=lambda: ["user"])

# Optional normalizer to format conversation history into a single text block.
# Must implement MessageStringNormalizer (e.g., TokenizerTemplateNormalizer or ConversationContextNormalizer).
# When None and normalization is needed (e.g., for non-chat targets), a default
# ConversationContextNormalizer is used that produces "Turn N: User/Assistant" format.
# When None and adaptation is needed, a default ConversationContextNormalizer is used
# that produces "Turn N: User/Assistant" format.
message_normalizer: MessageStringNormalizer | None = None

def get_message_normalizer(self) -> MessageStringNormalizer:
Expand Down
2 changes: 1 addition & 1 deletion pyrit/executor/attack/multi_turn/crescendo.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ def __init__(
max_turns (int): Maximum number of turns allowed.
prepended_conversation_config (PrependedConversationConfiguration | None):
Configuration for how to process prepended conversations. Controls converter
application by role, message normalization, and non-chat target behavior.
application by role and first-send formatting for targets without editable history.

Raises:
ValueError: If objective_target does not natively support editable history.
Expand Down
2 changes: 1 addition & 1 deletion pyrit/executor/attack/multi_turn/red_teaming.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ def __init__(
# Initialize utilities
self._prompt_normalizer = prompt_normalizer or PromptNormalizer()

self._conversation_manager = ConversationManager()
self._conversation_manager = ConversationManager(prompt_normalizer=self._prompt_normalizer)

# set the maximum number of turns for the attack
if max_turns <= 0:
Expand Down
Loading