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
172 changes: 139 additions & 33 deletions doc/code/executor/3_attack_configuration.ipynb

Large diffs are not rendered by default.

38 changes: 33 additions & 5 deletions doc/code/executor/3_attack_configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
# |---|---|
# | `objective` | What you are trying to get the **objective target** (the system under test) to do. Drives scoring and multi-turn adversarial prompts. |
# | `memory_labels` | A `dict[str, str]` tagged onto every prompt/response, so you can filter this run later in memory. |
# | `prepended_conversation` | A list of `Message`s to seed the conversation before the attack's own turns (system prompt, prior history). |
# | `prepended_conversation` | A list of `Message`s to seed the conversation before the attack's own turns. This is also where the objective target's **system prompt** goes — `Message.from_system_prompt(...)` builds one (see below). |
# | `next_message` | The exact next message to send, instead of letting the attack derive it from the objective. Useful for multimodal or pre-built seeds. |
#
# Construction-time configuration objects — **adversarial**, **scoring**, and **converter** — are
Expand All @@ -36,6 +36,7 @@
PromptSendingAttack,
SingleTurnAttackContext,
)
from pyrit.models import Message
from pyrit.output import output_attack_async
from pyrit.prompt_target import TextTarget
from pyrit.setup import IN_MEMORY, initialize_pyrit_async
Expand All @@ -59,15 +60,42 @@
)
await output_attack_async(result)

# %% [markdown]
# ## Setting a system prompt
#
# The objective target's system prompt is just a `system`-role message at the front of the
# conversation, so you set it through `prepended_conversation`. `Message.from_system_prompt(...)`
# builds that message:
#
# ```python
# prepended_conversation=[Message.from_system_prompt("...")]
# ```
#
# Because `prepended_conversation` is a list, targets that accept more than one system message just
# take more than one entry. `Message.from_system_prompts(...)` is a shorthand that builds the list for
# you — `Message.from_system_prompts("Policy.", "Persona.")` is the same as
# `[Message.from_system_prompt("Policy."), Message.from_system_prompt("Persona.")]` — and you can
# interleave `user` / `assistant` turns too (next section).

# %%
result = await attack.execute_async( # type: ignore
objective="Explain how a saponification reaction works",
prepended_conversation=[
Message.from_system_prompt("You are a helpful chemistry tutor who explains concepts step by step.")
],
)
await output_attack_async(result)

# %% [markdown]
# ## Prepended conversations
#
# A prepended conversation seeds the exchange before the attack adds its own turn. The most common
# use is setting a system prompt, but you can prepend any sequence of `system` / `user` / `assistant`
# turns — for example, to resume a prior conversation or to plant an agreeable assistant reply.
# A system prompt is the simplest prepended conversation. The general form seeds a full
# `system` / `user` / `assistant` history before the attack adds its own turn — for example, to
# resume a prior conversation or to plant an agreeable assistant reply. It is just a list of
# `Message`s, so the system prompt and any seed turns compose freely.

# %%
from pyrit.models import Message, MessagePiece
from pyrit.models import MessagePiece

prepended_conversation = [
Message.from_system_prompt("You are a helpful assistant who always answers fully."),
Expand Down
4 changes: 2 additions & 2 deletions doc/code/targets/11_message_normalizer.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@
"source": [
"## GenericSystemSquashNormalizer\n",
"\n",
"Some models don't support system messages. The `GenericSystemSquashNormalizer` merges the system message into the first user message using a standardized instruction format.\n",
"Some models don't support system messages. The `GenericSystemSquashNormalizer` merges system messages into the user messages that follow them using a standardized instruction format.\n",
"\n",
"The format is:\n",
"```\n",
Expand Down Expand Up @@ -359,7 +359,7 @@
"The `TokenizerTemplateNormalizer` supports different strategies for handling system messages:\n",
"\n",
"- **`keep`**: Pass system messages as-is (default)\n",
"- **`squash`**: Merge system into first user message using `GenericSystemSquashNormalizer`\n",
"- **`squash`**: Merge system messages into the following user message using `GenericSystemSquashNormalizer`\n",
"- **`ignore`**: Drop system messages entirely\n",
"- **`developer`**: Change system role to developer role (for newer OpenAI models)"
]
Expand Down
4 changes: 2 additions & 2 deletions doc/code/targets/11_message_normalizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@
# %% [markdown]
# ## GenericSystemSquashNormalizer
#
# Some models don't support system messages. The `GenericSystemSquashNormalizer` merges the system message into the first user message using a standardized instruction format.
# Some models don't support system messages. The `GenericSystemSquashNormalizer` merges system messages into the user messages that follow them using a standardized instruction format.
#
# The format is:
# ```
Expand Down Expand Up @@ -171,7 +171,7 @@
# The `TokenizerTemplateNormalizer` supports different strategies for handling system messages:
#
# - **`keep`**: Pass system messages as-is (default)
# - **`squash`**: Merge system into first user message using `GenericSystemSquashNormalizer`
# - **`squash`**: Merge system messages into the following user message using `GenericSystemSquashNormalizer`
# - **`ignore`**: Drop system messages entirely
# - **`developer`**: Change system role to developer role (for newer OpenAI models)

Expand Down
37 changes: 23 additions & 14 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
from pyrit.message_normalizer import ConversationContextNormalizer, GenericSystemSquashNormalizer
from pyrit.models import (
ChatMessageRole,
ComponentIdentifier,
Expand Down Expand Up @@ -359,23 +359,37 @@ async def _handle_non_chat_target_async(
if config is None:
config = PrependedConversationConfig()

# Normalize conversation to string
normalizer = config.get_message_normalizer()
normalized_context = await normalizer.normalize_string_async(prepended_conversation)
messages_to_normalize = prepended_conversation
if isinstance(normalizer, ConversationContextNormalizer):
messages_to_normalize = await GenericSystemSquashNormalizer().normalize_async(prepended_conversation)

# Prepend to next_message if it exists, otherwise create new message
if context.next_message is not None:
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 context.next_message.message_pieces:
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
text_piece.original_value = f"{normalized_context}\n\n{text_piece.original_value}"
text_piece.converted_value = f"{normalized_context}\n\n{text_piece.converted_value}"
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(
Expand All @@ -387,12 +401,7 @@ async def _handle_non_chat_target_async(
converted_value_data_type="text",
)
# Create a new message with the context piece prepended
context.next_message = Message(
message_pieces=[context_piece] + list(context.next_message.message_pieces)
)
else:
# Create new message with just the context
context.next_message = Message.from_prompt(prompt=normalized_context, role="user")
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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,6 @@ class SingleTurnAttackContext(AttackContext[AttackParamsT]):
# Unique identifier of the main conversation between the attacker and model
conversation_id: str = field(default_factory=lambda: str(uuid.uuid4()))

# System prompt for chat-based targets
system_prompt: str | None = None
Comment thread
adrian-gavrila marked this conversation as resolved.

# Arbitrary metadata that downstream attacks or scorers may attach
metadata: dict[str, str | int] | None = None

Expand Down
2 changes: 1 addition & 1 deletion pyrit/message_normalizer/chat_message_normalizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ class ChatMessageNormalizer(MessageListNormalizer[ChatMessage], MessageStringNor
Defaults to False for backward compatibility.
system_message_behavior: How to handle system messages before conversion.
- "keep": Keep system messages as-is (default)
- "squash": Merge system message into first user message
- "squash": Merge system messages into the following user message
- "ignore": Drop system messages entirely
"""

Expand Down
108 changes: 78 additions & 30 deletions pyrit/message_normalizer/generic_system_squash.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@

class GenericSystemSquashNormalizer(MessageListNormalizer[Message]):
"""
Normalizer that combines the first system message with the first user message using generic instruction tags.
Normalizer that combines system messages with the following user message using generic instruction tags.
"""

async def normalize_async(self, messages: list[Message]) -> list[Message]:
"""
Return messages with the first system message combined into the first user message.
Return messages with each system message combined into the following user message.

The format uses generic instruction tags:
### Instructions ###
Expand All @@ -25,43 +25,94 @@ async def normalize_async(self, messages: list[Message]) -> list[Message]:
messages: The list of messages to normalize.

Returns:
A Message with the system message squashed into the first user message.
Messages with system instructions squashed into the following user message.

Raises:
ValueError: If the messages list is empty.
"""
if not messages:
raise ValueError("Messages list cannot be empty")

# Check if first message is a system message
first_piece = messages[0].get_piece()
if first_piece.api_role != "system":
# No system message to squash, return messages unchanged
system_messages = [message for message in messages if message.api_role == "system"]
if not system_messages:
return list(messages)

if len(messages) == 1:
# Only system message, convert to user message.
return [
build_squashed_user_message(
new_message_content=first_piece.converted_value, source_messages=messages[:1]
system_messages_by_user_index: dict[int, list[Message]] = {}
attached_system_indexes: set[int] = set()
next_user_index: int | None = None
for index in range(len(messages) - 1, -1, -1):
message = messages[index]
if message.api_role == "user":
next_user_index = index
elif message.api_role == "system" and next_user_index is not None:
system_messages_by_user_index.setdefault(next_user_index, []).insert(0, message)
attached_system_indexes.add(index)

result: list[Message] = []
index = 0
while index < len(messages):
message = messages[index]
if message.api_role == "system":
if index in attached_system_indexes:
index += 1
continue

orphan_system_messages = [message]
index += 1
while index < len(messages) and messages[index].api_role == "system":
orphan_system_messages.append(messages[index])
index += 1
result.append(
build_squashed_user_message(
new_message_content=self._get_system_content(orphan_system_messages),
source_messages=orphan_system_messages,
)
)
]
continue

user_message_index = next(
(i for i, message in enumerate(messages[1:], start=1) if message.api_role == "user"),
-1,
)
if user_message_index == -1:
# Preserve the instruction content without rewriting non-user messages.
return [
build_squashed_user_message(
new_message_content=first_piece.converted_value, source_messages=messages[:1]
if message.api_role == "user" and index in system_messages_by_user_index:
result.append(
self._squash_system_messages_into_user(
system_messages=system_messages_by_user_index[index],
user_message=message,
)
)
] + list(messages[1:])
else:
result.append(message)
index += 1

return result

# Combine system with the first user message, preserving non-text pieces (e.g. images) and their order.
system_content = first_piece.converted_value
user_message = messages[user_message_index]
@staticmethod
def _get_system_content(system_messages: list[Message]) -> str:
"""
Combine system-message pieces in message order.

Args:
system_messages: The system messages to combine.

Returns:
The combined system-message content.
"""
return "\n\n".join(piece.converted_value for message in system_messages for piece in message.message_pieces)

def _squash_system_messages_into_user(
self,
*,
system_messages: list[Message],
user_message: Message,
) -> Message:
"""
Merge system instructions into a user message while preserving its pieces.

Args:
system_messages: The system messages to merge.
user_message: The following user message.

Returns:
The user message with the system instructions applied.
"""
system_content = self._get_system_content(system_messages)
# Propagate prompt_metadata from the user message's first piece so downstream normalizers
# (e.g. JsonSchemaNormalizer) still see request-level metadata after squashing.
propagated_metadata = dict(user_message.message_pieces[0].prompt_metadata)
Expand Down Expand Up @@ -96,7 +147,4 @@ async def normalize_async(self, messages: list[Message]) -> list[Message]:
+ list(user_message.message_pieces[text_piece_index + 1 :])
)

squashed_message = Message(message_pieces=squashed_pieces)

# Remove system (index 0), replace the first user message with the squashed version, preserve all others
return list(messages[1:user_message_index]) + [squashed_message] + list(messages[user_message_index + 1 :])
return Message(message_pieces=squashed_pieces)
4 changes: 2 additions & 2 deletions pyrit/message_normalizer/message_normalizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"""
How to handle system messages in models with varying support:
- "keep": Keep system messages as-is (default for most models)
- "squash": Merge system message into first user message
- "squash": Merge system messages into the following user message
- "ignore": Drop system messages entirely
"""

Expand Down Expand Up @@ -90,7 +90,7 @@ async def apply_system_message_behavior_async(
messages: The list of Message objects to process.
behavior: How to handle system messages:
- "keep": Return messages unchanged
- "squash": Merge system into first user message
- "squash": Merge system messages into the following user message
- "ignore": Remove system messages

Returns:
Expand Down
6 changes: 3 additions & 3 deletions pyrit/message_normalizer/tokenizer_template_normalizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"""
Extended system message behavior for tokenizer templates:
- "keep": Keep system messages as-is (default for most models)
- "squash": Merge system message into first user message
- "squash": Merge system messages into the following user message
- "ignore": Drop system messages entirely
- "developer": Change system role to developer role (for newer OpenAI models)
"""
Expand Down Expand Up @@ -101,7 +101,7 @@ def __init__(
tokenizer: A Hugging Face tokenizer with a chat template.
system_message_behavior: How to handle system messages. Options:
- "keep": Keep system messages as-is (default)
- "squash": Merge system into first user message
- "squash": Merge system messages into the following user message
- "ignore": Drop system messages entirely
- "developer": Change system role to developer role
"""
Expand Down Expand Up @@ -195,7 +195,7 @@ async def normalize_string_async(self, messages: list[Message]) -> str:

Handles system messages based on the configured system_message_behavior:
- "keep": Pass system messages as-is
- "squash": Merge system into first user message
- "squash": Merge system messages into the following user message
- "ignore": Drop system messages entirely
- "developer": Change system role to developer role

Expand Down
14 changes: 14 additions & 0 deletions pyrit/models/messages/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,20 @@ def from_system_prompt(cls, system_prompt: str) -> Message:
"""
return cls.from_prompt(prompt=system_prompt, role="system")

@classmethod
def from_system_prompts(cls, *system_prompts: str) -> list[Message]:
"""
Build a list of system-role messages, ready to pass as ``prepended_conversation``.

Args:
*system_prompts (str): One or more system instruction texts.

Returns:
list[Message]: One system-role message per input, in order.

"""
return [cls.from_system_prompt(system_prompt) for system_prompt in system_prompts]
Comment thread
adrian-gavrila marked this conversation as resolved.

def duplicate(self) -> Message:
"""
Create a deep copy of this message with new IDs and timestamp for all message pieces.
Expand Down
Loading