@@ -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+
9296class _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" :
0 commit comments