Skip to content

Commit cc12dbc

Browse files
committed
keep web search calls replayable and out of the way of the answer
1 parent f916d8e commit cc12dbc

5 files changed

Lines changed: 156 additions & 33 deletions

File tree

‎pyrit/prompt_target/openai/openai_response_target.py‎

Lines changed: 29 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@ class MessagePieceType(str, Enum):
8989
MCP_APPROVAL_REQUEST = "mcp_approval_request"
9090

9191

92+
# Fields of a web_search_call input item (all required); the output item has the same ones.
93+
_WEB_SEARCH_CALL_INPUT_FIELDS: tuple[str, ...] = ("id", "type", "status", "action")
94+
95+
9296
class _ResponseToolCallContent(BaseModel):
9397
model_config = ConfigDict(extra="allow")
9498
type: str = Field(min_length=1, pattern=r"\S")
@@ -347,14 +351,14 @@ def _serialize_function_call(self, piece: MessagePiece) -> "ResponseFunctionTool
347351
"arguments": call.function.arguments,
348352
}
349353

350-
def _serialize_tool_call(self, piece: MessagePiece) -> dict[str, Any]:
354+
def _serialize_tool_call(self, piece: MessagePiece) -> dict[str, Any] | None:
351355
stored = _ResponseToolCallContent.model_validate_json(piece.converted_value).model_dump(exclude_unset=True)
352356
if stored.get("type") == "web_search_call":
353-
return {
354-
"type": stored["type"],
355-
"call_id": stored.get("call_id"),
356-
"query": stored.get("query"),
357-
}
357+
# The input item takes exactly the output item's fields, and all of them are required. Older rows
358+
# kept only type and id, which the API rejects, so those can't be replayed.
359+
if any(stored.get(key) is None for key in _WEB_SEARCH_CALL_INPUT_FIELDS):
360+
return None
361+
return {key: stored[key] for key in _WEB_SEARCH_CALL_INPUT_FIELDS}
358362
filtered = {"type": stored["type"]}
359363
filtered.update({key: stored[key] for key in ("call_id", "query", "name", "arguments") if key in stored})
360364
return filtered
@@ -377,7 +381,14 @@ async def _serialize_piece_async(self, *, piece: MessagePiece, message_index: in
377381
if data_type == "function_call":
378382
return _SerializedPiece(item=dict(self._serialize_function_call(piece)), placement="top_level")
379383
if data_type == "tool_call":
380-
return _SerializedPiece(item=self._serialize_tool_call(piece), placement="top_level")
384+
tool_call = self._serialize_tool_call(piece)
385+
if tool_call is None:
386+
logger.warning(
387+
"Skipping web_search_call in message index %d: it lacks the id, status or action the API needs.",
388+
message_index,
389+
)
390+
return None
391+
return _SerializedPiece(item=tool_call, placement="top_level")
381392
if data_type == "function_call_output":
382393
return _SerializedPiece(item=dict(self._serialize_function_call_output(piece)), placement="top_level")
383394
raise ValueError(f"Unsupported data type '{data_type}' in message index {message_index}")
@@ -610,7 +621,14 @@ async def _construct_message_from_response_async(self, response: Response, reque
610621
# reasoning commonly precedes the actual message in provider output, so
611622
# retain it for memory/debugging after the actionable response pieces.
612623
# This must stay ahead of the metadata writes below, which target the first piece.
613-
extracted_response_pieces.sort(key=lambda piece: piece.converted_value_data_type == "reasoning")
624+
# Hosted tool records (e.g. web_search_call) also precede the answer; keep them after it too. Replay
625+
# order is unaffected because tool records are always emitted before the message item.
626+
extracted_response_pieces.sort(
627+
key=lambda piece: (
628+
piece.converted_value_data_type == "reasoning",
629+
piece.converted_value_data_type == "tool_call",
630+
)
631+
)
614632

615633
# Capture token usage and the stop reason in the first piece's metadata. This also runs on
616634
# the truncated path: usage is populated on token-limit responses and is most valuable
@@ -834,20 +852,9 @@ def _parse_response_output_section(
834852
piece_type = "function_call"
835853

836854
elif section_type == MessagePieceType.WEB_SEARCH_CALL:
837-
# Forward web_search_call with only API-expected fields
838-
# Note: web search may have different field structure than function calls
839-
web_search_data = {
840-
"type": "web_search_call",
841-
}
842-
# Add optional fields if they exist
843-
if hasattr(section, "call_id") and section.call_id:
844-
web_search_data["call_id"] = section.call_id
845-
if hasattr(section, "query") and section.query:
846-
web_search_data["query"] = section.query
847-
if hasattr(section, "id") and section.id:
848-
web_search_data["id"] = section.id
849-
850-
piece_value = json.dumps(web_search_data, separators=(",", ":"))
855+
# Keep the whole item (id, status and the search action). The API needs all of it to accept the
856+
# call back as input on the next turn.
857+
piece_value = json.dumps(section.model_dump(mode="json", exclude_none=True), separators=(",", ":"))
851858
piece_type = "tool_call"
852859

853860
elif section_type == "custom_tool_call":

‎tests/unit/backend/test_conversation_editor.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -476,7 +476,10 @@ async def test_provider_tool_extensions_round_trip_without_execution_async(
476476
) -> None:
477477
request = draft()
478478
request.target_registry_name = "responses"
479-
payload = '{"type":"web_search_call","call_id":"web-1","query":"query","extension":{"keep":true}}'
479+
payload = (
480+
'{"type":"web_search_call","id":"ws-1","status":"completed",'
481+
'"action":{"type":"search","query":"query"},"extension":{"keep":true}}'
482+
)
480483
request.messages = [
481484
ConversationMessageRequest(
482485
role="simulated_assistant",

‎tests/unit/prompt_target/target/test_openai_response_adapters.py‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,20 @@ def test_tool_preflight_and_serialization_reject_invalid_provider_fields(payload
4848
@pytest.mark.usefixtures("patch_central_database")
4949
def test_tool_preflight_accepts_empty_history_and_preserves_provider_extensions() -> None:
5050
target = object.__new__(OpenAIResponseTarget)
51-
payload = '{"type":"web_search_call","call_id":"web-1","query":"query","extension":{"key":"value"}}'
51+
payload = (
52+
'{"type":"web_search_call","id":"ws-1","status":"completed",'
53+
'"action":{"type":"search","query":"query"},"extension":{"key":"value"}}'
54+
)
5255
piece = MessagePiece(role="simulated_assistant", original_value_data_type="tool_call", original_value=payload)
5356
target.validate_tool_history([])
5457
target.validate_tool_history([Message(message_pieces=[piece])])
5558
assert piece.converted_value == payload
56-
assert target._serialize_tool_call(piece) == {"type": "web_search_call", "call_id": "web-1", "query": "query"}
59+
assert target._serialize_tool_call(piece) == {
60+
"id": "ws-1",
61+
"type": "web_search_call",
62+
"status": "completed",
63+
"action": {"type": "search", "query": "query"},
64+
}
5765

5866

5967
@pytest.mark.usefixtures("patch_central_database")

‎tests/unit/prompt_target/target/test_openai_response_target.py‎

Lines changed: 103 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,18 @@
77
from collections.abc import MutableSequence
88
from tempfile import NamedTemporaryFile
99
from types import SimpleNamespace
10-
from typing import Any
10+
from typing import Any, get_type_hints
1111
from unittest.mock import AsyncMock, MagicMock, patch
1212

1313
import pytest
1414
from openai import BadRequestError, RateLimitError
15-
from openai.types.responses import ResponseOutputMessage, ResponseOutputRefusal, ResponseOutputText
15+
from openai.types.responses import (
16+
Response,
17+
ResponseFunctionWebSearchParam,
18+
ResponseOutputMessage,
19+
ResponseOutputRefusal,
20+
ResponseOutputText,
21+
)
1622
from unit.mocks import (
1723
get_audio_message_piece,
1824
get_image_message_piece,
@@ -53,8 +59,6 @@ def create_mock_response(response_dict: dict = None) -> MagicMock:
5359
Returns:
5460
A mock object that simulates the OpenAI SDK response with Pydantic-style attribute access.
5561
"""
56-
from openai.types.responses import Response
57-
5862
if response_dict is None:
5963
response_dict = openai_response_json_dict()
6064

@@ -970,7 +974,15 @@ async def test_build_input_for_multi_modal_async_preserves_mixed_payload_contrac
970974
),
971975
MessagePiece(
972976
role="assistant",
973-
original_value=json.dumps({"type": "web_search_call", "call_id": "web-1", "id": "drop-id"}),
977+
original_value=json.dumps(
978+
{
979+
"type": "web_search_call",
980+
"id": "ws-1",
981+
"status": "completed",
982+
"action": {"type": "search", "query": "query"},
983+
"extra": "drop",
984+
}
985+
),
974986
original_value_data_type="tool_call",
975987
),
976988
MessagePiece(
@@ -1036,7 +1048,12 @@ async def test_build_input_for_multi_modal_async_preserves_mixed_payload_contrac
10361048
"name": "lookup",
10371049
"arguments": '{"value":1}',
10381050
},
1039-
{"type": "web_search_call", "call_id": "web-1", "query": None},
1051+
{
1052+
"id": "ws-1",
1053+
"type": "web_search_call",
1054+
"status": "completed",
1055+
"action": {"type": "search", "query": "query"},
1056+
},
10401057
{
10411058
"type": "provider_tool_call",
10421059
"call_id": "tool-1",
@@ -1055,6 +1072,86 @@ async def test_build_input_for_multi_modal_async_preserves_mixed_payload_contrac
10551072
]
10561073

10571074

1075+
def _web_search_response() -> Response:
1076+
return Response.model_validate(
1077+
{
1078+
"id": "resp-1",
1079+
"object": "response",
1080+
"created_at": 1,
1081+
"model": "gpt-4.1",
1082+
"parallel_tool_calls": True,
1083+
"tool_choice": "auto",
1084+
"tools": [{"type": "web_search_preview"}],
1085+
"status": "completed",
1086+
"output": [
1087+
{
1088+
"type": "web_search_call",
1089+
"id": "ws-1",
1090+
"status": "completed",
1091+
"action": {"type": "search", "query": "pyrit release"},
1092+
},
1093+
{
1094+
"type": "message",
1095+
"id": "msg-1",
1096+
"role": "assistant",
1097+
"status": "completed",
1098+
"content": [{"type": "output_text", "text": "PyRIT 0.13 is out.", "annotations": []}],
1099+
},
1100+
],
1101+
}
1102+
)
1103+
1104+
1105+
async def test_web_search_answer_is_the_response_value_async(
1106+
target: OpenAIResponseTarget, dummy_text_message_piece: MessagePiece
1107+
):
1108+
reply = await target._construct_message_from_response_async(_web_search_response(), dummy_text_message_piece)
1109+
1110+
assert reply.get_value() == "PyRIT 0.13 is out."
1111+
assert [piece.converted_value_data_type for piece in reply.message_pieces] == ["text", "tool_call"]
1112+
1113+
1114+
async def test_web_search_call_replays_as_a_valid_input_item_async(
1115+
target: OpenAIResponseTarget, dummy_text_message_piece: MessagePiece
1116+
):
1117+
reply = await target._construct_message_from_response_async(_web_search_response(), dummy_text_message_piece)
1118+
follow_up = Message.from_prompt(prompt="Anything else?", role="user")
1119+
1120+
items = await target._build_input_for_multi_modal_async([dummy_text_message_piece.to_message(), reply, follow_up])
1121+
1122+
web_search = {
1123+
"id": "ws-1",
1124+
"type": "web_search_call",
1125+
"status": "completed",
1126+
"action": {"type": "search", "query": "pyrit release"},
1127+
}
1128+
assert items[1] == web_search
1129+
assert set(web_search) == set(get_type_hints(ResponseFunctionWebSearchParam))
1130+
assert items[2] == {"role": "assistant", "content": [{"type": "output_text", "text": "PyRIT 0.13 is out."}]}
1131+
1132+
1133+
async def test_web_search_call_stored_without_its_action_is_skipped_on_replay_async(
1134+
target: OpenAIResponseTarget, caplog: pytest.LogCaptureFixture
1135+
):
1136+
reply = Message(
1137+
message_pieces=[
1138+
MessagePiece(role="assistant", original_value="answer", conversation_id="c-1"),
1139+
MessagePiece(
1140+
role="assistant",
1141+
original_value='{"type":"web_search_call","id":"ws-1"}',
1142+
original_value_data_type="tool_call",
1143+
conversation_id="c-1",
1144+
),
1145+
]
1146+
)
1147+
1148+
with caplog.at_level(logging.WARNING):
1149+
items = await target._build_input_for_multi_modal_async([reply])
1150+
1151+
assert items == [{"role": "assistant", "content": [{"type": "output_text", "text": "answer"}]}]
1152+
assert "Skipping web_search_call" in caplog.text
1153+
1154+
10581155
@pytest.mark.parametrize("data_type", ["function_call", "tool_call", "function_call_output"])
10591156
async def test_build_input_for_multi_modal_async_validates_malformed_artifact_async(
10601157
target: OpenAIResponseTarget, data_type: PromptDataType

‎tests/unit/prompt_target/target/test_tool_call_history.py‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,10 @@ def test_responses_preflight_and_serializer_reject_invalid_tool_fields(payload:
345345
def test_responses_preflight_retains_provider_extensions_and_uses_converted_values() -> None:
346346
target = _target(OpenAIResponseTarget)
347347
assert isinstance(target, OpenAIResponseTarget)
348-
payload = '{"type":"web_search_call","call_id":"web-1","query":"edited","extension":{"key":"value"}}'
348+
payload = (
349+
'{"type":"web_search_call","id":"ws-1","status":"completed",'
350+
'"action":{"type":"search","query":"edited"},"extension":{"key":"value"}}'
351+
)
349352
piece = MessagePiece(
350353
role="simulated_assistant",
351354
original_value_data_type="tool_call",
@@ -354,7 +357,12 @@ def test_responses_preflight_retains_provider_extensions_and_uses_converted_valu
354357
)
355358
target.validate_tool_history([piece.to_message()])
356359
assert piece.converted_value == payload
357-
assert target._serialize_tool_call(piece) == {"type": "web_search_call", "call_id": "web-1", "query": "edited"}
360+
assert target._serialize_tool_call(piece) == {
361+
"id": "ws-1",
362+
"type": "web_search_call",
363+
"status": "completed",
364+
"action": {"type": "search", "query": "edited"},
365+
}
358366

359367

360368
@pytest.mark.parametrize("target_type", [OpenAIChatTarget, LiteLLMChatTarget])

0 commit comments

Comments
 (0)