README.md

# LoomEx

An Elixir framework for building AI agents from OTP primitives.

> **Status: Active Development** — LoomEx is being developed and iterated on. APIs may change. Contributions and feedback welcome.

## What is LoomEx?

LoomEx weaves conversations, tool calls, and reasoning into coherent AI agents using Elixir's OTP building blocks (GenServer, Task.Supervisor, ETS). Instead of importing a heavyweight framework, you compose agents from simple behaviours and let the BEAM handle concurrency, fault tolerance, and streaming.

**Core idea**: Define an agent (system prompt + tools + model), call `LoomEx.run/3`, get streaming results with automatic multi-step tool execution.

```elixir
defmodule MyAgent do
  use LoomEx.Agent

  def system_prompt(_ctx), do: "You are a helpful coding assistant."
  def tools, do: [LoomEx.Tools.Bash, LoomEx.Tools.ReadFile, LoomEx.Tools.Grep]
  def model, do: "fireworks/accounts/fireworks/models/kimi-k2p5"
end

{:ok, result} = LoomEx.run(MyAgent, [LoomEx.Message.user("Find all TODO comments")],
  sink: fn {:text_delta, d} -> IO.write(d); _ -> :ok end)
```

## Features

- **Agent behaviour** — Declarative agent definition with callbacks for system prompt, tools, model, temperature, max steps, error handling
- **Automatic tool loop** — LLM calls tool -> execute -> feed result back -> LLM continues -> ... until done. Parallel tool execution via Task.Supervisor
- **Streaming-first** — Every token streams through pluggable Sinks (callback function, process message, Phoenix SSE)
- **Provider-agnostic** — Any OpenAI-compatible API via model string routing (`"provider/model-name"`)
- **7 built-in tools** — bash, read_file, write_file, edit_file, grep, glob, human (with more planned)
- **Phoenix integration** — `LoomEx.Phoenix.Plug.stream_agent/4` for one-line SSE streaming with AI SDK v2 protocol compatibility
- **GenServer agents** — Long-lived multi-turn conversations via `LoomEx.start_agent/2` and `LoomEx.call/3`
- **Context management** — Auto-compaction when messages exceed context window, preserving tool call/result pairs
- **Model registry** — Fetches metadata for 4,000+ models from [models.dev](https://models.dev) (context window, pricing, capabilities)
- **Retry with backoff** — Exponential backoff for transient errors (429, 503, connection failures)
- **Telemetry** — Structured events for agent lifecycle, LLM calls, tool execution, context compaction
- **Standalone CLI** — Ship as a single binary via [Burrito](https://github.com/burrito-elixir/burrito) (9MB, zero dependencies)

## Installation

Add LoomEx to your `mix.exs`:

```elixir
# From GitHub
{:loom_ex, github: "lulucatdev/loom_ex"}

# Or as a path dependency during development
{:loom_ex, path: "../loom_ex"}
```

Configure a provider in `config/runtime.exs`:

```elixir
config :loom_ex,
  providers: %{
    fireworks: %{
      api_key: System.get_env("FIREWORKS_API_KEY"),
      base_url: "https://api.fireworks.ai/inference/v1/chat/completions"
    },
    openrouter: %{
      api_key: System.get_env("OPENROUTER_API_KEY"),
      base_url: "https://openrouter.ai/api/v1/chat/completions"
    }
  }
```

## Quick Start

### Define an Agent

```elixir
defmodule MyApp.MathAgent do
  use LoomEx.Agent

  @impl true
  def system_prompt(_ctx), do: "You are a math tutor. Use the calculator when needed."

  @impl true
  def tools, do: [MyApp.Tools.Calculator]

  @impl true
  def model, do: "fireworks/accounts/fireworks/models/kimi-k2p5"
end
```

### Define a Tool

```elixir
defmodule MyApp.Tools.Calculator do
  use LoomEx.Tool

  @impl true
  def name, do: "calculator"

  @impl true
  def description, do: "Evaluate a math expression."

  @impl true
  def parameters do
    %{
      type: "object",
      properties: %{
        expr: %{type: "string", description: "Math expression, e.g. '6 * 7'"}
      },
      required: ["expr"]
    }
  end

  @impl true
  def execute(%{"expr" => expr}, _ctx) do
    {result, _} = Code.eval_string(expr)
    {:ok, %{"result" => result}}
  end
end
```

### Run

```elixir
# Single execution with streaming
{:ok, result} = LoomEx.run(MyApp.MathAgent, [LoomEx.Message.user("What is 123 * 456?")],
  sink: fn
    {:text_delta, d} -> IO.write(d)
    {:tool_call_complete, tc} -> IO.puts("\n[Tool: #{tc.name}]")
    _ -> :ok
  end)

# result.messages  — full conversation history
# result.steps     — number of LLM calls
# result.usage     — %{"prompt_tokens" => ..., "completion_tokens" => ...}
```

### Multi-turn Conversations

```elixir
{:ok, pid} = LoomEx.start_agent(MyApp.MathAgent)
{:ok, _} = LoomEx.call(pid, "What is 2 + 2?")
{:ok, _} = LoomEx.call(pid, "Now multiply that by 10")
messages = LoomEx.get_messages(pid)  # full history
```

### Phoenix Controller

```elixir
defmodule MyAppWeb.ChatController do
  use MyAppWeb, :controller

  def chat(conn, %{"messages" => messages}) do
    {conn, _result} = LoomEx.Phoenix.Plug.stream_agent(conn, MyApp.ChatAgent, messages)
    conn
  end
end
```

The response streams as Server-Sent Events compatible with the [Vercel AI SDK](https://sdk.vercel.ai/) `useChat` hook.

## Built-in Tools

| Tool | Module | Description |
|------|--------|-------------|
| bash | `LoomEx.Tools.Bash` | Execute shell commands with timeout and output truncation |
| read_file | `LoomEx.Tools.ReadFile` | Read files with line-numbered pagination |
| write_file | `LoomEx.Tools.WriteFile` | Write files, auto-create directories |
| edit_file | `LoomEx.Tools.EditFile` | Exact string replacement with uniqueness check |
| grep | `LoomEx.Tools.Grep` | Search file contents with regex, glob filtering |
| glob | `LoomEx.Tools.Glob` | Find files by wildcard pattern |
| human | `LoomEx.Tools.Human` | Pause agent, ask user for input, continue |

Use them by listing in your agent's `tools/0`:

```elixir
def tools, do: [LoomEx.Tools.Bash, LoomEx.Tools.ReadFile, LoomEx.Tools.Grep]
```

## CLI Binary

LoomEx can be packaged as a standalone CLI via Burrito:

```bash
# Build (requires Zig: brew install zig)
MIX_ENV=prod mix release

# Run
./burrito_out/loom_ex_macos_arm64 "What is 2+2?"
./burrito_out/loom_ex_macos_arm64 chat --tools bash,grep "Find all TODOs"
./burrito_out/loom_ex_macos_arm64 chat -i --model anthropic/claude-sonnet-4-6

# Pipe support
cat file.ex | ./loom_ex "summarize this code"
git diff | ./loom_ex "review this diff"
```

## Agent Callbacks

| Callback | Default | Description |
|----------|---------|-------------|
| `system_prompt(ctx)` | *required* | System prompt, receives context map |
| `tools()` | *required* | List of tool modules |
| `model()` | *required* | Model string, e.g. `"fireworks/model-name"` |
| `max_steps()` | `10` | Maximum tool-call loops before stopping |
| `temperature()` | `0.1` | LLM temperature |
| `context_window()` | `128_000` | Fallback context window (auto-resolved from models.dev) |
| `extra_body()` | `%{}` | Extra fields merged into LLM request body |
| `on_step(info, ctx)` | `:continue` | Called after each tool execution step |
| `on_error(error, ctx)` | `{:stop, error}` | Called on LLM errors |

## Architecture

```
LoomEx.run/3 or LoomEx.start_agent/2 + LoomEx.call/3
  |
  v
LoomEx.Agent.Runner (core loop)
  |
  +-- LoomEx.Context.maybe_compact()     auto-compress long conversations
  +-- LoomEx.LLM.Retry.chat_stream()    exponential backoff retry
  |     +-- LoomEx.LLM.Client           Req + SSEParser streaming
  |     +-- LoomEx.LLM.Provider         model string -> provider config
  |     +-- LoomEx.Models (ETS)         models.dev metadata cache
  +-- Tool execution                   Task.Supervisor (parallel)
  +-- LoomEx.Sink                        streaming output
  |     +-- Callback (fn)
  |     +-- Process (pid message)
  |     +-- LoomEx.Phoenix.SSESink       AI SDK v2 SSE protocol
  +-- LoomEx.Telemetry                   structured observability events
```

## Telemetry Events

| Event | Measurements | Metadata |
|-------|-------------|----------|
| `[:loom_ex, :agent, :start]` | | agent, context |
| `[:loom_ex, :agent, :stop]` | duration, steps | agent, result |
| `[:loom_ex, :step, :start]` | | agent, step |
| `[:loom_ex, :step, :stop]` | duration | agent, step |
| `[:loom_ex, :llm, :start]` | | model, message_count |
| `[:loom_ex, :llm, :stop]` | duration | model, finish_reason |
| `[:loom_ex, :llm, :retry]` | delay_ms | model, attempt, error |
| `[:loom_ex, :tool, :start]` | | tool, tool_call_id |
| `[:loom_ex, :tool, :stop]` | duration | tool, tool_call_id |
| `[:loom_ex, :tool, :error]` | duration | tool, error |
| `[:loom_ex, :context, :compact]` | tokens_before, tokens_after | removed, kept |

Attach the default logger for development:

```elixir
LoomEx.Telemetry.attach_default_logger()
```

## Design Principles

- **OTP-native** — Agents are GenServers, tools execute via Task.Supervisor, model registry lives in ETS. No alien abstractions.
- **Streaming-first** — Every LLM token streams to the consumer in real-time. Backpressure handled naturally by the BEAM.
- **Transparent message chain** — Messages are explicit parameters, never hidden behind framework state. You always know what the LLM sees.
- **Provider-agnostic** — Any OpenAI-compatible API. Model string format: `"provider/model-name"`.
- **Composable** — Agents can delegate to sub-agents via tools. Tools are plain modules implementing a behaviour.

## Inspirations

- [pi-mono](https://github.com/badlogic/pi-mono) — Minimalist agent framework philosophy, extension system
- [Legion](https://github.com/dimamik/legion) — Elixir agent framework, GenServer agent lifecycle, Vault pattern
- [Vercel AI SDK](https://sdk.vercel.ai/) — Streaming protocol, `useChat` hook, `maxSteps`
- [Why Elixir/OTP Doesn't Need an Agent Framework](https://goto-code.com/why-elixir-otp-doesnt-need-agent-framework-part-1/) — Use OTP primitives directly
- [models.dev](https://models.dev) — Model metadata registry

## License

MIT