@@ -161,7 +161,13 @@ def prepare_input(
161161
162162 # On first call (when there are no generated items yet), include the original input
163163 if not generated_items :
164- input_items .extend (ItemHelpers .input_to_new_input_list (original_input ))
164+ # Normalize original_input items to ensure field names are in snake_case
165+ # (items from RunState deserialization may have camelCase)
166+ raw_input_list = ItemHelpers .input_to_new_input_list (original_input )
167+ # Filter out function_call items that don't have corresponding function_call_output
168+ # (API requires every function_call to have a function_call_output)
169+ filtered_input_list = AgentRunner ._filter_incomplete_function_calls (raw_input_list )
170+ input_items .extend (AgentRunner ._normalize_input_items (filtered_input_list ))
165171
166172 # First, collect call_ids from tool_call_output_item items
167173 # (completed tool calls with outputs) and build a map of
@@ -737,8 +743,8 @@ async def run(
737743 original_user_input = run_state ._original_input
738744 # Normalize items to remove top-level providerData (API doesn't accept it there)
739745 if isinstance (original_user_input , list ):
740- prepared_input : str | list [TResponseInputItem ] = (
741- AgentRunner . _normalize_input_items ( original_user_input )
746+ prepared_input : str | list [TResponseInputItem ] = AgentRunner . _normalize_input_items (
747+ original_user_input
742748 )
743749 else :
744750 prepared_input = original_user_input
@@ -833,8 +839,7 @@ async def run(
833839 if session is not None and generated_items :
834840 # Save tool_call_output_item items (the outputs)
835841 tool_output_items : list [RunItem ] = [
836- item for item in generated_items
837- if item .type == "tool_call_output_item"
842+ item for item in generated_items if item .type == "tool_call_output_item"
838843 ]
839844 # Also find and save the corresponding function_call items
840845 # (they might not be in session if the run was interrupted before saving)
@@ -995,7 +1000,7 @@ async def run(
9951000 )
9961001 if call_id in output_call_ids and item not in items_to_save :
9971002 items_to_save .append (item )
998-
1003+
9991004 # Don't save original_user_input again - it was already saved at the start
10001005 await self ._save_result_to_session (session , [], items_to_save )
10011006
@@ -1369,9 +1374,12 @@ async def _start_streaming(
13691374 # state's input, causing duplicate items.
13701375 if run_state is not None :
13711376 # Resuming from state - normalize items to remove top-level providerData
1377+ # and filter incomplete function_call pairs
13721378 if isinstance (starting_input , list ):
1379+ # Filter incomplete function_call pairs before normalizing
1380+ filtered = AgentRunner ._filter_incomplete_function_calls (starting_input )
13731381 prepared_input : str | list [TResponseInputItem ] = (
1374- AgentRunner ._normalize_input_items (starting_input )
1382+ AgentRunner ._normalize_input_items (filtered )
13751383 )
13761384 else :
13771385 prepared_input = starting_input
@@ -2345,20 +2353,82 @@ def _get_model(cls, agent: Agent[Any], run_config: RunConfig) -> Model:
23452353
23462354 return run_config .model_provider .get_model (agent .model )
23472355
2356+ @staticmethod
2357+ def _filter_incomplete_function_calls (
2358+ items : list [TResponseInputItem ],
2359+ ) -> list [TResponseInputItem ]:
2360+ """Filter out function_call items that don't have corresponding function_call_output.
2361+
2362+ The OpenAI API requires every function_call in an assistant message to have a
2363+ corresponding function_call_output (tool message). This function ensures only
2364+ complete pairs are included to prevent API errors.
2365+
2366+ IMPORTANT: This only filters incomplete function_call items. All other items
2367+ (messages, complete function_call pairs, etc.) are preserved to maintain
2368+ conversation history integrity.
2369+
2370+ Args:
2371+ items: List of input items to filter
2372+
2373+ Returns:
2374+ Filtered list with only complete function_call pairs. All non-function_call
2375+ items and complete function_call pairs are preserved.
2376+ """
2377+ # First pass: collect call_ids from function_call_output/function_call_result items
2378+ completed_call_ids : set [str ] = set ()
2379+ for item in items :
2380+ if isinstance (item , dict ):
2381+ item_type = item .get ("type" )
2382+ # Handle both API format (function_call_output) and
2383+ # protocol format (function_call_result)
2384+ if item_type in ("function_call_output" , "function_call_result" ):
2385+ call_id = item .get ("call_id" ) or item .get ("callId" )
2386+ if call_id and isinstance (call_id , str ):
2387+ completed_call_ids .add (call_id )
2388+
2389+ # Second pass: only include function_call items that have corresponding outputs
2390+ filtered : list [TResponseInputItem ] = []
2391+ for item in items :
2392+ if isinstance (item , dict ):
2393+ item_type = item .get ("type" )
2394+ if item_type == "function_call" :
2395+ call_id = item .get ("call_id" ) or item .get ("callId" )
2396+ # Only include if there's a corresponding
2397+ # function_call_output/function_call_result
2398+ if call_id and call_id in completed_call_ids :
2399+ filtered .append (item )
2400+ else :
2401+ # Include all non-function_call items
2402+ filtered .append (item )
2403+ else :
2404+ # Include non-dict items as-is
2405+ filtered .append (item )
2406+
2407+ return filtered
2408+
23482409 @staticmethod
23492410 def _normalize_input_items (items : list [TResponseInputItem ]) -> list [TResponseInputItem ]:
2350- """Normalize input items by removing top-level providerData/provider_data.
2351-
2411+ """Normalize input items by removing top-level providerData/provider_data
2412+ and normalizing field names (callId -> call_id).
2413+
23522414 The OpenAI API doesn't accept providerData at the top level of input items.
23532415 providerData should only be in content where it belongs. This function removes
23542416 top-level providerData while preserving it in content.
2355-
2417+
2418+ Also normalizes field names from camelCase (callId) to snake_case (call_id)
2419+ to match API expectations.
2420+
2421+ Normalizes item types: converts 'function_call_result' to 'function_call_output'
2422+ to match API expectations.
2423+
23562424 Args:
23572425 items: List of input items to normalize
2358-
2426+
23592427 Returns:
23602428 Normalized list of input items
23612429 """
2430+ from .run_state import _normalize_field_names
2431+
23622432 normalized : list [TResponseInputItem ] = []
23632433 for item in items :
23642434 if isinstance (item , dict ):
@@ -2368,6 +2438,18 @@ def _normalize_input_items(items: list[TResponseInputItem]) -> list[TResponseInp
23682438 # The API doesn't accept providerData at the top level of input items
23692439 normalized_item .pop ("providerData" , None )
23702440 normalized_item .pop ("provider_data" , None )
2441+ # Normalize item type: API expects 'function_call_output',
2442+ # not 'function_call_result'
2443+ item_type = normalized_item .get ("type" )
2444+ if item_type == "function_call_result" :
2445+ normalized_item ["type" ] = "function_call_output"
2446+ item_type = "function_call_output"
2447+ # Remove invalid fields based on item type
2448+ # function_call_output items should not have 'name' field
2449+ if item_type == "function_call_output" :
2450+ normalized_item .pop ("name" , None )
2451+ # Normalize field names (callId -> call_id, responseId -> response_id)
2452+ normalized_item = _normalize_field_names (normalized_item )
23712453 normalized .append (cast (TResponseInputItem , normalized_item ))
23722454 else :
23732455 # For non-dict items, keep as-is (they should already be in correct format)
@@ -2414,10 +2496,14 @@ async def _prepare_input_with_session(
24142496 f"Invalid `session_input_callback` value: { session_input_callback } . "
24152497 "Choose between `None` or a custom callable function."
24162498 )
2417-
2499+
2500+ # Filter incomplete function_call pairs before normalizing
2501+ # (API requires every function_call to have a function_call_output)
2502+ filtered = cls ._filter_incomplete_function_calls (merged )
2503+
24182504 # Normalize items to remove top-level providerData and deduplicate by ID
2419- normalized = cls ._normalize_input_items (merged )
2420-
2505+ normalized = cls ._normalize_input_items (filtered )
2506+
24212507 # Deduplicate items by ID to prevent sending duplicate items to the API
24222508 # This can happen when resuming from state and items are already in the session
24232509 seen_ids : set [str ] = set ()
@@ -2429,13 +2515,13 @@ async def _prepare_input_with_session(
24292515 item_id = cast (str | None , item .get ("id" ))
24302516 elif hasattr (item , "id" ):
24312517 item_id = cast (str | None , getattr (item , "id" , None ))
2432-
2518+
24332519 # Only add items we haven't seen before (or items without IDs)
24342520 if item_id is None or item_id not in seen_ids :
24352521 deduplicated .append (item )
24362522 if item_id :
24372523 seen_ids .add (item_id )
2438-
2524+
24392525 return deduplicated
24402526
24412527 @classmethod
0 commit comments