Tool calling
AI auto-discovers and invokes other agents as tools through the control plane
AI automatically discovers other agents in the network and invokes them as tools to complete tasks. Pass tools="discover" and the SDK queries the control plane for all available agent capabilities, converts them to LLM-native tool schemas, and runs an automatic execution loop -- the LLM decides which agents to call, the SDK dispatches, feeds results back, and repeats until done.
from agentfield.tool_calling import ToolCallConfig
@app.reasoner()
async def plan_trip(destination: str, budget: float) -> dict:
# Auto-discover all agents in the network — LLM picks which to call
result = await app.ai(
system="You are a travel planner. Use available tools to build an itinerary.",
user=f"Plan a 5-day trip to {destination} under ${budget}",
tools="discover", # finds flight-search, hotel-booking, weather, etc.
)
# Full trace observability — see exactly what the AI did
print(f"{result.trace.total_turns} turns, {result.trace.total_tool_calls} tool calls")
for call in result.trace.calls:
status = "OK" if not call.error else f"ERR: {call.error}"
print(f" [{call.turn}] {call.tool_name} ({call.latency_ms:.0f}ms) -> {status}")
return {"itinerary": result.text}
# Filtered discovery — only expose specific capabilities by tag
result = await app.ai(
user="What's the weather in Tokyo and translate the forecast to Spanish?",
tools=ToolCallConfig(
tags=["weather", "translation"], # only matching agents visible to LLM
max_turns=5,
max_tool_calls=10,
),
)
# Lazy schema hydration — for large registries (100+ tools)
result = await app.ai(
user="Help me analyze this dataset.",
tools=ToolCallConfig(
schema_hydration="lazy", # send names+descriptions first
max_candidate_tools=50, # present at most 50 tools
max_hydrated_tools=10, # full schemas only for top 10 selected
),
)
# Combine tool calling with structured output
class TravelPlan(BaseModel):
destination: str
activities: list[str]
estimated_cost: float
plan = await app.ai(
user="Plan a weekend in Barcelona.",
tools="discover", # gather data via tools
schema=TravelPlan, # return validated schema
)
print(plan.estimated_cost) # 847.50 — typed floatWhat just happened
The SDK discovered callable capabilities, converted them into tool definitions for the model, executed the chosen agent calls, and returned both the final answer and the trace metadata. That means the visible example is not just tool use, it is tool use with bounded turns, bounded calls, and replayable execution details.
{
"total_turns": 3,
"total_tool_calls": 2,
"calls": [
{ "turn": 1, "tool": "flights.search", "latency_ms": 184 },
{ "turn": 2, "tool": "hotels.search", "latency_ms": 231 }
]
}
Session tools
Sessions expose two different tool-calling surfaces — they look similar and mean different things:
| Surface | Who decides to call | Use for |
|---|---|---|
session.call(target, **input) | The handler code | Handler-controlled orchestration: same shape as app.call, runs through the control plane. |
tools=[...] on the session decorator | The realtime model or browser-side tool bridge | Provider-visible allowlist for autonomous tool calls during the live audio loop. |
tools=[...] is not Python dependency injection and is not required for the handler to call a reasoner. It is the explicit exposure boundary for the capabilities the live realtime model may invoke on its own. Either path forwards X-Session-ID into execute/async, so the call lands in the session's workflow DAG.
How It Works
Patterns
Filtered Discovery
Only expose specific agents or capabilities to the LLM.
from agentfield.tool_calling import ToolCallConfig
result = await app.ai(
user="What's the weather in Tokyo and translate the forecast to Spanish?",
tools=ToolCallConfig(
tags=["weather", "translation"], # Only weather and translation tools
max_turns=5,
max_tool_calls=10,
),
)Lazy Schema Hydration
For large registries (100+ tools), send metadata first and hydrate schemas only for tools the LLM selects.
result = await app.ai(
user="Help me analyze this dataset.",
tools=ToolCallConfig(
schema_hydration="lazy", # Send names+descriptions first
max_candidate_tools=50, # Present at most 50 tools
max_hydrated_tools=10, # Full schemas for top 10 selected
),
)Structured Output with Tool Calling
Combine tool calling with structured output -- the LLM uses tools to gather data, then returns a validated schema.
class TravelPlan(BaseModel):
destination: str
weather: str
activities: list[str]
estimated_cost: float
plan = await app.ai(
system="Plan a trip using available tools for weather and activity data.",
user="Plan a weekend trip to Barcelona.",
tools="discover",
schema=TravelPlan,
)
print(plan.destination) # Validated TravelPlanObservability and Debugging
Inspect the full trace to understand what the AI did.
result = await app.ai(
user="Research and summarize recent AI papers.",
tools="discover",
)
print(f"Completed in {result.trace.total_turns} turns")
print(f"Made {result.trace.total_tool_calls} tool calls")
for call in result.trace.calls:
status = "OK" if not call.error else f"ERROR: {call.error}"
print(f" [{call.turn}] {call.tool_name} ({call.latency_ms:.0f}ms) -> {status}")Per-Call Limits
Override the default config for specific calls.
# Quick lookup -- tight limits
result = await app.ai(
user="What time is it in London?",
tools="discover",
max_turns=3,
max_tool_calls=2,
)
# Complex research -- generous limits
result = await app.ai(
user="Compare pricing across all our vendor APIs.",
tools="discover",
max_turns=20,
max_tool_calls=50,
)ToolCallConfig
Fine-tune the tool-calling loop with ToolCallConfig.
| Field | Type | Default | Description |
|---|---|---|---|
max_turns | int | 10 | Maximum LLM round-trips |
max_tool_calls | int | 25 | Maximum total tool invocations |
max_candidate_tools | int? | None | Limit tools presented to the LLM |
max_hydrated_tools | int? | None | Limit tools with full schemas (lazy mode) |
schema_hydration | "eager" | "lazy" | "eager" | When to fetch full input schemas |
fallback_broadening | bool | False | Broaden discovery if no tools match |
tags | list[str]? | None | Filter by capability tags |
agent_ids | list[str]? | None | Filter by specific agent IDs |
health_status | string? | None | Filter by agent health status |
ToolCallTrace
Every tool-calling response includes a ToolCallTrace for full observability.
| Field | Type | Description |
|---|---|---|
calls | list[ToolCallRecord] | Individual tool call records |
total_turns | int | Number of LLM round-trips |
total_tool_calls | int | Total tool invocations |
final_response | string? | Final text from the LLM |
ToolCallRecord
| Field | Type | Description |
|---|---|---|
tool_name | string | Sanitized tool name |
arguments | dict | Arguments passed to the tool |
result | Any? | Tool call result |
error | string? | Error message on failure |
latency_ms | float | Call duration in milliseconds |
turn | int | Which turn this call occurred in |
SDK Reference
| Feature | Python | TypeScript | Go |
|---|---|---|---|
| Auto-discover | tools="discover" | tools: 'discover' | AIWithTools() discovers automatically |
| Config object | ToolCallConfig(...) | { maxTurns, maxToolCalls, ... } | ai.ToolCallConfig{} |
| Response type | ToolCallResponse | { text: string; trace: ToolCallTrace } | (*ai.Response, *ai.ToolCallTrace, error) |
| Access trace | result.trace | result.trace | Second return value |
| Access text | result.text | result.text | resp.Choices[0].Message.Content[0].Text |
| Per-call limits | max_turns=5, max_tool_calls=10 | { maxTurns: 5, maxToolCalls: 10 } | ToolCallConfig{MaxTurns: 5, MaxToolCalls: 10} |
| Manual tool schemas | N/A | N/A | CapabilityToToolDefinition(cap) |
| Execute loop | Built into app.ai() | Built into ctx.aiWithTools() | aiClient.ExecuteToolCallLoop(ctx, msgs, tools, config, callFn) |
Go SDK Details
The Go SDK provides three key functions for tool calling:
// AIWithTools — high-level: discover + loop + trace in one call
resp, trace, err := app.AIWithTools(ctx, prompt, ai.ToolCallConfig{
MaxTurns: 10,
MaxToolCalls: 25,
})
// CapabilitiesToToolDefinitions — convert all discovered capabilities to OpenAI tool schemas
caps, _ := app.Discover(ctx)
tools := ai.CapabilitiesToToolDefinitions(caps.JSON.Capabilities)
// ExecuteToolCallLoop — low-level: run the tool-call loop on the AI client
messages := []ai.Message{{Role: "user", Content: []ai.ContentPart{{Type: "text", Text: prompt}}}}
tools := ai.CapabilitiesToToolDefinitions(caps.JSON.Capabilities)
callFn := func(ctx context.Context, target string, input map[string]interface{}) (map[string]interface{}, error) {
return app.Call(ctx, target, input)
}
finalResp, trace, err := aiClient.ExecuteToolCallLoop(ctx, messages, tools, ai.ToolCallConfig{
MaxTurns: 5,
MaxToolCalls: 25,
}, callFn)