LlmCore includes an in-process agentic tool-calling loop that runs entirely inside the BEAM VM — no subprocess, no CLI overhead.

Overview

The agent loop implements the standard agentic pattern:


           LlmCore.Agent.Loop             
                                          
  1. Call LLM with messages + tools       
  2. LLM responds with tool calls?        
     YES  dispatch tools, collect results 
            append to messages  goto 1    
     NO   return final text response     
                                          
  [Iteration Pipeline processes each turn] 

The loop is transport-agnostic — it doesn't know about HTTP, CLIs, or specific providers. It calls a user-supplied llm_send_fn and a resolve_tool function. This makes it work with any LLM backend.

Basic Usage

alias LlmCore.Agent.Loop

# Define your LLM send function
llm_send = fn messages, opts ->
  LlmCore.LLM.Provider.dispatch(LlmCore.LLM.Anthropic, messages, opts)
end

# Define your tool resolver
resolve_tool = fn tool_call ->
  case tool_call.function_name do
    "search" -> {:ok, "search results..."}
    "calculate" -> {:ok, "42"}
    _ -> {:error, "unknown tool"}
  end
end

# Run the loop
{:ok, final_response, messages} =
  Loop.run(
    [%{role: :user, content: "What is 6 × 7?"}],
    llm_send,
    tools: [
      %LlmToolkit.Tool{
        name: "calculate",
        description: "Evaluate a mathematical expression",
        parameters: %{type: "object", properties: %{expr: %{type: "string"}}}
      }
    ],
    resolve_tool: resolve_tool,
    max_iterations: 10
  )

final_response.content
#=> "6 × 7 = 42"

Options

OptionRequiredDefaultDescription
:toolsYesList of LlmToolkit.Tool.t() definitions
:resolve_toolYesfn(Call.t()) -> {:ok, String.t()} | {:error, String.t()}
:resolver_moduleNonilModule implementing ToolResolver behaviour for dispatch recipes
:max_iterationsNo10Hard iteration ceiling
:on_iterationNonilCallback fn(Context.t()) -> :ok invoked after each iteration
:pipeline_optsNo[sync: true]Options forwarded to the ALF pipeline
:llm_optsNo[]Extra options forwarded to llm_send_fn

Return Values

ResultMeaning
{:ok, response, messages}LLM produced a text-only response. response is LlmCore.LLM.Response.t(), messages is the full conversation history.
{:error, :max_iterations_reached}Iteration budget exhausted without a text-only response.
{:error, {:circuit_breaker, msg}}Same tool error repeated 3+ times — systematic bug, not transient.
{:error, {:llm_error, reason}}The llm_send_fn returned an error.
{:error, {:pipeline_error, reason}}The ALF iteration pipeline crashed.

Iteration Pipeline

Each iteration flows through LlmCore.Agent.Pipeline.Iteration, an ALF pipeline with these stages:

parse_tool_calls        Extract tool calls from the LLM response
     
validate_calls          Check calls against tool definitions
     
loop_decision           Text-only?  :done. Tool calls?  :continue
     
dispatch_tools          Execute tool calls (serial or parallel)
     
inject_results          Build tool result messages
     
budget_guard            Check iteration budget

The pipeline produces a LlmCore.Agent.Context with a decision field that the outer loop reads.

Context Struct

LlmCore.Agent.Context carries data through the pipeline:

  • Input fields (set by the loop): messages, tools, response, resolve_tool, iteration, max_iterations
  • Intermediate fields (populated by stages): tool_calls, tool_results, result_messages, validation_errors
  • Output fields (read by the loop): decision, status, error, trace

Circuit Breaker

The loop detects when the same tool error repeats 3+ consecutive times. This indicates a systematic bug (wrong arguments, missing resource) rather than a transient failure — the model can't fix it by varying arguments. The loop breaks out with {:error, {:circuit_breaker, msg}}.

Telemetry

The loop emits a single event on completion:

:telemetry.execute(
  [:llm_core, :agent, :complete],
  %{total_iterations: N},
  %{tool_calls_count: N}
)

Integration Example

A real-world consumer uses the agent loop for conversational LLM calls that need tool access:

# From a consumer adapter
# (resolves provider, wraps LlmCore.LLM.Provider.dispatch/3 as the send function)
defp send_via_agent_loop(messages, opts, tools, resolve_tool) do
  {:ok, provider, call_opts} = resolve_provider(opts)

  llm_send_fn = fn loop_messages, loop_opts ->
    LlmCore.LLM.Provider.dispatch(provider, loop_messages, Keyword.merge(call_opts, loop_opts))
  end

  case LlmCore.Agent.Loop.run(messages, llm_send_fn,
         tools: tools,
         resolve_tool: resolve_tool,
         max_iterations: 5
       ) do
    {:ok, response, _final_messages} ->
      {:ok, response.content || "", response.usage || %{}}
    {:error, reason} ->
      {:error, reason}
  end
end

This pattern — resolve a provider, build a send function, pass it to the loop — is the recommended integration path for any application that needs tool-calling LLM interactions.