Skip to content

Commit 9ac31ab

Browse files
authored
feat: add Responses API tool search support (#2610)
1 parent 9b1f6ed commit 9ac31ab

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

60 files changed

+7140
-285
lines changed

examples/tools/tool_search.py

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import asyncio
2+
import json
3+
import sys
4+
from collections.abc import Mapping
5+
from typing import Annotated, Any
6+
7+
from agents import (
8+
Agent,
9+
ModelSettings,
10+
Runner,
11+
ToolSearchTool,
12+
function_tool,
13+
tool_namespace,
14+
trace,
15+
)
16+
17+
CUSTOMER_PROFILES = {
18+
"customer_42": {
19+
"customer_id": "customer_42",
20+
"full_name": "Avery Chen",
21+
"tier": "enterprise",
22+
}
23+
}
24+
25+
OPEN_ORDERS = {
26+
"customer_42": [
27+
{"order_id": "ord_1042", "status": "awaiting fulfillment"},
28+
{"order_id": "ord_1049", "status": "pending approval"},
29+
]
30+
}
31+
32+
INVOICE_STATUSES = {
33+
"inv_2001": "paid",
34+
}
35+
36+
SHIPPING_ETAS = {
37+
"ZX-123": "2026-03-06 14:00 JST",
38+
}
39+
40+
SHIPPING_CREDIT_BALANCES = {
41+
"customer_42": "$125.00",
42+
}
43+
44+
45+
@function_tool(defer_loading=True)
46+
def get_customer_profile(
47+
customer_id: Annotated[str, "The CRM customer identifier to look up."],
48+
) -> str:
49+
"""Fetch a CRM customer profile."""
50+
return json.dumps(CUSTOMER_PROFILES[customer_id], indent=2)
51+
52+
53+
@function_tool(defer_loading=True)
54+
def list_open_orders(
55+
customer_id: Annotated[str, "The CRM customer identifier to look up."],
56+
) -> str:
57+
"""List open orders for a customer."""
58+
return json.dumps(OPEN_ORDERS.get(customer_id, []), indent=2)
59+
60+
61+
@function_tool(defer_loading=True)
62+
def get_invoice_status(
63+
invoice_id: Annotated[str, "The invoice identifier to look up."],
64+
) -> str:
65+
"""Look up the status of an invoice."""
66+
return INVOICE_STATUSES.get(invoice_id, "unknown")
67+
68+
69+
@function_tool(defer_loading=True)
70+
def get_shipping_eta(
71+
tracking_number: Annotated[str, "The shipment tracking number to look up."],
72+
) -> str:
73+
"""Look up a shipment ETA by tracking number."""
74+
return SHIPPING_ETAS.get(tracking_number, "unavailable")
75+
76+
77+
@function_tool(defer_loading=True)
78+
def get_shipping_credit_balance(
79+
customer_id: Annotated[str, "The customer account identifier to look up."],
80+
) -> str:
81+
"""Look up the available shipping credit balance for a customer."""
82+
return SHIPPING_CREDIT_BALANCES.get(customer_id, "$0.00")
83+
84+
85+
crm_tools = tool_namespace(
86+
name="crm",
87+
description="CRM tools for customer lookups.",
88+
tools=[get_customer_profile, list_open_orders],
89+
)
90+
91+
billing_tools = tool_namespace(
92+
name="billing",
93+
description="Billing tools for invoice lookups.",
94+
tools=[get_invoice_status],
95+
)
96+
97+
namespaced_agent = Agent(
98+
name="Operations assistant",
99+
model="gpt-5.4",
100+
instructions=(
101+
"For customer questions in this example, load the full `crm` namespace with no query "
102+
"filter before calling tools. "
103+
"Do not search `billing` unless the user asks about invoices."
104+
),
105+
model_settings=ModelSettings(parallel_tool_calls=False),
106+
tools=[*crm_tools, *billing_tools, ToolSearchTool()],
107+
)
108+
109+
top_level_agent = Agent(
110+
name="Shipping assistant",
111+
model="gpt-5.4",
112+
instructions=(
113+
"For ETA questions in this example, search `get_shipping_eta` before calling tools. "
114+
"Do not search `get_shipping_credit_balance` unless the user asks about shipping credits."
115+
),
116+
model_settings=ModelSettings(parallel_tool_calls=False),
117+
tools=[get_shipping_eta, get_shipping_credit_balance, ToolSearchTool()],
118+
)
119+
120+
121+
def loaded_paths(result: Any) -> list[str]:
122+
paths: set[str] = set()
123+
124+
for item in result.new_items:
125+
if item.type != "tool_search_output_item":
126+
continue
127+
128+
raw_tools = (
129+
item.raw_item.get("tools")
130+
if isinstance(item.raw_item, Mapping)
131+
else getattr(item.raw_item, "tools", None)
132+
)
133+
if not isinstance(raw_tools, list):
134+
continue
135+
136+
for raw_tool in raw_tools:
137+
tool_payload = (
138+
raw_tool
139+
if isinstance(raw_tool, Mapping)
140+
else (
141+
raw_tool.model_dump(exclude_unset=True)
142+
if callable(getattr(raw_tool, "model_dump", None))
143+
else None
144+
)
145+
)
146+
if not isinstance(tool_payload, Mapping):
147+
continue
148+
149+
tool_type = tool_payload.get("type")
150+
if tool_type == "namespace":
151+
path = tool_payload.get("name")
152+
elif tool_type == "function":
153+
path = tool_payload.get("name")
154+
else:
155+
path = tool_payload.get("server_label")
156+
157+
if isinstance(path, str) and path:
158+
paths.add(path)
159+
160+
return sorted(paths)
161+
162+
163+
def print_result(title: str, result: Any, registered_paths: list[str]) -> None:
164+
loaded = loaded_paths(result)
165+
untouched = [path for path in registered_paths if path not in loaded]
166+
167+
print(f"## {title}")
168+
print("### Final output")
169+
print(result.final_output)
170+
print("\n### Loaded paths")
171+
print(f"- registered: {', '.join(registered_paths)}")
172+
print(f"- loaded: {', '.join(loaded) if loaded else 'none'}")
173+
print(f"- untouched: {', '.join(untouched) if untouched else 'none'}")
174+
print("\n### Relevant items")
175+
for item in result.new_items:
176+
if item.type in {"tool_search_call_item", "tool_search_output_item", "tool_call_item"}:
177+
print(f"- {item.type}: {item.raw_item}")
178+
print()
179+
180+
181+
async def run_namespaced_example() -> None:
182+
result = await Runner.run(
183+
namespaced_agent,
184+
"Look up customer_42 and list their open orders.",
185+
)
186+
print_result(
187+
"Tool search with namespaces",
188+
result,
189+
registered_paths=["crm", "billing"],
190+
)
191+
192+
193+
async def run_top_level_example() -> None:
194+
result = await Runner.run(
195+
top_level_agent,
196+
"Can you get my ETA for tracking number ZX-123?",
197+
)
198+
print_result(
199+
"Tool search with top-level deferred tools",
200+
result,
201+
registered_paths=["get_shipping_eta", "get_shipping_credit_balance"],
202+
)
203+
204+
205+
async def main() -> None:
206+
mode = sys.argv[1] if len(sys.argv) > 1 else "all"
207+
208+
if mode not in {"all", "namespace", "top-level"}:
209+
raise SystemExit(f"Unknown mode: {mode}. Expected one of: all, namespace, top-level.")
210+
211+
with trace("Tool search example"):
212+
if mode in {"all", "namespace"}:
213+
await run_namespaced_example()
214+
if mode in {"all", "top-level"}:
215+
await run_top_level_example()
216+
217+
218+
if __name__ == "__main__":
219+
asyncio.run(main())

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ requires-python = ">=3.10"
77
license = "MIT"
88
authors = [{ name = "OpenAI", email = "support@openai.com" }]
99
dependencies = [
10-
"openai>=2.19.0,<3",
10+
"openai>=2.25.0,<3",
1111
"pydantic>=2.12.3, <3",
1212
"griffe>=1.5.6, <2",
1313
"typing-extensions>=4.12.2, <5",

src/agents/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,11 +154,13 @@
154154
ToolOutputImageDict,
155155
ToolOutputText,
156156
ToolOutputTextDict,
157+
ToolSearchTool,
157158
WebSearchTool,
158159
default_tool_error_function,
159160
dispose_resolved_computers,
160161
function_tool,
161162
resolve_computer,
163+
tool_namespace,
162164
)
163165
from .tool_guardrails import (
164166
ToolGuardrailFunctionOutput,
@@ -420,7 +422,9 @@ def enable_verbose_stdout_logging():
420422
"ToolOutputImageDict",
421423
"ToolOutputFileContent",
422424
"ToolOutputFileContentDict",
425+
"ToolSearchTool",
423426
"function_tool",
427+
"tool_namespace",
424428
"resolve_computer",
425429
"dispose_resolved_computers",
426430
"Usage",

0 commit comments

Comments
 (0)