Runs an agentic tool-calling loop on top of LlmComposer.run_completion/3.
Where LlmComposer.simple_chat/2 and LlmComposer.run_completion/3 perform a single model
turn, LlmComposer.Agent.run/3 automates the full cycle:
ask → model requests tool calls → execute them → feed the results back → repeat
→ until the model returns a final, tool-free answer.The loop is pure orchestration over existing building blocks
(LlmComposer.FunctionExecutor, LlmComposer.FunctionCallHelpers) and does not change any
provider behaviour.
Streaming
When settings.stream_response is true, run/3 returns {:ok, stream} where stream is a
lazy Enumerable of LlmComposer.StreamChunk. The stream carries only the final, tool-free
answer (token-by-token :text_delta chunks) followed by a terminal :done chunk whose :usage
and :cost_info hold the run totals and whose metadata.agent_result holds the full
LlmComposer.Agent.Result. A hard failure (e.g. :max_iterations_reached) is delivered as a
terminal :error chunk instead.
Intermediate progress — tool calls, per-iteration data and reasoning — is not placed on the
answer stream. It is exposed via :telemetry (see below) so a UI can subscribe without having to
filter the answer.
{:ok, stream} = LlmComposer.Agent.run(settings, "Weather in Paris?")
stream
|> Enum.reduce(nil, fn
%LlmComposer.StreamChunk{type: :text_delta, text: t}, acc -> IO.write(t); acc
%LlmComposer.StreamChunk{type: :done, metadata: %{agent_result: r}}, _ -> r
_other, acc -> acc
end)All providers support the streaming agent. Note that the native :ollama provider does not emit
tool-call deltas; for tool-call streaming with Ollama use the :open_ai provider pointed at
Ollama's OpenAI-compatible endpoint.
Example
defmodule MyTools do
def calculator(%{"expression" => expression}) do
{result, _binding} = Code.eval_string(expression)
result
end
end
calculator = %LlmComposer.Function{
mf: {MyTools, :calculator},
name: "calculator",
description: "Evaluates a math expression",
schema: %{
"type" => "object",
"properties" => %{"expression" => %{"type" => "string"}},
"required" => ["expression"]
}
}
settings = %LlmComposer.Settings{
providers: [{LlmComposer.Providers.OpenAI, [model: "gpt-4.1-mini", functions: [calculator]]}],
system_prompt: "You are a helpful assistant.",
api_key: "sk-...",
track_costs: true
}
{:ok, result} = LlmComposer.Agent.run(settings, "How much is (2 + 3) * 4?")
result.response.main_response.content
# => "The result is 20."
result.iterations
# => 2Options
:max_iterations— maximum number of model turns before giving up. Defaults to10. When exceeded while the model still wants to call tools,run/3returns{:error, :max_iterations_reached}.:functions— list ofLlmComposer.Function.t()available to the model. Defaults to the:functionsconfigured in the settings' provider options, so you usually do not need to set this explicitly.:tool_execution—:sequential(default) runs tool calls one by one;:parallelruns them concurrently withTask.async_stream/3while preserving result order.:tool_timeout— per-task timeout (ms or:infinity) used in:parallelmode. Defaults to:infinity.
Tool errors
When a tool call fails (:function_not_found, invalid arguments, or an exception during
execution) the loop does not abort. Instead it feeds an "Error: ..." string back to the
model as the tool result, giving the model a chance to recover or explain the failure. A model
or network error returned by the provider, by contrast, aborts the loop and is returned as
{:error, reason}.
Telemetry
The loop emits the following :telemetry events (in both synchronous and streaming modes):
[:llm_composer, :agent, :run, :start | :stop | :exception]— the whole run. The:stopevent includes an:iterationsmeasurement and a:status(:ok/:error/:halted) metadata key.[:llm_composer, :agent, :iteration, :stop]— one per model turn. Measurement:tool_call_count; metadata:iteration,:cost_info,:final.[:llm_composer, :agent, :tool, :start | :stop | :exception]— one per tool execution. Metadata:name,:arguments,:metadata,:idand (on stop):status(:ok/:error).[:llm_composer, :agent, :reasoning, :delta]— streaming only: one per intermediate reasoning fragment. Metadata:iterationand:reasoning.
Pass telemetry_metadata: map() to run/3 and the map is merged into the metadata of every event
above, alongside an auto-generated :run_id. This lets a handler scope itself to a single run and,
for example, broadcast to Phoenix.PubSub or send/2 a pid:
{:ok, _} = LlmComposer.Agent.run(settings, prompt, telemetry_metadata: %{conversation_id: cid})
:telemetry.attach("agent-ui", [:llm_composer, :agent, :tool, :start], fn
_event, _meas, %{conversation_id: ^cid, name: name, arguments: args}, _cfg ->
Phoenix.PubSub.broadcast(MyApp.PubSub, "conv:#{cid}", {:tool_call, name, args})
end, nil)
Summary
Functions
Runs the agentic tool-calling loop until the model returns a final, tool-free response.
Types
@type input() :: String.t() | [LlmComposer.Message.t()]
@type run_opts() :: [ max_iterations: pos_integer(), functions: [LlmComposer.Function.t()], tool_execution: :sequential | :parallel, tool_timeout: timeout(), telemetry_metadata: map() ]
Functions
@spec run(LlmComposer.Settings.t(), input(), run_opts()) :: {:ok, LlmComposer.Agent.Result.t()} | {:ok, Enumerable.t()} | {:error, term()}
Runs the agentic tool-calling loop until the model returns a final, tool-free response.
Accepts either a user prompt string (wrapped into a :user message, honouring the settings'
:user_prompt_prefix) or an explicit list of LlmComposer.Message.t().
When settings.stream_response is false, returns {:ok, %LlmComposer.Agent.Result{}} on success
or {:error, reason} (where reason may be :max_iterations_reached or any error returned by the
underlying provider). When settings.stream_response is true, returns {:ok, stream} — see the
"Streaming" section of the module documentation.
See the module documentation for the available options.