feat: add Responses API tool search support (#2610)
This commit is contained in:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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], [])
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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=(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": []})
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user