AgentFieldbuild

Tool calling

AI auto-discovers and invokes other agents as tools through the control plane

LLM-driven tool discovery and execution

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 float

What 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:

SurfaceWho decides to callUse for
session.call(target, **input)The handler codeHandler-controlled orchestration: same shape as app.call, runs through the control plane.
tools=[...] on the session decoratorThe realtime model or browser-side tool bridgeProvider-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 TravelPlan

Observability 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.

FieldTypeDefaultDescription
max_turnsint10Maximum LLM round-trips
max_tool_callsint25Maximum total tool invocations
max_candidate_toolsint?NoneLimit tools presented to the LLM
max_hydrated_toolsint?NoneLimit tools with full schemas (lazy mode)
schema_hydration"eager" | "lazy""eager"When to fetch full input schemas
fallback_broadeningboolFalseBroaden discovery if no tools match
tagslist[str]?NoneFilter by capability tags
agent_idslist[str]?NoneFilter by specific agent IDs
health_statusstring?NoneFilter by agent health status
ToolCallTrace

Every tool-calling response includes a ToolCallTrace for full observability.

FieldTypeDescription
callslist[ToolCallRecord]Individual tool call records
total_turnsintNumber of LLM round-trips
total_tool_callsintTotal tool invocations
final_responsestring?Final text from the LLM

ToolCallRecord

FieldTypeDescription
tool_namestringSanitized tool name
argumentsdictArguments passed to the tool
resultAny?Tool call result
errorstring?Error message on failure
latency_msfloatCall duration in milliseconds
turnintWhich turn this call occurred in
SDK Reference
FeaturePythonTypeScriptGo
Auto-discovertools="discover"tools: 'discover'AIWithTools() discovers automatically
Config objectToolCallConfig(...){ maxTurns, maxToolCalls, ... }ai.ToolCallConfig{}
Response typeToolCallResponse{ text: string; trace: ToolCallTrace }(*ai.Response, *ai.ToolCallTrace, error)
Access traceresult.traceresult.traceSecond return value
Access textresult.textresult.textresp.Choices[0].Message.Content[0].Text
Per-call limitsmax_turns=5, max_tool_calls=10{ maxTurns: 5, maxToolCalls: 10 }ToolCallConfig{MaxTurns: 5, MaxToolCalls: 10}
Manual tool schemasN/AN/ACapabilityToToolDefinition(cap)
Execute loopBuilt 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)