Skip to content

Commit 5ab0bbc

Browse files
biefanCopilot
andauthored
Preserve empty responses in prompt normalizer batches (microsoft#1506)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent c295d2d commit 5ab0bbc

2 files changed

Lines changed: 87 additions & 36 deletions

File tree

pyrit/prompt_normalizer/prompt_normalizer.py

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -83,13 +83,12 @@ async def send_prompt_async(
8383
attack_identifier (Optional[ComponentIdentifier], optional): Identifier for the attack. Defaults to
8484
None.
8585
86+
Returns:
87+
Message: The response received from the target.
88+
8689
Raises:
8790
Exception: If an error occurs during the request processing.
8891
ValueError: If the message pieces are not part of the same sequence.
89-
EmptyResponseException: If the target returns no valid responses.
90-
91-
Returns:
92-
Message: The response received from the target.
9392
"""
9493
# Validates that the MessagePieces in the Message are part of the same sequence
9594
request_converter_configurations = request_converter_configurations or []
@@ -156,7 +155,15 @@ async def send_prompt_async(
156155
await self._calc_hash(request=request)
157156
self.memory.add_message_to_memory(request=request)
158157
return request
159-
raise EmptyResponseException(message="Target returned no valid responses")
158+
empty_response = construct_response_from_request(
159+
request=request.message_pieces[0],
160+
response_text_pieces=[""],
161+
response_type="text",
162+
error="empty",
163+
)
164+
await self._calc_hash(request=empty_response)
165+
self.memory.add_message_to_memory(request=empty_response)
166+
return empty_response
160167

161168
# Process all response messages (targets return list[Message])
162169
# Only apply response converters to the last message (final response)
@@ -210,7 +217,7 @@ async def send_prompt_batch_to_target_async(
210217
"conversation_id",
211218
]
212219

213-
responses = await batch_task_async(
220+
return await batch_task_async(
214221
prompt_target=target,
215222
batch_size=batch_size,
216223
items_to_batch=batch_items,
@@ -221,9 +228,6 @@ async def send_prompt_batch_to_target_async(
221228
attack_identifier=attack_identifier,
222229
)
223230

224-
# Filter out None responses (e.g., from empty responses)
225-
return [response for response in responses if response is not None]
226-
227231
async def convert_values(
228232
self,
229233
converter_configurations: list[PromptConverterConfiguration],

tests/unit/prompt_normalizer/test_prompt_normalizer.py

Lines changed: 74 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -117,21 +117,23 @@ async def test_send_prompt_async_multiple_converters(mock_memory_instance, seed_
117117

118118

119119
@pytest.mark.asyncio
120-
async def test_send_prompt_async_no_response_raises_empty_response(mock_memory_instance, seed_group):
121-
prompt_target = AsyncMock()
120+
async def test_send_prompt_async_no_response_adds_memory(mock_memory_instance, seed_group):
121+
prompt_target = MagicMock()
122122
prompt_target.send_prompt_async = AsyncMock(return_value=None)
123+
prompt_target.get_identifier.return_value = get_mock_target_identifier("MockTarget")
123124

124125
normalizer = PromptNormalizer()
125126
message = Message.from_prompt(prompt=seed_group.prompts[0].value, role="user")
126127

127-
with pytest.raises(EmptyResponseException):
128-
await normalizer.send_prompt_async(message=message, target=prompt_target)
129-
130-
# Request should still be added to memory before the exception
131-
assert mock_memory_instance.add_message_to_memory.call_count == 1
128+
response = await normalizer.send_prompt_async(message=message, target=prompt_target)
129+
assert mock_memory_instance.add_message_to_memory.call_count == 2
132130

133131
request = mock_memory_instance.add_message_to_memory.call_args[1]["request"]
134132
assert_message_piece_hashes_set(request)
133+
assert response.message_pieces[0].response_error == "empty"
134+
assert response.message_pieces[0].original_value == ""
135+
assert response.message_pieces[0].original_value_data_type == "text"
136+
assert_message_piece_hashes_set(response)
135137

136138

137139
@pytest.mark.asyncio
@@ -187,34 +189,29 @@ async def test_send_prompt_async_request_response_added_to_memory(mock_memory_in
187189

188190
@pytest.mark.asyncio
189191
async def test_send_prompt_async_exception(mock_memory_instance, seed_group):
190-
prompt_target = AsyncMock()
192+
prompt_target = MagicMock()
193+
prompt_target.send_prompt_async = AsyncMock(side_effect=ValueError("test_exception"))
194+
prompt_target.get_identifier.return_value = get_mock_target_identifier("MockTarget")
191195

192196
seed_prompt_value = seed_group.prompts[0].value
193197

194198
normalizer = PromptNormalizer()
195199
message = Message.from_prompt(prompt=seed_prompt_value, role="user")
196200

197-
with patch("pyrit.models.construct_response_from_request") as mock_construct:
198-
mock_construct.return_value = "test"
201+
with pytest.raises(Exception, match="Error sending prompt with conversation ID"):
202+
await normalizer.send_prompt_async(message=message, target=prompt_target)
199203

200-
try:
201-
await normalizer.send_prompt_async(message=message, target=prompt_target)
202-
except ValueError:
203-
assert mock_memory_instance.add_message_to_memory.call_count == 2
204+
assert mock_memory_instance.add_message_to_memory.call_count == 2
204205

205-
# Validate that first request is added to memory, then exception is added to memory
206-
assert (
207-
seed_prompt_value
208-
== mock_memory_instance.add_message_to_memory.call_args_list[0][1]["request"]
209-
.message_pieces[0]
210-
.original_value
211-
)
212-
assert (
213-
mock_memory_instance.add_message_to_memory.call_args_list[1][1]["request"]
214-
.message_pieces[0]
215-
.original_value
216-
== "test_exception"
217-
)
206+
# Validate that first request is added to memory, then exception is added to memory
207+
assert (
208+
seed_prompt_value
209+
== mock_memory_instance.add_message_to_memory.call_args_list[0][1]["request"].message_pieces[0].original_value
210+
)
211+
assert (
212+
"test_exception"
213+
in mock_memory_instance.add_message_to_memory.call_args_list[1][1]["request"].message_pieces[0].original_value
214+
)
218215

219216

220217
@pytest.mark.asyncio
@@ -386,6 +383,56 @@ async def test_prompt_normalizer_send_prompt_batch_async_throws(
386383
assert len(results) == 1
387384

388385

386+
@pytest.mark.asyncio
387+
async def test_prompt_normalizer_send_prompt_batch_async_preserves_empty_response_alignment(
388+
mock_memory_instance,
389+
):
390+
prompt_target = MagicMock()
391+
prompt_target._max_requests_per_minute = None
392+
prompt_target.get_identifier.return_value = get_mock_target_identifier("MockTarget")
393+
prompt_target.send_prompt_async = AsyncMock(
394+
side_effect=[
395+
[MessagePiece(role="assistant", original_value="response 1", conversation_id="conv-1").to_message()],
396+
None,
397+
]
398+
)
399+
400+
normalizer = PromptNormalizer()
401+
requests = [
402+
NormalizerRequest(
403+
message=Message.from_prompt(prompt="prompt 1", role="user"),
404+
conversation_id="conv-1",
405+
),
406+
NormalizerRequest(
407+
message=Message.from_prompt(prompt="prompt 2", role="user"),
408+
conversation_id="conv-2",
409+
),
410+
]
411+
412+
results = await normalizer.send_prompt_batch_to_target_async(requests=requests, target=prompt_target, batch_size=2)
413+
414+
assert len(results) == 2
415+
assert results[0].message_pieces[0].original_value == "response 1"
416+
assert results[1].message_pieces[0].response_error == "empty"
417+
assert results[1].message_pieces[0].original_value == ""
418+
assert results[1].message_pieces[0].conversation_id == "conv-2"
419+
420+
421+
@pytest.mark.asyncio
422+
async def test_send_prompt_async_none_in_list_response_returns_empty(mock_memory_instance, seed_group):
423+
"""Target returning [None] (list containing None) should produce an empty response."""
424+
prompt_target = MagicMock()
425+
prompt_target.send_prompt_async = AsyncMock(return_value=[None])
426+
prompt_target.get_identifier.return_value = get_mock_target_identifier("MockTarget")
427+
428+
normalizer = PromptNormalizer()
429+
message = Message.from_prompt(prompt=seed_group.prompts[0].value, role="user")
430+
431+
response = await normalizer.send_prompt_async(message=message, target=prompt_target)
432+
assert response.message_pieces[0].response_error == "empty"
433+
assert response.message_pieces[0].original_value == ""
434+
435+
389436
@pytest.mark.asyncio
390437
async def test_build_message(mock_memory_instance, seed_group):
391438
# This test is obsolete since _build_message was removed and message preparation

0 commit comments

Comments
 (0)