Packages
AI agent framework for Elixir built on OTP. TEA-based agents with crash isolation, inter-agent messaging, team supervision, and real SSE streaming to Anthropic, OpenAI, Ollama, and more.
Current section
Files
Jump to
Current section
Files
lib/raxol/agent/strategy/react.ex
defmodule Raxol.Agent.Strategy.ReAct do
@moduledoc """
ReAct (Reasoning + Acting) strategy.
Sends the current context to an LLM with available Actions as tools.
The LLM decides which tool to call, the strategy executes the Action,
feeds the result back, and repeats until the LLM produces a final
text answer or max iterations are reached.
## Required context keys
- `:backend` -- AIBackend module (e.g. `Raxol.Agent.Backend.HTTP`)
- `:backend_opts` -- keyword list for backend calls (api_key, model, etc.)
- `:actions` -- list of Action modules available as tools
## Optional context keys
- `:system_prompt` -- system message prepended to conversation
- `:max_iterations` -- loop guard (default 10)
"""
@behaviour Raxol.Agent.Strategy
alias Raxol.Agent.Action.ToolConverter
@default_max_iterations 10
@impl true
def execute({_action, params}, state, context) do
prompt = Map.get(params, :prompt) || Map.get(params, "prompt") || inspect(params)
max_iter = Map.get(context, :max_iterations, @default_max_iterations)
with {:ok, backend} <- fetch_required(context, :backend) do
backend_opts = Map.get(context, :backend_opts, [])
actions = Map.get(context, :actions, [])
system_prompt = Map.get(context, :system_prompt)
tools = ToolConverter.to_tool_definitions(actions)
opts = Keyword.merge(backend_opts, tools: tools)
initial_messages = build_initial_messages(system_prompt, prompt)
case run_loop(initial_messages, backend, opts, actions, context, max_iter, 0) do
{:ok, answer, tool_results} ->
new_state =
state
|> Map.put(:last_answer, answer)
|> Map.put(:tool_results, tool_results)
{:ok, new_state}
{:error, _} = error ->
error
end
end
end
# -- Loop ------------------------------------------------------------------
defp run_loop(_messages, _backend, _opts, _actions, _context, max_iter, iteration)
when iteration >= max_iter do
{:error, :max_iterations_reached}
end
defp run_loop(messages, backend, opts, actions, context, max_iter, iteration) do
case backend.complete(messages, opts) do
{:ok, %{tool_calls: tool_calls}}
when is_list(tool_calls) and tool_calls != [] ->
{tool_messages, _tool_results} = execute_tool_calls(tool_calls, actions, context)
assistant_msg = %{role: :assistant, content: format_tool_call_text(tool_calls)}
updated_messages = messages ++ [assistant_msg | tool_messages]
run_loop(updated_messages, backend, opts, actions, context, max_iter, iteration + 1)
{:ok, %{content: answer}} ->
tool_results = extract_accumulated_results(messages)
{:ok, answer, tool_results}
{:error, _} = error ->
error
end
end
# -- Tool execution --------------------------------------------------------
defp execute_tool_calls(tool_calls, actions, context) do
{msgs_rev, results_rev} =
Enum.reduce(tool_calls, {[], []}, fn tool_call, {msgs, results} ->
name = Map.get(tool_call, "name")
case ToolConverter.dispatch_tool_call(tool_call, actions, context) do
{:ok, result} ->
msg = %{role: :user, content: "[Tool result for #{name}]: #{Jason.encode!(result)}"}
{[msg | msgs], [{name, result} | results]}
{:ok, result, _commands} ->
msg = %{role: :user, content: "[Tool result for #{name}]: #{Jason.encode!(result)}"}
{[msg | msgs], [{name, result} | results]}
{:error, reason} ->
msg = %{role: :user, content: "[Tool error for #{name}]: #{inspect(reason)}"}
{[msg | msgs], [{name, {:error, reason}} | results]}
end
end)
{Enum.reverse(msgs_rev), Enum.reverse(results_rev)}
end
# -- Message building ------------------------------------------------------
defp build_initial_messages(nil, prompt) do
[%{role: :user, content: prompt}]
end
defp build_initial_messages(system_prompt, prompt) do
[
%{role: :system, content: system_prompt},
%{role: :user, content: prompt}
]
end
defp format_tool_call_text(tool_calls) do
tool_calls
|> Enum.map_join(", ", fn tc -> Map.get(tc, "name", "unknown") end)
|> then(&"[Calling tools: #{&1}]")
end
defp extract_accumulated_results(messages) do
messages
|> Enum.filter(fn msg ->
content = Map.get(msg, :content, "")
is_binary(content) and String.starts_with?(content, "[Tool result for ")
end)
|> Enum.map(& &1.content)
end
defp fetch_required(map, key) do
case Map.fetch(map, key) do
{:ok, value} -> {:ok, value}
:error -> {:error, {:missing_required_context, key}}
end
end
end