Skip to content

Commit b532083

Browse files
biefanjbolor21romanlutz
authored
FIX Preserve user message structure in generic system squash (#1505)
Co-authored-by: jbolor21 <86250273+jbolor21@users.noreply.github.com> Co-authored-by: Roman Lutz <romanlutz13@gmail.com> Co-authored-by: Roman Lutz <[email protected]>
1 parent 390ed46 commit b532083

2 files changed

Lines changed: 185 additions & 11 deletions

File tree

pyrit/message_normalizer/generic_system_squash.py

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
# Copyright (c) Microsoft Corporation.
22
# Licensed under the MIT license.
33

4-
54
from pyrit.message_normalizer._helpers import build_squashed_user_message
65
from pyrit.message_normalizer.message_normalizer import MessageListNormalizer
7-
from pyrit.models import Message
6+
from pyrit.models import Message, MessagePiece
87

98

109
class GenericSystemSquashNormalizer(MessageListNormalizer[Message]):
@@ -48,15 +47,56 @@ async def normalize_async(self, messages: list[Message]) -> list[Message]:
4847
)
4948
]
5049

51-
# Combine system with first user message
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]
59+
)
60+
] + list(messages[1:])
61+
62+
# Combine system with the first user message, preserving non-text pieces (e.g. images) and their order.
5263
system_content = first_piece.converted_value
53-
user_piece = messages[1].get_piece()
54-
user_content = user_piece.converted_value
64+
user_message = messages[user_message_index]
65+
# Propagate prompt_metadata from the user message's first piece so downstream normalizers
66+
# (e.g. JsonSchemaNormalizer) still see request-level metadata after squashing.
67+
propagated_metadata = dict(user_message.message_pieces[0].prompt_metadata)
68+
text_piece_index = next(
69+
(i for i, piece in enumerate(user_message.message_pieces) if piece.converted_value_data_type == "text"),
70+
-1,
71+
)
5572

56-
combined_content = f"### Instructions ###\n\n{system_content}\n\n######\n\n{user_content}"
73+
if text_piece_index == -1:
74+
# No text piece to merge into; prepend an instruction-only text piece so non-text pieces are preserved.
75+
template_piece = user_message.get_piece()
76+
instruction_piece = MessagePiece(
77+
role="user",
78+
original_value=f"### Instructions ###\n\n{system_content}\n\n######",
79+
conversation_id=template_piece.conversation_id,
80+
sequence=template_piece.sequence,
81+
prompt_metadata=propagated_metadata,
82+
)
83+
squashed_pieces = [instruction_piece] + list(user_message.message_pieces)
84+
else:
85+
text_piece = user_message.message_pieces[text_piece_index]
86+
combined_piece = MessagePiece(
87+
role="user",
88+
original_value=f"### Instructions ###\n\n{system_content}\n\n######\n\n{text_piece.converted_value}",
89+
conversation_id=text_piece.conversation_id,
90+
sequence=text_piece.sequence,
91+
prompt_metadata=propagated_metadata,
92+
)
93+
squashed_pieces = (
94+
list(user_message.message_pieces[:text_piece_index])
95+
+ [combined_piece]
96+
+ list(user_message.message_pieces[text_piece_index + 1 :])
97+
)
5798

58-
squashed_message = build_squashed_user_message(
59-
new_message_content=combined_content, source_messages=messages[:2]
60-
)
61-
# Return the squashed message followed by remaining messages (skip first two)
62-
return [squashed_message] + list(messages[2:])
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 :])

tests/unit/message_normalizer/test_generic_system_squash_normalizer.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,140 @@ async def test_generic_squash_normalize_to_dicts_async():
6969
assert "User message" in result[0]["pieces"][0]["converted_value"]
7070

7171

72+
async def test_generic_squash_preserves_multipart_user_message():
73+
"""Test that squashing keeps non-text user pieces instead of collapsing to plain text."""
74+
conversation_id = "conv-1"
75+
messages = [
76+
_make_message("system", "System message"),
77+
Message(
78+
message_pieces=[
79+
MessagePiece(
80+
role="user",
81+
original_value="User message",
82+
conversation_id=conversation_id,
83+
sequence=0,
84+
),
85+
MessagePiece(
86+
role="user",
87+
original_value="/tmp/example.png",
88+
original_value_data_type="image_path",
89+
conversation_id=conversation_id,
90+
sequence=0,
91+
),
92+
]
93+
),
94+
]
95+
96+
result = await GenericSystemSquashNormalizer().normalize_async(messages)
97+
98+
assert len(result) == 1
99+
assert result[0].api_role == "user"
100+
assert len(result[0].message_pieces) == 2
101+
assert result[0].get_value() == "### Instructions ###\n\nSystem message\n\n######\n\nUser message"
102+
assert result[0].message_pieces[1].converted_value == "/tmp/example.png"
103+
assert result[0].message_pieces[1].converted_value_data_type == "image_path"
104+
105+
106+
async def test_generic_squash_uses_first_user_message_instead_of_rewriting_assistant():
107+
"""Test that squash targets the first user message even if assistant messages appear first."""
108+
messages = [
109+
_make_message("system", "System message"),
110+
_make_message("assistant", "Assistant message"),
111+
_make_message("user", "User message"),
112+
]
113+
114+
result = await GenericSystemSquashNormalizer().normalize_async(messages)
115+
116+
assert len(result) == 2
117+
assert result[0].api_role == "assistant"
118+
assert result[0].get_value() == "Assistant message"
119+
assert result[1].api_role == "user"
120+
assert result[1].get_value() == "### Instructions ###\n\nSystem message\n\n######\n\nUser message"
121+
122+
123+
async def test_generic_squash_no_user_message_converts_system_to_user():
124+
"""Test that system is converted to user when no user messages exist."""
125+
messages = [
126+
_make_message("system", "System message"),
127+
_make_message("assistant", "Assistant message"),
128+
]
129+
130+
result = await GenericSystemSquashNormalizer().normalize_async(messages)
131+
132+
assert len(result) == 2
133+
assert result[0].api_role == "user"
134+
assert result[0].get_value() == "System message"
135+
assert result[1].api_role == "assistant"
136+
assert result[1].get_value() == "Assistant message"
137+
138+
139+
async def test_generic_squash_preserves_image_first_multipart_user_message():
140+
"""Test that squashing merges into the first text piece when an image piece comes first."""
141+
conversation_id = "conv-image-first"
142+
messages = [
143+
_make_message("system", "System message"),
144+
Message(
145+
message_pieces=[
146+
MessagePiece(
147+
role="user",
148+
original_value="/tmp/example.png",
149+
original_value_data_type="image_path",
150+
conversation_id=conversation_id,
151+
sequence=0,
152+
),
153+
MessagePiece(
154+
role="user",
155+
original_value="Describe this image",
156+
conversation_id=conversation_id,
157+
sequence=0,
158+
),
159+
]
160+
),
161+
]
162+
163+
result = await GenericSystemSquashNormalizer().normalize_async(messages)
164+
165+
assert len(result) == 1
166+
assert result[0].api_role == "user"
167+
assert len(result[0].message_pieces) == 2
168+
assert result[0].message_pieces[0].converted_value == "/tmp/example.png"
169+
assert result[0].message_pieces[0].converted_value_data_type == "image_path"
170+
assert result[0].message_pieces[1].converted_value_data_type == "text"
171+
assert (
172+
result[0].message_pieces[1].converted_value
173+
== "### Instructions ###\n\nSystem message\n\n######\n\nDescribe this image"
174+
)
175+
176+
177+
async def test_generic_squash_user_message_without_text_pieces_prepends_instructions():
178+
"""Test that an instruction-only text piece is prepended when no text piece exists to merge into."""
179+
conversation_id = "conv-no-text"
180+
messages = [
181+
_make_message("system", "System message"),
182+
Message(
183+
message_pieces=[
184+
MessagePiece(
185+
role="user",
186+
original_value="/tmp/example.png",
187+
original_value_data_type="image_path",
188+
conversation_id=conversation_id,
189+
sequence=0,
190+
),
191+
]
192+
),
193+
]
194+
195+
result = await GenericSystemSquashNormalizer().normalize_async(messages)
196+
197+
assert len(result) == 1
198+
assert result[0].api_role == "user"
199+
assert len(result[0].message_pieces) == 2
200+
assert result[0].message_pieces[0].converted_value_data_type == "text"
201+
assert result[0].message_pieces[0].converted_value == "### Instructions ###\n\nSystem message\n\n######"
202+
assert result[0].message_pieces[1].converted_value == "/tmp/example.png"
203+
assert result[0].message_pieces[1].converted_value_data_type == "image_path"
204+
205+
72206
async def test_generic_squash_propagates_user_piece_metadata():
73207
"""
74208
Regression: when squashing system + user, the squashed piece must carry the

0 commit comments

Comments
 (0)