AI Tool Calling

Let LLMs automatically discover and invoke agent capabilities across your system

AI Tool Calling

LLMs that discover, select, and invoke agent capabilities autonomously

Building multi-agent systems means making agents coordinate. With app.call(), your code explicitly chooses which agent to invoke. But what if the LLM could decide which tools to use based on the user's request?

That's AI tool calling. Instead of hardcoding orchestration logic, you give the LLM access to discovered capabilities and let it decide. The LLM sees what's available, picks the right tools, calls them through the control plane, reads the results, and produces a final answer.

One line of code: tools="discover". The control plane handles the rest.

What You'd Otherwise Build

Manual Orchestration

What you build:

  • Discover available tools via API
  • Convert capabilities to LLM tool schemas
  • Parse LLM tool-call responses
  • Dispatch each call to the right agent
  • Feed results back to the LLM
  • Handle multi-turn conversations
  • Track tool call latency and errors
  • Implement retry and limit logic

Then you write business logic.

Agentfield Tool Calling

What you write:

result = await app.ai(
    user="What's the weather in Tokyo?",
    tools="discover",
)

Agentfield provides:

  • Automatic capability discovery
  • Schema conversion to LLM format
  • Tool-call loop execution
  • Control plane dispatching
  • Result aggregation
  • Guardrails (turn/call limits)
  • Per-call observability
  • Progressive schema hydration

How It Works

The tool-calling pipeline follows a discover → ai → call loop:

Discover

The SDK queries the control plane for available capabilities. Each agent's reasoners and skills are returned with their names, descriptions, and input schemas.

# This happens automatically when you pass tools="discover"
capabilities = app.discover(tags=["weather"], include_input_schema=True)

Convert

Capabilities are converted to the LLM's native tool format (OpenAI function-calling schema). Colons in agent names are sanitized for compatibility.

utility-worker:get_weather → utility-worker__get_weather

AI Decides

The LLM receives the user's question along with the available tool schemas. It decides which tools to call and with what arguments.

Call

Each tool call is dispatched through the control plane via app.call(). The control plane handles routing, context propagation, and workflow tracking.

Loop

Tool results are fed back to the LLM. If the LLM needs more information, it can make additional tool calls. This continues until the LLM produces a final text response or guardrails are hit.

Quick Start

Here's a complete two-agent example. A worker provides tools; an orchestrator lets the LLM use them.

Worker Agent (provides tools)

from agentfield import Agent, AIConfig

app = Agent(
    node_id="utility-worker",
    agentfield_server="http://localhost:8080",
    ai_config=AIConfig(model="openrouter/openai/gpt-4o-mini"),
)

@app.skill(tags=["weather"])
def get_weather(city: str) -> dict:
    """Get the current weather for a city."""
    weather_data = {
        "tokyo": {"temp_f": 81, "conditions": "Sunny", "humidity": 55},
        "london": {"temp_f": 58, "conditions": "Overcast", "humidity": 80},
    }
    return {"city": city, **weather_data.get(city.lower(), {"temp_f": 70, "conditions": "Clear", "humidity": 60})}

@app.skill(tags=["math"])
def calculate(operation: str, a: float, b: float) -> dict:
    """Perform a math operation: add, subtract, multiply, divide."""
    ops = {"add": a + b, "subtract": a - b, "multiply": a * b, "divide": a / b if b != 0 else float("inf")}
    return {"operation": operation, "a": a, "b": b, "result": ops.get(operation.lower())}

app.run(port=8001)
import { Agent } from '@agentfield/sdk';

const app = new Agent({
  nodeId: 'utility-worker-ts',
  agentFieldUrl: 'http://localhost:8080',
  port: 8003,
  aiConfig: { provider: 'openrouter', model: 'openai/gpt-4o-mini' },
});

app.skill('get_weather', async (ctx) => {
  const { city } = ctx.input as { city: string };
  const data: Record<string, any> = {
    'tokyo': { temp_f: 81, conditions: 'Sunny', humidity: 55 },
    'london': { temp_f: 58, conditions: 'Overcast', humidity: 80 },
  };
  return { city, ...(data[city.toLowerCase()] ?? { temp_f: 70, conditions: 'Clear', humidity: 60 }) };
}, {
  tags: ['weather'],
  description: 'Get the current weather for a city.',
  inputSchema: {
    type: 'object',
    properties: { city: { type: 'string', description: 'The city to get weather for' } },
    required: ['city'],
  },
});

app.skill('calculate', async (ctx) => {
  const { operation, a, b } = ctx.input as { operation: string; a: number; b: number };
  const ops: Record<string, number> = {
    add: a + b, subtract: a - b, multiply: a * b, divide: b !== 0 ? a / b : Infinity,
  };
  return { operation, a, b, result: ops[operation.toLowerCase()] };
}, {
  tags: ['math'],
  description: 'Perform a math operation: add, subtract, multiply, divide.',
  inputSchema: {
    type: 'object',
    properties: {
      operation: { type: 'string', description: 'Math operation: add, subtract, multiply, divide' },
      a: { type: 'number', description: 'First operand' },
      b: { type: 'number', description: 'Second operand' },
    },
    required: ['operation', 'a', 'b'],
  },
});

app.serve();

Orchestrator Agent (uses tools)

from agentfield import Agent, AIConfig, ToolCallConfig

app = Agent(
    node_id="orchestrator",
    agentfield_server="http://localhost:8080",
    ai_config=AIConfig(model="openrouter/openai/gpt-4o-mini", temperature=0.2),
)

@app.reasoner(tags=["demo"])
async def ask_with_tools(question: str) -> dict:
    """Answer a question using auto-discovered tools."""
    result = await app.ai(
        system="You are a helpful assistant. Use available tools to answer accurately.",
        user=question,
        tools="discover",
    )
    return {"answer": str(result)}

app.run(port=8002)
import { Agent } from '@agentfield/sdk';

const app = new Agent({
  nodeId: 'orchestrator-ts',
  agentFieldUrl: 'http://localhost:8080',
  port: 8004,
  aiConfig: { provider: 'openrouter', model: 'openai/gpt-4o-mini', temperature: 0.2 },
});

app.reasoner('ask_with_tools', async (ctx) => {
  const { text, trace } = await ctx.aiWithTools(ctx.input.question, {
    tools: 'discover',
    system: 'You are a helpful assistant. Use available tools to answer accurately.',
  });

  console.log(`Tool calls: ${trace.totalToolCalls}, Turns: ${trace.totalTurns}`);
  return { answer: text, trace };
}, { tags: ['demo'], description: 'Answer using auto-discovered tools.' });

app.run();

Run It

# Terminal 1: Start control plane
af server

# Terminal 2: Start worker
python worker.py

# Terminal 3: Start orchestrator
python orchestrator.py

# Terminal 4: Test it
curl -X POST http://localhost:8080/api/v1/execute/orchestrator.ask_with_tools \
  -H "Content-Type: application/json" \
  -d '{"input": {"question": "What is the weather in Tokyo and what is 42 * 17?"}}'

The LLM automatically discovers get_weather and calculate from the worker, calls both, and synthesizes a final answer.

Filtering and Configuration

Filter by Tags

Only discover tools with specific tags:

result = await app.ai(
    user="What's the weather in London and Paris?",
    tools=ToolCallConfig(tags=["weather"]),
)

Filter by Agent ID

Restrict to tools from a specific agent:

result = await app.ai(
    user="Calculate 100 * 200",
    tools=ToolCallConfig(agent_ids=["utility-worker"]),
)

Dict-Based Config

Pass configuration as a plain dictionary:

result = await app.ai(
    user="Summarize weather across all cities",
    tools={
        "tags": ["weather"],
        "agent_ids": ["utility-worker"],
        "max_turns": 5,
        "max_tool_calls": 10,
    },
)

Guardrails

Tool calling includes built-in limits to prevent runaway execution:

GuardrailDefaultDescription
max_turns10Maximum LLM round-trips
max_tool_calls25Maximum total tool calls across all turns

When either limit is reached, the SDK makes one final LLM call without tools to produce a summary response.

# Strict limits for cost-sensitive workloads
result = await app.ai(
    user="Quick question about weather",
    tools="discover",
    max_turns=3,
    max_tool_calls=5,
)

Progressive Discovery (Lazy Hydration)

For systems with many capabilities, eager schema loading can flood the LLM's context window. Lazy hydration solves this by loading tool schemas in two passes:

  1. First pass: Send only tool names and descriptions (metadata-only stubs)
  2. LLM selects: The LLM picks which tools it wants to use
  3. Second pass: Full parameter schemas are hydrated for only the selected tools
  4. Execute: The tool-call loop runs with fully-hydrated tools
result = await app.ai(
    user="Find relevant data and generate a report",
    tools=ToolCallConfig(
        schema_hydration="lazy",
        max_candidate_tools=30,   # Show up to 30 tool stubs
        max_hydrated_tools=8,     # Hydrate up to 8 selected tools
    ),
)

Lazy hydration is available in the Python and TypeScript SDKs. The Go SDK currently supports eager hydration only.

Observability

Every tool-call loop produces a ToolCallTrace with full per-call observability:

result = await app.ai(user="Weather in Tokyo", tools="discover")

print(f"Turns: {result.trace.total_turns}")
print(f"Total tool calls: {result.trace.total_tool_calls}")

for call in result.trace.calls:
    print(f"  Turn {call.turn}: {call.tool_name}")
    print(f"    Args: {call.arguments}")
    print(f"    Latency: {call.latency_ms:.0f}ms")
    if call.error:
        print(f"    Error: {call.error}")

The trace integrates with Agentfield's existing workflow observability. Each tool call dispatched through app.call() appears in the workflow DAG, inherits context propagation, and is tracked with the same execution model as explicit cross-agent calls.

What This Enables

Dynamic Orchestration

LLMs decide which agents to call based on the user's request. No hardcoded orchestration logic. New agents are automatically discoverable.

Full Observability

Every tool call is traced with latency, arguments, results, and errors. Integrates with workflow DAGs and distributed tracing.

Built-in Guardrails

Turn limits and tool call limits prevent runaway execution. Lazy hydration manages context window pressure at scale.

Scale-Ready

Progressive discovery handles hundreds of capabilities without flooding context. Filter by tags, agents, or health status.

Next Steps