Current section
Files
Jump to
Current section
Files
lib/alloy.ex
defmodule Alloy do
@moduledoc """
Model-agnostic agent harness for Elixir.
Alloy provides the minimal agent loop: send messages to any LLM,
execute tool calls, loop until done. Zero framework dependencies.
## Quick Start
{:ok, result} = Alloy.run("What is 2+2?",
provider: {Alloy.Provider.Anthropic, api_key: "sk-ant-..."},
system_prompt: "You are helpful."
)
result.text #=> "4"
## With Tools
{:ok, result} = Alloy.run("Read mix.exs and tell me the version",
provider: {Alloy.Provider.Anthropic, api_key: "sk-ant-..."},
tools: [Alloy.Tool.Core.Read],
max_turns: 10
)
## Continuing a Conversation
{:ok, result} = Alloy.run("Now edit that file",
provider: {Alloy.Provider.OpenAI, api_key: "sk-..."},
tools: [Alloy.Tool.Core.Read, Alloy.Tool.Core.Edit],
messages: previous_result.messages
)
## One-Shot Streaming
{:ok, result} = Alloy.stream("Explain OTP", fn chunk ->
IO.write(chunk)
end,
provider: {Alloy.Provider.OpenAI, api_key: "sk-...", model: "gpt-5.4"}
)
## Options
- `:provider` - `{module, config_keyword_list}` or just `module` (required)
- `:tools` - list of modules implementing `Alloy.Tool` and/or `Alloy.Tool.Inline` structs built with `Alloy.Tool.inline/1` (default: `[]`)
- `:system_prompt` - system prompt string (default: `nil`)
- `:messages` - existing conversation history (default: `[]`)
- `:max_turns` - maximum agent loop iterations (default: `25`)
- `:max_tokens` - context window budget for compaction (default: provider model window when known, otherwise `200_000`)
- `:compaction` - grouped compaction settings like `reserve_tokens`, `keep_recent_tokens`, and `fallback` (default: derived from `:max_tokens`)
- `:middleware` - list of `Alloy.Middleware` modules (default: `[]`)
- `:working_directory` - base path for file tools (default: `"."`)
- `:context` - arbitrary map passed to tools and middleware (default: `%{}`)
- `:max_pending` - max queued async `send_message/3` requests while one is running (default: `0`)
- `:model_metadata_overrides` - overrides for model context windows used to derive `:max_tokens` when not set explicitly (default: `%{}`)
- `:model_catalog` - module implementing `Alloy.ModelCatalog`, consulted for model context windows after `:model_metadata_overrides` (default: `Alloy.ModelMetadata`)
- `:until_tool` - tool name (string) that must be called before the loop completes. If the model signals `:end_turn` without calling this tool, the loop continues with a prompt to call it. Useful for structured output enforcement. (default: `nil`)
"""
alias Alloy.Agent.{Config, Server, State, Turn}
alias Alloy.{Message, Result}
@type result :: Result.t()
@doc """
Send a message to a running agent without blocking the caller.
Non-blocking fire-and-forget. Returns `{:ok, request_id}` immediately.
Results are broadcast via PubSub. See `Alloy.Agent.Server.send_message/3`
for full documentation.
"""
@spec send_message(GenServer.server(), String.t(), keyword()) ::
{:ok, binary()} | {:error, :busy | :queue_full | :no_pubsub}
def send_message(server, message, opts \\ [])
defdelegate send_message(server, message, opts), to: Server
@doc """
Cancel an async request by `request_id`.
"""
@spec cancel_request(GenServer.server(), binary()) :: :ok | {:error, :not_found}
defdelegate cancel_request(server, request_id), to: Server
@doc """
Run the agent loop with a message and options.
The first argument can be a string (converted to a user message)
or ignored if `:messages` option provides conversation history.
Returns `{:ok, result}` on completion or `{:error, result}` on failure.
"""
@spec run(String.t() | nil, keyword()) :: {:ok, result()} | {:error, result()}
def run(message \\ nil, opts) do
do_run(message, opts, [])
end
@doc """
Run the agent loop and stream text deltas as they arrive.
This is a one-shot convenience API for callers who do not need a persistent
`Alloy.Agent.Server` process. It returns the same result shape as `run/2`.
The first argument can be a string (converted to a user message)
or `nil` if the `:messages` option provides conversation history.
## Options
Accepts the same options as `run/2`, plus:
- `:on_event` - function called with normalized event envelopes during the run
"""
@spec stream(String.t() | nil, (String.t() -> any), keyword()) ::
{:ok, result()} | {:error, result()}
def stream(message, on_chunk, opts \\ []) when is_function(on_chunk, 1) do
on_event = validate_on_event(Keyword.get(opts, :on_event))
turn_opts =
[streaming: true, on_chunk: on_chunk]
|> maybe_put(:on_event, on_event)
do_run(message, opts, turn_opts)
end
defp do_run(message, opts, turn_opts) do
config = Config.from_opts(opts)
messages = build_messages(message, opts)
state = %{State.init(config, messages) | status: :running}
try do
final_state = Turn.run_loop(state, turn_opts)
result = Result.from_state(final_state)
case final_state.status do
:completed -> {:ok, result}
:max_turns -> {:ok, result}
_ -> {:error, result}
end
after
State.cleanup(state)
end
end
defp build_messages(nil, opts) do
Keyword.get(opts, :messages, [])
end
defp build_messages(text, opts) when is_binary(text) do
existing = Keyword.get(opts, :messages, [])
existing ++ [Message.user(text)]
end
defp validate_on_event(nil), do: nil
defp validate_on_event(on_event) when is_function(on_event, 1), do: on_event
defp validate_on_event(bad) do
raise ArgumentError, "on_event must be a 1-arity function, got: #{inspect(bad)}"
end
defp maybe_put(opts, _key, nil), do: opts
defp maybe_put(opts, key, value), do: Keyword.put(opts, key, value)
end