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/action/tool_converter.ex
defmodule Raxol.Agent.Action.ToolConverter do
@moduledoc """
Converts Action modules to LLM tool definitions and dispatches tool calls.
Use `to_tool_definitions/1` to generate the `tools` parameter for
OpenAI/Anthropic API calls, then `dispatch_tool_call/3` to route
the LLM's tool call response back to the matching Action module.
"""
@doc """
Convert action modules to tool definitions for LLM API calls.
Returns a list of JSON Schema function tool definitions.
"""
@spec to_tool_definitions([module()]) :: [map()]
def to_tool_definitions(action_modules) when is_list(action_modules) do
Enum.map(action_modules, fn module ->
module.to_tool_definition()
end)
end
# Max nesting depth for LLM-supplied argument maps
@max_arg_depth 4
# Max total keys across all nesting levels
@max_arg_keys 64
# Max byte size for string argument values
@max_arg_value_bytes 10_000
@doc """
Dispatch an LLM tool call to the matching action module.
The `tool_call` map should have `"name"` and `"arguments"` keys
(standard OpenAI/Anthropic function calling format). Arguments can
be a string (JSON) or a pre-parsed map with string keys.
Validates argument depth, key count, and value sizes before dispatch
to prevent resource exhaustion from malformed LLM output.
Returns `{:ok, result}` or `{:error, reason}`.
"""
@spec dispatch_tool_call(map(), [module()], map()) ::
{:ok, map()} | {:ok, map(), [Raxol.Core.Runtime.Command.t()]} | {:error, term()}
def dispatch_tool_call(tool_call, action_modules, context \\ %{}) do
name = Map.get(tool_call, "name") || Map.get(tool_call, :name)
raw_args = Map.get(tool_call, "arguments") || Map.get(tool_call, :arguments, %{})
with {:ok, module} <- find_action(name, action_modules),
{:ok, params} <- parse_arguments(raw_args, module),
:ok <- validate_arg_limits(params) do
module.call(params, context)
end
end
@doc """
Build a tool result message for feeding back to the LLM.
Encodes the result as a JSON string suitable for the `tool` role message.
"""
@spec format_tool_result(String.t(), map()) :: map()
def format_tool_result(tool_call_id, result) when is_map(result) do
%{
role: "tool",
tool_call_id: tool_call_id,
content: Jason.encode!(result)
}
end
# -- Private ---------------------------------------------------------------
defp find_action(name, action_modules) do
case Enum.find(action_modules, fn mod -> mod.__action_meta__().name == name end) do
nil -> {:error, {:unknown_tool, name}}
module -> {:ok, module}
end
end
defp parse_arguments(args, _action_module) when is_map(args) do
{:ok, atomize_keys(args)}
end
defp parse_arguments(args, _action_module) when is_binary(args) do
case Jason.decode(args) do
{:ok, map} when is_map(map) -> {:ok, atomize_keys(map)}
{:ok, _} -> {:error, :arguments_not_object}
{:error, _} = err -> err
end
end
defp parse_arguments(_args, _action_module), do: {:ok, %{}}
defp atomize_keys(map) when is_map(map) do
Map.new(map, fn
{k, v} when is_binary(k) -> {safe_to_existing_atom(k), v}
{k, v} -> {k, v}
end)
end
defp safe_to_existing_atom(str) do
String.to_existing_atom(str)
rescue
ArgumentError -> str
end
defp validate_arg_limits(params) when is_map(params) do
case check_depth_and_size(params, 0, 0) do
{:ok, _key_count} -> :ok
{:error, _} = err -> err
end
end
defp check_depth_and_size(_value, depth, _keys) when depth > @max_arg_depth do
{:error, :arguments_too_deep}
end
defp check_depth_and_size(map, depth, keys) when is_map(map) do
new_keys = keys + map_size(map)
if new_keys > @max_arg_keys, do: throw({:error, :too_many_argument_keys})
Enum.reduce_while(map, {:ok, new_keys}, fn {_k, v}, {:ok, acc_keys} ->
case check_depth_and_size(v, depth + 1, acc_keys) do
{:ok, updated} -> {:cont, {:ok, updated}}
{:error, _} = err -> {:halt, err}
end
end)
catch
{:error, _} = err -> err
end
defp check_depth_and_size(list, depth, keys) when is_list(list) do
Enum.reduce_while(list, {:ok, keys}, fn v, {:ok, acc_keys} ->
case check_depth_and_size(v, depth + 1, acc_keys) do
{:ok, updated} -> {:cont, {:ok, updated}}
{:error, _} = err -> {:halt, err}
end
end)
end
defp check_depth_and_size(str, _depth, keys) when is_binary(str) do
if byte_size(str) > @max_arg_value_bytes do
{:error, :argument_value_too_large}
else
{:ok, keys}
end
end
defp check_depth_and_size(_value, _depth, keys), do: {:ok, keys}
end