diff --git a/examples/tools/programmatic_tool_calling.py b/examples/tools/programmatic_tool_calling.py new file mode 100644 index 0000000000..8248970bcd --- /dev/null +++ b/examples/tools/programmatic_tool_calling.py @@ -0,0 +1,129 @@ +import asyncio +from typing import Literal + +from openai.types.responses import ResponseFunctionToolCall +from openai.types.responses.response_output_item import Program +from pydantic import BaseModel + +from agents import ( + Agent, + ModelSettings, + ProgrammaticToolCallingTool, + Runner, + ToolCallItem, + function_tool, +) + +Sku = Literal["desk-lamp", "ergonomic-keyboard", "usb-c-dock"] + +inventory: dict[Sku, int] = { + "desk-lamp": 12, + "ergonomic-keyboard": 7, + "usb-c-dock": 22, +} + +weekly_demand: dict[Sku, int] = { + "desk-lamp": 18, + "ergonomic-keyboard": 16, + "usb-c-dock": 14, +} + +inbound_units: dict[Sku, int] = { + "desk-lamp": 4, + "ergonomic-keyboard": 2, + "usb-c-dock": 0, +} + + +class InventoryOutput(BaseModel): + sku: Sku + available_units: int + + +class WeeklyDemandOutput(BaseModel): + sku: Sku + forecast_units: int + + +class InboundUnitsOutput(BaseModel): + sku: Sku + inbound_units: int + + +@function_tool(allowed_callers=["programmatic"]) +def get_inventory(sku: Sku) -> InventoryOutput: + """Return the currently available units for one SKU.""" + print(f"[tool] get_inventory({sku})") + return InventoryOutput(sku=sku, available_units=inventory[sku]) + + +@function_tool(allowed_callers=["programmatic"]) +def get_weekly_demand(sku: Sku) -> WeeklyDemandOutput: + """Return forecast demand for one SKU for the next seven days.""" + print(f"[tool] get_weekly_demand({sku})") + return WeeklyDemandOutput(sku=sku, forecast_units=weekly_demand[sku]) + + +@function_tool(allowed_callers=["programmatic"]) +def get_inbound_units(sku: Sku) -> InboundUnitsOutput: + """Return units already scheduled to arrive for one SKU.""" + print(f"[tool] get_inbound_units({sku})") + return InboundUnitsOutput(sku=sku, inbound_units=inbound_units[sku]) + + +async def main() -> None: + agent = Agent( + name="Replenishment planner", + model="gpt-5.6", + instructions=""" + +Use Programmatic Tool Calling to prepare a replenishment plan for desk-lamp, +ergonomic-keyboard, and usb-c-dock. For every SKU, call get_inventory, +get_weekly_demand, and get_inbound_units. Create all nine tool-call promises +before awaiting them, then run them concurrently with one Promise.all call. + +Use a safety stock of 5 units. Calculate reorder_units as +max(forecast_units + 5 - available_units - inbound_units, 0). In the program, +return exactly one JSON object with recommendations and total_reorder_units. +Each recommendation must include sku, available_units, forecast_units, +inbound_units, and reorder_units. Include only positive reorder quantities and +sort recommendations by reorder_units descending. + +Do not call these tools directly. In the final answer, explain the plan using +the source values returned by the program. + + """.strip(), + model_settings=ModelSettings(tool_choice="programmatic_tool_calling"), + tools=[ + get_inventory, + get_weekly_demand, + get_inbound_units, + ProgrammaticToolCallingTool(), + ], + ) + + result = await Runner.run( + agent, + "Which products should we reorder this week, and in what quantities?", + ) + + programmatic_calls: list[str] = [] + for item in result.new_items: + if not isinstance(item, ToolCallItem): + continue + raw_item = item.raw_item + if isinstance(raw_item, Program): + print(f"\nGenerated program:\n{raw_item.code}\n") + elif ( + isinstance(raw_item, ResponseFunctionToolCall) + and raw_item.caller is not None + and raw_item.caller.type == "program" + ): + programmatic_calls.append(raw_item.name) + + print(f"Programmatic calls: {', '.join(programmatic_calls)}") + print(f"\nFinal answer:\n{result.final_output}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/agents/__init__.py b/src/agents/__init__.py index 4e9f2b0c18..2ec18c2460 100644 --- a/src/agents/__init__.py +++ b/src/agents/__init__.py @@ -156,6 +156,7 @@ MCPToolApprovalFunction, MCPToolApprovalFunctionResult, MCPToolApprovalRequest, + ProgrammaticToolCallingTool, ShellActionRequest, ShellCallData, ShellCallOutcome, @@ -179,6 +180,7 @@ ShellToolLocalSkill, ShellToolSkillReference, Tool, + ToolCaller, ToolOrigin, ToolOriginType, ToolOutputFileContent, @@ -510,7 +512,9 @@ def enable_verbose_stdout_logging(): "ApplyPatchTool", "ApplyPatchToolCustomDataContext", "ApplyPatchToolCustomDataExtractor", + "ProgrammaticToolCallingTool", "Tool", + "ToolCaller", "WebSearchTool", "HostedMCPTool", "MCPToolApprovalFunction", diff --git a/src/agents/function_schema.py b/src/agents/function_schema.py index b11b20f17e..86f791dc43 100644 --- a/src/agents/function_schema.py +++ b/src/agents/function_schema.py @@ -40,6 +40,8 @@ class FuncSchema: strict_json_schema: bool = True """Whether the JSON schema is in strict mode. We **strongly** recommend setting this to True, as it increases the likelihood of correct JSON input.""" + return_annotation: Any = inspect.Signature.empty + """The resolved return annotation, including `Annotated` metadata when present.""" def to_call_args(self, data: BaseModel) -> tuple[list[Any], dict[str, Any]]: """ @@ -474,4 +476,5 @@ def function_schema( signature=sig, takes_context=takes_context, strict_json_schema=strict_json_schema, + return_annotation=type_hints_with_extras.get("return", sig.return_annotation), ) diff --git a/src/agents/items.py b/src/agents/items.py index edc4366c81..012d81b1dd 100644 --- a/src/agents/items.py +++ b/src/agents/items.py @@ -45,13 +45,15 @@ McpApprovalRequest, McpCall, McpListTools, + Program, + ProgramOutput, ) from openai.types.responses.response_reasoning_item import ResponseReasoningItem from pydantic import BaseModel from typing_extensions import assert_never from ._tool_identity import FunctionToolLookupKey, get_function_tool_lookup_key, tool_trace_name -from .exceptions import AgentsException, ModelBehaviorError +from .exceptions import AgentsException, ModelBehaviorError, UserError from .logger import logger from .tool import ( ToolOrigin, @@ -60,6 +62,7 @@ ToolOutputText, ValidToolOutputPydanticModels, ValidToolOutputPydanticModelsTypeAdapter, + _is_programmatic_tool_call, ) from .usage import Usage from .util._json import _to_dump_compatible @@ -85,6 +88,7 @@ # Distinguish a missing dict entry from an explicit None value. _MISSING_ATTR_SENTINEL = object() +_JSON_OUTPUT_ADAPTER = pydantic.TypeAdapter(Any) @dataclass @@ -339,6 +343,7 @@ def release_agent(self) -> None: | ResponseCodeInterpreterToolCall | ImageGenerationCall | LocalShellCall + | Program | dict[str, Any] ) """A type that represents a tool call item.""" @@ -382,6 +387,7 @@ def call_id(self) -> str | None: | ComputerCallOutput | LocalShellCallOutput | ResponseFunctionShellToolCallOutput + | ProgramOutput | dict[str, Any] ) @@ -785,7 +791,12 @@ def text_message_output(cls, message: MessageOutputItem) -> str: @classmethod def tool_call_output_item( - cls, tool_call: ResponseFunctionToolCall, output: Any + cls, + tool_call: ResponseFunctionToolCall, + output: Any, + *, + output_json_schema: dict[str, Any] | None = None, + output_type_adapter: pydantic.TypeAdapter[Any] | None = None, ) -> FunctionCallOutput: """Creates a tool call output item from a tool call and its output. @@ -794,20 +805,108 @@ def tool_call_output_item( provided as Pydantic models or dicts, or an iterable of such items. """ - converted_output = cls._convert_tool_output(output) + converted_output: str | ResponseFunctionCallOutputItemListParam + if output_type_adapter is not None: + try: + validated_output = ( + output_type_adapter.validate_json(output) + if isinstance(output, str) + else output_type_adapter.validate_python(output) + ) + except pydantic.ValidationError as error: + raise UserError( + "Function tool output does not match its declared output schema." + ) from error + dumped_output = output_type_adapter.dump_python( + validated_output, + mode="json", + by_alias=True, + ) + if not isinstance(dumped_output, Mapping): + raise UserError("Function tool output schema requires a JSON object.") + converted_output = json.dumps( + dict(dumped_output), + ensure_ascii=False, + separators=(",", ":"), + ) + elif output_json_schema is not None: + if isinstance(output, str): + try: + dumped_output = json.loads(output) + except json.JSONDecodeError as error: + raise UserError( + "Function tool output schema requires a JSON object." + ) from error + else: + dumped_output = _JSON_OUTPUT_ADAPTER.dump_python(output, mode="json") + if not isinstance(dumped_output, Mapping): + raise UserError("Function tool output schema requires a JSON object.") + converted_output = json.dumps( + dict(dumped_output), + ensure_ascii=False, + separators=(",", ":"), + ) + elif isinstance(output, str): + converted_output = output + elif _is_programmatic_tool_call(tool_call): + structured_output = cls._convert_tool_output_as_structured(output) + if structured_output is not None: + converted_output = structured_output + else: + try: + converted_output = _JSON_OUTPUT_ADAPTER.dump_json(output).decode("utf-8") + except Exception as error: + raise UserError( + "Programmatic function tool outputs must be strings, structured tool " + "outputs, or JSON-serializable values." + ) from error + else: + converted_output = cls._convert_tool_output(output) - return { + output_item: FunctionCallOutput = { "call_id": tool_call.call_id, "output": converted_output, "type": "function_call_output", } + return cast(FunctionCallOutput, cls.copy_tool_call_caller(tool_call, output_item)) + + @classmethod + def copy_tool_call_caller( + cls, + tool_call: Any, + output_item: Any, + ) -> Any: + """Copy a program caller relationship from a tool call to its output item.""" + caller = ( + tool_call.get("caller") + if isinstance(tool_call, Mapping) + else getattr(tool_call, "caller", None) + ) + if caller is not None: + model_dump = getattr(caller, "model_dump", None) + output_item["caller"] = ( + model_dump(mode="json", exclude_none=True) + if callable(model_dump) + else _to_dump_compatible(caller) + ) + return output_item @classmethod def _convert_tool_output(cls, output: Any) -> str | ResponseFunctionCallOutputItemListParam: """Converts a tool return value into an output acceptable by the Responses API.""" + structured_output = cls._convert_tool_output_as_structured(output) + return structured_output if structured_output is not None else str(output) + + @classmethod + def _convert_tool_output_as_structured( + cls, + output: Any, + ) -> ResponseFunctionCallOutputItemListParam | None: + """Convert known structured tool outputs without stringifying other values.""" + # If the output is either a single or list of the known structured output types, convert to - # ResponseFunctionCallOutputItemListParam. Else, just stringify. + # ResponseFunctionCallOutputItemListParam. if isinstance(output, list | tuple): maybe_converted_output_list = [ cls._maybe_get_output_as_structured_function_output(item) for item in output @@ -821,14 +920,12 @@ def _convert_tool_output(cls, output: Any) -> str | ResponseFunctionCallOutputIt for item in maybe_converted_output_list if item is not None ] - else: - return str(output) - else: - maybe_converted_output = cls._maybe_get_output_as_structured_function_output(output) - if maybe_converted_output: - return [cls._convert_single_tool_output_pydantic_model(maybe_converted_output)] - else: - return str(output) + return None + + maybe_converted_output = cls._maybe_get_output_as_structured_function_output(output) + if maybe_converted_output: + return [cls._convert_single_tool_output_pydantic_model(maybe_converted_output)] + return None @classmethod def _maybe_get_output_as_structured_function_output( diff --git a/src/agents/models/chatcmpl_converter.py b/src/agents/models/chatcmpl_converter.py index aca9b79b41..21943a971f 100644 --- a/src/agents/models/chatcmpl_converter.py +++ b/src/agents/models/chatcmpl_converter.py @@ -88,7 +88,7 @@ def convert_tool_choice( else: ensure_tool_choice_supports_backend( tool_choice, - backend_name="OpenAI Responses models", + backend_name="Chat Completions-compatible models", ) return { "type": "function", diff --git a/src/agents/models/openai_responses.py b/src/agents/models/openai_responses.py index f07af0f8ee..1938f40a59 100644 --- a/src/agents/models/openai_responses.py +++ b/src/agents/models/openai_responses.py @@ -63,12 +63,14 @@ HostedMCPTool, ImageGenerationTool, LocalShellTool, + ProgrammaticToolCallingTool, ShellTool, ShellToolEnvironment, Tool, ToolSearchTool, WebSearchTool, has_required_tool_search_surface, + validate_responses_programmatic_tool_calling_configuration, validate_responses_tool_search_configuration, ) from ..tracing import SpanError, response_span @@ -1637,6 +1639,11 @@ def convert_tool_choice( return "auto" elif tool_choice == "none": return "none" + elif tool_choice == "programmatic_tool_calling": + return cast( + response_create_params.ToolChoice, + {"type": "programmatic_tool_calling"}, + ) elif tool_choice == "file_search": return { "type": "file_search", @@ -1897,6 +1904,11 @@ def convert_tools( tools, allow_opaque_search_surface=allow_opaque_tool_search_surface, ) + validate_responses_programmatic_tool_calling_configuration( + tools, + tool_choice=tool_choice, + allow_opaque_tool_search_surface=allow_opaque_tool_search_surface, + ) computer_tools = [tool for tool in tools if isinstance(tool, ComputerTool)] if len(computer_tools) > 1: @@ -1975,6 +1987,10 @@ def _convert_function_tool( } if include_defer_loading and tool.defer_loading: function_tool_param["defer_loading"] = True + if tool.allowed_callers is not None: + function_tool_param["allowed_callers"] = tool.allowed_callers + if tool.output_json_schema is not None: + function_tool_param["output_schema"] = tool.output_json_schema return function_tool_param, None @classmethod @@ -2057,16 +2073,21 @@ def _convert_tool( elif isinstance(tool, ApplyPatchTool): tool_config = getattr(tool, "tool_config", None) if tool_config is not None: - return _require_responses_tool_param(tool_config), None - return ApplyPatchToolParam(type="apply_patch"), None + converted_tool_config = dict(tool_config) + else: + converted_tool_config = dict(ApplyPatchToolParam(type="apply_patch")) + if tool.allowed_callers is not None: + converted_tool_config["allowed_callers"] = tool.allowed_callers + return _require_responses_tool_param(converted_tool_config), None elif isinstance(tool, ShellTool): + shell_tool_config: dict[str, Any] = { + "type": "shell", + "environment": cls._convert_shell_environment(tool.environment), + } + if tool.allowed_callers is not None: + shell_tool_config["allowed_callers"] = tool.allowed_callers return ( - _require_responses_tool_param( - { - "type": "shell", - "environment": cls._convert_shell_environment(tool.environment), - } - ), + _require_responses_tool_param(shell_tool_config), None, ) elif isinstance(tool, ImageGenerationTool): @@ -2084,6 +2105,8 @@ def _convert_tool( if tool.parameters is not None: tool_search_tool_param["parameters"] = tool.parameters return tool_search_tool_param, None + elif isinstance(tool, ProgrammaticToolCallingTool): + return _require_responses_tool_param({"type": "programmatic_tool_calling"}), None else: raise UserError(f"Unknown tool type: {type(tool)}, tool") diff --git a/src/agents/run.py b/src/agents/run.py index 3b174a3015..5461a10ecf 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -1327,6 +1327,12 @@ def _finalize_result(result: RunResult) -> RunResult: else getattr(item.raw_item, "call_id", None) for item in turn_result.new_step_items if item.type == "tool_call_output_item" + and ( + item.raw_item.get("type") + if isinstance(item.raw_item, dict) + else getattr(item.raw_item, "type", None) + ) + != "program_output" } for item in generated_items: if item.type != "tool_call_item": diff --git a/src/agents/run_internal/items.py b/src/agents/run_internal/items.py index aadba1d361..de76624df3 100644 --- a/src/agents/run_internal/items.py +++ b/src/agents/run_internal/items.py @@ -21,6 +21,7 @@ TOOL_CALL_SESSION_DESCRIPTION_KEY = "_agents_tool_description" TOOL_CALL_SESSION_TITLE_KEY = "_agents_tool_title" _TOOL_CALL_TO_OUTPUT_TYPE: dict[str, str] = { + "program": "program_output", "function_call": "function_call_output", "custom_tool_call": "custom_tool_call_output", "shell_call": "shell_call_output", @@ -29,6 +30,19 @@ "local_shell_call": "local_shell_call_output", "tool_search_call": "tool_search_output", } +_PROGRAM_OWNED_HOSTED_ITEM_TYPES = frozenset( + { + "hosted_tool_call", + "file_search_call", + "web_search_call", + "code_interpreter_call", + "image_generation_call", + "mcp_list_tools", + "mcp_call", + "mcp_approval_request", + "mcp_approval_response", + } +) __all__ = [ "ReasoningItemIdPolicy", @@ -101,15 +115,38 @@ def drop_orphan_function_calls( pruning_indexes: set[int] | None = None, ) -> list[TResponseInputItem]: """ - Remove tool call items that do not have corresponding outputs so resumptions or retries do not - replay stale tool calls. Reasoning items that immediately precede a tool call dropped by this - pass are also removed, since the Responses API rejects reasoning items that are not followed - by their associated model-emitted item (``Item 'rs_...' of type 'reasoning' was provided - without its required following item``). + Remove tool and program call items that do not have corresponding outputs so resumptions or + retries do not replay stale calls. Program-owned items are removed with an orphan program, + while programs with retained hosted calls or tool outputs remain available for continuation. + Reasoning items that immediately precede a call dropped by this pass are also removed, since + the Responses API rejects reasoning items that are not followed by their associated + model-emitted item (``Item 'rs_...' of type 'reasoning' was provided without its required + following item``). """ completed_call_ids = _completed_call_ids_by_type(items) matched_anonymous_tool_search_calls = _matched_anonymous_tool_search_call_indexes(items) + active_program_call_ids: set[str] = set() + orphan_program_call_ids: set[str] = set() + + for index, entry in enumerate(items): + if pruning_indexes is not None and index not in pruning_indexes: + continue + if not isinstance(entry, dict) or entry.get("type") != "program": + continue + call_id = entry.get("call_id") + if not isinstance(call_id, str): + continue + if call_id in completed_call_ids["program_output"]: + continue + if any( + _get_program_caller_id(candidate) == call_id + and _is_retained_program_owned_item(candidate, candidate_index, pruning_indexes) + for candidate_index, candidate in enumerate(items) + ): + active_program_call_ids.add(call_id) + else: + orphan_program_call_ids.add(call_id) dropped_indexes: set[int] = set() filtered: list[TResponseInputItem] = [] @@ -121,14 +158,24 @@ def drop_orphan_function_calls( if not isinstance(entry_type, str): filtered.append(entry) continue + if pruning_indexes is not None and index not in pruning_indexes: + filtered.append(entry) + continue + program_caller_id = _get_program_caller_id(entry) + if program_caller_id is not None and program_caller_id in orphan_program_call_ids: + dropped_indexes.add(index) + continue output_type = _TOOL_CALL_TO_OUTPUT_TYPE.get(entry_type) if output_type is None: filtered.append(entry) continue - if pruning_indexes is not None and index not in pruning_indexes: + call_id = entry.get("call_id") + if program_caller_id is not None and _is_pending_hosted_shell_call(entry): + filtered.append(entry) + continue + if entry_type == "program" and call_id in active_program_call_ids: filtered.append(entry) continue - call_id = entry.get("call_id") if isinstance(call_id, str) and call_id in completed_call_ids.get(output_type, set()): filtered.append(entry) continue @@ -359,7 +406,10 @@ def function_rejection_item( drop_agent_tool_run_result(tool_call, scope_id=scope_id) return ToolCallOutputItem( output=rejection_message, - raw_item=ItemHelpers.tool_call_output_item(tool_call, rejection_message), + raw_item=ItemHelpers.tool_call_output_item( + tool_call, + rejection_message, + ), agent=agent, tool_origin=tool_origin, ) @@ -369,6 +419,7 @@ def shell_rejection_item( agent: Any, call_id: str, *, + tool_call: Any | None = None, rejection_message: str = REJECTION_MESSAGE, ) -> ToolCallOutputItem: """Build a ToolCallOutputItem representing a rejected shell call.""" @@ -382,6 +433,8 @@ def shell_rejection_item( "call_id": call_id, "output": [rejection_output], } + if tool_call is not None: + ItemHelpers.copy_tool_call_caller(tool_call, rejection_raw_item) return ToolCallOutputItem(agent=agent, output=rejection_message, raw_item=rejection_raw_item) @@ -389,6 +442,7 @@ def apply_patch_rejection_item( agent: Any, call_id: str, *, + tool_call: Any | None = None, output_type: Literal["apply_patch_call_output", "custom_tool_call_output"] = ( "apply_patch_call_output" ), @@ -402,6 +456,8 @@ def apply_patch_rejection_item( } if output_type == "apply_patch_call_output": rejection_raw_item["status"] = "failed" + if tool_call is not None: + ItemHelpers.copy_tool_call_caller(tool_call, rejection_raw_item) return ToolCallOutputItem( agent=agent, output=rejection_message, @@ -476,6 +532,44 @@ def _completed_call_ids_by_type(payload: list[TResponseInputItem]) -> dict[str, return completed +def _get_program_caller_id(entry: TResponseInputItem) -> str | None: + """Return the owning program call id for a program-issued item.""" + if not isinstance(entry, dict): + return None + caller = entry.get("caller") + if not isinstance(caller, dict) or caller.get("type") != "program": + return None + caller_id = caller.get("caller_id") + return caller_id if isinstance(caller_id, str) else None + + +def _is_pending_hosted_shell_call(entry: TResponseInputItem) -> bool: + """Return whether a hosted shell call can remain pending without an output item.""" + if not isinstance(entry, dict) or entry.get("type") != "shell_call": + return False + status = entry.get("status") + return status is None or status == "in_progress" + + +def _is_retained_program_owned_item( + entry: TResponseInputItem, + index: int, + pruning_indexes: set[int] | None, +) -> bool: + """Return whether a program-owned item keeps its parent program active.""" + if pruning_indexes is not None: + return index not in pruning_indexes + if _is_pending_hosted_shell_call(entry): + return True + if not isinstance(entry, dict): + return False + entry_type = cast(dict[str, Any], entry).get("type") + return ( + entry_type in _PROGRAM_OWNED_HOSTED_ITEM_TYPES + or entry_type in _TOOL_CALL_TO_OUTPUT_TYPE.values() + ) + + def _matched_anonymous_tool_search_call_indexes(payload: list[TResponseInputItem]) -> set[int]: """Return anonymous tool_search_call indexes that have a later anonymous output.""" matched_indexes: set[int] = set() diff --git a/src/agents/run_internal/model_retry.py b/src/agents/run_internal/model_retry.py index e6f50b9bec..aa41d07e6b 100644 --- a/src/agents/run_internal/model_retry.py +++ b/src/agents/run_internal/model_retry.py @@ -353,7 +353,10 @@ def _should_disable_provider_managed_retries( *, attempt: int, stateful_request: bool, + replay_unsafe_request: bool, ) -> bool: + if replay_unsafe_request: + return True if ( retry_settings is not None and retry_settings.max_retries is not None @@ -411,12 +414,15 @@ async def get_response_with_retry( get_retry_advice: GetRetryAdviceCallable, previous_response_id: str | None, conversation_id: str | None, + replay_unsafe_request: bool = False, ) -> ModelResponse: request_attempt = 1 policy_attempt = 1 failed_policy_attempts = 0 compatibility_retries_taken = 0 - disable_websocket_pre_event_retry = _should_disable_websocket_pre_event_retry(retry_settings) + disable_websocket_pre_event_retry = replay_unsafe_request or ( + _should_disable_websocket_pre_event_retry(retry_settings) + ) stateful_request = _is_stateful_request( previous_response_id=previous_response_id, conversation_id=conversation_id, @@ -432,6 +438,7 @@ async def get_response_with_retry( retry_settings, attempt=request_attempt, stateful_request=stateful_request, + replay_unsafe_request=replay_unsafe_request, ) ), websocket_pre_event_retries_disabled(disable_websocket_pre_event_retry), @@ -443,9 +450,11 @@ async def get_response_with_retry( ) return response except Exception as error: - if _is_conversation_locked_error( - error - ) and _should_preserve_conversation_locked_compatibility(retry_settings): + if ( + not replay_unsafe_request + and _is_conversation_locked_error(error) + and _should_preserve_conversation_locked_compatibility(retry_settings) + ): # Preserve the historical conversation_locked retry path for backward # compatibility, including when callers enable retry policies for unrelated # failures. Callers can explicitly opt out of this compatibility behavior with @@ -480,7 +489,7 @@ async def get_response_with_retry( retry_policy=retry_settings.policy if retry_settings else None, retry_backoff=retry_settings.backoff if retry_settings else None, stream=False, - replay_unsafe_request=stateful_request, + replay_unsafe_request=stateful_request or replay_unsafe_request, emitted_retry_unsafe_event=False, provider_advice=provider_advice, ) @@ -511,12 +520,15 @@ async def stream_response_with_retry( previous_response_id: str | None, conversation_id: str | None, failed_retry_attempts_out: list[int] | None = None, + replay_unsafe_request: bool = False, ) -> AsyncIterator[TResponseStreamEvent]: request_attempt = 1 policy_attempt = 1 failed_policy_attempts = 0 compatibility_retries_taken = 0 - disable_websocket_pre_event_retry = _should_disable_websocket_pre_event_retry(retry_settings) + disable_websocket_pre_event_retry = replay_unsafe_request or ( + _should_disable_websocket_pre_event_retry(retry_settings) + ) stateful_request = _is_stateful_request( previous_response_id=previous_response_id, conversation_id=conversation_id, @@ -530,6 +542,7 @@ async def stream_response_with_retry( retry_settings, attempt=request_attempt, stateful_request=stateful_request, + replay_unsafe_request=replay_unsafe_request, ) # Pull stream events under the retry-disable context, but yield them outside it so # unrelated model calls made by the consumer do not inherit this setting. @@ -562,9 +575,11 @@ async def stream_response_with_retry( raise if not isinstance(error, Exception): raise - if _is_conversation_locked_error( - error - ) and _should_preserve_conversation_locked_compatibility(retry_settings): + if ( + not replay_unsafe_request + and _is_conversation_locked_error(error) + and _should_preserve_conversation_locked_compatibility(retry_settings) + ): if compatibility_retries_taken < COMPATIBILITY_CONVERSATION_LOCKED_RETRIES: compatibility_retries_taken += 1 delay = 1.0 * (2 ** (compatibility_retries_taken - 1)) @@ -597,7 +612,7 @@ async def stream_response_with_retry( retry_policy=retry_settings.policy if retry_settings else None, retry_backoff=retry_settings.backoff if retry_settings else None, stream=True, - replay_unsafe_request=stateful_request, + replay_unsafe_request=stateful_request or replay_unsafe_request, emitted_retry_unsafe_event=emitted_retry_unsafe_event, provider_advice=provider_advice, ) diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index ffd7ce56df..885b8e056c 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -76,6 +76,7 @@ ) from ..tool import ( FunctionTool, + ProgrammaticToolCallingTool, Tool, ToolOrigin, ToolOriginType, @@ -1502,6 +1503,9 @@ async def rewind_model_request() -> None: previous_response_id=previous_response_id, conversation_id=conversation_id, failed_retry_attempts_out=stream_failed_retry_attempts, + replay_unsafe_request=any( + isinstance(tool, ProgrammaticToolCallingTool) for tool in all_tools + ), ) async for event in model_run_context_stream(retry_stream, tool_use_tracker): @@ -1918,6 +1922,9 @@ async def rewind_model_request() -> None: get_retry_advice=model.get_retry_advice, previous_response_id=previous_response_id, conversation_id=conversation_id, + replay_unsafe_request=any( + isinstance(tool, ProgrammaticToolCallingTool) for tool in all_tools + ), ) if server_conversation_tracker is not None: # Retry helpers rewind sent-input tracking before replaying a failed request. Mark the diff --git a/src/agents/run_internal/tool_actions.py b/src/agents/run_internal/tool_actions.py index 611cb21b66..ee3d685997 100644 --- a/src/agents/run_internal/tool_actions.py +++ b/src/agents/run_internal/tool_actions.py @@ -21,7 +21,7 @@ from .._tool_identity import get_mapping_or_attr, get_tool_trace_name_for_tool from ..agent import Agent from ..exceptions import ModelBehaviorError -from ..items import RunItem, ToolCallOutputItem +from ..items import ItemHelpers, RunItem, ToolCallOutputItem from ..logger import logger from ..run_config import RunConfig from ..run_context import RunContextWrapper @@ -478,6 +478,7 @@ async def _run_call(span: Any | None) -> RunItem: return shell_rejection_item( agent, shell_call.call_id, + tool_call=call.tool_call, rejection_message=rejection_message, ) @@ -588,6 +589,7 @@ async def _run_call(span: Any | None) -> RunItem: "output": structured_output, "status": status, } + ItemHelpers.copy_tool_call_caller(call.tool_call, raw_item) if max_output_length is not None: raw_item["max_output_length"] = max_output_length if raw_entries: @@ -668,7 +670,16 @@ async def _run_call(span: Any | None) -> RunItem: tool_name=custom_tool.name, call_id=call_id, ) - return cls._tool_output_item(agent, call_id, rejection_message) + return cls._tool_output_item( + agent, + call_id, + rejection_message, + raw_item=cls._raw_tool_output_item( + call_id, + rejection_message, + tool_call=call.tool_call, + ), + ) if approval_status is not True: return approval_item @@ -704,7 +715,11 @@ async def _run_call(span: Any | None) -> RunItem: ) logger.error("Custom tool failed: %s", exc, exc_info=True) - raw_item = cls._raw_tool_output_item(call_id, output_text) + raw_item = cls._raw_tool_output_item( + call_id, + output_text, + tool_call=call.tool_call, + ) custom_data = await maybe_extract_custom_data( custom_tool.custom_data_extractor, CustomToolCustomDataContext( @@ -746,12 +761,20 @@ def _normalize_output(output: Any) -> str: return output if isinstance(output, str) else str(output) @staticmethod - def _raw_tool_output_item(call_id: str, output: str) -> dict[str, Any]: - return { + def _raw_tool_output_item( + call_id: str, + output: str, + *, + tool_call: Any | None = None, + ) -> dict[str, Any]: + raw_item = { "type": "custom_tool_call_output", "call_id": call_id, "output": output, } + if tool_call is not None: + ItemHelpers.copy_tool_call_caller(tool_call, raw_item) + return raw_item @classmethod def _tool_output_item( @@ -835,6 +858,7 @@ async def _run_call(span: Any | None) -> RunItem: return apply_patch_rejection_item( agent, call_id, + tool_call=call.tool_call, output_type="apply_patch_call_output", rejection_message=rejection_message, ) @@ -903,6 +927,7 @@ async def _run_call(span: Any | None) -> RunItem: "call_id": call_id, "status": status, } + ItemHelpers.copy_tool_call_caller(call.tool_call, raw_item) if output_text: raw_item["output"] = output_text diff --git a/src/agents/run_internal/tool_caller.py b/src/agents/run_internal/tool_caller.py new file mode 100644 index 0000000000..569a9bb351 --- /dev/null +++ b/src/agents/run_internal/tool_caller.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import json +from collections.abc import Collection, Sequence +from typing import Any + +from ..exceptions import ModelBehaviorError +from ..tool import ToolCaller +from ..tracing import SpanError +from ..util import _error_tracing + + +def ensure_tool_caller_allowed( + *, + tool_call: Any, + allowed_callers: Sequence[ToolCaller] | None, + tool_name: str, + agent_name: str, +) -> None: + """Reject tool calls whose direct or programmatic caller is not allowed.""" + # Import lazily because tool_execution imports RunState through the tool-use tracker. + from .tool_execution import extract_tool_call_id, get_mapping_or_attr + + caller_value = get_mapping_or_attr(tool_call, "caller") + caller_type = get_mapping_or_attr(caller_value, "type") + if caller_value is None: + caller: ToolCaller = "direct" + elif caller_type == "direct": + caller = "direct" + elif caller_type == "program": + caller = "programmatic" + else: + message = f"Model invoked tool {tool_name} with unsupported caller type {caller_type!r}." + _error_tracing.attach_error_to_current_span( + SpanError( + message=message, + data={ + "agent_name": agent_name, + "tool_name": tool_name, + "tool_call_id": extract_tool_call_id(tool_call), + "tool_caller_type": caller_type, + }, + ) + ) + raise ModelBehaviorError(message) + + effective_allowed_callers = list(allowed_callers) if allowed_callers is not None else ["direct"] + if caller in effective_allowed_callers: + return + + message = ( + f"Model invoked tool {tool_name} with caller {caller}, but the tool allows only " + f"{json.dumps(effective_allowed_callers)}." + ) + _error_tracing.attach_error_to_current_span( + SpanError( + message=message, + data={ + "agent_name": agent_name, + "tool_name": tool_name, + "tool_call_id": extract_tool_call_id(tool_call), + "tool_caller": caller, + }, + ) + ) + raise ModelBehaviorError(message) + + +def ensure_programmatic_tool_call_parent( + *, + tool_call: Any, + programmatic_tool_present: bool, + program_call_ids: Collection[str], + completed_program_call_ids: Collection[str], + agent_name: str, +) -> None: + """Reject program-owned calls without an active parent program.""" + # Import lazily because tool_execution imports RunState through the tool-use tracker. + from .tool_execution import get_mapping_or_attr + + caller = get_mapping_or_attr(tool_call, "caller") + if get_mapping_or_attr(caller, "type") != "program": + return + + output_type = get_mapping_or_attr(tool_call, "type") + caller_id = get_mapping_or_attr(caller, "caller_id") + if not programmatic_tool_present: + message = f"Model produced {output_type} item without a programmatic_tool_calling tool." + _error_tracing.attach_error_to_current_span( + SpanError( + message="Programmatic tool not found", + data={"agent_name": agent_name, "output_type": output_type}, + ) + ) + raise ModelBehaviorError(message) + + if not isinstance(caller_id, str) or caller_id not in program_call_ids: + message = ( + f"Model produced {output_type} item with a program caller that does not " + "match a parent program item." + ) + _error_tracing.attach_error_to_current_span( + SpanError( + message="Program parent not found", + data={ + "agent_name": agent_name, + "output_type": output_type, + "caller_id": caller_id, + }, + ) + ) + raise ModelBehaviorError(message) + + if caller_id in completed_program_call_ids: + message = ( + f"Model produced {output_type} item with a program caller whose parent " + "program is already completed." + ) + _error_tracing.attach_error_to_current_span( + SpanError( + message="Program parent already completed", + data={ + "agent_name": agent_name, + "output_type": output_type, + "caller_id": caller_id, + }, + ) + ) + raise ModelBehaviorError(message) diff --git a/src/agents/run_internal/tool_execution.py b/src/agents/run_internal/tool_execution.py index 50ccceb07c..0c45697de9 100644 --- a/src/agents/run_internal/tool_execution.py +++ b/src/agents/run_internal/tool_execution.py @@ -75,8 +75,10 @@ Tool, ToolOrigin, _computer_tool_uses_run_scoped_initializer, + _consume_function_tool_default_failure, + _invoke_function_tool_with_metadata, + _is_programmatic_tool_call, get_function_tool_origin, - invoke_function_tool, maybe_invoke_function_tool_failure_error_function, resolve_computer, ) @@ -180,6 +182,14 @@ class _FunctionToolFailure: source: _FunctionToolFailureSource = "direct" +@dataclasses.dataclass(frozen=True) +class _ToolOutputGuardrailExecutionResult: + """A tool output plus whether it was synthesized by a rejecting guardrail.""" + + output: Any + is_rejection: bool = False + + @dataclasses.dataclass class _FunctionToolTaskState: """Mutable execution state tracked for each function-tool task in a batch.""" @@ -1265,6 +1275,7 @@ def process_hosted_mcp_approvals( ) if approved is False and rejection_message is not None: raw_item["reason"] = rejection_message + ItemHelpers.copy_tool_call_caller(mcp_run.request_item, raw_item) response_item = MCPApprovalResponseItem(raw_item=raw_item, agent=agent) append_item(response_item) continue @@ -1321,6 +1332,7 @@ def collect_manual_mcp_approvals( ) if approval_status is False and rejection_message is not None: approval_response_raw["reason"] = rejection_message + ItemHelpers.copy_tool_call_caller(request_item, approval_response_raw) approved.append(MCPApprovalResponseItem(raw_item=approval_response_raw, agent=agent)) continue @@ -1368,6 +1380,14 @@ def should_keep_hosted_mcp_item( ) +def _uses_programmatic_output_schema( + function_tool: FunctionTool, + tool_call: Any, +) -> bool: + """Return whether this call must satisfy its advertised program output schema.""" + return function_tool.output_json_schema is not None and _is_programmatic_tool_call(tool_call) + + class _FunctionToolBatchExecutor: """Own the mutable state needed to execute and arbitrate a function-tool batch.""" @@ -1396,6 +1416,7 @@ def __init__( self.task_states: dict[asyncio.Task[Any], _FunctionToolTaskState] = {} self.teardown_cancelled_tasks: set[asyncio.Task[Any]] = set() self.results_by_tool_run: dict[int, Any] = {} + self.schema_bypassed_tool_runs: set[int] = set() self.custom_data_by_tool_run: dict[int, dict[str, Any]] = {} self.pending_tasks: set[asyncio.Task[Any]] = set() self.propagating_failure: BaseException | None = None @@ -1765,6 +1786,7 @@ async def _execute_single_tool_body( tool_input_guardrail_results=self.tool_input_guardrail_results, ) if rejected_message is not None: + self.schema_bypassed_tool_runs.add(id(task_state.tool_run)) return rejected_message await asyncio.gather( @@ -1805,12 +1827,15 @@ async def _invoke_tool_and_run_post_invoke( tool_context: ToolContext[Any], agent_hooks: Any, ) -> Any: + bypass_output_schema = False try: - real_result = await invoke_function_tool( + invocation_result = await _invoke_function_tool_with_metadata( function_tool=func_tool, context=tool_context, arguments=tool_call.arguments, ) + real_result = invocation_result.output + bypass_output_schema = invocation_result.is_sdk_generated_error except asyncio.CancelledError as e: if outer_task in self.teardown_cancelled_tasks: raise @@ -1823,6 +1848,10 @@ async def _invoke_tool_and_run_post_invoke( if result is None: raise + bypass_output_schema = _consume_function_tool_default_failure( + tool_context + ) and not _uses_programmatic_output_schema(func_tool, tool_call) + trace_error = get_trace_tool_error( trace_include_sensitive_data=self.config.trace_include_sensitive_data, error_message=str(e), @@ -1837,14 +1866,23 @@ async def _invoke_tool_and_run_post_invoke( task_state.in_post_invoke_phase = True - final_result = await _execute_tool_output_guardrails( + output_guardrail_result = await _execute_tool_output_guardrails( func_tool=func_tool, tool_context=tool_context, agent=self.public_agent, real_result=real_result, tool_output_guardrail_results=self.tool_output_guardrail_results, ) - raw_output_item = ItemHelpers.tool_call_output_item(tool_call, final_result) + final_result = output_guardrail_result.output + bypass_output_schema = bypass_output_schema or (output_guardrail_result.is_rejection) + if bypass_output_schema: + self.schema_bypassed_tool_runs.add(id(task_state.tool_run)) + raw_output_item = ItemHelpers.tool_call_output_item( + tool_call, + final_result, + output_json_schema=None if bypass_output_schema else func_tool.output_json_schema, + output_type_adapter=None if bypass_output_schema else func_tool._output_type_adapter, + ) extracted_custom_data = await maybe_extract_custom_data( func_tool.custom_data_extractor, FunctionToolCustomDataContext( @@ -1956,12 +1994,26 @@ def _build_function_tool_results(self) -> list[FunctionToolResult]: continue nested_run_result, nested_interruptions = self._resolve_nested_tool_run_result(tool_run) + bypass_output_schema = id(tool_run) in self.schema_bypassed_tool_runs run_item: RunItem | None if not nested_interruptions: run_item = ToolCallOutputItem( output=result, - raw_item=ItemHelpers.tool_call_output_item(tool_run.tool_call, result), + raw_item=ItemHelpers.tool_call_output_item( + tool_run.tool_call, + result, + output_json_schema=( + None + if bypass_output_schema + else tool_run.function_tool.output_json_schema + ), + output_type_adapter=( + None + if bypass_output_schema + else tool_run.function_tool._output_type_adapter + ), + ), agent=self.public_agent, tool_origin=get_function_tool_origin(tool_run.function_tool), custom_data=self.custom_data_by_tool_run.get(id(tool_run)), @@ -2388,10 +2440,10 @@ async def _execute_tool_output_guardrails( agent: Agent[Any], real_result: Any, tool_output_guardrail_results: list[ToolOutputGuardrailResult], -) -> Any: +) -> _ToolOutputGuardrailExecutionResult: """Execute output guardrails for a tool call and return the final result.""" if not func_tool.tool_output_guardrails: - return real_result + return _ToolOutputGuardrailExecutionResult(real_result) final_result = real_result for output_guardrail in func_tool.tool_output_guardrails: @@ -2413,10 +2465,12 @@ async def _execute_tool_output_guardrails( if gr_out.behavior["type"] == "raise_exception": raise ToolOutputGuardrailTripwireTriggered(guardrail=output_guardrail, output=gr_out) elif gr_out.behavior["type"] == "reject_content": - final_result = gr_out.behavior["message"] - break + return _ToolOutputGuardrailExecutionResult( + gr_out.behavior["message"], + is_rejection=True, + ) - return final_result + return _ToolOutputGuardrailExecutionResult(final_result) def _normalize_exit_code(value: Any) -> int | None: diff --git a/src/agents/run_internal/tool_planning.py b/src/agents/run_internal/tool_planning.py index 6c337ca0c3..e960b1edf0 100644 --- a/src/agents/run_internal/tool_planning.py +++ b/src/agents/run_internal/tool_planning.py @@ -14,6 +14,7 @@ from ..agent import Agent from ..exceptions import UserError from ..items import ( + ItemHelpers, MCPApprovalResponseItem, RunItem, RunItemBase, @@ -128,6 +129,7 @@ async def run_single_approval(approval_request: ToolRunMCPApprovalRequest) -> Ru } if not result["approve"] and reason: raw_item["reason"] = reason + ItemHelpers.copy_tool_call_caller(request_item, raw_item) return MCPApprovalResponseItem( raw_item=raw_item, agent=agent, diff --git a/src/agents/run_internal/turn_resolution.py b/src/agents/run_internal/turn_resolution.py index 33e1bee8e2..9a7548301c 100644 --- a/src/agents/run_internal/turn_resolution.py +++ b/src/agents/run_internal/turn_resolution.py @@ -24,6 +24,8 @@ McpApprovalRequest, McpCall, McpListTools, + Program, + ProgramOutput, ) from openai.types.responses.response_reasoning_item import ResponseReasoningItem @@ -33,6 +35,7 @@ get_function_tool_lookup_key, get_function_tool_lookup_key_for_call, get_function_tool_lookup_key_for_tool, + get_function_tool_qualified_name, get_tool_call_namespace, get_tool_call_qualified_name, get_tool_call_trace_name, @@ -73,12 +76,14 @@ from ..stream_events import StreamEvent from ..tool import ( ApplyPatchTool, + CodeInterpreterTool, ComputerTool, CustomTool, FunctionTool, FunctionToolResult, HostedMCPTool, LocalShellTool, + ProgrammaticToolCallingTool, ShellTool, Tool, ToolOrigin, @@ -123,6 +128,7 @@ ToolRunShellCall, ) from .streaming import stream_step_items_to_queue +from .tool_caller import ensure_programmatic_tool_call_parent, ensure_tool_caller_allowed from .tool_execution import ( build_litellm_json_tool_call, coerce_apply_patch_operations, @@ -927,6 +933,39 @@ async def execute_tools_and_side_effects( ) +def _collect_program_parent_state( + items: Sequence[Any], + *, + server_manages_conversation: bool = False, +) -> tuple[set[str], set[str]]: + """Collect retained program parents and completed program outputs.""" + program_call_ids: set[str] = set() + completed_program_call_ids: set[str] = set() + for item in items: + raw_item = getattr(item, "raw_item", item) + item_type = get_mapping_or_attr(raw_item, "type") + call_id = get_mapping_or_attr(raw_item, "call_id") + if not isinstance(call_id, str) or not call_id: + call_id = None + if item_type == "program" and call_id is not None: + program_call_ids.add(call_id) + elif item_type == "program_output" and call_id is not None: + if server_manages_conversation: + program_call_ids.add(call_id) + if get_mapping_or_attr(raw_item, "status") == "completed": + completed_program_call_ids.add(call_id) + if server_manages_conversation: + caller = get_mapping_or_attr(raw_item, "caller") + caller_id = get_mapping_or_attr(caller, "caller_id") + if ( + get_mapping_or_attr(caller, "type") == "program" + and isinstance(caller_id, str) + and caller_id + ): + program_call_ids.add(caller_id) + return program_call_ids, completed_program_call_ids + + async def resolve_interrupted_turn( *, bindings: AgentBindings[TContext], @@ -1073,6 +1112,7 @@ async def _build_shell_rejection(run: ToolRunShellCall, call_id: str) -> RunItem shell_rejection_item( public_agent, call_id, + tool_call=run.tool_call, rejection_message=rejection_message, ), ) @@ -1090,6 +1130,7 @@ async def _build_apply_patch_rejection(run: ToolRunApplyPatchCall, call_id: str) apply_patch_rejection_item( public_agent, call_id, + tool_call=run.tool_call, output_type="apply_patch_call_output", rejection_message=rejection_message, ), @@ -1103,17 +1144,16 @@ async def _build_custom_rejection(run: ToolRunCustom, call_id: str) -> RunItem: tool_name=run.custom_tool.name, call_id=call_id, ) + raw_item = { + "type": "custom_tool_call_output", + "call_id": call_id, + "output": rejection_message, + } + ItemHelpers.copy_tool_call_caller(run.tool_call, raw_item) return ToolCallOutputItem( agent=public_agent, output=rejection_message, - raw_item=cast( - Any, - { - "type": "custom_tool_call_output", - "call_id": call_id, - "output": rejection_message, - }, - ), + raw_item=cast(Any, raw_item), ) async def _shell_needs_approval(run: ToolRunShellCall) -> bool: @@ -1246,6 +1286,13 @@ def _approval_matches_agent(approval: ToolApprovalItem) -> bool: for tool in await execution_agent.get_all_tools(context_wrapper) if isinstance(tool, FunctionTool) ] + program_call_ids, completed_program_call_ids = _collect_program_parent_state( + [*original_pre_step_items, *new_response.output], + server_manages_conversation=server_manages_conversation, + ) + programmatic_tool_present = any( + isinstance(tool, ProgrammaticToolCallingTool) for tool in execution_agent.tools + ) async def _rebuild_function_runs_from_approvals() -> list[ToolRunFunction]: if not pending_approval_items: @@ -1327,11 +1374,27 @@ def _add_unmatched_pending(approval: ToolApprovalItem) -> None: } if namespace is not None: tool_call_payload["namespace"] = namespace + caller = get_mapping_or_attr(raw, "caller") + if caller is not None: + tool_call_payload["caller"] = caller tool_call = ResponseFunctionToolCall(**tool_call_payload) tool_call = cast( ResponseFunctionToolCall, normalize_tool_call_for_function_tool(tool_call, resolved_tool), ) + ensure_programmatic_tool_call_parent( + tool_call=tool_call, + programmatic_tool_present=programmatic_tool_present, + program_call_ids=program_call_ids, + completed_program_call_ids=completed_program_call_ids, + agent_name=public_agent.name, + ) + ensure_tool_caller_allowed( + tool_call=tool_call, + allowed_callers=resolved_tool.allowed_callers, + tool_name=get_function_tool_qualified_name(resolved_tool) or resolved_tool.name, + agent_name=public_agent.name, + ) if not (isinstance(rebuilt_call_id, str) and isinstance(arguments, str)): _add_unmatched_pending(approval) @@ -1639,6 +1702,8 @@ def process_model_response( handoffs: list[Handoff], existing_items: Sequence[RunItem] | None = None, run_config: RunConfig | None = None, + server_manages_conversation: bool = False, + server_managed_input_items: Sequence[Any] | None = None, ) -> ProcessedResponse: items: list[RunItem] = [] @@ -1661,6 +1726,13 @@ def process_model_response( local_shell_tool = next((tool for tool in all_tools if isinstance(tool, LocalShellTool)), None) shell_tool = next((tool for tool in all_tools if isinstance(tool, ShellTool)), None) apply_patch_tool = next((tool for tool in all_tools if isinstance(tool, ApplyPatchTool)), None) + code_interpreter_tool = next( + (tool for tool in all_tools if isinstance(tool, CodeInterpreterTool)), + None, + ) + programmatic_tool = next( + (tool for tool in all_tools if isinstance(tool, ProgrammaticToolCallingTool)), None + ) hosted_mcp_server_map = { tool.tool_config["server_label"]: tool for tool in all_tools @@ -1669,6 +1741,11 @@ def process_model_response( hosted_mcp_tool_metadata = collect_mcp_list_tools_metadata(existing_items or ()) hosted_mcp_tool_metadata.update(collect_mcp_list_tools_metadata(response.output)) + program_call_ids, completed_program_call_ids = _collect_program_parent_state( + [*(server_managed_input_items or ()), *(existing_items or ())], + server_manages_conversation=server_manages_conversation, + ) + def _dump_output_item(raw_item: Any) -> dict[str, Any]: if isinstance(raw_item, dict): return dict(raw_item) @@ -1689,6 +1766,110 @@ def _dump_output_item(raw_item: Any) -> dict[str, Any]: output_type, output.__class__.__name__ if hasattr(output, "__class__") else type(output), ) + caller = get_mapping_or_attr(output, "caller") + is_program_owned = get_mapping_or_attr(caller, "type") == "program" + if ( + output_type in ("program", "program_output") or is_program_owned + ) and programmatic_tool is None: + tools_used.append("programmatic_tool_calling") + _error_tracing.attach_error_to_current_span( + SpanError( + message="Programmatic tool not found", + data={}, + ) + ) + raise ModelBehaviorError( + f"Model produced {output_type} item without a programmatic_tool_calling tool." + ) + if is_program_owned: + ensure_programmatic_tool_call_parent( + tool_call=output, + programmatic_tool_present=programmatic_tool is not None, + program_call_ids=program_call_ids, + completed_program_call_ids=completed_program_call_ids, + agent_name=agent.name, + ) + if output_type == "program": + call_id = get_mapping_or_attr(output, "call_id") + if not isinstance(call_id, str) or not call_id: + _error_tracing.attach_error_to_current_span( + SpanError( + message="Program call ID missing", + data={"call_id": call_id}, + ) + ) + raise ModelBehaviorError("Model produced program item without a valid call_id.") + program_call_ids.add(call_id) + items.append(ToolCallItem(raw_item=cast(Program, output), agent=agent)) + tools_used.append("programmatic_tool_calling") + continue + if output_type == "program_output": + call_id = get_mapping_or_attr(output, "call_id") + if not isinstance(call_id, str) or call_id not in program_call_ids: + _error_tracing.attach_error_to_current_span( + SpanError( + message="Program parent not found", + data={ + "output_type": output_type, + "call_id": call_id, + }, + ) + ) + raise ModelBehaviorError( + "Model produced program_output item that does not match a parent program item." + ) + if call_id in completed_program_call_ids: + _error_tracing.attach_error_to_current_span( + SpanError( + message="Program parent already completed", + data={ + "output_type": output_type, + "call_id": call_id, + }, + ) + ) + raise ModelBehaviorError( + "Model produced program_output item whose parent program is already completed." + ) + status = get_mapping_or_attr(output, "status") + if status not in ("completed", "incomplete"): + _error_tracing.attach_error_to_current_span( + SpanError( + message="Program output status invalid", + data={ + "call_id": call_id, + "status": status, + }, + ) + ) + raise ModelBehaviorError( + "Model produced program_output item without a valid status." + ) + result = get_mapping_or_attr(output, "result") + if not isinstance(result, str): + _error_tracing.attach_error_to_current_span( + SpanError( + message="Program output result invalid", + data={ + "call_id": call_id, + "result_type": type(result).__name__, + }, + ) + ) + raise ModelBehaviorError( + "Model produced program_output item without a string result." + ) + items.append( + ToolCallOutputItem( + raw_item=cast(ProgramOutput, output), + output=result, + agent=agent, + ) + ) + if status == "completed": + completed_program_call_ids.add(call_id) + tools_used.append("programmatic_tool_calling") + continue if output_type == "shell_call": if isinstance(output, dict): shell_call_raw = dict(output) @@ -1715,6 +1896,12 @@ def _dump_output_item(raw_item: Any) -> dict[str, Any]: ) ) raise ModelBehaviorError("Model produced shell call without a shell tool.") + ensure_tool_caller_allowed( + tool_call=output, + allowed_callers=shell_tool.allowed_callers, + tool_name=shell_tool.name, + agent_name=agent.name, + ) tools_used.append(shell_tool.name) shell_environment = shell_tool.environment if shell_environment is None or shell_environment["type"] != "local": @@ -1739,6 +1926,12 @@ def _dump_output_item(raw_item: Any) -> dict[str, Any]: if output_type == "shell_call_output" and isinstance( output, dict | ResponseFunctionShellToolCallOutput ): + ensure_tool_caller_allowed( + tool_call=output, + allowed_callers=shell_tool.allowed_callers if shell_tool is not None else None, + tool_name=shell_tool.name if shell_tool is not None else "shell", + agent_name=agent.name, + ) tools_used.append(shell_tool.name if shell_tool else "shell") if isinstance(output, dict): shell_output_raw = dict(output) @@ -1773,8 +1966,14 @@ def _dump_output_item(raw_item: Any) -> dict[str, Any]: "created_by": get_mapping_or_attr(output, "created_by"), } apply_patch_call_raw.pop("created_by", None) - items.append(ToolCallItem(raw_item=cast(Any, apply_patch_call_raw), agent=agent)) if apply_patch_tool: + ensure_tool_caller_allowed( + tool_call=apply_patch_call_raw, + allowed_callers=apply_patch_tool.allowed_callers, + tool_name=apply_patch_tool.name, + agent_name=agent.name, + ) + items.append(ToolCallItem(raw_item=cast(Any, apply_patch_call_raw), agent=agent)) tools_used.append(apply_patch_tool.name) call_identifier = get_mapping_or_attr(apply_patch_call_raw, "call_id") logger.debug("Queuing apply_patch_call %s", call_identifier) @@ -1810,6 +2009,12 @@ def _dump_output_item(raw_item: Any) -> dict[str, Any]: ) continue if output_type == "tool_search_call": + ensure_tool_caller_allowed( + tool_call=output, + allowed_callers=None, + tool_name="tool_search", + agent_name=agent.name, + ) tool_search_call_raw = coerce_tool_search_call_raw_item(output) if get_mapping_or_attr(tool_search_call_raw, "execution") == "client": raise ModelBehaviorError( @@ -1821,6 +2026,12 @@ def _dump_output_item(raw_item: Any) -> dict[str, Any]: tools_used.append("tool_search") continue if output_type == "tool_search_output": + ensure_tool_caller_allowed( + tool_call=output, + allowed_callers=None, + tool_name="tool_search", + agent_name=agent.name, + ) items.append( ToolSearchOutputItem( raw_item=coerce_tool_search_output_raw_item(output), @@ -1832,15 +2043,26 @@ def _dump_output_item(raw_item: Any) -> dict[str, Any]: if isinstance(output, ResponseOutputMessage): items.append(MessageOutputItem(raw_item=output, agent=agent)) elif isinstance(output, ResponseFileSearchToolCall): + ensure_tool_caller_allowed( + tool_call=output, + allowed_callers=None, + tool_name="file_search", + agent_name=agent.name, + ) items.append(ToolCallItem(raw_item=output, agent=agent)) tools_used.append("file_search") elif isinstance(output, ResponseFunctionWebSearch): + ensure_tool_caller_allowed( + tool_call=output, + allowed_callers=None, + tool_name="web_search", + agent_name=agent.name, + ) items.append(ToolCallItem(raw_item=output, agent=agent)) tools_used.append("web_search") elif isinstance(output, ResponseReasoningItem): items.append(ReasoningItem(raw_item=output, agent=agent)) elif isinstance(output, ResponseComputerToolCall): - items.append(ToolCallItem(raw_item=output, agent=agent)) if not computer_tool: tools_used.append("computer") _error_tracing.attach_error_to_current_span( @@ -1850,12 +2072,18 @@ def _dump_output_item(raw_item: Any) -> dict[str, Any]: ) ) raise ModelBehaviorError("Model produced computer action without a computer tool.") + ensure_tool_caller_allowed( + tool_call=output, + allowed_callers=None, + tool_name=computer_tool.name, + agent_name=agent.name, + ) + items.append(ToolCallItem(raw_item=output, agent=agent)) tools_used.append(computer_tool.name) computer_actions.append( ToolRunComputerAction(tool_call=output, computer_tool=computer_tool) ) elif isinstance(output, McpApprovalRequest): - items.append(MCPApprovalRequestItem(raw_item=output, agent=agent)) if output.server_label not in hosted_mcp_server_map: _error_tracing.attach_error_to_current_span( SpanError( @@ -1865,6 +2093,13 @@ def _dump_output_item(raw_item: Any) -> dict[str, Any]: ) raise ModelBehaviorError(f"MCP server label {output.server_label} not found") server = hosted_mcp_server_map[output.server_label] + ensure_tool_caller_allowed( + tool_call=output, + allowed_callers=server.tool_config.get("allowed_callers"), + tool_name=server.name, + agent_name=agent.name, + ) + items.append(MCPApprovalRequestItem(raw_item=output, agent=agent)) mcp_approval_requests.append( ToolRunMCPApprovalRequest( request_item=output, @@ -1878,8 +2113,30 @@ def _dump_output_item(raw_item: Any) -> dict[str, Any]: output.server_label, ) elif isinstance(output, McpListTools): + mcp_server = hosted_mcp_server_map.get(output.server_label) + ensure_tool_caller_allowed( + tool_call=output, + allowed_callers=( + mcp_server.tool_config.get("allowed_callers") + if mcp_server is not None + else None + ), + tool_name=mcp_server.name if mcp_server is not None else "hosted_mcp", + agent_name=agent.name, + ) items.append(MCPListToolsItem(raw_item=output, agent=agent)) elif isinstance(output, McpCall): + mcp_server = hosted_mcp_server_map.get(output.server_label) + ensure_tool_caller_allowed( + tool_call=output, + allowed_callers=( + mcp_server.tool_config.get("allowed_callers") + if mcp_server is not None + else None + ), + tool_name=mcp_server.name if mcp_server is not None else "hosted_mcp", + agent_name=agent.name, + ) metadata = hosted_mcp_tool_metadata.get((output.server_label, output.name)) items.append( ToolCallItem( @@ -1895,19 +2152,52 @@ def _dump_output_item(raw_item: Any) -> dict[str, Any]: ) tools_used.append("mcp") elif isinstance(output, ImageGenerationCall): + ensure_tool_caller_allowed( + tool_call=output, + allowed_callers=None, + tool_name="image_generation", + agent_name=agent.name, + ) items.append(ToolCallItem(raw_item=output, agent=agent)) tools_used.append("image_generation") elif isinstance(output, ResponseCodeInterpreterToolCall): + ensure_tool_caller_allowed( + tool_call=output, + allowed_callers=( + code_interpreter_tool.tool_config.get("allowed_callers") + if code_interpreter_tool is not None + else None + ), + tool_name=( + code_interpreter_tool.name + if code_interpreter_tool is not None + else "code_interpreter" + ), + agent_name=agent.name, + ) items.append(ToolCallItem(raw_item=output, agent=agent)) tools_used.append("code_interpreter") elif isinstance(output, LocalShellCall): - items.append(ToolCallItem(raw_item=output, agent=agent)) if local_shell_tool: + ensure_tool_caller_allowed( + tool_call=output, + allowed_callers=None, + tool_name=local_shell_tool.name, + agent_name=agent.name, + ) + items.append(ToolCallItem(raw_item=output, agent=agent)) tools_used.append("local_shell") local_shell_calls.append( ToolRunLocalShellCall(tool_call=output, local_shell_tool=local_shell_tool) ) elif shell_tool: + ensure_tool_caller_allowed( + tool_call=output, + allowed_callers=shell_tool.allowed_callers, + tool_name=shell_tool.name, + agent_name=agent.name, + ) + items.append(ToolCallItem(raw_item=output, agent=agent)) tools_used.append(shell_tool.name) shell_calls.append(ToolRunShellCall(tool_call=output, shell_tool=shell_tool)) else: @@ -1924,6 +2214,12 @@ def _dump_output_item(raw_item: Any) -> dict[str, Any]: elif isinstance(output, ResponseCustomToolCall): custom_tool = custom_tool_map.get(output.name) if custom_tool is not None: + ensure_tool_caller_allowed( + tool_call=output, + allowed_callers=custom_tool.allowed_callers, + tool_name=custom_tool.name, + agent_name=agent.name, + ) items.append(ToolCallItem(raw_item=cast(Any, output), agent=agent)) tools_used.append(custom_tool.name) custom_tool_calls.append(ToolRunCustom(tool_call=output, custom_tool=custom_tool)) @@ -1934,8 +2230,15 @@ def _dump_output_item(raw_item: Any) -> dict[str, Any]: "call_id": output.call_id, **parsed_operation, } - items.append(ToolCallItem(raw_item=cast(Any, pseudo_call), agent=agent)) + ItemHelpers.copy_tool_call_caller(output, pseudo_call) if apply_patch_tool: + ensure_tool_caller_allowed( + tool_call=pseudo_call, + allowed_callers=apply_patch_tool.allowed_callers, + tool_name=apply_patch_tool.name, + agent_name=agent.name, + ) + items.append(ToolCallItem(raw_item=cast(Any, pseudo_call), agent=agent)) tools_used.append(apply_patch_tool.name) apply_patch_calls.append( ToolRunApplyPatchCall( @@ -1974,8 +2277,15 @@ def _dump_output_item(raw_item: Any) -> dict[str, Any]: "call_id": output.call_id, "operation": parsed_operation, } - items.append(ToolCallItem(raw_item=cast(Any, pseudo_call), agent=agent)) + ItemHelpers.copy_tool_call_caller(output, pseudo_call) if apply_patch_tool: + ensure_tool_caller_allowed( + tool_call=pseudo_call, + allowed_callers=apply_patch_tool.allowed_callers, + tool_name=apply_patch_tool.name, + agent_name=agent.name, + ) + items.append(ToolCallItem(raw_item=cast(Any, pseudo_call), agent=agent)) tools_used.append(apply_patch_tool.name) apply_patch_calls.append( ToolRunApplyPatchCall(tool_call=pseudo_call, apply_patch_tool=apply_patch_tool) @@ -2004,6 +2314,12 @@ def _dump_output_item(raw_item: Any) -> dict[str, Any]: qualified_output_name = get_tool_call_qualified_name(output) if qualified_output_name == output.name and output.name in handoff_map: + ensure_tool_caller_allowed( + tool_call=output, + allowed_callers=None, + tool_name=output.name, + agent_name=agent.name, + ) items.append(HandoffCallItem(raw_item=output, agent=agent)) handoff = ToolRunHandoff( tool_call=output, @@ -2016,6 +2332,12 @@ def _dump_output_item(raw_item: Any) -> dict[str, Any]: if func_tool is None: if output_schema is not None and output.name == "json_tool_call": synthetic_tool = build_litellm_json_tool_call(output) + ensure_tool_caller_allowed( + tool_call=output, + allowed_callers=None, + tool_name=synthetic_tool.name, + agent_name=agent.name, + ) items.append( ToolCallItem( raw_item=output, @@ -2051,6 +2373,12 @@ def _dump_output_item(raw_item: Any) -> dict[str, Any]: ) raise ModelBehaviorError(error) + ensure_tool_caller_allowed( + tool_call=output, + allowed_callers=func_tool.allowed_callers, + tool_name=qualified_output_name or func_tool.name, + agent_name=agent.name, + ) items.append( ToolCallItem( raw_item=output, @@ -2110,6 +2438,12 @@ async def get_single_step_result_from_response( handoffs=handoffs, existing_items=pre_step_items, run_config=run_config, + server_manages_conversation=server_manages_conversation, + server_managed_input_items=( + ItemHelpers.input_to_new_input_list(original_input) + if server_manages_conversation + else None + ), ) if before_side_effects is not None: diff --git a/src/agents/run_state.py b/src/agents/run_state.py index 5e5d7f0533..bf3e4a9877 100644 --- a/src/agents/run_state.py +++ b/src/agents/run_state.py @@ -8,7 +8,7 @@ import json import threading from collections import deque -from collections.abc import Callable, Iterator, Mapping, Sequence +from collections.abc import Callable, Collection, Iterator, Mapping, Sequence from dataclasses import dataclass, field from pathlib import Path from typing import TYPE_CHECKING, Any, Generic, Literal, cast @@ -33,6 +33,8 @@ LocalShellCall, McpApprovalRequest, McpListTools, + Program, + ProgramOutput, ) from pydantic import TypeAdapter, ValidationError from typing_extensions import TypeVar @@ -75,11 +77,16 @@ ToolSearchCallItem, ToolSearchOutputItem, TResponseInputItem, + TResponseOutputItem, coerce_tool_search_call_raw_item, coerce_tool_search_output_raw_item, ) from .logger import logger from .run_context import RunContextWrapper +from .run_internal.tool_caller import ( + ensure_programmatic_tool_call_parent, + ensure_tool_caller_allowed, +) from .sandbox.capabilities.capability import Capability from .sandbox.session.base_sandbox_session import BaseSandboxSession from .tool import ( @@ -89,7 +96,9 @@ FunctionTool, HostedMCPTool, LocalShellTool, + ProgrammaticToolCallingTool, ShellTool, + ToolCaller, ToolOrigin, ) from .tool_guardrails import ( @@ -130,7 +139,7 @@ # 3. to_json() always emits CURRENT_SCHEMA_VERSION. # 4. Forward compatibility is intentionally fail-fast (older SDKs reject newer or unsupported # versions). -CURRENT_SCHEMA_VERSION = "1.12" +CURRENT_SCHEMA_VERSION = "1.13" # Keep this mapping in chronological order. Every schema bump must add a one-line summary here. SCHEMA_VERSION_SUMMARIES: dict[str, str] = { "1.0": "Initial RunState snapshot format for HITL pause/resume flows.", @@ -149,6 +158,7 @@ "1.10": "Allows serialized RunState snapshots to disable max_turns with null.", "1.11": "Persists SDK-only custom data on tool output items across resume flows.", "1.12": "Persists input cache-write token usage across resume flows.", + "1.13": "Persists programmatic tool calls, outputs, and caller relationships.", } SUPPORTED_SCHEMA_VERSIONS = frozenset(SCHEMA_VERSION_SUMMARIES) @@ -1718,6 +1728,8 @@ async def _deserialize_processed_response( scope_id: str | None = None, context_deserializer: ContextDeserializer | None = None, strict_context: bool = False, + program_call_ids: Collection[str] = (), + completed_program_call_ids: Collection[str] = (), ) -> ProcessedResponse: """Deserialize a ProcessedResponse from JSON data. @@ -1749,6 +1761,9 @@ async def _deserialize_processed_response( apply_patch_tools_map = _build_named_tool_map(all_tools, ApplyPatchTool) mcp_tools_map = _build_named_tool_map(all_tools, HostedMCPTool) handoffs_map = _build_handoffs_map(current_agent) + programmatic_tool_present = any( + isinstance(tool, ProgrammaticToolCallingTool) for tool in all_tools + ) from .run_internal.run_steps import ( ProcessedResponse, @@ -1762,6 +1777,26 @@ async def _deserialize_processed_response( ToolRunShellCall, ) + def _ensure_restored_tool_call_allowed( + *, + tool_call: Any, + allowed_callers: Sequence[ToolCaller] | None, + tool_name: str, + ) -> None: + ensure_programmatic_tool_call_parent( + tool_call=tool_call, + programmatic_tool_present=programmatic_tool_present, + program_call_ids=program_call_ids, + completed_program_call_ids=completed_program_call_ids, + agent_name=current_agent.name, + ) + ensure_tool_caller_allowed( + tool_call=tool_call, + allowed_callers=allowed_callers, + tool_name=tool_name, + agent_name=current_agent.name, + ) + def _deserialize_actions( entries: list[dict[str, Any]], *, @@ -1805,6 +1840,15 @@ def _deserialize_actions( tool_call = call_parser(tool_call_data) except Exception: continue + if isinstance(tool, Handoff): + permission_tool_name = getattr(tool, "tool_name", getattr(tool, "name", "handoff")) + else: + permission_tool_name = getattr(tool, "name", str(tool_name)) + _ensure_restored_tool_call_allowed( + tool_call=tool_call, + allowed_callers=getattr(tool, "allowed_callers", None), + tool_name=permission_tool_name, + ) deserialized.append(action_factory(tool_call, tool)) return deserialized @@ -1874,6 +1918,13 @@ def _deserialize_function_actions() -> list[_DeserializedFunctionAction]: tool_call = ResponseFunctionToolCall(**tool_call_data) except Exception: continue + _ensure_restored_tool_call_allowed( + tool_call=tool_call, + allowed_callers=function_tool.allowed_callers, + tool_name=( + get_function_tool_qualified_name(function_tool) or function_tool.name + ), + ) nested_state_data = entry.get("agent_run_state") deserialized.append( @@ -2015,6 +2066,11 @@ def _deserialize_function_actions() -> list[_DeserializedFunctionAction]: mcp_tool = mcp_tools_map.get(mcp_tool_name) if mcp_tool_name else None if mcp_tool: + _ensure_restored_tool_call_allowed( + tool_call=request_item, + allowed_callers=mcp_tool.tool_config.get("allowed_callers"), + tool_name=mcp_tool.name, + ) mcp_approval_requests.append( ToolRunMCPApprovalRequest( request_item=request_item, @@ -2061,6 +2117,12 @@ def _deserialize_tool_call_raw_item(normalized_raw_item: Mapping[str, Any]) -> A except Exception: return normalized_raw_item + if tool_type == "program": + try: + return Program(**normalized_raw_item) + except Exception: + return normalized_raw_item + if tool_type in {"shell_call", "apply_patch_call", "hosted_tool_call", "local_shell_call"}: return normalized_raw_item @@ -2197,8 +2259,15 @@ def _deserialize_tool_approval_item( def _deserialize_tool_call_output_raw_item( raw_item: Mapping[str, Any], -) -> FunctionCallOutput | ComputerCallOutput | LocalShellCallOutput | dict[str, Any] | None: - """Deserialize a tool call output raw item; return None when validation fails.""" +) -> ( + FunctionCallOutput + | ComputerCallOutput + | LocalShellCallOutput + | ProgramOutput + | dict[str, Any] + | None +): + """Deserialize a tool call output raw item, preserving program output mappings.""" if not isinstance(raw_item, Mapping): return cast( FunctionCallOutput | ComputerCallOutput | LocalShellCallOutput | dict[str, Any], @@ -2214,6 +2283,11 @@ def _deserialize_tool_call_output_raw_item( return _COMPUTER_OUTPUT_ADAPTER.validate_python(normalized_raw_item) if output_type == "local_shell_call_output": return _LOCAL_SHELL_OUTPUT_ADAPTER.validate_python(normalized_raw_item) + if output_type == "program_output": + try: + return ProgramOutput(**normalized_raw_item) + except Exception: + return normalized_raw_item if output_type in {"shell_call_output", "apply_patch_call_output", "custom_tool_call_output"}: return normalized_raw_item @@ -2245,6 +2319,120 @@ def _parse_guardrail_entry( return name, guardrail_output, entry_dict +def _raw_item_uses_programmatic_tool_calling(value: Any) -> bool: + """Return whether a known raw run item uses the programmatic calling protocol.""" + if not isinstance(value, Mapping): + return False + if value.get("type") in {"program", "program_output"}: + return True + caller = value.get("caller") + return ( + isinstance(caller, Mapping) + and caller.get("type") == "program" + and isinstance(caller.get("caller_id"), str) + ) + + +def _run_state_raw_items(state_json: Mapping[str, Any]) -> list[Any]: + """Collect raw items from durable RunState locations.""" + raw_items: list[Any] = [] + + original_input = state_json.get("original_input") + if isinstance(original_input, list): + raw_items.extend(original_input) + + for response_key in ("model_responses", "last_model_response"): + responses = state_json.get(response_key) + if isinstance(responses, Mapping): + responses = [responses] + if not isinstance(responses, list): + continue + for response in responses: + if isinstance(response, Mapping) and isinstance(response.get("output"), list): + raw_items.extend(response["output"]) + + for item_list_key in ("generated_items", "session_items"): + items = state_json.get(item_list_key) + if not isinstance(items, list): + continue + raw_items.extend(item.get("raw_item") for item in items if isinstance(item, Mapping)) + + processed_response = state_json.get("last_processed_response") + if isinstance(processed_response, Mapping): + for item_list_key in ("new_items", "interruptions"): + items = processed_response.get(item_list_key) + if isinstance(items, list): + raw_items.extend( + item.get("raw_item") for item in items if isinstance(item, Mapping) + ) + for action_key in ( + "functions", + "computer_actions", + "custom_tool_actions", + "local_shell_actions", + "shell_actions", + "apply_patch_actions", + "handoffs", + ): + actions = processed_response.get(action_key) + if isinstance(actions, list): + raw_items.extend( + action.get("tool_call") for action in actions if isinstance(action, Mapping) + ) + + current_step = state_json.get("current_step") + if isinstance(current_step, Mapping): + step_data = current_step.get("data") + if isinstance(step_data, Mapping) and isinstance(step_data.get("interruptions"), list): + raw_items.extend( + item.get("raw_item") + for item in step_data["interruptions"] + if isinstance(item, Mapping) + ) + + return raw_items + + +def _run_state_uses_programmatic_tool_calling(state_json: Mapping[str, Any]) -> bool: + """Inspect only durable run-item locations for programmatic calling data.""" + return any( + _raw_item_uses_programmatic_tool_calling(item) for item in _run_state_raw_items(state_json) + ) + + +def _run_state_program_call_ids( + state_json: Mapping[str, Any], +) -> tuple[set[str], set[str]]: + """Return all and completed program call IDs from durable RunState items.""" + program_call_ids: set[str] = set() + completed_program_call_ids: set[str] = set() + server_manages_conversation = bool( + state_json.get("conversation_id") + or state_json.get("previous_response_id") + or state_json.get("auto_previous_response_id") + ) + for item in _run_state_raw_items(state_json): + if not isinstance(item, Mapping): + continue + call_id = item.get("call_id") + if not isinstance(call_id, str) or not call_id: + call_id = None + if item.get("type") == "program" and call_id is not None: + program_call_ids.add(call_id) + elif item.get("type") == "program_output" and call_id is not None: + if server_manages_conversation: + program_call_ids.add(call_id) + if item.get("status") == "completed": + completed_program_call_ids.add(call_id) + if server_manages_conversation: + caller = item.get("caller") + if isinstance(caller, Mapping): + caller_id = caller.get("caller_id") + if caller.get("type") == "program" and isinstance(caller_id, str) and caller_id: + program_call_ids.add(caller_id) + return program_call_ids, completed_program_call_ids + + def _parse_tool_guardrail_entry( entry: Any, *, expected_type: Literal["tool_input", "tool_output"] ) -> tuple[str, ToolGuardrailFunctionOutput] | None: @@ -2424,6 +2612,14 @@ async def _build_run_state_from_json( f"Supported versions are: {supported_versions}. " f"New snapshots are written as version {CURRENT_SCHEMA_VERSION}." ) + if schema_version != CURRENT_SCHEMA_VERSION and _run_state_uses_programmatic_tool_calling( + state_json + ): + raise UserError( + "Run state contains Programmatic Tool Calling data but uses schema version " + f"{schema_version}. Programmatic Tool Calling requires schema version " + f"{CURRENT_SCHEMA_VERSION}." + ) agent_identity_map = _build_agent_identity_map(initial_agent) agent_map = _build_agent_map(initial_agent) @@ -2532,6 +2728,7 @@ async def _build_run_state_from_json( last_processed_response_data = state_json.get("last_processed_response") if last_processed_response_data and state._context is not None: + program_call_ids, completed_program_call_ids = _run_state_program_call_ids(state_json) state._last_processed_response = await _deserialize_processed_response( last_processed_response_data, current_agent, @@ -2541,6 +2738,8 @@ async def _build_run_state_from_json( scope_id=state._agent_tool_state_scope_id, context_deserializer=context_deserializer, strict_context=strict_context, + program_call_ids=program_call_ids, + completed_program_call_ids=completed_program_call_ids, ) else: state._last_processed_response = None @@ -3106,24 +3305,41 @@ def _deserialize_model_responses(responses_data: list[dict[str, Any]]) -> list[M for resp_data in responses_data: usage = deserialize_usage(resp_data.get("usage", {})) - output: list[Any] = [ - _deserialize_message_output_item(item) - if isinstance(item, Mapping) and item.get("type") == "message" - else item - for item in resp_data["output"] - ] + output: list[Any] = [] + for item in resp_data["output"]: + if not isinstance(item, Mapping): + output.append(item) + elif item.get("type") == "message": + output.append(_deserialize_message_output_item(item)) + elif item.get("type") == "program": + output.append(_deserialize_tool_call_raw_item(item)) + elif item.get("type") == "program_output": + output.append(_deserialize_tool_call_output_raw_item(item)) + else: + output.append(item) response_id = resp_data.get("response_id") request_id = resp_data.get("request_id") - result.append( - ModelResponse( + if any( + isinstance(item, Mapping) and item.get("type") in {"program", "program_output"} + for item in output + ): + model_response = ModelResponse( + usage=usage, + output=[], + response_id=response_id, + request_id=request_id, + ) + model_response.output = cast(list[TResponseOutputItem], output) + else: + model_response = ModelResponse( usage=usage, output=output, response_id=response_id, request_id=request_id, ) - ) + result.append(model_response) return result @@ -3323,6 +3539,9 @@ def _resolve_agent_info( raw_item_mcp_response = _MCP_APPROVAL_RESPONSE_ADAPTER.validate_python( normalized_raw_item ) + caller = normalized_raw_item.get("caller") + if caller is not None: + cast(dict[str, Any], raw_item_mcp_response)["caller"] = caller result.append(MCPApprovalResponseItem(agent=agent, raw_item=raw_item_mcp_response)) elif item_type == "tool_approval_item": diff --git a/src/agents/tool.py b/src/agents/tool.py index a9044999d9..def9ea2e66 100644 --- a/src/agents/tool.py +++ b/src/agents/tool.py @@ -69,6 +69,8 @@ ToolParams = ParamSpec("ToolParams") +ToolCaller = Literal["direct", "programmatic"] +_TOOL_CALLERS: tuple[ToolCaller, ...] = ("direct", "programmatic") ToolFunctionWithoutContext = Callable[ToolParams, Any] ToolFunctionWithContext = Callable[Concatenate[RunContextWrapper[Any], ToolParams], Any] @@ -455,6 +457,19 @@ class FunctionTool: ) """Optional callback that attaches SDK-only custom data to the tool output item.""" + allowed_callers: list[ToolCaller] | None = field(default=None, kw_only=True) + """Callers that may invoke this tool on OpenAI Responses models.""" + + output_json_schema: dict[str, Any] | None = field(default=None, kw_only=True) + """Optional JSON Schema describing this tool's output for programmatic callers.""" + + _output_type_adapter: TypeAdapter[Any] | None = field( + default=None, + kw_only=True, + repr=False, + ) + """Internal adapter used to validate and JSON-serialize typed function outputs.""" + _failure_error_function: ToolErrorFunction | None = field( default=None, kw_only=True, @@ -501,6 +516,14 @@ def qualified_name(self) -> str: ) def __post_init__(self): + self.allowed_callers = _normalize_tool_allowed_callers( + self.allowed_callers, + tool_name=self.qualified_name, + ) + if self.output_json_schema is not None: + self.output_json_schema = _normalize_function_tool_output_json_schema( + self.output_json_schema + ) bind_to_function_tool = getattr(self.on_invoke_tool, "__agents_bind_function_tool__", None) if callable(bind_to_function_tool): self.on_invoke_tool = bind_to_function_tool(self) @@ -616,6 +639,9 @@ def _build_wrapped_function_tool( timeout_error_function: ToolErrorFunction | None = None, defer_loading: bool = False, custom_data_extractor: FunctionToolCustomDataExtractor | None = None, + allowed_callers: list[ToolCaller] | None = None, + output_json_schema: dict[str, Any] | None = None, + output_type_adapter: TypeAdapter[Any] | None = None, sync_invoker: bool = False, mcp_title: str | None = None, tool_origin: ToolOrigin | None = None, @@ -644,6 +670,9 @@ def _build_wrapped_function_tool( timeout_error_function=timeout_error_function, defer_loading=defer_loading, custom_data_extractor=custom_data_extractor, + allowed_callers=allowed_callers, + output_json_schema=output_json_schema, + _output_type_adapter=output_type_adapter, _mcp_title=mcp_title, _tool_origin=tool_origin, ), @@ -972,6 +1001,16 @@ class HostedMCPTool: provided, you will need to manually add approvals/rejections to the input and call `Runner.run(...)` again.""" + def __post_init__(self) -> None: + tool_config = dict(self.tool_config) + allowed_callers = tool_config.get("allowed_callers") + if allowed_callers is not None: + tool_config["allowed_callers"] = _normalize_tool_allowed_callers( + allowed_callers, + tool_name=f"hosted MCP server `{tool_config.get('server_label', 'unknown')}`", + ) + self.tool_config = cast(Mcp, tool_config) + @property def name(self): return "hosted_mcp" @@ -984,6 +1023,16 @@ class CodeInterpreterTool: tool_config: CodeInterpreter """The tool config, which includes the container and other settings.""" + def __post_init__(self) -> None: + tool_config = dict(self.tool_config) + allowed_callers = tool_config.get("allowed_callers") + if allowed_callers is not None: + tool_config["allowed_callers"] = _normalize_tool_allowed_callers( + allowed_callers, + tool_name="code_interpreter", + ) + self.tool_config = cast(CodeInterpreter, tool_config) + @property def name(self): return "code_interpreter" @@ -1235,8 +1284,15 @@ class ShellTool: If omitted, local mode is used. """ + allowed_callers: list[ToolCaller] | None = field(default=None, kw_only=True) + """Callers that may invoke this tool on OpenAI Responses models.""" + def __post_init__(self) -> None: """Validate shell tool configuration and normalize environment fields.""" + self.allowed_callers = _normalize_tool_allowed_callers( + self.allowed_callers, + tool_name=self.name, + ) normalized_environment = _normalize_shell_tool_environment(self.environment) self.environment = normalized_environment @@ -1284,6 +1340,15 @@ class ApplyPatchTool: ) """Optional callback that attaches SDK-only custom data to the tool output item.""" + allowed_callers: list[ToolCaller] | None = field(default=None, kw_only=True) + """Callers that may invoke this tool on OpenAI Responses models.""" + + def __post_init__(self) -> None: + self.allowed_callers = _normalize_tool_allowed_callers( + self.allowed_callers, + tool_name=self.name, + ) + @property def type(self) -> str: return "apply_patch" @@ -1308,9 +1373,16 @@ class CustomTool: ) """Optional callback that attaches SDK-only custom data to the tool output item.""" + allowed_callers: list[ToolCaller] | None = field(default=None, kw_only=True) + """Callers that may invoke this tool on OpenAI Responses models.""" + tool_config: CustomToolParam = field(init=False, repr=False) def __post_init__(self) -> None: + self.allowed_callers = _normalize_tool_allowed_callers( + self.allowed_callers, + tool_name=self.name, + ) tool_config: CustomToolParam = { "type": "custom", "name": self.name, @@ -1320,6 +1392,8 @@ def __post_init__(self) -> None: tool_config["format"] = self.format # type: ignore[typeddict-item] if self.defer_loading: tool_config["defer_loading"] = True + if self.allowed_callers is not None: + tool_config["allowed_callers"] = self.allowed_callers self.tool_config = tool_config def runtime_needs_approval(self) -> bool | CustomToolApprovalFunction: @@ -1352,6 +1426,15 @@ def name(self) -> str: return "tool_search" +@dataclass +class ProgrammaticToolCallingTool: + """A hosted Responses tool that lets generated JavaScript orchestrate other tools.""" + + @property + def name(self) -> str: + return "programmatic_tool_calling" + + Tool = ( FunctionTool | FileSearchTool @@ -1365,6 +1448,7 @@ def name(self) -> str: | ImageGenerationTool | CodeInterpreterTool | ToolSearchTool + | ProgrammaticToolCallingTool ) """A tool that can be used in an agent.""" @@ -1402,6 +1486,10 @@ def get_function_tool_responses_only_features(tool: FunctionTool) -> tuple[str, features.append("tool_namespace()") if tool.defer_loading: features.append("defer_loading=True") + if tool.allowed_callers is not None: + features.append("allowed_callers") + if tool.output_json_schema is not None: + features.append("output_json_schema") return tuple(features) @@ -1429,7 +1517,11 @@ def ensure_tool_choice_supports_backend( backend_name: str, ) -> None: """Backend-specific converters should validate reserved tool choices.""" - return None + if tool_choice == "programmatic_tool_calling": + raise UserError( + "tool_choice='programmatic_tool_calling' is only supported with OpenAI Responses " + f"models and cannot be used with {backend_name}." + ) def is_responses_tool_search_surface(tool: Tool) -> bool: @@ -1489,6 +1581,70 @@ def validate_responses_tool_search_configuration( ) +def _get_tool_allowed_callers(tool: Tool) -> list[ToolCaller] | None: + """Return the caller permissions advertised by a Programmatic Tool Calling eligible tool.""" + if isinstance(tool, FunctionTool | CustomTool | ShellTool | ApplyPatchTool): + return tool.allowed_callers + if isinstance(tool, HostedMCPTool | CodeInterpreterTool): + return tool.tool_config.get("allowed_callers") + return None + + +def _get_tool_display_name(tool: Tool) -> str: + """Return an actionable display name for Programmatic Tool Calling errors.""" + if isinstance(tool, FunctionTool): + return tool.qualified_name + if isinstance(tool, HostedMCPTool): + return f"hosted MCP server `{tool.tool_config.get('server_label', 'unknown')}`" + return getattr(tool, "name", type(tool).__name__) + + +def validate_responses_programmatic_tool_calling_configuration( + tools: list[Tool], + *, + tool_choice: Literal["auto", "required", "none"] | str | Any | None = None, + allow_opaque_tool_search_surface: bool = False, +) -> None: + """Validate the complete Responses Programmatic Tool Calling configuration.""" + programmatic_tools = [tool for tool in tools if isinstance(tool, ProgrammaticToolCallingTool)] + if len(programmatic_tools) > 1: + raise UserError( + "Only one ProgrammaticToolCallingTool() is allowed when using OpenAI Responses models." + ) + + has_programmatic_tool = bool(programmatic_tools) + if tool_choice == "programmatic_tool_calling" and not has_programmatic_tool: + raise UserError( + "tool_choice='programmatic_tool_calling' requires ProgrammaticToolCallingTool() " + "when using OpenAI Responses models." + ) + + eligible_tools: list[Tool] = [] + for tool in tools: + allowed_callers = _get_tool_allowed_callers(tool) + if allowed_callers is None or "programmatic" not in allowed_callers: + continue + eligible_tools.append(tool) + if "direct" not in allowed_callers and not has_programmatic_tool: + raise UserError( + f"Tool `{_get_tool_display_name(tool)}` only allows programmatic callers and " + "requires ProgrammaticToolCallingTool() when using OpenAI Responses models." + ) + + has_tool_search = any(isinstance(tool, ToolSearchTool) for tool in tools) + if ( + has_programmatic_tool + and not eligible_tools + and not has_tool_search + and not allow_opaque_tool_search_surface + ): + raise UserError( + "ProgrammaticToolCallingTool() requires at least one tool whose allowed_callers " + "includes 'programmatic', a ToolSearchTool(), or an opaque prompt-managed tool " + "surface." + ) + + def prune_orphaned_tool_search_tools(tools: list[Tool]) -> list[Tool]: """Preserve explicit ToolSearchTool entries until request conversion validates them. @@ -1647,13 +1803,46 @@ def set_function_tool_failure_error_function( def resolve_function_tool_failure_error_function( function_tool: FunctionTool, + context: RunContextWrapper[Any] | None = None, ) -> ToolErrorFunction | None: """Return the configured tool failure formatter for runtime-generated error handling.""" if function_tool._use_default_failure_error_function: + if function_tool.output_json_schema is not None and _is_programmatic_tool_context(context): + return None return default_tool_error_function return function_tool._failure_error_function +_DEFAULT_FAILURE_HANDLED_ATTR = "_function_tool_default_failure_handled" + + +def _is_programmatic_tool_context(context: RunContextWrapper[Any] | None) -> bool: + """Return whether a tool context belongs to a hosted program call.""" + if not isinstance(context, ToolContext) or context.tool_call is None: + return False + return _is_programmatic_tool_call(context.tool_call) + + +def _is_programmatic_tool_call(tool_call: Any) -> bool: + """Return whether a tool call was produced by a hosted program.""" + caller = ( + tool_call.get("caller") + if isinstance(tool_call, Mapping) + else getattr(tool_call, "caller", None) + ) + caller_type = ( + caller.get("type") if isinstance(caller, Mapping) else getattr(caller, "type", None) + ) + return caller_type == "program" + + +def _consume_function_tool_default_failure(context: ToolContext[Any]) -> bool: + """Consume whether the default formatter produced the latest invocation result.""" + was_handled = bool(getattr(context, _DEFAULT_FAILURE_HANDLED_ATTR, False)) + setattr(context, _DEFAULT_FAILURE_HANDLED_ATTR, False) + return was_handled + + class _FunctionToolCancelledError(Exception): """Adapter that preserves the public ToolErrorFunction Exception contract on cancellation.""" @@ -1681,14 +1870,16 @@ async def maybe_invoke_function_tool_failure_error_function( error: BaseException, ) -> str | None: """Invoke the configured failure formatter, if one exists.""" - failure_error_function = resolve_function_tool_failure_error_function(function_tool) + failure_error_function = resolve_function_tool_failure_error_function(function_tool, context) if failure_error_function is None: return None formatter_error = _coerce_tool_error_for_failure_error_function(error) result = failure_error_function(context, formatter_error) if inspect.isawaitable(result): - return await result + result = await result + if function_tool._use_default_failure_error_function and isinstance(context, ToolContext): + setattr(context, _DEFAULT_FAILURE_HANDLED_ATTR, True) return result @@ -1810,21 +2001,53 @@ async def invoke_function_tool( arguments: str, ) -> Any: """Invoke a function tool, enforcing timeout configuration when provided.""" + invocation_result = await _invoke_function_tool_with_metadata( + function_tool=function_tool, + context=context, + arguments=arguments, + ) + return invocation_result.output + + +@dataclass(frozen=True) +class _FunctionToolInvocationResult: + """A function tool result with metadata for SDK-generated error outputs.""" + + output: Any + is_sdk_generated_error: bool = False + + +async def _invoke_function_tool_with_metadata( + *, + function_tool: FunctionTool, + context: ToolContext[Any], + arguments: str, +) -> _FunctionToolInvocationResult: + """Invoke a function tool and identify default SDK-generated timeout outputs.""" + _consume_function_tool_default_failure(context) invoke_context = _get_function_tool_invoke_context(function_tool, context) timeout_seconds = function_tool.timeout_seconds if timeout_seconds is None: - return await function_tool.on_invoke_tool(cast(Any, invoke_context), arguments) + output = await function_tool.on_invoke_tool(cast(Any, invoke_context), arguments) + return _FunctionToolInvocationResult( + output, + is_sdk_generated_error=_consume_function_tool_default_failure(context), + ) tool_task: asyncio.Future[Any] = asyncio.ensure_future( function_tool.on_invoke_tool(cast(Any, invoke_context), arguments) ) try: - return await asyncio.wait_for(tool_task, timeout=timeout_seconds) + output = await asyncio.wait_for(tool_task, timeout=timeout_seconds) + return _FunctionToolInvocationResult( + output, + is_sdk_generated_error=_consume_function_tool_default_failure(context), + ) except asyncio.TimeoutError as exc: if tool_task.done() and not tool_task.cancelled(): tool_exception = tool_task.exception() if tool_exception is None: - return tool_task.result() + return _FunctionToolInvocationResult(tool_task.result()) raise tool_exception from None timeout_error = ToolTimeoutError( @@ -1836,15 +2059,124 @@ async def invoke_function_tool( timeout_error_function = function_tool.timeout_error_function if timeout_error_function is None: - return default_tool_timeout_error_message( - tool_name=function_tool.name, - timeout_seconds=timeout_seconds, + return _FunctionToolInvocationResult( + default_tool_timeout_error_message( + tool_name=function_tool.name, + timeout_seconds=timeout_seconds, + ), + is_sdk_generated_error=True, ) timeout_result = timeout_error_function(context, timeout_error) if inspect.isawaitable(timeout_result): - return await timeout_result - return timeout_result + timeout_result = await timeout_result + return _FunctionToolInvocationResult(timeout_result) + + +def _json_schema_is_object(schema: dict[str, Any]) -> bool: + """Return whether a JSON Schema resolves to an object at its root.""" + current: Any = schema + seen_refs: set[str] = set() + while isinstance(current, dict): + if current.get("type") == "object": + return True + ref = current.get("$ref") + if not isinstance(ref, str) or not ref.startswith("#/") or ref in seen_refs: + return False + seen_refs.add(ref) + current = schema + for part in ref[2:].split("/"): + if not isinstance(current, dict): + return False + current = current.get(part.replace("~1", "/").replace("~0", "~")) + return False + + +def _build_function_tool_output_type( + output_type: Any, +) -> tuple[dict[str, Any], TypeAdapter[Any]]: + """Build a strict object schema and runtime adapter for a function output type.""" + try: + output_type_adapter = TypeAdapter(output_type) + output_json_schema = output_type_adapter.json_schema(mode="serialization") + if not _json_schema_is_object(output_json_schema): + raise UserError("the generated JSON Schema is not an object schema") + output_json_schema = ensure_strict_json_schema(copy.deepcopy(output_json_schema)) + except Exception as error: + raise UserError( + "Function tool output_type must define a strict JSON object schema. " + "Use a Pydantic model, TypedDict, or dataclass, or provide output_json_schema " + "directly." + ) from error + return output_json_schema, output_type_adapter + + +def _unwrap_annotated_type(annotation: Any) -> Any: + """Return the underlying type while ignoring Annotated metadata.""" + while get_origin(annotation) is Annotated: + args = get_args(annotation) + if not args: + break + annotation = args[0] + return annotation + + +def _resolve_function_tool_output( + *, + return_annotation: Any, + allowed_callers: list[ToolCaller] | None, + output_type: Any | None, + output_json_schema: dict[str, Any] | None, +) -> tuple[dict[str, Any] | None, TypeAdapter[Any] | None]: + """Resolve explicit or inferred structured output configuration for a function tool.""" + if output_type is not None and output_json_schema is not None: + raise UserError("output_type and output_json_schema cannot both be provided.") + + if output_type is not None: + return _build_function_tool_output_type(output_type) + + if output_json_schema is not None: + return copy.deepcopy(output_json_schema), None + + if allowed_callers is None or "programmatic" not in allowed_callers: + return None, None + + plain_return_annotation = _unwrap_annotated_type(return_annotation) + if ( + plain_return_annotation is inspect.Signature.empty + or plain_return_annotation is Any + or plain_return_annotation is None + or plain_return_annotation is str + or plain_return_annotation is type(None) + ): + return None, None + + try: + return _build_function_tool_output_type(return_annotation) + except UserError as error: + raise UserError( + "A programmatic function tool return annotation must define a strict JSON object " + "schema. Use a Pydantic model, TypedDict, or dataclass; annotate the return as " + "str or Any for an untyped result; or provide output_type or output_json_schema." + ) from error + + +def _validate_function_tool_output( + *, + tool_name: str, + output: Any, + output_type_adapter: TypeAdapter[Any] | None, +) -> Any: + """Validate a typed function output before it reaches hooks or serialization.""" + if output_type_adapter is None: + return output + try: + return output_type_adapter.validate_python(output) + except ValidationError as error: + raise UserError( + f"Function tool {tool_name} returned an output that does not match its declared " + f"output type: {error}" + ) from error @overload @@ -1867,6 +2199,9 @@ def function_tool( timeout_error_function: ToolErrorFunction | None = None, defer_loading: bool = False, custom_data_extractor: FunctionToolCustomDataExtractor | None = None, + allowed_callers: list[ToolCaller] | None = None, + output_type: Any | None = None, + output_json_schema: dict[str, Any] | None = None, ) -> FunctionTool: """Overload for usage as @function_tool (no parentheses).""" ... @@ -1891,6 +2226,9 @@ def function_tool( timeout_error_function: ToolErrorFunction | None = None, defer_loading: bool = False, custom_data_extractor: FunctionToolCustomDataExtractor | None = None, + allowed_callers: list[ToolCaller] | None = None, + output_type: Any | None = None, + output_json_schema: dict[str, Any] | None = None, ) -> Callable[[ToolFunction[...]], FunctionTool]: """Overload for usage as @function_tool(...).""" ... @@ -1915,6 +2253,9 @@ def function_tool( timeout_error_function: ToolErrorFunction | None = None, defer_loading: bool = False, custom_data_extractor: FunctionToolCustomDataExtractor | None = None, + allowed_callers: list[ToolCaller] | None = None, + output_type: Any | None = None, + output_json_schema: dict[str, Any] | None = None, ) -> FunctionTool | Callable[[ToolFunction[...]], FunctionTool]: """ Decorator to create a FunctionTool from a function. By default, we will: @@ -1962,6 +2303,13 @@ def function_tool( explicitly loads it. custom_data_extractor: Optional callback that returns SDK-only custom data to attach to the emitted ``ToolCallOutputItem``. The returned mapping is not sent to the model. + allowed_callers: Callers that may invoke the tool on OpenAI Responses models. Include + ``"programmatic"`` to allow generated programs to call it. + output_type: Optional Python output type used to generate and validate a strict output + schema. For programmatic tools this is inferred from a structured return annotation + when omitted. Use this override when the callable has no usable return annotation. + output_json_schema: Optional JSON Schema describing the tool's output for programmatic + callers. This low-level escape hatch is mutually exclusive with ``output_type``. """ def _create_function_tool(the_func: ToolFunction[...]) -> FunctionTool: @@ -1974,6 +2322,12 @@ def _create_function_tool(the_func: ToolFunction[...]) -> FunctionTool: use_docstring_info=use_docstring_info, strict_json_schema=strict_mode, ) + resolved_output_json_schema, output_type_adapter = _resolve_function_tool_output( + return_annotation=schema.return_annotation, + allowed_callers=allowed_callers, + output_type=output_type, + output_json_schema=output_json_schema, + ) async def _on_invoke_tool_impl(ctx: ToolContext[Any], input: str) -> Any: tool_name = ctx.tool_name @@ -2005,6 +2359,12 @@ async def _on_invoke_tool_impl(ctx: ToolContext[Any], input: str) -> Any: else: result = await asyncio.to_thread(the_func, *args, **kwargs_dict) + result = _validate_function_tool_output( + tool_name=tool_name, + output=result, + output_type_adapter=output_type_adapter, + ) + if _debug.DONT_LOG_TOOL_DATA: logger.debug("Tool %s completed.", tool_name) else: @@ -2033,6 +2393,9 @@ async def _on_invoke_tool_impl(ctx: ToolContext[Any], input: str) -> Any: timeout_error_function=timeout_error_function, defer_loading=defer_loading, custom_data_extractor=custom_data_extractor, + allowed_callers=allowed_callers, + output_json_schema=resolved_output_json_schema, + output_type_adapter=output_type_adapter, sync_invoker=is_sync_function_tool, ) return function_tool @@ -2053,6 +2416,51 @@ def decorator(real_func: ToolFunction[...]) -> FunctionTool: # -------------------------- +def _normalize_tool_allowed_callers( + allowed_callers: Any, + *, + tool_name: str, +) -> list[ToolCaller] | None: + """Validate and copy the caller permission list for an eligible Responses tool.""" + if allowed_callers is None: + return None + if not isinstance(allowed_callers, list) or not allowed_callers: + raise UserError( + f"Tool `{tool_name}` allowed_callers must be a non-empty list containing " + "'direct', 'programmatic', or both." + ) + + normalized: list[ToolCaller] = [] + for caller in allowed_callers: + if caller not in _TOOL_CALLERS: + raise UserError( + f"Tool `{tool_name}` allowed_callers contains unsupported caller {caller!r}. " + "Expected 'direct' or 'programmatic'." + ) + normalized_caller = cast(ToolCaller, caller) + if normalized_caller in normalized: + raise UserError( + f"Tool `{tool_name}` allowed_callers contains duplicate caller " + f"{normalized_caller!r}." + ) + normalized.append(normalized_caller) + return normalized + + +def _normalize_function_tool_output_json_schema( + output_json_schema: dict[str, Any], +) -> dict[str, Any]: + """Copy and normalize a declared function output schema as a strict object schema.""" + if not isinstance(output_json_schema, dict) or not _json_schema_is_object(output_json_schema): + raise UserError("Function tool output_json_schema must define a JSON object schema.") + try: + return ensure_strict_json_schema(copy.deepcopy(output_json_schema)) + except Exception as error: + raise UserError( + "Function tool output_json_schema must define a strict JSON object schema." + ) from error + + def _is_computer_provider(candidate: object) -> bool: if isinstance(candidate, ComputerProvider): return True diff --git a/tests/models/test_model_retry.py b/tests/models/test_model_retry.py index fa9b6fe842..c81f4e3dc3 100644 --- a/tests/models/test_model_retry.py +++ b/tests/models/test_model_retry.py @@ -109,6 +109,55 @@ def _status_error_without_code(status_code: int, body_code: str = "server_error" ) +@pytest.mark.asyncio +async def test_programmatic_request_disables_hidden_provider_retries() -> None: + provider_retry_flags: list[bool] = [] + + async def get_response() -> ModelResponse: + provider_retry_flags.append(should_disable_provider_managed_retries()) + return ModelResponse(output=[get_text_message("ok")], usage=Usage(), response_id="resp") + + result = await get_response_with_retry( + get_response=get_response, + rewind=lambda: asyncio.sleep(0), + retry_settings=None, + get_retry_advice=lambda _request: None, + previous_response_id=None, + conversation_id=None, + replay_unsafe_request=True, + ) + + assert result.response_id == "resp" + assert provider_retry_flags == [True] + + +@pytest.mark.asyncio +async def test_programmatic_request_requires_replay_approval_for_runner_retry() -> None: + calls = 0 + + async def get_response() -> ModelResponse: + nonlocal calls + calls += 1 + raise _connection_error() + + with pytest.raises(APIConnectionError): + await get_response_with_retry( + get_response=get_response, + rewind=lambda: asyncio.sleep(0), + retry_settings=ModelRetrySettings( + max_retries=1, + backoff={"initial_delay": 0}, + policy=retry_policies.network_error(), + ), + get_retry_advice=lambda _request: None, + previous_response_id=None, + conversation_id=None, + replay_unsafe_request=True, + ) + + assert calls == 1 + + def test_get_openai_retry_advice_returns_none_for_non_retryable_status() -> None: advice = get_openai_retry_advice( ModelRetryAdviceRequest( diff --git a/tests/test_programmatic_tool_calling.py b/tests/test_programmatic_tool_calling.py new file mode 100644 index 0000000000..3ad360ab51 --- /dev/null +++ b/tests/test_programmatic_tool_calling.py @@ -0,0 +1,2597 @@ +from __future__ import annotations + +import asyncio +import json +from dataclasses import dataclass +from typing import Annotated, Any, Literal, cast + +import pytest +from openai.types.responses import ( + ResponseApplyPatchToolCall, + ResponseCustomToolCall, + ResponseFileSearchToolCall, + ResponseFunctionShellToolCall, + ResponseFunctionShellToolCallOutput, + ResponseFunctionToolCall, + ResponseFunctionWebSearch, + ResponseToolSearchCall, + ResponseToolSearchOutputItem, +) +from openai.types.responses.response_apply_patch_tool_call import OperationCreateFile +from openai.types.responses.response_code_interpreter_tool_call import ( + ResponseCodeInterpreterToolCall, +) +from openai.types.responses.response_function_shell_tool_call import Action +from openai.types.responses.response_function_tool_call import CallerProgram +from openai.types.responses.response_function_web_search import ActionSearch +from openai.types.responses.response_output_item import ( + ImageGenerationCall, + McpApprovalRequest, + McpCall, + McpListTools, + Program, + ProgramOutput, +) +from pydantic import BaseModel, ConfigDict, Field +from typing_extensions import TypedDict + +from agents import ( + Agent, + ApplyPatchTool, + CodeInterpreterTool, + CustomTool, + HostedMCPTool, + ModelResponse, + ModelSettings, + ProgrammaticToolCallingTool, + RunConfig, + RunItem, + Runner, + RunState, + ShellTool, + ToolCallItem, + ToolCallOutputItem, + ToolExecutionConfig, + ToolGuardrailFunctionOutput, + ToolInputGuardrailData, + ToolOutputGuardrailData, + ToolSearchTool, + Usage, + UserError, + function_tool, + tool_input_guardrail, + tool_output_guardrail, +) +from agents.exceptions import ModelBehaviorError +from agents.items import ItemHelpers +from agents.memory import SQLiteSession +from agents.models.chatcmpl_converter import Converter as ChatCompletionsConverter +from agents.models.openai_responses import Converter as ResponsesConverter +from agents.run_internal.turn_resolution import process_model_response +from agents.tool_context import ToolContext + +from .fake_model import FakeModel +from .test_responses import get_text_message + +PROGRAM_CALL_ID = "call_program" +FUNCTION_CALL_ID = "call_lookup" +PROGRAM_CALLER = {"type": "program", "caller_id": PROGRAM_CALL_ID} + + +class InventoryOutput(BaseModel): + sku: str + available_units: int + + +class InventoryDict(TypedDict): + sku: str + available_units: int + + +@dataclass +class InventoryData: + sku: str + available_units: int + + +class AliasedInventoryOutput(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + sku: str + available_units: int = Field( + validation_alias="inputUnits", + serialization_alias="availableUnits", + ) + + +def _program() -> Program: + return Program( + id="program_item", + call_id=PROGRAM_CALL_ID, + code='lookup_inventory(sku="A-1")', + fingerprint="fingerprint", + type="program", + ) + + +def _function_call() -> ResponseFunctionToolCall: + return ResponseFunctionToolCall( + id="function_item", + call_id=FUNCTION_CALL_ID, + name="lookup_inventory", + arguments='{"sku":"A-1"}', + caller=CallerProgram(type="program", caller_id=PROGRAM_CALL_ID), + type="function_call", + ) + + +def _program_output( + status: Literal["completed", "incomplete"] = "completed", +) -> ProgramOutput: + return ProgramOutput( + id="program_output_item", + call_id=PROGRAM_CALL_ID, + result='{"sku":"A-1","available_units":42}', + status=status, + type="program_output", + ) + + +def _hosted_program_call_and_tool( + output_type: str, + allowed_callers: list[Any] | None, +) -> tuple[Any, Any]: + if output_type in ("mcp_approval_request", "mcp_call", "mcp_list_tools"): + mcp_config: dict[str, Any] = { + "type": "mcp", + "server_label": "docs_server", + "server_url": "https://example.com/mcp", + } + if allowed_callers is not None: + mcp_config["allowed_callers"] = allowed_callers + hosted_tool = HostedMCPTool(tool_config=cast(Any, mcp_config)) + if output_type == "mcp_list_tools": + return ( + McpListTools.model_construct( + id="mcp_list_tools_1", + server_label="docs_server", + tools=[], + type="mcp_list_tools", + caller=PROGRAM_CALLER, + ), + hosted_tool, + ) + call_type: Any = McpApprovalRequest if output_type == "mcp_approval_request" else McpCall + output = call_type.model_construct( + id=f"{output_type}_1", + arguments="{}", + name="search_docs", + server_label="docs_server", + type=output_type, + caller=PROGRAM_CALLER, + ) + return output, hosted_tool + + code_interpreter_config: dict[str, Any] = { + "type": "code_interpreter", + "container": "auto", + } + if allowed_callers is not None: + code_interpreter_config["allowed_callers"] = allowed_callers + return ( + ResponseCodeInterpreterToolCall.model_construct( + id="code_interpreter_1", + container_id="container_1", + status="completed", + type="code_interpreter_call", + caller=PROGRAM_CALLER, + ), + CodeInterpreterTool(tool_config=cast(Any, code_interpreter_config)), + ) + + +def _caller_dict(value: Any) -> dict[str, str]: + if isinstance(value, dict): + return cast(dict[str, str], value) + return cast(dict[str, str], value.model_dump(exclude_none=True)) + + +def _raw_item_type(value: Any) -> str | None: + if isinstance(value, dict): + item_type = value.get("type") + return item_type if isinstance(item_type, str) else None + item_type = getattr(value, "type", None) + return item_type if isinstance(item_type, str) else None + + +def _function_output_raw_items(result: Any) -> list[dict[str, Any]]: + return [ + cast(dict[str, Any], item.raw_item) + for item in result.new_items + if isinstance(item, ToolCallOutputItem) + and isinstance(item.raw_item, dict) + and item.raw_item.get("type") == "function_call_output" + ] + + +def test_responses_converter_serializes_programmatic_tool_configuration() -> None: + @function_tool(allowed_callers=["programmatic"]) + def lookup_inventory(sku: str) -> InventoryOutput: + return InventoryOutput(sku=sku, available_units=42) + + converted = ResponsesConverter.convert_tools( + tools=[ProgrammaticToolCallingTool(), lookup_inventory], + handoffs=[], + ) + + assert converted.tools[0] == {"type": "programmatic_tool_calling"} + function_payload = cast(dict[str, Any], converted.tools[1]) + assert function_payload["allowed_callers"] == ["programmatic"] + assert function_payload["output_schema"] == lookup_inventory.output_json_schema + assert function_payload["output_schema"] == { + "additionalProperties": False, + "properties": { + "sku": {"title": "Sku", "type": "string"}, + "available_units": {"title": "Available Units", "type": "integer"}, + }, + "required": ["sku", "available_units"], + "title": "InventoryOutput", + "type": "object", + } + assert ResponsesConverter.convert_tool_choice("programmatic_tool_calling") == { + "type": "programmatic_tool_calling" + } + + +def test_function_tool_infers_typed_dict_and_dataclass_output_schemas() -> None: + @function_tool(allowed_callers=["programmatic"]) + def typed_dict_tool() -> InventoryDict: + return {"sku": "A-1", "available_units": 42} + + @function_tool(allowed_callers=["programmatic"]) + def dataclass_tool() -> InventoryData: + return InventoryData(sku="A-1", available_units=42) + + assert typed_dict_tool.output_json_schema is not None + assert typed_dict_tool.output_json_schema["type"] == "object" + assert typed_dict_tool.output_json_schema["additionalProperties"] is False + assert dataclass_tool.output_json_schema is not None + assert dataclass_tool.output_json_schema["type"] == "object" + assert dataclass_tool.output_json_schema["additionalProperties"] is False + + +def test_function_tool_treats_annotated_plain_returns_as_untyped() -> None: + @function_tool(allowed_callers=["programmatic"]) + def string_tool() -> Annotated[str, "plain string"]: + return "ok" + + @function_tool(allowed_callers=["programmatic"]) + def any_tool() -> Annotated[Any, "untyped value"]: + return {"status": "ok"} + + @function_tool(allowed_callers=["programmatic"]) + def none_tool() -> Annotated[None, "no value"]: + return None + + assert string_tool.output_json_schema is None + assert string_tool._output_type_adapter is None + assert any_tool.output_json_schema is None + assert any_tool._output_type_adapter is None + assert none_tool.output_json_schema is None + assert none_tool._output_type_adapter is None + + +def test_function_tool_preserves_annotated_structured_return_metadata() -> None: + @function_tool(allowed_callers=["programmatic"]) + def lookup_inventory() -> Annotated[ + InventoryOutput, + Field(description="Inventory result"), + ]: + return InventoryOutput(sku="A-1", available_units=42) + + assert lookup_inventory.output_json_schema is not None + assert lookup_inventory.output_json_schema["description"] == "Inventory result" + assert lookup_inventory._output_type_adapter is not None + + +def test_function_tool_output_type_override_and_raw_schema_are_mutually_exclusive() -> None: + def unannotated_tool() -> Any: + return {"sku": "A-1", "available_units": 42} + + tool = function_tool( + unannotated_tool, + allowed_callers=["programmatic"], + output_type=InventoryOutput, + ) + + assert tool.output_json_schema is not None + assert tool.output_json_schema["title"] == "InventoryOutput" + + with pytest.raises(UserError, match="cannot both be provided"): + function_tool( + unannotated_tool, + allowed_callers=["programmatic"], + output_type=InventoryOutput, + output_json_schema={"type": "object"}, + ) + + with pytest.raises(UserError, match="output_type must define a strict JSON object"): + function_tool( + unannotated_tool, + allowed_callers=["programmatic"], + output_type=str, + ) + + +def test_function_tool_rejects_loose_programmatic_output_annotation() -> None: + def loose_dict_tool() -> dict[str, Any]: + return {"sku": "A-1", "available_units": 42} + + with pytest.raises(UserError, match="return annotation must define a strict JSON object"): + function_tool(loose_dict_tool, allowed_callers=["programmatic"]) + + +def test_function_tool_does_not_infer_non_programmatic_output() -> None: + @function_tool + def direct_tool() -> InventoryOutput: + return InventoryOutput(sku="A-1", available_units=42) + + assert direct_tool.output_json_schema is None + + +@pytest.mark.parametrize( + "output_json_schema", + [ + {"type": "string"}, + {"type": "object", "additionalProperties": True}, + ], +) +def test_function_tool_rejects_non_object_or_non_strict_raw_output_schema( + output_json_schema: dict[str, Any], +) -> None: + with pytest.raises(UserError, match="output_json_schema must define a.*object schema"): + function_tool( + lambda: "ok", + allowed_callers=["programmatic"], + output_json_schema=output_json_schema, + ) + + +@pytest.mark.asyncio +async def test_function_tool_validates_inferred_output_type() -> None: + @function_tool(allowed_callers=["programmatic"]) + def invalid_tool() -> InventoryOutput: + return {"sku": "A-1", "available_units": "many"} # type: ignore[return-value] + + context = ToolContext( + None, + tool_name=invalid_tool.name, + tool_call_id="invalid", + tool_arguments="{}", + tool_call=_function_call(), + ) + with pytest.raises(UserError, match="does not match its declared output type"): + await invalid_tool.on_invoke_tool(context, "{}") + + +@pytest.mark.asyncio +async def test_schema_backed_programmatic_tool_bypasses_default_failure_formatter() -> None: + @function_tool(allowed_callers=["programmatic"]) + def failing_tool() -> InventoryOutput: + raise RuntimeError("inventory unavailable") + + context = ToolContext( + None, + tool_name=failing_tool.name, + tool_call_id="failing", + tool_arguments="{}", + tool_call=_function_call(), + ) + with pytest.raises(RuntimeError, match="inventory unavailable"): + await failing_tool.on_invoke_tool(context, "{}") + + @function_tool( + allowed_callers=["programmatic"], + output_json_schema={ + "type": "object", + "properties": {"error": {"type": "string"}}, + "required": ["error"], + "additionalProperties": False, + }, + ) + def failing_declared_schema_tool() -> str: + raise RuntimeError("declared schema unavailable") + + declared_context = ToolContext( + None, + tool_name=failing_declared_schema_tool.name, + tool_call_id="failing-declared", + tool_arguments="{}", + tool_call=_function_call(), + ) + with pytest.raises(RuntimeError, match="declared schema unavailable"): + await failing_declared_schema_tool.on_invoke_tool(declared_context, "{}") + + +@pytest.mark.asyncio +async def test_schema_backed_direct_tool_preserves_argument_error_formatter() -> None: + @function_tool(allowed_callers=["direct", "programmatic"]) + def failing_tool(sku: str) -> InventoryOutput: + return InventoryOutput(sku=sku, available_units=42) + + direct_call = ResponseFunctionToolCall( + id="function_item", + call_id=FUNCTION_CALL_ID, + name=failing_tool.name, + arguments="{}", + type="function_call", + ) + context = ToolContext( + None, + tool_name=failing_tool.name, + tool_call_id=FUNCTION_CALL_ID, + tool_arguments="{}", + tool_call=direct_call, + ) + + result = await failing_tool.on_invoke_tool(context, "{}") + + assert result.startswith("An error occurred while running the tool. Please try again. Error:") + assert "sku" in result + + +@pytest.mark.asyncio +async def test_runner_preserves_direct_error_for_schema_backed_tool() -> None: + model = FakeModel() + direct_call = ResponseFunctionToolCall( + id="function_item", + call_id=FUNCTION_CALL_ID, + name="lookup_inventory", + arguments='{"sku":"A-1"}', + type="function_call", + ) + model.add_multiple_turn_outputs([[direct_call], [get_text_message("inventory lookup failed")]]) + + @function_tool(allowed_callers=["direct", "programmatic"]) + def lookup_inventory(sku: str) -> InventoryOutput: + raise RuntimeError(f"inventory unavailable for {sku}") + + result = await Runner.run( + Agent(name="inventory", model=model, tools=[lookup_inventory]), + "Check inventory", + ) + + function_output = next( + item for item in result.new_items if isinstance(item, ToolCallOutputItem) + ) + expected_error = ( + "An error occurred while running the tool. Please try again. " + "Error: inventory unavailable for A-1" + ) + assert result.final_output == "inventory lookup failed" + assert function_output.output == expected_error + assert cast(dict[str, Any], function_output.raw_item)["output"] == expected_error + + +@pytest.mark.asyncio +async def test_runner_preserves_direct_default_timeout_for_schema_backed_tool() -> None: + model = FakeModel() + direct_call = ResponseFunctionToolCall( + id="function_item", + call_id=FUNCTION_CALL_ID, + name="lookup_inventory", + arguments='{"sku":"A-1"}', + type="function_call", + ) + model.add_multiple_turn_outputs([[direct_call], [get_text_message("timed out")]]) + + @function_tool(allowed_callers=["direct", "programmatic"], timeout=0.01) + async def lookup_inventory(sku: str) -> InventoryOutput: + await asyncio.sleep(0.2) + return InventoryOutput(sku=sku, available_units=42) + + result = await Runner.run( + Agent(name="inventory", model=model, tools=[lookup_inventory]), + "Check inventory", + ) + + function_output = next( + item for item in result.new_items if isinstance(item, ToolCallOutputItem) + ) + assert result.final_output == "timed out" + assert isinstance(function_output.output, str) + assert "timed out" in function_output.output.lower() + assert cast(dict[str, Any], function_output.raw_item)["output"] == function_output.output + + +@pytest.mark.asyncio +async def test_schema_backed_function_tool_accepts_conforming_custom_error_output() -> None: + @function_tool( + allowed_callers=["programmatic"], + failure_error_function=lambda _context, _error: json.dumps( + {"sku": "ERROR", "available_units": 0} + ), + ) + def failing_tool() -> InventoryOutput: + raise RuntimeError("inventory unavailable") + + context = ToolContext( + None, + tool_name=failing_tool.name, + tool_call_id="failing", + tool_arguments="{}", + ) + result = await failing_tool.on_invoke_tool(context, "{}") + output = ItemHelpers.tool_call_output_item( + _function_call(), + result, + output_json_schema=failing_tool.output_json_schema, + output_type_adapter=failing_tool._output_type_adapter, + ) + + assert json.loads(cast(str, output["output"])) == { + "sku": "ERROR", + "available_units": 0, + } + + +@pytest.mark.asyncio +async def test_schema_backed_programmatic_tool_accepts_conforming_custom_timeout_output() -> None: + model = FakeModel() + model.add_multiple_turn_outputs( + [ + [_program(), _function_call()], + [_program_output(), get_text_message("timeout handled")], + ] + ) + + @function_tool( + allowed_callers=["programmatic"], + timeout=0.01, + timeout_error_function=lambda _context, _error: json.dumps( + {"sku": "TIMEOUT", "available_units": 0} + ), + ) + async def lookup_inventory(sku: str) -> InventoryOutput: + await asyncio.sleep(0.2) + return InventoryOutput(sku=sku, available_units=42) + + result = await Runner.run( + Agent( + name="inventory", + model=model, + tools=[ProgrammaticToolCallingTool(), lookup_inventory], + ), + "Check inventory", + ) + + function_output = next( + item for item in result.new_items if isinstance(item, ToolCallOutputItem) + ) + assert result.final_output == "timeout handled" + assert json.loads(cast(str, cast(dict[str, Any], function_output.raw_item)["output"])) == { + "sku": "TIMEOUT", + "available_units": 0, + } + + +def test_schema_backed_function_output_rejects_plain_error_text() -> None: + @function_tool(allowed_callers=["programmatic"]) + def lookup_inventory() -> InventoryOutput: + return InventoryOutput(sku="A-1", available_units=42) + + with pytest.raises(UserError, match="does not match its declared output schema"): + ItemHelpers.tool_call_output_item( + _function_call(), + "inventory unavailable", + output_json_schema=lookup_inventory.output_json_schema, + output_type_adapter=lookup_inventory._output_type_adapter, + ) + + with pytest.raises(UserError, match="requires a JSON object"): + ItemHelpers.tool_call_output_item( + _function_call(), + "inventory unavailable", + output_json_schema={"type": "object"}, + ) + + +@pytest.mark.asyncio +async def test_function_tool_serializes_typed_output_with_schema_aliases() -> None: + @function_tool(allowed_callers=["programmatic"]) + def aliased_tool() -> AliasedInventoryOutput: + return AliasedInventoryOutput(sku="A-1", available_units=42) + + context = ToolContext( + None, + tool_name=aliased_tool.name, + tool_call_id="aliased", + tool_arguments="{}", + ) + result = await aliased_tool.on_invoke_tool(context, "{}") + output = ItemHelpers.tool_call_output_item( + _function_call(), + result, + output_json_schema=aliased_tool.output_json_schema, + output_type_adapter=aliased_tool._output_type_adapter, + ) + + assert aliased_tool.output_json_schema is not None + assert "availableUnits" in aliased_tool.output_json_schema["properties"] + assert json.loads(cast(str, output["output"])) == { + "sku": "A-1", + "availableUnits": 42, + } + + +def test_responses_converter_serializes_allowed_callers_for_other_eligible_tools() -> None: + async def shell_executor(_request: Any) -> str: + return "ok" + + def custom_executor(_context: Any, _input: str) -> str: + return "ok" + + class Editor: + def create_file(self, _operation: Any) -> str: + return "ok" + + def update_file(self, _operation: Any) -> str: + return "ok" + + def delete_file(self, _operation: Any) -> str: + return "ok" + + converted = ResponsesConverter.convert_tools( + tools=[ + ProgrammaticToolCallingTool(), + ShellTool(executor=shell_executor, allowed_callers=["programmatic"]), + ApplyPatchTool(editor=Editor(), allowed_callers=["direct", "programmatic"]), + CustomTool( + name="custom", + description="Custom tool", + on_invoke_tool=custom_executor, + allowed_callers=["programmatic"], + ), + ], + handoffs=[], + ) + + tool_payloads = [cast(dict[str, Any], tool) for tool in converted.tools] + assert tool_payloads[1]["allowed_callers"] == ["programmatic"] + assert tool_payloads[2]["allowed_callers"] == ["direct", "programmatic"] + assert tool_payloads[3]["allowed_callers"] == ["programmatic"] + + +@pytest.mark.parametrize( + "allowed_callers", + [ + [], + ["direct", "direct"], + ["unsupported"], + ], +) +def test_tool_construction_rejects_invalid_allowed_callers( + allowed_callers: list[Any], +) -> None: + with pytest.raises(UserError, match="allowed_callers"): + function_tool(lambda: "ok", allowed_callers=allowed_callers) + + with pytest.raises(UserError, match="allowed_callers"): + ShellTool(executor=lambda _request: "ok", allowed_callers=allowed_callers) + + with pytest.raises(UserError, match="allowed_callers"): + HostedMCPTool( + tool_config=cast( + Any, + { + "type": "mcp", + "server_label": "inventory", + "server_url": "https://example.com/mcp", + "allowed_callers": allowed_callers, + }, + ) + ) + + with pytest.raises(UserError, match="allowed_callers"): + CodeInterpreterTool( + tool_config=cast( + Any, + { + "type": "code_interpreter", + "container": "auto", + "allowed_callers": allowed_callers, + }, + ) + ) + + +def test_responses_converter_rejects_incomplete_programmatic_configuration() -> None: + @function_tool(allowed_callers=["programmatic"]) + def programmatic_only() -> str: + return "ok" + + with pytest.raises(UserError, match="requires ProgrammaticToolCallingTool"): + ResponsesConverter.convert_tools(tools=[programmatic_only], handoffs=[]) + + with pytest.raises(UserError, match="requires ProgrammaticToolCallingTool"): + ResponsesConverter.convert_tools( + tools=[], + handoffs=[], + tool_choice="programmatic_tool_calling", + ) + + with pytest.raises(UserError, match="requires at least one tool"): + ResponsesConverter.convert_tools( + tools=[ProgrammaticToolCallingTool()], + handoffs=[], + ) + + with pytest.raises(UserError, match="Only one ProgrammaticToolCallingTool"): + ResponsesConverter.convert_tools( + tools=[ProgrammaticToolCallingTool(), ProgrammaticToolCallingTool()], + handoffs=[], + ) + + +def test_responses_converter_accepts_mixed_or_tool_search_managed_configuration() -> None: + @function_tool(allowed_callers=["direct", "programmatic"]) + def mixed_callers() -> str: + return "ok" + + converted_mixed = ResponsesConverter.convert_tools(tools=[mixed_callers], handoffs=[]) + assert cast(dict[str, Any], converted_mixed.tools[0])["allowed_callers"] == [ + "direct", + "programmatic", + ] + + converted_search = ResponsesConverter.convert_tools( + tools=[ProgrammaticToolCallingTool(), ToolSearchTool()], + handoffs=[], + allow_opaque_tool_search_surface=True, + ) + assert converted_search.tools == [ + {"type": "programmatic_tool_calling"}, + {"type": "tool_search"}, + ] + + +def test_chat_completions_rejects_programmatic_tool_configuration() -> None: + @function_tool(allowed_callers=["programmatic"]) + def lookup_inventory() -> InventoryOutput: + return InventoryOutput(sku="A-1", available_units=42) + + with pytest.raises(UserError, match="only supported with OpenAI Responses models"): + ChatCompletionsConverter.tool_to_openai(lookup_inventory) + + with pytest.raises(UserError, match="programmatic_tool_calling"): + ChatCompletionsConverter.convert_tool_choice("programmatic_tool_calling") + + with pytest.raises(UserError, match="Hosted tools are not supported"): + ChatCompletionsConverter.tool_to_openai(ProgrammaticToolCallingTool()) + + +def test_function_output_preserves_caller_and_uses_declared_json_schema() -> None: + output = ItemHelpers.tool_call_output_item( + _function_call(), + {"sku": "A-1", "available_units": 42}, + output_json_schema={"type": "object"}, + ) + + assert json.loads(cast(str, output["output"])) == { + "sku": "A-1", + "available_units": 42, + } + assert _caller_dict(output["caller"]) == PROGRAM_CALLER + + programmatic_output = ItemHelpers.tool_call_output_item( + _function_call(), + {"sku": "A-1", "units": [1, 2]}, + ) + assert json.loads(cast(str, programmatic_output["output"])) == { + "sku": "A-1", + "units": [1, 2], + } + assert _caller_dict(programmatic_output["caller"]) == PROGRAM_CALLER + + direct_call = ResponseFunctionToolCall( + id="direct_function_item", + call_id="direct_call", + name="lookup_inventory", + arguments="{}", + type="function_call", + ) + legacy_output = ItemHelpers.tool_call_output_item(direct_call, {"sku": "A-1"}) + assert legacy_output["output"] == "{'sku': 'A-1'}" + + +def test_process_model_response_keeps_program_items_in_order() -> None: + @function_tool(allowed_callers=["programmatic"]) + def lookup_inventory(sku: str) -> str: + return sku + + agent = Agent( + name="inventory", + tools=[ProgrammaticToolCallingTool(), lookup_inventory], + ) + response = ModelResponse( + output=[_program(), _function_call(), _program_output()], + usage=Usage(), + response_id="response_1", + ) + + processed = process_model_response( + agent=agent, + all_tools=agent.tools, + response=response, + output_schema=None, + handoffs=[], + ) + + assert [type(item) for item in processed.new_items] == [ + ToolCallItem, + ToolCallItem, + ToolCallOutputItem, + ] + assert [_raw_item_type(item.raw_item) for item in processed.new_items] == [ + "program", + "function_call", + "program_output", + ] + assert processed.tools_used == [ + "programmatic_tool_calling", + "lookup_inventory", + "programmatic_tool_calling", + ] + + +@pytest.mark.parametrize("call_id", [None, ""]) +def test_process_model_response_rejects_program_without_valid_call_id( + call_id: str | None, +) -> None: + program: dict[str, Any] = { + "type": "program", + "id": "program_item", + "code": "return 42", + "fingerprint": "fingerprint", + } + if call_id is not None: + program["call_id"] = call_id + + response = ModelResponse(output=[], usage=Usage(), response_id="response_1") + response.output = cast(Any, [program]) + agent = Agent(name="inventory", tools=[ProgrammaticToolCallingTool()]) + + with pytest.raises(ModelBehaviorError, match="without a valid call_id"): + process_model_response( + agent=agent, + all_tools=agent.tools, + response=response, + output_schema=None, + handoffs=[], + ) + + +@pytest.mark.parametrize( + "program_output", + [_program_output(), _program_output().model_dump(exclude_none=True)], +) +def test_process_model_response_rejects_orphan_program_output(program_output: Any) -> None: + agent = Agent(name="inventory", tools=[ProgrammaticToolCallingTool()]) + + with pytest.raises(ModelBehaviorError, match="does not match a parent program item"): + process_model_response( + agent=agent, + all_tools=agent.tools, + response=ModelResponse( + output=[program_output], + usage=Usage(), + response_id="response_1", + ), + output_schema=None, + handoffs=[], + ) + + +def test_process_model_response_accepts_program_output_for_retained_program() -> None: + agent = Agent(name="inventory", tools=[ProgrammaticToolCallingTool()]) + existing_program = ToolCallItem(raw_item=_program(), agent=agent) + + processed = process_model_response( + agent=agent, + all_tools=agent.tools, + response=ModelResponse( + output=[_program_output()], + usage=Usage(), + response_id="response_1", + ), + output_schema=None, + handoffs=[], + existing_items=[existing_program], + ) + + assert len(processed.new_items) == 1 + assert isinstance(processed.new_items[0], ToolCallOutputItem) + + +@pytest.mark.parametrize( + ("field", "value", "remove_field", "error_match"), + [ + ("status", None, True, "without a valid status"), + ("status", "running", False, "without a valid status"), + ("result", None, True, "without a string result"), + ("result", 42, False, "without a string result"), + ], +) +def test_process_model_response_rejects_malformed_program_output( + field: str, + value: Any, + remove_field: bool, + error_match: str, +) -> None: + agent = Agent(name="inventory", tools=[ProgrammaticToolCallingTool()]) + program_output = _program_output().model_dump(exclude_none=True) + if remove_field: + program_output.pop(field) + else: + program_output[field] = value + response = ModelResponse(output=[], usage=Usage(), response_id="response_1") + response.output = cast(Any, [_program(), program_output]) + + with pytest.raises(ModelBehaviorError, match=error_match): + process_model_response( + agent=agent, + all_tools=agent.tools, + response=response, + output_schema=None, + handoffs=[], + ) + + +@pytest.mark.parametrize("parent_location", ["existing_items", "current_response"]) +@pytest.mark.parametrize("as_mapping", [False, True]) +def test_process_model_response_rejects_duplicate_completed_program_output( + parent_location: str, + as_mapping: bool, +) -> None: + agent = Agent(name="inventory", tools=[ProgrammaticToolCallingTool()]) + program: Any = _program() + completed_output: Any = _program_output() + duplicate_output: Any = _program_output().model_copy( + update={"id": "duplicate_program_output_item"} + ) + if as_mapping: + program = program.model_dump(exclude_none=True) + completed_output = completed_output.model_dump(exclude_none=True) + duplicate_output = duplicate_output.model_dump(exclude_none=True) + + response_output = [duplicate_output] + existing_items: list[RunItem] = [] + if parent_location == "existing_items": + existing_items = [ + ToolCallItem(raw_item=program, agent=agent), + ToolCallOutputItem( + raw_item=completed_output, + output=( + completed_output["result"] + if isinstance(completed_output, dict) + else completed_output.result + ), + agent=agent, + ), + ] + else: + response_output = [program, completed_output, duplicate_output] + + with pytest.raises(ModelBehaviorError, match="parent program is already completed"): + process_model_response( + agent=agent, + all_tools=agent.tools, + response=ModelResponse( + output=response_output, + usage=Usage(), + response_id="response_1", + ), + output_schema=None, + handoffs=[], + existing_items=existing_items, + ) + + +@pytest.mark.parametrize("as_mapping", [False, True]) +def test_process_model_response_rejects_program_owned_call_for_completed_retained_program( + as_mapping: bool, +) -> None: + @function_tool(allowed_callers=["programmatic"]) + def lookup_inventory(sku: str) -> str: + return sku + + agent = Agent( + name="inventory", + tools=[ProgrammaticToolCallingTool(), lookup_inventory], + ) + program: Any = _program() + program_output: Any = _program_output() + tool_call: Any = _function_call() + if as_mapping: + program = program.model_dump(exclude_none=True) + program_output = program_output.model_dump(exclude_none=True) + tool_call = tool_call.model_dump(exclude_none=True) + + existing_items: list[RunItem] = [ + ToolCallItem(raw_item=program, agent=agent), + ToolCallOutputItem( + raw_item=program_output, + output=getattr(program_output, "result", None) + or cast(dict[str, Any], program_output)["result"], + agent=agent, + ), + ] + + with pytest.raises(ModelBehaviorError, match="parent program is already completed"): + process_model_response( + agent=agent, + all_tools=agent.tools, + response=ModelResponse(output=[tool_call], usage=Usage(), response_id="response_1"), + output_schema=None, + handoffs=[], + existing_items=existing_items, + ) + + +@pytest.mark.parametrize("as_mapping", [False, True]) +def test_process_model_response_rejects_program_owned_call_after_completed_program_output( + as_mapping: bool, +) -> None: + @function_tool(allowed_callers=["programmatic"]) + def lookup_inventory(sku: str) -> str: + return sku + + agent = Agent( + name="inventory", + tools=[ProgrammaticToolCallingTool(), lookup_inventory], + ) + output: list[Any] = [_program(), _program_output(), _function_call()] + if as_mapping: + output = [item.model_dump(exclude_none=True) for item in output] + + with pytest.raises(ModelBehaviorError, match="parent program is already completed"): + process_model_response( + agent=agent, + all_tools=agent.tools, + response=ModelResponse(output=output, usage=Usage(), response_id="response_1"), + output_schema=None, + handoffs=[], + ) + + +@pytest.mark.parametrize("as_mapping", [False, True]) +def test_process_model_response_accepts_program_owned_call_for_incomplete_retained_program( + as_mapping: bool, +) -> None: + @function_tool(allowed_callers=["programmatic"]) + def lookup_inventory(sku: str) -> str: + return sku + + agent = Agent( + name="inventory", + tools=[ProgrammaticToolCallingTool(), lookup_inventory], + ) + program: Any = _program() + program_output: Any = _program_output("incomplete") + tool_call: Any = _function_call() + if as_mapping: + program = program.model_dump(exclude_none=True) + program_output = program_output.model_dump(exclude_none=True) + tool_call = tool_call.model_dump(exclude_none=True) + + existing_items: list[RunItem] = [ + ToolCallItem(raw_item=program, agent=agent), + ToolCallOutputItem( + raw_item=program_output, + output=getattr(program_output, "result", None) + or cast(dict[str, Any], program_output)["result"], + agent=agent, + ), + ] + processed = process_model_response( + agent=agent, + all_tools=agent.tools, + response=ModelResponse(output=[tool_call], usage=Usage(), response_id="response_1"), + output_schema=None, + handoffs=[], + existing_items=existing_items, + ) + + assert len(processed.new_items) == 1 + assert _raw_item_type(processed.new_items[0].raw_item) == "function_call" + + +@pytest.mark.parametrize( + "output", + [ + _program(), + _program().model_dump(exclude_none=True), + _program_output(), + _program_output().model_dump(exclude_none=True), + ], +) +def test_process_model_response_rejects_program_items_without_programmatic_tool( + output: Any, +) -> None: + agent = Agent(name="inventory") + + with pytest.raises(ModelBehaviorError, match="programmatic_tool_calling tool"): + process_model_response( + agent=agent, + all_tools=agent.tools, + response=ModelResponse(output=[output], usage=Usage(), response_id="response_1"), + output_schema=None, + handoffs=[], + ) + + +@pytest.mark.asyncio +async def test_runner_rejects_program_item_without_programmatic_tool() -> None: + model = FakeModel() + model.set_next_output([_program()]) + agent = Agent(name="inventory", model=model) + + with pytest.raises(ModelBehaviorError, match="programmatic_tool_calling tool"): + await Runner.run(agent, "Check inventory") + + +def test_process_model_response_rejects_program_owned_calls_without_programmatic_tool() -> None: + @function_tool(allowed_callers=["programmatic"]) + def lookup_inventory(sku: str) -> str: + return sku + + async def shell_executor(_request: Any) -> str: + return "ok" + + shell_tool = ShellTool(executor=shell_executor, allowed_callers=["programmatic"]) + shell_call = ResponseFunctionShellToolCall( + id="shell_item", + call_id="call_shell", + action=Action(commands=["echo ok"]), + status="completed", + type="shell_call", + caller=cast(Any, PROGRAM_CALLER), + ) + custom_tool = CustomTool( + name="custom", + description="Custom tool", + on_invoke_tool=lambda _context, _input: "ok", + allowed_callers=["programmatic"], + ) + custom_call = ResponseCustomToolCall( + id="custom_item", + call_id="call_custom", + input="input", + name="custom", + type="custom_tool_call", + caller=cast(Any, PROGRAM_CALLER), + ) + + cases: list[tuple[Any, Any]] = [ + (lookup_inventory, _function_call()), + (lookup_inventory, _function_call().model_dump(exclude_none=True)), + (shell_tool, shell_call), + (shell_tool, shell_call.model_dump(exclude_none=True)), + (custom_tool, custom_call), + (custom_tool, custom_call.model_dump(exclude_none=True)), + ] + for tool, tool_call in cases: + agent = Agent(name="tool agent", tools=[tool]) + with pytest.raises(ModelBehaviorError, match="programmatic_tool_calling tool"): + process_model_response( + agent=agent, + all_tools=agent.tools, + response=ModelResponse(output=[tool_call], usage=Usage(), response_id="response_1"), + output_schema=None, + handoffs=[], + ) + + +@pytest.mark.asyncio +async def test_runner_does_not_execute_program_owned_call_without_programmatic_tool() -> None: + model = FakeModel() + model.set_next_output([_function_call()]) + executed = False + + @function_tool(allowed_callers=["programmatic"]) + def lookup_inventory(sku: str) -> str: + nonlocal executed + executed = True + return sku + + agent = Agent(name="inventory", model=model, tools=[lookup_inventory]) + + with pytest.raises(ModelBehaviorError, match="programmatic_tool_calling tool"): + await Runner.run(agent, "Check inventory") + + assert executed is False + + +def test_process_model_response_rejects_program_owned_calls_without_parent_program() -> None: + @function_tool(allowed_callers=["programmatic"]) + def lookup_inventory(sku: str) -> str: + return sku + + async def shell_executor(_request: Any) -> str: + return "ok" + + shell_tool = ShellTool(executor=shell_executor, allowed_callers=["programmatic"]) + shell_call = ResponseFunctionShellToolCall( + id="shell_item", + call_id="call_shell", + action=Action(commands=["echo ok"]), + status="completed", + type="shell_call", + caller=cast(Any, PROGRAM_CALLER), + ) + custom_tool = CustomTool( + name="custom", + description="Custom tool", + on_invoke_tool=lambda _context, _input: "ok", + allowed_callers=["programmatic"], + ) + custom_call = ResponseCustomToolCall( + id="custom_item", + call_id="call_custom", + input="input", + name="custom", + type="custom_tool_call", + caller=cast(Any, PROGRAM_CALLER), + ) + + cases: list[tuple[Any, Any]] = [ + (lookup_inventory, _function_call()), + (lookup_inventory, _function_call().model_dump(exclude_none=True)), + (shell_tool, shell_call), + (shell_tool, shell_call.model_dump(exclude_none=True)), + (custom_tool, custom_call), + (custom_tool, custom_call.model_dump(exclude_none=True)), + ] + for tool, tool_call in cases: + agent = Agent(name="tool agent", tools=[ProgrammaticToolCallingTool(), tool]) + with pytest.raises(ModelBehaviorError, match="parent program item"): + process_model_response( + agent=agent, + all_tools=agent.tools, + response=ModelResponse(output=[tool_call], usage=Usage(), response_id="response_1"), + output_schema=None, + handoffs=[], + ) + + +@pytest.mark.parametrize("parent_location", ["current_response", "existing_items"]) +@pytest.mark.parametrize("as_mapping", [False, True]) +def test_process_model_response_accepts_program_owned_call_with_parent_program( + parent_location: str, + as_mapping: bool, +) -> None: + @function_tool(allowed_callers=["programmatic"]) + def lookup_inventory(sku: str) -> str: + return sku + + agent = Agent( + name="inventory", + tools=[ProgrammaticToolCallingTool(), lookup_inventory], + ) + tool_call: Any = _function_call() + parent_program: Any = _program() + if as_mapping: + tool_call = tool_call.model_dump(exclude_none=True) + parent_program = parent_program.model_dump(exclude_none=True) + + response_output: list[Any] = [tool_call] + existing_items: list[ToolCallItem] = [] + if parent_location == "current_response": + response_output.insert(0, parent_program) + else: + existing_items.append(ToolCallItem(raw_item=parent_program, agent=agent)) + + processed = process_model_response( + agent=agent, + all_tools=agent.tools, + response=ModelResponse(output=response_output, usage=Usage(), response_id="response_1"), + output_schema=None, + handoffs=[], + existing_items=existing_items, + ) + + assert "function_call" in {_raw_item_type(item.raw_item) for item in processed.new_items} + + +@pytest.mark.parametrize("child_type", ["function_call", "program_output"]) +@pytest.mark.parametrize("as_mapping", [False, True]) +def test_process_model_response_rejects_program_child_before_parent( + child_type: str, + as_mapping: bool, +) -> None: + @function_tool(allowed_callers=["programmatic"]) + def lookup_inventory(sku: str) -> str: + return sku + + agent = Agent( + name="inventory", + tools=[ProgrammaticToolCallingTool(), lookup_inventory], + ) + child: Any = _function_call() if child_type == "function_call" else _program_output() + parent: Any = _program() + if as_mapping: + child = child.model_dump(exclude_none=True) + parent = parent.model_dump(exclude_none=True) + + with pytest.raises(ModelBehaviorError, match="parent program item"): + process_model_response( + agent=agent, + all_tools=agent.tools, + response=ModelResponse( + output=[child, parent], + usage=Usage(), + response_id="response_1", + ), + output_schema=None, + handoffs=[], + ) + + +@pytest.mark.parametrize("child_type", ["function_call", "program_output"]) +@pytest.mark.parametrize("as_mapping", [False, True]) +def test_process_model_response_accepts_server_owned_parent_from_submitted_delta( + child_type: str, + as_mapping: bool, +) -> None: + @function_tool(allowed_callers=["programmatic"]) + def lookup_inventory(sku: str) -> str: + return sku + + agent = Agent( + name="inventory", + tools=[ProgrammaticToolCallingTool(), lookup_inventory], + ) + child: Any = _function_call() if child_type == "function_call" else _program_output() + if as_mapping: + child = child.model_dump(exclude_none=True) + submitted_delta = [ + { + "type": "function_call_output", + "call_id": FUNCTION_CALL_ID, + "output": '{"sku":"A-1","available_units":42}', + "caller": PROGRAM_CALLER, + } + ] + + processed = process_model_response( + agent=agent, + all_tools=agent.tools, + response=ModelResponse(output=[child], usage=Usage(), response_id="response_1"), + output_schema=None, + handoffs=[], + server_manages_conversation=True, + server_managed_input_items=submitted_delta, + ) + + assert child_type in {_raw_item_type(item.raw_item) for item in processed.new_items} + + +def test_process_model_response_rejects_server_owned_child_without_parent_evidence() -> None: + @function_tool(allowed_callers=["programmatic"]) + def lookup_inventory(sku: str) -> str: + return sku + + agent = Agent( + name="inventory", + tools=[ProgrammaticToolCallingTool(), lookup_inventory], + ) + + with pytest.raises(ModelBehaviorError, match="parent program item"): + process_model_response( + agent=agent, + all_tools=agent.tools, + response=ModelResponse( + output=[_function_call()], + usage=Usage(), + response_id="response_1", + ), + output_schema=None, + handoffs=[], + server_manages_conversation=True, + ) + + +@pytest.mark.parametrize("as_mapping", [False, True]) +def test_process_model_response_accepts_server_owned_parent_after_incomplete_output( + as_mapping: bool, +) -> None: + @function_tool(allowed_callers=["programmatic"]) + def lookup_inventory(sku: str) -> str: + return sku + + agent = Agent( + name="inventory", + tools=[ProgrammaticToolCallingTool(), lookup_inventory], + ) + prior_output: Any = _program_output("incomplete") + if as_mapping: + prior_output = prior_output.model_dump(exclude_none=True) + + processed = process_model_response( + agent=agent, + all_tools=agent.tools, + response=ModelResponse( + output=[_function_call()], + usage=Usage(), + response_id="response_2", + ), + output_schema=None, + handoffs=[], + existing_items=[ + ToolCallOutputItem( + raw_item=prior_output, + output='{"status":"waiting"}', + agent=agent, + ) + ], + server_manages_conversation=True, + ) + + assert _raw_item_type(processed.new_items[0].raw_item) == "function_call" + + +@pytest.mark.asyncio +async def test_runner_does_not_execute_program_owned_call_without_parent_program() -> None: + model = FakeModel() + model.set_next_output([_function_call()]) + executed = False + + @function_tool(allowed_callers=["programmatic"]) + def lookup_inventory(sku: str) -> str: + nonlocal executed + executed = True + return sku + + agent = Agent( + name="inventory", + model=model, + tools=[ProgrammaticToolCallingTool(), lookup_inventory], + ) + + with pytest.raises(ModelBehaviorError, match="parent program item"): + await Runner.run(agent, "Check inventory") + + assert executed is False + + +@pytest.mark.parametrize( + ("allowed_callers", "caller", "expected_caller"), + [ + (None, CallerProgram(type="program", caller_id=PROGRAM_CALL_ID), "programmatic"), + (["direct"], CallerProgram(type="program", caller_id=PROGRAM_CALL_ID), "programmatic"), + (["programmatic"], None, "direct"), + ], +) +def test_process_model_response_rejects_disallowed_function_callers( + allowed_callers: list[Any] | None, + caller: CallerProgram | None, + expected_caller: str, +) -> None: + @function_tool(allowed_callers=allowed_callers) + def lookup_inventory(sku: str) -> str: + return sku + + agent = Agent( + name="inventory", + tools=[ProgrammaticToolCallingTool(), lookup_inventory], + ) + tool_call = ResponseFunctionToolCall( + id="function_item", + call_id=FUNCTION_CALL_ID, + name="lookup_inventory", + arguments='{"sku":"A-1"}', + caller=caller, + type="function_call", + ) + response_output: list[Any] = [_program(), tool_call] if caller is not None else [tool_call] + + with pytest.raises(ModelBehaviorError, match=f"caller {expected_caller}"): + process_model_response( + agent=agent, + all_tools=agent.tools, + response=ModelResponse(output=response_output, usage=Usage(), response_id="response_1"), + output_schema=None, + handoffs=[], + ) + + +def test_process_model_response_rejects_unknown_function_caller_type() -> None: + @function_tool + def lookup_inventory(sku: str) -> str: + return sku + + agent = Agent(name="inventory", tools=[lookup_inventory]) + tool_call = ResponseFunctionToolCall.model_construct( + id="function_item", + call_id=FUNCTION_CALL_ID, + name="lookup_inventory", + arguments='{"sku":"A-1"}', + caller={"type": "unknown", "caller_id": PROGRAM_CALL_ID}, + type="function_call", + ) + + with pytest.raises(ModelBehaviorError, match="unsupported caller type 'unknown'"): + process_model_response( + agent=agent, + all_tools=agent.tools, + response=ModelResponse(output=[tool_call], usage=Usage(), response_id="response_1"), + output_schema=None, + handoffs=[], + ) + + +def test_process_model_response_rejects_disallowed_non_function_callers() -> None: + caller = cast(Any, PROGRAM_CALLER) + + async def shell_executor(_request: Any) -> str: + return "ok" + + shell_tool = ShellTool(executor=shell_executor) + programmatic_tool = ProgrammaticToolCallingTool() + shell_call = ResponseFunctionShellToolCall( + id="shell_item", + call_id="call_shell", + action=Action(commands=["echo ok"]), + status="completed", + type="shell_call", + caller=caller, + ) + with pytest.raises(ModelBehaviorError, match="caller programmatic"): + process_model_response( + agent=Agent(name="shell", tools=[programmatic_tool, shell_tool]), + all_tools=[programmatic_tool, shell_tool], + response=ModelResponse( + output=[_program(), shell_call], usage=Usage(), response_id="response_1" + ), + output_schema=None, + handoffs=[], + ) + + custom_tool = CustomTool( + name="custom", + description="Custom tool", + on_invoke_tool=lambda _context, _input: "ok", + ) + custom_call = ResponseCustomToolCall( + id="custom_item", + call_id="call_custom", + input="input", + name="custom", + type="custom_tool_call", + caller=caller, + ) + with pytest.raises(ModelBehaviorError, match="caller programmatic"): + process_model_response( + agent=Agent(name="custom", tools=[programmatic_tool, custom_tool]), + all_tools=[programmatic_tool, custom_tool], + response=ModelResponse( + output=[_program(), custom_call], usage=Usage(), response_id="response_1" + ), + output_schema=None, + handoffs=[], + ) + + class Editor: + def create_file(self, _operation: Any) -> str: + return "ok" + + def update_file(self, _operation: Any) -> str: + return "ok" + + def delete_file(self, _operation: Any) -> str: + return "ok" + + apply_patch_tool = ApplyPatchTool(editor=Editor(), allowed_callers=["programmatic"]) + apply_patch_call = ResponseApplyPatchToolCall( + id="apply_patch_item", + call_id="call_apply_patch", + operation=OperationCreateFile(type="create_file", path="example.txt", diff="hello"), + status="completed", + type="apply_patch_call", + ) + with pytest.raises(ModelBehaviorError, match="caller direct"): + process_model_response( + agent=Agent(name="apply patch", tools=[programmatic_tool, apply_patch_tool]), + all_tools=[programmatic_tool, apply_patch_tool], + response=ModelResponse( + output=[apply_patch_call], usage=Usage(), response_id="response_1" + ), + output_schema=None, + handoffs=[], + ) + + +@pytest.mark.parametrize("as_mapping", [False, True]) +@pytest.mark.parametrize( + "tool_call", + [ + ResponseFileSearchToolCall.model_construct( + id="file_search_1", + queries=["inventory"], + status="completed", + type="file_search_call", + caller=PROGRAM_CALLER, + ), + ResponseFunctionWebSearch.model_construct( + id="web_search_1", + action=ActionSearch(type="search", query="inventory"), + status="completed", + type="web_search_call", + caller=PROGRAM_CALLER, + ), + ImageGenerationCall.model_construct( + id="image_generation_1", + status="completed", + type="image_generation_call", + caller=PROGRAM_CALLER, + ), + ], +) +def test_process_model_response_rejects_program_owned_direct_only_hosted_calls( + tool_call: Any, + as_mapping: bool, +) -> None: + output = tool_call.model_dump(exclude_none=True) if as_mapping else tool_call + agent = Agent(name="hosted", tools=[ProgrammaticToolCallingTool()]) + + with pytest.raises(ModelBehaviorError, match="caller programmatic"): + process_model_response( + agent=agent, + all_tools=agent.tools, + response=ModelResponse( + output=[_program(), output], + usage=Usage(), + response_id="response_1", + ), + output_schema=None, + handoffs=[], + ) + + +@pytest.mark.parametrize( + "tool_search_item", + [ + ResponseToolSearchCall.model_construct( + id="tool_search_call_item", + arguments={}, + execution="server", + status="completed", + type="tool_search_call", + caller=PROGRAM_CALLER, + ), + ResponseToolSearchOutputItem.model_construct( + id="tool_search_output_item", + execution="server", + status="completed", + tools=[], + type="tool_search_output", + caller=PROGRAM_CALLER, + ), + ], +) +def test_process_model_response_rejects_program_owned_tool_search_items( + tool_search_item: Any, +) -> None: + agent = Agent( + name="tool search", + tools=[ProgrammaticToolCallingTool(), ToolSearchTool()], + ) + with pytest.raises(ModelBehaviorError, match="caller programmatic"): + process_model_response( + agent=agent, + all_tools=agent.tools, + response=ModelResponse( + output=[_program(), tool_search_item], + usage=Usage(), + response_id="response_1", + ), + output_schema=None, + handoffs=[], + ) + + +@pytest.mark.parametrize( + ("output_type", "allowed_callers"), + [ + ("mcp_approval_request", None), + ("mcp_approval_request", ["direct"]), + ("mcp_call", None), + ("mcp_call", ["direct"]), + ("mcp_list_tools", None), + ("mcp_list_tools", ["direct"]), + ("code_interpreter_call", None), + ("code_interpreter_call", ["direct"]), + ], +) +def test_process_model_response_rejects_disallowed_hosted_program_callers( + output_type: str, + allowed_callers: list[Any] | None, +) -> None: + output, hosted_tool = _hosted_program_call_and_tool(output_type, allowed_callers) + programmatic_tool = ProgrammaticToolCallingTool() + agent = Agent(name="hosted", tools=[programmatic_tool, hosted_tool]) + with pytest.raises(ModelBehaviorError, match="caller programmatic"): + process_model_response( + agent=agent, + all_tools=agent.tools, + response=ModelResponse( + output=[_program(), output], + usage=Usage(), + response_id="response_1", + ), + output_schema=None, + handoffs=[], + ) + + +@pytest.mark.parametrize( + "output_type", + ["mcp_approval_request", "mcp_call", "mcp_list_tools", "code_interpreter_call"], +) +def test_process_model_response_accepts_allowed_hosted_program_callers( + output_type: str, +) -> None: + output, hosted_tool = _hosted_program_call_and_tool(output_type, ["programmatic"]) + programmatic_tool = ProgrammaticToolCallingTool() + agent = Agent(name="hosted", tools=[programmatic_tool, hosted_tool]) + processed = process_model_response( + agent=agent, + all_tools=agent.tools, + response=ModelResponse( + output=[_program(), output], + usage=Usage(), + response_id="response_1", + ), + output_schema=None, + handoffs=[], + ) + + assert len(processed.new_items) == 2 + + +@pytest.mark.parametrize("as_mapping", [False, True]) +@pytest.mark.parametrize("allowed_callers", [None, ["direct"]]) +def test_process_model_response_rejects_disallowed_program_owned_shell_output( + as_mapping: bool, + allowed_callers: list[Any] | None, +) -> None: + shell_output: Any = ResponseFunctionShellToolCallOutput.model_construct( + id="shell_output_1", + call_id="shell_call_1", + status="completed", + type="shell_call_output", + output=[], + caller=PROGRAM_CALLER, + ) + if as_mapping: + shell_output = shell_output.model_dump(exclude_none=True) + shell_tool = ShellTool(executor=lambda _request: "ok", allowed_callers=allowed_callers) + agent = Agent( + name="shell", + tools=[ProgrammaticToolCallingTool(), shell_tool], + ) + + with pytest.raises(ModelBehaviorError, match="caller programmatic"): + process_model_response( + agent=agent, + all_tools=agent.tools, + response=ModelResponse( + output=[_program(), shell_output], + usage=Usage(), + response_id="response_1", + ), + output_schema=None, + handoffs=[], + ) + + +@pytest.mark.parametrize("as_mapping", [False, True]) +def test_process_model_response_accepts_allowed_program_owned_shell_output( + as_mapping: bool, +) -> None: + shell_output: Any = ResponseFunctionShellToolCallOutput.model_construct( + id="shell_output_1", + call_id="shell_call_1", + status="completed", + type="shell_call_output", + output=[], + caller=PROGRAM_CALLER, + ) + if as_mapping: + shell_output = shell_output.model_dump(exclude_none=True) + shell_tool = ShellTool(executor=lambda _request: "ok", allowed_callers=["programmatic"]) + agent = Agent( + name="shell", + tools=[ProgrammaticToolCallingTool(), shell_tool], + ) + + processed = process_model_response( + agent=agent, + all_tools=agent.tools, + response=ModelResponse( + output=[_program(), shell_output], + usage=Usage(), + response_id="response_1", + ), + output_schema=None, + handoffs=[], + ) + + assert len(processed.new_items) == 2 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_runner_executes_and_replays_programmatic_function_calls(streamed: bool) -> None: + model = FakeModel() + model.add_multiple_turn_outputs( + [ + [_program(), _function_call()], + [_program_output(), get_text_message("42 units are available")], + ] + ) + + @function_tool(allowed_callers=["programmatic"]) + def lookup_inventory(sku: str) -> InventoryOutput: + return InventoryOutput(sku=sku, available_units=42) + + agent = Agent( + name="inventory", + model=model, + tools=[ProgrammaticToolCallingTool(), lookup_inventory], + model_settings=ModelSettings(tool_choice="programmatic_tool_calling"), + ) + + result: Any + if streamed: + result = Runner.run_streamed(agent, "Check inventory") + events = [event async for event in result.stream_events()] + assert any( + getattr(event, "name", None) == "tool_called" + and _raw_item_type(getattr(getattr(event, "item", None), "raw_item", None)) == "program" + for event in events + ) + else: + result = await Runner.run(agent, "Check inventory") + + assert result.final_output == "42 units are available" + assert model.first_turn_args is not None + assert model.first_turn_args["model_settings"].tool_choice == "programmatic_tool_calling" + assert model.last_turn_args["model_settings"].tool_choice is None + + function_outputs = [ + item + for item in result.new_items + if isinstance(item, ToolCallOutputItem) + and getattr(item.raw_item, "get", lambda _key: None)("type") == "function_call_output" + ] + assert len(function_outputs) == 1 + raw_output = cast(dict[str, Any], function_outputs[0].raw_item) + assert json.loads(cast(str, raw_output["output"])) == { + "sku": "A-1", + "available_units": 42, + } + assert _caller_dict(raw_output["caller"]) == PROGRAM_CALLER + + replayed_output = next( + item + for item in model.last_turn_args["input"] + if isinstance(item, dict) and item.get("type") == "function_call_output" + ) + assert _caller_dict(replayed_output["caller"]) == PROGRAM_CALLER + + +@pytest.mark.asyncio +async def test_typed_programmatic_tool_preserves_input_guardrail_rejection() -> None: + model = FakeModel() + model.add_multiple_turn_outputs( + [ + [_program(), _function_call()], + [_program_output(), get_text_message("request rejected")], + ] + ) + executed = False + + @tool_input_guardrail + def reject_tool_input(_data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput: + return ToolGuardrailFunctionOutput.reject_content("inventory lookup blocked") + + @function_tool( + allowed_callers=["programmatic"], + tool_input_guardrails=[reject_tool_input], + ) + def lookup_inventory(sku: str) -> InventoryOutput: + nonlocal executed + executed = True + return InventoryOutput(sku=sku, available_units=42) + + agent = Agent( + name="inventory", + model=model, + tools=[ProgrammaticToolCallingTool(), lookup_inventory], + ) + + result = await Runner.run(agent, "Check inventory") + + assert executed is False + assert result.final_output == "request rejected" + function_outputs = _function_output_raw_items(result) + assert len(function_outputs) == 1 + assert function_outputs[0]["output"] == "inventory lookup blocked" + assert _caller_dict(function_outputs[0]["caller"]) == PROGRAM_CALLER + + +@pytest.mark.asyncio +async def test_typed_programmatic_tool_preserves_default_timeout_result() -> None: + model = FakeModel() + model.add_multiple_turn_outputs( + [ + [_program(), _function_call()], + [_program_output(), get_text_message("request timed out")], + ] + ) + + @function_tool(allowed_callers=["programmatic"], timeout=0.01) + async def lookup_inventory(sku: str) -> InventoryOutput: + await asyncio.sleep(0.2) + return InventoryOutput(sku=sku, available_units=42) + + agent = Agent( + name="inventory", + model=model, + tools=[ProgrammaticToolCallingTool(), lookup_inventory], + ) + + result = await Runner.run(agent, "Check inventory") + + assert result.final_output == "request timed out" + function_outputs = _function_output_raw_items(result) + assert len(function_outputs) == 1 + assert isinstance(function_outputs[0]["output"], str) + assert "timed out" in function_outputs[0]["output"].lower() + assert _caller_dict(function_outputs[0]["caller"]) == PROGRAM_CALLER + + +@pytest.mark.asyncio +async def test_typed_programmatic_tool_preserves_output_guardrail_rejection() -> None: + model = FakeModel() + model.add_multiple_turn_outputs( + [ + [_program(), _function_call()], + [_program_output(), get_text_message("request rejected")], + ] + ) + + @tool_output_guardrail + def reject_tool_output(_data: ToolOutputGuardrailData) -> ToolGuardrailFunctionOutput: + return ToolGuardrailFunctionOutput.reject_content("inventory result blocked") + + @function_tool( + allowed_callers=["programmatic"], + tool_output_guardrails=[reject_tool_output], + ) + def lookup_inventory(sku: str) -> InventoryOutput: + return InventoryOutput(sku=sku, available_units=42) + + agent = Agent( + name="inventory", + model=model, + tools=[ProgrammaticToolCallingTool(), lookup_inventory], + ) + + result = await Runner.run(agent, "Check inventory") + + assert result.final_output == "request rejected" + function_outputs = _function_output_raw_items(result) + assert len(function_outputs) == 1 + assert function_outputs[0]["output"] == "inventory result blocked" + assert _caller_dict(function_outputs[0]["caller"]) == PROGRAM_CALLER + + +@pytest.mark.asyncio +async def test_typed_programmatic_tool_preserves_approval_rejection() -> None: + model = FakeModel() + model.add_multiple_turn_outputs( + [ + [_program(), _function_call()], + [_program_output(), get_text_message("request rejected")], + ] + ) + + @function_tool(allowed_callers=["programmatic"], needs_approval=True) + def lookup_inventory(sku: str) -> InventoryOutput: + return InventoryOutput(sku=sku, available_units=42) + + agent = Agent( + name="inventory", + model=model, + tools=[ProgrammaticToolCallingTool(), lookup_inventory], + ) + first_result = await Runner.run(agent, "Check inventory") + assert len(first_result.interruptions) == 1 + + state = first_result.to_state() + state.reject(first_result.interruptions[0]) + result = await Runner.run(agent, state) + + assert result.final_output == "request rejected" + function_outputs = _function_output_raw_items(result) + assert len(function_outputs) == 1 + assert function_outputs[0]["output"] == "Tool execution was not approved." + assert _caller_dict(function_outputs[0]["caller"]) == PROGRAM_CALLER + + +@pytest.mark.asyncio +async def test_rebuilt_mapping_programmatic_approval_preserves_caller() -> None: + model = FakeModel() + model.add_multiple_turn_outputs( + [ + [_program(), _function_call()], + [_program_output(), get_text_message("done")], + ] + ) + executed = False + + @function_tool(allowed_callers=["programmatic"], needs_approval=True) + def lookup_inventory(sku: str) -> InventoryOutput: + nonlocal executed + executed = True + return InventoryOutput(sku=sku, available_units=42) + + agent = Agent( + name="inventory", + model=model, + tools=[ProgrammaticToolCallingTool(), lookup_inventory], + ) + first_result = await Runner.run(agent, "Check inventory") + state = first_result.to_state() + approval = state.get_interruptions()[0] + approval.raw_item = cast(Any, approval.raw_item).model_dump(exclude_none=True) + assert state._last_processed_response is not None + state._last_processed_response.functions.clear() + state.approve(approval) + + result = await Runner.run(agent, state) + + assert executed is True + assert result.final_output == "done" + function_outputs = _function_output_raw_items(result) + assert len(function_outputs) == 1 + assert _caller_dict(function_outputs[0]["caller"]) == PROGRAM_CALLER + + +@pytest.mark.asyncio +async def test_rebuilt_mapping_programmatic_approval_rechecks_caller_permissions() -> None: + model = FakeModel() + model.set_next_output([_program(), _function_call()]) + executed = False + + @function_tool(allowed_callers=["programmatic"], needs_approval=True) + def lookup_inventory(sku: str) -> InventoryOutput: + nonlocal executed + executed = True + return InventoryOutput(sku=sku, available_units=42) + + agent = Agent( + name="inventory", + model=model, + tools=[ProgrammaticToolCallingTool(), lookup_inventory], + ) + first_result = await Runner.run(agent, "Check inventory") + state = first_result.to_state() + approval = state.get_interruptions()[0] + approval.raw_item = cast(Any, approval.raw_item).model_dump(exclude_none=True) + lookup_inventory.allowed_callers = ["direct"] + assert state._last_processed_response is not None + state._last_processed_response.functions.clear() + state.approve(approval) + + with pytest.raises(ModelBehaviorError, match="caller programmatic"): + await Runner.run(agent, state) + + assert executed is False + + +@pytest.mark.asyncio +@pytest.mark.parametrize("parent_state", ["missing", "completed"]) +async def test_rebuilt_programmatic_approval_requires_active_parent(parent_state: str) -> None: + model = FakeModel() + model.add_multiple_turn_outputs( + [ + [_program(), _function_call()], + [_program_output(), get_text_message("done")], + ] + ) + executed = False + + @function_tool(allowed_callers=["programmatic"], needs_approval=True) + def lookup_inventory(sku: str) -> InventoryOutput: + nonlocal executed + executed = True + return InventoryOutput(sku=sku, available_units=42) + + agent = Agent( + name="inventory", + model=model, + tools=[ProgrammaticToolCallingTool(), lookup_inventory], + ) + first_result = await Runner.run(agent, "Check inventory") + state = first_result.to_state() + approval = state.get_interruptions()[0] + approval.raw_item = cast(Any, approval.raw_item).model_dump(exclude_none=True) + assert state._last_processed_response is not None + state._last_processed_response.functions.clear() + assert state._model_responses + if parent_state == "missing": + state._generated_items = [ + item for item in state._generated_items if _raw_item_type(item.raw_item) != "program" + ] + state._last_processed_response.new_items = [ + item + for item in state._last_processed_response.new_items + if _raw_item_type(item.raw_item) != "program" + ] + state._model_responses[-1].output = [ + item for item in state._model_responses[-1].output if _raw_item_type(item) != "program" + ] + expected_error = "parent program item" + else: + state._model_responses[-1].output.append(_program_output()) + expected_error = "already completed" + state.approve(approval) + + with pytest.raises(ModelBehaviorError, match=expected_error): + await Runner.run(agent, state) + + assert executed is False + + +@pytest.mark.asyncio +async def test_typed_programmatic_tool_preserves_pre_approval_guardrail_rejection() -> None: + model = FakeModel() + model.add_multiple_turn_outputs( + [ + [_program(), _function_call()], + [_program_output(), get_text_message("request rejected")], + ] + ) + + @tool_input_guardrail + def reject_tool_input(_data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput: + return ToolGuardrailFunctionOutput.reject_content("inventory lookup blocked") + + @function_tool( + allowed_callers=["programmatic"], + needs_approval=True, + tool_input_guardrails=[reject_tool_input], + ) + def lookup_inventory(sku: str) -> InventoryOutput: + return InventoryOutput(sku=sku, available_units=42) + + agent = Agent( + name="inventory", + model=model, + tools=[ProgrammaticToolCallingTool(), lookup_inventory], + ) + run_config = RunConfig( + tool_execution=ToolExecutionConfig(pre_approval_tool_input_guardrails=True) + ) + + result = await Runner.run(agent, "Check inventory", run_config=run_config) + + assert result.final_output == "request rejected" + function_outputs = _function_output_raw_items(result) + assert len(function_outputs) == 1 + assert function_outputs[0]["output"] == "inventory lookup blocked" + assert _caller_dict(function_outputs[0]["caller"]) == PROGRAM_CALLER + + +@pytest.mark.asyncio +async def test_runner_handles_multiple_pauses_from_one_program() -> None: + model = FakeModel() + second_call = ResponseFunctionToolCall( + id="function_item_2", + call_id="call_lookup_2", + name="lookup_inventory", + arguments='{"sku":"B-2"}', + caller=CallerProgram(type="program", caller_id=PROGRAM_CALL_ID), + type="function_call", + ) + model.add_multiple_turn_outputs( + [ + [_program(), _function_call()], + [_program_output("incomplete"), second_call], + [ + ProgramOutput( + id="program_output_item_2", + call_id=PROGRAM_CALL_ID, + result='{"total":84}', + status="completed", + type="program_output", + ), + get_text_message("84 units are available"), + ], + ] + ) + + calls: list[str] = [] + + @function_tool(allowed_callers=["programmatic"]) + def lookup_inventory(sku: str) -> InventoryOutput: + calls.append(sku) + return InventoryOutput(sku=sku, available_units=42) + + agent = Agent( + name="inventory", + model=model, + tools=[ProgrammaticToolCallingTool(), lookup_inventory], + model_settings=ModelSettings(tool_choice="programmatic_tool_calling"), + ) + + result = await Runner.run(agent, "Check two SKUs") + + assert result.final_output == "84 units are available" + assert calls == ["A-1", "B-2"] + assert model.last_turn_args["model_settings"].tool_choice is None + assert len([item for item in result.new_items if isinstance(item, ToolCallOutputItem)]) == 4 + + +@pytest.mark.asyncio +async def test_runner_executes_programmatic_batch_calls_concurrently() -> None: + model = FakeModel() + batch_calls = [ + ResponseFunctionToolCall( + id=f"function_item_{index}", + call_id=f"call_lookup_{index}", + name="lookup_inventory", + arguments=json.dumps({"sku": f"SKU-{index}"}), + caller=CallerProgram(type="program", caller_id=PROGRAM_CALL_ID), + type="function_call", + ) + for index in range(9) + ] + model.add_multiple_turn_outputs( + [ + [_program(), *batch_calls], + [_program_output(), get_text_message("batch complete")], + ] + ) + + active_calls = 0 + max_active_calls = 0 + + @function_tool(allowed_callers=["programmatic"]) + async def lookup_inventory(sku: str) -> InventoryOutput: + nonlocal active_calls, max_active_calls + active_calls += 1 + max_active_calls = max(max_active_calls, active_calls) + await asyncio.sleep(0.01) + active_calls -= 1 + return InventoryOutput(sku=sku, available_units=42) + + agent = Agent( + name="inventory", + model=model, + tools=[ProgrammaticToolCallingTool(), lookup_inventory], + ) + + result = await Runner.run(agent, "Check nine SKUs") + + function_outputs = [ + item + for item in result.new_items + if isinstance(item, ToolCallOutputItem) + and isinstance(item.raw_item, dict) + and item.raw_item.get("type") == "function_call_output" + ] + assert result.final_output == "batch complete" + assert len(result.raw_responses) == 2 + assert len(function_outputs) == 9 + assert max_active_calls == 9 + assert all( + _caller_dict(cast(dict[str, Any], item.raw_item)["caller"]) == PROGRAM_CALLER + for item in function_outputs + ) + + +@pytest.mark.asyncio +async def test_previous_response_id_continuation_sends_only_program_function_output() -> None: + model = FakeModel() + model.add_multiple_turn_outputs( + [ + [_program(), _function_call()], + [_program_output(), get_text_message("done")], + ] + ) + + @function_tool(allowed_callers=["programmatic"]) + def lookup_inventory(sku: str) -> InventoryOutput: + return InventoryOutput(sku=sku, available_units=42) + + agent = Agent( + name="inventory", + model=model, + tools=[ProgrammaticToolCallingTool(), lookup_inventory], + ) + + result = await Runner.run(agent, "Check inventory", auto_previous_response_id=True) + + assert result.final_output == "done" + assert model.last_turn_args["previous_response_id"] == "resp-789" + last_input = model.last_turn_args["input"] + assert isinstance(last_input, list) + assert len(last_input) == 1 + function_output = cast(dict[str, Any], last_input[0]) + assert function_output["type"] == "function_call_output" + assert _caller_dict(function_output["caller"]) == PROGRAM_CALLER + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_previous_response_id_continuation_accepts_server_owned_program_output( + streamed: bool, +) -> None: + model = FakeModel() + model.set_next_output([_program_output(), get_text_message("done")]) + + @function_tool(allowed_callers=["programmatic"]) + def lookup_inventory(sku: str) -> InventoryOutput: + return InventoryOutput(sku=sku, available_units=42) + + agent = Agent( + name="inventory", + model=model, + tools=[ProgrammaticToolCallingTool(), lookup_inventory], + ) + submitted_delta = [ + { + "type": "function_call_output", + "call_id": FUNCTION_CALL_ID, + "output": '{"sku":"A-1","available_units":42}', + "caller": PROGRAM_CALLER, + } + ] + + result: Any + if streamed: + result = Runner.run_streamed( + agent, + cast(Any, submitted_delta), + previous_response_id="response_with_program_parent", + ) + _events = [event async for event in result.stream_events()] + else: + result = await Runner.run( + agent, + cast(Any, submitted_delta), + previous_response_id="response_with_program_parent", + ) + + assert result.final_output == "done" + assert model.last_turn_args["previous_response_id"] == "response_with_program_parent" + + +@pytest.mark.asyncio +async def test_previous_response_id_continuation_accepts_repeated_program_pause() -> None: + model = FakeModel() + model.add_multiple_turn_outputs( + [ + [_function_call()], + [_program_output(), get_text_message("done")], + ] + ) + executed = False + + @function_tool(allowed_callers=["programmatic"]) + def lookup_inventory(sku: str) -> InventoryOutput: + nonlocal executed + executed = True + return InventoryOutput(sku=sku, available_units=42) + + agent = Agent( + name="inventory", + model=model, + tools=[ProgrammaticToolCallingTool(), lookup_inventory], + ) + submitted_delta = [ + { + "type": "function_call_output", + "call_id": "call_previous_lookup", + "output": '{"sku":"A-0","available_units":21}', + "caller": PROGRAM_CALLER, + } + ] + + result = await Runner.run( + agent, + cast(Any, submitted_delta), + previous_response_id="response_with_program_parent", + ) + + assert executed is True + assert result.final_output == "done" + assert len(result.raw_responses) == 2 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("parent_source", ["caller", "incomplete_program_output"]) +async def test_run_state_round_trip_preserves_server_owned_program_parent( + parent_source: str, +) -> None: + model = FakeModel() + model.add_multiple_turn_outputs( + [ + [_function_call()], + [_program_output(), get_text_message("done")], + ] + ) + executed = False + + @function_tool(allowed_callers=["programmatic"], needs_approval=True) + def lookup_inventory(sku: str) -> InventoryOutput: + nonlocal executed + executed = True + return InventoryOutput(sku=sku, available_units=42) + + agent = Agent( + name="inventory", + model=model, + tools=[ProgrammaticToolCallingTool(), lookup_inventory], + ) + if parent_source == "caller": + submitted_delta = [ + { + "type": "function_call_output", + "call_id": "call_previous_lookup", + "output": '{"sku":"A-0","available_units":21}', + "caller": PROGRAM_CALLER, + } + ] + else: + submitted_delta = [_program_output("incomplete").model_dump(exclude_none=True)] + + first_result = await Runner.run( + agent, + cast(Any, submitted_delta), + previous_response_id="response_with_program_parent", + ) + assert len(first_result.interruptions) == 1 + + restored_state = await RunState.from_json(agent, first_result.to_state().to_json()) + approval = restored_state.get_interruptions()[0] + restored_state.approve(approval) + result = await Runner.run(agent, restored_state) + + assert executed is True + assert result.final_output == "done" + assert model.last_turn_args["previous_response_id"] == "resp-789" + + +@pytest.mark.asyncio +async def test_sqlite_session_round_trip_preserves_program_history_and_caller() -> None: + model = FakeModel() + model.add_multiple_turn_outputs( + [ + [_program(), _function_call()], + [_program_output(), get_text_message("done")], + ] + ) + + @function_tool(allowed_callers=["programmatic"]) + def lookup_inventory(sku: str) -> InventoryOutput: + return InventoryOutput(sku=sku, available_units=42) + + agent = Agent( + name="inventory", + model=model, + tools=[ProgrammaticToolCallingTool(), lookup_inventory], + ) + session = SQLiteSession("programmatic-tool-calling") + try: + result = await Runner.run(agent, "Check inventory", session=session) + assert result.final_output == "done" + + session_items = await session.get_items() + assert [_raw_item_type(item) for item in session_items] == [ + None, + "program", + "function_call", + "function_call_output", + "program_output", + "message", + ] + function_output = next( + cast(dict[str, Any], item) + for item in session_items + if _raw_item_type(item) == "function_call_output" + ) + assert _caller_dict(function_output["caller"]) == PROGRAM_CALLER + finally: + session.close() + + +@pytest.mark.asyncio +async def test_non_function_programmatic_outputs_preserve_caller() -> None: + caller = cast(Any, PROGRAM_CALLER) + + async def run_tool(tool: Any, tool_call: Any) -> dict[str, Any]: + model = FakeModel() + model.add_multiple_turn_outputs([[_program(), tool_call], [get_text_message("done")]]) + agent = Agent( + name="tool agent", + model=model, + tools=[ProgrammaticToolCallingTool(), tool], + ) + result = await Runner.run(agent, "Run the tool") + return next( + cast(dict[str, Any], item.raw_item) + for item in result.new_items + if isinstance(item, ToolCallOutputItem) + ) + + async def shell_executor(_request: Any) -> str: + return "shell done" + + shell_output = await run_tool( + ShellTool(executor=shell_executor, allowed_callers=["programmatic"]), + ResponseFunctionShellToolCall( + id="shell_item", + call_id="call_shell", + action=Action(commands=["echo ok"]), + status="completed", + type="shell_call", + caller=caller, + ), + ) + assert _caller_dict(shell_output["caller"]) == PROGRAM_CALLER + + custom_output = await run_tool( + CustomTool( + name="custom", + description="Custom tool", + on_invoke_tool=lambda _context, _input: "custom done", + allowed_callers=["programmatic"], + ), + ResponseCustomToolCall( + id="custom_item", + call_id="call_custom", + input="input", + name="custom", + type="custom_tool_call", + caller=caller, + ), + ) + assert _caller_dict(custom_output["caller"]) == PROGRAM_CALLER + + class Editor: + def create_file(self, _operation: Any) -> str: + return "patch done" + + def update_file(self, _operation: Any) -> str: + return "patch done" + + def delete_file(self, _operation: Any) -> str: + return "patch done" + + apply_patch_output = await run_tool( + ApplyPatchTool(editor=Editor(), allowed_callers=["programmatic"]), + ResponseApplyPatchToolCall( + id="apply_patch_item", + call_id="call_apply_patch", + operation=OperationCreateFile(type="create_file", path="example.txt", diff="hello"), + status="completed", + type="apply_patch_call", + caller=caller, + ), + ) + assert _caller_dict(apply_patch_output["caller"]) == PROGRAM_CALLER diff --git a/tests/test_run_internal_items.py b/tests/test_run_internal_items.py index c7997930d1..8933442b1d 100644 --- a/tests/test_run_internal_items.py +++ b/tests/test_run_internal_items.py @@ -73,6 +73,100 @@ def test_drop_orphan_function_calls_preserves_non_mapping_entries() -> None: ) +def test_replay_pruning_drops_orphan_program_call_chain() -> None: + generated_items = cast( + list[TResponseInputItem], + [ + { + "type": "program", + "call_id": "program_orphan", + "code": "return await tools.lookup({});", + "fingerprint": "fingerprint:orphan", + }, + { + "type": "function_call", + "call_id": "function_orphan", + "name": "lookup", + "arguments": "{}", + "caller": {"type": "program", "caller_id": "program_orphan"}, + }, + ], + ) + + prepared = run_items.prepare_model_input_items([], generated_items) + resumed = run_items.normalize_resumed_input(generated_items) + + assert prepared == [] + assert resumed == [] + + +def test_drop_orphan_function_calls_preserves_active_program_call_chain() -> None: + payload = cast( + list[TResponseInputItem], + [ + { + "type": "program", + "call_id": "program_active", + "code": "return await tools.lookup({});", + "fingerprint": "fingerprint:active", + }, + { + "type": "function_call", + "call_id": "function_completed", + "name": "lookup", + "arguments": "{}", + "caller": {"type": "program", "caller_id": "program_active"}, + }, + { + "type": "function_call_output", + "call_id": "function_completed", + "output": "done", + "caller": {"type": "program", "caller_id": "program_active"}, + }, + ], + ) + + filtered = run_items.drop_orphan_function_calls(payload) + + assert filtered == payload + + +@pytest.mark.parametrize( + "hosted_item_type", + [ + "file_search_call", + "web_search_call", + "code_interpreter_call", + "image_generation_call", + "mcp_list_tools", + "mcp_call", + "mcp_approval_request", + "mcp_approval_response", + ], +) +def test_replay_pruning_preserves_program_owned_hosted_items(hosted_item_type: str) -> None: + payload = cast( + list[TResponseInputItem], + [ + { + "type": "program", + "call_id": "program_pending", + "code": "return await tools.lookup({});", + "fingerprint": "fingerprint:pending", + }, + { + "type": hosted_item_type, + "id": "hosted_item", + "caller": {"type": "program", "caller_id": "program_pending"}, + }, + ], + ) + + assert run_items.drop_orphan_function_calls(payload) == payload + assert run_items.prepare_model_input_items([], payload) == payload + assert run_items.normalize_resumed_input(payload) == payload + + def test_drop_orphan_function_calls_drops_reasoning_preceding_dropped_tool_call() -> None: # Regression: reasoning items tied to a now-dropped orphan tool call would otherwise be # forwarded to the API and trigger diff --git a/tests/test_run_state.py b/tests/test_run_state.py index a6955a777b..eef0bf37ad 100644 --- a/tests/test_run_state.py +++ b/tests/test_run_state.py @@ -26,14 +26,20 @@ ActionScreenshot, ResponseComputerToolCall, ) -from openai.types.responses.response_output_item import LocalShellCall, McpApprovalRequest +from openai.types.responses.response_function_tool_call import CallerProgram +from openai.types.responses.response_output_item import ( + LocalShellCall, + McpApprovalRequest, + Program, + ProgramOutput, +) from openai.types.responses.response_usage import InputTokensDetails from openai.types.responses.tool_param import Mcp from pydantic import BaseModel from agents import Agent, Model, ModelSettings, RunConfig, Runner, handoff, trace from agents.computer import Computer -from agents.exceptions import UserError +from agents.exceptions import ModelBehaviorError, UserError from agents.guardrail import ( GuardrailFunctionOutput, InputGuardrail, @@ -45,6 +51,7 @@ from agents.items import ( HandoffOutputItem, ItemHelpers, + MCPApprovalResponseItem, MessageOutputItem, ModelResponse, ReasoningItem, @@ -97,6 +104,7 @@ FunctionTool, HostedMCPTool, LocalShellTool, + ProgrammaticToolCallingTool, ShellTool, function_tool, tool_namespace, @@ -1810,7 +1818,7 @@ async def test_preserves_usage_data(self): serialized = json.loads(str_data) new_state = await RunState.from_string(agent, str_data) - assert serialized["$schemaVersion"] == "1.12" + assert serialized["$schemaVersion"] == CURRENT_SCHEMA_VERSION assert serialized["context"]["usage"]["input_tokens_details"] == [ {"cached_tokens": 3, "cache_write_tokens": 7} ] @@ -4148,12 +4156,18 @@ async def test_deserialize_mcp_items(self): "type": "mcp_approval_response", "approval_request_id": "req123", "approve": True, + "caller": {"type": "program", "caller_id": "program123"}, }, } result_response = _deserialize_items([item_data_response], {"TestAgent": agent}) assert len(result_response) == 1 assert result_response[0].type == "mcp_approval_response_item" + assert isinstance(result_response[0], MCPApprovalResponseItem) + assert result_response[0].raw_item.get("caller") == { + "type": "program", + "caller_id": "program123", + } async def test_deserialize_tool_approval_item(self): """Test deserialization of tool_approval_item.""" @@ -4973,6 +4987,396 @@ async def test_from_json_accepts_previous_schema_version(self): assert restored._context is not None assert restored._context.context == {"foo": "bar"} + @pytest.mark.asyncio + async def test_programmatic_tool_calling_round_trip_uses_current_schema(self): + agent = Agent(name="TestAgent") + state: RunState[Any, Agent[Any]] = make_state( + agent, + context=RunContextWrapper(context={}), + original_input="test", + ) + program = Program( + id="program_item", + call_id="call_program", + code="lookup()", + fingerprint="fingerprint", + type="program", + ) + function_call = ResponseFunctionToolCall( + id="function_item", + call_id="call_function", + name="lookup", + arguments="{}", + caller=CallerProgram(type="program", caller_id="call_program"), + type="function_call", + ) + program_output = ProgramOutput( + id="program_output_item", + call_id="call_program", + result="done", + status="completed", + type="program_output", + ) + state._model_responses = [ + ModelResponse( + output=[program, function_call, program_output], + usage=Usage(), + response_id="response_1", + ) + ] + state._generated_items = [ + ToolCallItem(agent=agent, raw_item=program), + ToolCallItem(agent=agent, raw_item=function_call), + ToolCallOutputItem(agent=agent, raw_item=program_output, output="done"), + ] + + json_data = state.to_json() + assert json_data["$schemaVersion"] == CURRENT_SCHEMA_VERSION + + restored = await RunState.from_json(agent, json_data) + assert isinstance(restored._model_responses[0].output[0], Program) + assert isinstance(restored._model_responses[0].output[2], ProgramOutput) + restored_call = cast(ResponseFunctionToolCall, restored._model_responses[0].output[1]) + assert restored_call["caller"] if isinstance(restored_call, dict) else restored_call.caller + assert isinstance(restored._generated_items[0].raw_item, Program) + assert isinstance(restored._generated_items[2].raw_item, ProgramOutput) + + @pytest.mark.asyncio + async def test_programmatic_tool_calling_round_trip_preserves_mapping_items(self): + agent = Agent(name="TestAgent") + state: RunState[Any, Agent[Any]] = make_state( + agent, + context=RunContextWrapper(context={}), + original_input="test", + ) + program = { + "id": "program_item", + "call_id": "call_program", + "code": "lookup()", + "type": "program", + } + program_output = { + "id": "program_output_item", + "call_id": "call_program", + "result": "done", + "type": "program_output", + } + model_response = ModelResponse(output=[], usage=Usage(), response_id="response_1") + model_response.output = cast(list[TResponseOutputItem], [program, program_output]) + state._model_responses = [model_response] + state._generated_items = [ + ToolCallItem(agent=agent, raw_item=program), + ToolCallOutputItem(agent=agent, raw_item=program_output, output="done"), + ] + + restored = await RunState.from_json(agent, state.to_json()) + + assert cast(list[Any], restored._model_responses[0].output) == [program, program_output] + assert restored._generated_items[0].raw_item == program + assert restored._generated_items[1].raw_item == program_output + + @pytest.mark.asyncio + async def test_programmatic_tool_calling_rechecks_allowed_callers_on_resume(self): + @function_tool(allowed_callers=["programmatic"]) + def saved_lookup() -> str: + return "saved" + + saved_agent = Agent( + name="TestAgent", + tools=[ProgrammaticToolCallingTool(), saved_lookup], + ) + state: RunState[Any, Agent[Any]] = make_state( + saved_agent, + context=RunContextWrapper(context={}), + original_input="test", + ) + program = Program( + id="program_item", + call_id="call_program", + code="saved_lookup()", + fingerprint="fingerprint", + type="program", + ) + function_call = ResponseFunctionToolCall( + id="function_item", + call_id="call_function", + name="saved_lookup", + arguments="{}", + caller=CallerProgram(type="program", caller_id="call_program"), + type="function_call", + ) + state._model_responses = [ + ModelResponse(output=[program], usage=Usage(), response_id="response_1") + ] + state._last_processed_response = make_processed_response( + functions=[ + ToolRunFunction( + tool_call=function_call, + function_tool=saved_lookup, + ) + ] + ) + + @function_tool(name_override="saved_lookup") + def rebound_lookup() -> str: + return "rebound" + + rebound_agent = Agent( + name="TestAgent", + tools=[ProgrammaticToolCallingTool(), rebound_lookup], + ) + with pytest.raises(ModelBehaviorError, match="caller programmatic"): + await RunState.from_json( + rebound_agent, + state.to_json(), + context_override={}, + ) + + @pytest.mark.asyncio + async def test_programmatic_tool_calling_requires_configured_tool_on_resume(self): + @function_tool(allowed_callers=["programmatic"]) + def saved_lookup() -> str: + return "saved" + + saved_agent = Agent( + name="TestAgent", + tools=[ProgrammaticToolCallingTool(), saved_lookup], + ) + state: RunState[Any, Agent[Any]] = make_state( + saved_agent, + context=RunContextWrapper(context={}), + original_input="test", + ) + program = Program( + id="program_item", + call_id="call_program", + code="saved_lookup()", + fingerprint="fingerprint", + type="program", + ) + function_call = ResponseFunctionToolCall( + id="function_item", + call_id="call_function", + name="saved_lookup", + arguments="{}", + caller=CallerProgram(type="program", caller_id="call_program"), + type="function_call", + ) + state._model_responses = [ + ModelResponse(output=[program], usage=Usage(), response_id="response_1") + ] + state._last_processed_response = make_processed_response( + functions=[ToolRunFunction(tool_call=function_call, function_tool=saved_lookup)] + ) + + @function_tool(name_override="saved_lookup", allowed_callers=["programmatic"]) + def rebound_lookup() -> str: + return "rebound" + + rebound_agent = Agent(name="TestAgent", tools=[rebound_lookup]) + with pytest.raises(ModelBehaviorError, match="programmatic_tool_calling tool"): + await RunState.from_json(rebound_agent, state.to_json(), context_override={}) + + @pytest.mark.asyncio + async def test_programmatic_tool_calling_rejects_missing_parent_on_resume(self): + @function_tool(allowed_callers=["programmatic"]) + def saved_lookup() -> str: + return "saved" + + agent = Agent( + name="TestAgent", + tools=[ProgrammaticToolCallingTool(), saved_lookup], + ) + state: RunState[Any, Agent[Any]] = make_state( + agent, + context=RunContextWrapper(context={}), + original_input="test", + ) + function_call = ResponseFunctionToolCall( + id="function_item", + call_id="call_function", + name="saved_lookup", + arguments="{}", + caller=CallerProgram(type="program", caller_id="missing_program"), + type="function_call", + ) + state._last_processed_response = make_processed_response( + functions=[ToolRunFunction(tool_call=function_call, function_tool=saved_lookup)] + ) + + with pytest.raises(ModelBehaviorError, match="parent program item"): + await RunState.from_json(agent, state.to_json(), context_override={}) + + @pytest.mark.asyncio + async def test_programmatic_tool_calling_rejects_completed_parent_on_resume(self): + @function_tool(allowed_callers=["programmatic"]) + def saved_lookup() -> str: + return "saved" + + agent = Agent( + name="TestAgent", + tools=[ProgrammaticToolCallingTool(), saved_lookup], + ) + state: RunState[Any, Agent[Any]] = make_state( + agent, + context=RunContextWrapper(context={}), + original_input="test", + ) + program = Program( + id="program_item", + call_id="call_program", + code="saved_lookup()", + fingerprint="fingerprint", + type="program", + ) + program_output = ProgramOutput( + id="program_output_item", + call_id="call_program", + result="done", + status="completed", + type="program_output", + ) + function_call = ResponseFunctionToolCall( + id="function_item", + call_id="call_function", + name="saved_lookup", + arguments="{}", + caller=CallerProgram(type="program", caller_id="call_program"), + type="function_call", + ) + state._model_responses = [ + ModelResponse( + output=[program, program_output], + usage=Usage(), + response_id="response_1", + ) + ] + state._last_processed_response = make_processed_response( + functions=[ToolRunFunction(tool_call=function_call, function_tool=saved_lookup)] + ) + + with pytest.raises(ModelBehaviorError, match="already completed"): + await RunState.from_json(agent, state.to_json(), context_override={}) + + @pytest.mark.asyncio + async def test_programmatic_mcp_approval_rechecks_allowed_callers_on_resume(self): + saved_mcp_tool = HostedMCPTool( + tool_config=cast( + Mcp, + { + "type": "mcp", + "server_label": "docs_server", + "server_url": "https://example.com/mcp", + "allowed_callers": ["programmatic"], + }, + ) + ) + saved_agent = Agent( + name="TestAgent", + tools=[ProgrammaticToolCallingTool(), saved_mcp_tool], + ) + state: RunState[Any, Agent[Any]] = make_state( + saved_agent, + context=RunContextWrapper(context={}), + original_input="test", + ) + program = Program( + id="program_item", + call_id="call_program", + code="tools.docs_server.lookup()", + fingerprint="fingerprint", + type="program", + ) + approval_request = McpApprovalRequest.model_construct( + id="approval_item", + arguments="{}", + name="lookup", + server_label="docs_server", + type="mcp_approval_request", + caller=CallerProgram(type="program", caller_id="call_program"), + ) + state._model_responses = [ + ModelResponse(output=[program], usage=Usage(), response_id="response_1") + ] + state._last_processed_response = make_processed_response( + mcp_approval_requests=[ + ToolRunMCPApprovalRequest( + request_item=approval_request, + mcp_tool=saved_mcp_tool, + ) + ] + ) + + rebound_mcp_tool = HostedMCPTool( + tool_config=cast( + Mcp, + { + "type": "mcp", + "server_label": "docs_server", + "server_url": "https://example.com/mcp", + "allowed_callers": ["direct"], + }, + ) + ) + rebound_agent = Agent( + name="TestAgent", + tools=[ProgrammaticToolCallingTool(), rebound_mcp_tool], + ) + with pytest.raises(ModelBehaviorError, match="caller programmatic"): + await RunState.from_json( + rebound_agent, + state.to_json(), + context_override={}, + ) + + @pytest.mark.asyncio + async def test_previous_schema_rejects_programmatic_tool_calling_items(self): + agent = Agent(name="TestAgent") + state: RunState[Any, Agent[Any]] = make_state( + agent, + context=RunContextWrapper(context={}), + original_input="test", + ) + state._model_responses = [ + ModelResponse( + output=[ + Program( + id="program_item", + call_id="call_program", + code="lookup()", + fingerprint="fingerprint", + type="program", + ) + ], + usage=Usage(), + response_id="response_1", + ) + ] + json_data = state.to_json() + json_data["$schemaVersion"] = "1.12" + + with pytest.raises(UserError, match="Programmatic Tool Calling requires schema version"): + await RunState.from_json(agent, json_data) + + @pytest.mark.asyncio + async def test_previous_schema_ignores_program_like_arbitrary_context(self): + agent = Agent(name="TestAgent") + state = make_state( + agent, + context=RunContextWrapper( + context={"payload": {"type": "program", "call_id": "not-a-run-item"}} + ), + original_input="test", + ) + json_data = state.to_json() + json_data["$schemaVersion"] = "1.12" + + restored = await RunState.from_json(agent, json_data) + assert restored._context is not None + assert restored._context.context == { + "payload": {"type": "program", "call_id": "not-a-run-item"} + } + def test_supported_schema_versions_match_released_boundary(self): """The support set should include released versions plus the current unreleased writer.""" assert SUPPORTED_SCHEMA_VERSIONS == frozenset( @@ -4989,6 +5393,7 @@ def test_supported_schema_versions_match_released_boundary(self): "1.9", "1.10", "1.11", + "1.12", CURRENT_SCHEMA_VERSION, } ) diff --git a/tests/test_run_step_execution.py b/tests/test_run_step_execution.py index 1cc7895da2..555befc489 100644 --- a/tests/test_run_step_execution.py +++ b/tests/test_run_step_execution.py @@ -3142,12 +3142,14 @@ async def test_execute_tools_runs_hosted_mcp_callback_when_present(): on_approval_request=lambda request: {"approve": True}, ) agent = make_agent(tools=[mcp_tool]) - request_item = McpApprovalRequest( + program_caller = {"type": "program", "caller_id": "program-1"} + request_item = McpApprovalRequest.model_construct( id="mcp-approval-1", type="mcp_approval_request", server_label="test_mcp_server", arguments="{}", name="list_repo_languages", + caller=program_caller, ) processed_response = make_processed_response( new_items=[MCPApprovalRequestItem(raw_item=request_item, agent=agent)], @@ -3162,7 +3164,11 @@ async def test_execute_tools_runs_hosted_mcp_callback_when_present(): result = await run_execute_with_processed_response(agent, processed_response) assert not isinstance(result.next_step, NextStepInterruption) - assert any(isinstance(item, MCPApprovalResponseItem) for item in result.new_step_items) + responses = [ + item for item in result.new_step_items if isinstance(item, MCPApprovalResponseItem) + ] + assert responses + assert responses[0].raw_item.get("caller") == program_caller assert not result.processed_response or not result.processed_response.interruptions @@ -3333,12 +3339,14 @@ async def test_resolve_interrupted_turn_uses_public_agent_for_resumed_hosted_mcp public_agent = make_agent(tools=[mcp_tool]) execution_agent = public_agent.clone() set_public_agent(execution_agent, public_agent) - request_item = McpApprovalRequest( + program_caller = {"type": "program", "caller_id": "program-resume"} + request_item = McpApprovalRequest.model_construct( id="mcp-approval-resume-public-agent", type="mcp_approval_request", server_label="test_mcp_server", arguments="{}", name="list_repo_languages", + caller=program_caller, ) approval_item = ToolApprovalItem( agent=public_agent, @@ -3376,6 +3384,7 @@ async def test_resolve_interrupted_turn_uses_public_agent_for_resumed_hosted_mcp ] assert responses assert all(item.agent is public_agent for item in responses) + assert all(item.raw_item.get("caller") == program_caller for item in responses) @pytest.mark.asyncio