README.md

# Tubie

A minimal agent composition library in Elixir. Inspired by [PocketFlow](https://github.com/The-Pocket/PocketFlow).

**Core idea:** An agent is any function `State -> State`. Combinators compose agents into larger agents. That's the whole framework.

## Installation

```elixir
def deps do
  [{:tubie, "~> 0.1.0"}]
end
```

Or in a script:

```elixir
Mix.install([{:tubie, "~> 0.1.0"}])
```

## State

`Tubie.State` is a map-like accumulator with a `status` field (`:ok`, `:halt`, or `{:error, reason}`) that controls flow:

```elixir
state = Tubie.State.new(%{messages: []})
state = Tubie.State.put(state, :name, "Alice")
Tubie.State.get(state, :name)  # => "Alice"
```

## Combinators

Every combinator takes agents and returns a new agent. All are pipeable.

| Combinator | What it does |
|---|---|
| `sequence([a, b, c])` | Run left-to-right, early exit on halt/error |
| `and_then(a, b)` | Pipeable sequence of two |
| `branch(classifier, table)` | Dispatch based on state |
| `loop(a, max: n)` | Repeat until halt or max iterations |
| `fan_out([a, b], as: key)` | Run concurrently, collect results under `key` |
| `fan_out([a, b])` | Run concurrently, fire-and-forget |
| `with_retry(a, max: n)` | Retry on error with optional backoff |
| `with_fallback(a, handler)` | Rescue exceptions |

## Examples

### LLM tool-calling loop

```elixir
weather_agent =
  call_llm
  |> Tubie.with_retry(max: 3, wait: 1_000)
  |> Tubie.with_fallback(fn state, e ->
    Tubie.State.error(state, Exception.message(e))
  end)
  |> Tubie.and_then(
    Tubie.branch(has_tool_calls?, %{
      tools: execute_tools,
      done:  &Tubie.State.halt/1
    })
  )
  |> Tubie.loop(max: 10)
```

### Concurrent fan-out with merge

```elixir
fetch_weather = fn label ->
  fn state ->
    loc = Tubie.State.get(state, :location)
    temp = Enum.random(50..95)
    Tubie.State.put(state, :temp, temp)
  end
end

average_temps = fn state ->
  [a, b] = Tubie.State.get(state, :readings)
  avg = div(Tubie.State.get(a, :temp) + Tubie.State.get(b, :temp), 2)
  Tubie.State.put(state, :avg_temp, avg)
end

Tubie.fan_out([fetch_weather.("A"), fetch_weather.("B")], as: :readings)
|> Tubie.and_then(average_temps)
```

### Running the full example

```bash
OPENAI_API_KEY=sk-... elixir examples/weather_agent.exs
```

See the full example at [examples/weather_agent.exs](https://github.com/zxygentoo/tubie/blob/main/examples/weather_agent.exs).
```

## License

MIT