@@ -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)
@@ -1411,9 +1416,12 @@ async def _start_streaming(
14111416 # state's input, causing duplicate items.
14121417 if run_state is not None :
14131418 # Resuming from state - normalize items to remove top-level providerData
1419+ # and filter incomplete function_call pairs
14141420 if isinstance (starting_input , list ):
1421+ # Filter incomplete function_call pairs before normalizing
1422+ filtered = AgentRunner ._filter_incomplete_function_calls (starting_input )
14151423 prepared_input : str | list [TResponseInputItem ] = (
1416- AgentRunner ._normalize_input_items (starting_input )
1424+ AgentRunner ._normalize_input_items (filtered )
14171425 )
14181426 else :
14191427 prepared_input = starting_input
@@ -2414,20 +2422,82 @@ def _get_model(cls, agent: Agent[Any], run_config: RunConfig) -> Model:
24142422
24152423 return run_config .model_provider .get_model (agent .model )
24162424
2425+ @staticmethod
2426+ def _filter_incomplete_function_calls (
2427+ items : list [TResponseInputItem ],
2428+ ) -> list [TResponseInputItem ]:
2429+ """Filter out function_call items that don't have corresponding function_call_output.
2430+
2431+ The OpenAI API requires every function_call in an assistant message to have a
2432+ corresponding function_call_output (tool message). This function ensures only
2433+ complete pairs are included to prevent API errors.
2434+
2435+ IMPORTANT: This only filters incomplete function_call items. All other items
2436+ (messages, complete function_call pairs, etc.) are preserved to maintain
2437+ conversation history integrity.
2438+
2439+ Args:
2440+ items: List of input items to filter
2441+
2442+ Returns:
2443+ Filtered list with only complete function_call pairs. All non-function_call
2444+ items and complete function_call pairs are preserved.
2445+ """
2446+ # First pass: collect call_ids from function_call_output/function_call_result items
2447+ completed_call_ids : set [str ] = set ()
2448+ for item in items :
2449+ if isinstance (item , dict ):
2450+ item_type = item .get ("type" )
2451+ # Handle both API format (function_call_output) and
2452+ # protocol format (function_call_result)
2453+ if item_type in ("function_call_output" , "function_call_result" ):
2454+ call_id = item .get ("call_id" ) or item .get ("callId" )
2455+ if call_id and isinstance (call_id , str ):
2456+ completed_call_ids .add (call_id )
2457+
2458+ # Second pass: only include function_call items that have corresponding outputs
2459+ filtered : list [TResponseInputItem ] = []
2460+ for item in items :
2461+ if isinstance (item , dict ):
2462+ item_type = item .get ("type" )
2463+ if item_type == "function_call" :
2464+ call_id = item .get ("call_id" ) or item .get ("callId" )
2465+ # Only include if there's a corresponding
2466+ # function_call_output/function_call_result
2467+ if call_id and call_id in completed_call_ids :
2468+ filtered .append (item )
2469+ else :
2470+ # Include all non-function_call items
2471+ filtered .append (item )
2472+ else :
2473+ # Include non-dict items as-is
2474+ filtered .append (item )
2475+
2476+ return filtered
2477+
24172478 @staticmethod
24182479 def _normalize_input_items (items : list [TResponseInputItem ]) -> list [TResponseInputItem ]:
2419- """Normalize input items by removing top-level providerData/provider_data.
2420-
2480+ """Normalize input items by removing top-level providerData/provider_data
2481+ and normalizing field names (callId -> call_id).
2482+
24212483 The OpenAI API doesn't accept providerData at the top level of input items.
24222484 providerData should only be in content where it belongs. This function removes
24232485 top-level providerData while preserving it in content.
2424-
2486+
2487+ Also normalizes field names from camelCase (callId) to snake_case (call_id)
2488+ to match API expectations.
2489+
2490+ Normalizes item types: converts 'function_call_result' to 'function_call_output'
2491+ to match API expectations.
2492+
24252493 Args:
24262494 items: List of input items to normalize
2427-
2495+
24282496 Returns:
24292497 Normalized list of input items
24302498 """
2499+ from .run_state import _normalize_field_names
2500+
24312501 normalized : list [TResponseInputItem ] = []
24322502 for item in items :
24332503 if isinstance (item , dict ):
@@ -2437,6 +2507,18 @@ def _normalize_input_items(items: list[TResponseInputItem]) -> list[TResponseInp
24372507 # The API doesn't accept providerData at the top level of input items
24382508 normalized_item .pop ("providerData" , None )
24392509 normalized_item .pop ("provider_data" , None )
2510+ # Normalize item type: API expects 'function_call_output',
2511+ # not 'function_call_result'
2512+ item_type = normalized_item .get ("type" )
2513+ if item_type == "function_call_result" :
2514+ normalized_item ["type" ] = "function_call_output"
2515+ item_type = "function_call_output"
2516+ # Remove invalid fields based on item type
2517+ # function_call_output items should not have 'name' field
2518+ if item_type == "function_call_output" :
2519+ normalized_item .pop ("name" , None )
2520+ # Normalize field names (callId -> call_id, responseId -> response_id)
2521+ normalized_item = _normalize_field_names (normalized_item )
24402522 normalized .append (cast (TResponseInputItem , normalized_item ))
24412523 else :
24422524 # For non-dict items, keep as-is (they should already be in correct format)
@@ -2483,10 +2565,14 @@ async def _prepare_input_with_session(
24832565 f"Invalid `session_input_callback` value: { session_input_callback } . "
24842566 "Choose between `None` or a custom callable function."
24852567 )
2486-
2568+
2569+ # Filter incomplete function_call pairs before normalizing
2570+ # (API requires every function_call to have a function_call_output)
2571+ filtered = cls ._filter_incomplete_function_calls (merged )
2572+
24872573 # Normalize items to remove top-level providerData and deduplicate by ID
2488- normalized = cls ._normalize_input_items (merged )
2489-
2574+ normalized = cls ._normalize_input_items (filtered )
2575+
24902576 # Deduplicate items by ID to prevent sending duplicate items to the API
24912577 # This can happen when resuming from state and items are already in the session
24922578 seen_ids : set [str ] = set ()
@@ -2498,13 +2584,13 @@ async def _prepare_input_with_session(
24982584 item_id = cast (str | None , item .get ("id" ))
24992585 elif hasattr (item , "id" ):
25002586 item_id = cast (str | None , getattr (item , "id" , None ))
2501-
2587+
25022588 # Only add items we haven't seen before (or items without IDs)
25032589 if item_id is None or item_id not in seen_ids :
25042590 deduplicated .append (item )
25052591 if item_id :
25062592 seen_ids .add (item_id )
2507-
2593+
25082594 return deduplicated
25092595
25102596 @classmethod
0 commit comments