Powered by AppSignal & Oban Pro

Tool Calling: LLM Function-Calling Execution Loop

03_tool_calling.livemd

Tool Calling: LLM Function-Calling Execution Loop

Tools let an LLM call external functions — weather APIs, calculators, databases — and receive results before composing its final response. This tutorial covers defining tools, building a tool registry, and running the automated execution loop.

Setup

Mix.install([
  {:livekit, path: Path.join(__DIR__, "../../..")},
  {:kino, "~> 0.14"}
])

alias Livekit.Agents.Tool
alias Livekit.Agents.Tool.{ToolSpec, ToolContext, ToolError}
alias Livekit.Agents.ChatContext
alias Livekit.Agents.ChatContext.{ChatMessage, FunctionCall, FunctionCallOutput}

Defining a Tool with ToolSpec

A ToolSpec has four required fields:

  • :name — the function name the LLM will use in its call (snake_case string)
  • :description — natural language explanation the LLM reads to decide when to call it
  • :parameters — JSON Schema map (OpenAI parameters format)
  • :handlerfn args_map -> {:ok, result_string} | {:error, reason_string} end
weather_tool = ToolSpec.new(
  name: "get_weather",
  description: "Returns current weather conditions for a given city",
  parameters: %{
    "type" => "object",
    "properties" => %{
      "city" => %{
        "type" => "string",
        "description" => "City name, e.g. London or Tokyo"
      },
      "units" => %{
        "type" => "string",
        "enum" => ["celsius", "fahrenheit"],
        "description" => "Temperature unit (default: celsius)"
      }
    },
    "required" => ["city"]
  },
  handler: fn %{"city" => city} = args ->
    units = Map.get(args, "units", "celsius")
    # Simulated response — in a real tool you would call a weather API here
    {:ok, "#{city}: 18°#{String.first(String.upcase(units))}, partly cloudy"}
  end
)

IO.inspect(weather_tool, label: "ToolSpec")

Generating OpenAI Function Schema

ToolSpec.to_openai_schema/1 converts a ToolSpec into the JSON structure OpenAI expects in the tools parameter of a chat completion request.

schema = ToolSpec.to_openai_schema(weather_tool)
IO.puts("OpenAI schema:\n#{Jason.encode!(schema, pretty: true)}")

Building a ToolContext Registry

A ToolContext holds multiple ToolSpec entries indexed by name for O(1) lookup. to_openai_tools/1 produces the full list you pass to the LLM provider.

calculator_tool = ToolSpec.new(
  name: "calculate",
  description: "Evaluates a simple arithmetic expression and returns the result",
  parameters: %{
    "type" => "object",
    "properties" => %{
      "expression" => %{
        "type" => "string",
        "description" => "Arithmetic expression to evaluate, e.g. '2 + 3 * 4'"
      }
    },
    "required" => ["expression"]
  },
  handler: fn %{"expression" => expr} ->
    # Safely evaluate only simple numeric expressions
    # In production use a proper parser; this is illustrative
    result = expr
      |> String.replace(~r/[^0-9\+\-\*\/\.\s]/, "")
      |> String.trim()

    {:ok, "Result of #{expr}: #{result} (computed)"}
  end
)

tool_ctx = ToolContext.new([weather_tool, calculator_tool])

IO.puts("Tools registered: #{map_size(tool_ctx.tools)}")
IO.puts("Tool names: #{Map.keys(tool_ctx.tools) |> Enum.join(", ")}")

all_schemas = ToolContext.to_openai_tools(tool_ctx)
IO.puts("\nAll schemas (#{length(all_schemas)} tools):")
IO.puts(Jason.encode!(all_schemas, pretty: true))

Looking Up Tools

case ToolContext.lookup(tool_ctx, "get_weather") do
  {:ok, spec} -> IO.puts("Found: #{spec.name}#{spec.description}")
  {:error, :not_found} -> IO.puts("Not found")
end

case ToolContext.lookup(tool_ctx, "nonexistent_tool") do
  {:ok, _} -> IO.puts("Found (unexpected)")
  {:error, :not_found} -> IO.puts("Not found (expected)")
end

The Execution Loop: Tool.run/3

Tool.run/3 is the heart of function calling. It:

  1. Calls llm_module.chat(ctx, opts) to get a response
  2. Looks for FunctionCall items in the returned context
  3. Calls each tool's handler and appends a FunctionCallOutput
  4. Feeds the updated context back to the LLM
  5. Repeats until the LLM produces no more tool calls, or max_tool_steps is reached

To demonstrate this without a real LLM, we build a mock that simulates one tool call before returning a natural completion.

defmodule MockLLMWithToolCall do
  use Livekit.Agents.LLM

  # step 0: LLM requests a tool call
  # step 1: LLM sees tool output and gives final answer
  @impl true
  def chat(chat_context, _opts) do
    has_tool_output =
      Enum.any?(chat_context.items, &match?(%FunctionCallOutput{}, &1))

    if has_tool_output do
      # Second call: compose final answer using the tool result
      tool_outputs =
        chat_context.items
        |> Enum.filter(&match?(%FunctionCallOutput{}, &1))
        |> Enum.map(& &1.output)
        |> Enum.join(", ")

      reply = %ChatMessage{
        id: random_id(),
        role: :assistant,
        content: ["Based on the tool results: #{tool_outputs}. Have a great day!"],
        interrupted: false,
        created_at: DateTime.utc_now()
      }

      {:ok, reply}
    else
      # First call: request the weather tool
      fc = %FunctionCall{
        id: random_id(),
        call_id: "call_mock_001",
        name: "get_weather",
        arguments: Jason.encode!(%{"city" => "Paris", "units" => "celsius"}),
        created_at: DateTime.utc_now()
      }

      {:ok, fc}
    end
  end

  @impl true
  def capabilities, do: %{streaming: false, tool_calling: true, vision: false, max_context_tokens: 8192}

  defp random_id, do: :crypto.strong_rand_bytes(4) |> Base.encode16(case: :lower)
end

# Run the tool calling loop
initial_ctx =
  ChatContext.new()
  |> ChatContext.add(ChatContext.new_message(:system, ["You are a weather assistant."]))
  |> ChatContext.add(ChatContext.new_message(:user, ["What's the weather in Paris?"]))

{:ok, final_ctx} = Tool.run(MockLLMWithToolCall, initial_ctx,
  tool_context: tool_ctx,
  max_tool_steps: 5
)

IO.puts("Final context has #{length(final_ctx.items)} items:\n")
Enum.each(final_ctx.items, fn item ->
  case item do
    %ChatMessage{role: r, content: c} ->
      IO.puts("  [#{r}] #{Enum.join(c, " ")}")
    %FunctionCall{name: n, arguments: a} ->
      IO.puts("  [function_call] #{n}(#{a})")
    %FunctionCallOutput{name: n, output: o, is_error: e} ->
      IO.puts("  [function_output] #{n} -> #{o} (error=#{e})")
  end
end)

The max_tool_steps Safety Cap

Prevent infinite loops by setting max_tool_steps. When the cap is reached, Tool.run/3 returns the context as-is, even if the LLM is still requesting tool calls.

defmodule InfiniteToolLLM do
  use Livekit.Agents.LLM

  @impl true
  def chat(_chat_context, _opts) do
    # Always requests a tool call — simulates a stuck LLM
    fc = %FunctionCall{
      id: :crypto.strong_rand_bytes(4) |> Base.encode16(case: :lower),
      call_id: "call_#{System.unique_integer()}",
      name: "get_weather",
      arguments: Jason.encode!(%{"city" => "London"}),
      created_at: DateTime.utc_now()
    }
    {:ok, fc}
  end

  @impl true
  def capabilities, do: %{streaming: false, tool_calling: true, vision: false, max_context_tokens: 4096}
end

ctx = ChatContext.new()
|> ChatContext.add(ChatContext.new_message(:user, ["Keep calling tools forever..."]))

{:ok, capped_ctx} = Tool.run(InfiniteToolLLM, ctx,
  tool_context: tool_ctx,
  max_tool_steps: 3   # hard cap at 3 iterations
)

function_calls = Enum.count(capped_ctx.items, &match?(%FunctionCall{}, &1))
function_outputs = Enum.count(capped_ctx.items, &match?(%FunctionCallOutput{}, &1))

IO.puts("After capping at max_tool_steps: 3")
IO.puts("  FunctionCall items:  #{function_calls}")
IO.puts("  FunctionOutput items: #{function_outputs}")
IO.puts("  (loop stopped, no final assistant message)")

Error Isolation

When a tool handler returns {:error, reason}, the execution loop:

  1. Catches the ToolError
  2. Creates a FunctionCallOutput with is_error: true
  3. Feeds the error back to the LLM so it can respond gracefully

The LLM is never crashed by a bad tool — errors are surfaced as data.

failing_tool = ToolSpec.new(
  name: "failing_tool",
  description: "Always fails for demonstration",
  parameters: %{"type" => "object", "properties" => %{}, "required" => []},
  handler: fn _args ->
    {:error, "External service unavailable (HTTP 503)"}
  end
)

error_tool_ctx = ToolContext.new([failing_tool])

defmodule FailingToolLLM do
  use Livekit.Agents.LLM

  @impl true
  def chat(chat_context, _opts) do
    has_output = Enum.any?(chat_context.items, &match?(%FunctionCallOutput{}, &1))

    if has_output do
      output = Enum.find(chat_context.items, &match?(%FunctionCallOutput{}, &1))
      content = if output.is_error do
        "I tried to use a tool but it failed: #{output.output}. I'll answer without it."
      else
        "Tool worked: #{output.output}"
      end
      {:ok, %ChatMessage{
        id: :crypto.strong_rand_bytes(4) |> Base.encode16(case: :lower),
        role: :assistant,
        content: [content],
        interrupted: false,
        created_at: DateTime.utc_now()
      }}
    else
      {:ok, %FunctionCall{
        id: :crypto.strong_rand_bytes(4) |> Base.encode16(case: :lower),
        call_id: "call_fail_01",
        name: "failing_tool",
        arguments: "{}",
        created_at: DateTime.utc_now()
      }}
    end
  end

  @impl true
  def capabilities, do: %{streaming: false, tool_calling: true, vision: false, max_context_tokens: 4096}
end

err_ctx =
  ChatContext.new()
  |> ChatContext.add(ChatContext.new_message(:user, ["Please use the failing tool."]))

{:ok, result_ctx} = Tool.run(FailingToolLLM, err_ctx,
  tool_context: error_tool_ctx,
  max_tool_steps: 5
)

IO.puts("Context after error isolation:")
Enum.each(result_ctx.items, fn item ->
  case item do
    %ChatMessage{role: r, content: c} -> IO.puts("  [#{r}] #{Enum.join(c, " ")}")
    %FunctionCall{name: n} -> IO.puts("  [function_call] #{n}")
    %FunctionCallOutput{name: n, is_error: true, output: o} -> IO.puts("  [ERROR output] #{n}: #{o}")
    %FunctionCallOutput{name: n, output: o} -> IO.puts("  [function_output] #{n}: #{o}")
  end
end)

Summary

You now know how to:

  • Define a tool with ToolSpec.new/1 (name, description, JSON Schema, handler)
  • Generate OpenAI function-calling schema with ToolSpec.to_openai_schema/1
  • Build a tool registry with ToolContext.new/1 and look up tools with lookup/2
  • Run the automated execution loop with Tool.run/3
  • Cap iterations with max_tool_steps to prevent infinite loops
  • Rely on error isolation: handler failures become FunctionCallOutput(is_error: true)

Next: 04_deepgram_stt.livemd — real speech-to-text with a Deepgram API key.