feat: add Responses API tool search support (#2610)

This commit is contained in:
Kazuhiro Sera
2026-03-06 19:23:38 +09:00
committed by GitHub
parent 9b1f6eddc8
commit 9ac31ab49f
60 changed files with 7143 additions and 288 deletions
+219
View File
@@ -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())
+1 -1
View File
@@ -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",
+4
View File
@@ -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",
+428
View File
@@ -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))
+48 -4
View File
@@ -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(
+6
View File
@@ -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",
]
@@ -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()
+166 -37
View File
@@ -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)
+154 -5
View File
@@ -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:
+20 -2
View File
@@ -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": {
+329 -77
View File
@@ -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,
)
+16 -2
View File
@@ -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,
+2 -2
View File
@@ -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
+152 -20
View File
@@ -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:
+20 -7
View File
@@ -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)
+36 -1
View File
@@ -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):
+89 -5
View File
@@ -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(
+86 -12
View File
@@ -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)
+6
View File
@@ -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):
+129 -12
View File
@@ -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,
+6
View File
@@ -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),
)
+48 -3
View File
@@ -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)
+128 -31
View File
@@ -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 = [
+183 -14
View File
@@ -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
+2
View File
@@ -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",
+179
View File
@@ -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
+26
View File
@@ -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,
@@ -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"
+135 -3
View File
@@ -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
+1 -1
View File
@@ -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",
+10 -1
View File
@@ -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 = {
@@ -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], [])
+255
View File
@@ -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,
+349
View File
@@ -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."""
+50
View File
@@ -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=(
+119
View File
@@ -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:
+9
View File
@@ -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:
+257
View File
@@ -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."""
+48
View File
@@ -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(
+6 -7
View File
@@ -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(
@@ -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
+91 -1
View File
@@ -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):
+583
View File
@@ -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(
+429 -3
View File
@@ -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=[],
)
+15 -8
View File
@@ -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(
+149
View File
@@ -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
)
+20
View File
@@ -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"
+256 -1
View File
@@ -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": []})
+580
View File
@@ -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")
+293
View File
@@ -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."""
+387 -1
View File
@@ -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(
+79 -1
View File
@@ -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
)
+7
View File
@@ -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()
+42
View File
@@ -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",
+19 -1
View File
@@ -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)
+44 -3
View File
@@ -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
+139 -1
View File
@@ -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]]:
+27 -8
View File
@@ -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,
)
+9
View File
@@ -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,
)
Generated
+4 -4
View File
@@ -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" },