Skip to content

Commit d23086d

Browse files
adrian-gavrilaCopilotromanlutzCopilot
authored
MAINT: Standardize system prompts on prepended_conversation (#2040)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Roman Lutz <romanlutz13@gmail.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent a00a23e commit d23086d

15 files changed

Lines changed: 522 additions & 111 deletions

doc/code/executor/3_attack_configuration.ipynb

Lines changed: 138 additions & 31 deletions
Large diffs are not rendered by default.

doc/code/executor/3_attack_configuration.py

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
# |---|---|
2222
# | `objective` | What you are trying to get the **objective target** (the system under test) to do. Drives scoring and multi-turn adversarial prompts. |
2323
# | `memory_labels` | A `dict[str, str]` tagged onto every prompt/response, so you can filter this run later in memory. |
24-
# | `prepended_conversation` | A list of `Message`s to seed the conversation before the attack's own turns (system prompt, prior history). |
24+
# | `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). |
2525
# | `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. |
2626
#
2727
# Construction-time configuration objects — **adversarial**, **scoring**, and **converter** — are
@@ -36,6 +36,7 @@
3636
PromptSendingAttack,
3737
SingleTurnAttackContext,
3838
)
39+
from pyrit.models import Message
3940
from pyrit.output import output_attack_async
4041
from pyrit.prompt_target import TextTarget
4142
from pyrit.setup import IN_MEMORY, initialize_pyrit_async
@@ -59,15 +60,42 @@
5960
)
6061
await output_attack_async(result)
6162

63+
# %% [markdown]
64+
# ## Setting a system prompt
65+
#
66+
# The objective target's system prompt is just a `system`-role message at the front of the
67+
# conversation, so you set it through `prepended_conversation`. `Message.from_system_prompt(...)`
68+
# builds that message:
69+
#
70+
# ```python
71+
# prepended_conversation=[Message.from_system_prompt("...")]
72+
# ```
73+
#
74+
# Because `prepended_conversation` is a list, targets that accept more than one system message just
75+
# take more than one entry. `Message.from_system_prompts(...)` is a shorthand that builds the list for
76+
# you — `Message.from_system_prompts("Policy.", "Persona.")` is the same as
77+
# `[Message.from_system_prompt("Policy."), Message.from_system_prompt("Persona.")]` — and you can
78+
# interleave `user` / `assistant` turns too (next section).
79+
80+
# %%
81+
result = await attack.execute_async( # type: ignore
82+
objective="Explain how a saponification reaction works",
83+
prepended_conversation=[
84+
Message.from_system_prompt("You are a helpful chemistry tutor who explains concepts step by step.")
85+
],
86+
)
87+
await output_attack_async(result)
88+
6289
# %% [markdown]
6390
# ## Prepended conversations
6491
#
65-
# A prepended conversation seeds the exchange before the attack adds its own turn. The most common
66-
# use is setting a system prompt, but you can prepend any sequence of `system` / `user` / `assistant`
67-
# turns — for example, to resume a prior conversation or to plant an agreeable assistant reply.
92+
# A system prompt is the simplest prepended conversation. The general form seeds a full
93+
# `system` / `user` / `assistant` history before the attack adds its own turn — for example, to
94+
# resume a prior conversation or to plant an agreeable assistant reply. System prompts and seeded
95+
# `user` / `assistant` turns can be combined in the same list, and PyRIT preserves their order.
6896

6997
# %%
70-
from pyrit.models import Message, MessagePiece
98+
from pyrit.models import MessagePiece
7199

72100
prepended_conversation = [
73101
Message.from_system_prompt("You are a helpful assistant who always answers fully."),

doc/code/targets/11_message_normalizer.ipynb

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,13 @@
178178
"source": [
179179
"## GenericSystemSquashNormalizer\n",
180180
"\n",
181-
"Some models don't support system messages. The `GenericSystemSquashNormalizer` merges the system message into the first user message using a standardized instruction format.\n",
181+
"Some models don't support system messages. The `GenericSystemSquashNormalizer` combines consecutive\n",
182+
"system messages in their original order and merges them into the user message immediately following\n",
183+
"them. If no user immediately follows, it converts the system messages to a user message in their\n",
184+
"original position.\n",
185+
"\n",
186+
"For example, `system: Policy`, `system: Persona`, `user: Question` becomes one user message containing\n",
187+
"the Policy and Persona instructions followed by the Question.\n",
182188
"\n",
183189
"The format is:\n",
184190
"```\n",
@@ -359,7 +365,7 @@
359365
"The `TokenizerTemplateNormalizer` supports different strategies for handling system messages:\n",
360366
"\n",
361367
"- **`keep`**: Pass system messages as-is (default)\n",
362-
"- **`squash`**: Merge system into first user message using `GenericSystemSquashNormalizer`\n",
368+
"- **`squash`**: Merge system messages into the following user message using `GenericSystemSquashNormalizer`\n",
363369
"- **`ignore`**: Drop system messages entirely\n",
364370
"- **`developer`**: Change system role to developer role (for newer OpenAI models)"
365371
]
@@ -518,6 +524,9 @@
518524
}
519525
],
520526
"metadata": {
527+
"jupytext": {
528+
"main_language": "python"
529+
},
521530
"language_info": {
522531
"codemirror_mode": {
523532
"name": "ipython",

doc/code/targets/11_message_normalizer.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
# extension: .py
66
# format_name: percent
77
# format_version: '1.3'
8-
# jupytext_version: 1.19.0
8+
# jupytext_version: 1.19.4
99
# ---
1010

1111
# %% [markdown]
@@ -82,7 +82,13 @@
8282
# %% [markdown]
8383
# ## GenericSystemSquashNormalizer
8484
#
85-
# Some models don't support system messages. The `GenericSystemSquashNormalizer` merges the system message into the first user message using a standardized instruction format.
85+
# Some models don't support system messages. The `GenericSystemSquashNormalizer` combines consecutive
86+
# system messages in their original order and merges them into the user message immediately following
87+
# them. If no user immediately follows, it converts the system messages to a user message in their
88+
# original position.
89+
#
90+
# For example, `system: Policy`, `system: Persona`, `user: Question` becomes one user message containing
91+
# the Policy and Persona instructions followed by the Question.
8692
#
8793
# The format is:
8894
# ```
@@ -171,7 +177,7 @@
171177
# The `TokenizerTemplateNormalizer` supports different strategies for handling system messages:
172178
#
173179
# - **`keep`**: Pass system messages as-is (default)
174-
# - **`squash`**: Merge system into first user message using `GenericSystemSquashNormalizer`
180+
# - **`squash`**: Merge system messages into the following user message using `GenericSystemSquashNormalizer`
175181
# - **`ignore`**: Drop system messages entirely
176182
# - **`developer`**: Change system role to developer role (for newer OpenAI models)
177183

@@ -203,7 +209,6 @@
203209
# You can create custom normalizers by extending the base classes.
204210

205211
# %%
206-
207212
from pyrit.message_normalizer import MessageStringNormalizer
208213
from pyrit.models import Message
209214

pyrit/executor/attack/component/conversation_manager.py

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
PrependedConversationConfig,
1414
)
1515
from pyrit.memory import CentralMemory
16-
from pyrit.message_normalizer import ConversationContextNormalizer
16+
from pyrit.message_normalizer import ConversationContextNormalizer, GenericSystemSquashNormalizer
1717
from pyrit.models import (
1818
ChatMessageRole,
1919
ComponentIdentifier,
@@ -359,23 +359,37 @@ async def _handle_non_chat_target_async(
359359
if config is None:
360360
config = PrependedConversationConfig()
361361

362-
# Normalize conversation to string
363362
normalizer = config.get_message_normalizer()
364-
normalized_context = await normalizer.normalize_string_async(prepended_conversation)
363+
messages_to_normalize = prepended_conversation
364+
if isinstance(normalizer, ConversationContextNormalizer):
365+
messages_to_normalize = await GenericSystemSquashNormalizer().normalize_async(prepended_conversation)
365366

366-
# Prepend to next_message if it exists, otherwise create new message
367-
if context.next_message is not None:
367+
normalized_context = await normalizer.normalize_string_async(messages_to_normalize)
368+
369+
next_message = context.next_message
370+
if next_message is None:
371+
next_message = Message.from_prompt(prompt=context.objective, role="user")
372+
context.next_message = next_message
373+
374+
if normalized_context:
368375
# Find an existing text piece to prepend to
369376
text_piece = None
370-
for piece in context.next_message.message_pieces:
377+
for piece in next_message.message_pieces:
371378
if piece.original_value_data_type == "text":
372379
text_piece = piece
373380
break
374381

375382
if text_piece:
376383
# Prepend context to the existing text piece
377-
text_piece.original_value = f"{normalized_context}\n\n{text_piece.original_value}"
378-
text_piece.converted_value = f"{normalized_context}\n\n{text_piece.converted_value}"
384+
context_prefix = f"{normalized_context}\n\n"
385+
if text_piece.original_value != normalized_context and not text_piece.original_value.startswith(
386+
context_prefix
387+
):
388+
text_piece.original_value = f"{context_prefix}{text_piece.original_value}"
389+
if text_piece.converted_value != normalized_context and not text_piece.converted_value.startswith(
390+
context_prefix
391+
):
392+
text_piece.converted_value = f"{context_prefix}{text_piece.converted_value}"
379393
else:
380394
# No text piece found (multimodal message), add a new text piece at the beginning
381395
context_piece = MessagePiece(
@@ -387,12 +401,7 @@ async def _handle_non_chat_target_async(
387401
converted_value_data_type="text",
388402
)
389403
# Create a new message with the context piece prepended
390-
context.next_message = Message(
391-
message_pieces=[context_piece] + list(context.next_message.message_pieces)
392-
)
393-
else:
394-
# Create new message with just the context
395-
context.next_message = Message.from_prompt(prompt=normalized_context, role="user")
404+
context.next_message = Message(message_pieces=[context_piece] + list(next_message.message_pieces))
396405

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

pyrit/executor/attack/single_turn/single_turn_attack_strategy.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from dataclasses import dataclass, field
1010
from typing import TYPE_CHECKING, Any
1111

12+
from pyrit.common.deprecation import print_deprecation_message
1213
from pyrit.common.logger import logger
1314
from pyrit.executor.attack.core.attack_parameters import AttackParameters, AttackParamsT
1415
from pyrit.executor.attack.core.attack_strategy import AttackContext, AttackStrategy
@@ -31,12 +32,22 @@ class SingleTurnAttackContext(AttackContext[AttackParamsT]):
3132
# Unique identifier of the main conversation between the attacker and model
3233
conversation_id: str = field(default_factory=lambda: str(uuid.uuid4()))
3334

34-
# System prompt for chat-based targets
35+
# Deprecated, non-functional no-op; removed in 0.17.0. Set the objective
36+
# target's system prompt via ``prepended_conversation`` instead.
3537
system_prompt: str | None = None
3638

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

42+
def __post_init__(self) -> None:
43+
"""Warn that ``system_prompt`` is deprecated and non-functional when it is set."""
44+
if self.system_prompt is not None:
45+
print_deprecation_message(
46+
old_item="SingleTurnAttackContext.system_prompt",
47+
new_item="prepended_conversation=[Message.from_system_prompt(...)]",
48+
removed_in="0.17.0",
49+
)
50+
4051

4152
class SingleTurnAttackStrategy(AttackStrategy[SingleTurnAttackContext[Any], AttackResult], ABC):
4253
"""

pyrit/message_normalizer/chat_message_normalizer.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ class ChatMessageNormalizer(MessageListNormalizer[ChatMessage], MessageStringNor
3636
Defaults to False for backward compatibility.
3737
system_message_behavior: How to handle system messages before conversion.
3838
- "keep": Keep system messages as-is (default)
39-
- "squash": Merge system message into first user message
39+
- "squash": Merge system messages into the following user message
4040
- "ignore": Drop system messages entirely
4141
"""
4242

pyrit/message_normalizer/generic_system_squash.py

Lines changed: 66 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,12 @@
88

99
class GenericSystemSquashNormalizer(MessageListNormalizer[Message]):
1010
"""
11-
Normalizer that combines the first system message with the first user message using generic instruction tags.
11+
Normalizer that combines system messages with the following user message using generic instruction tags.
1212
"""
1313

1414
async def normalize_async(self, messages: list[Message]) -> list[Message]:
1515
"""
16-
Return messages with the first system message combined into the first user message.
16+
Return messages with each system message combined into the following user message.
1717
1818
The format uses generic instruction tags:
1919
### Instructions ###
@@ -25,43 +25,81 @@ async def normalize_async(self, messages: list[Message]) -> list[Message]:
2525
messages: The list of messages to normalize.
2626
2727
Returns:
28-
A Message with the system message squashed into the first user message.
28+
Messages with system instructions squashed into the following user message.
2929
3030
Raises:
3131
ValueError: If the messages list is empty.
3232
"""
3333
if not messages:
3434
raise ValueError("Messages list cannot be empty")
3535

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

42-
if len(messages) == 1:
43-
# Only system message, convert to user message.
44-
return [
45-
build_squashed_user_message(
46-
new_message_content=first_piece.converted_value, source_messages=messages[:1]
47-
)
48-
]
40+
result: list[Message] = []
41+
index = 0
42+
while index < len(messages):
43+
message = messages[index]
44+
if message.api_role != "system":
45+
result.append(message)
46+
index += 1
47+
continue
4948

50-
user_message_index = next(
51-
(i for i, message in enumerate(messages[1:], start=1) if message.api_role == "user"),
52-
-1,
53-
)
54-
if user_message_index == -1:
55-
# Preserve the instruction content without rewriting non-user messages.
56-
return [
57-
build_squashed_user_message(
58-
new_message_content=first_piece.converted_value, source_messages=messages[:1]
49+
system_messages = [message]
50+
index += 1
51+
while index < len(messages) and messages[index].api_role == "system":
52+
system_messages.append(messages[index])
53+
index += 1
54+
55+
if index < len(messages) and messages[index].api_role == "user":
56+
result.append(
57+
self._squash_system_messages_into_user(
58+
system_messages=system_messages,
59+
user_message=messages[index],
60+
)
61+
)
62+
index += 1
63+
else:
64+
result.append(
65+
build_squashed_user_message(
66+
new_message_content=self._get_system_content(system_messages),
67+
source_messages=system_messages,
68+
)
5969
)
60-
] + list(messages[1:])
6170

62-
# Combine system with the first user message, preserving non-text pieces (e.g. images) and their order.
63-
system_content = first_piece.converted_value
64-
user_message = messages[user_message_index]
71+
return result
72+
73+
@staticmethod
74+
def _get_system_content(system_messages: list[Message]) -> str:
75+
"""
76+
Combine system-message pieces in message order.
77+
78+
Args:
79+
system_messages: The system messages to combine.
80+
81+
Returns:
82+
The combined system-message content.
83+
"""
84+
return "\n\n".join(piece.converted_value for message in system_messages for piece in message.message_pieces)
85+
86+
def _squash_system_messages_into_user(
87+
self,
88+
*,
89+
system_messages: list[Message],
90+
user_message: Message,
91+
) -> Message:
92+
"""
93+
Merge system instructions into a user message while preserving its pieces.
94+
95+
Args:
96+
system_messages: The system messages to merge.
97+
user_message: The following user message.
98+
99+
Returns:
100+
The user message with the system instructions applied.
101+
"""
102+
system_content = self._get_system_content(system_messages)
65103
# Propagate prompt_metadata from the user message's first piece so downstream normalizers
66104
# (e.g. JsonSchemaNormalizer) still see request-level metadata after squashing.
67105
propagated_metadata = dict(user_message.message_pieces[0].prompt_metadata)
@@ -96,7 +134,4 @@ async def normalize_async(self, messages: list[Message]) -> list[Message]:
96134
+ list(user_message.message_pieces[text_piece_index + 1 :])
97135
)
98136

99-
squashed_message = Message(message_pieces=squashed_pieces)
100-
101-
# Remove system (index 0), replace the first user message with the squashed version, preserve all others
102-
return list(messages[1:user_message_index]) + [squashed_message] + list(messages[user_message_index + 1 :])
137+
return Message(message_pieces=squashed_pieces)

pyrit/message_normalizer/message_normalizer.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"""
1414
How to handle system messages in models with varying support:
1515
- "keep": Keep system messages as-is (default for most models)
16-
- "squash": Merge system message into first user message
16+
- "squash": Merge system messages into the following user message
1717
- "ignore": Drop system messages entirely
1818
"""
1919

@@ -90,7 +90,7 @@ async def apply_system_message_behavior_async(
9090
messages: The list of Message objects to process.
9191
behavior: How to handle system messages:
9292
- "keep": Return messages unchanged
93-
- "squash": Merge system into first user message
93+
- "squash": Merge system messages into the following user message
9494
- "ignore": Remove system messages
9595
9696
Returns:

0 commit comments

Comments
 (0)