From 9ac31ab49ff655478f344f8dfdc44550683ff475 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 6 Mar 2026 19:23:38 +0900 Subject: [PATCH] feat: add Responses API tool search support (#2610) --- examples/tools/tool_search.py | 219 +++++++ pyproject.toml | 2 +- src/agents/__init__.py | 4 + src/agents/_tool_identity.py | 428 +++++++++++++ src/agents/agent.py | 52 +- src/agents/extensions/handoff_filters.py | 6 + .../memory/advanced_sqlite_session.py | 56 +- src/agents/extensions/tool_output_trimmer.py | 203 ++++-- src/agents/items.py | 159 ++++- src/agents/models/chatcmpl_converter.py | 22 +- src/agents/models/openai_responses.py | 406 +++++++++--- src/agents/realtime/openai_realtime.py | 18 +- src/agents/run.py | 4 +- src/agents/run_context.py | 172 +++++- src/agents/run_internal/approvals.py | 27 +- src/agents/run_internal/items.py | 37 +- src/agents/run_internal/oai_conversation.py | 94 ++- src/agents/run_internal/run_loop.py | 98 ++- src/agents/run_internal/streaming.py | 6 + src/agents/run_internal/tool_execution.py | 141 ++++- src/agents/run_internal/tool_planning.py | 6 + src/agents/run_internal/tool_use_tracker.py | 51 +- src/agents/run_internal/turn_resolution.py | 159 ++++- src/agents/run_state.py | 197 +++++- src/agents/stream_events.py | 2 + src/agents/tool.py | 179 ++++++ src/agents/tool_context.py | 26 + .../memory/test_advanced_sqlite_session.py | 168 +++++ tests/extensions/test_tool_output_trimmer.py | 138 ++++- tests/fake_model.py | 2 +- tests/realtime/test_openai_realtime.py | 11 +- .../test_openai_realtime_conversions.py | 26 +- tests/test_agent_as_tool.py | 255 ++++++++ tests/test_agent_runner.py | 349 +++++++++++ tests/test_extension_filters.py | 50 ++ tests/test_function_tool.py | 119 ++++ tests/test_function_tool_decorator.py | 9 + tests/test_hitl_error_scenarios.py | 257 ++++++++ tests/test_items_helpers.py | 48 ++ tests/test_model_payload_iterators.py | 13 +- .../test_openai_chatcompletions_converter.py | 7 + tests/test_openai_responses.py | 92 ++- tests/test_openai_responses_converter.py | 583 ++++++++++++++++++ tests/test_process_model_response.py | 432 ++++++++++++- tests/test_responses.py | 23 +- tests/test_run_context_approvals.py | 149 +++++ tests/test_run_context_wrapper.py | 20 + tests/test_run_internal_items.py | 257 +++++++- tests/test_run_state.py | 580 +++++++++++++++++ tests/test_run_step_execution.py | 293 +++++++++ tests/test_server_conversation_tracker.py | 388 +++++++++++- tests/test_stream_events.py | 80 ++- tests/test_tool_choice_reset.py | 7 + tests/test_tool_context.py | 42 ++ tests/test_tool_converter.py | 20 +- tests/test_tool_use_behavior.py | 47 +- tests/test_tool_use_tracker.py | 140 ++++- tests/utils/factories.py | 35 +- tests/utils/hitl.py | 9 + uv.lock | 8 +- 60 files changed, 7143 insertions(+), 288 deletions(-) create mode 100644 examples/tools/tool_search.py create mode 100644 src/agents/_tool_identity.py diff --git a/examples/tools/tool_search.py b/examples/tools/tool_search.py new file mode 100644 index 00000000..d0d83cc2 --- /dev/null +++ b/examples/tools/tool_search.py @@ -0,0 +1,219 @@ +import asyncio +import json +import sys +from collections.abc import Mapping +from typing import Annotated, Any + +from agents import ( + Agent, + ModelSettings, + Runner, + ToolSearchTool, + function_tool, + tool_namespace, + trace, +) + +CUSTOMER_PROFILES = { + "customer_42": { + "customer_id": "customer_42", + "full_name": "Avery Chen", + "tier": "enterprise", + } +} + +OPEN_ORDERS = { + "customer_42": [ + {"order_id": "ord_1042", "status": "awaiting fulfillment"}, + {"order_id": "ord_1049", "status": "pending approval"}, + ] +} + +INVOICE_STATUSES = { + "inv_2001": "paid", +} + +SHIPPING_ETAS = { + "ZX-123": "2026-03-06 14:00 JST", +} + +SHIPPING_CREDIT_BALANCES = { + "customer_42": "$125.00", +} + + +@function_tool(defer_loading=True) +def get_customer_profile( + customer_id: Annotated[str, "The CRM customer identifier to look up."], +) -> str: + """Fetch a CRM customer profile.""" + return json.dumps(CUSTOMER_PROFILES[customer_id], indent=2) + + +@function_tool(defer_loading=True) +def list_open_orders( + customer_id: Annotated[str, "The CRM customer identifier to look up."], +) -> str: + """List open orders for a customer.""" + return json.dumps(OPEN_ORDERS.get(customer_id, []), indent=2) + + +@function_tool(defer_loading=True) +def get_invoice_status( + invoice_id: Annotated[str, "The invoice identifier to look up."], +) -> str: + """Look up the status of an invoice.""" + return INVOICE_STATUSES.get(invoice_id, "unknown") + + +@function_tool(defer_loading=True) +def get_shipping_eta( + tracking_number: Annotated[str, "The shipment tracking number to look up."], +) -> str: + """Look up a shipment ETA by tracking number.""" + return SHIPPING_ETAS.get(tracking_number, "unavailable") + + +@function_tool(defer_loading=True) +def get_shipping_credit_balance( + customer_id: Annotated[str, "The customer account identifier to look up."], +) -> str: + """Look up the available shipping credit balance for a customer.""" + return SHIPPING_CREDIT_BALANCES.get(customer_id, "$0.00") + + +crm_tools = tool_namespace( + name="crm", + description="CRM tools for customer lookups.", + tools=[get_customer_profile, list_open_orders], +) + +billing_tools = tool_namespace( + name="billing", + description="Billing tools for invoice lookups.", + tools=[get_invoice_status], +) + +namespaced_agent = Agent( + name="Operations assistant", + model="gpt-5.4", + instructions=( + "For customer questions in this example, load the full `crm` namespace with no query " + "filter before calling tools. " + "Do not search `billing` unless the user asks about invoices." + ), + model_settings=ModelSettings(parallel_tool_calls=False), + tools=[*crm_tools, *billing_tools, ToolSearchTool()], +) + +top_level_agent = Agent( + name="Shipping assistant", + model="gpt-5.4", + instructions=( + "For ETA questions in this example, search `get_shipping_eta` before calling tools. " + "Do not search `get_shipping_credit_balance` unless the user asks about shipping credits." + ), + model_settings=ModelSettings(parallel_tool_calls=False), + tools=[get_shipping_eta, get_shipping_credit_balance, ToolSearchTool()], +) + + +def loaded_paths(result: Any) -> list[str]: + paths: set[str] = set() + + for item in result.new_items: + if item.type != "tool_search_output_item": + continue + + raw_tools = ( + item.raw_item.get("tools") + if isinstance(item.raw_item, Mapping) + else getattr(item.raw_item, "tools", None) + ) + if not isinstance(raw_tools, list): + continue + + for raw_tool in raw_tools: + tool_payload = ( + raw_tool + if isinstance(raw_tool, Mapping) + else ( + raw_tool.model_dump(exclude_unset=True) + if callable(getattr(raw_tool, "model_dump", None)) + else None + ) + ) + if not isinstance(tool_payload, Mapping): + continue + + tool_type = tool_payload.get("type") + if tool_type == "namespace": + path = tool_payload.get("name") + elif tool_type == "function": + path = tool_payload.get("name") + else: + path = tool_payload.get("server_label") + + if isinstance(path, str) and path: + paths.add(path) + + return sorted(paths) + + +def print_result(title: str, result: Any, registered_paths: list[str]) -> None: + loaded = loaded_paths(result) + untouched = [path for path in registered_paths if path not in loaded] + + print(f"## {title}") + print("### Final output") + print(result.final_output) + print("\n### Loaded paths") + print(f"- registered: {', '.join(registered_paths)}") + print(f"- loaded: {', '.join(loaded) if loaded else 'none'}") + print(f"- untouched: {', '.join(untouched) if untouched else 'none'}") + print("\n### Relevant items") + for item in result.new_items: + if item.type in {"tool_search_call_item", "tool_search_output_item", "tool_call_item"}: + print(f"- {item.type}: {item.raw_item}") + print() + + +async def run_namespaced_example() -> None: + result = await Runner.run( + namespaced_agent, + "Look up customer_42 and list their open orders.", + ) + print_result( + "Tool search with namespaces", + result, + registered_paths=["crm", "billing"], + ) + + +async def run_top_level_example() -> None: + result = await Runner.run( + top_level_agent, + "Can you get my ETA for tracking number ZX-123?", + ) + print_result( + "Tool search with top-level deferred tools", + result, + registered_paths=["get_shipping_eta", "get_shipping_credit_balance"], + ) + + +async def main() -> None: + mode = sys.argv[1] if len(sys.argv) > 1 else "all" + + if mode not in {"all", "namespace", "top-level"}: + raise SystemExit(f"Unknown mode: {mode}. Expected one of: all, namespace, top-level.") + + with trace("Tool search example"): + if mode in {"all", "namespace"}: + await run_namespaced_example() + if mode in {"all", "top-level"}: + await run_top_level_example() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/pyproject.toml b/pyproject.toml index 99cefee9..8bd45d5a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ requires-python = ">=3.10" license = "MIT" authors = [{ name = "OpenAI", email = "support@openai.com" }] dependencies = [ - "openai>=2.19.0,<3", + "openai>=2.25.0,<3", "pydantic>=2.12.3, <3", "griffe>=1.5.6, <2", "typing-extensions>=4.12.2, <5", diff --git a/src/agents/__init__.py b/src/agents/__init__.py index d49f995e..35d30e23 100644 --- a/src/agents/__init__.py +++ b/src/agents/__init__.py @@ -154,11 +154,13 @@ from .tool import ( ToolOutputImageDict, ToolOutputText, ToolOutputTextDict, + ToolSearchTool, WebSearchTool, default_tool_error_function, dispose_resolved_computers, function_tool, resolve_computer, + tool_namespace, ) from .tool_guardrails import ( ToolGuardrailFunctionOutput, @@ -420,7 +422,9 @@ __all__ = [ "ToolOutputImageDict", "ToolOutputFileContent", "ToolOutputFileContentDict", + "ToolSearchTool", "function_tool", + "tool_namespace", "resolve_computer", "dispose_resolved_computers", "Usage", diff --git a/src/agents/_tool_identity.py b/src/agents/_tool_identity.py new file mode 100644 index 00000000..520e4440 --- /dev/null +++ b/src/agents/_tool_identity.py @@ -0,0 +1,428 @@ +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any, Literal, cast + +from typing_extensions import Required, TypedDict + +from .exceptions import UserError + +BareFunctionToolLookupKey = tuple[Literal["bare"], str] +NamespacedFunctionToolLookupKey = tuple[Literal["namespaced"], str, str] +DeferredTopLevelFunctionToolLookupKey = tuple[Literal["deferred_top_level"], str] +FunctionToolLookupKey = ( + BareFunctionToolLookupKey + | NamespacedFunctionToolLookupKey + | DeferredTopLevelFunctionToolLookupKey +) +NamedToolLookupKey = FunctionToolLookupKey | str + + +class SerializedFunctionToolLookupKey(TypedDict, total=False): + """Serialized representation of a function-tool lookup key.""" + + kind: Required[Literal["bare", "namespaced", "deferred_top_level"]] + name: Required[str] + namespace: str + + +def get_mapping_or_attr(value: Any, key: str) -> Any: + """Read a key from either a mapping or object attribute.""" + if isinstance(value, dict): + return value.get(key) + return getattr(value, key, None) + + +def tool_qualified_name(name: str | None, namespace: str | None = None) -> str | None: + """Return `namespace.name` when a namespace exists, otherwise `name`.""" + if not isinstance(name, str) or not name: + return None + if isinstance(namespace, str) and namespace: + return f"{namespace}.{name}" + return name + + +def tool_trace_name(name: str | None, namespace: str | None = None) -> str | None: + """Return a display-friendly tool name, collapsing synthetic deferred namespaces.""" + if is_reserved_synthetic_tool_namespace(name, namespace): + return name + return tool_qualified_name(name, namespace) + + +def is_reserved_synthetic_tool_namespace(name: str | None, namespace: str | None) -> bool: + """Return True when a namespace matches the reserved deferred top-level wire shape.""" + return ( + isinstance(name, str) + and bool(name) + and isinstance(namespace, str) + and bool(namespace) + and namespace == name + ) + + +def get_tool_call_namespace(tool_call: Any) -> str | None: + """Extract an optional namespace from a tool call payload.""" + namespace = get_mapping_or_attr(tool_call, "namespace") + return namespace if isinstance(namespace, str) and namespace else None + + +def get_tool_call_name(tool_call: Any) -> str | None: + """Extract a tool name from a tool call payload.""" + name = get_mapping_or_attr(tool_call, "name") + return name if isinstance(name, str) and name else None + + +def get_tool_call_qualified_name(tool_call: Any) -> str | None: + """Return the qualified name for a tool call payload.""" + return tool_qualified_name( + get_tool_call_name(tool_call), + get_tool_call_namespace(tool_call), + ) + + +def get_function_tool_lookup_key( + tool_name: str | None, + tool_namespace: str | None = None, +) -> FunctionToolLookupKey | None: + """Return the collision-free lookup key for a function tool name/namespace pair.""" + if not isinstance(tool_name, str) or not tool_name: + return None + if is_reserved_synthetic_tool_namespace(tool_name, tool_namespace): + return ("deferred_top_level", tool_name) + if isinstance(tool_namespace, str) and tool_namespace: + return ("namespaced", tool_namespace, tool_name) + return ("bare", tool_name) + + +def get_function_tool_lookup_key_for_call(tool_call: Any) -> FunctionToolLookupKey | None: + """Return the collision-free lookup key for a function tool call payload.""" + return get_function_tool_lookup_key( + get_tool_call_name(tool_call), + get_tool_call_namespace(tool_call), + ) + + +def get_function_tool_lookup_key_for_tool(tool: Any) -> FunctionToolLookupKey | None: + """Return the canonical lookup key for a function tool definition.""" + tool_name = get_function_tool_public_name(tool) + if tool_name is None: + return None + if is_deferred_top_level_function_tool(tool): + return ("deferred_top_level", tool_name) + return get_function_tool_lookup_key(tool_name, get_explicit_function_tool_namespace(tool)) + + +def serialize_function_tool_lookup_key( + lookup_key: FunctionToolLookupKey | None, +) -> SerializedFunctionToolLookupKey | None: + """Serialize a function-tool lookup key into a JSON-friendly mapping.""" + if lookup_key is None: + return None + + kind = lookup_key[0] + if kind == "bare": + return {"kind": "bare", "name": lookup_key[1]} + if kind == "namespaced": + namespaced_lookup_key = cast(NamespacedFunctionToolLookupKey, lookup_key) + return { + "kind": "namespaced", + "namespace": namespaced_lookup_key[1], + "name": namespaced_lookup_key[2], + } + return {"kind": "deferred_top_level", "name": lookup_key[1]} + + +def deserialize_function_tool_lookup_key(data: Any) -> FunctionToolLookupKey | None: + """Deserialize a persisted function-tool lookup key mapping.""" + if not isinstance(data, dict): + return None + + kind = data.get("kind") + name = data.get("name") + if not isinstance(kind, str) or not isinstance(name, str) or not name: + return None + + if kind == "bare": + return ("bare", name) + if kind == "deferred_top_level": + return ("deferred_top_level", name) + if kind == "namespaced": + namespace = data.get("namespace") + if isinstance(namespace, str) and namespace: + return ("namespaced", namespace, name) + return None + + +def get_tool_call_trace_name(tool_call: Any) -> str | None: + """Return the trace display name for a tool call payload.""" + return tool_trace_name( + get_tool_call_name(tool_call), + get_tool_call_namespace(tool_call), + ) + + +def _remove_tool_call_namespace(tool_call: Any) -> Any: + """Return a shallow copy of the tool call without its namespace field.""" + if isinstance(tool_call, dict): + normalized_tool_call = dict(tool_call) + normalized_tool_call.pop("namespace", None) + return normalized_tool_call + + model_dump = getattr(tool_call, "model_dump", None) + if callable(model_dump): + payload = model_dump(exclude_unset=True) + if isinstance(payload, dict): + payload.pop("namespace", None) + try: + return type(tool_call)(**payload) + except Exception: + return payload + + return tool_call + + +def has_function_tool_shape(tool: Any) -> bool: + """Return True when the object looks like a FunctionTool instance.""" + return callable(getattr(tool, "on_invoke_tool", None)) and isinstance( + getattr(tool, "params_json_schema", None), dict + ) + + +def get_function_tool_public_name(tool: Any) -> str | None: + """Return the public name exposed for a function tool.""" + if not has_function_tool_shape(tool): + return None + tool_name = getattr(tool, "name", None) + return tool_name if isinstance(tool_name, str) and tool_name else None + + +def get_function_tool_namespace(tool: Any) -> str | None: + """Return the explicit namespace for a function tool, if any.""" + return get_explicit_function_tool_namespace(tool) + + +def get_explicit_function_tool_namespace(tool: Any) -> str | None: + """Return only explicitly attached namespace metadata for a function tool.""" + explicit_namespace = getattr(tool, "_tool_namespace", None) + if isinstance(explicit_namespace, str) and explicit_namespace: + return explicit_namespace + return None + + +def get_function_tool_namespace_description(tool: Any) -> str | None: + """Return the namespace description attached to a function tool, if any.""" + description = getattr(tool, "_tool_namespace_description", None) + return description if isinstance(description, str) and description else None + + +def is_deferred_top_level_function_tool(tool: Any) -> bool: + """Return True when the tool is deferred-loading without an explicit namespace.""" + return ( + bool(getattr(tool, "defer_loading", False)) + and get_explicit_function_tool_namespace(tool) is None + and get_function_tool_public_name(tool) is not None + ) + + +def get_function_tool_dispatch_name(tool: Any) -> str | None: + """Return the canonical dispatch key for a function tool.""" + tool_name = get_function_tool_public_name(tool) + if tool_name is None: + return None + return tool_qualified_name(tool_name, get_explicit_function_tool_namespace(tool)) + + +def get_function_tool_lookup_keys(tool: Any) -> tuple[FunctionToolLookupKey, ...]: + """Return all lookup keys that should resolve this function tool.""" + tool_name = get_function_tool_public_name(tool) + if tool_name is None: + return () + + lookup_keys: list[FunctionToolLookupKey] = [] + dispatch_key = get_function_tool_lookup_key( + tool_name, + get_explicit_function_tool_namespace(tool), + ) + if dispatch_key is not None and not is_deferred_top_level_function_tool(tool): + lookup_keys.append(dispatch_key) + + synthetic_lookup_key = get_deferred_top_level_function_tool_lookup_key(tool) + if synthetic_lookup_key is not None and synthetic_lookup_key not in lookup_keys: + lookup_keys.append(synthetic_lookup_key) + + return tuple(lookup_keys) + + +def should_allow_bare_name_approval_alias(tool: Any, all_tools: Sequence[Any]) -> bool: + """Allow bare-name approval aliases only for deferred top-level tools without visible peers.""" + tool_name = get_function_tool_public_name(tool) + if tool_name is None or not is_deferred_top_level_function_tool(tool): + return False + + for candidate in all_tools: + if candidate is tool or get_function_tool_public_name(candidate) != tool_name: + continue + if get_explicit_function_tool_namespace(candidate) is not None: + continue + if bool(getattr(candidate, "defer_loading", False)): + continue + return False + + return True + + +def get_deferred_top_level_function_tool_lookup_key( + tool: Any, +) -> DeferredTopLevelFunctionToolLookupKey | None: + """Return the synthetic lookup key used for deferred top-level tool calls.""" + tool_name = get_function_tool_public_name(tool) + if tool_name is None or not is_deferred_top_level_function_tool(tool): + return None + return ("deferred_top_level", tool_name) + + +def validate_function_tool_namespace_shape( + tool_name: str | None, + tool_namespace: str | None, +) -> None: + """Reject reserved namespace shapes that collide with deferred top-level tool calls.""" + if not is_reserved_synthetic_tool_namespace(tool_name, tool_namespace): + return + + reserved_key = tool_qualified_name(tool_name, tool_namespace) or tool_name or "unknown_tool" + raise UserError( + "Responses tool-search reserves the synthetic namespace " + f"`{reserved_key}` for deferred top-level function tools. " + "Rename the namespace or tool name to avoid ambiguous dispatch." + ) + + +def validate_function_tool_lookup_configuration(tools: Sequence[Any]) -> None: + """Reject function-tool combinations that are ambiguous on the Responses wire.""" + qualified_name_owners: dict[str, Any] = {} + deferred_top_level_name_owners: dict[str, Any] = {} + for tool in tools: + tool_name = get_function_tool_public_name(tool) + explicit_namespace = get_explicit_function_tool_namespace(tool) + validate_function_tool_namespace_shape(tool_name, explicit_namespace) + + deferred_lookup_key = get_deferred_top_level_function_tool_lookup_key(tool) + if deferred_lookup_key is not None: + deferred_name = deferred_lookup_key[1] + prior_deferred_owner = deferred_top_level_name_owners.get(deferred_name) + if prior_deferred_owner is not None: + raise UserError( + "Ambiguous function tool configuration: the deferred top-level tool name " + f"`{deferred_name}` is used by multiple tools. Rename one of the " + "deferred-loading top-level function tools to avoid ambiguous dispatch." + ) + deferred_top_level_name_owners[deferred_name] = tool + + qualified_name = get_function_tool_qualified_name(tool) + if qualified_name is None: + continue + + prior_owner = qualified_name_owners.get(qualified_name) + if prior_owner is None: + qualified_name_owners[qualified_name] = tool + continue + + prior_namespace = get_explicit_function_tool_namespace(prior_owner) + if explicit_namespace is None and prior_namespace is None: + continue + + raise UserError( + "Ambiguous function tool configuration: the qualified name " + f"`{qualified_name}` is used by multiple tools. " + "Rename the namespace-wrapped function or dotted top-level tool to avoid " + "ambiguous dispatch." + ) + + +def build_function_tool_lookup_map(tools: Sequence[Any]) -> dict[FunctionToolLookupKey, Any]: + """Build a function-tool lookup map using last-wins precedence.""" + validate_function_tool_lookup_configuration(tools) + tool_map: dict[FunctionToolLookupKey, Any] = {} + for tool in tools: + for lookup_key in get_function_tool_lookup_keys(tool): + tool_map[lookup_key] = tool + return tool_map + + +def get_function_tool_approval_keys( + *, + tool_name: str | None, + tool_namespace: str | None = None, + allow_bare_name_alias: bool = False, + tool_lookup_key: FunctionToolLookupKey | None = None, + prefer_legacy_same_name_namespace: bool = False, + include_legacy_deferred_key: bool = False, +) -> tuple[str, ...]: + """Return approval keys for a tool name/namespace pair.""" + if not isinstance(tool_name, str) or not tool_name: + return () + + approval_keys: list[str] = [] + lookup_key = tool_lookup_key + if lookup_key is None and not ( + prefer_legacy_same_name_namespace + and is_reserved_synthetic_tool_namespace(tool_name, tool_namespace) + ): + lookup_key = get_function_tool_lookup_key(tool_name, tool_namespace) + + qualified_name = tool_qualified_name(tool_name, tool_namespace) + + if allow_bare_name_alias and tool_name not in approval_keys: + approval_keys.append(tool_name) + + if lookup_key is not None: + if lookup_key[0] == "namespaced": + key = tool_qualified_name(lookup_key[2], lookup_key[1]) + elif lookup_key[0] == "deferred_top_level": + key = f"deferred_top_level:{lookup_key[1]}" + else: + key = lookup_key[1] + if key is not None and key not in approval_keys: + approval_keys.append(key) + if ( + include_legacy_deferred_key + and lookup_key[0] == "deferred_top_level" + and qualified_name is not None + and qualified_name not in approval_keys + ): + approval_keys.append(qualified_name) + elif qualified_name is not None and qualified_name not in approval_keys: + approval_keys.append(qualified_name) + + if not approval_keys: + approval_keys.append(tool_name) + + return tuple(approval_keys) + + +def normalize_tool_call_for_function_tool(tool_call: Any, tool: Any) -> Any: + """Strip synthetic namespaces from deferred top-level tool calls.""" + tool_name = get_function_tool_public_name(tool) + if tool_name is None or not is_deferred_top_level_function_tool(tool): + return tool_call + + if get_tool_call_name(tool_call) != tool_name: + return tool_call + + if get_tool_call_namespace(tool_call) != tool_name: + return tool_call + + return _remove_tool_call_namespace(tool_call) + + +def get_function_tool_qualified_name(tool: Any) -> str | None: + """Return the qualified lookup key for a function tool.""" + return get_function_tool_dispatch_name(tool) + + +def get_function_tool_trace_name(tool: Any) -> str | None: + """Return the trace display name for a function tool.""" + tool_name = get_function_tool_public_name(tool) + if tool_name is None: + return None + return tool_trace_name(tool_name, get_function_tool_namespace(tool)) diff --git a/src/agents/agent.py b/src/agents/agent.py index d40d9052..ff1b2d1f 100644 --- a/src/agents/agent.py +++ b/src/agents/agent.py @@ -11,6 +11,7 @@ from openai.types.responses.response_prompt_param import ResponsePromptParam from pydantic import BaseModel, TypeAdapter, ValidationError from typing_extensions import NotRequired, TypeAlias, TypedDict +from ._tool_identity import get_function_tool_approval_keys from .agent_output import AgentOutputSchemaBase from .agent_tool_input import ( AgentAsToolInput, @@ -50,6 +51,7 @@ from .tool import ( _log_function_tool_invocation, _parse_function_tool_json_input, default_tool_error_function, + prune_orphaned_tool_search_tools, ) from .tool_context import ToolContext from .util import _transforms @@ -210,7 +212,7 @@ class AgentBase(Generic[TContext]): results = await asyncio.gather(*(_check_tool_enabled(t) for t in self.tools)) enabled: list[Tool] = [t for t, ok in zip(self.tools, results) if ok] - all_tools: list[Tool] = [*mcp_tools, *enabled] + all_tools: list[Tool] = prune_orphaned_tool_search_tools([*mcp_tools, *enabled]) _validate_codex_tool_name_collisions(all_tools) return all_tools @@ -597,6 +599,7 @@ class Agent(AgentBase, Generic[TContext]): tool_call_id=context.tool_call_id, tool_arguments=context.tool_arguments, tool_call=context.tool_call, + tool_namespace=context.tool_namespace, agent=context.agent, run_config=resolved_run_config, ) @@ -631,8 +634,12 @@ class Agent(AgentBase, Generic[TContext]): if not call_id: has_pending = True continue + tool_namespace = RunContextWrapper._resolve_tool_namespace(interruption) status = context.get_approval_status( - interruption.tool_name or "", call_id, existing_pending=interruption + interruption.tool_name or "", + call_id, + tool_namespace=tool_namespace, + existing_pending=interruption, ) if status is False: return "rejected" @@ -651,17 +658,54 @@ class Agent(AgentBase, Generic[TContext]): parent_context: RunContextWrapper[Any], interruptions: list[ToolApprovalItem], ) -> None: + def _find_mirrored_approval_record( + interruption: ToolApprovalItem, + *, + approved: bool, + ) -> Any | None: + candidate_keys = list(RunContextWrapper._resolve_approval_keys(interruption)) + for candidate_key in get_function_tool_approval_keys( + tool_name=RunContextWrapper._resolve_tool_name(interruption), + tool_namespace=RunContextWrapper._resolve_tool_namespace(interruption), + tool_lookup_key=RunContextWrapper._resolve_tool_lookup_key(interruption), + include_legacy_deferred_key=True, + ): + if candidate_key not in candidate_keys: + candidate_keys.append(candidate_key) + fallback: Any | None = None + for candidate_key in candidate_keys: + candidate = parent_context._approvals.get(candidate_key) + if candidate is None: + continue + if approved and candidate.approved is True: + return candidate + if not approved and candidate.rejected is True: + return candidate + if fallback is None: + fallback = candidate + return fallback + for interruption in interruptions: call_id = interruption.call_id if not call_id: continue tool_name = RunContextWrapper._resolve_tool_name(interruption) + tool_namespace = RunContextWrapper._resolve_tool_namespace(interruption) + approval_key = RunContextWrapper._resolve_approval_key(interruption) status = parent_context.get_approval_status( - tool_name, call_id, existing_pending=interruption + tool_name, + call_id, + tool_namespace=tool_namespace, + existing_pending=interruption, ) if status is None: continue - approval_record = parent_context._approvals.get(tool_name) + approval_record = parent_context._approvals.get(approval_key) + if approval_record is None: + approval_record = _find_mirrored_approval_record( + interruption, + approved=status, + ) if status is True: always_approve = bool(approval_record and approval_record.approved is True) nested_context.approve_tool( diff --git a/src/agents/extensions/handoff_filters.py b/src/agents/extensions/handoff_filters.py index 85d68c1d..6ccb32f4 100644 --- a/src/agents/extensions/handoff_filters.py +++ b/src/agents/extensions/handoff_filters.py @@ -12,6 +12,8 @@ from ..items import ( RunItem, ToolCallItem, ToolCallOutputItem, + ToolSearchCallItem, + ToolSearchOutputItem, TResponseInputItem, ) @@ -50,6 +52,8 @@ def _remove_tools_from_items(items: tuple[RunItem, ...]) -> tuple[RunItem, ...]: if ( isinstance(item, HandoffCallItem) or isinstance(item, HandoffOutputItem) + or isinstance(item, ToolSearchCallItem) + or isinstance(item, ToolSearchOutputItem) or isinstance(item, ToolCallItem) or isinstance(item, ToolCallOutputItem) or isinstance(item, ReasoningItem) @@ -68,6 +72,8 @@ def _remove_tool_types_from_input( "computer_call", "computer_call_output", "file_search_call", + "tool_search_call", + "tool_search_output", "web_search_call", ] diff --git a/src/agents/extensions/memory/advanced_sqlite_session.py b/src/agents/extensions/memory/advanced_sqlite_session.py index c60a3bae..f5712685 100644 --- a/src/agents/extensions/memory/advanced_sqlite_session.py +++ b/src/agents/extensions/memory/advanced_sqlite_session.py @@ -11,6 +11,7 @@ from typing import Any, Union, cast from agents.result import RunResult from agents.usage import Usage +from ..._tool_identity import is_reserved_synthetic_tool_namespace, tool_qualified_name from ...items import TResponseInputItem from ...memory import SQLiteSession from ...memory.session_settings import SessionSettings, resolve_session_limit @@ -527,13 +528,28 @@ class AdvancedSQLiteSession(SQLiteSession): "file_search_call", "web_search_call", "code_interpreter_call", + "tool_search_call", + "tool_search_output", }: + if item_type in {"tool_search_call", "tool_search_output"}: + return "tool_search" return item_type # Most other tool calls have a 'name' field elif "name" in item: name = item.get("name") - return str(name) if name is not None else None + namespace = item.get("namespace") + if name is not None: + name_str = str(name) + namespace_str = str(namespace) if namespace is not None else None + if is_reserved_synthetic_tool_namespace(name_str, namespace_str): + return name_str + qualified_name = tool_qualified_name( + name_str, + namespace_str, + ) + return qualified_name or name_str + return None return None @@ -1051,17 +1067,41 @@ class AdvancedSQLiteSession(SQLiteSession): with closing(conn.cursor()) as cursor: cursor.execute( """ - SELECT tool_name, COUNT(*), user_turn_number - FROM message_structure - WHERE session_id = ? AND branch_id = ? AND message_type IN ( - 'tool_call', 'function_call', 'computer_call', 'file_search_call', - 'web_search_call', 'code_interpreter_call', 'custom_tool_call', - 'mcp_call', 'mcp_approval_request' + SELECT tool_name, SUM(usage_count), user_turn_number + FROM ( + SELECT tool_name, 1 AS usage_count, user_turn_number + FROM message_structure + WHERE session_id = ? AND branch_id = ? AND message_type IN ( + 'tool_call', 'function_call', 'computer_call', 'file_search_call', + 'web_search_call', 'code_interpreter_call', 'tool_search_call', + 'custom_tool_call', 'mcp_call', 'mcp_approval_request' + ) + + UNION ALL + + SELECT ms.tool_name, 1 AS usage_count, ms.user_turn_number + FROM message_structure ms + WHERE ms.session_id = ? AND ms.branch_id = ? + AND ms.message_type = 'tool_search_output' + AND NOT EXISTS ( + SELECT 1 + FROM message_structure calls + WHERE calls.session_id = ms.session_id + AND calls.branch_id = ms.branch_id + AND calls.user_turn_number = ms.user_turn_number + AND calls.tool_name = ms.tool_name + AND calls.message_type = 'tool_search_call' + ) ) GROUP BY tool_name, user_turn_number ORDER BY user_turn_number """, - (self.session_id, branch_id), + ( + self.session_id, + branch_id, + self.session_id, + branch_id, + ), ) return cursor.fetchall() diff --git a/src/agents/extensions/tool_output_trimmer.py b/src/agents/extensions/tool_output_trimmer.py index b75883b6..c3c5ed7d 100644 --- a/src/agents/extensions/tool_output_trimmer.py +++ b/src/agents/extensions/tool_output_trimmer.py @@ -27,9 +27,12 @@ compact preview. from __future__ import annotations +import json import logging from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast + +from .._tool_identity import get_tool_call_name, get_tool_call_trace_name if TYPE_CHECKING: from ..run_config import CallModelData, ModelInputData @@ -53,8 +56,10 @@ class ToolOutputTrimmer: trimming. Defaults to 500. preview_chars: How many characters of the original output to preserve as a preview when trimming. Defaults to 200. - trimmable_tools: Optional set of tool names whose outputs can be trimmed. If - ``None``, all tool outputs are eligible for trimming. Defaults to ``None``. + trimmable_tools: Optional set of tool names whose outputs can be trimmed. For + namespaced tools, both bare names and qualified ``namespace.name`` entries are + supported. If ``None``, all tool outputs are eligible for trimming. Defaults + to ``None``. """ recent_turns: int = 2 @@ -92,43 +97,42 @@ class ToolOutputTrimmer: if boundary == 0: return model_data - call_id_to_name = self._build_call_id_to_name(items) + call_id_to_names = self._build_call_id_to_names(items) trimmed_count = 0 chars_saved = 0 new_items: list[Any] = [] for i, item in enumerate(items): - if ( - i < boundary - and isinstance(item, dict) - and item.get("type") == "function_call_output" - ): - output = item.get("output", "") - output_str = output if isinstance(output, str) else str(output) - output_len = len(output_str) + if i < boundary and isinstance(item, dict): + item_dict = cast(dict[str, Any], item) + item_type = item_dict.get("type") + call_id = str(item_dict.get("call_id") or item_dict.get("id") or "") + tool_names = call_id_to_names.get( + call_id, + ("tool_search",) if item_type == "tool_search_output" else (), + ) - call_id = str(item.get("call_id", "")) - tool_name = call_id_to_name.get(call_id, "") - - if output_len > self.max_output_chars and ( - self.trimmable_tools is None or tool_name in self.trimmable_tools + if self.trimmable_tools is not None and not any( + candidate in self.trimmable_tools for candidate in tool_names ): - display_name = tool_name or "unknown_tool" - preview = output_str[: self.preview_chars] - summary = ( - f"[Trimmed: {display_name} output — {output_len} chars → " - f"{self.preview_chars} char preview]\n{preview}..." - ) - # Only replace if summary is actually shorter than the original - if len(summary) < output_len: - trimmed_item = dict(item) - trimmed_item["output"] = summary - new_items.append(trimmed_item) + new_items.append(item) + continue - trimmed_count += 1 - chars_saved += output_len - len(summary) - continue + trimmed_item: dict[str, Any] | None = None + saved_chars = 0 + if item_type == "function_call_output": + trimmed_item, saved_chars = self._trim_function_call_output( + item_dict, tool_names + ) + elif item_type == "tool_search_output": + trimmed_item, saved_chars = self._trim_tool_search_output(item_dict) + + if trimmed_item is not None: + new_items.append(trimmed_item) + trimmed_count += 1 + chars_saved += saved_chars + continue new_items.append(item) @@ -158,13 +162,138 @@ class ToolOutputTrimmer: return i return 0 - def _build_call_id_to_name(self, items: list[Any]) -> dict[str, str]: - """Build a mapping from function call_id to tool name.""" - mapping: dict[str, str] = {} + def _build_call_id_to_names(self, items: list[Any]) -> dict[str, tuple[str, ...]]: + """Build a mapping from function call_id to candidate tool names.""" + mapping: dict[str, tuple[str, ...]] = {} for item in items: if isinstance(item, dict) and item.get("type") == "function_call": call_id = item.get("call_id") - name = item.get("name") - if call_id and name: - mapping[call_id] = name + qualified_name = get_tool_call_trace_name(item) + bare_name = get_tool_call_name(item) + names: list[str] = [] + if qualified_name: + names.append(qualified_name) + if bare_name and bare_name != qualified_name: + names.append(bare_name) + if call_id and names: + mapping[str(call_id)] = tuple(names) + elif isinstance(item, dict) and item.get("type") == "tool_search_call": + call_id = item.get("call_id") or item.get("id") + if call_id: + mapping[str(call_id)] = ("tool_search",) return mapping + + def _trim_function_call_output( + self, + item: dict[str, Any], + tool_names: tuple[str, ...], + ) -> tuple[dict[str, Any] | None, int]: + """Trim a function_call_output item when its serialized output is too large.""" + output = item.get("output", "") + output_str = output if isinstance(output, str) else str(output) + output_len = len(output_str) + if output_len <= self.max_output_chars: + return None, 0 + + tool_name = tool_names[0] if tool_names else "" + display_name = tool_name or "unknown_tool" + preview = output_str[: self.preview_chars] + summary = ( + f"[Trimmed: {display_name} output — {output_len} chars → " + f"{self.preview_chars} char preview]\n{preview}..." + ) + if len(summary) >= output_len: + return None, 0 + + trimmed_item = dict(item) + trimmed_item["output"] = summary + return trimmed_item, output_len - len(summary) + + def _trim_tool_search_output(self, item: dict[str, Any]) -> tuple[dict[str, Any] | None, int]: + """Trim a tool_search_output item while keeping a valid replayable shape.""" + if isinstance(item.get("results"), list): + return self._trim_legacy_tool_search_results(item) + + tools = item.get("tools") + if not isinstance(tools, list): + return None, 0 + + original = self._serialize_json_like(tools) + if len(original) <= self.max_output_chars: + return None, 0 + + trimmed_tools = [self._trim_tool_search_tool(tool) for tool in tools] + trimmed = self._serialize_json_like(trimmed_tools) + if len(trimmed) >= len(original): + return None, 0 + + trimmed_item = dict(item) + trimmed_item["tools"] = trimmed_tools + return trimmed_item, len(original) - len(trimmed) + + def _trim_legacy_tool_search_results( + self, + item: dict[str, Any], + ) -> tuple[dict[str, Any] | None, int]: + """Trim legacy partial tool_search_output snapshots that still store free-text results.""" + serialized_results = self._serialize_json_like(item.get("results")) + output_len = len(serialized_results) + if output_len <= self.max_output_chars: + return None, 0 + + preview = serialized_results[: self.preview_chars] + summary = ( + f"[Trimmed: tool_search output — {output_len} chars → " + f"{self.preview_chars} char preview]\n{preview}..." + ) + if len(summary) >= output_len: + return None, 0 + + trimmed_item = dict(item) + trimmed_item["results"] = [{"text": summary}] + return trimmed_item, output_len - len(summary) + + def _trim_tool_search_tool(self, tool: Any) -> Any: + """Recursively strip bulky descriptions and schema prose from tool search results.""" + if not isinstance(tool, dict): + return tool + + trimmed_tool = dict(tool) + if isinstance(trimmed_tool.get("description"), str): + trimmed_tool["description"] = trimmed_tool["description"][: self.preview_chars] + if len(tool["description"]) > self.preview_chars: + trimmed_tool["description"] += "..." + + tool_type = trimmed_tool.get("type") + if tool_type == "function" and isinstance(trimmed_tool.get("parameters"), dict): + trimmed_tool["parameters"] = self._trim_json_schema(trimmed_tool["parameters"]) + elif tool_type == "namespace" and isinstance(trimmed_tool.get("tools"), list): + trimmed_tool["tools"] = [ + self._trim_tool_search_tool(nested_tool) for nested_tool in trimmed_tool["tools"] + ] + + return trimmed_tool + + def _trim_json_schema(self, schema: dict[str, Any]) -> dict[str, Any]: + """Remove verbose prose from a JSON schema while preserving its structure.""" + trimmed_schema: dict[str, Any] = {} + for key, value in schema.items(): + if key in {"description", "title", "$comment", "examples"}: + continue + if isinstance(value, dict): + trimmed_schema[key] = self._trim_json_schema(value) + elif isinstance(value, list): + trimmed_schema[key] = [ + self._trim_json_schema(item) if isinstance(item, dict) else item + for item in value + ] + else: + trimmed_schema[key] = value + return trimmed_schema + + def _serialize_json_like(self, value: Any) -> str: + """Serialize structured tool output for sizing comparisons.""" + try: + return json.dumps(value, ensure_ascii=False, sort_keys=True, default=str) + except Exception: + return str(value) diff --git a/src/agents/items.py b/src/agents/items.py index 8eadcbcd..2a677c8c 100644 --- a/src/agents/items.py +++ b/src/agents/items.py @@ -3,6 +3,7 @@ from __future__ import annotations import abc import json import weakref +from collections.abc import Mapping from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, Union, cast @@ -20,6 +21,8 @@ from openai.types.responses import ( ResponseOutputRefusal, ResponseOutputText, ResponseStreamEvent, + ResponseToolSearchCall, + ResponseToolSearchOutputItem, ) from openai.types.responses.response_code_interpreter_tool_call import ( ResponseCodeInterpreterToolCall, @@ -47,6 +50,7 @@ from openai.types.responses.response_reasoning_item import ResponseReasoningItem from pydantic import BaseModel from typing_extensions import TypeAlias, assert_never +from ._tool_identity import FunctionToolLookupKey, get_function_tool_lookup_key, tool_trace_name from .exceptions import AgentsException, ModelBehaviorError from .logger import logger from .tool import ( @@ -74,7 +78,9 @@ TResponseOutputItem = ResponseOutputItem TResponseStreamEvent = ResponseStreamEvent """A type alias for the ResponseStreamEvent type from the OpenAI SDK.""" -T = TypeVar("T", bound=Union[TResponseOutputItem, TResponseInputItem]) +T = TypeVar("T", bound=Union[TResponseOutputItem, TResponseInputItem, dict[str, Any]]) +ToolSearchCallRawItem: TypeAlias = ResponseToolSearchCall | dict[str, Any] +ToolSearchOutputRawItem: TypeAlias = ResponseToolSearchOutputItem | dict[str, Any] # Distinguish a missing dict entry from an explicit None value. _MISSING_ATTR_SENTINEL = object() @@ -156,6 +162,105 @@ class MessageOutputItem(RunItemBase[ResponseOutputMessage]): type: Literal["message_output_item"] = "message_output_item" +@dataclass +class ToolSearchCallItem(RunItemBase[ToolSearchCallRawItem]): + """Represents a Responses API tool search request emitted by the model.""" + + raw_item: ToolSearchCallRawItem + """The raw tool search call item, preserving partial dict snapshots when needed.""" + + type: Literal["tool_search_call_item"] = "tool_search_call_item" + + def to_input_item(self) -> TResponseInputItem: + """Convert the tool search call into a replayable Responses input item.""" + return _tool_search_item_to_input_item(self.raw_item) + + +@dataclass +class ToolSearchOutputItem(RunItemBase[ToolSearchOutputRawItem]): + """Represents the output of a Responses API tool search.""" + + raw_item: ToolSearchOutputRawItem + """The raw tool search output item, preserving partial dict snapshots when needed.""" + + type: Literal["tool_search_output_item"] = "tool_search_output_item" + + def to_input_item(self) -> TResponseInputItem: + """Convert the tool search output into a replayable Responses input item.""" + return _tool_search_item_to_input_item(self.raw_item) + + +def _tool_search_item_to_input_item( + raw_item: ToolSearchCallRawItem | ToolSearchOutputRawItem, +) -> TResponseInputItem: + """Strip output-only tool_search fields before replaying items back to the API.""" + if isinstance(raw_item, dict): + payload = dict(raw_item) + elif isinstance(raw_item, BaseModel): + payload = raw_item.model_dump(exclude_unset=True) + else: + raise AgentsException(f"Unexpected raw item type: {type(raw_item)}") + + payload.pop("created_by", None) + return cast(TResponseInputItem, payload) + + +def _output_item_to_input_item(raw_item: Any) -> TResponseInputItem: + """Convert an output item into replayable input, normalizing tool_search items.""" + item_type = ( + raw_item.get("type") if isinstance(raw_item, dict) else getattr(raw_item, "type", None) + ) + if item_type in {"tool_search_call", "tool_search_output"}: + return _tool_search_item_to_input_item(raw_item) + + if isinstance(raw_item, dict): + return cast(TResponseInputItem, dict(raw_item)) + if isinstance(raw_item, BaseModel): + return cast(TResponseInputItem, raw_item.model_dump(exclude_unset=True)) + + raise AgentsException(f"Unexpected raw item type: {type(raw_item)}") + + +def _copy_tool_search_mapping(raw_item: Mapping[str, Any]) -> dict[str, Any]: + copied = dict(raw_item) + copied_type = copied.get("type") + if isinstance(copied_type, str): + copied["type"] = copied_type + return copied + + +def coerce_tool_search_call_raw_item(raw_item: Any) -> ToolSearchCallRawItem: + """Prefer the typed SDK tool_search call model while tolerating partial snapshots.""" + if isinstance(raw_item, ResponseToolSearchCall): + return raw_item + if isinstance(raw_item, Mapping): + copied = _copy_tool_search_mapping(raw_item) + if copied.get("type") != "tool_search_call": + raise AgentsException(f"Unexpected tool search call item type: {copied.get('type')!r}") + try: + return ResponseToolSearchCall.model_validate(copied) + except pydantic.ValidationError: + return copied + raise AgentsException(f"Unexpected tool search call item type: {type(raw_item)}") + + +def coerce_tool_search_output_raw_item(raw_item: Any) -> ToolSearchOutputRawItem: + """Prefer the typed SDK tool_search output model while tolerating partial snapshots.""" + if isinstance(raw_item, ResponseToolSearchOutputItem): + return raw_item + if isinstance(raw_item, Mapping): + copied = _copy_tool_search_mapping(raw_item) + if copied.get("type") != "tool_search_output": + raise AgentsException( + f"Unexpected tool search output item type: {copied.get('type')!r}" + ) + try: + return ResponseToolSearchOutputItem.model_validate(copied) + except pydantic.ValidationError: + return copied + raise AgentsException(f"Unexpected tool search output item type: {type(raw_item)}") + + @dataclass class HandoffCallItem(RunItemBase[ResponseFunctionToolCall]): """Represents a tool call for a handoff from one agent to another.""" @@ -375,8 +480,23 @@ class ToolApprovalItem(RunItemBase[Any]): tool_name: str | None = None """Tool name for approval tracking; falls back to raw_item.name when absent.""" + _allow_bare_name_alias: bool = field(default=False, kw_only=True, repr=False) + """Whether permanent approval decisions should also be recorded under the bare tool name.""" + + # Keep `type` ahead of `tool_namespace` to preserve the historical 4-argument positional + # constructor shape: `(agent, raw_item, tool_name, type)`. type: Literal["tool_approval_item"] = "tool_approval_item" + tool_namespace: str | None = None + """Optional Responses API namespace for function-tool approvals.""" + + tool_lookup_key: FunctionToolLookupKey | None = field( + default=None, + kw_only=True, + repr=False, + ) + """Canonical function-tool lookup metadata when the approval targets a function tool.""" + def __post_init__(self) -> None: """Populate tool_name from the raw item if not provided.""" if self.tool_name is None: @@ -387,6 +507,26 @@ class ToolApprovalItem(RunItemBase[Any]): self.tool_name = self.raw_item.name else: self.tool_name = None + if self.tool_namespace is None: + if isinstance(self.raw_item, dict): + namespace = self.raw_item.get("namespace") + else: + namespace = getattr(self.raw_item, "namespace", None) + self.tool_namespace = namespace if isinstance(namespace, str) else None + if self.tool_lookup_key is None: + if isinstance(self.raw_item, dict): + raw_type = self.raw_item.get("type") + else: + raw_type = getattr(self.raw_item, "type", None) + if ( + raw_type == "function_call" + and self.tool_name is not None + and (self.tool_namespace is None or self.tool_namespace != self.tool_name) + ): + self.tool_lookup_key = get_function_tool_lookup_key( + self.tool_name, + self.tool_namespace, + ) def __hash__(self) -> int: """Hash by object identity to keep distinct approvals separate.""" @@ -409,6 +549,13 @@ class ToolApprovalItem(RunItemBase[Any]): ) return str(candidate) if candidate is not None else None + @property + def qualified_name(self) -> str | None: + """Return a display-friendly tool name, collapsing synthetic deferred namespaces.""" + if self.tool_name is None: + return None + return tool_trace_name(self.tool_name, self.tool_namespace) or self.tool_name + @property def arguments(self) -> str | None: """Return tool call arguments if present on the raw item.""" @@ -453,6 +600,8 @@ class ToolApprovalItem(RunItemBase[Any]): RunItem: TypeAlias = Union[ MessageOutputItem, + ToolSearchCallItem, + ToolSearchOutputItem, HandoffCallItem, HandoffOutputItem, ToolCallItem, @@ -488,10 +637,10 @@ class ModelResponse: def to_input_items(self) -> list[TResponseInputItem]: """Convert the output into a list of input items suitable for passing to the model.""" - # We happen to know that the shape of the Pydantic output items are the same as the - # equivalent TypedDict input items, so we can just convert each one. - # This is also tested via unit tests. - return [it.model_dump(exclude_unset=True) for it in self.output] # type: ignore + # Most output items can be replayed via a direct model_dump. Tool-search items carry + # output-only metadata such as `created_by`, so they must go through the same replay + # sanitizer used elsewhere in the runtime. + return [_output_item_to_input_item(it) for it in self.output] class ItemHelpers: diff --git a/src/agents/models/chatcmpl_converter.py b/src/agents/models/chatcmpl_converter.py index 7444507a..f30c69b6 100644 --- a/src/agents/models/chatcmpl_converter.py +++ b/src/agents/models/chatcmpl_converter.py @@ -48,7 +48,12 @@ from ..exceptions import AgentsException, UserError from ..handoffs import Handoff from ..items import TResponseInputItem, TResponseOutputItem from ..model_settings import MCPToolChoice -from ..tool import FunctionTool, Tool +from ..tool import ( + FunctionTool, + Tool, + ensure_function_tool_supports_responses_only_features, + ensure_tool_choice_supports_backend, +) from .fake_id import FAKE_RESPONSES_ID ResponseInputContentWithAudioParam = Union[ResponseInputContentParam, ResponseInputAudioParam] @@ -70,6 +75,10 @@ class Converter: elif tool_choice == "none": return "none" else: + ensure_tool_choice_supports_backend( + tool_choice, + backend_name="OpenAI Responses models", + ) return { "type": "function", "function": { @@ -329,12 +338,17 @@ class Converter: raise UserError( f"Only image URLs are supported for input_image {casted_image_param}" ) + detail = casted_image_param.get("detail", "auto") + if detail == "original": + # Chat Completions only supports auto/low/high, so preserve the caller's + # highest-fidelity intent with the closest available value. + detail = "high" out.append( ChatCompletionContentPartImageParam( type="image_url", image_url={ "url": casted_image_param["image_url"], - "detail": casted_image_param.get("detail", "auto"), + "detail": detail, }, ) ) @@ -734,6 +748,10 @@ class Converter: @classmethod def tool_to_openai(cls, tool: Tool) -> ChatCompletionToolParam: if isinstance(tool, FunctionTool): + ensure_function_tool_supports_responses_only_features( + tool, + backend_name="Chat Completions-compatible models", + ) return { "type": "function", "function": { diff --git a/src/agents/models/openai_responses.py b/src/agents/models/openai_responses.py index d46eae2a..3f3006ca 100644 --- a/src/agents/models/openai_responses.py +++ b/src/agents/models/openai_responses.py @@ -5,27 +5,37 @@ import contextlib import inspect import json import weakref -from collections.abc import AsyncIterator, Awaitable, Callable, Mapping +from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence from contextvars import ContextVar from dataclasses import asdict, dataclass, is_dataclass from enum import Enum -from typing import TYPE_CHECKING, Any, Literal, cast, overload +from typing import TYPE_CHECKING, Any, Literal, TypedDict, TypeGuard, cast, get_args, overload import httpx from openai import AsyncOpenAI, NotGiven, Omit, omit from openai.types import ChatModel from openai.types.responses import ( + ApplyPatchToolParam, + ComputerToolParam, + FileSearchToolParam, + FunctionToolParam, Response, ResponseCompletedEvent, ResponseIncludable, ResponseStreamEvent, ResponseTextConfigParam, - ToolParam, + ToolParam as ResponsesToolParam, + ToolSearchToolParam, response_create_params, ) from openai.types.responses.response_prompt_param import ResponsePromptParam +from openai.types.responses.tool_param import LocalShell from .. import _debug +from .._tool_identity import ( + get_explicit_function_tool_namespace, + get_function_tool_namespace_description, +) from ..agent_output import AgentOutputSchemaBase from ..computer import AsyncComputer, Computer from ..exceptions import UserError @@ -45,7 +55,10 @@ from ..tool import ( ShellTool, ShellToolEnvironment, Tool, + ToolSearchTool, WebSearchTool, + has_required_tool_search_surface, + validate_responses_tool_search_configuration, ) from ..tracing import SpanError, response_span from ..usage import Usage @@ -65,6 +78,16 @@ _HEADERS = {"User-Agent": _USER_AGENT} _HEADERS_OVERRIDE: ContextVar[dict[str, str] | None] = ContextVar( "openai_responses_headers_override", default=None ) +_RESPONSE_INCLUDABLE_VALUES = frozenset( + value for value in get_args(ResponseIncludable) if isinstance(value, str) +) + + +class _NamespaceToolParam(TypedDict): + type: Literal["namespace"] + name: str + description: str + tools: list[FunctionToolParam] def _json_dumps_default(value: Any) -> Any: @@ -88,6 +111,45 @@ def _is_openai_omitted_value(value: Any) -> bool: return isinstance(value, (Omit, NotGiven)) +def _require_responses_tool_param(value: object) -> ResponsesToolParam: + if not isinstance(value, Mapping): + raise TypeError(f"Invalid Responses tool param payload: {value!r}") + + tool_type = value.get("type") + if not isinstance(tool_type, str): + raise TypeError(f"Invalid Responses tool param payload: {value!r}") + + return cast(ResponsesToolParam, value) + + +def _is_response_includable(value: object) -> TypeGuard[ResponseIncludable]: + return isinstance(value, str) and value in _RESPONSE_INCLUDABLE_VALUES + + +def _coerce_response_includables(values: Sequence[str]) -> list[ResponseIncludable]: + includables: list[ResponseIncludable] = [] + for value in values: + if not isinstance(value, str): + raise UserError(f"Unsupported Responses include value: {value}") + # ModelSettings.response_include deliberately accepts arbitrary strings so callers can + # pass through new server-supported flags before the local SDK updates its enum union. + includables.append(cast(ResponseIncludable, value)) + return includables + + +def _materialize_responses_tool_params( + tools: Sequence[ResponsesToolParam], +) -> list[ResponsesToolParam]: + materialized = _to_dump_compatible(list(tools)) + if not isinstance(materialized, list): + raise TypeError("Materialized Responses tools payload must be a list.") + + typed_tools: list[ResponsesToolParam] = [] + for tool in materialized: + typed_tools.append(_require_responses_tool_param(tool)) + return typed_tools + + async def _refresh_openai_client_api_key_if_supported(client: Any) -> None: """Refresh client auth if the current OpenAI SDK exposes a refresh hook.""" refresh_api_key = getattr(client, "_refresh_api_key", None) @@ -566,9 +628,20 @@ class OpenAIResponsesModel(Model): else: parallel_tool_calls = omit - tool_choice = Converter.convert_tool_choice(model_settings.tool_choice) - converted_tools = Converter.convert_tools(tools, handoffs) - converted_tools_payload = _to_dump_compatible(converted_tools.tools) + tool_choice = Converter.convert_tool_choice( + model_settings.tool_choice, + tools=tools, + handoffs=handoffs, + ) + if prompt is None: + converted_tools = Converter.convert_tools(tools, handoffs) + else: + converted_tools = Converter.convert_tools( + tools, + handoffs, + allow_opaque_tool_search_surface=True, + ) + converted_tools_payload = _materialize_responses_tool_params(converted_tools.tools) response_format = Converter.get_response_format(output_schema) should_omit_model = prompt is not None and not self._model_is_explicit model_param: str | ChatModel | Omit = self.model if not should_omit_model else omit @@ -576,19 +649,19 @@ class OpenAIResponsesModel(Model): # In prompt-managed tool flows without local tools payload, omit only named tool choices # that must match an explicit tool list. Keep control literals like "none"/"required". should_omit_tool_choice = should_omit_tools and isinstance(tool_choice, dict) - tools_param: list[ToolParam] | Omit = ( + tools_param: list[ResponsesToolParam] | Omit = ( converted_tools_payload if not should_omit_tools else omit ) tool_choice_param: response_create_params.ToolChoice | Omit = ( tool_choice if not should_omit_tool_choice else omit ) - include_set: set[str] = set(converted_tools.includes) + include_set: set[ResponseIncludable] = set(converted_tools.includes) if model_settings.response_include is not None: - include_set.update(model_settings.response_include) + include_set.update(_coerce_response_includables(model_settings.response_include)) if model_settings.top_logprobs is not None: include_set.add("message.output_text.logprobs") - include = cast(list[ResponseIncludable], list(include_set)) + include: list[ResponseIncludable] = list(include_set) if _debug.DONT_LOG_MODEL_DATA: logger.debug("Calling LLM") @@ -1292,7 +1365,7 @@ class OpenAIResponsesWSModel(OpenAIResponsesModel): @dataclass class ConvertedTools: - tools: list[ToolParam] + tools: list[ResponsesToolParam] includes: list[ResponseIncludable] @@ -1312,7 +1385,11 @@ class Converter: @classmethod def convert_tool_choice( - cls, tool_choice: Literal["auto", "required", "none"] | str | MCPToolChoice | None + cls, + tool_choice: Literal["auto", "required", "none"] | str | MCPToolChoice | None, + *, + tools: Sequence[Tool] | None = None, + handoffs: Sequence[Handoff[Any, Any]] | None = None, ) -> response_create_params.ToolChoice | Omit: if tool_choice is None: return omit @@ -1323,6 +1400,7 @@ class Converter: "name": tool_choice.name, } elif tool_choice == "required": + cls._validate_required_tool_choice(tools=tools) return "required" elif tool_choice == "auto": return "auto" @@ -1358,11 +1436,113 @@ class Converter: # but migrating to MCPToolChoice is recommended. return {"type": "mcp"} # type: ignore[misc, return-value] else: + cls._validate_named_function_tool_choice( + tool_choice, + tools=tools, + handoffs=handoffs, + ) return { "type": "function", "name": tool_choice, } + @classmethod + def _validate_required_tool_choice( + cls, + *, + tools: Sequence[Tool] | None, + ) -> None: + """Reject required tool choice only when deferred tools cannot surface any tool call.""" + if not tools: + return + + if any(isinstance(tool, ToolSearchTool) for tool in tools): + return + + if has_required_tool_search_surface(list(tools)): + raise UserError( + "tool_choice='required' is not currently supported when deferred-loading " + "Responses tools are configured without ToolSearchTool() on the OpenAI " + "Responses API. Add ToolSearchTool() or use `auto`." + ) + + @classmethod + def _validate_named_function_tool_choice( + cls, + tool_choice: str, + *, + tools: Sequence[Tool] | None, + handoffs: Sequence[Handoff[Any, Any]] | None = None, + ) -> None: + """Reject named tool choices that would point at unsupported namespace surfaces.""" + if not tools and not handoffs: + return + + top_level_function_names: set[str] = set() + all_local_function_names: set[str] = set() + deferred_only_function_names: set[str] = set() + namespaced_function_names: set[str] = set() + namespace_names: set[str] = set() + has_hosted_tool_search = any(isinstance(tool, ToolSearchTool) for tool in tools or ()) + + for handoff in handoffs or (): + top_level_function_names.add(handoff.tool_name) + all_local_function_names.add(handoff.tool_name) + + for tool in tools or (): + if not isinstance(tool, FunctionTool): + continue + + all_local_function_names.add(tool.name) + explicit_namespace = get_explicit_function_tool_namespace(tool) + if explicit_namespace is None: + if tool.defer_loading: + deferred_only_function_names.add(tool.name) + else: + top_level_function_names.add(tool.name) + continue + + namespaced_function_names.add(tool.name) + namespace_names.add(explicit_namespace) + + if ( + tool_choice == "tool_search" + and has_hosted_tool_search + and tool_choice not in all_local_function_names + ): + raise UserError( + "tool_choice='tool_search' is not supported for ToolSearchTool() on the " + "OpenAI Responses API. Use `auto` or `required`, or target a real " + "top-level function tool named `tool_search`." + ) + if ( + tool_choice == "tool_search" + and not has_hosted_tool_search + and tool_choice not in all_local_function_names + ): + raise UserError( + "tool_choice='tool_search' requires ToolSearchTool() or a real top-level " + "function tool named `tool_search` on the OpenAI Responses API." + ) + if ( + tool_choice in namespaced_function_names and tool_choice not in top_level_function_names + ) or (tool_choice in namespace_names and tool_choice not in top_level_function_names): + raise UserError( + "Named tool_choice must target a callable tool, not a namespace wrapper or " + "bare inner name from tool_namespace(), on the OpenAI Responses API. Use " + "`auto`, `required`, `none`, or target a top-level or qualified namespaced " + "function tool." + ) + if ( + tool_choice in deferred_only_function_names + and tool_choice not in top_level_function_names + ): + raise UserError( + "Named tool_choice is not currently supported for deferred-loading function " + "tools on the OpenAI Responses API. Use `auto`, `required`, `none`, or load " + "the tool via ToolSearchTool() first." + ) + @classmethod def get_response_format( cls, output_schema: AgentOutputSchemaBase | None @@ -1384,60 +1564,129 @@ class Converter: cls, tools: list[Tool], handoffs: list[Handoff[Any, Any]], + *, + allow_opaque_tool_search_surface: bool = False, ) -> ConvertedTools: - converted_tools: list[ToolParam] = [] + converted_tools: list[ResponsesToolParam | None] = [] includes: list[ResponseIncludable] = [] + namespace_index_by_name: dict[str, int] = {} + namespace_tools_by_name: dict[str, list[FunctionToolParam]] = {} + namespace_descriptions: dict[str, str] = {} + validate_responses_tool_search_configuration( + tools, + allow_opaque_search_surface=allow_opaque_tool_search_surface, + ) computer_tools = [tool for tool in tools if isinstance(tool, ComputerTool)] if len(computer_tools) > 1: raise UserError(f"You can only provide one computer tool. Got {len(computer_tools)}") for tool in tools: - converted_tool, include = cls._convert_tool(tool) - converted_tools.append(converted_tool) + namespace_name = ( + get_explicit_function_tool_namespace(tool) + if isinstance(tool, FunctionTool) + else None + ) + if isinstance(tool, FunctionTool) and namespace_name: + if namespace_name not in namespace_index_by_name: + namespace_index_by_name[namespace_name] = len(converted_tools) + converted_tools.append(None) + namespace_tools_by_name[namespace_name] = [] + namespace_descriptions[namespace_name] = ( + get_function_tool_namespace_description(tool) or "" + ) + else: + expected_description = namespace_descriptions.get(namespace_name) + actual_description = get_function_tool_namespace_description(tool) or "" + if expected_description != actual_description: + raise UserError( + f"All tools in namespace '{namespace_name}' must share the same " + "description." + ) + + converted_tool, include = cls._convert_function_tool( + tool, + include_defer_loading=True, + ) + namespace_tools_by_name[namespace_name].append(converted_tool) + if include: + includes.append(include) + continue + + converted_non_namespace_tool, include = cls._convert_tool(tool) + converted_tools.append(converted_non_namespace_tool) if include: includes.append(include) + for namespace_name, index in namespace_index_by_name.items(): + namespace_payload: _NamespaceToolParam = { + "type": "namespace", + "name": namespace_name, + "description": namespace_descriptions[namespace_name], + "tools": namespace_tools_by_name[namespace_name], + } + converted_tools[index] = _require_responses_tool_param(namespace_payload) + for handoff in handoffs: converted_tools.append(cls._convert_handoff_tool(handoff)) - return ConvertedTools(tools=converted_tools, includes=includes) + return ConvertedTools( + tools=[tool for tool in converted_tools if tool is not None], + includes=includes, + ) @classmethod - def _convert_tool(cls, tool: Tool) -> tuple[ToolParam, ResponseIncludable | None]: + def _convert_function_tool( + cls, + tool: FunctionTool, + *, + include_defer_loading: bool = True, + ) -> tuple[FunctionToolParam, ResponseIncludable | None]: + function_tool_param: FunctionToolParam = { + "name": tool.name, + "parameters": tool.params_json_schema, + "strict": tool.strict_json_schema, + "type": "function", + "description": tool.description, + } + if include_defer_loading and tool.defer_loading: + function_tool_param["defer_loading"] = True + return function_tool_param, None + + @classmethod + def _convert_tool(cls, tool: Tool) -> tuple[ResponsesToolParam, ResponseIncludable | None]: """Returns converted tool and includes""" if isinstance(tool, FunctionTool): - converted_tool: ToolParam = { - "name": tool.name, - "parameters": tool.params_json_schema, - "strict": tool.strict_json_schema, - "type": "function", - "description": tool.description, - } - includes: ResponseIncludable | None = None + return cls._convert_function_tool(tool) elif isinstance(tool, WebSearchTool): - # TODO: revisit the type: ignore comment when ToolParam is updated in the future - converted_tool = { - "type": "web_search", - "filters": tool.filters.model_dump() if tool.filters is not None else None, # type: ignore [typeddict-item] - "user_location": tool.user_location, - "search_context_size": tool.search_context_size, - } - includes = None + return ( + _require_responses_tool_param( + { + "type": "web_search", + "filters": tool.filters.model_dump() if tool.filters is not None else None, + "user_location": tool.user_location, + "search_context_size": tool.search_context_size, + } + ), + None, + ) elif isinstance(tool, FileSearchTool): - converted_tool = { + file_search_tool_param: FileSearchToolParam = { "type": "file_search", "vector_store_ids": tool.vector_store_ids, } if tool.max_num_results: - converted_tool["max_num_results"] = tool.max_num_results + file_search_tool_param["max_num_results"] = tool.max_num_results if tool.ranking_options: - converted_tool["ranking_options"] = tool.ranking_options + file_search_tool_param["ranking_options"] = tool.ranking_options if tool.filters: - converted_tool["filters"] = tool.filters + file_search_tool_param["filters"] = tool.filters - includes = "file_search_call.results" if tool.include_search_results else None + include: ResponseIncludable | None = ( + "file_search_call.results" if tool.include_search_results else None + ) + return file_search_tool_param, include elif isinstance(tool, ComputerTool): computer = tool.computer if not isinstance(computer, (Computer, AsyncComputer)): @@ -1446,50 +1695,53 @@ class Converter: "resolve_computer({ tool, run_context }) with a run context first " "when building payloads manually." ) - converted_tool = { - "type": "computer_use_preview", - "environment": computer.environment, - "display_width": computer.dimensions[0], - "display_height": computer.dimensions[1], - } - includes = None - elif isinstance(tool, HostedMCPTool): - converted_tool = tool.tool_config - includes = None - elif isinstance(tool, ApplyPatchTool): - converted_tool = cast(ToolParam, {"type": "apply_patch"}) - includes = None - elif isinstance(tool, ShellTool): - converted_tool = cast( - ToolParam, - { - "type": "shell", - "environment": cls._convert_shell_environment(tool.environment), - }, + return ( + ComputerToolParam( + type="computer_use_preview", + environment=computer.environment, + display_width=computer.dimensions[0], + display_height=computer.dimensions[1], + ), + None, + ) + elif isinstance(tool, HostedMCPTool): + return tool.tool_config, None + elif isinstance(tool, ApplyPatchTool): + return ApplyPatchToolParam(type="apply_patch"), None + elif isinstance(tool, ShellTool): + return ( + _require_responses_tool_param( + { + "type": "shell", + "environment": cls._convert_shell_environment(tool.environment), + } + ), + None, ) - includes = None elif isinstance(tool, ImageGenerationTool): - converted_tool = tool.tool_config - includes = None + return tool.tool_config, None elif isinstance(tool, CodeInterpreterTool): - converted_tool = tool.tool_config - includes = None + return tool.tool_config, None elif isinstance(tool, LocalShellTool): - converted_tool = { - "type": "local_shell", - } - includes = None + return LocalShell(type="local_shell"), None + elif isinstance(tool, ToolSearchTool): + tool_search_tool_param = ToolSearchToolParam(type="tool_search") + if isinstance(tool.description, str): + tool_search_tool_param["description"] = tool.description + if tool.execution is not None: + tool_search_tool_param["execution"] = tool.execution + if tool.parameters is not None: + tool_search_tool_param["parameters"] = tool.parameters + return tool_search_tool_param, None else: raise UserError(f"Unknown tool type: {type(tool)}, tool") - return converted_tool, includes - @classmethod - def _convert_handoff_tool(cls, handoff: Handoff) -> ToolParam: - return { - "name": handoff.tool_name, - "parameters": handoff.input_json_schema, - "strict": handoff.strict_json_schema, - "type": "function", - "description": handoff.tool_description, - } + def _convert_handoff_tool(cls, handoff: Handoff) -> ResponsesToolParam: + return FunctionToolParam( + name=handoff.tool_name, + parameters=handoff.input_json_schema, + strict=handoff.strict_json_schema, + type="function", + description=handoff.tool_description, + ) diff --git a/src/agents/realtime/openai_realtime.py b/src/agents/realtime/openai_realtime.py index 63bfa71b..fdf6ac58 100644 --- a/src/agents/realtime/openai_realtime.py +++ b/src/agents/realtime/openai_realtime.py @@ -87,7 +87,12 @@ from agents.handoffs import Handoff from agents.prompts import Prompt from agents.realtime._default_tracker import ModelAudioTracker from agents.realtime.audio_formats import to_realtime_audio_format -from agents.tool import FunctionTool, Tool +from agents.tool import ( + FunctionTool, + Tool, + ensure_function_tool_supports_responses_only_features, + ensure_tool_choice_supports_backend, +) from agents.util._types import MaybeAwaitable from ..exceptions import UserError @@ -1133,7 +1138,12 @@ class OpenAIRealtimeWebSocketModel(RealtimeModel): ) if "tool_choice" in model_settings: - session_create_request.tool_choice = cast(Any, model_settings.get("tool_choice")) + tool_choice = model_settings.get("tool_choice") + ensure_tool_choice_supports_backend( + tool_choice, + backend_name="OpenAI Responses models", + ) + session_create_request.tool_choice = cast(Any, tool_choice) return session_create_request @@ -1144,6 +1154,10 @@ class OpenAIRealtimeWebSocketModel(RealtimeModel): for tool in tools: if not isinstance(tool, FunctionTool): raise UserError(f"Tool {tool.name} is unsupported. Must be a function tool.") + ensure_function_tool_supports_responses_only_features( + tool, + backend_name="Realtime models", + ) converted_tools.append( OpenAISessionFunction( name=tool.name, diff --git a/src/agents/run.py b/src/agents/run.py index 5e35c898..7610a6cf 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -666,9 +666,9 @@ class AgentRunner: ) if run_state._last_processed_response is not None: - tool_use_tracker.add_tool_use( + tool_use_tracker.record_processed_response( current_agent, - run_state._last_processed_response.tools_used, + run_state._last_processed_response, ) original_input = turn_result.original_input diff --git a/src/agents/run_context.py b/src/agents/run_context.py index 4473b50c..d7cd6cd5 100644 --- a/src/agents/run_context.py +++ b/src/agents/run_context.py @@ -5,6 +5,13 @@ from typing import TYPE_CHECKING, Any, Generic from typing_extensions import TypeVar +from ._tool_identity import ( + FunctionToolLookupKey, + get_function_tool_approval_keys, + get_function_tool_lookup_key, + is_reserved_synthetic_tool_namespace, + tool_qualified_name, +) from .usage import Usage if TYPE_CHECKING: @@ -75,6 +82,64 @@ class RunContextWrapper(Generic[TContext]): candidate = getattr(raw, "name", None) or getattr(raw, "type", None) return RunContextWrapper._to_str_or_none(candidate) or "unknown_tool" + @staticmethod + def _resolve_tool_namespace(approval_item: ToolApprovalItem) -> str | None: + raw = approval_item.raw_item + if isinstance(approval_item.tool_namespace, str) and approval_item.tool_namespace: + return approval_item.tool_namespace + if isinstance(raw, dict): + candidate = raw.get("namespace") + else: + candidate = getattr(raw, "namespace", None) + return RunContextWrapper._to_str_or_none(candidate) + + @staticmethod + def _resolve_approval_key(approval_item: ToolApprovalItem) -> str: + tool_name = RunContextWrapper._resolve_tool_name(approval_item) + tool_namespace = RunContextWrapper._resolve_tool_namespace(approval_item) + lookup_key = RunContextWrapper._resolve_tool_lookup_key(approval_item) + approval_keys = get_function_tool_approval_keys( + tool_name=tool_name, + tool_namespace=tool_namespace, + tool_lookup_key=lookup_key, + prefer_legacy_same_name_namespace=lookup_key is None, + ) + if approval_keys: + return approval_keys[-1] + return tool_qualified_name(tool_name, tool_namespace) or tool_name or "unknown_tool" + + @staticmethod + def _resolve_approval_keys(approval_item: ToolApprovalItem) -> tuple[str, ...]: + """Return all approval keys that should mirror this approval record.""" + lookup_key = RunContextWrapper._resolve_tool_lookup_key(approval_item) + return get_function_tool_approval_keys( + tool_name=RunContextWrapper._resolve_tool_name(approval_item), + tool_namespace=RunContextWrapper._resolve_tool_namespace(approval_item), + allow_bare_name_alias=getattr(approval_item, "_allow_bare_name_alias", False), + tool_lookup_key=lookup_key, + prefer_legacy_same_name_namespace=lookup_key is None, + ) + + @staticmethod + def _resolve_tool_lookup_key(approval_item: ToolApprovalItem) -> FunctionToolLookupKey | None: + candidate = getattr(approval_item, "tool_lookup_key", None) + if isinstance(candidate, tuple): + return candidate + + raw = approval_item.raw_item + if isinstance(raw, dict): + raw_type = raw.get("type") + else: + raw_type = getattr(raw, "type", None) + if raw_type != "function_call": + return None + + tool_name = RunContextWrapper._resolve_tool_name(approval_item) + tool_namespace = RunContextWrapper._resolve_tool_namespace(approval_item) + if is_reserved_synthetic_tool_namespace(tool_name, tool_namespace): + return None + return get_function_tool_lookup_key(tool_name, tool_namespace) + @staticmethod def _resolve_call_id(approval_item: ToolApprovalItem) -> str | None: raw = approval_item.raw_item @@ -109,7 +174,11 @@ class RunContextWrapper(Generic[TContext]): def is_tool_approved(self, tool_name: str, call_id: str) -> bool | None: """Return True/False/None for the given tool call.""" - approval_entry = self._approvals.get(tool_name) + return self._get_approval_status_for_key(tool_name, call_id) + + def _get_approval_status_for_key(self, approval_key: str, call_id: str) -> bool | None: + """Return True/False/None for a concrete approval key and tool call.""" + approval_entry = self._approvals.get(approval_key) if not approval_entry: return None @@ -142,24 +211,27 @@ class RunContextWrapper(Generic[TContext]): self, approval_item: ToolApprovalItem, *, always: bool, approve: bool ) -> None: """Record an approval or rejection decision.""" - tool_name = self._resolve_tool_name(approval_item) + approval_keys = self._resolve_approval_keys(approval_item) or ("unknown_tool",) + exact_approval_key = self._resolve_approval_key(approval_item) call_id = self._resolve_call_id(approval_item) + decision_keys = (exact_approval_key,) if always or call_id is None else approval_keys - approval_entry = self._get_or_create_approval_entry(tool_name) - if always or call_id is None: - approval_entry.approved = approve - approval_entry.rejected = [] if approve else True - if not approve: - approval_entry.approved = False - return + for approval_key in decision_keys: + approval_entry = self._get_or_create_approval_entry(approval_key) + if always or call_id is None: + approval_entry.approved = approve + approval_entry.rejected = [] if approve else True + if not approve: + approval_entry.approved = False + continue - opposite = approval_entry.rejected if approve else approval_entry.approved - if isinstance(opposite, list) and call_id in opposite: - opposite.remove(call_id) + opposite = approval_entry.rejected if approve else approval_entry.approved + if isinstance(opposite, list) and call_id in opposite: + opposite.remove(call_id) - target = approval_entry.approved if approve else approval_entry.rejected - if isinstance(target, list) and call_id not in target: - target.append(call_id) + target = approval_entry.approved if approve else approval_entry.rejected + if isinstance(target, list) and call_id not in target: + target.append(call_id) def approve_tool(self, approval_item: ToolApprovalItem, always_approve: bool = False) -> None: """Approve a tool call, optionally for all future calls.""" @@ -178,13 +250,73 @@ class RunContextWrapper(Generic[TContext]): ) def get_approval_status( - self, tool_name: str, call_id: str, *, existing_pending: ToolApprovalItem | None = None + self, + tool_name: str, + call_id: str, + *, + tool_namespace: str | None = None, + existing_pending: ToolApprovalItem | None = None, + tool_lookup_key: FunctionToolLookupKey | None = None, ) -> bool | None: """Return approval status, retrying with pending item's tool name if necessary.""" - status = self.is_tool_approved(tool_name, call_id) - if status is None and existing_pending: - fallback_tool_name = self._resolve_tool_name(existing_pending) - status = self.is_tool_approved(fallback_tool_name, call_id) + candidates: list[str] = [] + explicit_namespace = ( + tool_namespace if isinstance(tool_namespace, str) and tool_namespace else None + ) + pending_namespace = ( + self._resolve_tool_namespace(existing_pending) if existing_pending is not None else None + ) + pending_key = self._resolve_approval_key(existing_pending) if existing_pending else None + pending_tool_name = self._resolve_tool_name(existing_pending) if existing_pending else None + pending_keys = ( + list(self._resolve_approval_keys(existing_pending)) + if existing_pending is not None + else [] + ) + + if existing_pending and pending_key is not None: + candidates.append(pending_key) + explicit_keys = ( + list( + get_function_tool_approval_keys( + tool_name=tool_name, + tool_namespace=explicit_namespace, + tool_lookup_key=tool_lookup_key, + include_legacy_deferred_key=True, + ) + ) + if explicit_namespace is not None or tool_lookup_key is not None + else [] + ) + for explicit_key in explicit_keys: + if explicit_key not in candidates: + candidates.append(explicit_key) + if not explicit_keys and pending_namespace and pending_key is not None: + if pending_key not in candidates: + candidates.append(pending_key) + if ( + explicit_namespace is None + and tool_lookup_key is None + and existing_pending is None + and tool_name not in candidates + ): + candidates.append(tool_name) + if existing_pending: + for pending_candidate in pending_keys: + if pending_candidate not in candidates: + candidates.append(pending_candidate) + if ( + pending_namespace is None + and pending_tool_name is not None + and pending_tool_name not in candidates + ): + candidates.append(pending_tool_name) + + status: bool | None = None + for candidate in candidates: + status = self._get_approval_status_for_key(candidate, call_id) + if status is not None: + break return status def _rebuild_approvals(self, approvals: dict[str, dict[str, Any]]) -> None: diff --git a/src/agents/run_internal/approvals.py b/src/agents/run_internal/approvals.py index edea4454..2c6bd6c9 100644 --- a/src/agents/run_internal/approvals.py +++ b/src/agents/run_internal/approvals.py @@ -77,10 +77,23 @@ def _build_function_tool_call_for_approval_error( """Coerce raw tool call payloads into a normalized function_call for approval errors.""" if isinstance(tool_call, ResponseFunctionToolCall): return tool_call - return ResponseFunctionToolCall( - type="function_call", - name=tool_name, - call_id=call_id or "unknown", - status="completed", - arguments="{}", - ) + namespace = None + if isinstance(tool_call, dict): + candidate = tool_call.get("namespace") + if isinstance(candidate, str) and candidate: + namespace = candidate + else: + candidate = getattr(tool_call, "namespace", None) + if isinstance(candidate, str) and candidate: + namespace = candidate + + kwargs: dict[str, Any] = { + "type": "function_call", + "name": tool_name, + "call_id": call_id or "unknown", + "status": "completed", + "arguments": "{}", + } + if namespace is not None: + kwargs["namespace"] = namespace + return ResponseFunctionToolCall(**kwargs) diff --git a/src/agents/run_internal/items.py b/src/agents/run_internal/items.py index ed21e801..3fd5e804 100644 --- a/src/agents/run_internal/items.py +++ b/src/agents/run_internal/items.py @@ -24,6 +24,7 @@ _TOOL_CALL_TO_OUTPUT_TYPE: dict[str, str] = { "apply_patch_call": "apply_patch_call_output", "computer_call": "computer_call_output", "local_shell_call": "local_shell_call_output", + "tool_search_call": "tool_search_output", } __all__ = [ @@ -92,9 +93,10 @@ def drop_orphan_function_calls(items: list[TResponseInputItem]) -> list[TRespons """ completed_call_ids = _completed_call_ids_by_type(items) + matched_anonymous_tool_search_calls = _matched_anonymous_tool_search_call_indexes(items) filtered: list[TResponseInputItem] = [] - for entry in items: + for index, entry in enumerate(items): if not isinstance(entry, dict): filtered.append(entry) continue @@ -109,6 +111,13 @@ def drop_orphan_function_calls(items: list[TResponseInputItem]) -> list[TRespons 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 + if ( + entry_type == "tool_search_call" + and not isinstance(call_id, str) + and index in matched_anonymous_tool_search_calls + ): + filtered.append(entry) return filtered @@ -365,6 +374,32 @@ def _completed_call_ids_by_type(payload: list[TResponseInputItem]) -> dict[str, return completed +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() + pending_anonymous_outputs = 0 + + for index in range(len(payload) - 1, -1, -1): + entry = payload[index] + if not isinstance(entry, dict): + continue + + item_type = entry.get("type") + if item_type == "tool_search_output" and not isinstance(entry.get("call_id"), str): + pending_anonymous_outputs += 1 + continue + + if ( + item_type == "tool_search_call" + and not isinstance(entry.get("call_id"), str) + and pending_anonymous_outputs > 0 + ): + matched_indexes.add(index) + pending_anonymous_outputs -= 1 + + return matched_indexes + + def _coerce_to_dict(value: object) -> dict[str, Any] | None: """Convert model items to dicts so fields can be renamed and sanitized.""" if isinstance(value, dict): diff --git a/src/agents/run_internal/oai_conversation.py b/src/agents/run_internal/oai_conversation.py index 66de694a..56658170 100644 --- a/src/agents/run_internal/oai_conversation.py +++ b/src/agents/run_internal/oai_conversation.py @@ -9,7 +9,13 @@ from collections.abc import Sequence from dataclasses import dataclass, field from typing import Any, cast -from ..items import ItemHelpers, ModelResponse, RunItem, TResponseInputItem +from ..items import ( + ItemHelpers, + ModelResponse, + RunItem, + TResponseInputItem, + _output_item_to_input_item, +) from ..logger import logger from ..models.fake_id import FAKE_RESPONSES_ID from .items import ( @@ -35,9 +41,48 @@ def _normalize_server_item_id(value: Any) -> str | None: def _fingerprint_for_tracker(item: Any) -> str | None: """Return a stable fingerprint for dedupe, ignoring failures.""" + if _is_tool_search_item(item): + try: + replayable_item = _output_item_to_input_item(item) + item_id = _normalize_server_item_id( + replayable_item.get("id") + if isinstance(replayable_item, dict) + else getattr(replayable_item, "id", None) + ) + call_id = ( + replayable_item.get("call_id") + if isinstance(replayable_item, dict) + else getattr(replayable_item, "call_id", None) + ) + return fingerprint_input_item( + replayable_item, + ignore_ids_for_matching=item_id is None and not isinstance(call_id, str), + ) + except Exception: + return None return fingerprint_input_item(item) +def _anonymous_tool_search_fingerprint(item: Any) -> str | None: + """Return a content-only fingerprint for restored anonymous tool_search items.""" + if not _is_tool_search_item(item): + return None + + try: + return fingerprint_input_item( + _output_item_to_input_item(item), + ignore_ids_for_matching=True, + ) + except Exception: + return None + + +def _is_tool_search_item(item: Any) -> bool: + """Return True for tool_search items that currently lack stable provider identifiers.""" + item_type = item.get("type") if isinstance(item, dict) else getattr(item, "type", None) + return item_type in {"tool_search_call", "tool_search_output"} + + @dataclass class OpenAIServerConversationTracker: """Track server-side conversation state for conversation-aware runs. @@ -63,9 +108,11 @@ class OpenAIServerConversationTracker: # Stable provider identifiers returned by the Responses API. server_item_ids: set[str] = field(default_factory=set) server_tool_call_ids: set[str] = field(default_factory=set) + server_output_fingerprints: set[str] = field(default_factory=set) # Content-based dedupe for resume/retry paths where objects are reconstructed. sent_item_fingerprints: set[str] = field(default_factory=set) + restored_anonymous_tool_search_fingerprints: set[str] = field(default_factory=set) sent_initial_input: bool = False remaining_initial_input: list[TResponseInputItem] | None = None primed_from_state: bool = False @@ -121,6 +168,9 @@ class OpenAIServerConversationTracker: fp = _fingerprint_for_tracker(item) if fp: self.sent_item_fingerprints.add(fp) + anonymous_tool_search_fp = _anonymous_tool_search_fingerprint(item) + if anonymous_tool_search_fp: + self.restored_anonymous_tool_search_fingerprints.add(anonymous_tool_search_fp) self.sent_initial_input = True self.remaining_initial_input = None @@ -170,12 +220,19 @@ class OpenAIServerConversationTracker: fp = _fingerprint_for_tracker(item) if fp: self.sent_item_fingerprints.add(fp) + anonymous_tool_search_fp = _anonymous_tool_search_fingerprint(item) + if anonymous_tool_search_fp: + self.restored_anonymous_tool_search_fingerprints.add(anonymous_tool_search_fp) for item in generated_items: # type: ignore[assignment] run_item: RunItem = cast(RunItem, item) raw_item = run_item.raw_item if raw_item is None: continue is_tool_call_item = run_item.type in {"tool_call_item", "handoff_call_item"} + is_tool_search_item = run_item.type in { + "tool_search_call_item", + "tool_search_output_item", + } if isinstance(raw_item, dict): item_id = _normalize_server_item_id(raw_item.get("id")) @@ -183,8 +240,10 @@ class OpenAIServerConversationTracker: has_output_payload = "output" in raw_item has_output_payload = has_output_payload or hasattr(raw_item, "output") has_call_id = isinstance(call_id, str) - should_mark = item_id is not None or ( - has_call_id and (has_output_payload or is_tool_call_item) + should_mark = ( + item_id is not None + or (has_call_id and (has_output_payload or is_tool_call_item)) + or is_tool_search_item ) if not should_mark: continue @@ -194,6 +253,11 @@ class OpenAIServerConversationTracker: fp = _fingerprint_for_tracker(raw_item) if fp: self.sent_item_fingerprints.add(fp) + if is_tool_search_item: + self.server_output_fingerprints.add(fp) + anonymous_tool_search_fp = _anonymous_tool_search_fingerprint(raw_item) + if anonymous_tool_search_fp: + self.restored_anonymous_tool_search_fingerprints.add(anonymous_tool_search_fp) if item_id is not None: self.server_item_ids.add(item_id) @@ -204,8 +268,10 @@ class OpenAIServerConversationTracker: call_id = getattr(raw_item, "call_id", None) has_output_payload = hasattr(raw_item, "output") has_call_id = isinstance(call_id, str) - should_mark = item_id is not None or ( - has_call_id and (has_output_payload or is_tool_call_item) + should_mark = ( + item_id is not None + or (has_call_id and (has_output_payload or is_tool_call_item)) + or is_tool_search_item ) if not should_mark: continue @@ -214,6 +280,11 @@ class OpenAIServerConversationTracker: fp = _fingerprint_for_tracker(raw_item) if fp: self.sent_item_fingerprints.add(fp) + if is_tool_search_item: + self.server_output_fingerprints.add(fp) + anonymous_tool_search_fp = _anonymous_tool_search_fingerprint(raw_item) + if anonymous_tool_search_fp: + self.restored_anonymous_tool_search_fingerprints.add(anonymous_tool_search_fp) if item_id is not None: self.server_item_ids.add(item_id) if isinstance(call_id, str) and has_output_payload: @@ -250,6 +321,8 @@ class OpenAIServerConversationTracker: if fp: self.sent_item_fingerprints.add(fp) server_item_fingerprints.add(fp) + if _is_tool_search_item(output_item): + self.server_output_fingerprints.add(fp) if self.remaining_initial_input and server_item_fingerprints: remaining: list[TResponseInputItem] = [] @@ -387,8 +460,19 @@ class OpenAIServerConversationTracker: if converted_input_item is None: continue fp = _fingerprint_for_tracker(converted_input_item) + if fp and fp in self.server_output_fingerprints: + continue if fp and self.primed_from_state and fp in self.sent_item_fingerprints: continue + anonymous_tool_search_fp = _anonymous_tool_search_fingerprint(converted_input_item) + if ( + self.primed_from_state + and anonymous_tool_search_fp + and item_id is None + and not isinstance(call_id, str) + and anonymous_tool_search_fp in self.restored_anonymous_tool_search_fingerprints + ): + continue input_items.append(converted_input_item) self._register_prepared_item_source( diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index b32276bd..4cb7eaee 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -7,13 +7,19 @@ from __future__ import annotations import asyncio import dataclasses as _dc -from collections.abc import Awaitable, Callable +import json +from collections.abc import Awaitable, Callable, Mapping from typing import Any, TypeVar, cast from openai.types.responses import Response, ResponseCompletedEvent, ResponseOutputItemDoneEvent from openai.types.responses.response_prompt_param import ResponsePromptParam from openai.types.responses.response_reasoning_item import ResponseReasoningItem +from .._tool_identity import ( + NamedToolLookupKey, + build_function_tool_lookup_map, + get_function_tool_lookup_key_for_call, +) from ..agent import Agent from ..agent_output import AgentOutputSchemaBase from ..exceptions import ( @@ -34,7 +40,11 @@ from ..items import ( ToolApprovalItem, ToolCallItem, ToolCallItemTypes, + ToolSearchCallItem, + ToolSearchOutputItem, TResponseInputItem, + coerce_tool_search_call_raw_item, + coerce_tool_search_output_raw_item, ) from ..lifecycle import RunHooks from ..logger import logger @@ -49,7 +59,7 @@ from ..stream_events import ( RawResponsesStreamEvent, RunItemStreamEvent, ) -from ..tool import Tool, dispose_resolved_computers +from ..tool import FunctionTool, Tool, dispose_resolved_computers from ..tracing import Span, SpanError, agent_span, get_current_trace from ..tracing.model_tracing import get_model_tracing_impl from ..tracing.span_data import AgentSpanData @@ -586,8 +596,8 @@ async def start_streaming( run_state=run_state, ) - tool_use_tracker.add_tool_use( - current_agent, run_state._last_processed_response.tools_used + tool_use_tracker.record_processed_response( + current_agent, run_state._last_processed_response ) streamed_result._tool_use_tracker_snapshot = serialize_tool_use_tracker( tool_use_tracker @@ -749,6 +759,7 @@ async def start_streaming( streamed_result.new_items.append(synthesized_item) if run_state is not None: run_state._generated_items = list(streamed_result._model_input_items) + run_state._clear_generated_items_last_processed_marker() run_state._session_items = list(streamed_result.new_items) stream_step_items_to_queue([synthesized_item], streamed_result._event_queue) store_setting = current_agent.model_settings.resolve( @@ -940,6 +951,7 @@ async def start_streaming( run_state._model_responses = streamed_result.raw_responses run_state._last_processed_response = processed_response_for_state run_state._generated_items = streamed_result._model_input_items + run_state._mark_generated_items_merged_with_last_processed() run_state._session_items = list(streamed_result.new_items) run_state._current_step = turn_result.next_step run_state._current_turn = current_turn @@ -1059,10 +1071,34 @@ async def run_single_turn_streamed( """Run a single streamed turn and emit events as results arrive.""" emitted_tool_call_ids: set[str] = set() emitted_reasoning_item_ids: set[str] = set() - # Precompute tool name -> tool map once per turn. Dict "last wins" semantics match - # execution in process_model_response, so duplicate names (e.g., MCP + local tool) - # stream the same description that execution uses. - tool_map = {t.name: t for t in all_tools if hasattr(t, "name") and t.name} + emitted_tool_search_fingerprints: set[str] = set() + # Precompute the lookup map used for streaming descriptions. Function tools use the same + # collision-free lookup keys as runtime dispatch, including deferred top-level aliases. + tool_map: dict[NamedToolLookupKey, Any] = cast( + dict[NamedToolLookupKey, Any], + build_function_tool_lookup_map( + [tool for tool in all_tools if isinstance(tool, FunctionTool)] + ), + ) + for tool in all_tools: + tool_name = getattr(tool, "name", None) + if not isinstance(tool_name, str) or not tool_name: + continue + if isinstance(tool, FunctionTool): + continue + tool_map[tool_name] = tool + + def _tool_search_fingerprint(raw_item: Any) -> str: + if isinstance(raw_item, Mapping): + payload: Any = dict(raw_item) + elif hasattr(raw_item, "model_dump"): + payload = cast(Any, raw_item).model_dump(exclude_unset=True) + else: + payload = { + "type": getattr(raw_item, "type", None), + "id": getattr(raw_item, "id", None), + } + return json.dumps(payload, sort_keys=True, default=str) try: turn_input = ItemHelpers.input_to_new_input_list(streamed_result.input) @@ -1233,8 +1269,33 @@ async def run_single_turn_streamed( if isinstance(event, ResponseOutputItemDoneEvent): output_item = event.item + output_item_type = getattr(output_item, "type", None) - if isinstance(output_item, TOOL_CALL_TYPES): + if output_item_type == "tool_search_call": + emitted_tool_search_fingerprints.add(_tool_search_fingerprint(output_item)) + streamed_result._event_queue.put_nowait( + RunItemStreamEvent( + item=ToolSearchCallItem( + raw_item=coerce_tool_search_call_raw_item(output_item), + agent=agent, + ), + name="tool_search_called", + ) + ) + + elif output_item_type == "tool_search_output": + emitted_tool_search_fingerprints.add(_tool_search_fingerprint(output_item)) + streamed_result._event_queue.put_nowait( + RunItemStreamEvent( + item=ToolSearchOutputItem( + raw_item=coerce_tool_search_output_raw_item(output_item), + agent=agent, + ), + name="tool_search_output_created", + ) + ) + + elif isinstance(output_item, TOOL_CALL_TYPES): output_call_id: str | None = getattr( output_item, "call_id", getattr(output_item, "id", None) ) @@ -1248,10 +1309,13 @@ async def run_single_turn_streamed( # Look up tool description from precomputed map ("last wins" matches # execution behavior in process_model_response). - tool_name = getattr(output_item, "name", None) + tool_lookup_key = get_function_tool_lookup_key_for_call(output_item) + matched_tool = ( + tool_map.get(tool_lookup_key) if tool_lookup_key is not None else None + ) tool_description: str | None = None - if isinstance(tool_name, str) and tool_name in tool_map: - tool_description = getattr(tool_map[tool_name], "description", None) + if matched_tool is not None: + tool_description = getattr(matched_tool, "description", None) tool_item = ToolCallItem( raw_item=cast(ToolCallItemTypes, output_item), @@ -1330,6 +1394,16 @@ async def run_single_turn_streamed( ) ] + if emitted_tool_search_fingerprints: + items_to_filter = [ + item + for item in items_to_filter + if not ( + isinstance(item, (ToolSearchCallItem, ToolSearchOutputItem)) + and _tool_search_fingerprint(item.raw_item) in emitted_tool_search_fingerprints + ) + ] + items_to_filter = [item for item in items_to_filter if not isinstance(item, HandoffCallItem)] filtered_result = _dc.replace(single_step_result, new_step_items=items_to_filter) diff --git a/src/agents/run_internal/streaming.py b/src/agents/run_internal/streaming.py index cfd969cd..c91dc80e 100644 --- a/src/agents/run_internal/streaming.py +++ b/src/agents/run_internal/streaming.py @@ -14,6 +14,8 @@ from ..items import ( ToolApprovalItem, ToolCallItem, ToolCallOutputItem, + ToolSearchCallItem, + ToolSearchOutputItem, ) from ..logger import logger from ..stream_events import RunItemStreamEvent, StreamEvent @@ -36,6 +38,10 @@ def stream_step_items_to_queue( event = RunItemStreamEvent(item=item, name="handoff_occured") elif isinstance(item, ToolCallItem): event = RunItemStreamEvent(item=item, name="tool_called") + elif isinstance(item, ToolSearchCallItem): + event = RunItemStreamEvent(item=item, name="tool_search_called") + elif isinstance(item, ToolSearchOutputItem): + event = RunItemStreamEvent(item=item, name="tool_search_output_created") elif isinstance(item, ToolCallOutputItem): event = RunItemStreamEvent(item=item, name="tool_output") elif isinstance(item, ReasoningItem): diff --git a/src/agents/run_internal/tool_execution.py b/src/agents/run_internal/tool_execution.py index c8c5a4a5..d67037e8 100644 --- a/src/agents/run_internal/tool_execution.py +++ b/src/agents/run_internal/tool_execution.py @@ -20,6 +20,20 @@ from openai.types.responses.response_input_item_param import ( from openai.types.responses.response_input_param import McpApprovalResponse from openai.types.responses.response_output_item import McpApprovalRequest +from .._tool_identity import ( + FunctionToolLookupKey, + NamedToolLookupKey, + build_function_tool_lookup_map, + get_function_tool_lookup_key, + get_function_tool_lookup_key_for_call, + get_function_tool_trace_name, + get_tool_call_namespace, + get_tool_call_trace_name, + is_deferred_top_level_function_tool, + normalize_tool_call_for_function_tool, + should_allow_bare_name_approval_alias, + tool_trace_name, +) from ..agent import Agent from ..agent_tool_state import ( consume_agent_tool_run_result, @@ -122,6 +136,7 @@ __all__ = [ "resolve_approval_interruption", "resolve_approval_rejection_message", "function_needs_approval", + "resolve_enabled_function_tools", "execute_function_tool_calls", "execute_local_shell_calls", "execute_shell_calls", @@ -505,6 +520,29 @@ def maybe_reset_tool_choice( return model_settings +async def resolve_enabled_function_tools( + agent: Agent[Any], + context_wrapper: RunContextWrapper[Any], +) -> list[FunctionTool]: + """Resolve enabled function tools without triggering MCP tool discovery.""" + + async def _check_tool_enabled(tool: FunctionTool) -> bool: + attr = tool.is_enabled + if isinstance(attr, bool): + return attr + result = attr(context_wrapper, agent) + if inspect.isawaitable(result): + return bool(await result) + return bool(result) + + function_tools = [tool for tool in agent.tools if isinstance(tool, FunctionTool)] + if not function_tools: + return [] + + enabled_results = await asyncio.gather(*(_check_tool_enabled(tool) for tool in function_tools)) + return [tool for tool, enabled in zip(function_tools, enabled_results) if enabled] + + async def initialize_computer_tools( *, tools: list[Tool], @@ -945,14 +983,24 @@ async def resolve_approval_status( raw_item: Any, agent: Agent[Any], context_wrapper: RunContextWrapper[Any], + tool_namespace: str | None = None, + tool_lookup_key: FunctionToolLookupKey | None = None, on_approval: Callable[[RunContextWrapper[Any], ToolApprovalItem], Any] | None = None, ) -> tuple[bool | None, ToolApprovalItem]: """Build approval item, run on_approval hook if needed, and return latest approval status.""" - approval_item = ToolApprovalItem(agent=agent, raw_item=raw_item, tool_name=tool_name) + approval_item = ToolApprovalItem( + agent=agent, + raw_item=raw_item, + tool_name=tool_name, + tool_namespace=tool_namespace, + tool_lookup_key=tool_lookup_key, + ) approval_status = context_wrapper.get_approval_status( tool_name, call_id, + tool_namespace=tool_namespace, existing_pending=approval_item, + tool_lookup_key=tool_lookup_key, ) if approval_status is None and on_approval: decision_result = on_approval(context_wrapper, approval_item) @@ -966,7 +1014,9 @@ async def resolve_approval_status( approval_status = context_wrapper.get_approval_status( tool_name, call_id, + tool_namespace=tool_namespace, existing_pending=approval_item, + tool_lookup_key=tool_lookup_key, ) return approval_status, approval_item @@ -1218,12 +1268,20 @@ class _FunctionToolBatchExecutor: self.results_by_tool_run: dict[int, Any] = {} self.pending_tasks: set[asyncio.Task[Any]] = set() self.propagating_failure: BaseException | None = None + self.available_function_tools: list[FunctionTool] = [] async def execute( self, ) -> tuple[ list[FunctionToolResult], list[ToolInputGuardrailResult], list[ToolOutputGuardrailResult] ]: + self.available_function_tools = await resolve_enabled_function_tools( + self.agent, + self.context_wrapper, + ) + for tool_run in self.tool_runs: + if tool_run.function_tool not in self.available_function_tools: + self.available_function_tools.append(tool_run.function_tool) for order, tool_run in enumerate(self.tool_runs): self._create_tool_task(tool_run, order) @@ -1342,15 +1400,29 @@ class _FunctionToolBatchExecutor: func_tool: FunctionTool, tool_call: ResponseFunctionToolCall, ) -> Any: + raw_tool_call = tool_call current_task = asyncio.current_task() if current_task is not None: self.task_states[current_task].in_post_invoke_phase = False - with function_span(func_tool.name) as span_fn: + tool_call = cast( + ResponseFunctionToolCall, + normalize_tool_call_for_function_tool(tool_call, func_tool), + ) + trace_tool_name = ( + get_tool_call_trace_name(tool_call) + or get_function_tool_trace_name(func_tool) + or func_tool.name + ) + with function_span(trace_tool_name) as span_fn: + tool_context_namespace = get_tool_call_namespace(raw_tool_call) + if tool_context_namespace is None: + tool_context_namespace = get_tool_call_namespace(tool_call) tool_context = ToolContext.from_agent_context( self.context_wrapper, tool_call.call_id, - tool_call=tool_call, + tool_call=raw_tool_call, + tool_namespace=tool_context_namespace, agent=self.agent, run_config=self.config, ) @@ -1362,6 +1434,7 @@ class _FunctionToolBatchExecutor: approval_result = await self._maybe_execute_tool_approval( func_tool=func_tool, tool_call=tool_call, + raw_tool_call=raw_tool_call, span_fn=span_fn, ) if approval_result is not None: @@ -1394,6 +1467,7 @@ class _FunctionToolBatchExecutor: *, func_tool: FunctionTool, tool_call: ResponseFunctionToolCall, + raw_tool_call: ResponseFunctionToolCall, span_fn: Span[Any], ) -> Any | None: needs_approval_result = await function_needs_approval( @@ -1404,15 +1478,29 @@ class _FunctionToolBatchExecutor: if not needs_approval_result: return None + tool_namespace = get_tool_call_namespace(raw_tool_call) + if tool_namespace is None and is_deferred_top_level_function_tool(func_tool): + tool_namespace = func_tool.name + tool_lookup_key = get_function_tool_lookup_key_for_call(raw_tool_call) + if is_deferred_top_level_function_tool(func_tool): + tool_lookup_key = ("deferred_top_level", func_tool.name) approval_status = self.context_wrapper.get_approval_status( func_tool.name, tool_call.call_id, + tool_namespace=tool_namespace, + tool_lookup_key=tool_lookup_key, ) if approval_status is None: approval_item = ToolApprovalItem( agent=self.agent, - raw_item=tool_call, + raw_item=raw_tool_call, tool_name=func_tool.name, + tool_namespace=tool_namespace, + tool_lookup_key=tool_lookup_key, + _allow_bare_name_alias=should_allow_bare_name_approval_alias( + func_tool, + self.available_function_tools, + ), ) return FunctionToolResult(tool=func_tool, output=None, run_item=approval_item) @@ -1423,7 +1511,7 @@ class _FunctionToolBatchExecutor: context_wrapper=self.context_wrapper, run_config=self.config, tool_type="function", - tool_name=func_tool.name, + tool_name=tool_trace_name(func_tool.name, tool_namespace) or func_tool.name, call_id=tool_call.call_id, ) span_fn.set_error( @@ -1817,7 +1905,19 @@ async def execute_approved_tools( ) -> None: """Execute tools that have been approved after an interruption (HITL resume path).""" tool_runs: list[ToolRunFunction] = [] - tool_map: dict[str, Tool] = {tool.name: tool for tool in all_tools or []} + tool_map: dict[NamedToolLookupKey, Tool] = cast( + dict[NamedToolLookupKey, Tool], + build_function_tool_lookup_map( + [tool for tool in all_tools or [] if isinstance(tool, FunctionTool)] + ), + ) + for tool in all_tools or []: + if isinstance(tool, FunctionTool): + continue + if hasattr(tool, "name"): + tool_name = getattr(tool, "name", None) + if isinstance(tool_name, str) and tool_name: + tool_map[tool_name] = tool def _append_error(message: str, *, tool_call: Any, tool_name: str, call_id: str) -> None: append_approval_error_output( @@ -1834,6 +1934,15 @@ async def execute_approved_tools( ) -> tuple[ResponseFunctionToolCall, FunctionTool, str, str] | None: tool_call = interruption.raw_item tool_name = interruption.name or RunContextWrapper._resolve_tool_name(interruption) + tool_namespace = getattr(interruption, "tool_namespace", None) + tool_lookup_key = getattr( + interruption, "tool_lookup_key", None + ) or get_function_tool_lookup_key( + tool_name, + tool_namespace, + ) + approval_key = tool_lookup_key + display_tool_name = tool_trace_name(tool_name, tool_namespace) or tool_name or "unknown" if not tool_name: _append_error( message="Tool approval item missing tool name.", @@ -1854,17 +1963,23 @@ async def execute_approved_tools( return None approval_status = context_wrapper.get_approval_status( - tool_name, call_id, existing_pending=interruption + tool_name, + call_id, + tool_namespace=tool_namespace, + existing_pending=interruption, + tool_lookup_key=tool_lookup_key, ) if approval_status is False: - resolved_tool = tool_map.get(tool_name) + resolved_tool = tool_map.get(approval_key) if approval_key is not None else None + if resolved_tool is None and tool_namespace is None: + resolved_tool = tool_map.get(tool_name) message = REJECTION_MESSAGE if isinstance(resolved_tool, FunctionTool): message = await resolve_approval_rejection_message( context_wrapper=context_wrapper, run_config=run_config, tool_type="function", - tool_name=tool_name, + tool_name=display_tool_name, call_id=call_id, ) _append_error( @@ -1884,10 +1999,12 @@ async def execute_approved_tools( ) return None - tool = tool_map.get(tool_name) + tool = tool_map.get(approval_key) if approval_key is not None else None + if tool is None and tool_namespace is None: + tool = tool_map.get(tool_name) if tool is None: _append_error( - message=f"Tool '{tool_name}' not found.", + message=f"Tool '{display_tool_name}' not found.", tool_call=tool_call, tool_name=tool_name, call_id=call_id, @@ -1896,7 +2013,7 @@ async def execute_approved_tools( if not isinstance(tool, FunctionTool): _append_error( - message=f"Tool '{tool_name}' is not a function tool.", + message=f"Tool '{display_tool_name}' is not a function tool.", tool_call=tool_call, tool_name=tool_name, call_id=call_id, diff --git a/src/agents/run_internal/tool_planning.py b/src/agents/run_internal/tool_planning.py index 5a3a2c1c..dabb83b4 100644 --- a/src/agents/run_internal/tool_planning.py +++ b/src/agents/run_internal/tool_planning.py @@ -10,6 +10,7 @@ from typing import Any, TypeVar, cast from openai.types.responses import ResponseFunctionToolCall from openai.types.responses.response_input_param import McpApprovalResponse +from .._tool_identity import get_function_tool_lookup_key_for_call, get_tool_call_namespace from ..agent import Agent from ..exceptions import UserError from ..items import ( @@ -413,6 +414,10 @@ async def _collect_runs_by_approval( agent=agent, raw_item=get_mapping_or_attr(run, "tool_call"), tool_name=tool_name, + tool_namespace=get_tool_call_namespace(get_mapping_or_attr(run, "tool_call")), + tool_lookup_key=get_function_tool_lookup_key_for_call( + get_mapping_or_attr(run, "tool_call") + ), ) pending_interruption_adder(pending_item) @@ -482,6 +487,7 @@ async def _select_function_tool_runs_for_resume( approval_status = context_wrapper.get_approval_status( run.function_tool.name, call_id, + tool_namespace=get_tool_call_namespace(run.tool_call), existing_pending=approval_items_by_call_id.get(call_id), ) diff --git a/src/agents/run_internal/tool_use_tracker.py b/src/agents/run_internal/tool_use_tracker.py index 33e2d721..e763f175 100644 --- a/src/agents/run_internal/tool_use_tracker.py +++ b/src/agents/run_internal/tool_use_tracker.py @@ -7,10 +7,18 @@ from __future__ import annotations from typing import Any, get_args, get_origin +from .._tool_identity import get_function_tool_trace_name from ..agent import Agent -from ..items import ToolCallItemTypes +from ..items import ( + HandoffCallItem, + ToolCallItem, + ToolCallItemTypes, + ToolCallOutputItem, + ToolSearchCallItem, + ToolSearchOutputItem, +) from ..run_state import _build_agent_map -from .run_steps import ToolRunFunction +from .run_steps import ProcessedResponse, ToolRunFunction __all__ = [ "AgentToolUseTracker", @@ -20,6 +28,20 @@ __all__ = [ "TOOL_CALL_TYPES", ] +_TOOL_USE_RESET_TRACKING_ITEM_TYPES = ( + HandoffCallItem, + ToolCallItem, + ToolCallOutputItem, +) + +_PROCESSED_RESPONSE_TOOL_ITEM_TYPES = ( + HandoffCallItem, + ToolCallItem, + ToolCallOutputItem, + ToolSearchCallItem, + ToolSearchOutputItem, +) + class AgentToolUseTracker: """Track which tools an agent has used to support model_settings resets.""" @@ -31,11 +53,34 @@ class AgentToolUseTracker: self.agent_to_tools: list[tuple[Agent[Any], list[str]]] = [] def record_used_tools(self, agent: Agent[Any], tools: list[ToolRunFunction]) -> None: - tool_names = [tool.function_tool.name for tool in tools] + tool_names = [ + get_function_tool_trace_name(tool.function_tool) or tool.function_tool.name + for tool in tools + ] + self.add_tool_use(agent, tool_names) + + def record_processed_response( + self, agent: Agent[Any], processed_response: ProcessedResponse + ) -> None: + """Track resettable tool usage from a processed model response.""" + tool_name_iter = iter(processed_response.tools_used) + tool_names: list[str] = [] + for item in processed_response.new_items: + if not isinstance(item, _PROCESSED_RESPONSE_TOOL_ITEM_TYPES): + continue + tool_name = next(tool_name_iter, None) + if tool_name is None: + break + if isinstance(item, _TOOL_USE_RESET_TRACKING_ITEM_TYPES): + tool_names.append(tool_name) + self.add_tool_use(agent, tool_names) def add_tool_use(self, agent: Agent[Any], tool_names: list[str]) -> None: """Maintain compatibility for callers that append tool usage directly.""" + if not tool_names: + return + agent_name = getattr(agent, "name", agent.__class__.__name__) names_set = self.agent_map.setdefault(agent_name, set()) names_set.update(tool_names) diff --git a/src/agents/run_internal/turn_resolution.py b/src/agents/run_internal/turn_resolution.py index 59996c6e..c8ceade7 100644 --- a/src/agents/run_internal/turn_resolution.py +++ b/src/agents/run_internal/turn_resolution.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio import inspect -from collections.abc import Awaitable, Callable, Sequence +from collections.abc import Awaitable, Callable, Mapping, Sequence from typing import Any, Literal, cast from openai.types.responses import ( @@ -27,6 +27,16 @@ from openai.types.responses.response_output_item import ( ) from openai.types.responses.response_reasoning_item import ResponseReasoningItem +from .._tool_identity import ( + build_function_tool_lookup_map, + get_function_tool_lookup_key, + get_function_tool_lookup_key_for_call, + get_tool_call_namespace, + get_tool_call_qualified_name, + get_tool_call_trace_name, + normalize_tool_call_for_function_tool, + should_allow_bare_name_approval_alias, +) from ..agent import Agent, ToolsToFinalOutputResult from ..agent_output import AgentOutputSchemaBase from ..agent_tool_state import get_agent_tool_state_scope, peek_agent_tool_run_result @@ -46,7 +56,11 @@ from ..items import ( ToolApprovalItem, ToolCallItem, ToolCallOutputItem, + ToolSearchCallItem, + ToolSearchOutputItem, TResponseInputItem, + coerce_tool_search_call_raw_item, + coerce_tool_search_output_raw_item, ) from ..lifecycle import RunHooks from ..logger import logger @@ -107,6 +121,7 @@ from .tool_execution import ( parse_apply_patch_function_args, process_hosted_mcp_approvals, resolve_approval_rejection_message, + resolve_enabled_function_tools, should_keep_hosted_mcp_item, ) from .tool_planning import ( @@ -460,7 +475,7 @@ async def check_for_final_output_from_tools( elif isinstance(agent.tool_use_behavior, dict): names = agent.tool_use_behavior.get("stop_at_tool_names", []) for tool_result in tool_results: - if tool_result.tool.name in names: + if tool_result.tool.name in names or tool_result.tool.qualified_name in names: return ToolsToFinalOutputResult( is_final_output=True, final_output=tool_result.output ) @@ -693,7 +708,7 @@ async def resolve_interrupted_turn( context_wrapper=context_wrapper, run_config=run_config, tool_type="function", - tool_name=function_tool.name, + tool_name=get_tool_call_trace_name(tool_call) or function_tool.name, call_id=call_id, ) rejected_function_outputs.append( @@ -833,7 +848,10 @@ async def resolve_interrupted_turn( has_pending = True continue status = context_wrapper.get_approval_status( - interruption.tool_name or "", call_id, existing_pending=interruption + interruption.tool_name or "", + call_id, + tool_namespace=interruption.tool_namespace, + existing_pending=interruption, ) if status is False: return "rejected" @@ -877,13 +895,19 @@ async def resolve_interrupted_turn( return True return getattr(approval_agent, "name", None) == agent.name + available_function_tools = await resolve_enabled_function_tools(agent, context_wrapper) + approval_rebuild_function_tools = available_function_tools + if pending_approval_items and agent.mcp_servers: + approval_rebuild_function_tools = [ + tool + for tool in await agent.get_all_tools(context_wrapper) + if isinstance(tool, FunctionTool) + ] + async def _rebuild_function_runs_from_approvals() -> list[ToolRunFunction]: if not pending_approval_items: return [] - all_tools = await agent.get_all_tools(context_wrapper) - tool_map: dict[str, FunctionTool] = { - tool.name: tool for tool in all_tools if isinstance(tool, FunctionTool) - } + tool_map = build_function_tool_lookup_map(approval_rebuild_function_tools) existing_pending_call_ids: set[str] = set() for existing_pending in pending_interruptions: if isinstance(existing_pending, ToolApprovalItem): @@ -899,7 +923,10 @@ async def resolve_interrupted_turn( return tool_name = approval.tool_name or "" approval_status = context_wrapper.get_approval_status( - tool_name, call_id, existing_pending=approval + tool_name, + call_id, + tool_namespace=approval.tool_namespace, + existing_pending=approval, ) if approval_status is None: _add_pending_interruption(approval) @@ -916,7 +943,14 @@ async def resolve_interrupted_turn( _add_unmatched_pending(approval) continue name = get_mapping_or_attr(raw, "name") - if not (isinstance(name, str) and name in tool_map): + namespace = get_tool_call_namespace(raw) + if namespace is None and isinstance(approval.tool_namespace, str): + namespace = approval.tool_namespace + approval_key = getattr(approval, "tool_lookup_key", None) + if approval_key is None: + approval_key = get_function_tool_lookup_key(name, namespace) + resolved_tool = tool_map.get(approval_key) if approval_key is not None else None + if not (isinstance(name, str) and resolved_tool is not None): _add_unmatched_pending(approval) continue @@ -941,26 +975,36 @@ async def resolve_interrupted_turn( "incomplete", ): valid_status = status # type: ignore[assignment] - tool_call = ResponseFunctionToolCall( - type="function_call", - name=name, - call_id=rebuilt_call_id, - arguments=arguments, - status=valid_status, - ) + tool_call_payload: dict[str, Any] = { + "type": "function_call", + "name": name, + "call_id": rebuilt_call_id, + "arguments": arguments, + "status": valid_status, + } + if namespace is not None: + tool_call_payload["namespace"] = namespace + tool_call = ResponseFunctionToolCall(**tool_call_payload) + tool_call = cast( + ResponseFunctionToolCall, + normalize_tool_call_for_function_tool(tool_call, resolved_tool), + ) if not (isinstance(rebuilt_call_id, str) and isinstance(arguments, str)): _add_unmatched_pending(approval) continue approval_status = context_wrapper.get_approval_status( - name, rebuilt_call_id, existing_pending=approval + name, + rebuilt_call_id, + tool_namespace=namespace, + existing_pending=approval, ) if approval_status is False: await _record_function_rejection( rebuilt_call_id, tool_call, - tool_map[name], + resolved_tool, ) continue if approval_status is None: @@ -968,7 +1012,7 @@ async def resolve_interrupted_turn( _add_pending_interruption(approval) existing_pending_call_ids.add(rebuilt_call_id) continue - rebuilt_runs.append(ToolRunFunction(function_tool=tool_map[name], tool_call=tool_call)) + rebuilt_runs.append(ToolRunFunction(function_tool=resolved_tool, tool_call=tool_call)) return rebuilt_runs function_tool_runs = await _select_function_tool_runs_for_resume( @@ -979,7 +1023,17 @@ async def resolve_interrupted_turn( output_exists_checker=_function_output_exists, record_rejection=_record_function_rejection, pending_interruption_adder=_add_pending_interruption, - pending_item_builder=lambda run: ToolApprovalItem(agent=agent, raw_item=run.tool_call), + pending_item_builder=lambda run: ToolApprovalItem( + agent=agent, + raw_item=run.tool_call, + tool_name=run.function_tool.name, + tool_namespace=get_tool_call_namespace(run.tool_call), + tool_lookup_key=get_function_tool_lookup_key_for_call(run.tool_call), + _allow_bare_name_alias=should_allow_bare_name_approval_alias( + run.function_tool, + available_function_tools, + ), + ), ) rebuilt_function_tool_runs = await _rebuild_function_runs_from_approvals() @@ -1229,7 +1283,9 @@ def process_model_response( mcp_approval_requests = [] tools_used: list[str] = [] handoff_map = {handoff.tool_name: handoff for handoff in handoffs} - function_map = {tool.name: tool for tool in all_tools if isinstance(tool, FunctionTool)} + function_map = build_function_tool_lookup_map( + [tool for tool in all_tools if isinstance(tool, FunctionTool)] + ) computer_tool = next((tool for tool in all_tools if isinstance(tool, ComputerTool)), None) 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) @@ -1240,6 +1296,19 @@ def process_model_response( if isinstance(tool, HostedMCPTool) } + def _dump_output_item(raw_item: Any) -> dict[str, Any]: + if isinstance(raw_item, dict): + return dict(raw_item) + if hasattr(raw_item, "model_dump"): + dumped = cast(Any, raw_item).model_dump(exclude_unset=True) + if isinstance(dumped, Mapping): + return dict(dumped) + return {"type": get_mapping_or_attr(raw_item, "type")} + return { + "type": get_mapping_or_attr(raw_item, "type"), + "id": get_mapping_or_attr(raw_item, "id"), + } + for output in response.output: output_type = get_mapping_or_attr(output, "type") logger.debug( @@ -1367,6 +1436,26 @@ def process_model_response( CompactionItem(agent=agent, raw_item=cast(TResponseInputItem, compaction_raw)) ) continue + if output_type == "tool_search_call": + tool_search_call_raw = coerce_tool_search_call_raw_item(output) + if get_mapping_or_attr(tool_search_call_raw, "execution") == "client": + raise ModelBehaviorError( + "Client-executed tool_search calls are not supported by the standard " + "agent runner. Handle the tool_search_call yourself and return a matching " + "tool_search_output item with the same call_id." + ) + items.append(ToolSearchCallItem(raw_item=tool_search_call_raw, agent=agent)) + tools_used.append("tool_search") + continue + if output_type == "tool_search_output": + items.append( + ToolSearchOutputItem( + raw_item=coerce_tool_search_output_raw_item(output), + agent=agent, + ) + ) + tools_used.append("tool_search") + continue if isinstance(output, ResponseOutputMessage): items.append(MessageOutputItem(raw_item=output, agent=agent)) elif isinstance(output, ResponseFileSearchToolCall): @@ -1490,7 +1579,7 @@ def process_model_response( elif ( isinstance(output, ResponseFunctionToolCall) and is_apply_patch_name(output.name, apply_patch_tool) - and output.name not in function_map + and get_function_tool_lookup_key_for_call(output) not in function_map ): parsed_operation = parse_apply_patch_function_args(output.arguments) pseudo_call = { @@ -1524,9 +1613,10 @@ def process_model_response( if not isinstance(output, ResponseFunctionToolCall): continue - tools_used.append(output.name) + tools_used.append(get_tool_call_trace_name(output) or output.name) + qualified_output_name = get_tool_call_qualified_name(output) - if output.name in handoff_map: + if qualified_output_name == output.name and output.name in handoff_map: items.append(HandoffCallItem(raw_item=output, agent=agent)) handoff = ToolRunHandoff( tool_call=output, @@ -1534,7 +1624,9 @@ def process_model_response( ) run_handoffs.append(handoff) else: - if output.name not in function_map: + lookup_key = get_function_tool_lookup_key_for_call(output) + func_tool = function_map.get(lookup_key) if lookup_key is not None else None + if func_tool is None: if output_schema is not None and output.name == "json_tool_call": items.append(ToolCallItem(raw_item=output, agent=agent)) functions.append( @@ -1547,15 +1639,20 @@ def process_model_response( _error_tracing.attach_error_to_current_span( SpanError( message="Tool not found", - data={"tool_name": output.name}, + data={"tool_name": qualified_output_name or output.name}, ) ) - error = f"Tool {output.name} not found in agent {agent.name}" + error = ( + f"Tool {qualified_output_name or output.name} not found in agent {agent.name}" + ) raise ModelBehaviorError(error) - func_tool = function_map[output.name] items.append( - ToolCallItem(raw_item=output, agent=agent, description=func_tool.description) + ToolCallItem( + raw_item=output, + agent=agent, + description=func_tool.description, + ) ) functions.append( ToolRunFunction( @@ -1601,7 +1698,7 @@ async def get_single_step_result_from_response( handoffs=handoffs, ) - tool_use_tracker.add_tool_use(agent, processed_response.tools_used) + tool_use_tracker.record_processed_response(agent, processed_response) if event_queue is not None and processed_response.new_items: handoff_items = [ diff --git a/src/agents/run_state.py b/src/agents/run_state.py index b541d40d..58db6e81 100644 --- a/src/agents/run_state.py +++ b/src/agents/run_state.py @@ -31,6 +31,17 @@ from openai.types.responses.response_output_item import ( from pydantic import TypeAdapter, ValidationError from typing_extensions import TypeVar +from ._tool_identity import ( + FunctionToolLookupKey, + NamedToolLookupKey, + build_function_tool_lookup_map, + deserialize_function_tool_lookup_key, + get_function_tool_lookup_key, + get_function_tool_lookup_key_for_tool, + get_function_tool_namespace, + get_function_tool_qualified_name, + serialize_function_tool_lookup_key, +) from .exceptions import UserError from .guardrail import ( GuardrailFunctionOutput, @@ -54,7 +65,11 @@ from .items import ( ToolApprovalItem, ToolCallItem, ToolCallOutputItem, + ToolSearchCallItem, + ToolSearchOutputItem, TResponseInputItem, + coerce_tool_search_call_raw_item, + coerce_tool_search_output_raw_item, ) from .logger import logger from .run_context import RunContextWrapper @@ -95,13 +110,16 @@ ContextOverride = Union[Mapping[str, Any], RunContextWrapper[Any]] ContextSerializer = Callable[[Any], Mapping[str, Any]] ContextDeserializer = Callable[[Mapping[str, Any]], Any] + # RunState schema policy. # 1. Bump CURRENT_SCHEMA_VERSION when serialized shape/semantics change. # 2. Keep older readable versions in SUPPORTED_SCHEMA_VERSIONS for backward reads. # 3. to_json() always emits CURRENT_SCHEMA_VERSION. # 4. Forward compatibility is intentionally fail-fast (older SDKs reject newer versions). -CURRENT_SCHEMA_VERSION = "1.4" -SUPPORTED_SCHEMA_VERSIONS = frozenset({"1.0", "1.1", "1.2", "1.3", CURRENT_SCHEMA_VERSION}) +CURRENT_SCHEMA_VERSION = "1.6" +SUPPORTED_SCHEMA_VERSIONS = frozenset( + {"1.0", "1.1", "1.2", "1.3", "1.4", "1.5", CURRENT_SCHEMA_VERSION} +) _FUNCTION_OUTPUT_ADAPTER: TypeAdapter[FunctionCallOutput] = TypeAdapter(FunctionCallOutput) _COMPUTER_OUTPUT_ADAPTER: TypeAdapter[ComputerCallOutput] = TypeAdapter(ComputerCallOutput) @@ -185,6 +203,9 @@ class RunState(Generic[TContext, TAgent]): _last_processed_response: ProcessedResponse | None = None """The last processed model response. This is needed for resuming from interruptions.""" + _generated_items_last_processed_marker: str | None = field(default=None, repr=False) + """Tracks whether _generated_items already include the current last_processed_response.""" + _current_turn_persisted_item_count: int = 0 """Tracks how many items from this turn were already written to the session.""" @@ -227,6 +248,7 @@ class RunState(Generic[TContext, TAgent]): self._current_step = None self._current_turn = 0 self._last_processed_response = None + self._generated_items_last_processed_marker = None self._current_turn_persisted_item_count = 0 self._tool_use_tracker_snapshot = {} self._trace_state = None @@ -445,12 +467,48 @@ class RunState(Generic[TContext, TAgent]): return _to_dump_compatible(tool_input) + def _current_generated_items_merge_marker(self) -> str | None: + """Return a marker for the processed response already reflected in _generated_items.""" + if not (self._last_processed_response and self._last_processed_response.new_items): + return None + + latest_response_id = ( + self._model_responses[-1].response_id if self._model_responses else None + ) + serialized_items = [ + self._serialize_item(item) for item in self._last_processed_response.new_items + ] + return json.dumps( + { + "current_turn": self._current_turn, + "last_response_id": latest_response_id, + "new_items": serialized_items, + }, + sort_keys=True, + default=str, + ) + + def _mark_generated_items_merged_with_last_processed(self) -> None: + """Remember that _generated_items already include the current processed response.""" + self._generated_items_last_processed_marker = self._current_generated_items_merge_marker() + + def _clear_generated_items_last_processed_marker(self) -> None: + """Forget any prior merge marker after _generated_items is replaced.""" + self._generated_items_last_processed_marker = None + def _merge_generated_items_with_processed(self) -> list[RunItem]: """Merge persisted and newly processed items without duplication.""" generated_items = list(self._generated_items) if not (self._last_processed_response and self._last_processed_response.new_items): return generated_items + current_merge_marker = self._current_generated_items_merge_marker() + if ( + current_merge_marker is not None + and self._generated_items_last_processed_marker == current_merge_marker + ): + return generated_items + seen_id_types: set[tuple[str, str]] = set() seen_call_ids: set[str] = set() seen_call_id_types: set[tuple[str, str]] = set() @@ -500,6 +558,9 @@ class RunState(Generic[TContext, TAgent]): elif call_id: seen_call_ids.add(call_id) generated_items.append(new_item) + + if current_merge_marker is not None: + self._generated_items_last_processed_marker = current_merge_marker return generated_items def to_json( @@ -689,6 +750,13 @@ class RunState(Generic[TContext, TAgent]): result["target_agent"] = {"name": item.target_agent.name} if hasattr(item, "tool_name") and item.tool_name is not None: result["tool_name"] = item.tool_name + if hasattr(item, "tool_namespace") and item.tool_namespace is not None: + result["tool_namespace"] = item.tool_namespace + tool_lookup_key = serialize_function_tool_lookup_key(getattr(item, "tool_lookup_key", None)) + if tool_lookup_key is not None: + result["tool_lookup_key"] = tool_lookup_key + if getattr(item, "_allow_bare_name_alias", False): + result["allow_bare_name_alias"] = True if hasattr(item, "description") and item.description is not None: result["description"] = item.description @@ -1017,6 +1085,15 @@ def _serialize_tool_metadata( ) -> dict[str, Any]: """Build a dictionary of tool metadata for serialization.""" metadata: dict[str, Any] = {"name": tool.name if hasattr(tool, "name") else None} + namespace = get_function_tool_namespace(tool) + if namespace is not None: + metadata["namespace"] = namespace + qualified_name = get_function_tool_qualified_name(tool) + if qualified_name is not None and qualified_name != metadata["name"]: + metadata["qualifiedName"] = qualified_name + lookup_key = serialize_function_tool_lookup_key(get_function_tool_lookup_key_for_tool(tool)) + if lookup_key is not None: + metadata["lookupKey"] = lookup_key if include_description and hasattr(tool, "description"): metadata["description"] = tool.description if include_params_schema and hasattr(tool, "params_json_schema"): @@ -1122,6 +1199,15 @@ def _serialize_tool_approval_interruption( } if include_tool_name and interruption.tool_name is not None: interruption_dict["tool_name"] = interruption.tool_name + if interruption.tool_namespace is not None: + interruption_dict["tool_namespace"] = interruption.tool_namespace + tool_lookup_key = serialize_function_tool_lookup_key( + getattr(interruption, "tool_lookup_key", None) + ) + if tool_lookup_key is not None: + interruption_dict["tool_lookup_key"] = tool_lookup_key + if interruption._allow_bare_name_alias: + interruption_dict["allow_bare_name_alias"] = True return interruption_dict @@ -1330,11 +1416,27 @@ def _serialize_last_model_response(model_responses: list[dict[str, Any]]) -> Any return model_responses[-1] -def _build_named_tool_map(tools: Sequence[Any], tool_type: type[Any]) -> dict[str, Any]: +def _build_named_tool_map( + tools: Sequence[Any], tool_type: type[Any] +) -> dict[NamedToolLookupKey, Any]: """Build a name-indexed map for tools of a given type.""" - return { - tool.name: tool for tool in tools if isinstance(tool, tool_type) and hasattr(tool, "name") - } + if tool_type is FunctionTool: + return cast( + dict[NamedToolLookupKey, Any], + build_function_tool_lookup_map( + [tool for tool in tools if isinstance(tool, FunctionTool)] + ), + ) + + tool_map: dict[NamedToolLookupKey, Any] = {} + for tool in tools: + if not isinstance(tool, tool_type) or not hasattr(tool, "name"): + continue + tool_name = getattr(tool, "name", None) + if not isinstance(tool_name, str) or not tool_name: + continue + tool_map[tool_name] = tool + return tool_map def _build_handoffs_map(current_agent: Agent[Any]) -> dict[str, Handoff[Any, Agent[Any]]]: @@ -1455,23 +1557,34 @@ async def _deserialize_processed_response( entries: list[dict[str, Any]], *, tool_key: str, - tool_map: Mapping[str, Any], + tool_map: Mapping[NamedToolLookupKey, Any], call_parser: Callable[[dict[str, Any]], Any], action_factory: Callable[[Any, Any], Any], - name_resolver: Callable[[Mapping[str, Any]], str | None] | None = None, + name_resolver: Callable[[Mapping[str, Any]], NamedToolLookupKey | None] | None = None, ) -> list[Any]: """Deserialize tool actions with shared structure.""" deserialized: list[Any] = [] for entry in entries or []: + tool_container = entry.get(tool_key, {}) if isinstance(entry, Mapping) else {} if name_resolver: tool_name = name_resolver(entry) else: - tool_container = entry.get(tool_key, {}) if isinstance(entry, Mapping) else {} if isinstance(tool_container, Mapping): tool_name = tool_container.get("name") else: tool_name = None tool = tool_map.get(tool_name) if tool_name else None + if ( + tool is None + and name_resolver is None + and isinstance(tool_container, Mapping) + and not isinstance(tool_container.get("namespace"), str) + ): + bare_name = tool_container.get("name") + if isinstance(bare_name, str): + bare_lookup_key = get_function_tool_lookup_key(bare_name) + if bare_lookup_key is not None: + tool = tool_map.get(bare_lookup_key) if not tool: continue @@ -1499,14 +1612,46 @@ async def _deserialize_processed_response( return data def _deserialize_action_groups() -> dict[str, list[Any]]: + def _resolve_handoff_tool_name(data: Mapping[str, Any]) -> NamedToolLookupKey | None: + handoff_data = data.get("handoff", {}) + if not isinstance(handoff_data, Mapping): + return None + tool_name = handoff_data.get("tool_name") + return cast( + NamedToolLookupKey | None, tool_name if isinstance(tool_name, str) else None + ) + + def _resolve_function_tool_name(data: Mapping[str, Any]) -> FunctionToolLookupKey | None: + tool_data = data.get("tool", {}) + if isinstance(tool_data, Mapping): + lookup_key = deserialize_function_tool_lookup_key(tool_data.get("lookupKey")) + if lookup_key is not None: + return lookup_key + + tool_call_data = data.get("tool_call", {}) + if isinstance(tool_call_data, Mapping): + lookup_key = get_function_tool_lookup_key( + cast(str | None, tool_call_data.get("name")), + cast(str | None, tool_call_data.get("namespace")), + ) + if lookup_key is not None: + return lookup_key + + if not isinstance(tool_data, Mapping): + return None + return get_function_tool_lookup_key( + cast(str | None, tool_data.get("name")), + cast(str | None, tool_data.get("namespace")), + ) + action_specs: list[ tuple[ str, str, - Mapping[str, Any], + Mapping[Any, Any], Callable[[dict[str, Any]], Any], Callable[[Any, Any], Any], - Callable[[Mapping[str, Any]], str | None] | None, + Callable[[Mapping[str, Any]], NamedToolLookupKey | None] | None, ] ] = [ ( @@ -1515,7 +1660,7 @@ async def _deserialize_processed_response( handoffs_map, lambda data: ResponseFunctionToolCall(**data), lambda tool_call, handoff: ToolRunHandoff(tool_call=tool_call, handoff=handoff), - lambda data: data.get("handoff", {}).get("tool_name"), + _resolve_handoff_tool_name, ), ( "functions", @@ -1525,7 +1670,7 @@ async def _deserialize_processed_response( lambda tool_call, function_tool: ToolRunFunction( tool_call=tool_call, function_tool=function_tool ), - None, + _resolve_function_tool_name, ), ( "computer_actions", @@ -1719,8 +1864,18 @@ def _deserialize_tool_approval_item( raw_item_data = dict(raw_item_data) tool_name = item_data.get("tool_name") + tool_namespace = item_data.get("tool_namespace") + tool_lookup_key = deserialize_function_tool_lookup_key(item_data.get("tool_lookup_key")) + allow_bare_name_alias = item_data.get("allow_bare_name_alias") is True raw_item = _deserialize_tool_approval_raw_item(raw_item_data) - return ToolApprovalItem(agent=agent, raw_item=raw_item, tool_name=tool_name) + return ToolApprovalItem( + agent=agent, + raw_item=raw_item, + tool_name=tool_name, + tool_namespace=tool_namespace, + tool_lookup_key=tool_lookup_key, + _allow_bare_name_alias=allow_bare_name_alias, + ) def _deserialize_tool_call_output_raw_item( @@ -2059,6 +2214,8 @@ async def _build_run_state_from_json( else: state._session_items = state._merge_generated_items_with_processed() + state._mark_generated_items_merged_with_last_processed() + state._input_guardrail_results = _deserialize_input_guardrail_results( state_json.get("input_guardrail_results", []) ) @@ -2297,6 +2454,18 @@ def _deserialize_items( raw_item_msg = ResponseOutputMessage(**normalized_raw_item) result.append(MessageOutputItem(agent=agent, raw_item=raw_item_msg)) + elif item_type == "tool_search_call_item": + raw_item_tool_search_call = coerce_tool_search_call_raw_item(normalized_raw_item) + result.append(ToolSearchCallItem(agent=agent, raw_item=raw_item_tool_search_call)) + + elif item_type == "tool_search_output_item": + raw_item_tool_search_output = coerce_tool_search_output_raw_item( + normalized_raw_item + ) + result.append( + ToolSearchOutputItem(agent=agent, raw_item=raw_item_tool_search_output) + ) + elif item_type == "tool_call_item": # Tool call items can be function calls, shell calls, apply_patch calls, # MCP calls, etc. Check the type field to determine which type to deserialize as diff --git a/src/agents/stream_events.py b/src/agents/stream_events.py index c0e9807a..fcb2fe40 100644 --- a/src/agents/stream_events.py +++ b/src/agents/stream_events.py @@ -34,6 +34,8 @@ class RunItemStreamEvent: # This is misspelled, but we can't change it because that would be a breaking change "handoff_occured", "tool_called", + "tool_search_called", + "tool_search_output_created", "tool_output", "reasoning_item_created", "mcp_approval_requested", diff --git a/src/agents/tool.py b/src/agents/tool.py index b99feed9..3674dad3 100644 --- a/src/agents/tool.py +++ b/src/agents/tool.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import copy import dataclasses import inspect import json @@ -34,6 +35,12 @@ from pydantic import BaseModel, TypeAdapter, ValidationError, model_validator from typing_extensions import Concatenate, NotRequired, ParamSpec, TypedDict from . import _debug +from ._tool_identity import ( + get_explicit_function_tool_namespace, + tool_qualified_name, + validate_function_tool_lookup_configuration, + validate_function_tool_namespace_shape, +) from .computer import AsyncComputer, Computer from .editor import ApplyPatchEditor, ApplyPatchOperation from .exceptions import ModelBehaviorError, ToolTimeoutError, UserError @@ -278,6 +285,9 @@ class FunctionTool: timeout_error_function: ToolErrorFunction | None = None """Optional formatter for timeout errors when timeout_behavior is "error_as_result".""" + defer_loading: bool = False + """Whether the Responses API should hide this tool definition until tool search loads it.""" + _failure_error_function: ToolErrorFunction | None = field( default=None, kw_only=True, @@ -301,6 +311,19 @@ class FunctionTool: _agent_instance: Any = field(default=None, kw_only=True, repr=False) """Internal reference to the agent instance if this is an agent-as-tool.""" + _tool_namespace: str | None = field(default=None, kw_only=True, repr=False) + """Internal namespace metadata used to group function tools for the Responses API.""" + + _tool_namespace_description: str | None = field(default=None, kw_only=True, repr=False) + """Internal namespace description used when serializing grouped function tools.""" + + @property + def qualified_name(self) -> str: + """Return the public qualified name used to identify this function tool.""" + return ( + tool_qualified_name(self.name, get_explicit_function_tool_namespace(self)) or self.name + ) + def __post_init__(self): bind_to_function_tool = getattr(self.on_invoke_tool, "__agents_bind_function_tool__", None) if callable(bind_to_function_tool): @@ -393,6 +416,7 @@ def _build_wrapped_function_tool( timeout_seconds: float | None = None, timeout_behavior: ToolTimeoutBehavior = "error_as_result", timeout_error_function: ToolErrorFunction | None = None, + defer_loading: bool = False, sync_invoker: bool = False, ) -> FunctionTool: """Create a FunctionTool with copied-tool-aware failure handling bound in one place.""" @@ -417,6 +441,7 @@ def _build_wrapped_function_tool( timeout_seconds=timeout_seconds, timeout_behavior=timeout_behavior, timeout_error_function=timeout_error_function, + defer_loading=defer_loading, ), failure_error_function, ) @@ -1010,6 +1035,23 @@ class ApplyPatchTool: return "apply_patch" +@dataclass +class ToolSearchTool: + """A hosted Responses API tool that lets the model search deferred tools by namespace. + + `execution="client"` is supported for manual Responses orchestration, but the standard + OpenAI Agents runner does not auto-execute client tool search calls. + """ + + description: str | None = None + execution: Literal["server", "client"] | None = None + parameters: object | None = None + + @property + def name(self) -> str: + return "tool_search" + + Tool = Union[ FunctionTool, FileSearchTool, @@ -1021,10 +1063,141 @@ Tool = Union[ LocalShellTool, ImageGenerationTool, CodeInterpreterTool, + ToolSearchTool, ] """A tool that can be used in an agent.""" +def tool_namespace( + *, + name: str, + description: str | None, + tools: list[FunctionTool], +) -> list[FunctionTool]: + """Attach namespace metadata to function tools for OpenAI Responses tool search.""" + if not isinstance(name, str) or not name.strip(): + raise UserError("tool_namespace() requires a non-empty namespace name.") + if not isinstance(description, str) or not description.strip(): + raise UserError("tool_namespace() requires a non-empty description.") + if any(not isinstance(tool, FunctionTool) for tool in tools): + raise UserError("tool_namespace() only supports FunctionTool instances.") + + namespace_name = name.strip() + normalized_description = description.strip() + namespaced_tools: list[FunctionTool] = [] + for tool in tools: + validate_function_tool_namespace_shape(tool.name, namespace_name) + namespaced_tool = copy.copy(tool) + namespaced_tool._tool_namespace = namespace_name + namespaced_tool._tool_namespace_description = normalized_description + namespaced_tools.append(namespaced_tool) + return namespaced_tools + + +def get_function_tool_responses_only_features(tool: FunctionTool) -> tuple[str, ...]: + """Return Responses-only features used by a function tool.""" + features: list[str] = [] + if get_explicit_function_tool_namespace(tool) is not None: + features.append("tool_namespace()") + if tool.defer_loading: + features.append("defer_loading=True") + return tuple(features) + + +def ensure_function_tool_supports_responses_only_features( + tool: FunctionTool, + *, + backend_name: str, +) -> None: + """Reject Responses-only function-tool features on unsupported backends.""" + unsupported_features = get_function_tool_responses_only_features(tool) + if not unsupported_features: + return + + tool_name = tool.qualified_name + raise UserError( + "The following function-tool features are only supported with OpenAI Responses " + f"models: {', '.join(unsupported_features)}. " + f"Tool `{tool_name}` cannot be used with {backend_name}." + ) + + +def ensure_tool_choice_supports_backend( + tool_choice: Literal["auto", "required", "none"] | str | Any | None, + *, + backend_name: str, +) -> None: + """Backend-specific converters should validate reserved tool choices.""" + return None + + +def is_responses_tool_search_surface(tool: Tool) -> bool: + """Return True when a tool can be exposed through hosted Responses tool search.""" + if isinstance(tool, FunctionTool): + return tool.defer_loading or get_explicit_function_tool_namespace(tool) is not None + if isinstance(tool, HostedMCPTool): + return bool(tool.tool_config.get("defer_loading")) + return False + + +def has_responses_tool_search_surface(tools: list[Tool]) -> bool: + """Return True when tool search has at least one eligible searchable surface.""" + return any(is_responses_tool_search_surface(tool) for tool in tools) + + +def is_required_tool_search_surface(tool: Tool) -> bool: + """Return True when a tool requires ToolSearchTool() to stay reachable.""" + if isinstance(tool, FunctionTool): + return tool.defer_loading + if isinstance(tool, HostedMCPTool): + return bool(tool.tool_config.get("defer_loading")) + return False + + +def has_required_tool_search_surface(tools: list[Tool]) -> bool: + """Return True when any enabled surface requires ToolSearchTool().""" + return any(is_required_tool_search_surface(tool) for tool in tools) + + +def validate_responses_tool_search_configuration( + tools: list[Tool], + *, + allow_opaque_search_surface: bool = False, +) -> None: + """Validate the Responses-only tool_search and defer-loading contract.""" + tool_search_tools = [tool for tool in tools if isinstance(tool, ToolSearchTool)] + tool_search_count = len(tool_search_tools) + has_tool_search = tool_search_count > 0 + has_tool_search_surface = has_responses_tool_search_surface(tools) + has_required_tool_search = has_required_tool_search_surface(tools) + + if tool_search_count > 1: + raise UserError("Only one ToolSearchTool() is allowed when using OpenAI Responses models.") + validate_function_tool_lookup_configuration(tools) + if has_required_tool_search and not has_tool_search: + raise UserError( + "Deferred-loading Responses tools require ToolSearchTool() when using OpenAI " + "Responses models." + ) + if has_tool_search and not has_tool_search_surface and not allow_opaque_search_surface: + raise UserError( + "ToolSearchTool() requires at least one searchable Responses surface: a " + "tool_namespace(...) function tool, a deferred-loading function tool " + "(`function_tool(..., defer_loading=True)`), or a deferred-loading hosted MCP " + "server (`HostedMCPTool(tool_config={..., 'defer_loading': True})`)." + ) + + +def prune_orphaned_tool_search_tools(tools: list[Tool]) -> list[Tool]: + """Preserve explicit ToolSearchTool entries until request conversion validates them. + + Whether a tool_search definition is valid can depend on prompt-managed surfaces that are + only known during request conversion, so pruning here hides misconfiguration instead of + surfacing a clear error. + """ + return tools + + def _extract_json_decode_error(error: BaseException) -> json.JSONDecodeError | None: current: BaseException | None = error while current is not None: @@ -1253,6 +1426,7 @@ def function_tool( timeout: float | None = None, timeout_behavior: ToolTimeoutBehavior = "error_as_result", timeout_error_function: ToolErrorFunction | None = None, + defer_loading: bool = False, ) -> FunctionTool: """Overload for usage as @function_tool (no parentheses).""" ... @@ -1275,6 +1449,7 @@ def function_tool( timeout: float | None = None, timeout_behavior: ToolTimeoutBehavior = "error_as_result", timeout_error_function: ToolErrorFunction | None = None, + defer_loading: bool = False, ) -> Callable[[ToolFunction[...]], FunctionTool]: """Overload for usage as @function_tool(...).""" ... @@ -1297,6 +1472,7 @@ def function_tool( timeout: float | None = None, timeout_behavior: ToolTimeoutBehavior = "error_as_result", timeout_error_function: ToolErrorFunction | None = None, + defer_loading: bool = False, ) -> FunctionTool | Callable[[ToolFunction[...]], FunctionTool]: """ Decorator to create a FunctionTool from a function. By default, we will: @@ -1340,6 +1516,8 @@ def function_tool( while "raise_exception" raises ToolTimeoutError and fails the run. timeout_error_function: Optional formatter used for timeout messages when timeout_behavior="error_as_result". + defer_loading: Whether to hide this tool definition until Responses API tool search + explicitly loads it. """ def _create_function_tool(the_func: ToolFunction[...]) -> FunctionTool: @@ -1409,6 +1587,7 @@ def function_tool( timeout_seconds=timeout, timeout_behavior=timeout_behavior, timeout_error_function=timeout_error_function, + defer_loading=defer_loading, sync_invoker=is_sync_function_tool, ) return function_tool diff --git a/src/agents/tool_context.py b/src/agents/tool_context.py index 4156b32a..d8ea1aa1 100644 --- a/src/agents/tool_context.py +++ b/src/agents/tool_context.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Any, cast from openai.types.responses import ResponseFunctionToolCall +from ._tool_identity import get_tool_call_namespace, tool_trace_name from .agent_tool_state import get_agent_tool_state_scope, set_agent_tool_state_scope from .run_context import RunContextWrapper, TContext from .usage import Usage @@ -47,6 +48,9 @@ class ToolContext(RunContextWrapper[TContext]): tool_call: ResponseFunctionToolCall | None = None """The tool call object associated with this invocation.""" + tool_namespace: str | None = None + """The Responses API namespace for this tool call, when present.""" + agent: AgentBase[Any] | None = None """The active agent for this tool call, when available.""" @@ -62,6 +66,7 @@ class ToolContext(RunContextWrapper[TContext]): tool_arguments: str | object = _MISSING, tool_call: ResponseFunctionToolCall | None = None, *, + tool_namespace: str | None = None, agent: AgentBase[Any] | None = None, run_config: RunConfig | None = None, turn_input: list[TResponseInputItem] | None = None, @@ -91,9 +96,19 @@ class ToolContext(RunContextWrapper[TContext]): else cast(str, tool_call_id) ) self.tool_call = tool_call + self.tool_namespace = ( + tool_namespace + if isinstance(tool_namespace, str) + else get_tool_call_namespace(tool_call) + ) self.agent = agent self.run_config = run_config + @property + def qualified_tool_name(self) -> str: + """Return the tool name qualified by namespace when available.""" + return tool_trace_name(self.tool_name, self.tool_namespace) or self.tool_name + @classmethod def from_agent_context( cls, @@ -102,6 +117,7 @@ class ToolContext(RunContextWrapper[TContext]): tool_call: ResponseFunctionToolCall | None = None, agent: AgentBase[Any] | None = None, *, + tool_namespace: str | None = None, run_config: RunConfig | None = None, ) -> ToolContext: """ @@ -127,6 +143,16 @@ class ToolContext(RunContextWrapper[TContext]): tool_call_id=tool_call_id, tool_arguments=tool_args, tool_call=tool_call, + tool_namespace=( + tool_namespace + if isinstance(tool_namespace, str) + else ( + getattr(tool_call, "namespace", None) + if tool_call is not None + and isinstance(getattr(tool_call, "namespace", None), str) + else None + ) + ), agent=tool_agent, run_config=tool_run_config, **base_values, diff --git a/tests/extensions/memory/test_advanced_sqlite_session.py b/tests/extensions/memory/test_advanced_sqlite_session.py index 197ae36d..cbebd0a1 100644 --- a/tests/extensions/memory/test_advanced_sqlite_session.py +++ b/tests/extensions/memory/test_advanced_sqlite_session.py @@ -159,6 +159,174 @@ async def test_tool_usage_tracking(agent: Agent): session.close() +async def test_tool_usage_tracking_preserves_namespaces_and_tool_search(agent: Agent): + """Tool usage should retain namespaces and count tool_search calls once.""" + session_id = "tools_namespace_test" + session = AdvancedSQLiteSession(session_id=session_id, create_tables=True) + + items: list[TResponseInputItem] = [ + {"role": "user", "content": "Look up the same account in multiple systems"}, + { + "type": "function_call", + "name": "lookup_account", + "namespace": "crm", + "arguments": '{"account_id": "acct_123"}', + "call_id": "crm-call", + }, + { + "type": "function_call", + "name": "lookup_account", + "namespace": "billing", + "arguments": '{"account_id": "acct_123"}', + "call_id": "billing-call", + }, + { + "type": "tool_search_call", + "id": "tsc_memory", + "arguments": {"paths": ["crm"], "query": "lookup_account"}, + "execution": "server", + "status": "completed", + }, + cast( + TResponseInputItem, + { + "type": "tool_search_output", + "id": "tso_memory", + "execution": "server", + "status": "completed", + "tools": [ + { + "type": "function", + "name": "lookup_account", + "description": "Look up an account.", + "parameters": { + "type": "object", + "properties": { + "account_id": { + "type": "string", + } + }, + "required": ["account_id"], + }, + "defer_loading": True, + } + ], + }, + ), + ] + await session.add_items(items) + + usage_by_tool = {tool_name: count for tool_name, count, _turn in await session.get_tool_usage()} + + assert usage_by_tool["crm.lookup_account"] == 1 + assert usage_by_tool["billing.lookup_account"] == 1 + assert usage_by_tool["tool_search"] == 1 + + session.close() + + +async def test_tool_usage_tracking_counts_tool_search_output_without_matching_call( + agent: Agent, +) -> None: + """Tool-search output-only histories should still report one tool_search usage.""" + session_id = "tools_tool_search_output_only_test" + session = AdvancedSQLiteSession(session_id=session_id, create_tables=True) + + items: list[TResponseInputItem] = [ + {"role": "user", "content": "Look up customer_42"}, + cast( + TResponseInputItem, + { + "type": "tool_search_output", + "id": "tso_memory_only", + "execution": "server", + "status": "completed", + "tools": [ + { + "type": "function", + "name": "lookup_account", + "description": "Look up an account.", + "parameters": { + "type": "object", + "properties": { + "account_id": { + "type": "string", + } + }, + "required": ["account_id"], + }, + } + ], + }, + ), + ] + await session.add_items(items) + + usage_by_tool = {tool_name: count for tool_name, count, _turn in await session.get_tool_usage()} + + assert usage_by_tool["tool_search"] == 1 + + session.close() + + +async def test_tool_usage_tracking_uses_bare_name_for_deferred_top_level_calls(agent: Agent): + """Deferred top-level tool calls should not retain synthetic namespace aliases.""" + session_id = "tools_deferred_top_level_test" + session = AdvancedSQLiteSession(session_id=session_id, create_tables=True) + + items: list[TResponseInputItem] = [ + {"role": "user", "content": "What is the weather?"}, + { + "type": "function_call", + "name": "get_weather", + "arguments": '{"city": "Tokyo"}', + "call_id": "weather-call", + }, + { + "type": "function_call", + "name": "get_weather", + "namespace": "get_weather", + "arguments": '{"city": "Osaka"}', + "call_id": "weather-call-2", + }, + ] + await session.add_items(items) + + usage_by_tool = {tool_name: count for tool_name, count, _turn in await session.get_tool_usage()} + + assert usage_by_tool["get_weather"] == 2 + assert "get_weather.get_weather" not in usage_by_tool + + session.close() + + +async def test_tool_usage_tracking_collapses_reserved_same_name_namespace_shape( + agent: Agent, +): + """Reserved same-name namespace wire shapes should collapse to the bare tool name.""" + session_id = "tools_deferred_top_level_namespace_test" + session = AdvancedSQLiteSession(session_id=session_id, create_tables=True) + + items: list[TResponseInputItem] = [ + {"role": "user", "content": "What is the weather?"}, + { + "type": "function_call", + "name": "lookup_account", + "namespace": "lookup_account", + "arguments": '{"account_id": "acct_123"}', + "call_id": "lookup-call", + }, + ] + await session.add_items(items) + + usage_by_tool = {tool_name: count for tool_name, count, _turn in await session.get_tool_usage()} + + assert usage_by_tool["lookup_account"] == 1 + assert "lookup_account.lookup_account" not in usage_by_tool + + session.close() + + async def test_branching_functionality(agent: Agent): """Test branching functionality - create, switch, and delete branches.""" session_id = "branching_test" diff --git a/tests/extensions/test_tool_output_trimmer.py b/tests/extensions/test_tool_output_trimmer.py index 02a993d8..f8663468 100644 --- a/tests/extensions/test_tool_output_trimmer.py +++ b/tests/extensions/test_tool_output_trimmer.py @@ -5,7 +5,8 @@ large tool outputs from older conversation turns. from __future__ import annotations import copy -from typing import Any +import json +from typing import Any, cast from unittest.mock import MagicMock import pytest @@ -26,8 +27,11 @@ def _assistant(text: str = "response") -> dict[str, Any]: return {"role": "assistant", "content": text} -def _func_call(call_id: str, name: str) -> dict[str, Any]: - return {"type": "function_call", "call_id": call_id, "name": name, "arguments": "{}"} +def _func_call(call_id: str, name: str, *, namespace: str | None = None) -> dict[str, Any]: + item = {"type": "function_call", "call_id": call_id, "name": name, "arguments": "{}"} + if namespace is not None: + item["namespace"] = namespace + return item def _func_output(call_id: str, output: str) -> dict[str, Any]: @@ -229,6 +233,134 @@ class TestTrimming: # resolve_entity output preserved assert _output(result, 4) == large + def test_respects_qualified_tool_names_allowlist(self) -> None: + """Qualified allowlist entries should match namespaced function tools.""" + large = "x" * 1000 + items = [ + _user("q1"), + _func_call("c1", "lookup_account", namespace="billing"), + _func_output("c1", large), + _assistant("a1"), + _user("q2"), + _assistant("a2"), + _user("q3"), + _assistant("a3"), + ] + trimmer = ToolOutputTrimmer(trimmable_tools=frozenset({"billing.lookup_account"})) + result = trimmer(_make_data(items)) + assert "[Trimmed:" in _output(result, 2) + assert "billing.lookup_account" in _output(result, 2) + + def test_namespaced_tools_still_match_bare_allowlist_entries(self) -> None: + """Bare allowlist entries remain valid for namespaced tools.""" + large = "x" * 1000 + items = [ + _user("q1"), + _func_call("c1", "lookup_account", namespace="billing"), + _func_output("c1", large), + _assistant("a1"), + _user("q2"), + _assistant("a2"), + _user("q3"), + _assistant("a3"), + ] + trimmer = ToolOutputTrimmer(trimmable_tools=frozenset({"lookup_account"})) + result = trimmer(_make_data(items)) + assert "[Trimmed:" in _output(result, 2) + assert "billing.lookup_account" in _output(result, 2) + + def test_synthetic_same_name_namespace_uses_bare_display_name(self) -> None: + """Deferred synthetic namespaces should not display as `name.name`.""" + large = "x" * 1000 + items = [ + _user("q1"), + _func_call("c1", "get_weather", namespace="get_weather"), + _func_output("c1", large), + _assistant("a1"), + _user("q2"), + _assistant("a2"), + _user("q3"), + _assistant("a3"), + ] + trimmer = ToolOutputTrimmer(trimmable_tools=frozenset({"get_weather"})) + result = trimmer(_make_data(items)) + assert "[Trimmed:" in _output(result, 2) + assert "get_weather.get_weather" not in _output(result, 2) + assert "get_weather" in _output(result, 2) + + def test_trims_tool_search_output_tool_definitions(self) -> None: + """Large tool_search_output tool definitions should be structurally trimmed.""" + verbose_schema = { + "type": "object", + "description": "schema " * 200, + "properties": { + "customer_id": { + "type": "string", + "description": "customer id " * 200, + "default": "cust_123", + } + }, + "required": ["customer_id"], + } + items = [ + _user("q1"), + {"type": "tool_search_call", "call_id": "ts1", "arguments": {"query": "profile"}}, + { + "type": "tool_search_output", + "call_id": "ts1", + "tools": [ + { + "type": "function", + "name": "lookup_account", + "description": "tool description " * 200, + "parameters": verbose_schema, + } + ], + }, + _assistant("a1"), + _user("q2"), + _assistant("a2"), + _user("q3"), + _assistant("a3"), + ] + + original_len = len(json.dumps(items[2]["tools"], sort_keys=True)) + trimmer = ToolOutputTrimmer(max_output_chars=400, preview_chars=60) + result = trimmer(_make_data(items)) + trimmed_item_dict = cast(dict[str, Any], result.input[2]) + + assert trimmed_item_dict["type"] == "tool_search_output" + trimmed_tools = list(trimmed_item_dict["tools"]) + assert trimmed_tools[0]["name"] == "lookup_account" + assert "description" not in trimmed_tools[0]["parameters"] + assert trimmed_tools[0]["parameters"]["properties"]["customer_id"]["default"] == "cust_123" + assert len(json.dumps(trimmed_tools, sort_keys=True)) < original_len + + def test_trims_legacy_tool_search_output_results(self) -> None: + """Legacy tool_search_output snapshots with free-text results should still trim.""" + large = "x" * 2000 + items = [ + _user("q1"), + {"type": "tool_search_call", "call_id": "ts1", "arguments": {"query": "profile"}}, + { + "type": "tool_search_output", + "call_id": "ts1", + "results": [{"text": large}], + }, + _assistant("a1"), + _user("q2"), + _assistant("a2"), + _user("q3"), + _assistant("a3"), + ] + + trimmer = ToolOutputTrimmer(max_output_chars=400, preview_chars=80) + result = trimmer(_make_data(items)) + trimmed_item = cast(dict[str, Any], result.input[2]) + + assert trimmed_item["type"] == "tool_search_output" + assert "[Trimmed: tool_search output" in trimmed_item["results"][0]["text"] + def test_trims_all_tools_when_allowlist_is_none(self) -> None: """When trimmable_tools is None, all tools are eligible.""" large = "x" * 1000 diff --git a/tests/fake_model.py b/tests/fake_model.py index 641e82c2..ed44d72d 100644 --- a/tests/fake_model.py +++ b/tests/fake_model.py @@ -281,7 +281,7 @@ class FakeModel(Model): sequence_number += 1 elif isinstance(output_item, ResponseOutputMessage): - for content_index, content_part in enumerate(output_item.content): + for content_index, content_part in enumerate(output_item.content or []): if isinstance(content_part, ResponseOutputText): yield ResponseContentPartAddedEvent( type="response.content_part.added", diff --git a/tests/realtime/test_openai_realtime.py b/tests/realtime/test_openai_realtime.py index 64ff8aa5..06dd2108 100644 --- a/tests/realtime/test_openai_realtime.py +++ b/tests/realtime/test_openai_realtime.py @@ -8,7 +8,7 @@ from unittest.mock import AsyncMock, Mock, patch import pytest import websockets -from agents import Agent +from agents import Agent, function_tool from agents.exceptions import UserError from agents.handoffs import handoff from agents.realtime.model import RealtimeModelConfig @@ -796,6 +796,15 @@ class TestSendEventAndConfig(TestOpenAIRealtimeWebSocketModel): assert cfg.audio.output.format is not None assert cfg.audio.output.format.type == "audio/pcm" + def test_session_config_allows_tool_search_as_named_function_tool_choice(self, model): + cfg = model._get_session_config( + { + "tool_choice": "tool_search", + "tools": [function_tool(lambda city: city, name_override="tool_search")], + } + ) + assert cfg.tool_choice == "tool_search" + def test_session_config_preserves_sip_audio_formats(self, model): model._call_id = "call-123" settings = { diff --git a/tests/realtime/test_openai_realtime_conversions.py b/tests/realtime/test_openai_realtime_conversions.py index 2597b7dc..2d80a502 100644 --- a/tests/realtime/test_openai_realtime_conversions.py +++ b/tests/realtime/test_openai_realtime_conversions.py @@ -8,7 +8,7 @@ from openai.types.realtime.realtime_tracing_config import ( TracingConfiguration, ) -from agents import Agent +from agents import Agent, function_tool, tool_namespace from agents.exceptions import UserError from agents.handoffs import handoff from agents.realtime.config import RealtimeModelTracingConfig @@ -101,3 +101,27 @@ def test_tools_to_session_tools_includes_handoffs(): m = OpenAIRealtimeWebSocketModel() out = m._tools_to_session_tools([], [h]) assert out[0].name is not None and out[0].name.startswith("transfer_to_") + + +def test_tools_to_session_tools_rejects_namespaced_function_tools(): + tool = tool_namespace( + name="crm", + description="CRM tools", + tools=[function_tool(lambda customer_id: customer_id, name_override="lookup_account")], + )[0] + m = OpenAIRealtimeWebSocketModel() + + with pytest.raises(UserError, match="tool_namespace\\(\\)"): + m._tools_to_session_tools([tool], []) + + +def test_tools_to_session_tools_rejects_deferred_function_tools(): + tool = function_tool( + lambda customer_id: customer_id, + name_override="lookup_account", + defer_loading=True, + ) + m = OpenAIRealtimeWebSocketModel() + + with pytest.raises(UserError, match="defer_loading=True"): + m._tools_to_session_tools([tool], []) diff --git a/tests/test_agent_as_tool.py b/tests/test_agent_as_tool.py index 34a882db..a09dccc3 100644 --- a/tests/test_agent_as_tool.py +++ b/tests/test_agent_as_tool.py @@ -27,6 +27,7 @@ from agents import ( SessionSettings, ToolApprovalItem, TResponseInputItem, + tool_namespace, ) from agents.agent_tool_input import StructuredToolInputBuilderOptions from agents.agent_tool_state import ( @@ -34,6 +35,7 @@ from agents.agent_tool_state import ( record_agent_tool_run_result, set_agent_tool_state_scope, ) +from agents.run_context import _ApprovalRecord from agents.run_state import _build_agent_map from agents.stream_events import AgentUpdatedStreamEvent, RawResponsesStreamEvent from agents.tool_context import ToolContext @@ -1080,6 +1082,212 @@ async def test_agent_as_tool_rejected_nested_approval_resumes_run( assert run_inputs == [resume_state] +@pytest.mark.asyncio +async def test_agent_as_tool_namespaced_nested_always_approve_stays_permanent( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Permanent namespaced approvals should carry into nested resumed runs.""" + + agent = Agent(name="outer") + tool_call = make_function_tool_call( + "outer_tool", + call_id="outer-1", + arguments='{"input": "hello"}', + ) + tool_context = ToolContext( + context=None, + tool_name="outer_tool", + tool_call_id="outer-1", + tool_arguments=tool_call.arguments, + tool_call=tool_call, + ) + + inner_call = cast( + Any, + { + "type": "function_call", + "name": "lookup_account", + "namespace": "billing", + "call_id": "inner-1", + "arguments": "{}", + }, + ) + approval_item = ToolApprovalItem(agent=agent, raw_item=inner_call) + + class DummyState: + def __init__(self, nested_context: ToolContext) -> None: + self._context = nested_context + + class DummyPendingResult: + def __init__(self) -> None: + self.interruptions = [approval_item] + self.final_output = None + + def to_state(self) -> DummyState: + return resume_state + + class DummyResumedResult: + def __init__(self) -> None: + self.interruptions: list[ToolApprovalItem] = [] + self.final_output = "approved" + + nested_context = ToolContext( + context=None, + tool_name=tool_call.name, + tool_call_id=tool_call.call_id, + tool_arguments=tool_call.arguments, + tool_call=tool_call, + ) + resume_state = DummyState(nested_context) + pending_result = DummyPendingResult() + record_agent_tool_run_result(tool_call, cast(Any, pending_result)) + tool_context.approve_tool(approval_item, always_approve=True) + + resumed_result = DummyResumedResult() + run_inputs: list[Any] = [] + + async def run_resume(cls, /, starting_agent, input, **kwargs) -> DummyResumedResult: + run_inputs.append(input) + assert input is resume_state + assert input._context is not None + assert input._context.is_tool_approved("billing.lookup_account", "inner-1") is True + assert input._context.is_tool_approved("billing.lookup_account", "inner-2") is True + return resumed_result + + monkeypatch.setattr(Runner, "run", classmethod(run_resume)) + + tool = agent.as_tool( + tool_name="outer_tool", + tool_description="Outer agent tool", + is_enabled=True, + ) + + output = await tool.on_invoke_tool(tool_context, tool_call.arguments) + + assert output == "approved" + assert run_inputs == [resume_state] + + +@pytest.mark.asyncio +async def test_agent_as_tool_deferred_same_name_legacy_nested_always_approve_stays_permanent( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Legacy deferred approval keys should remain permanent in nested resumed runs.""" + + agent = Agent(name="outer") + tool_call = make_function_tool_call( + "outer_tool", + call_id="outer-1", + arguments='{"input": "hello"}', + ) + tool_context = ToolContext( + context=None, + tool_name="outer_tool", + tool_call_id="outer-1", + tool_arguments=tool_call.arguments, + tool_call=tool_call, + ) + + inner_call = cast( + Any, + { + "type": "function_call", + "name": "get_weather", + "namespace": "get_weather", + "call_id": "inner-1", + "arguments": "{}", + }, + ) + approval_item = ToolApprovalItem( + agent=agent, + raw_item=inner_call, + tool_lookup_key=("deferred_top_level", "get_weather"), + ) + + class DummyState: + def __init__(self, nested_context: ToolContext) -> None: + self._context = nested_context + + class DummyPendingResult: + def __init__(self) -> None: + self.interruptions = [approval_item] + self.final_output = None + + def to_state(self) -> DummyState: + return resume_state + + class DummyResumedResult: + def __init__(self) -> None: + self.interruptions: list[ToolApprovalItem] = [] + self.final_output = "approved" + + nested_context = ToolContext( + context=None, + tool_name=tool_call.name, + tool_call_id=tool_call.call_id, + tool_arguments=tool_call.arguments, + tool_call=tool_call, + ) + tool_context._approvals["get_weather.get_weather"] = _ApprovalRecord( + approved=True, + rejected=[], + ) + resume_state = DummyState(nested_context) + pending_result = DummyPendingResult() + record_agent_tool_run_result(tool_call, cast(Any, pending_result)) + + resumed_result = DummyResumedResult() + run_inputs: list[Any] = [] + + async def run_resume(cls, /, starting_agent, input, **kwargs) -> DummyResumedResult: + run_inputs.append(input) + assert input is resume_state + assert input._context is not None + followup_item = ToolApprovalItem( + agent=agent, + raw_item={ + "type": "function_call", + "name": "get_weather", + "namespace": "get_weather", + "call_id": "inner-2", + "arguments": "{}", + }, + tool_lookup_key=("deferred_top_level", "get_weather"), + ) + assert ( + input._context.get_approval_status( + "get_weather", + "inner-1", + tool_namespace="get_weather", + existing_pending=approval_item, + ) + is True + ) + assert ( + input._context.get_approval_status( + "get_weather", + "inner-2", + tool_namespace="get_weather", + existing_pending=followup_item, + ) + is True + ) + return resumed_result + + monkeypatch.setattr(Runner, "run", classmethod(run_resume)) + + tool = agent.as_tool( + tool_name="outer_tool", + tool_description="Outer agent tool", + is_enabled=True, + ) + + output = await tool.on_invoke_tool(tool_context, tool_call.arguments) + + assert output == "approved" + assert run_inputs == [resume_state] + + @pytest.mark.asyncio async def test_agent_as_tool_preserves_scope_for_nested_tool_context( monkeypatch: pytest.MonkeyPatch, @@ -1116,6 +1324,53 @@ async def test_agent_as_tool_preserves_scope_for_nested_tool_context( assert output == "ok" +@pytest.mark.asyncio +async def test_agent_as_tool_preserves_namespace_for_nested_tool_context( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Nested ToolContext instances should preserve the parent tool namespace.""" + + class DummyResult: + def __init__(self) -> None: + self.final_output = "ok" + self.interruptions: list[ToolApprovalItem] = [] + + agent = Agent(name="namespace-agent") + tool = tool_namespace( + name="billing", + description="Billing tools", + tools=[agent.as_tool(tool_name="lookup_account", tool_description="Lookup account")], + )[0] + + async def fake_run(cls, /, starting_agent, input, **kwargs) -> DummyResult: + del cls, starting_agent, input + nested_context = kwargs.get("context") + assert isinstance(nested_context, ToolContext) + assert nested_context.tool_namespace == "billing" + assert nested_context.qualified_tool_name == "billing.lookup_account" + return DummyResult() + + monkeypatch.setattr(Runner, "run", classmethod(fake_run)) + + tool_call = make_function_tool_call( + "lookup_account", + call_id="lookup-call", + arguments='{"input":"hello"}', + namespace="billing", + ) + tool_context = ToolContext( + context=None, + tool_name="lookup_account", + tool_call_id="lookup-call", + tool_arguments=tool_call.arguments, + tool_call=tool_call, + tool_namespace="billing", + ) + + output = await tool.on_invoke_tool(tool_context, tool_call.arguments) + assert output == "ok" + + @pytest.mark.asyncio async def test_agent_as_tool_preserves_scope_for_nested_run_context_wrapper( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_agent_runner.py b/tests/test_agent_runner.py index b0057753..a6ee9eed 100644 --- a/tests/test_agent_runner.py +++ b/tests/test_agent_runner.py @@ -35,6 +35,7 @@ from agents import ( ToolTimeoutError, UserError, handoff, + tool_namespace, ) from agents.agent import ToolsToFinalOutputResult from agents.computer import Computer @@ -301,6 +302,126 @@ def test_normalize_resumed_input_drops_orphan_function_calls(): assert "paired_call" in call_ids +def test_normalize_resumed_input_drops_orphan_tool_search_calls(): + raw_input: list[TResponseInputItem] = [ + cast( + TResponseInputItem, + { + "type": "tool_search_call", + "call_id": "orphan_search", + "arguments": {"query": "orphan"}, + "execution": "server", + "status": "completed", + }, + ), + cast( + TResponseInputItem, + { + "type": "tool_search_call", + "call_id": "paired_search", + "arguments": {"query": "paired"}, + "execution": "server", + "status": "completed", + }, + ), + cast( + TResponseInputItem, + { + "type": "tool_search_output", + "call_id": "paired_search", + "execution": "server", + "status": "completed", + "tools": [], + }, + ), + ] + + normalized = normalize_resumed_input(raw_input) + assert isinstance(normalized, list) + call_ids = [ + cast(dict[str, Any], item).get("call_id") + for item in normalized + if isinstance(item, dict) and item.get("type") == "tool_search_call" + ] + assert "orphan_search" not in call_ids + assert "paired_search" in call_ids + + +def test_normalize_resumed_input_preserves_hosted_tool_search_pair_without_call_ids(): + raw_input: list[TResponseInputItem] = [ + cast( + TResponseInputItem, + { + "type": "tool_search_call", + "call_id": None, + "arguments": {"query": "paired"}, + "execution": "server", + "status": "completed", + }, + ), + cast( + TResponseInputItem, + { + "type": "tool_search_output", + "call_id": None, + "execution": "server", + "status": "completed", + "tools": [], + }, + ), + ] + + normalized = normalize_resumed_input(raw_input) + assert isinstance(normalized, list) + assert [cast(dict[str, Any], item)["type"] for item in normalized] == [ + "tool_search_call", + "tool_search_output", + ] + + +def test_normalize_resumed_input_matches_latest_anonymous_tool_search_call(): + raw_input: list[TResponseInputItem] = [ + cast( + TResponseInputItem, + { + "type": "tool_search_call", + "call_id": None, + "arguments": {"query": "orphan"}, + "execution": "server", + "status": "completed", + }, + ), + cast( + TResponseInputItem, + { + "type": "tool_search_call", + "call_id": None, + "arguments": {"query": "paired"}, + "execution": "server", + "status": "completed", + }, + ), + cast( + TResponseInputItem, + { + "type": "tool_search_output", + "call_id": None, + "execution": "server", + "status": "completed", + "tools": [], + }, + ), + ] + + normalized = normalize_resumed_input(raw_input) + assert isinstance(normalized, list) + assert [cast(dict[str, Any], item)["type"] for item in normalized] == [ + "tool_search_call", + "tool_search_output", + ] + assert cast(dict[str, Any], normalized[0])["arguments"] == {"query": "paired"} + + def testnormalize_input_items_for_api_preserves_provider_data(): items: list[TResponseInputItem] = [ cast( @@ -3213,6 +3334,38 @@ async def test_execute_approved_tools_with_rejected_tool_uses_run_level_formatte assert generated_items[0].output == "run-level test_tool denied (2)" +@pytest.mark.asyncio +async def test_execute_approved_tools_with_rejected_deferred_tool_uses_display_name(): + """Rejected deferred tools should collapse synthetic namespaces in formatter output.""" + + async def get_weather() -> str: + return "sunny" + + tool = function_tool(get_weather, name_override="get_weather", defer_loading=True) + _, agent = make_model_and_agent(tools=[tool]) + + tool_call = get_function_tool_call("get_weather", "{}", namespace="get_weather") + assert isinstance(tool_call, ResponseFunctionToolCall) + approval_item = ToolApprovalItem( + agent=agent, + raw_item=tool_call, + tool_name="get_weather", + tool_namespace="get_weather", + ) + + generated_items = await run_execute_approved_tools( + agent=agent, + approval_item=approval_item, + approve=False, + run_config=RunConfig( + tool_error_formatter=lambda args: f"run-level {args.tool_name} denied ({args.call_id})" + ), + ) + + assert len(generated_items) == 1 + assert generated_items[0].output == "run-level get_weather denied (2)" + + @pytest.mark.asyncio async def test_execute_approved_tools_with_rejected_tool_formatter_none_uses_default(): """Rejected tools should use default message when formatter returns None.""" @@ -3291,6 +3444,202 @@ async def test_execute_approved_tools_with_missing_tool(): assert "not found" in generated_items[0].output.lower() +@pytest.mark.asyncio +async def test_execute_approved_tools_does_not_resolve_explicit_namespaced_tool_by_bare_name(): + crm_calls: list[str] = [] + billing_calls: list[str] = [] + + async def crm_lookup() -> str: + crm_calls.append("crm") + return "crm" + + async def billing_lookup() -> str: + billing_calls.append("billing") + return "billing" + + crm_tool = tool_namespace( + name="crm", + description="CRM tools", + tools=[function_tool(crm_lookup, name_override="lookup_account")], + )[0] + billing_tool = tool_namespace( + name="billing", + description="Billing tools", + tools=[function_tool(billing_lookup, name_override="lookup_account")], + )[0] + agent = Agent(name="TestAgent", model=FakeModel(), tools=[crm_tool, billing_tool]) + + tool_call = get_function_tool_call("lookup_account", "{}", call_id="call-ambiguous") + assert isinstance(tool_call, ResponseFunctionToolCall) + approval_item = ToolApprovalItem(agent=agent, raw_item=tool_call) + + generated_items = await run_execute_approved_tools( + agent=agent, + approval_item=approval_item, + approve=True, + ) + + assert len(generated_items) == 1 + assert isinstance(generated_items[0], ToolCallOutputItem) + assert "not found" in generated_items[0].output.lower() + assert crm_calls == [] + assert billing_calls == [] + + +@pytest.mark.asyncio +async def test_execute_approved_tools_does_not_fallback_from_namespaced_approval_to_bare_tool(): + bare_calls: list[str] = [] + + async def bare_lookup() -> str: + bare_calls.append("bare") + return "bare" + + bare_tool = function_tool(bare_lookup, name_override="lookup_account") + agent = Agent(name="TestAgent", model=FakeModel(), tools=[bare_tool]) + + tool_call = get_function_tool_call( + "lookup_account", + "{}", + call_id="call-billing", + namespace="billing", + ) + assert isinstance(tool_call, ResponseFunctionToolCall) + approval_item = ToolApprovalItem(agent=agent, raw_item=tool_call) + + generated_items = await run_execute_approved_tools( + agent=agent, + approval_item=approval_item, + approve=True, + ) + + assert len(generated_items) == 1 + assert isinstance(generated_items[0], ToolCallOutputItem) + assert "billing.lookup_account" in generated_items[0].output + assert "not found" in generated_items[0].output.lower() + assert bare_calls == [] + + +@pytest.mark.asyncio +async def test_execute_approved_tools_prefers_visible_top_level_function_over_deferred_same_name_tool( # noqa: E501 +): + visible_calls: list[str] = [] + deferred_calls: list[str] = [] + + async def visible_lookup() -> str: + visible_calls.append("visible") + return "visible" + + async def deferred_lookup() -> str: + deferred_calls.append("deferred") + return "deferred" + + visible_tool = function_tool(visible_lookup, name_override="lookup_account") + deferred_tool = function_tool( + deferred_lookup, + name_override="lookup_account", + defer_loading=True, + ) + agent = Agent(name="TestAgent", model=FakeModel(), tools=[visible_tool, deferred_tool]) + + tool_call = get_function_tool_call("lookup_account", "{}", call_id="call-visible") + assert isinstance(tool_call, ResponseFunctionToolCall) + approval_item = ToolApprovalItem(agent=agent, raw_item=tool_call) + + generated_items = await run_execute_approved_tools( + agent=agent, + approval_item=approval_item, + approve=True, + ) + + assert len(generated_items) == 1 + assert isinstance(generated_items[0], ToolCallOutputItem) + assert generated_items[0].output == "visible" + assert visible_calls == ["visible"] + assert deferred_calls == [] + + +@pytest.mark.asyncio +async def test_execute_approved_tools_uses_internal_lookup_key_for_deferred_top_level_calls() -> ( + None +): + visible_calls: list[str] = [] + deferred_calls: list[str] = [] + + async def visible_lookup() -> str: + visible_calls.append("visible") + return "visible" + + async def deferred_lookup() -> str: + deferred_calls.append("deferred") + return "deferred" + + visible_tool = function_tool( + visible_lookup, + name_override="lookup_account.lookup_account", + ) + deferred_tool = function_tool( + deferred_lookup, + name_override="lookup_account", + defer_loading=True, + ) + agent = Agent(name="TestAgent", model=FakeModel(), tools=[visible_tool, deferred_tool]) + + tool_call = get_function_tool_call( + "lookup_account", + "{}", + call_id="call-deferred", + namespace="lookup_account", + ) + assert isinstance(tool_call, ResponseFunctionToolCall) + approval_item = ToolApprovalItem(agent=agent, raw_item=tool_call) + + generated_items = await run_execute_approved_tools( + agent=agent, + approval_item=approval_item, + approve=True, + ) + + assert len(generated_items) == 1 + assert isinstance(generated_items[0], ToolCallOutputItem) + assert generated_items[0].output == "deferred" + assert visible_calls == [] + assert deferred_calls == ["deferred"] + + +@pytest.mark.asyncio +async def test_execute_approved_tools_uses_last_duplicate_top_level_function(): + first_calls: list[str] = [] + second_calls: list[str] = [] + + async def first_lookup() -> str: + first_calls.append("first") + return "first" + + async def second_lookup() -> str: + second_calls.append("second") + return "second" + + first_tool = function_tool(first_lookup, name_override="lookup_account") + second_tool = function_tool(second_lookup, name_override="lookup_account") + agent = Agent(name="TestAgent", model=FakeModel(), tools=[first_tool, second_tool]) + + tool_call = get_function_tool_call("lookup_account", "{}", call_id="call-shadow") + assert isinstance(tool_call, ResponseFunctionToolCall) + approval_item = ToolApprovalItem(agent=agent, raw_item=tool_call) + + generated_items = await run_execute_approved_tools( + agent=agent, + approval_item=approval_item, + approve=True, + ) + + assert len(generated_items) == 1 + assert isinstance(generated_items[0], ToolCallOutputItem) + assert generated_items[0].output == "second" + assert first_calls == [] + assert second_calls == ["second"] + + @pytest.mark.asyncio async def test_execute_approved_tools_with_missing_call_id(): """Test _execute_approved_tools handles tool approvals without call IDs.""" diff --git a/tests/test_extension_filters.py b/tests/test_extension_filters.py index 65262d77..0f1ee1bd 100644 --- a/tests/test_extension_filters.py +++ b/tests/test_extension_filters.py @@ -22,6 +22,8 @@ from agents.items import ( MessageOutputItem, ReasoningItem, ToolCallOutputItem, + ToolSearchCallItem, + ToolSearchOutputItem, TResponseInputItem, ) @@ -58,6 +60,21 @@ def _get_function_result_input_item(content: str) -> TResponseInputItem: } +def _get_tool_search_call_input_item() -> dict[str, Any]: + return { + "type": "tool_search_call", + "arguments": {"paths": ["crm"], "query": "profile"}, + "status": "completed", + } + + +def _get_tool_search_result_input_item() -> dict[str, Any]: + return { + "type": "tool_search_output", + "tools": [{"type": "tool_reference", "namespace": "crm", "function_name": "lookup"}], + } + + def _get_message_output_run_item(content: str) -> MessageOutputItem: return MessageOutputItem( agent=fake_agent(), @@ -85,6 +102,14 @@ def _get_tool_output_run_item(content: str) -> ToolCallOutputItem: ) +def _get_tool_search_call_run_item() -> ToolSearchCallItem: + return ToolSearchCallItem(agent=fake_agent(), raw_item=_get_tool_search_call_input_item()) + + +def _get_tool_search_output_run_item() -> ToolSearchOutputItem: + return ToolSearchOutputItem(agent=fake_agent(), raw_item=_get_tool_search_result_input_item()) + + def _get_handoff_input_item(content: str) -> TResponseInputItem: return { "call_id": "1", @@ -239,6 +264,31 @@ def test_removes_tools_from_new_items_and_history(): assert len(filtered_data.new_items) == 1 +def test_removes_tool_search_from_history_and_items() -> None: + handoff_input_data = handoff_data( + input_history=( + _get_message_input_item("Hello1"), + cast(TResponseInputItem, _get_tool_search_call_input_item()), + cast(TResponseInputItem, _get_tool_search_result_input_item()), + _get_message_input_item("Hello2"), + ), + pre_handoff_items=( + _get_tool_search_call_run_item(), + _get_message_output_run_item("123"), + ), + new_items=( + _get_tool_search_output_run_item(), + _get_message_output_run_item("World"), + ), + ) + + filtered_data = remove_all_tools(handoff_input_data) + + assert len(filtered_data.input_history) == 2 + assert len(filtered_data.pre_handoff_items) == 1 + assert len(filtered_data.new_items) == 1 + + def test_removes_handoffs_from_history(): handoff_input_data = handoff_data( input_history=( diff --git a/tests/test_function_tool.py b/tests/test_function_tool.py index cda0a99f..11eb5d7c 100644 --- a/tests/test_function_tool.py +++ b/tests/test_function_tool.py @@ -15,14 +15,18 @@ from agents import ( Agent, AgentBase, FunctionTool, + HostedMCPTool, ModelBehaviorError, RunContextWrapper, ToolGuardrailFunctionOutput, ToolInputGuardrailData, ToolOutputGuardrailData, + ToolSearchTool, ToolTimeoutError, + UserError, function_tool, tool_input_guardrail, + tool_namespace, tool_output_guardrail, ) from agents.tool import default_tool_error_function @@ -33,6 +37,60 @@ def argless_function() -> str: return "ok" +def test_tool_namespace_copies_tools_with_metadata() -> None: + tool = function_tool(argless_function) + + namespaced_tools = tool_namespace( + name="crm", + description="CRM tools", + tools=[tool], + ) + + assert len(namespaced_tools) == 1 + assert namespaced_tools[0] is not tool + assert namespaced_tools[0]._tool_namespace == "crm" + assert namespaced_tools[0]._tool_namespace_description == "CRM tools" + assert namespaced_tools[0].qualified_name == "crm.argless_function" + assert tool._tool_namespace is None + assert tool.qualified_name == "argless_function" + + +def test_tool_namespace_requires_keyword_arguments() -> None: + tool = function_tool(argless_function) + + with pytest.raises(TypeError): + tool_namespace("crm", "CRM tools", [tool]) # type: ignore[misc] + + +def test_tool_namespace_requires_non_empty_description() -> None: + tool = function_tool(argless_function) + + with pytest.raises(UserError, match="non-empty description"): + tool_namespace( + name="crm", + description=None, + tools=[tool], + ) + + with pytest.raises(UserError, match="non-empty description"): + tool_namespace( + name="crm", + description=" ", + tools=[tool], + ) + + +def test_tool_namespace_rejects_reserved_same_name_shape() -> None: + tool = function_tool(argless_function, name_override="lookup_account") + + with pytest.raises(UserError, match="synthetic namespace `lookup_account.lookup_account`"): + tool_namespace( + name="lookup_account", + description="Same-name namespace", + tools=[tool], + ) + + @pytest.mark.asyncio async def test_argless_function(): tool = function_tool(argless_function) @@ -437,6 +495,67 @@ async def test_is_enabled_bool_and_callable(): assert tools_with_ctx[1].name == "third_tool" +@pytest.mark.asyncio +async def test_get_all_tools_preserves_explicit_tool_search_when_deferred_tools_are_disabled(): + async def deferred_enabled(ctx: RunContextWrapper[BoolCtx], agent: AgentBase) -> bool: + return ctx.context.enable_tools + + @function_tool(defer_loading=True, is_enabled=deferred_enabled) + def deferred_lookup() -> str: + return "loaded" + + agent = Agent(name="t", tools=[deferred_lookup, ToolSearchTool()]) + + tools_with_disabled_context = await agent.get_all_tools( + RunContextWrapper(BoolCtx(enable_tools=False)) + ) + assert len(tools_with_disabled_context) == 1 + assert isinstance(tools_with_disabled_context[0], ToolSearchTool) + + tools_with_enabled_context = await agent.get_all_tools( + RunContextWrapper(BoolCtx(enable_tools=True)) + ) + assert tools_with_enabled_context[0] is deferred_lookup + assert isinstance(tools_with_enabled_context[1], ToolSearchTool) + + +@pytest.mark.asyncio +async def test_get_all_tools_keeps_tool_search_for_namespace_only_tools(): + namespaced_lookup = tool_namespace( + name="crm", + description="CRM tools", + tools=[function_tool(lambda account_id: account_id, name_override="lookup_account")], + )[0] + + agent = Agent(name="t", tools=[namespaced_lookup, ToolSearchTool()]) + + tools = await agent.get_all_tools(RunContextWrapper(BoolCtx(enable_tools=False))) + + assert tools[0] is namespaced_lookup + assert isinstance(tools[1], ToolSearchTool) + + +@pytest.mark.asyncio +async def test_get_all_tools_keeps_tool_search_for_deferred_hosted_mcp() -> None: + hosted_mcp = HostedMCPTool( + tool_config=cast( + Any, + { + "type": "mcp", + "server_label": "crm_server", + "server_url": "https://example.com/mcp", + "defer_loading": True, + }, + ) + ) + agent = Agent(name="t", tools=[hosted_mcp, ToolSearchTool()]) + + tools = await agent.get_all_tools(RunContextWrapper(BoolCtx(enable_tools=False))) + + assert tools[0] is hosted_mcp + assert isinstance(tools[1], ToolSearchTool) + + @pytest.mark.asyncio async def test_async_failure_error_function_is_awaited() -> None: async def failure_handler(ctx: RunContextWrapper[Any], exc: Exception) -> str: diff --git a/tests/test_function_tool_decorator.py b/tests/test_function_tool_decorator.py index 6539d1e1..4bc219d0 100644 --- a/tests/test_function_tool_decorator.py +++ b/tests/test_function_tool_decorator.py @@ -149,6 +149,15 @@ async def test_no_error_on_invalid_json_async(): assert result == "error_ModelBehaviorError" +@function_tool(defer_loading=True) +def deferred_lookup(customer_id: str) -> str: + return customer_id + + +def test_function_tool_defer_loading(): + assert deferred_lookup.defer_loading is True + + @function_tool(strict_mode=False) def optional_param_function(a: int, b: Optional[int] = None) -> str: if b is None: diff --git a/tests/test_hitl_error_scenarios.py b/tests/test_hitl_error_scenarios.py index c16cdb1c..d0de312d 100644 --- a/tests/test_hitl_error_scenarios.py +++ b/tests/test_hitl_error_scenarios.py @@ -24,6 +24,7 @@ from agents import ( ShellTool, ToolApprovalItem, function_tool, + tool_namespace, ) from agents.computer import Computer, Environment from agents.exceptions import ModelBehaviorError, UserError @@ -48,11 +49,13 @@ from agents.run_internal.run_loop import ( ToolRunShellCall, extract_tool_call_id, ) +from agents.run_internal.tool_planning import _select_function_tool_runs_for_resume from agents.run_state import RunState as RunStateClass from agents.tool import HostedMCPTool from agents.usage import Usage from .fake_model import FakeModel +from .mcp.helpers import FakeMCPServer from .test_responses import get_text_message from .utils.hitl import ( HITL_REJECTION_MSG, @@ -1026,6 +1029,142 @@ async def test_resume_rebuilds_function_runs_from_pending_approvals() -> None: assert "call-rebuild-1" in executed_call_ids, "Function should be rebuilt and executed" +@pytest.mark.asyncio +async def test_resume_rebuilds_deferred_function_runs_from_lookup_key_without_raw_namespace() -> ( + None +): + """Resumed approvals should use persisted lookup identity when raw namespace is missing.""" + + @function_tool(needs_approval=True, name_override="lookup_account") + async def visible_lookup_account(customer_id: str) -> str: + return f"visible:{customer_id}" + + @function_tool( + needs_approval=True, + name_override="lookup_account", + defer_loading=True, + ) + async def deferred_lookup_account(customer_id: str) -> str: + return f"deferred:{customer_id}" + + _model, agent = make_model_and_agent(tools=[visible_lookup_account, deferred_lookup_account]) + approval_item = ToolApprovalItem( + agent=agent, + raw_item={ + "type": "function_call", + "name": "lookup_account", + "call_id": "call-deferred-rebuild", + "arguments": '{"customer_id":"customer_1"}', + "status": "completed", + }, + tool_name="lookup_account", + tool_namespace="lookup_account", + tool_lookup_key=("deferred_top_level", "lookup_account"), + ) + context_wrapper = make_context_wrapper() + context_wrapper.approve_tool(approval_item) + + run_state = make_state_with_interruptions(agent, [approval_item]) + processed_response = ProcessedResponse( + new_items=[], + handoffs=[], + functions=[], + computer_actions=[], + local_shell_calls=[], + shell_calls=[], + apply_patch_calls=[], + tools_used=[], + mcp_approval_requests=[], + interruptions=[], + ) + + result = await run_loop.resolve_interrupted_turn( + agent=agent, + original_input="resume approvals", + original_pre_step_items=[], + new_response=ModelResponse(output=[], usage=Usage(), response_id="resp"), + processed_response=processed_response, + hooks=RunHooks(), + context_wrapper=context_wrapper, + run_config=RunConfig(), + run_state=run_state, + ) + + assert not isinstance(result.next_step, NextStepInterruption) + deferred_outputs = [ + item.output + for item in result.new_step_items + if isinstance(item, ToolCallOutputItem) and item.output == "deferred:customer_1" + ] + assert deferred_outputs == ["deferred:customer_1"] + + +@pytest.mark.asyncio +async def test_resume_honors_permanent_namespaced_function_approval_with_new_call_id() -> None: + @function_tool(needs_approval=True, name_override="lookup_account") + async def lookup_account(customer_id: str) -> str: + return customer_id + + namespaced_tool = tool_namespace( + name="billing", + description="Billing tools", + tools=[lookup_account], + )[0] + context_wrapper = make_context_wrapper() + approved_item = ToolApprovalItem( + agent=Agent(name="billing-agent"), + raw_item=make_function_tool_call( + "lookup_account", + call_id="approved-call", + arguments='{"customer_id":"customer_1"}', + namespace="billing", + ), + ) + context_wrapper.approve_tool(approved_item, always_approve=True) + + resumed_run = ToolRunFunction( + tool_call=make_function_tool_call( + "lookup_account", + call_id="resumed-call", + arguments='{"customer_id":"customer_2"}', + namespace="billing", + ), + function_tool=namespaced_tool, + ) + pending: list[ToolApprovalItem] = [] + rejections: list[str | None] = [] + + async def _needs_approval_checker(_run: ToolRunFunction) -> bool: + return True + + async def _record_rejection( + call_id: str | None, + _tool_call: ResponseFunctionToolCall, + _tool: Any, + ) -> None: + rejections.append(call_id) + + selected = await _select_function_tool_runs_for_resume( + [resumed_run], + approval_items_by_call_id={}, + context_wrapper=context_wrapper, + needs_approval_checker=_needs_approval_checker, + output_exists_checker=lambda _run: False, + record_rejection=_record_rejection, + pending_interruption_adder=pending.append, + pending_item_builder=lambda run: ToolApprovalItem( + agent=Agent(name="billing-agent"), + raw_item=run.tool_call, + tool_name=run.function_tool.name, + tool_namespace="billing", + ), + ) + + assert selected == [resumed_run] + assert pending == [] + assert rejections == [] + + @pytest.mark.asyncio async def test_resume_rebuilds_function_runs_from_object_approvals() -> None: """Rebuild should handle ResponseFunctionToolCall approval items.""" @@ -1082,6 +1221,124 @@ async def test_resume_rebuilds_function_runs_from_object_approvals() -> None: ) +@pytest.mark.asyncio +async def test_resume_rebuilds_local_mcp_function_runs_from_approvals() -> None: + """Rebuild should resolve approved MCP-backed function tools from agent.mcp_servers.""" + + server = FakeMCPServer(require_approval="always") + server.add_tool("add", {"type": "object", "properties": {}}) + + agent = Agent(name="TestAgent", mcp_servers=[server]) + tool_call = make_function_tool_call( + "add", + call_id="call-mcp-rebuild", + arguments='{"value": 1}', + ) + approval_item = ToolApprovalItem(agent=agent, raw_item=tool_call, tool_name="add") + context_wrapper = make_context_wrapper() + context_wrapper.approve_tool(approval_item) + + run_state = make_state_with_interruptions(agent, [approval_item]) + processed_response = ProcessedResponse( + new_items=[], + handoffs=[], + functions=[], + computer_actions=[], + local_shell_calls=[], + shell_calls=[], + apply_patch_calls=[], + tools_used=[], + mcp_approval_requests=[], + interruptions=[], + ) + + result = await run_loop.resolve_interrupted_turn( + agent=agent, + original_input="resume approvals", + original_pre_step_items=[], + new_response=ModelResponse(output=[], usage=Usage(), response_id="resp"), + processed_response=processed_response, + hooks=RunHooks(), + context_wrapper=context_wrapper, + run_config=RunConfig(), + run_state=run_state, + ) + + assert not isinstance(result.next_step, NextStepInterruption) + assert server.tool_calls == ["add"] + executed_call_ids = { + extract_tool_call_id(item.raw_item) + for item in result.new_step_items + if isinstance(item, ToolCallOutputItem) + } + assert "call-mcp-rebuild" in executed_call_ids, ( + "Approved local MCP tool should be rebuilt and executed from pending approvals" + ) + + +@pytest.mark.asyncio +async def test_resume_rebuild_rejections_use_deferred_tool_display_name() -> None: + """Resume-time rejection formatting should collapse synthetic deferred namespaces.""" + + async def get_weather() -> str: + return "sunny" + + _model, agent = make_model_and_agent( + tools=[function_tool(get_weather, name_override="get_weather", defer_loading=True)] + ) + context_wrapper = make_context_wrapper() + + rejected_call = make_function_tool_call( + "get_weather", + call_id="call-deferred-reject", + namespace="get_weather", + ) + assert isinstance(rejected_call, ResponseFunctionToolCall) + + rejected_item = ToolApprovalItem( + agent=agent, + raw_item=rejected_call, + tool_name="get_weather", + tool_namespace="get_weather", + ) + context_wrapper.reject_tool(rejected_item) + + run_state = make_state_with_interruptions(agent, [rejected_item]) + processed_response = ProcessedResponse( + new_items=[], + handoffs=[], + functions=[], + computer_actions=[], + local_shell_calls=[], + shell_calls=[], + apply_patch_calls=[], + tools_used=[], + mcp_approval_requests=[], + interruptions=[], + ) + + result = await run_loop.resolve_interrupted_turn( + agent=agent, + original_input="resume approvals", + original_pre_step_items=[], + new_response=ModelResponse(output=[], usage=Usage(), response_id="resp"), + processed_response=processed_response, + hooks=RunHooks(), + context_wrapper=context_wrapper, + run_config=RunConfig( + tool_error_formatter=lambda args: ( + f"resume-level {args.tool_name} denied ({args.call_id})" + ) + ), + run_state=run_state, + ) + + rejection_outputs = [ + item.output for item in result.new_step_items if isinstance(item, ToolCallOutputItem) + ] + assert rejection_outputs == ["resume-level get_weather denied (call-deferred-reject)"] + + @pytest.mark.asyncio async def test_rebuild_function_runs_handles_object_pending_and_rejections() -> None: """Rebuild should surface pending approvals and emit rejections for object approvals.""" diff --git a/tests/test_items_helpers.py b/tests/test_items_helpers.py index 3090ae0b..56fab8ec 100644 --- a/tests/test_items_helpers.py +++ b/tests/test_items_helpers.py @@ -29,6 +29,8 @@ from openai.types.responses.response_output_text import ResponseOutputText from openai.types.responses.response_output_text_param import ResponseOutputTextParam from openai.types.responses.response_reasoning_item import ResponseReasoningItem, Summary from openai.types.responses.response_reasoning_item_param import ResponseReasoningItemParam +from openai.types.responses.response_tool_search_call import ResponseToolSearchCall +from openai.types.responses.response_tool_search_output_item import ResponseToolSearchOutputItem from pydantic import TypeAdapter from agents import ( @@ -435,6 +437,52 @@ def test_to_input_items_for_reasoning() -> None: assert converted_dict == expected +def test_to_input_items_for_tool_search_strips_created_by() -> None: + """Tool-search output items should reuse the replay sanitizer before round-tripping.""" + tool_search_call = ResponseToolSearchCall( + id="tsc_123", + call_id="call_tsc_123", + arguments={"query": "profile"}, + execution="server", + status="completed", + type="tool_search_call", + created_by="server", + ) + tool_search_output = ResponseToolSearchOutputItem( + id="tso_123", + call_id="call_tsc_123", + execution="server", + status="completed", + tools=[], + type="tool_search_output", + created_by="server", + ) + + resp = ModelResponse( + output=[tool_search_call, tool_search_output], usage=Usage(), response_id=None + ) + input_items = resp.to_input_items() + + assert input_items == [ + { + "id": "tsc_123", + "call_id": "call_tsc_123", + "arguments": {"query": "profile"}, + "execution": "server", + "status": "completed", + "type": "tool_search_call", + }, + { + "id": "tso_123", + "call_id": "call_tsc_123", + "execution": "server", + "status": "completed", + "tools": [], + "type": "tool_search_output", + }, + ] + + def test_input_to_new_input_list_copies_the_ones_produced_by_pydantic() -> None: """Validated input items should be copied and made JSON dump compatible.""" original = ResponseOutputMessageParam( diff --git a/tests/test_model_payload_iterators.py b/tests/test_model_payload_iterators.py index 5c16db07..e7198ecc 100644 --- a/tests/test_model_payload_iterators.py +++ b/tests/test_model_payload_iterators.py @@ -7,7 +7,6 @@ import httpx import pytest from openai import omit from openai.types.chat.chat_completion import ChatCompletion -from openai.types.responses import ToolParam from agents import ( ModelSettings, @@ -135,16 +134,16 @@ async def test_responses_materializes_iterator_payload(monkeypatch: pytest.Monke ) converted_tools = responses_module.ConvertedTools( - tools=cast( - list[ToolParam], - [ + tools=[ + cast( + Any, { "type": "function", "name": "dummy", "parameters": {"properties": tool_iter}, - } - ], - ), + }, + ) + ], includes=[], ) monkeypatch.setattr( diff --git a/tests/test_openai_chatcompletions_converter.py b/tests/test_openai_chatcompletions_converter.py index 2dd84ae5..a00960b1 100644 --- a/tests/test_openai_chatcompletions_converter.py +++ b/tests/test_openai_chatcompletions_converter.py @@ -215,6 +215,13 @@ def test_convert_tool_choice_handles_standard_and_named_options() -> None: assert tool_choice_dict["function"]["name"] == "mytool" +def test_convert_tool_choice_allows_tool_search_as_named_function_for_chat_models() -> None: + tool_choice_dict = Converter.convert_tool_choice("tool_search") + assert isinstance(tool_choice_dict, dict) + assert tool_choice_dict["type"] == "function" + assert tool_choice_dict["function"]["name"] == "tool_search" + + def test_convert_response_format_returns_not_given_for_plain_text_and_dict_for_schemas() -> None: """ The `Converter.convert_response_format` method should return the omit sentinel diff --git a/tests/test_openai_responses.py b/tests/test_openai_responses.py index a66387ba..5dcdc4f2 100644 --- a/tests/test_openai_responses.py +++ b/tests/test_openai_responses.py @@ -11,10 +11,12 @@ from openai import NOT_GIVEN, omit from openai.types.responses import ResponseCompletedEvent from openai.types.shared.reasoning import Reasoning -from agents import ModelSettings, ModelTracing, __version__ +from agents import ModelSettings, ModelTracing, ToolSearchTool, __version__ from agents.exceptions import UserError from agents.models.openai_responses import ( _HEADERS_OVERRIDE as RESP_HEADERS, + ConvertedTools, + Converter, OpenAIResponsesModel, OpenAIResponsesWSModel, ResponsesWebSocketError, @@ -698,6 +700,58 @@ def test_build_response_create_kwargs_rejects_duplicate_extra_args_keys(): ) +@pytest.mark.allow_call_model_methods +def test_build_response_create_kwargs_preserves_unknown_response_include_values(): + client = DummyWSClient() + model = OpenAIResponsesModel(model="gpt-4", openai_client=client) # type: ignore[arg-type] + + kwargs = model._build_response_create_kwargs( + system_instructions=None, + input="hi", + model_settings=ModelSettings(response_include=["response.future_flag"]), + tools=[], + output_schema=None, + handoffs=[], + previous_response_id=None, + conversation_id=None, + stream=False, + prompt=None, + ) + + assert kwargs["include"] == ["response.future_flag"] + + +@pytest.mark.allow_call_model_methods +def test_build_response_create_kwargs_preserves_unknown_tool_types(monkeypatch) -> None: + client = DummyWSClient() + model = OpenAIResponsesModel(model="gpt-4", openai_client=client) # type: ignore[arg-type] + + future_tool = cast(Any, {"type": "future_beta_tool", "label": "preview"}) + + monkeypatch.setattr( + Converter, + "convert_tools", + classmethod( + lambda cls, tools, handoffs, **kwargs: ConvertedTools(tools=[future_tool], includes=[]) + ), + ) + + kwargs = model._build_response_create_kwargs( + system_instructions=None, + input="hi", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + previous_response_id=None, + conversation_id=None, + stream=False, + prompt=None, + ) + + assert kwargs["tools"] == [future_tool] + + @pytest.mark.allow_call_model_methods @pytest.mark.asyncio async def test_prompt_id_omits_model_parameter(): @@ -842,6 +896,42 @@ async def test_prompt_id_keeps_literal_tool_choice_without_local_tools(tool_choi assert called_kwargs["tool_choice"] == tool_choice +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_prompt_id_keeps_explicit_tool_search_without_local_surface() -> None: + called_kwargs: dict[str, Any] = {} + + class DummyResponses: + async def create(self, **kwargs): + nonlocal called_kwargs + called_kwargs = kwargs + return get_response_obj([]) + + class DummyResponsesClient: + def __init__(self): + self.responses = DummyResponses() + + model = OpenAIResponsesModel( + model="gpt-4", + openai_client=DummyResponsesClient(), # type: ignore[arg-type] + model_is_explicit=False, + ) + + await model.get_response( + system_instructions=None, + input="hi", + model_settings=ModelSettings(), + tools=[ToolSearchTool()], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + prompt={"id": "pmpt_123"}, + ) + + assert called_kwargs["prompt"] == {"id": "pmpt_123"} + assert called_kwargs["tools"] == [{"type": "tool_search"}] + + @pytest.mark.allow_call_model_methods @pytest.mark.asyncio async def test_websocket_model_reuses_connection_and_sends_response_create_frames(monkeypatch): diff --git a/tests/test_openai_responses_converter.py b/tests/test_openai_responses_converter.py index 376bf67a..56e86a69 100644 --- a/tests/test_openai_responses_converter.py +++ b/tests/test_openai_responses_converter.py @@ -36,12 +36,15 @@ from agents import ( ComputerTool, FileSearchTool, Handoff, + HostedMCPTool, ShellTool, Tool, + ToolSearchTool, UserError, WebSearchTool, function_tool, handoff, + tool_namespace, ) from agents.models.openai_responses import Converter @@ -104,6 +107,206 @@ def test_convert_tool_choice_standard_values(): } +def test_convert_tool_choice_allows_function_named_tool_search() -> None: + tool = function_tool(lambda city: city, name_override="tool_search") + + assert Converter.convert_tool_choice("tool_search", tools=[tool]) == { + "type": "function", + "name": "tool_search", + } + + +def test_convert_tool_choice_rejects_hosted_tool_search_choice() -> None: + deferred_tool = function_tool( + lambda city: city, + name_override="lookup_weather", + defer_loading=True, + ) + + with pytest.raises(UserError, match="ToolSearchTool\\(\\)"): + Converter.convert_tool_choice("tool_search", tools=[deferred_tool, ToolSearchTool()]) + + +def test_convert_tool_choice_rejects_tool_search_without_matching_definition() -> None: + namespaced_tool = tool_namespace( + name="crm", + description="CRM tools", + tools=[function_tool(lambda city: city, name_override="lookup_weather")], + )[0] + + with pytest.raises( + UserError, + match="requires ToolSearchTool\\(\\) or a real top-level function tool named `tool_search`", + ): + Converter.convert_tool_choice("tool_search", tools=[namespaced_tool]) + + +def test_convert_tool_choice_allows_function_named_tool_search_with_hosted_tool_search() -> None: + named_tool = function_tool(lambda city: city, name_override="tool_search") + deferred_tool = function_tool( + lambda city: city, + name_override="lookup_weather", + defer_loading=True, + ) + + assert Converter.convert_tool_choice( + "tool_search", + tools=[named_tool, deferred_tool, ToolSearchTool()], + ) == { + "type": "function", + "name": "tool_search", + } + + +def test_convert_tool_choice_required_allows_eager_namespace_tools_without_tool_search() -> None: + tools = tool_namespace( + name="crm", + description="CRM tools", + tools=[function_tool(lambda customer_id: customer_id, name_override="lookup_account")], + ) + + assert Converter.convert_tool_choice("required", tools=tools) == "required" + + +def test_convert_tool_choice_required_allows_eager_namespace_tools_with_tool_search() -> None: + tools: list[Tool] = [ + *tool_namespace( + name="crm", + description="CRM tools", + tools=[function_tool(lambda customer_id: customer_id, name_override="lookup_account")], + ), + ToolSearchTool(), + ] + + assert Converter.convert_tool_choice("required", tools=tools) == "required" + + +def test_convert_tool_choice_required_rejects_deferred_function_tools() -> None: + tools: list[Tool] = [ + function_tool( + lambda customer_id: customer_id, + name_override="lookup_account", + defer_loading=True, + ) + ] + + with pytest.raises(UserError, match="ToolSearchTool\\(\\)"): + Converter.convert_tool_choice("required", tools=tools) + + +def test_convert_tool_choice_required_allows_deferred_function_tools_with_tool_search() -> None: + tools: list[Tool] = [ + function_tool( + lambda customer_id: customer_id, + name_override="lookup_account", + defer_loading=True, + ), + ToolSearchTool(), + ] + + assert Converter.convert_tool_choice("required", tools=tools) == "required" + + +def test_convert_tool_choice_required_allows_deferred_hosted_mcp_tools_with_tool_search() -> None: + tools: list[Tool] = [ + HostedMCPTool( + tool_config=cast( + Any, + { + "type": "mcp", + "server_label": "crm_server", + "server_url": "https://example.com/mcp", + "defer_loading": True, + }, + ) + ), + ToolSearchTool(), + ] + + assert Converter.convert_tool_choice("required", tools=tools) == "required" + + +def test_convert_tool_choice_allows_qualified_namespaced_function_tools() -> None: + namespaced_tool = tool_namespace( + name="crm", + description="CRM tools", + tools=[function_tool(lambda customer_id: customer_id, name_override="lookup_account")], + )[0] + + assert Converter.convert_tool_choice("crm.lookup_account", tools=[namespaced_tool]) == { + "type": "function", + "name": "crm.lookup_account", + } + + +def test_convert_tool_choice_rejects_namespace_wrapper_and_bare_inner_name() -> None: + namespaced_tool = tool_namespace( + name="crm", + description="CRM tools", + tools=[function_tool(lambda customer_id: customer_id, name_override="lookup_account")], + )[0] + + with pytest.raises(UserError, match="tool_namespace\\(\\)"): + Converter.convert_tool_choice("lookup_account", tools=[namespaced_tool]) + + with pytest.raises(UserError, match="tool_namespace\\(\\)"): + Converter.convert_tool_choice("crm", tools=[namespaced_tool]) + + +def test_convert_tool_choice_allows_top_level_function_with_namespaced_tools_present() -> None: + top_level_tool = function_tool(lambda customer_id: customer_id, name_override="lookup_account") + namespaced_tool = tool_namespace( + name="crm", + description="CRM tools", + tools=[function_tool(lambda customer_id: customer_id, name_override="lookup_account")], + )[0] + + assert Converter.convert_tool_choice( + "lookup_account", + tools=[top_level_tool, namespaced_tool], + ) == {"type": "function", "name": "lookup_account"} + + +def test_convert_tool_choice_allows_handoff_with_namespaced_function_name_clash() -> None: + namespaced_tool = tool_namespace( + name="crm", + description="CRM tools", + tools=[function_tool(lambda customer_id: customer_id, name_override="lookup_account")], + )[0] + transfer_handoff = handoff(Agent(name="specialist"), tool_name_override="lookup_account") + + assert Converter.convert_tool_choice( + "lookup_account", + tools=[namespaced_tool], + handoffs=[transfer_handoff], + ) == {"type": "function", "name": "lookup_account"} + + +def test_convert_tool_choice_rejects_deferred_only_function_tools() -> None: + deferred_tool = function_tool( + lambda customer_id: customer_id, + name_override="lookup_account", + defer_loading=True, + ) + + with pytest.raises(UserError, match="deferred-loading function tools"): + Converter.convert_tool_choice("lookup_account", tools=[deferred_tool]) + + +def test_convert_tool_choice_allows_visible_top_level_function_with_deferred_peer() -> None: + top_level_tool = function_tool(lambda customer_id: customer_id, name_override="lookup_account") + deferred_tool = function_tool( + lambda customer_id: customer_id, + name_override="lookup_account", + defer_loading=True, + ) + + assert Converter.convert_tool_choice( + "lookup_account", + tools=[top_level_tool, deferred_tool], + ) == {"type": "function", "name": "lookup_account"} + + def test_get_response_format_plain_text_and_json_schema(): """ For plain text output (default, or output type of `str`), the converter @@ -280,6 +483,386 @@ def test_convert_tools_shell_container_auto_environment() -> None: ] +def test_convert_tools_tool_search_and_namespaces() -> None: + eager_tool = function_tool( + lambda customer_id: customer_id, name_override="get_customer_profile" + ) + deferred_tool = function_tool( + lambda customer_id: customer_id, + name_override="list_open_orders", + defer_loading=True, + ) + + converted = Converter.convert_tools( + tools=[ + *tool_namespace( + name="crm", + description="CRM tools for customer lookups.", + tools=[eager_tool, deferred_tool], + ), + ToolSearchTool(), + ], + handoffs=[], + ) + + assert converted.includes == [] + assert converted.tools == [ + { + "type": "namespace", + "name": "crm", + "description": "CRM tools for customer lookups.", + "tools": [ + { + "type": "function", + "name": "get_customer_profile", + "description": eager_tool.description, + "parameters": eager_tool.params_json_schema, + "strict": True, + }, + { + "type": "function", + "name": "list_open_orders", + "description": deferred_tool.description, + "parameters": deferred_tool.params_json_schema, + "strict": True, + "defer_loading": True, + }, + ], + }, + {"type": "tool_search"}, + ] + + +def test_convert_tools_top_level_deferred_function_requires_tool_search() -> None: + deferred_tool = function_tool( + lambda city: city, + name_override="get_weather", + defer_loading=True, + ) + + with pytest.raises(UserError, match="ToolSearchTool\\(\\)"): + Converter.convert_tools(tools=[deferred_tool], handoffs=[]) + + +def test_convert_tools_rejects_tool_search_without_deferred_function() -> None: + eager_tool = function_tool(lambda city: city, name_override="get_weather") + + with pytest.raises( + UserError, + match=("ToolSearchTool\\(\\) requires at least one searchable Responses surface"), + ): + Converter.convert_tools(tools=[eager_tool, ToolSearchTool()], handoffs=[]) + + +def test_convert_tools_allows_prompt_managed_tool_search_without_local_surface() -> None: + converted = Converter.convert_tools( + tools=[ToolSearchTool()], + handoffs=[], + allow_opaque_tool_search_surface=True, + ) + + assert converted.tools == [{"type": "tool_search"}] + + +def test_convert_tools_rejects_duplicate_tool_search_tools() -> None: + deferred_tool = function_tool( + lambda city: city, + name_override="get_weather", + defer_loading=True, + ) + + with pytest.raises(UserError, match="Only one ToolSearchTool\\(\\) is allowed"): + Converter.convert_tools( + tools=[deferred_tool, ToolSearchTool(), ToolSearchTool()], + handoffs=[], + ) + + +def test_convert_tools_top_level_deferred_function_with_tool_search() -> None: + deferred_tool = function_tool( + lambda city: city, + name_override="get_weather", + defer_loading=True, + ) + + converted = Converter.convert_tools(tools=[deferred_tool, ToolSearchTool()], handoffs=[]) + + assert converted.tools == [ + { + "type": "function", + "name": "get_weather", + "description": deferred_tool.description, + "parameters": deferred_tool.params_json_schema, + "strict": True, + "defer_loading": True, + }, + {"type": "tool_search"}, + ] + + +def test_convert_tools_preserves_tool_search_config_fields() -> None: + deferred_tool = function_tool( + lambda city: city, + name_override="get_weather", + defer_loading=True, + ) + + converted = Converter.convert_tools( + tools=[ + deferred_tool, + ToolSearchTool( + description="Search deferred tools on the server.", + execution="server", + parameters={ + "type": "object", + "properties": { + "query": {"type": "string"}, + }, + "required": ["query"], + }, + ), + ], + handoffs=[], + ) + + assert converted.tools[-1] == { + "type": "tool_search", + "description": "Search deferred tools on the server.", + "execution": "server", + "parameters": { + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + }, + } + + +def test_convert_tools_allows_client_executed_tool_search_for_manual_flows() -> None: + deferred_tool = function_tool( + lambda city: city, + name_override="get_weather", + defer_loading=True, + ) + + converted = Converter.convert_tools( + tools=[ + deferred_tool, + ToolSearchTool( + execution="client", + parameters={ + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + }, + ), + ], + handoffs=[], + ) + + assert converted.tools[-1] == { + "type": "tool_search", + "execution": "client", + "parameters": { + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + }, + } + + +def test_convert_tools_namespace_only_allows_eager_namespaces_without_tool_search() -> None: + crm_tool = function_tool(lambda customer_id: customer_id, name_override="lookup_account") + + converted = Converter.convert_tools( + tools=[ + *tool_namespace( + name="crm", + description="CRM tools", + tools=[crm_tool], + ), + ], + handoffs=[], + ) + + assert converted.tools == [ + { + "type": "namespace", + "name": "crm", + "description": "CRM tools", + "tools": [ + { + "type": "function", + "name": "lookup_account", + "description": crm_tool.description, + "parameters": crm_tool.params_json_schema, + "strict": True, + } + ], + } + ] + + +def test_convert_tools_allows_tool_search_with_namespace_only_tools() -> None: + crm_tool = function_tool(lambda customer_id: customer_id, name_override="lookup_account") + + converted = Converter.convert_tools( + tools=[ + *tool_namespace( + name="crm", + description="CRM tools", + tools=[crm_tool], + ), + ToolSearchTool(), + ], + handoffs=[], + ) + + assert converted.tools == [ + { + "type": "namespace", + "name": "crm", + "description": "CRM tools", + "tools": [ + { + "type": "function", + "name": "lookup_account", + "description": crm_tool.description, + "parameters": crm_tool.params_json_schema, + "strict": True, + } + ], + }, + {"type": "tool_search"}, + ] + + +def test_convert_tools_deferred_hosted_mcp_requires_tool_search() -> None: + hosted_mcp = HostedMCPTool( + tool_config=cast( + Any, + { + "type": "mcp", + "server_label": "crm_server", + "server_url": "https://example.com/mcp", + "defer_loading": True, + }, + ) + ) + + with pytest.raises(UserError, match="ToolSearchTool\\(\\)"): + Converter.convert_tools(tools=[hosted_mcp], handoffs=[]) + + +def test_convert_tools_deferred_hosted_mcp_with_tool_search() -> None: + hosted_mcp = HostedMCPTool( + tool_config=cast( + Any, + { + "type": "mcp", + "server_label": "crm_server", + "server_url": "https://example.com/mcp", + "defer_loading": True, + }, + ) + ) + + converted = Converter.convert_tools(tools=[hosted_mcp, ToolSearchTool()], handoffs=[]) + + assert converted.tools == [ + { + "type": "mcp", + "server_label": "crm_server", + "server_url": "https://example.com/mcp", + "defer_loading": True, + }, + {"type": "tool_search"}, + ] + + +def test_convert_tools_rejects_reserved_same_name_namespace_shape() -> None: + invalid_tool = function_tool(lambda customer_id: customer_id, name_override="lookup_account") + invalid_tool._tool_namespace = "lookup_account" + invalid_tool._tool_namespace_description = "Same-name namespace" + + with pytest.raises(UserError, match="synthetic namespace `lookup_account.lookup_account`"): + Converter.convert_tools( + tools=[invalid_tool, ToolSearchTool()], + handoffs=[], + ) + + +def test_convert_tools_rejects_qualified_name_collision_with_dotted_top_level_tool() -> None: + dotted_top_level_tool = function_tool( + lambda customer_id: customer_id, + name_override="crm.lookup_account", + ) + namespaced_tool = tool_namespace( + name="crm", + description="CRM tools", + tools=[function_tool(lambda customer_id: customer_id, name_override="lookup_account")], + )[0] + + with pytest.raises(UserError, match="qualified name `crm.lookup_account`"): + Converter.convert_tools( + tools=[dotted_top_level_tool, namespaced_tool, ToolSearchTool()], + handoffs=[], + ) + + +def test_convert_tools_rejects_duplicate_deferred_top_level_names() -> None: + first_deferred_tool = function_tool( + lambda customer_id: customer_id, + name_override="lookup_account", + defer_loading=True, + ) + second_deferred_tool = function_tool( + lambda customer_id: customer_id, + name_override="lookup_account", + defer_loading=True, + ) + + with pytest.raises(UserError, match="deferred top-level tool name `lookup_account`"): + Converter.convert_tools( + tools=[first_deferred_tool, second_deferred_tool, ToolSearchTool()], + handoffs=[], + ) + + +def test_convert_tools_allows_dotted_non_function_tool_name_with_namespaced_function() -> None: + shell_tool = ShellTool(executor=lambda request: "ok", name="crm.lookup_account") + namespaced_tool = tool_namespace( + name="crm", + description="CRM tools", + tools=[function_tool(lambda customer_id: customer_id, name_override="lookup_account")], + )[0] + + converted = Converter.convert_tools( + tools=[shell_tool, namespaced_tool], + handoffs=[], + ) + + assert len(converted.tools) == 2 + namespace_tool = cast( + dict[str, Any], + next( + tool + for tool in converted.tools + if isinstance(tool, dict) and tool.get("type") == "namespace" + ), + ) + shell_payload = cast( + dict[str, Any], + next( + tool + for tool in converted.tools + if isinstance(tool, dict) and tool.get("type") == "shell" + ), + ) + assert shell_payload["environment"] == {"type": "local"} + assert namespace_tool["name"] == "crm" + assert namespace_tool["tools"][0]["name"] == "lookup_account" + + def test_convert_tools_shell_environment_passes_through_unknown_fields() -> None: shell_tool = ShellTool( environment=cast( diff --git a/tests/test_process_model_response.py b/tests/test_process_model_response.py index ee51f5b5..1d1cd44a 100644 --- a/tests/test_process_model_response.py +++ b/tests/test_process_model_response.py @@ -1,19 +1,41 @@ from typing import Any, cast import pytest +from openai._models import construct_type from openai.types.responses import ( ResponseApplyPatchToolCall, ResponseCompactionItem, ResponseFunctionShellToolCall, ResponseFunctionShellToolCallOutput, + ResponseOutputItem, + ResponseToolSearchCall, + ResponseToolSearchOutputItem, ) -from agents import Agent, ApplyPatchTool, CompactionItem, ShellTool -from agents.exceptions import ModelBehaviorError -from agents.items import ModelResponse, ToolCallItem, ToolCallOutputItem +from agents import ( + Agent, + ApplyPatchTool, + CompactionItem, + Handoff, + ShellTool, + Tool, + function_tool, + handoff, + tool_namespace, +) +from agents.exceptions import ModelBehaviorError, UserError +from agents.items import ( + HandoffCallItem, + ModelResponse, + ToolCallItem, + ToolCallOutputItem, + ToolSearchCallItem, + ToolSearchOutputItem, +) from agents.run_internal import run_loop from agents.usage import Usage from tests.fake_model import FakeModel +from tests.test_responses import get_function_tool_call from tests.utils.hitl import ( RecordingEditor, make_apply_patch_call, @@ -255,6 +277,36 @@ def test_process_model_response_converts_custom_apply_patch_call() -> None: assert converted_call.get("type") == "apply_patch_call" +def test_process_model_response_prefers_namespaced_function_over_apply_patch_fallback() -> None: + namespaced_tool = tool_namespace( + name="billing", + description="Billing tools", + tools=[function_tool(lambda payload: payload, name_override="apply_patch_lookup")], + )[0] + all_tools: list[Tool] = [namespaced_tool] + agent = Agent(name="billing-agent", model=FakeModel(), tools=all_tools) + + processed = run_loop.process_model_response( + agent=agent, + all_tools=all_tools, + response=_response( + [ + get_function_tool_call( + "apply_patch_lookup", + '{"payload":"value"}', + namespace="billing", + ) + ] + ), + output_schema=None, + handoffs=[], + ) + + assert len(processed.functions) == 1 + assert processed.functions[0].function_tool is namespaced_tool + assert processed.apply_patch_calls == [] + + def test_process_model_response_handles_compaction_item() -> None: agent = Agent(name="compaction-agent", model=FakeModel()) compaction_item = ResponseCompactionItem( @@ -279,3 +331,377 @@ def test_process_model_response_handles_compaction_item() -> None: assert item.raw_item["type"] == "compaction" assert item.raw_item["encrypted_content"] == "enc" assert "created_by" not in item.raw_item + + +def test_process_model_response_classifies_tool_search_items() -> None: + agent = Agent(name="tool-search-agent", model=FakeModel()) + tool_search_call = construct_type( + type_=ResponseOutputItem, + value={ + "id": "tsc_123", + "type": "tool_search_call", + "arguments": {"paths": ["crm"], "query": "profile"}, + "execution": "server", + "status": "completed", + }, + ) + tool_search_output = construct_type( + type_=ResponseOutputItem, + value={ + "id": "tso_123", + "type": "tool_search_output", + "execution": "server", + "status": "completed", + "tools": [ + { + "type": "function", + "name": "get_customer_profile", + "description": "Fetch a CRM customer profile.", + "parameters": { + "type": "object", + "properties": { + "customer_id": { + "type": "string", + } + }, + "required": ["customer_id"], + }, + "defer_loading": True, + } + ], + }, + ) + + processed = run_loop.process_model_response( + agent=agent, + all_tools=[], + response=_response([tool_search_call, tool_search_output]), + output_schema=None, + handoffs=[], + ) + + assert isinstance(processed.new_items[0], ToolSearchCallItem) + assert isinstance(processed.new_items[0].raw_item, ResponseToolSearchCall) + assert isinstance(processed.new_items[1], ToolSearchOutputItem) + assert isinstance(processed.new_items[1].raw_item, ResponseToolSearchOutputItem) + assert processed.tools_used == ["tool_search", "tool_search"] + + +def test_process_model_response_uses_namespace_for_duplicate_function_names() -> None: + crm_tool = function_tool(lambda customer_id: customer_id, name_override="lookup_account") + billing_tool = function_tool(lambda customer_id: customer_id, name_override="lookup_account") + crm_namespace = tool_namespace( + name="crm", + description="CRM tools", + tools=[crm_tool], + ) + billing_namespace = tool_namespace( + name="billing", + description="Billing tools", + tools=[billing_tool], + ) + all_tools: list[Tool] = [*crm_namespace, *billing_namespace] + agent = Agent(name="billing-agent", model=FakeModel(), tools=all_tools) + + processed = run_loop.process_model_response( + agent=agent, + all_tools=all_tools, + response=_response( + [ + get_function_tool_call( + "lookup_account", + '{"customer_id":"customer_42"}', + namespace="billing", + ) + ] + ), + output_schema=None, + handoffs=[], + ) + + assert len(processed.functions) == 1 + assert processed.functions[0].function_tool is billing_namespace[0] + assert processed.tools_used == ["billing.lookup_account"] + + +def test_process_model_response_collapses_synthetic_deferred_namespace_in_tools_used() -> None: + deferred_tool = function_tool( + lambda city: city, + name_override="get_weather", + defer_loading=True, + ) + agent = Agent(name="weather-agent", model=FakeModel(), tools=[deferred_tool]) + + processed = run_loop.process_model_response( + agent=agent, + all_tools=[deferred_tool], + response=_response( + [ + get_function_tool_call( + "get_weather", + '{"city":"Tokyo"}', + namespace="get_weather", + ) + ] + ), + output_schema=None, + handoffs=[], + ) + + assert len(processed.functions) == 1 + assert processed.functions[0].function_tool is deferred_tool + assert processed.tools_used == ["get_weather"] + + +def test_process_model_response_rejects_bare_name_for_duplicate_namespaced_functions() -> None: + crm_tool = function_tool(lambda customer_id: customer_id, name_override="lookup_account") + billing_tool = function_tool(lambda customer_id: customer_id, name_override="lookup_account") + crm_namespace = tool_namespace( + name="crm", + description="CRM tools", + tools=[crm_tool], + ) + billing_namespace = tool_namespace( + name="billing", + description="Billing tools", + tools=[billing_tool], + ) + all_tools: list[Tool] = [*crm_namespace, *billing_namespace] + agent = Agent(name="billing-agent", model=FakeModel(), tools=all_tools) + + with pytest.raises(ModelBehaviorError, match="Tool lookup_account not found"): + run_loop.process_model_response( + agent=agent, + all_tools=all_tools, + response=_response( + [get_function_tool_call("lookup_account", '{"customer_id":"customer_42"}')] + ), + output_schema=None, + handoffs=[], + ) + + +def test_process_model_response_uses_last_duplicate_top_level_function() -> None: + first_tool = function_tool(lambda customer_id: f"first:{customer_id}", name_override="lookup") + second_tool = function_tool(lambda customer_id: f"second:{customer_id}", name_override="lookup") + all_tools: list[Tool] = [first_tool, second_tool] + agent = Agent(name="lookup-agent", model=FakeModel(), tools=all_tools) + + processed = run_loop.process_model_response( + agent=agent, + all_tools=all_tools, + response=_response([get_function_tool_call("lookup", '{"customer_id":"customer_42"}')]), + output_schema=None, + handoffs=[], + ) + + assert len(processed.functions) == 1 + assert processed.functions[0].function_tool is second_tool + + +def test_process_model_response_rejects_reserved_same_name_namespace_shape() -> None: + invalid_tool = function_tool(lambda customer_id: customer_id, name_override="lookup_account") + invalid_tool._tool_namespace = "lookup_account" + invalid_tool._tool_namespace_description = "Same-name namespace" + all_tools: list[Tool] = [invalid_tool] + agent = Agent(name="lookup-agent", model=FakeModel(), tools=all_tools) + + with pytest.raises(UserError, match="synthetic namespace `lookup_account.lookup_account`"): + run_loop.process_model_response( + agent=agent, + all_tools=all_tools, + response=_response( + [ + get_function_tool_call( + "lookup_account", + '{"customer_id":"customer_42"}', + namespace="lookup_account", + ) + ] + ), + output_schema=None, + handoffs=[], + ) + + +def test_process_model_response_rejects_qualified_name_collision_with_dotted_top_level_tool() -> ( + None +): + dotted_top_level_tool = function_tool( + lambda customer_id: customer_id, + name_override="crm.lookup_account", + ) + namespaced_tool = tool_namespace( + name="crm", + description="CRM tools", + tools=[function_tool(lambda customer_id: customer_id, name_override="lookup_account")], + )[0] + all_tools: list[Tool] = [dotted_top_level_tool, namespaced_tool] + agent = Agent(name="lookup-agent", model=FakeModel(), tools=all_tools) + + with pytest.raises(UserError, match="qualified name `crm.lookup_account`"): + run_loop.process_model_response( + agent=agent, + all_tools=all_tools, + response=_response( + [ + get_function_tool_call( + "lookup_account", + '{"customer_id":"customer_42"}', + namespace="crm", + ) + ] + ), + output_schema=None, + handoffs=[], + ) + + +def test_process_model_response_prefers_visible_top_level_function_over_deferred_same_name_tool(): + visible_tool = function_tool( + lambda customer_id: f"visible:{customer_id}", + name_override="lookup_account", + ) + deferred_tool = function_tool( + lambda customer_id: f"deferred:{customer_id}", + name_override="lookup_account", + defer_loading=True, + ) + all_tools: list[Tool] = [visible_tool, deferred_tool] + agent = Agent(name="lookup-agent", model=FakeModel(), tools=all_tools) + + processed = run_loop.process_model_response( + agent=agent, + all_tools=all_tools, + response=_response( + [get_function_tool_call("lookup_account", '{"customer_id":"customer_42"}')] + ), + output_schema=None, + handoffs=[], + ) + + assert len(processed.functions) == 1 + assert processed.functions[0].function_tool is visible_tool + assert getattr(processed.functions[0].tool_call, "namespace", None) is None + assert isinstance(processed.new_items[0], ToolCallItem) + assert getattr(processed.new_items[0].raw_item, "namespace", None) is None + + +def test_process_model_response_uses_internal_lookup_key_for_deferred_top_level_calls() -> None: + visible_tool = function_tool( + lambda customer_id: f"visible:{customer_id}", + name_override="lookup_account.lookup_account", + ) + deferred_tool = function_tool( + lambda customer_id: f"deferred:{customer_id}", + name_override="lookup_account", + defer_loading=True, + ) + all_tools: list[Tool] = [visible_tool, deferred_tool] + agent = Agent(name="lookup-agent", model=FakeModel(), tools=all_tools) + + processed = run_loop.process_model_response( + agent=agent, + all_tools=all_tools, + response=_response( + [ + get_function_tool_call( + "lookup_account", + '{"customer_id":"customer_42"}', + namespace="lookup_account", + ) + ] + ), + output_schema=None, + handoffs=[], + ) + + assert len(processed.functions) == 1 + assert processed.functions[0].function_tool is deferred_tool + + +def test_process_model_response_preserves_synthetic_namespace_for_deferred_top_level_tools() -> ( + None +): + deferred_tool = function_tool( + lambda city: city, + name_override="get_weather", + defer_loading=True, + ) + all_tools: list[Tool] = [deferred_tool] + agent = Agent(name="weather-agent", model=FakeModel(), tools=all_tools) + + processed = run_loop.process_model_response( + agent=agent, + all_tools=all_tools, + response=_response( + [get_function_tool_call("get_weather", '{"city":"Tokyo"}', namespace="get_weather")] + ), + output_schema=None, + handoffs=[], + ) + + assert len(processed.functions) == 1 + assert processed.functions[0].function_tool is deferred_tool + assert getattr(processed.functions[0].tool_call, "namespace", None) == "get_weather" + assert isinstance(processed.new_items[0], ToolCallItem) + assert getattr(processed.new_items[0].raw_item, "namespace", None) == "get_weather" + + +def test_process_model_response_prefers_namespaced_function_over_handoff_name_collision() -> None: + billing_tool = function_tool(lambda customer_id: customer_id, name_override="lookup_account") + billing_namespace = tool_namespace( + name="billing", + description="Billing tools", + tools=[billing_tool], + ) + handoff_target = Agent(name="lookup-agent", model=FakeModel()) + lookup_handoff: Handoff = handoff(handoff_target, tool_name_override="lookup_account") + all_tools: list[Tool] = [*billing_namespace] + agent = Agent(name="billing-agent", model=FakeModel(), tools=all_tools) + + processed = run_loop.process_model_response( + agent=agent, + all_tools=all_tools, + response=_response( + [ + get_function_tool_call( + "lookup_account", + '{"customer_id":"customer_42"}', + namespace="billing", + ) + ] + ), + output_schema=None, + handoffs=[lookup_handoff], + ) + + assert len(processed.functions) == 1 + assert processed.functions[0].function_tool is billing_namespace[0] + assert processed.handoffs == [] + assert len(processed.new_items) == 1 + assert isinstance(processed.new_items[0], ToolCallItem) + assert not isinstance(processed.new_items[0], HandoffCallItem) + + +def test_process_model_response_rejects_mismatched_function_namespace() -> None: + bare_tool = function_tool(lambda customer_id: customer_id, name_override="lookup_account") + all_tools: list[Tool] = [bare_tool] + agent = Agent(name="bare-agent", model=FakeModel(), tools=all_tools) + + with pytest.raises(ModelBehaviorError, match="crm.lookup_account"): + run_loop.process_model_response( + agent=agent, + all_tools=all_tools, + response=_response( + [ + get_function_tool_call( + "lookup_account", + '{"customer_id":"customer_42"}', + namespace="crm", + ) + ] + ), + output_schema=None, + handoffs=[], + ) diff --git a/tests/test_responses.py b/tests/test_responses.py index 3bf242b0..a0dbac5b 100644 --- a/tests/test_responses.py +++ b/tests/test_responses.py @@ -50,15 +50,22 @@ def get_function_tool( def get_function_tool_call( - name: str, arguments: str | None = None, call_id: str | None = None + name: str, + arguments: str | None = None, + call_id: str | None = None, + *, + namespace: str | None = None, ) -> ResponseOutputItem: - return ResponseFunctionToolCall( - id="1", - call_id=call_id or "2", - type="function_call", - name=name, - arguments=arguments or "", - ) + kwargs: dict[str, Any] = { + "id": "1", + "call_id": call_id or "2", + "type": "function_call", + "name": name, + "arguments": arguments or "", + } + if namespace is not None: + kwargs["namespace"] = namespace + return ResponseFunctionToolCall(**kwargs) def get_handoff_tool_call( diff --git a/tests/test_run_context_approvals.py b/tests/test_run_context_approvals.py index 59ed45c3..b19865ce 100644 --- a/tests/test_run_context_approvals.py +++ b/tests/test_run_context_approvals.py @@ -18,3 +18,152 @@ def test_latest_approval_decision_wins_for_call_id() -> None: context_wrapper.approve_tool(approval_item) assert context_wrapper.is_tool_approved("test_tool", "call-1") is True + + +def test_namespaced_approval_status_does_not_fall_back_to_bare_tool_decisions() -> None: + agent = Agent(name="test-agent") + context_wrapper = RunContextWrapper(context=None) + bare_item = make_tool_approval_item(agent, call_id="call-bare", name="lookup_account") + billing_item = make_tool_approval_item( + agent, + call_id="call-billing", + name="lookup_account", + namespace="billing", + ) + + context_wrapper.approve_tool(bare_item, always_approve=True) + + assert ( + context_wrapper.get_approval_status( + "lookup_account", + "call-billing-2", + tool_namespace="billing", + existing_pending=billing_item, + ) + is None + ) + assert ( + context_wrapper.get_approval_status( + "lookup_account", + "call-billing-2", + existing_pending=billing_item, + ) + is None + ) + + +def test_deferred_top_level_per_call_approval_keeps_bare_name_lookup() -> None: + agent = Agent(name="test-agent") + context_wrapper = RunContextWrapper(context=None) + deferred_item = make_tool_approval_item( + agent, + call_id="call-weather", + name="get_weather", + namespace="get_weather", + allow_bare_name_alias=True, + ) + + context_wrapper.approve_tool(deferred_item) + + assert context_wrapper.is_tool_approved("get_weather", "call-weather") is True + + +def test_deferred_top_level_permanent_approval_does_not_alias_to_bare_name() -> None: + agent = Agent(name="test-agent") + context_wrapper = RunContextWrapper(context=None) + deferred_item = make_tool_approval_item( + agent, + call_id="call-weather", + name="get_weather", + namespace="get_weather", + allow_bare_name_alias=True, + ) + + context_wrapper.approve_tool(deferred_item, always_approve=True) + + assert context_wrapper.is_tool_approved("get_weather", "call-weather-2") is None + assert "deferred_top_level:get_weather" in context_wrapper._approvals + assert ( + context_wrapper.get_approval_status( + "get_weather", + "call-weather-2", + tool_namespace="get_weather", + existing_pending=deferred_item, + ) + is True + ) + + +def test_deferred_top_level_legacy_permanent_approval_key_still_restores() -> None: + agent = Agent(name="test-agent") + context_wrapper = RunContextWrapper(context=None) + deferred_item = make_tool_approval_item( + agent, + call_id="call-weather", + name="get_weather", + namespace="get_weather", + allow_bare_name_alias=True, + ) + + context_wrapper._rebuild_approvals( # noqa: SLF001 + {"get_weather.get_weather": {"approved": True, "rejected": []}} + ) + + assert ( + context_wrapper.get_approval_status( + "get_weather", + "call-weather-2", + tool_namespace="get_weather", + existing_pending=deferred_item, + ) + is True + ) + + +def test_deferred_top_level_approval_does_not_alias_to_visible_bare_sibling() -> None: + agent = Agent(name="test-agent") + context_wrapper = RunContextWrapper(context=None) + deferred_item = make_tool_approval_item( + agent, + call_id="call-lookup", + name="lookup_account", + namespace="lookup_account", + allow_bare_name_alias=False, + ) + + context_wrapper.approve_tool(deferred_item, always_approve=True) + + assert context_wrapper.is_tool_approved("lookup_account", "call-visible-2") is None + assert ( + context_wrapper.get_approval_status( + "lookup_account", + "call-deferred-2", + tool_namespace="lookup_account", + existing_pending=deferred_item, + ) + is True + ) + + +def test_explicit_same_name_namespace_does_not_alias_to_bare_tool() -> None: + agent = Agent(name="test-agent") + context_wrapper = RunContextWrapper(context=None) + explicit_namespaced_item = make_tool_approval_item( + agent, + call_id="call-namespaced", + name="lookup_account", + namespace="lookup_account", + ) + + context_wrapper.approve_tool(explicit_namespaced_item, always_approve=True) + + assert context_wrapper.is_tool_approved("lookup_account", "call-bare-2") is None + assert ( + context_wrapper.get_approval_status( + "lookup_account", + "call-namespaced-2", + tool_namespace="lookup_account", + existing_pending=explicit_namespaced_item, + ) + is True + ) diff --git a/tests/test_run_context_wrapper.py b/tests/test_run_context_wrapper.py index 3449adee..5ec43671 100644 --- a/tests/test_run_context_wrapper.py +++ b/tests/test_run_context_wrapper.py @@ -67,3 +67,23 @@ def test_run_context_unknown_tool_name_fallback() -> None: approval = ToolApprovalItem(agent=agent, raw_item=raw, tool_name=None) assert RunContextWrapper._resolve_tool_name(approval) == "unknown_tool" + + +def test_tool_approval_item_preserves_positional_type_argument() -> None: + raw: dict[str, Any] = { + "type": "function_call", + "name": "lookup_account", + "call_id": "call-1", + "namespace": "billing", + } + + approval = ToolApprovalItem( + make_agent(), + raw, + "lookup_account", + "tool_approval_item", + ) + + assert approval.type == "tool_approval_item" + assert approval.tool_name == "lookup_account" + assert approval.tool_namespace == "billing" diff --git a/tests/test_run_internal_items.py b/tests/test_run_internal_items.py index 61625b17..ef2632d1 100644 --- a/tests/test_run_internal_items.py +++ b/tests/test_run_internal_items.py @@ -3,11 +3,21 @@ from __future__ import annotations from typing import Any, cast import pytest +from openai.types.responses import ResponseToolSearchCall, ResponseToolSearchOutputItem from openai.types.responses.response_reasoning_item import ResponseReasoningItem from agents import Agent -from agents.items import ReasoningItem, TResponseInputItem +from agents.exceptions import AgentsException +from agents.items import ( + ReasoningItem, + ToolSearchCallItem, + ToolSearchOutputItem, + TResponseInputItem, + coerce_tool_search_output_raw_item, +) from agents.models.fake_id import FAKE_RESPONSES_ID +from agents.result import RunResult +from agents.run_context import RunContextWrapper from agents.run_internal import items as run_items @@ -58,6 +68,159 @@ def test_drop_orphan_function_calls_preserves_non_mapping_entries() -> None: ) +def test_drop_orphan_function_calls_handles_tool_search_calls() -> None: + payload: list[Any] = [ + cast( + TResponseInputItem, + { + "type": "tool_search_call", + "call_id": "tool_search_orphan", + "arguments": {"query": "orphan"}, + "execution": "server", + "status": "completed", + }, + ), + cast( + TResponseInputItem, + { + "type": "tool_search_call", + "call_id": "tool_search_keep", + "arguments": {"query": "keep"}, + "execution": "server", + "status": "completed", + }, + ), + cast( + TResponseInputItem, + { + "type": "tool_search_output", + "call_id": "tool_search_keep", + "execution": "server", + "status": "completed", + "tools": [], + }, + ), + ] + + filtered = run_items.drop_orphan_function_calls(cast(list[TResponseInputItem], payload)) + + assert any( + isinstance(entry, dict) + and entry.get("type") == "tool_search_call" + and entry.get("call_id") == "tool_search_keep" + for entry in filtered + ) + assert not any( + isinstance(entry, dict) + and entry.get("type") == "tool_search_call" + and entry.get("call_id") == "tool_search_orphan" + for entry in filtered + ) + + +def test_drop_orphan_function_calls_preserves_hosted_tool_search_pairs_without_call_ids() -> None: + payload: list[Any] = [ + cast( + TResponseInputItem, + { + "type": "tool_search_call", + "call_id": None, + "arguments": {"query": "keep"}, + "execution": "server", + "status": "completed", + }, + ), + cast( + TResponseInputItem, + { + "type": "tool_search_output", + "call_id": None, + "execution": "server", + "status": "completed", + "tools": [], + }, + ), + ] + + filtered = run_items.drop_orphan_function_calls(cast(list[TResponseInputItem], payload)) + + assert len(filtered) == 2 + assert cast(dict[str, Any], filtered[0])["type"] == "tool_search_call" + assert cast(dict[str, Any], filtered[1])["type"] == "tool_search_output" + + +def test_drop_orphan_function_calls_matches_latest_anonymous_tool_search_call() -> None: + payload: list[Any] = [ + cast( + TResponseInputItem, + { + "type": "tool_search_call", + "call_id": None, + "arguments": {"query": "orphan"}, + "execution": "server", + "status": "completed", + }, + ), + cast( + TResponseInputItem, + { + "type": "tool_search_call", + "call_id": None, + "arguments": {"query": "paired"}, + "execution": "server", + "status": "completed", + }, + ), + cast( + TResponseInputItem, + { + "type": "tool_search_output", + "call_id": None, + "execution": "server", + "status": "completed", + "tools": [], + }, + ), + ] + + filtered = run_items.drop_orphan_function_calls(cast(list[TResponseInputItem], payload)) + + assert [cast(dict[str, Any], item)["type"] for item in filtered] == [ + "tool_search_call", + "tool_search_output", + ] + assert cast(dict[str, Any], filtered[0])["arguments"] == {"query": "paired"} + + +def test_drop_orphan_function_calls_does_not_pair_named_tool_search_with_anonymous_output() -> None: + payload: list[Any] = [ + cast( + TResponseInputItem, + { + "type": "tool_search_call", + "call_id": "orphan_search", + "arguments": {"query": "keep"}, + "execution": "server", + "status": "completed", + }, + ), + cast( + TResponseInputItem, + { + "type": "tool_search_output", + "call_id": None, + "execution": "server", + "status": "completed", + "tools": [], + }, + ), + ] + + filtered = run_items.drop_orphan_function_calls(cast(list[TResponseInputItem], payload)) + + assert [cast(dict[str, Any], item)["type"] for item in filtered] == ["tool_search_output"] + + def test_normalize_and_ensure_input_item_format_keep_non_dict_entries() -> None: item = cast(TResponseInputItem, "raw-item") assert run_items.ensure_input_item_format(item) == item @@ -238,3 +401,95 @@ def test_run_item_to_input_item_omits_reasoning_item_ids_when_configured() -> No assert isinstance(result, dict) assert result.get("type") == "reasoning" assert "id" not in result + + +def test_run_item_to_input_item_preserves_tool_search_items() -> None: + agent = Agent(name="A") + tool_search_call = ToolSearchCallItem( + agent=agent, + raw_item={"type": "tool_search_call", "queries": [{"search_term": "profile"}]}, + ) + tool_search_output = ToolSearchOutputItem( + agent=agent, + raw_item={"type": "tool_search_output", "results": [{"text": "Customer profile"}]}, + ) + + converted_call = run_items.run_item_to_input_item(tool_search_call) + converted_output = run_items.run_item_to_input_item(tool_search_output) + + assert isinstance(converted_call, dict) + assert converted_call["type"] == "tool_search_call" + assert isinstance(converted_output, dict) + assert converted_output["type"] == "tool_search_output" + + +def test_run_item_to_input_item_strips_tool_search_created_by() -> None: + agent = Agent(name="A") + tool_search_call = ToolSearchCallItem( + agent=agent, + raw_item=ResponseToolSearchCall( + id="tsc_123", + type="tool_search_call", + arguments={"query": "profile"}, + execution="client", + status="completed", + created_by="server", + ), + ) + tool_search_output = ToolSearchOutputItem( + agent=agent, + raw_item=ResponseToolSearchOutputItem( + id="tso_123", + type="tool_search_output", + execution="client", + status="completed", + tools=[], + created_by="server", + ), + ) + + converted_call = run_items.run_item_to_input_item(tool_search_call) + converted_output = run_items.run_item_to_input_item(tool_search_output) + + assert isinstance(converted_call, dict) + assert converted_call["type"] == "tool_search_call" + assert "created_by" not in converted_call + assert isinstance(converted_output, dict) + assert converted_output["type"] == "tool_search_output" + assert "created_by" not in converted_output + + +def test_run_result_to_input_list_preserves_tool_search_items() -> None: + agent = Agent(name="A") + result = RunResult( + input="Find CRM tools", + new_items=[ + ToolSearchCallItem( + agent=agent, + raw_item={"type": "tool_search_call", "queries": [{"search_term": "profile"}]}, + ), + ToolSearchOutputItem( + agent=agent, + raw_item={"type": "tool_search_output", "results": [{"text": "Customer profile"}]}, + ), + ], + raw_responses=[], + final_output="done", + input_guardrail_results=[], + output_guardrail_results=[], + tool_input_guardrail_results=[], + tool_output_guardrail_results=[], + context_wrapper=RunContextWrapper(context=None), + _last_agent=agent, + ) + + input_items = result.to_input_list() + + assert len(input_items) == 3 + assert cast(dict[str, Any], input_items[1])["type"] == "tool_search_call" + assert cast(dict[str, Any], input_items[2])["type"] == "tool_search_output" + + +def test_coerce_tool_search_output_raw_item_rejects_legacy_type() -> None: + with pytest.raises(AgentsException, match="Unexpected tool search output item type"): + coerce_tool_search_output_raw_item({"type": "tool_search_result", "results": []}) diff --git a/tests/test_run_state.py b/tests/test_run_state.py index 39d0cdb4..8212175a 100644 --- a/tests/test_run_state.py +++ b/tests/test_run_state.py @@ -16,6 +16,8 @@ from openai.types.responses import ( ResponseOutputMessage, ResponseOutputText, ResponseReasoningItem, + ResponseToolSearchCall, + ResponseToolSearchOutputItem, ) from openai.types.responses.response_computer_tool_call import ( ActionScreenshot, @@ -46,6 +48,8 @@ from agents.items import ( ToolApprovalItem, ToolCallItem, ToolCallOutputItem, + ToolSearchCallItem, + ToolSearchOutputItem, TResponseInputItem, TResponseStreamEvent, ) @@ -80,6 +84,7 @@ from agents.tool import ( LocalShellTool, ShellTool, function_tool, + tool_namespace, ) from agents.tool_context import ToolContext from agents.tool_guardrails import ( @@ -752,6 +757,128 @@ class TestRunState: call_id = getattr(raw_item, "call_id", None) assert call_id == "call_1" + @pytest.mark.asyncio + async def test_anonymous_tool_search_items_keep_later_same_content_snapshot(self): + """Ensure later anonymous tool_search snapshots survive the generated-item merge.""" + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + agent = Agent(name="AgentToolSearchMerge") + state = make_state(agent, context=context, original_input="input", max_turns=2) + + first_tool_search_call_item = ToolSearchCallItem( + raw_item={ + "type": "tool_search_call", + "arguments": {"query": "account balance"}, + "execution": "server", + "status": "completed", + }, + agent=agent, + ) + first_tool_search_output_item = ToolSearchOutputItem( + raw_item={ + "type": "tool_search_output", + "execution": "server", + "status": "completed", + "tools": [], + }, + agent=agent, + ) + + state._generated_items = [ + first_tool_search_call_item, + first_tool_search_output_item, + ] + state._last_processed_response = make_processed_response( + new_items=[ + ToolSearchCallItem( + raw_item=dict(cast(dict[str, Any], first_tool_search_call_item.raw_item)), + agent=agent, + ), + ToolSearchOutputItem( + raw_item=dict(cast(dict[str, Any], first_tool_search_output_item.raw_item)), + agent=agent, + ), + ] + ) + + json_data = state.to_json() + assert [item["type"] for item in json_data["generated_items"]] == [ + "tool_search_call_item", + "tool_search_output_item", + "tool_search_call_item", + "tool_search_output_item", + ] + + @pytest.mark.asyncio + async def test_anonymous_tool_search_items_not_duplicated_across_round_trip(self): + """Ensure already-merged anonymous tool_search items do not grow across round-trips.""" + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + agent = Agent(name="AgentToolSearchDedup") + state = make_state(agent, context=context, original_input="input", max_turns=2) + + first_tool_search_call_item = ToolSearchCallItem( + raw_item={ + "type": "tool_search_call", + "arguments": {"query": "account balance"}, + "execution": "server", + "status": "completed", + }, + agent=agent, + ) + first_tool_search_output_item = ToolSearchOutputItem( + raw_item={ + "type": "tool_search_output", + "execution": "server", + "status": "completed", + "tools": [], + }, + agent=agent, + ) + later_tool_search_call_item = ToolSearchCallItem( + raw_item=dict(cast(dict[str, Any], first_tool_search_call_item.raw_item)), + agent=agent, + ) + later_tool_search_output_item = ToolSearchOutputItem( + raw_item=dict(cast(dict[str, Any], first_tool_search_output_item.raw_item)), + agent=agent, + ) + + state._generated_items = [ + first_tool_search_call_item, + first_tool_search_output_item, + later_tool_search_call_item, + later_tool_search_output_item, + ] + state._last_processed_response = make_processed_response( + new_items=[ + ToolSearchCallItem( + raw_item=dict(cast(dict[str, Any], later_tool_search_call_item.raw_item)), + agent=agent, + ), + ToolSearchOutputItem( + raw_item=dict(cast(dict[str, Any], later_tool_search_output_item.raw_item)), + agent=agent, + ), + ] + ) + state._mark_generated_items_merged_with_last_processed() + + json_data = state.to_json() + assert [item["type"] for item in json_data["generated_items"]] == [ + "tool_search_call_item", + "tool_search_output_item", + "tool_search_call_item", + "tool_search_output_item", + ] + + restored = await RunState.from_json(agent, json_data) + restored_json = restored.to_json() + assert [item["type"] for item in restored_json["generated_items"]] == [ + "tool_search_call_item", + "tool_search_output_item", + "tool_search_call_item", + "tool_search_output_item", + ] + @pytest.mark.asyncio async def test_to_json_deduplicates_items_with_direct_id_type_attributes(self): """Test deduplication when items have id/type attributes directly (not just in raw_item).""" @@ -2394,6 +2521,51 @@ class TestRunStateSerializationEdgeCases: assert serialized["handoffs"][0]["handoff"]["tool_name"] == "handoff_tool" assert serialized["mcp_approval_requests"][0]["mcp_tool"]["name"] == "mcp_tool" + def test_serialize_tool_action_groups_preserves_synthetic_namespace_for_deferred_tools(self): + """Deferred top-level function tool calls should keep their synthetic namespace.""" + deferred_tool = function_tool( + lambda city: city, + name_override="get_weather", + defer_loading=True, + ) + + processed_response = ProcessedResponse( + new_items=[], + handoffs=[], + functions=[ + ToolRunFunction( + tool_call=cast( + ResponseFunctionToolCall, + get_function_tool_call( + "get_weather", + '{"city": "Tokyo"}', + call_id="weather-call", + namespace="get_weather", + ), + ), + function_tool=deferred_tool, + ) + ], + computer_actions=[], + local_shell_calls=[], + shell_calls=[], + apply_patch_calls=[], + tools_used=[], + mcp_approval_requests=[], + interruptions=[], + ) + + serialized = _serialize_tool_action_groups(processed_response) + + assert serialized["functions"][0]["tool"]["name"] == "get_weather" + assert "namespace" not in serialized["functions"][0]["tool"] + assert "qualifiedName" not in serialized["functions"][0]["tool"] + assert serialized["functions"][0]["tool"]["lookupKey"] == { + "kind": "deferred_top_level", + "name": "get_weather", + } + assert serialized["functions"][0]["tool_call"]["namespace"] == "get_weather" + def test_serialize_guardrail_results(self): """Serialize both input and output guardrail results with agent data.""" guardrail_output = GuardrailFunctionOutput( @@ -3053,6 +3225,244 @@ class TestRunStateSerializationEdgeCases: assert result is not None assert len(result.functions) == 1 + async def test_deserialize_processed_response_function_uses_namespace(self): + """Test deserialization of ProcessedResponse with namespace-qualified function names.""" + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + agent = Agent(name="TestAgent") + + crm_tool = function_tool(lambda customer_id: customer_id, name_override="lookup_account") + billing_tool = function_tool( + lambda customer_id: customer_id, + name_override="lookup_account", + ) + crm_namespace = tool_namespace( + name="crm", + description="CRM tools", + tools=[crm_tool], + ) + billing_namespace = tool_namespace( + name="billing", + description="Billing tools", + tools=[billing_tool], + ) + agent.tools = [*crm_namespace, *billing_namespace] + + processed_response_data = { + "new_items": [], + "handoffs": [], + "functions": [ + { + "tool_call": { + "type": "function_call", + "name": "lookup_account", + "namespace": "billing", + "call_id": "call123", + "status": "completed", + "arguments": "{}", + }, + "tool": {"name": "lookup_account", "namespace": "billing"}, + } + ], + "computer_actions": [], + "local_shell_actions": [], + "mcp_approval_requests": [], + "tools_used": [], + "interruptions": [], + } + + result = await _deserialize_processed_response( + processed_response_data, agent, context, {"TestAgent": agent} + ) + + assert result is not None + assert len(result.functions) == 1 + assert result.functions[0].function_tool is billing_namespace[0] + + async def test_deserialize_processed_response_rejects_qualified_name_collision(self): + """Reject dotted top-level names that collide with namespace-wrapped functions.""" + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + agent = Agent(name="TestAgent") + + dotted_top_level_tool = function_tool( + lambda customer_id: customer_id, + name_override="crm.lookup_account", + ) + namespaced_tool = tool_namespace( + name="crm", + description="CRM tools", + tools=[function_tool(lambda customer_id: customer_id, name_override="lookup_account")], + )[0] + agent.tools = [dotted_top_level_tool, namespaced_tool] + + processed_response_data = { + "new_items": [], + "handoffs": [], + "functions": [ + { + "tool_call": { + "type": "function_call", + "name": "lookup_account", + "namespace": "crm", + "call_id": "call123", + "status": "completed", + "arguments": "{}", + }, + "tool": {"name": "lookup_account", "namespace": "crm"}, + } + ], + "computer_actions": [], + "local_shell_actions": [], + "mcp_approval_requests": [], + "tools_used": [], + "interruptions": [], + } + + with pytest.raises(UserError, match="qualified name `crm.lookup_account`"): + await _deserialize_processed_response( + processed_response_data, agent, context, {"TestAgent": agent} + ) + + async def test_deserialize_processed_response_uses_last_duplicate_top_level_function(self): + """Test deserialization preserves last-wins behavior for duplicate top-level tools.""" + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + agent = Agent(name="TestAgent") + + first_tool = function_tool(lambda customer_id: customer_id, name_override="lookup") + second_tool = function_tool(lambda customer_id: customer_id, name_override="lookup") + agent.tools = [first_tool, second_tool] + + processed_response_data = { + "new_items": [], + "handoffs": [], + "functions": [ + { + "tool_call": { + "type": "function_call", + "name": "lookup", + "call_id": "call123", + "status": "completed", + "arguments": "{}", + }, + "tool": {"name": "lookup"}, + } + ], + "computer_actions": [], + "local_shell_actions": [], + "mcp_approval_requests": [], + "tools_used": [], + "interruptions": [], + } + + result = await _deserialize_processed_response( + processed_response_data, agent, context, {"TestAgent": agent} + ) + + assert result is not None + assert len(result.functions) == 1 + assert result.functions[0].function_tool is second_tool + + async def test_deserialize_processed_response_uses_tool_call_namespace_for_deferred_top_level( + self, + ): + """Synthetic deferred namespaces should disambiguate resumed same-name top-level tools.""" + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + agent = Agent(name="TestAgent") + + visible_tool = function_tool( + lambda customer_id: customer_id, name_override="lookup_account" + ) + deferred_tool = function_tool( + lambda customer_id: customer_id, + name_override="lookup_account", + defer_loading=True, + ) + agent.tools = [visible_tool, deferred_tool] + + processed_response_data = { + "new_items": [], + "handoffs": [], + "functions": [ + { + "tool_call": { + "type": "function_call", + "name": "lookup_account", + "namespace": "lookup_account", + "call_id": "call123", + "status": "completed", + "arguments": "{}", + }, + "tool": {"name": "lookup_account"}, + } + ], + "computer_actions": [], + "local_shell_actions": [], + "mcp_approval_requests": [], + "tools_used": [], + "interruptions": [], + } + + result = await _deserialize_processed_response( + processed_response_data, agent, context, {"TestAgent": agent} + ) + + assert result is not None + assert len(result.functions) == 1 + assert result.functions[0].function_tool is deferred_tool + + async def test_deserialize_processed_response_uses_serialized_lookup_key_for_deferred_top_level( + self, + ) -> None: + """Serialized lookup metadata should disambiguate deferred tools without raw namespace.""" + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + agent = Agent(name="TestAgent") + + visible_tool = function_tool( + lambda customer_id: f"visible:{customer_id}", + name_override="lookup_account", + ) + deferred_tool = function_tool( + lambda customer_id: f"deferred:{customer_id}", + name_override="lookup_account", + defer_loading=True, + ) + agent.tools = [visible_tool, deferred_tool] + + processed_response_data = { + "new_items": [], + "handoffs": [], + "functions": [ + { + "tool_call": { + "type": "function_call", + "name": "lookup_account", + "call_id": "call123", + "status": "completed", + "arguments": "{}", + }, + "tool": { + "name": "lookup_account", + "lookupKey": { + "kind": "deferred_top_level", + "name": "lookup_account", + }, + }, + } + ], + "computer_actions": [], + "local_shell_actions": [], + "mcp_approval_requests": [], + "tools_used": [], + "interruptions": [], + } + + result = await _deserialize_processed_response( + processed_response_data, agent, context, {"TestAgent": agent} + ) + + assert result is not None + assert len(result.functions) == 1 + assert result.functions[0].function_tool is deferred_tool + async def test_deserialize_processed_response_computer_action_in_map(self): """Test deserialization of ProcessedResponse with computer action in computer_tools_map.""" context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) @@ -3879,6 +4289,38 @@ class TestToolApprovalItem: assert restored_item.tool_name == "explicit_name" assert restored_item.name == "explicit_name" + async def test_round_trip_serialization_preserves_allow_bare_name_alias(self): + """Test round-trip serialization preserves bare-name approval alias metadata.""" + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + agent = Agent(name="TestAgent") + state = make_state(agent, context=context, original_input="test") + + raw_item = { + "type": "function_call", + "name": "get_weather", + "call_id": "call123", + "status": "completed", + "arguments": "{}", + "namespace": "get_weather", + } + approval_item = ToolApprovalItem( + agent=agent, + raw_item=raw_item, + tool_name="get_weather", + tool_namespace="get_weather", + _allow_bare_name_alias=True, + ) + state._generated_items.append(approval_item) + + json_data = state.to_json() + assert json_data["generated_items"][0]["allow_bare_name_alias"] is True + + new_state = await RunState.from_json(agent, json_data) + + restored_item = new_state._generated_items[0] + assert isinstance(restored_item, ToolApprovalItem) + assert restored_item._allow_bare_name_alias is True + def test_tool_approval_item_arguments_property(self): """Test that ToolApprovalItem.arguments property correctly extracts arguments.""" agent = Agent(name="TestAgent") @@ -3920,6 +4362,144 @@ class TestToolApprovalItem: approval_item4 = ToolApprovalItem(agent=agent, raw_item=raw_item4) assert approval_item4.arguments is None + def test_tool_approval_item_tracks_namespace(self): + """Test that ToolApprovalItem keeps namespace metadata from Responses tool calls.""" + agent = Agent(name="TestAgent") + raw_item = make_tool_call( + call_id="call-ns-1", + name="lookup_account", + namespace="crm", + status="completed", + arguments="{}", + ) + + approval_item = ToolApprovalItem(agent=agent, raw_item=raw_item) + + assert approval_item.tool_name == "lookup_account" + assert approval_item.tool_namespace == "crm" + assert approval_item.qualified_name == "crm.lookup_account" + + def test_tool_approval_item_collapses_synthetic_deferred_namespace_in_qualified_name(self): + """Synthetic deferred namespaces should display as the bare tool name.""" + agent = Agent(name="TestAgent") + raw_item = make_tool_call( + call_id="call-weather-1", + name="get_weather", + namespace="get_weather", + status="completed", + arguments="{}", + ) + + approval_item = ToolApprovalItem(agent=agent, raw_item=raw_item) + + assert approval_item.tool_name == "get_weather" + assert approval_item.tool_namespace == "get_weather" + assert approval_item.qualified_name == "get_weather" + + async def test_round_trip_serialization_with_tool_namespace(self): + """Test round-trip serialization preserves tool namespace metadata.""" + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + agent = Agent(name="TestAgent") + state = make_state(agent, context=context, original_input="test") + + raw_item = make_tool_call( + call_id="call123", + name="lookup_account", + namespace="billing", + status="completed", + arguments="{}", + ) + approval_item = ToolApprovalItem(agent=agent, raw_item=raw_item) + state._generated_items.append(approval_item) + + new_state = await RunState.from_json(agent, state.to_json()) + + assert len(new_state._generated_items) == 1 + restored_item = new_state._generated_items[0] + assert isinstance(restored_item, ToolApprovalItem) + assert restored_item.tool_name == "lookup_account" + assert restored_item.tool_namespace == "billing" + assert restored_item.qualified_name == "billing.lookup_account" + + async def test_round_trip_serialization_preserves_tool_lookup_key(self) -> None: + """Deferred approval items should keep their explicit lookup key through RunState.""" + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + agent = Agent(name="TestAgent") + state = make_state(agent, context=context, original_input="test") + + raw_item = make_tool_call( + call_id="call-weather", + name="get_weather", + namespace="get_weather", + status="completed", + arguments="{}", + ) + approval_item = ToolApprovalItem( + agent=agent, + raw_item=raw_item, + tool_lookup_key=("deferred_top_level", "get_weather"), + ) + state._generated_items.append(approval_item) + + new_state = await RunState.from_json(agent, state.to_json()) + + assert len(new_state._generated_items) == 1 + restored_item = new_state._generated_items[0] + assert isinstance(restored_item, ToolApprovalItem) + assert restored_item.tool_lookup_key == ("deferred_top_level", "get_weather") + + async def test_deserialize_items_restores_tool_search_items(self): + """Test that tool search run items survive RunState round-trips.""" + agent = Agent(name="TestAgent") + items = _deserialize_items( + [ + { + "type": "tool_search_call_item", + "agent": {"name": "TestAgent"}, + "raw_item": { + "id": "tsc_state", + "type": "tool_search_call", + "arguments": {"paths": ["crm"], "query": "profile"}, + "execution": "server", + "status": "completed", + }, + }, + { + "type": "tool_search_output_item", + "agent": {"name": "TestAgent"}, + "raw_item": { + "id": "tso_state", + "type": "tool_search_output", + "execution": "server", + "status": "completed", + "tools": [ + { + "type": "function", + "name": "get_customer_profile", + "description": "Fetch a CRM customer profile.", + "parameters": { + "type": "object", + "properties": { + "customer_id": { + "type": "string", + } + }, + "required": ["customer_id"], + }, + "defer_loading": True, + } + ], + }, + }, + ], + {"TestAgent": agent}, + ) + + assert isinstance(items[0], ToolSearchCallItem) + assert isinstance(items[1], ToolSearchOutputItem) + assert isinstance(items[0].raw_item, ResponseToolSearchCall) + assert isinstance(items[1].raw_item, ResponseToolSearchOutputItem) + async def test_deserialize_items_handles_missing_agent_name(self): """Test that _deserialize_items handles items with missing agent name.""" agent = Agent(name="TestAgent") diff --git a/tests/test_run_step_execution.py b/tests/test_run_step_execution.py index 87c86ed6..ee2a301c 100644 --- a/tests/test_run_step_execution.py +++ b/tests/test_run_step_execution.py @@ -42,7 +42,9 @@ from agents import ( TResponseInputItem, Usage, UserError, + tool_namespace, tool_output_guardrail, + trace, ) from agents.run_internal import run_loop from agents.run_internal.run_loop import ( @@ -74,6 +76,7 @@ from .test_responses import ( get_text_input_item, get_text_message, ) +from .testing_processor import SPAN_PROCESSOR_TESTING from .utils.hitl import ( RecordingEditor, assert_single_approval_interruption, @@ -86,6 +89,23 @@ from .utils.hitl import ( ) +def _function_span_names() -> list[str]: + names: list[str] = [] + for span in SPAN_PROCESSOR_TESTING.get_ordered_spans(including_empty=True): + exported = span.export() + if not exported: + continue + span_data = exported.get("span_data") + if not isinstance(span_data, dict): + continue + if span_data.get("type") != "function": + continue + name = span_data.get("name") + if isinstance(name, str): + names.append(name) + return names + + @pytest.mark.asyncio async def test_empty_response_is_final_output(): agent = Agent[None](name="test") @@ -261,6 +281,77 @@ async def test_plaintext_agent_shell_output_only_without_message_runs_again(): assert isinstance(result.next_step, NextStepRunAgain) +@pytest.mark.asyncio +async def test_plaintext_agent_tool_search_only_without_message_runs_again(): + agent = Agent(name="test") + response = ModelResponse(output=[], usage=Usage(), response_id=None) + response.output = cast( + Any, + [ + { + "type": "tool_search_call", + "id": "tsc_step", + "arguments": {"paths": ["crm"], "query": "profile"}, + "execution": "server", + "status": "completed", + }, + { + "type": "tool_search_output", + "id": "tso_step", + "execution": "server", + "status": "completed", + "tools": [ + { + "type": "function", + "name": "lookup_account", + "description": "Look up a CRM account.", + "parameters": { + "type": "object", + "properties": { + "account_id": { + "type": "string", + } + }, + "required": ["account_id"], + }, + "defer_loading": True, + } + ], + }, + ], + ) + + result = await get_execute_result(agent, response) + + assert len(result.generated_items) == 2 + assert getattr(result.generated_items[0].raw_item, "type", None) == "tool_search_call" + raw_output = result.generated_items[1].raw_item + assert getattr(raw_output, "type", None) == "tool_search_output" + assert isinstance(result.next_step, NextStepRunAgain) + + +@pytest.mark.asyncio +async def test_plaintext_agent_client_tool_search_requires_manual_handling() -> None: + agent = Agent(name="test") + response = ModelResponse(output=[], usage=Usage(), response_id=None) + response.output = cast( + Any, + [ + { + "type": "tool_search_call", + "id": "tsc_client_step", + "call_id": "call_tool_search_client", + "arguments": {"paths": ["crm"], "query": "profile"}, + "execution": "client", + "status": "completed", + } + ], + ) + + with pytest.raises(ModelBehaviorError, match="Client-executed tool_search calls"): + await get_execute_result(agent, response) + + @pytest.mark.asyncio async def test_plaintext_agent_hosted_shell_with_refusal_message_is_final_output(): shell_tool = ShellTool(environment={"type": "container_auto"}) @@ -1072,6 +1163,103 @@ async def test_execute_function_tool_calls_parent_cancellation_skips_post_invoke assert not on_tool_end_called.is_set() +@pytest.mark.asyncio +async def test_execute_function_tool_calls_collapse_trace_name_for_top_level_deferred_tools(): + async def _shipping_eta(tracking_number: str) -> str: + return f"eta:{tracking_number}" + + tool = function_tool( + _shipping_eta, + name_override="get_shipping_eta", + defer_loading=True, + ) + tool_run = ToolRunFunction( + tool_call=cast( + ResponseFunctionToolCall, + get_function_tool_call( + "get_shipping_eta", + '{"tracking_number":"ZX-123"}', + call_id="call-1", + namespace="get_shipping_eta", + ), + ), + function_tool=tool, + ) + + with trace("test_execute_function_tool_calls_collapse_trace_name_for_top_level_deferred_tools"): + await execute_function_tool_calls( + agent=Agent(name="test", tools=[tool]), + tool_runs=[tool_run], + hooks=RunHooks(), + context_wrapper=RunContextWrapper(None), + config=RunConfig(), + ) + + assert "get_shipping_eta" in _function_span_names() + assert "get_shipping_eta.get_shipping_eta" not in _function_span_names() + + +@pytest.mark.asyncio +async def test_execute_function_tool_calls_preserve_trace_name_for_explicit_namespace(): + async def _shipping_eta(tracking_number: str) -> str: + return f"eta:{tracking_number}" + + tool = tool_namespace( + name="shipping", + description="Shipping tools", + tools=[ + function_tool( + _shipping_eta, + name_override="get_shipping_eta", + defer_loading=True, + ) + ], + )[0] + tool_run = ToolRunFunction( + tool_call=cast( + ResponseFunctionToolCall, + get_function_tool_call( + "get_shipping_eta", + '{"tracking_number":"ZX-123"}', + call_id="call-1", + namespace="shipping", + ), + ), + function_tool=tool, + ) + + with trace("test_execute_function_tool_calls_preserve_trace_name_for_explicit_namespace"): + await execute_function_tool_calls( + agent=Agent(name="test", tools=[tool]), + tool_runs=[tool_run], + hooks=RunHooks(), + context_wrapper=RunContextWrapper(None), + config=RunConfig(), + ) + + assert "shipping.get_shipping_eta" in _function_span_names() + assert "get_shipping_eta" not in _function_span_names() + + +@pytest.mark.asyncio +async def test_execute_function_tool_calls_rejects_reserved_same_name_namespace_shape(): + async def _lookup_account(customer_id: str) -> str: + return f"account:{customer_id}" + + with pytest.raises(UserError, match="synthetic namespace `lookup_account.lookup_account`"): + tool_namespace( + name="lookup_account", + description="Same-name namespace", + tools=[ + function_tool( + _lookup_account, + name_override="lookup_account", + defer_loading=True, + ) + ], + ) + + @pytest.mark.asyncio async def test_single_tool_call_still_raises_normal_exception(): async def _error_tool() -> str: @@ -2098,6 +2286,48 @@ async def test_function_tool_context_includes_run_config() -> None: assert isinstance(result.next_step, NextStepRunAgain) +@pytest.mark.asyncio +async def test_deferred_function_tool_context_preserves_search_loaded_namespace() -> None: + async def _tool_with_namespace(context: ToolContext[str]) -> str: + tool_call_namespace = getattr(context.tool_call, "namespace", None) + return json.dumps( + { + "tool_call_namespace": tool_call_namespace, + "tool_namespace": context.tool_namespace, + }, + sort_keys=True, + ) + + tool = function_tool( + _tool_with_namespace, + name_override="get_weather", + defer_loading=True, + failure_error_function=None, + ) + agent = Agent(name="test", tools=[tool]) + response = ModelResponse( + output=[ + get_function_tool_call( + "get_weather", + "{}", + call_id="call-1", + namespace="get_weather", + ) + ], + usage=Usage(), + response_id=None, + ) + + result = await get_execute_result(agent, response) + + assert len(result.generated_items) == 2 + assert_item_is_function_tool_call_output( + result.generated_items[1], + '{"tool_call_namespace": "get_weather", "tool_namespace": "get_weather"}', + ) + assert isinstance(result.next_step, NextStepRunAgain) + + @pytest.mark.asyncio async def test_handoff_output_leads_to_handoff_next_step(): agent_1 = Agent(name="test_1") @@ -2428,6 +2658,69 @@ async def test_execute_tools_handles_tool_approval_items( assert_single_approval_interruption(result, tool_name=scenario.expected_tool_name) +@pytest.mark.asyncio +async def test_execute_tools_preserves_synthetic_namespace_for_deferred_top_level_approval() -> ( + None +): + async def _deferred_weather() -> str: + return "tool_result" + + tool = function_tool( + _deferred_weather, + name_override="get_weather", + defer_loading=True, + needs_approval=True, + ) + agent = make_agent(tools=[tool]) + tool_call = cast( + ResponseFunctionToolCall, + get_function_tool_call("get_weather", "{}", namespace="get_weather"), + ) + tool_run = ToolRunFunction(function_tool=tool, tool_call=tool_call) + processed_response = make_processed_response(functions=[tool_run]) + + result = await run_execute_with_processed_response(agent, processed_response) + interruption = assert_single_approval_interruption(result, tool_name="get_weather") + + assert interruption.tool_namespace == "get_weather" + assert getattr(interruption.raw_item, "namespace", None) == "get_weather" + + +@pytest.mark.asyncio +async def test_deferred_tool_approval_allows_bare_alias_when_visible_peer_is_disabled() -> None: + async def _visible_weather() -> str: + return "visible" + + async def _deferred_weather() -> str: + return "deferred" + + visible_tool = function_tool( + _visible_weather, + name_override="get_weather", + needs_approval=True, + is_enabled=False, + ) + deferred_tool = function_tool( + _deferred_weather, + name_override="get_weather", + defer_loading=True, + needs_approval=True, + ) + agent = make_agent(tools=[visible_tool, deferred_tool]) + tool_call = cast( + ResponseFunctionToolCall, + get_function_tool_call("get_weather", "{}", namespace="get_weather"), + ) + tool_run = ToolRunFunction(function_tool=deferred_tool, tool_call=tool_call) + processed_response = make_processed_response(functions=[tool_run]) + + result = await run_execute_with_processed_response(agent, processed_response) + interruption = assert_single_approval_interruption(result, tool_name="get_weather") + + assert interruption.tool_namespace == "get_weather" + assert interruption._allow_bare_name_alias is True + + @pytest.mark.asyncio async def test_execute_tools_runs_hosted_mcp_callback_when_present(): """Hosted MCP approvals should invoke on_approval_request callbacks.""" diff --git a/tests/test_server_conversation_tracker.py b/tests/test_server_conversation_tracker.py index 338aa743..5eea1d70 100644 --- a/tests/test_server_conversation_tracker.py +++ b/tests/test_server_conversation_tracker.py @@ -3,7 +3,7 @@ from typing import Any, cast import pytest from agents import Agent -from agents.items import ModelResponse, TResponseInputItem +from agents.items import ModelResponse, RunItem, TResponseInputItem from agents.lifecycle import RunHooks from agents.models.fake_id import FAKE_RESPONSES_ID from agents.result import RunResultStreaming @@ -110,6 +110,392 @@ def test_mark_input_as_sent_uses_raw_generated_source_for_rebuilt_filtered_item( assert prepared_again == [] +def test_hydrate_from_state_skips_restored_tool_search_items_by_object_identity() -> None: + tracker = OpenAIServerConversationTracker(conversation_id="conv2c", previous_response_id=None) + tool_search_call = { + "type": "tool_search_call", + "queries": [{"search_term": "account balance"}], + } + tool_search_result = { + "type": "tool_search_output", + "results": [{"text": "Balance lookup docs"}], + } + hydrated_items = [ + DummyRunItem(tool_search_call, type="tool_search_call_item"), + DummyRunItem(tool_search_result, type="tool_search_output_item"), + ] + + tracker.hydrate_from_state( + original_input=[], + generated_items=cast(list[Any], hydrated_items), + model_responses=[], + ) + + prepared = tracker.prepare_input( + original_input=[], + generated_items=cast(list[Any], hydrated_items), + ) + + assert prepared == [] + + +def test_hydrate_from_state_skips_restored_tool_search_items_by_fingerprint() -> None: + tracker = OpenAIServerConversationTracker(conversation_id="conv2d", previous_response_id=None) + tool_search_call = { + "type": "tool_search_call", + "queries": [{"search_term": "account balance"}], + } + tool_search_result = { + "type": "tool_search_output", + "results": [{"text": "Balance lookup docs"}], + } + hydrated_items = [ + DummyRunItem(tool_search_call, type="tool_search_call_item"), + DummyRunItem(tool_search_result, type="tool_search_output_item"), + ] + rebuilt_items = [ + DummyRunItem(dict(tool_search_call), type="tool_search_call_item"), + DummyRunItem(dict(tool_search_result), type="tool_search_output_item"), + ] + + tracker.hydrate_from_state( + original_input=[], + generated_items=cast(list[Any], hydrated_items), + model_responses=[], + ) + + prepared = tracker.prepare_input( + original_input=[], + generated_items=cast(list[Any], rebuilt_items), + ) + + assert prepared == [] + + +def test_hydrate_from_state_skips_restored_tool_search_items_when_created_by_is_stripped() -> None: + tracker = OpenAIServerConversationTracker( + conversation_id="conv2d-created-by", previous_response_id=None + ) + session_items = [ + cast( + TResponseInputItem, + { + "type": "tool_search_call", + "call_id": "tool_search_call_1", + "arguments": {"query": "account balance"}, + "execution": "server", + "status": "completed", + "created_by": "server", + }, + ), + cast( + TResponseInputItem, + { + "type": "tool_search_output", + "call_id": "tool_search_call_1", + "execution": "server", + "status": "completed", + "tools": [], + "created_by": "server", + }, + ), + ] + + tracker.hydrate_from_state( + original_input=[], + generated_items=[], + model_responses=[], + session_items=session_items, + ) + + prepared = tracker.prepare_input( + original_input=[], + generated_items=cast( + list[RunItem], + [ + DummyRunItem( + { + "type": "tool_search_call", + "call_id": "tool_search_call_1", + "arguments": {"query": "account balance"}, + "execution": "server", + "status": "completed", + }, + type="tool_search_call_item", + ), + DummyRunItem( + { + "type": "tool_search_output", + "call_id": "tool_search_call_1", + "execution": "server", + "status": "completed", + "tools": [], + }, + type="tool_search_output_item", + ), + ], + ), + ) + + assert prepared == [] + + +def test_hydrate_from_state_skips_restored_tool_search_items_when_only_ids_differ() -> None: + tracker = OpenAIServerConversationTracker( + conversation_id="conv2d-ids-only", previous_response_id=None + ) + session_items = [ + cast( + TResponseInputItem, + { + "type": "tool_search_call", + "id": "tool_search_call_saved", + "arguments": {"query": "account balance"}, + "execution": "server", + "status": "completed", + }, + ), + cast( + TResponseInputItem, + { + "type": "tool_search_output", + "id": "tool_search_output_saved", + "execution": "server", + "status": "completed", + "tools": [], + }, + ), + ] + + tracker.hydrate_from_state( + original_input=[], + generated_items=[], + model_responses=[], + session_items=session_items, + ) + + prepared = tracker.prepare_input( + original_input=[], + generated_items=cast( + list[RunItem], + [ + DummyRunItem( + { + "type": "tool_search_call", + "arguments": {"query": "account balance"}, + "execution": "server", + "status": "completed", + }, + type="tool_search_call_item", + ), + DummyRunItem( + { + "type": "tool_search_output", + "execution": "server", + "status": "completed", + "tools": [], + }, + type="tool_search_output_item", + ), + ], + ), + ) + + assert prepared == [] + + +def test_prepare_input_keeps_repeated_tool_search_items_with_new_ids() -> None: + tracker = OpenAIServerConversationTracker( + conversation_id="conv2d-repeated-search", previous_response_id=None + ) + + prior_response = object.__new__(ModelResponse) + prior_response.output = [ + cast( + Any, + { + "type": "tool_search_call", + "id": "tool_search_call_saved", + "arguments": {"query": "account balance"}, + "execution": "server", + "status": "completed", + "created_by": "server", + }, + ), + cast( + Any, + { + "type": "tool_search_output", + "id": "tool_search_output_saved", + "execution": "server", + "status": "completed", + "tools": [], + "created_by": "server", + }, + ), + ] + prior_response.usage = Usage() + prior_response.response_id = "resp-tool-search-repeat-1" + + tracker.track_server_items(prior_response) + + repeated_items = [ + DummyRunItem( + { + "type": "tool_search_call", + "id": "tool_search_call_repeat", + "arguments": {"query": "account balance"}, + "execution": "server", + "status": "completed", + }, + type="tool_search_call_item", + ), + DummyRunItem( + { + "type": "tool_search_output", + "id": "tool_search_output_repeat", + "execution": "server", + "status": "completed", + "tools": [], + }, + type="tool_search_output_item", + ), + ] + + prepared = tracker.prepare_input( + original_input=[], + generated_items=cast(list[Any], repeated_items), + ) + + assert prepared == [ + cast( + TResponseInputItem, + { + "type": "tool_search_call", + "id": "tool_search_call_repeat", + "arguments": {"query": "account balance"}, + "execution": "server", + "status": "completed", + }, + ), + cast( + TResponseInputItem, + { + "type": "tool_search_output", + "id": "tool_search_output_repeat", + "execution": "server", + "status": "completed", + "tools": [], + }, + ), + ] + + +def test_track_server_items_skips_live_tool_search_items_on_next_prepare() -> None: + tracker = OpenAIServerConversationTracker(conversation_id="conv2e", previous_response_id=None) + tool_search_call = cast( + Any, + { + "type": "tool_search_call", + "call_id": "tool_search_call_live", + "arguments": {"query": "account balance"}, + "execution": "server", + "status": "completed", + "created_by": "server", + }, + ) + tool_search_result = cast( + Any, + { + "type": "tool_search_output", + "call_id": "tool_search_call_live", + "execution": "server", + "status": "completed", + "tools": [], + "created_by": "server", + }, + ) + model_response = object.__new__(ModelResponse) + model_response.output = [tool_search_call, tool_search_result] + model_response.usage = Usage() + model_response.response_id = "resp-tool-search" + + tracker.track_server_items(model_response) + + prepared = tracker.prepare_input( + original_input=[], + generated_items=cast( + list[RunItem], + [ + DummyRunItem( + { + "type": "tool_search_call", + "call_id": "tool_search_call_live", + "arguments": {"query": "account balance"}, + "execution": "server", + "status": "completed", + }, + type="tool_search_call_item", + ), + DummyRunItem( + { + "type": "tool_search_output", + "call_id": "tool_search_call_live", + "execution": "server", + "status": "completed", + "tools": [], + }, + type="tool_search_output_item", + ), + ], + ), + ) + + assert prepared == [] + + +def test_track_server_items_filters_pending_tool_search_by_sanitized_fingerprint() -> None: + tracker = OpenAIServerConversationTracker( + conversation_id="conv2e-pending", previous_response_id=None + ) + tracker.remaining_initial_input = [ + cast( + TResponseInputItem, + { + "type": "tool_search_call", + "call_id": "tool_search_pending", + "arguments": {"query": "account balance"}, + "execution": "server", + "status": "completed", + }, + ), + cast(TResponseInputItem, {"id": "keep-me", "type": "message"}), + ] + + model_response = object.__new__(ModelResponse) + model_response.output = [ + cast( + Any, + { + "type": "tool_search_call", + "call_id": "tool_search_pending", + "arguments": {"query": "account balance"}, + "execution": "server", + "status": "completed", + "created_by": "server", + }, + ) + ] + model_response.usage = Usage() + model_response.response_id = "resp-tool-search-pending" + + tracker.track_server_items(model_response) + + assert tracker.remaining_initial_input == [ + cast(TResponseInputItem, {"id": "keep-me", "type": "message"}) + ] + + def test_track_server_items_filters_remaining_initial_input_by_fingerprint() -> None: tracker = OpenAIServerConversationTracker(conversation_id="conv3", previous_response_id=None) pending_kept: TResponseInputItem = cast( diff --git a/tests/test_stream_events.py b/tests/test_stream_events.py index fc029265..447ddfc9 100644 --- a/tests/test_stream_events.py +++ b/tests/test_stream_events.py @@ -1,7 +1,9 @@ import asyncio import time +from typing import cast import pytest +from openai._models import construct_type from openai.types.responses import ( ResponseCompletedEvent, ResponseContentPartAddedEvent, @@ -10,6 +12,7 @@ from openai.types.responses import ( ResponseFunctionCallArgumentsDeltaEvent, ResponseFunctionCallArgumentsDoneEvent, ResponseInProgressEvent, + ResponseOutputItem, ResponseOutputItemAddedEvent, ResponseOutputItemDoneEvent, ResponseReasoningSummaryPartAddedEvent, @@ -24,7 +27,14 @@ from openai.types.responses.response_reasoning_item import ResponseReasoningItem from agents import Agent, HandoffCallItem, Runner, function_tool from agents.extensions.handoff_filters import remove_all_tools from agents.handoffs import handoff -from agents.items import MessageOutputItem, ReasoningItem, ToolCallItem, ToolCallOutputItem +from agents.items import ( + MessageOutputItem, + ReasoningItem, + ToolCallItem, + ToolCallOutputItem, + ToolSearchCallItem, + ToolSearchOutputItem, +) from .fake_model import FakeModel from .test_responses import get_function_tool_call, get_handoff_tool_call, get_text_message @@ -280,3 +290,71 @@ async def test_complete_streaming_events(): assert events[26].type == "run_item_stream_event" assert events[26].name == "message_output_created" assert isinstance(events[26].item, MessageOutputItem) + + +@pytest.mark.asyncio +async def test_stream_events_emit_tool_search_items() -> None: + model = FakeModel() + agent = Agent(name="ToolSearchAgent", model=model) + tool_search_call = cast( + ResponseOutputItem, + construct_type( + type_=ResponseOutputItem, + value={ + "id": "tsc_stream", + "type": "tool_search_call", + "arguments": {"paths": ["crm"], "query": "orders"}, + "execution": "server", + "status": "completed", + }, + ), + ) + tool_search_output = cast( + ResponseOutputItem, + construct_type( + type_=ResponseOutputItem, + value={ + "id": "tso_stream", + "type": "tool_search_output", + "execution": "server", + "status": "completed", + "tools": [ + { + "type": "function", + "name": "list_open_orders", + "description": "List open orders for a customer.", + "parameters": { + "type": "object", + "properties": { + "customer_id": { + "type": "string", + } + }, + "required": ["customer_id"], + }, + "defer_loading": True, + } + ], + }, + ), + ) + model.add_multiple_turn_outputs( + [[tool_search_call, tool_search_output, get_text_message("Done")]] + ) + + result = Runner.run_streamed(agent, input="Search for CRM order tools") + + seen_events: list[tuple[str, object]] = [] + async for event in result.stream_events(): + if event.type != "run_item_stream_event": + continue + seen_events.append((event.name, event.item)) + + assert any( + name == "tool_search_called" and isinstance(item, ToolSearchCallItem) + for name, item in seen_events + ) + assert any( + name == "tool_search_output_created" and isinstance(item, ToolSearchOutputItem) + for name, item in seen_events + ) diff --git a/tests/test_tool_choice_reset.py b/tests/test_tool_choice_reset.py index ecb460e2..ea3113e5 100644 --- a/tests/test_tool_choice_reset.py +++ b/tests/test_tool_choice_reset.py @@ -47,6 +47,13 @@ class TestToolChoiceReset: new_settings = maybe_reset_tool_choice(agent, tracker, model_settings) assert new_settings.tool_choice is None + # Case 5b: a literal tool named "tool_search" should count like any other tool. + model_settings = ModelSettings(tool_choice="required") + tracker = AgentToolUseTracker() + tracker.add_tool_use(agent, ["tool_search"]) + new_settings = maybe_reset_tool_choice(agent, tracker, model_settings) + assert new_settings.tool_choice is None + # Case 6: Tool usage on a different agent should not affect the tool choice model_settings = ModelSettings(tool_choice="foo_bar") tracker = AgentToolUseTracker() diff --git a/tests/test_tool_context.py b/tests/test_tool_context.py index 216f44a0..c1c07b83 100644 --- a/tests/test_tool_context.py +++ b/tests/test_tool_context.py @@ -74,6 +74,48 @@ def test_tool_context_constructor_accepts_agent_keyword() -> None: assert tool_ctx.agent is agent +def test_tool_context_constructor_infers_namespace_from_tool_call() -> None: + tool_call = ResponseFunctionToolCall( + type="function_call", + name="lookup_account", + call_id="call-2", + arguments="{}", + namespace="billing", + ) + + tool_ctx: ToolContext[dict[str, object]] = ToolContext( + context={}, + tool_name="lookup_account", + tool_call_id="call-2", + tool_arguments="{}", + tool_call=tool_call, + ) + + assert tool_ctx.tool_namespace == "billing" + assert tool_ctx.qualified_tool_name == "billing.lookup_account" + + +def test_tool_context_qualified_tool_name_collapses_synthetic_namespace() -> None: + tool_call = ResponseFunctionToolCall( + type="function_call", + name="get_weather", + call_id="call-weather", + arguments="{}", + namespace="get_weather", + ) + + tool_ctx: ToolContext[dict[str, object]] = ToolContext( + context={}, + tool_name="get_weather", + tool_call_id="call-weather", + tool_arguments="{}", + tool_call=tool_call, + ) + + assert tool_ctx.tool_namespace == "get_weather" + assert tool_ctx.qualified_tool_name == "get_weather" + + def test_tool_context_from_tool_context_inherits_agent() -> None: original_call = ResponseFunctionToolCall( type="function_call", diff --git a/tests/test_tool_converter.py b/tests/test_tool_converter.py index 6e76a13b..9fa8ac6a 100644 --- a/tests/test_tool_converter.py +++ b/tests/test_tool_converter.py @@ -1,7 +1,7 @@ import pytest from pydantic import BaseModel -from agents import Agent, Handoff, function_tool, handoff +from agents import Agent, Handoff, function_tool, handoff, tool_namespace from agents.exceptions import UserError from agents.models.chatcmpl_converter import Converter from agents.tool import FileSearchTool, WebSearchTool @@ -62,3 +62,21 @@ def test_tool_converter_hosted_tools_errors(): with pytest.raises(UserError): Converter.tool_to_openai(FileSearchTool(vector_store_ids=["abc"], max_num_results=1)) + + +def test_tool_converter_rejects_namespaced_function_tools_for_chat_backends(): + tool = tool_namespace( + name="crm", + description="CRM tools", + tools=[function_tool(some_function)], + )[0] + + with pytest.raises(UserError, match="tool_namespace\\(\\)"): + Converter.tool_to_openai(tool) + + +def test_tool_converter_rejects_deferred_function_tools_for_chat_backends(): + tool = function_tool(some_function, defer_loading=True) + + with pytest.raises(UserError, match="defer_loading=True"): + Converter.tool_to_openai(tool) diff --git a/tests/test_tool_use_behavior.py b/tests/test_tool_use_behavior.py index b05d5622..de7f98b4 100644 --- a/tests/test_tool_use_behavior.py +++ b/tests/test_tool_use_behavior.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import cast +from typing import Any, cast import pytest from openai.types.responses.response_input_item_param import FunctionCallOutput @@ -14,6 +14,8 @@ from agents import ( ToolCallOutputItem, ToolsToFinalOutputResult, UserError, + function_tool, + tool_namespace, ) from agents.run_internal import run_loop @@ -21,10 +23,14 @@ from .test_responses import get_function_tool def _make_function_tool_result( - agent: Agent, output: str, tool_name: str | None = None + agent: Agent, + output: str, + tool_name: str | None = None, + *, + tool: Any | None = None, ) -> FunctionToolResult: # Construct a FunctionToolResult with the given output using a simple function tool. - tool = get_function_tool(tool_name or "dummy", return_value=output) + tool = tool or get_function_tool(tool_name or "dummy", return_value=output) raw_item: FunctionCallOutput = cast( FunctionCallOutput, { @@ -183,3 +189,38 @@ async def test_tool_names_to_stop_at_behavior() -> None: ) assert result.is_final_output is True, "We should have stopped at tool1" assert result.final_output == "output1" + + +@pytest.mark.asyncio +async def test_stop_at_tool_names_supports_public_and_qualified_names_for_namespaced_tools() -> ( + None +): + namespaced_tool = tool_namespace( + name="billing", + description="Billing tools", + tools=[function_tool(lambda account_id: account_id, name_override="lookup_account")], + )[0] + agent = Agent( + name="test", + tools=[namespaced_tool], + tool_use_behavior={"stop_at_tool_names": ["lookup_account"]}, + ) + + tool_results = [ + _make_function_tool_result(agent, "billing-output", tool=namespaced_tool), + ] + result = await run_loop.check_for_final_output_from_tools( + agent=agent, + tool_results=tool_results, + context_wrapper=RunContextWrapper(context=None), + ) + assert result.is_final_output is True + assert result.final_output == "billing-output" + + agent.tool_use_behavior = {"stop_at_tool_names": ["billing.lookup_account"]} + result = await run_loop.check_for_final_output_from_tools( + agent=agent, + tool_results=tool_results, + context_wrapper=RunContextWrapper(context=None), + ) + assert result.is_final_output is True diff --git a/tests/test_tool_use_tracker.py b/tests/test_tool_use_tracker.py index 9784cbf6..d2276c85 100644 --- a/tests/test_tool_use_tracker.py +++ b/tests/test_tool_use_tracker.py @@ -1,12 +1,21 @@ from __future__ import annotations -from agents import Agent +from typing import Any, cast + +from openai.types.responses import ResponseFunctionToolCall + +from agents import Agent, ModelSettings, function_tool, tool_namespace +from agents.items import ToolCallItem, ToolCallOutputItem, ToolSearchCallItem, ToolSearchOutputItem +from agents.run_internal.run_loop import maybe_reset_tool_choice +from agents.run_internal.run_steps import ProcessedResponse, ToolRunFunction from agents.run_internal.tool_use_tracker import ( AgentToolUseTracker, hydrate_tool_use_tracker, serialize_tool_use_tracker, ) +from .test_responses import get_function_tool_call + def test_tool_use_tracker_as_serializable_uses_agent_map_or_runtime_snapshot() -> None: tracker = AgentToolUseTracker() @@ -30,6 +39,135 @@ def test_tool_use_tracker_from_and_serialize_snapshots() -> None: assert serialize_tool_use_tracker(runtime_tracker) == {"serialize-agent": ["one", "two"]} +def test_record_used_tools_uses_trace_names_for_namespaced_and_deferred_functions() -> None: + agent = Agent(name="tracked-agent") + tracker = AgentToolUseTracker() + + billing_tool = tool_namespace( + name="billing", + description="Billing tools", + tools=[function_tool(lambda customer_id: customer_id, name_override="lookup_account")], + )[0] + deferred_tool = function_tool( + lambda city: city, + name_override="get_weather", + defer_loading=True, + ) + + tracker.record_used_tools( + agent, + [ + ToolRunFunction( + function_tool=billing_tool, + tool_call=cast( + ResponseFunctionToolCall, + get_function_tool_call("lookup_account", namespace="billing"), + ), + ), + ToolRunFunction( + function_tool=deferred_tool, + tool_call=cast( + ResponseFunctionToolCall, + get_function_tool_call("get_weather", namespace="get_weather"), + ), + ), + ], + ) + + assert tracker.as_serializable() == {"tracked-agent": ["billing.lookup_account", "get_weather"]} + + +def test_record_processed_response_ignores_hosted_tool_search_for_resets(): + agent = Agent(name="tracked-agent") + tracker = AgentToolUseTracker() + processed_response = ProcessedResponse( + new_items=[ + ToolSearchCallItem(agent=agent, raw_item={"type": "tool_search_call"}), + ToolSearchOutputItem(agent=agent, raw_item={"type": "tool_search_output"}), + ], + handoffs=[], + functions=[], + computer_actions=[], + local_shell_calls=[], + shell_calls=[], + apply_patch_calls=[], + tools_used=["tool_search", "tool_search"], + mcp_approval_requests=[], + interruptions=[], + ) + + tracker.record_processed_response(agent, processed_response) + + assert tracker.has_used_tools(agent) is False + assert tracker.as_serializable() == {} + assert maybe_reset_tool_choice( + agent, tracker, ModelSettings(tool_choice="required") + ).tool_choice == ("required") + + +def test_record_processed_response_keeps_function_named_tool_search(): + agent = Agent(name="tracked-agent") + tracker = AgentToolUseTracker() + processed_response = ProcessedResponse( + new_items=[ + ToolSearchCallItem(agent=agent, raw_item={"type": "tool_search_call"}), + ToolSearchOutputItem(agent=agent, raw_item={"type": "tool_search_output"}), + ToolCallItem( + raw_item=cast(ResponseFunctionToolCall, get_function_tool_call("tool_search")), + agent=agent, + ), + ], + handoffs=[], + functions=[], + computer_actions=[], + local_shell_calls=[], + shell_calls=[], + apply_patch_calls=[], + tools_used=["tool_search", "tool_search", "tool_search"], + mcp_approval_requests=[], + interruptions=[], + ) + + tracker.record_processed_response(agent, processed_response) + + assert tracker.as_serializable() == {"tracked-agent": ["tool_search"]} + + +def test_record_processed_response_counts_output_only_tools_without_shifting_names() -> None: + agent = Agent(name="tracked-agent") + tracker = AgentToolUseTracker() + processed_response = ProcessedResponse( + new_items=[ + ToolCallOutputItem( + agent=agent, + raw_item=cast( + Any, + {"type": "shell_call_output", "call_id": "shell-1", "output": []}, + ), + output=[], + ), + ToolCallItem( + raw_item=cast(ResponseFunctionToolCall, get_function_tool_call("lookup_account")), + agent=agent, + ), + ], + handoffs=[], + functions=[], + computer_actions=[], + local_shell_calls=[], + shell_calls=[], + apply_patch_calls=[], + tools_used=["shell", "lookup_account"], + mcp_approval_requests=[], + interruptions=[], + ) + + tracker.record_processed_response(agent, processed_response) + + assert tracker.has_used_tools(agent) + assert tracker.as_serializable() == {"tracked-agent": ["lookup_account", "shell"]} + + def test_hydrate_tool_use_tracker_skips_unknown_agents() -> None: class _RunState: def get_tool_use_tracker_snapshot(self) -> dict[str, list[str]]: diff --git a/tests/utils/factories.py b/tests/utils/factories.py index 415e50dd..00be18d7 100644 --- a/tests/utils/factories.py +++ b/tests/utils/factories.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, Callable, Literal, TypeVar +from typing import Any, Callable, Literal, TypeVar, cast from openai.types.responses import ( ResponseFunctionToolCall, @@ -9,30 +9,36 @@ from openai.types.responses import ( ) from agents import Agent +from agents._tool_identity import FunctionToolLookupKey, get_function_tool_lookup_key from agents.items import ToolApprovalItem from agents.run_context import RunContextWrapper from agents.run_state import RunState TContext = TypeVar("TContext") +_AUTO_LOOKUP_KEY = object() def make_tool_call( call_id: str = "call_1", *, name: str = "test_tool", + namespace: str | None = None, status: Literal["in_progress", "completed", "incomplete"] | None = "completed", arguments: str = "{}", call_type: Literal["function_call"] = "function_call", ) -> ResponseFunctionToolCall: """Build a ResponseFunctionToolCall with common defaults.""" - return ResponseFunctionToolCall( - type=call_type, - name=name, - call_id=call_id, - status=status, - arguments=arguments, - ) + kwargs: dict[str, Any] = { + "type": call_type, + "name": name, + "call_id": call_id, + "status": status, + "arguments": arguments, + } + if namespace is not None: + kwargs["namespace"] = namespace + return ResponseFunctionToolCall(**kwargs) def make_tool_approval_item( @@ -40,19 +46,32 @@ def make_tool_approval_item( *, call_id: str = "call_1", name: str = "test_tool", + namespace: str | None = None, + allow_bare_name_alias: bool = False, status: Literal["in_progress", "completed", "incomplete"] | None = "completed", arguments: str = "{}", + tool_lookup_key: FunctionToolLookupKey | None | object = _AUTO_LOOKUP_KEY, ) -> ToolApprovalItem: """Create a ToolApprovalItem backed by a function call.""" + resolved_tool_lookup_key: FunctionToolLookupKey | None + if tool_lookup_key is _AUTO_LOOKUP_KEY: + resolved_tool_lookup_key = get_function_tool_lookup_key(name, namespace) + else: + resolved_tool_lookup_key = cast(FunctionToolLookupKey | None, tool_lookup_key) + return ToolApprovalItem( agent=agent, raw_item=make_tool_call( call_id=call_id, name=name, + namespace=namespace, status=status, arguments=arguments, ), + tool_namespace=namespace, + tool_lookup_key=resolved_tool_lookup_key, + _allow_bare_name_alias=allow_bare_name_alias, ) diff --git a/tests/utils/hitl.py b/tests/utils/hitl.py index a10d5178..39ff8b2a 100644 --- a/tests/utils/hitl.py +++ b/tests/utils/hitl.py @@ -311,13 +311,22 @@ def make_function_tool_call( *, call_id: str = "call-1", arguments: str = "{}", + namespace: str | None = None, ) -> ResponseFunctionToolCall: """Create a ResponseFunctionToolCall for HITL scenarios.""" + if namespace is None: + return ResponseFunctionToolCall( + type="function_call", + name=name, + call_id=call_id, + arguments=arguments, + ) return ResponseFunctionToolCall( type="function_call", name=name, call_id=call_id, arguments=arguments, + namespace=namespace, ) diff --git a/uv.lock b/uv.lock index baaafabb..d2b198ce 100644 --- a/uv.lock +++ b/uv.lock @@ -1860,7 +1860,7 @@ wheels = [ [[package]] name = "openai" -version = "2.20.0" +version = "2.25.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1872,9 +1872,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6e/5a/f495777c02625bfa18212b6e3b73f1893094f2bf660976eb4bc6f43a1ca2/openai-2.20.0.tar.gz", hash = "sha256:2654a689208cd0bf1098bb9462e8d722af5cbe961e6bba54e6f19fb843d88db1", size = 642355, upload-time = "2026-02-10T19:02:54.145Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/2b/a442b206ed74908dd79e2ad1ef3ffaeae66422b1fb506af981f0ef671ba0/openai-2.25.0.tar.gz", hash = "sha256:c3e1965d83c333dbd341eb2c7c8aceb783c272cd57fc57353404d9a443634e29", size = 666577, upload-time = "2026-03-05T18:35:38.292Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/a0/cf4297aa51bbc21e83ef0ac018947fa06aea8f2364aad7c96cbf148590e6/openai-2.20.0-py3-none-any.whl", hash = "sha256:38d989c4b1075cd1f76abc68364059d822327cf1a932531d429795f4fc18be99", size = 1098479, upload-time = "2026-02-10T19:02:52.157Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e6/ca496976bacdd7f86d4fa69359c2d9b5a547d7acbf0ae5cac7bff107ff50/openai-2.25.0-py3-none-any.whl", hash = "sha256:7c7c01a0a4b69771a29913e28d06bc1e9cee8781b4d206bb56cb946d8d2fcb23", size = 1136347, upload-time = "2026-03-05T18:35:36.497Z" }, ] [[package]] @@ -1964,7 +1964,7 @@ requires-dist = [ { name = "litellm", marker = "extra == 'litellm'", specifier = ">=1.81.0,<2" }, { name = "mcp", marker = "python_full_version >= '3.10'", specifier = ">=1.19.0,<2" }, { name = "numpy", marker = "python_full_version >= '3.10' and extra == 'voice'", specifier = ">=2.2.0,<3" }, - { name = "openai", specifier = ">=2.19.0,<3" }, + { name = "openai", specifier = ">=2.25.0,<3" }, { name = "pydantic", specifier = ">=2.12.3,<3" }, { name = "redis", marker = "extra == 'redis'", specifier = ">=7" }, { name = "requests", specifier = ">=2.0,<3" },