Skip to content

Instantly share code, notes, and snippets.

@josefrichter
Last active June 11, 2026 14:07
Show Gist options
  • Select an option

  • Save josefrichter/0d60ce88c9430b79581a5e2e12b0ffdb to your computer and use it in GitHub Desktop.

Select an option

Save josefrichter/0d60ce88c9430b79581a5e2e12b0ffdb to your computer and use it in GitHub Desktop.
Tiny AI-agent harness in Elixir (loop + tools + long-task planning) for local Ollama — an Elixir port of ruxu.dev's 'Build a Basic AI Agent From Scratch' series, commented with why the BEAM fits an agent harness.

mini agent harness (Elixir + Ollama)

A tiny AI-agent harness in Elixir, talking to a local Ollama. It's an Elixir port of Roger Oriol's "Build a Basic AI Agent From Scratch" series (part 1, part 2: tools, part 3: long-task planning), rebuilt on OTP primitives. All three stages are here:

  • Part 1 — the loop: an LLM connection + message history + a loop
  • Part 2 — tools: bash / read / write / edit / glob / grep / webfetch
  • Part 3 — planning: a scratchpad + a to-do list so it can plan long tasks

Run

ollama serve            # and: ollama pull llama3.1  (any tool-calling model)

git clone https://gist.github.com/0d60ce88c9430b79581a5e2e12b0ffdb.git mini-agent
cd mini-agent
elixir run.exs

Config via env vars: AGENT_MODEL (default llama3.1), OLLAMA_URL (default http://localhost:11434/v1/chat/completions).

Files

File Module What it is
run.exs — Entry point: installs deps, loads the modules, starts the REPL
tools.exs Harness.Tools Action tools + planning tools (scratchpad, to-do)
todo.exs Harness.Todo The to-do list rules, as pure functions over a list
llm.exs Harness.LLM The Ollama connection (OpenAI-compatible endpoint)
agent.exs Harness.Agent The GenServer — one isolated process per session — and the loop
cli.exs Harness.CLI A tiny REPL front end
todo_board.exs Harness.TodoBoard A to-do list shared by many agents — a GenServer (see below)
shared_board.exs — Runnable demo: many concurrent writers on one shared board (no LLM)

Architecture

Who owns what, and who talks to whom. Blue = a live process (a GenServer or a supervised Task); green = a plain stateless module (code, no process of its own); red = an external system.

flowchart TB
    user(["You (terminal)"])

    subgraph mainproc["main process"]
        cli["Harness.CLI<br/>REPL loop"]
    end

    subgraph sess["one agent session — its own processes"]
        agent["Harness.Agent · GenServer<br/>owns: messages · scratchpad · todos"]
        sup["Task.Supervisor<br/>one per agent"]
        task["tool tasks<br/>serial + parallel"]
    end

    llm["Harness.LLM"]
    tools["Harness.Tools<br/>dispatch + impls"]
    todo["Harness.Todo<br/>pure rules"]
    board["Harness.TodoBoard<br/>GenServer · shared"]
    ollama[("Ollama<br/>port 11434")]
    world[("bash · files · web")]

    user -->|"types a goal"| cli
    cli -->|"GenServer.call {:chat, input}"| agent
    agent -->|"in-process call"| llm
    llm <-->|"HTTP JSON"| ollama
    agent -->|"async_nolink / async_stream_nolink"| sup
    sup --> task
    task -->|"Harness.Tools.run/3"| tools
    tools -->|"action tools"| world
    tools -->|"private todos"| todo
    tools -->|"GenServer.call (shared mode)"| board
    board --> todo
    board -.->|"broadcast {:todo_board, items}"| agent
    agent -->|"final answer"| cli

    classDef proc fill:#dbeafe,stroke:#2563eb,color:#111;
    classDef mod fill:#dcfce7,stroke:#16a34a,color:#111;
    classDef ext fill:#fee2e2,stroke:#dc2626,color:#111;
    class agent,sup,task,board proc;
    class cli,llm,tools,todo mod;
    class ollama,world ext;
Loading

Two things to read off it: the conversation never leaves the Harness.Agent process (state ownership), and every tool runs out in a supervised Task, not in the agent — so a hung or crashing tool can't take the session down.

One turn, step by step — the loop runs until the model stops asking for tools:

sequenceDiagram
    actor U as You
    participant C as Harness.CLI
    participant A as Harness.Agent
    participant L as Harness.LLM / Ollama
    participant S as Task.Supervisor + tasks
    participant T as Tools / TodoBoard

    U->>C: goal
    C->>A: GenServer.call {:chat, input}
    loop until no more tool_calls
        A->>L: messages + tool schemas
        L-->>A: assistant message
        alt model asked for tools
            A->>S: spawn — serial threaded, parallel fanned-out
            S->>T: Harness.Tools.run/3
            T-->>S: result strings
            S-->>A: results, collected by id
            A->>A: append tool results, loop
        else final answer
            A-->>C: text
        end
    end
    C-->>U: prints answer
Loading

Why Elixir/BEAM fits an agent harness

A harness is "supervise a long-lived loop that runs untrusted, slow, crash-prone work." That's exactly what the BEAM was built for. Versus the Python original:

  1. Each agent session IS a process — a GenServer (Harness.Agent). Its conversation, scratchpad and to-do list are state owned by that one process. The Python version keeps the scratchpad/to-do as module-level mutable globals, so one OS process can only ever run one conversation. Here, start_link again and you have a second agent — isolated, with its own memory, running concurrently. Spawn thousands; the BEAM schedules them. Messages to a process are serialized, so there are no data races, and the process can be named, supervised, restarted, or told to stream progress to a UI.

  2. Tools run under the agent's own Task.Supervisor. Each tool call is a supervised task with a hard timeout (Task.shutdown / on_timeout: :kill_task) and no link back to the agent (async_nolink), so a tool that hangs (a runaway bash) or crashes comes back as an error string the model can replan around — it never takes the agent down. The Python loop only catches TypeError; anything else, or a subprocess with no timeout, wedges or kills the agent.

  3. State is immutable, so sharing it is safe. The loop threads one map and returns a new one; nothing mutates underneath you. That's what lets #1 hold thousands of isolated agents, and lets the parallel tool calls in #4 run without locks.

  4. Independent tool calls run in parallel. When the model fires off several reads/greps/fetches in one turn, they run concurrently via Task.Supervisor.async_stream_nolink; the few tools that mutate the agent's own state are serialized so their results can be threaded. Control flow stays plain — pattern matching + recursion, one function head per tool.

Design note: why the scratchpad and to-do list are not their own processes

A process earns its existence when it has concurrency, an independent lifecycle, or shared access by multiple processes. The agent has all three. Inside one agent's sequential loop, the scratchpad (a String) and to-do list (a [map]) have none of them — only the agent ever touches them, they die with the agent, and tool execution is already crash-isolated by the Task wrapper. Wrapping a value you access sequentially in a read/write process is the classic "process as a data structure" anti-pattern; here they're just the agent's owned, immutable state.

That flips the moment the state is shared (multiple sub-agents collaborating on one to-do board), must outlive the agent (persistence), or must stream to a LiveView. Then it genuinely deserves its own process (or ETS/DB) — and the message passing becomes the point, not boilerplate. Which is exactly the next file.

Shared to-do board (the multi-agent case)

Harness.TodoBoard is the version that earns a process, because a board shared by many agents has all three properties a private to-do list lacks:

  • concurrency — many agents append/update at once; the GenServer serializes every change, so no lost updates and no torn reads;
  • shared access — every agent holding the pid sees the same board;
  • own lifecycle — it can outlive any one agent, be supervised, and broadcast changes to watchers (a coordinator, a LiveView) via subscribe/2.
flowchart LR
    subgraph agents["many agent sessions"]
        a1["Harness.Agent #1"]
        a2["Harness.Agent #2"]
        a3["Harness.Agent #3"]
    end
    board["Harness.TodoBoard<br/>one shared GenServer"]
    watcher(["subscriber<br/>coordinator / LiveView"])

    a1 -->|"append · update · list"| board
    a2 -->|"append · update · list"| board
    a3 -->|"append · update · list"| board
    board -.->|"broadcast {:todo_board, items}"| watcher

    classDef proc fill:#dbeafe,stroke:#2563eb,color:#111;
    class a1,a2,a3,board proc;
Loading

The rules still live in Harness.Todo (pure) — the board only adds the concurrency/sharing/notification layer. Same to-do tool interface as before; the agent just picks the backend at start:

{:ok, board} = Harness.TodoBoard.start_link()
agents = for _ <- 1..3, do: elem(Harness.Agent.start_link(board: board), 1)
Task.async_stream(agents, &Harness.Agent.chat(&1, "...your slice of the goal..."))
IO.puts(Harness.TodoBoard.list(board, true))   # the merged plan

See it without an LLM (instant — four concurrent writers hammering one board):

elixir shared_board.exs

This is the heuristic in action: the same to-do data is private state in the single-agent example and a process here — driven purely by whether it's shared, not by a reflex to wrap everything in a GenServer.

# Harness.Agent — the agent itself. Loaded by run.exs.
#
# THIS is the Elixir differentiator: a session is a GenServer — a process that
# OWNS its state (conversation + scratchpad + to-do list). Start as many as you
# like, each fully isolated and running concurrently. The Python version can't:
# its scratchpad and to-do are global singletons shared by the whole interpreter,
# so one process = one conversation, forever.
defmodule Harness.Agent do
use GenServer
@max_steps 40
@tool_timeout 60_000
# Part 3's "how to think" lives in the system prompt. Trimmed from the article,
# but the same idea: plan in the scratchpad, track work in the to-do list,
# replan on failure, and verify before declaring done.
@system """
You are a capable coding and research assistant working in the current directory.
## Tools
Action: run_bash, read_file, write_file, edit_file, glob_files, grep, webfetch
Planning:
- scratchpad (read_scratchpad / write_scratchpad): your private working memory.
Use it to restate the goal, weigh options, and note findings before acting.
- to-do list (todo_append / todo_update / todo_list): track multi-step work.
## How to work
For tasks of ~3+ steps: first write your plan to the scratchpad, then break the
work into todos (status: pending). Mark exactly one item in_progress before you
start it, and mark it done the moment it's finished — don't batch. For trivial
one-step tasks, just act.
## Replanning
After each tool result, check it matched your expectation. On failure: diagnose
in the scratchpad, mark the task failed, then either retry (failed -> in_progress,
the tool reports the retry count), replace it with a better task, or reorder. If
the retry limit is reached, stop and escalate to the user.
## Done detection
Don't stop just because the to-do list looks empty. Before a final answer:
confirm no pending/in_progress/failed items, verify the result against the goal
(run the build/tests for code tasks), and re-read the scratchpad for loose ends.
Planning tool calls are internal bookkeeping — after one, keep working; never
reply with an empty message.
"""
# --- public API ----------------------------------------------------------
# Each start_link/1 is a new, independent agent. Options:
# :board — a Harness.TodoBoard pid to use a SHARED to-do list instead of a
# private one. Omit it and the agent keeps its own to-do list.
# plus any GenServer option (e.g. :name).
#
# A fleet of agents collaborating on ONE shared board:
# {:ok, board} = Harness.TodoBoard.start_link()
# agents = for _ <- 1..3, do: elem(Harness.Agent.start_link(board: board), 1)
# Task.async_stream(agents, &Harness.Agent.chat(&1, "...your slice of the goal..."))
# # ...then Harness.TodoBoard.list(board, true) shows the merged plan.
def start_link(opts \\ []) do
{board, gen_opts} = Keyword.pop(opts, :board)
GenServer.start_link(__MODULE__, board, gen_opts)
end
@doc "Send the agent a goal; blocks until it produces a final answer."
def chat(agent, input), do: GenServer.call(agent, {:chat, input}, :infinity)
# --- callbacks -----------------------------------------------------------
@impl true
def init(board) do
# Each agent owns its OWN Task.Supervisor (started here, so it's linked to this
# process and shares its lifecycle). Every tool call runs under it — that's how
# a hung or crashing tool stays contained instead of taking the agent down.
{:ok, tool_sup} = Task.Supervisor.start_link()
# The conversation and scratchpad always live HERE, private to this process.
# The to-do list is private too UNLESS a shared board pid was passed in — see
# Harness.Tools, which routes the todo_* tools to the board when present.
{:ok,
%{
messages: [%{"role" => "system", "content" => @system}],
scratchpad: "",
todos: [],
board: board,
tool_sup: tool_sup
}}
end
@impl true
def handle_call({:chat, input}, _from, state) do
# This call is busy for the whole loop (possibly many LLM round-trips). That's
# fine — every session has its OWN process, so other agents run undisturbed.
state = %{state | messages: state.messages ++ [%{"role" => "user", "content" => input}]}
{answer, state} = agent_loop(state, 0)
{:reply, answer, state}
end
# --- the loop (Part 1+2) -------------------------------------------------
# Ask the model; if it wants tools, run them and feed the results back;
# otherwise we have the final answer. Recursion replaces Python's while-True.
defp agent_loop(state, step) when step >= @max_steps,
do: {"[stopped after #{@max_steps} steps]", state}
defp agent_loop(state, step) do
message = Harness.LLM.chat(state.messages)
state = %{state | messages: state.messages ++ [message]}
case message["tool_calls"] do
calls when is_list(calls) and calls != [] ->
Enum.each(calls, &log_call/1)
{blocks, state} = run_tools(calls, state)
state = %{state | messages: state.messages ++ blocks}
agent_loop(state, step + 1)
_ ->
{message["content"] || "", state}
end
end
# Run a turn's tool calls. The catch with parallelism: a few tools mutate the
# agent's OWN state (write_scratchpad, and todo_append/update in private mode),
# and you can't fan those out and merge the results. So we split: state-mutating
# tools run sequentially, threading state; everything else — the file reads,
# greps, bash and fetches the model fired off together — runs in PARALLEL. All
# of it under the agent's Task.Supervisor, so a hung/crashing tool is contained.
@serial_tools ~w(write_scratchpad todo_append todo_update)
defp run_tools(calls, state) do
{serial, parallel} = Enum.split_with(calls, &(tool_name(&1) in @serial_tools))
# 1) state-mutating tools: one at a time, threading state through each.
{by_id, state} =
Enum.reduce(serial, {%{}, state}, fn call, {acc, st} ->
{result, st} = run_tool_supervised(st.tool_sup, tool_name(call), tool_args(call), st)
{Map.put(acc, call["id"], result), st}
end)
# 2) independent tools: concurrently, against the (now-updated) state. They
# only READ state, so we hand each a slim copy (no message history) and ignore
# the state they return. async_stream_nolink keeps a crash/timeout from reaching
# the agent — it comes back as {:exit, reason} instead.
ctx = Map.take(state, [:scratchpad, :todos, :board])
by_id =
state.tool_sup
|> Task.Supervisor.async_stream_nolink(
parallel,
fn call -> safe_run(tool_name(call), tool_args(call), ctx) |> elem(0) end,
timeout: @tool_timeout,
on_timeout: :kill_task,
ordered: true
)
|> Enum.zip(parallel)
|> Enum.reduce(by_id, fn
{{:ok, result}, call}, acc ->
Map.put(acc, call["id"], result)
{{:exit, :timeout}, call}, acc ->
Map.put(acc, call["id"], "Error: #{tool_name(call)} timed out after #{div(@tool_timeout, 1000)}s and was killed.")
{{:exit, reason}, call}, acc ->
Map.put(acc, call["id"], "Error: #{tool_name(call)} crashed: #{inspect(reason)}.")
end)
# Emit tool results in the model's original call order (the API matches them
# by id, so order is only cosmetic).
blocks =
Enum.map(calls, fn call ->
%{"role" => "tool", "tool_call_id" => call["id"], "content" => to_string(by_id[call["id"]])}
end)
{blocks, state}
end
# One state-mutating tool, under the supervisor, with a hard timeout. async_nolink
# means even a hung task we Task.shutdown comes back as nil rather than killing
# the agent; the inner safe_run turns ordinary exceptions into clean results.
defp run_tool_supervised(sup, name, args, state) do
task = Task.Supervisor.async_nolink(sup, fn -> safe_run(name, args, state) end)
case Task.yield(task, @tool_timeout) || Task.shutdown(task) do
{:ok, {result, new_state}} -> {result, new_state}
{:ok, other} -> {"Error: #{name} returned #{inspect(other)}", state}
{:exit, reason} -> {"Error: #{name} crashed: #{inspect(reason)}.", state}
nil -> {"Error: #{name} timed out after #{div(@tool_timeout, 1000)}s and was killed.", state}
end
end
# Two layers of safety, on purpose:
# * this try/rescue turns an EXPECTED tool exception (a bad regex, a missing
# file) into a tidy error string the model can read — no scary crash report;
# * the surrounding Task.Supervisor is the backstop for the UNEXPECTED — a tool
# that wedges past the timeout, or a process-level exit no rescue can catch.
defp safe_run(name, args, state) do
Harness.Tools.run(name, args, state)
rescue
e -> {"Error: #{name} raised: #{Exception.message(e)}", state}
end
defp log_call(call) do
IO.puts(IO.ANSI.faint() <> " ⚙ #{tool_name(call)} #{inspect(tool_args(call)) |> String.slice(0, 120)}" <> IO.ANSI.reset())
end
defp tool_name(call), do: call["function"]["name"]
defp tool_args(call), do: decode_args(call["function"]["arguments"])
# OpenAI/Ollama send tool arguments as a JSON string; some builds send an object.
defp decode_args(args) when is_binary(args), do: Jason.decode!(args)
defp decode_args(args) when is_map(args), do: args
defp decode_args(_), do: %{}
end
# Harness.CLI — a tiny REPL front end. Loaded by run.exs.
#
# It drives ONE agent process. The same Harness.Agent would be just as happy
# behind a Phoenix channel, an Oban job, or a DynamicSupervisor — it's only a
# process. The CLI is just one possible front end.
defmodule Harness.CLI do
def main do
IO.puts("mini agent (Ollama) — type a goal, or /exit to quit.\n")
{:ok, agent} = Harness.Agent.start_link()
repl(agent)
end
defp repl(agent) do
case IO.gets("\nYou: ") do
:eof ->
:ok
line ->
case String.trim(line) do
"" ->
repl(agent)
cmd when cmd in ["/exit", "exit", "quit", "\\exit"] ->
IO.puts("bye")
input ->
IO.puts("\nAssistant: #{Harness.Agent.chat(agent, input)}")
repl(agent)
end
end
end
end
# Harness.LLM — the LLM connection. Loaded by run.exs.
#
# Talks to Ollama's OpenAI-compatible /v1/chat/completions endpoint.
defmodule Harness.LLM do
@url System.get_env("OLLAMA_URL") || "http://localhost:11434/v1/chat/completions"
@model System.get_env("AGENT_MODEL") || "llama3.1"
# Send the whole conversation + tool schemas, return the assistant message map
# (with "content" and possibly "tool_calls"). We hand the raw message straight
# back into history on the next turn, so we never lose tool_call ids.
def chat(messages) do
body = %{
"model" => @model,
"messages" => messages,
"tools" => Harness.Tools.schemas(),
"temperature" => 0.7
}
case Req.post(@url, json: body, receive_timeout: 120_000) do
{:ok, %{status: 200, body: %{"choices" => [%{"message" => msg} | _]}}} ->
msg
{:ok, %{status: status, body: body}} ->
raise "Ollama returned #{status}: #{inspect(body)}"
{:error, reason} ->
raise "Can't reach Ollama at #{@url} (#{inspect(reason)}). Is `ollama serve` running?"
end
end
end
# Entry point. Run with: elixir run.exs
#
# A split-into-modules .exs program can't be run as a lone file, so this script
# does the wiring: install deps, then load the module files in dependency order
# (tools <- llm <- agent <- cli), then start the REPL. Loading in order keeps the
# compiler from warning about not-yet-defined modules.
Mix.install([:req])
for file <- ["todo.exs", "todo_board.exs", "tools.exs", "llm.exs", "agent.exs", "cli.exs"] do
Code.require_file(file, __DIR__)
end
Harness.CLI.main()
# Demo: many agents sharing ONE to-do board, concurrently. No LLM, no deps —
# runs instantly: elixir shared_board.exs
#
# It stands in plain Tasks for the "sub-agents" so the point is unmissable: the
# board is a process, several writers hit it at the same time, and it stays
# correct because the GenServer serializes every change. Swap the Tasks for
# Harness.Agent.start_link(board: board) and these become real LLM agents.
Code.require_file("todo.exs", __DIR__)
Code.require_file("todo_board.exs", __DIR__)
defmodule Demo do
# Count the live {:todo_board, _} broadcasts that piled up in our mailbox.
def flush(n \\ 0) do
receive do
{:todo_board, _items} -> flush(n + 1)
after
0 -> n
end
end
end
{:ok, board} = Harness.TodoBoard.start_link()
Harness.TodoBoard.subscribe(board)
# The board rejects duplicate ids GLOBALLY, so workers namespace their ids —
# exactly the cross-agent coordination you want. Try giving two workers the same
# id and you'll get an "already exists" back from the board.
workers = 1..4
workers
|> Task.async_stream(
fn w ->
for step <- 1..3 do
id = "w#{w}-#{step}"
Harness.TodoBoard.append(board, id, "worker #{w}, step #{step}", "pending")
Harness.TodoBoard.update(board, id, nil, "in_progress")
Harness.TodoBoard.update(board, id, nil, "done")
end
end,
max_concurrency: 4
)
|> Stream.run()
IO.puts("#{Demo.flush()} live board updates were broadcast to this subscriber.\n")
IO.puts(Harness.TodoBoard.list(board, true))
# Harness.Todo — the to-do list RULES, as pure functions over a plain list.
# Loaded by run.exs (and by shared_board.exs).
#
# No process, no state ownership — just `list -> {:ok, new_list, msg} | {:error, msg}`.
# This is deliberately NOT a GenServer: the rules are data transformations, and we
# want to host them two different ways without duplicating them —
# * privately, inside one agent's state (see Harness.Tools)
# * shared, inside a Harness.TodoBoard process (see todo_board.exs)
# A process is just one way to host this data; the logic doesn't need one.
defmodule Harness.Todo do
@statuses ~w(pending in_progress done cancelled failed)
@retry_limit 3
def statuses, do: @statuses
def append(items, id, content, status) do
cond do
status not in @statuses ->
{:error, "Invalid status '#{status}'. Valid: #{Enum.join(@statuses, ", ")}."}
Enum.any?(items, &(&1.id == id)) ->
{:error, "To-do '#{id}' already exists."}
true ->
item = %{id: id, content: content, status: status, retries: 0}
{:ok, items ++ [item], "Appended to-do '#{id}'."}
end
end
def update(items, id, content, status) do
case Enum.find_index(items, &(&1.id == id)) do
nil ->
{:error, "To-do '#{id}' not found."}
idx ->
item = Enum.at(items, idx)
if status && status not in @statuses do
{:error, "Invalid status '#{status}'."}
else
# Retrying a failed task (failed -> in_progress) bumps the retry count.
retries =
if item.status == "failed" and status == "in_progress",
do: item.retries + 1,
else: item.retries
updated = %{item | content: content || item.content, status: status || item.status, retries: retries}
msg =
cond do
status == "in_progress" and retries >= @retry_limit ->
"Updated '#{id}' to in_progress — retry #{retries}/#{@retry_limit} (limit reached). Stop retrying; escalate to the user."
status == "in_progress" and retries > 0 ->
"Updated '#{id}'. Retry attempt #{retries}/#{@retry_limit}."
true ->
"Updated '#{id}'."
end
{:ok, List.replace_at(items, idx, updated), msg}
end
end
end
def format(items, include_completed) do
items =
if include_completed,
do: items,
else: Enum.reject(items, &(&1.status in ["done", "cancelled"]))
summary = Enum.map_join(@statuses, "", fn s -> "#{Enum.count(items, &(&1.status == s))} #{s}\n" end)
body =
Enum.map_join(items, "\n", fn i ->
note = if i.retries > 0, do: ", #{i.retries} retries", else: ""
"- [#{i.id}] #{i.content} (#{i.status}#{note})"
end)
"To-Do (#{length(items)} shown)\n#{summary}-----\n#{body}"
end
end
# Harness.TodoBoard — a to-do list SHARED by many agents. Loaded by run.exs.
#
# THIS one earns a process. Unlike an agent's private scratchpad/to-do, a shared
# board has all three properties that justify a process:
# * concurrency — many agents append/update at once; the GenServer
# serializes them, so no lost updates and no torn reads.
# * shared access — every agent holding this pid sees the same board.
# * own lifecycle — it can outlive any single agent, be supervised, and
# broadcast changes to watchers (a coordinator, a LiveView).
#
# The actual rules still live in Harness.Todo (pure) — the process only adds the
# concurrency/sharing/notification layer on top.
defmodule Harness.TodoBoard do
use GenServer
# --- client API ----------------------------------------------------------
def start_link(opts \\ []), do: GenServer.start_link(__MODULE__, :ok, opts)
def append(board, id, content, status \\ "pending"),
do: GenServer.call(board, {:append, id, content, status})
def update(board, id, content \\ nil, status \\ nil),
do: GenServer.call(board, {:update, id, content, status})
def list(board, include_completed \\ false),
do: GenServer.call(board, {:list, include_completed})
# Watchers get a `{:todo_board, items}` message on every change — e.g. a
# coordinator printing progress, or a LiveView pushing it to the browser.
def subscribe(board, pid \\ self()), do: GenServer.call(board, {:subscribe, pid})
# --- server --------------------------------------------------------------
@impl true
def init(:ok), do: {:ok, %{items: [], subscribers: []}}
@impl true
def handle_call({:append, id, content, status}, _from, state) do
case Harness.Todo.append(state.items, id, content, status) do
{:ok, items, msg} -> {:reply, msg, broadcast(%{state | items: items})}
{:error, msg} -> {:reply, msg, state}
end
end
def handle_call({:update, id, content, status}, _from, state) do
case Harness.Todo.update(state.items, id, content, status) do
{:ok, items, msg} -> {:reply, msg, broadcast(%{state | items: items})}
{:error, msg} -> {:reply, msg, state}
end
end
def handle_call({:list, include_completed}, _from, state),
do: {:reply, Harness.Todo.format(state.items, include_completed), state}
def handle_call({:subscribe, pid}, _from, state),
do: {:reply, :ok, %{state | subscribers: [pid | state.subscribers]}}
defp broadcast(state) do
for pid <- state.subscribers, do: send(pid, {:todo_board, state.items})
state
end
end
# Harness.Tools — the tools. Loaded by run.exs.
#
# Every tool is `run(name, args, state) -> {result_string, new_state}`.
#
# Action tools (bash, read_file, ...) leave state untouched. Planning tools:
# * scratchpad — always the agent's private, owned state (a String).
# * to-do list — EITHER private state OR a shared Harness.TodoBoard process,
# depending on whether the agent was started with `board: pid`.
# Same tool interface, two backends; the rules live in
# Harness.Todo so neither backend duplicates them.
defmodule Harness.Tools do
# --- schemas (what we advertise to the model) ----------------------------
def schemas do
[
tool("run_bash", "Run a bash command and return its output.",
%{"command" => prop("string", "The bash command to execute.")}, ["command"]),
tool("read_file", "Read lines from a file, prefixed with line numbers.",
%{"path" => prop("string", "Path to the file."),
"offset" => prop("integer", "First line (1-indexed). Default 1."),
"limit" => prop("integer", "Max lines to return. Default 200.")}, ["path"]),
tool("write_file", "Write content to a file, creating parent dirs.",
%{"path" => prop("string", "Path of the file."),
"content" => prop("string", "Full content to write.")}, ["path", "content"]),
tool("edit_file", "Replace the first occurrence of a string in a file.",
%{"path" => prop("string", "Path of the file."),
"old_string" => prop("string", "Exact string to find."),
"new_string" => prop("string", "Replacement string.")}, ["path", "old_string", "new_string"]),
tool("glob_files", "Find files matching a glob pattern (e.g. '**/*.ex').",
%{"pattern" => prop("string", "Glob pattern."),
"path" => prop("string", "Root dir. Default '.'.")}, ["pattern"]),
tool("grep", "Search file contents for a regex; returns path:line:match.",
%{"pattern" => prop("string", "Regex to search for."),
"path" => prop("string", "Dir to search. Default '.'."),
"include" => prop("string", "Filename glob to restrict files. Default '*'.")}, ["pattern"]),
tool("webfetch", "Fetch an http(s) URL and return it as plain text.",
%{"url" => prop("string", "The URL to fetch.")}, ["url"]),
# --- planning tools (Part 3) -----------------------------------------
tool("read_scratchpad", "Read your private working memory.", %{}, []),
tool("write_scratchpad", "Overwrite your private working memory with new content.",
%{"content" => prop("string", "Text to store.")}, ["content"]),
tool("todo_append", "Add a task to the to-do list.",
%{"id" => prop("string", "Unique id."),
"content" => prop("string", "What the task is."),
"status" => prop("string", "pending | in_progress | done | cancelled | failed")},
["id", "content", "status"]),
tool("todo_update", "Update a task's content and/or status.",
%{"id" => prop("string", "Task id."),
"content" => prop("string", "New content (optional)."),
"status" => prop("string", "New status (optional).")}, ["id"]),
tool("todo_list", "List the to-do items with a status summary.",
%{"include_completed" => prop("boolean", "Include done/cancelled. Default false.")}, [])
]
end
defp tool(name, desc, props, required) do
%{"type" => "function",
"function" => %{"name" => name, "description" => desc,
"parameters" => %{"type" => "object", "properties" => props, "required" => required}}}
end
defp prop(type, desc), do: %{"type" => type, "description" => desc}
# --- dispatch — one function head per tool, courtesy of pattern matching --
def run("run_bash", %{"command" => c}, s), do: {bash(c), s}
def run("read_file", a, s), do: {read_file(a["path"], a["offset"] || 1, a["limit"] || 200), s}
def run("write_file", %{"path" => p, "content" => c}, s), do: {write_file(p, c), s}
def run("edit_file", %{"path" => p, "old_string" => o, "new_string" => n}, s), do: {edit_file(p, o, n), s}
def run("glob_files", a, s), do: {glob_files(a["pattern"], a["path"] || "."), s}
def run("grep", a, s), do: {grep(a["pattern"], a["path"] || ".", a["include"] || "*"), s}
def run("webfetch", %{"url" => u}, s), do: {webfetch(u), s}
def run("read_scratchpad", _a, s),
do: {if(s.scratchpad in [nil, ""], do: "(empty)", else: s.scratchpad), s}
def run("write_scratchpad", %{"content" => c}, s),
do: {"Saved to scratchpad.", %{s | scratchpad: String.trim(c)}}
def run("todo_append", a, s), do: todo_append(s, a["id"], a["content"], a["status"] || "pending")
def run("todo_update", a, s), do: todo_update(s, a["id"], a["content"], a["status"])
def run("todo_list", a, s), do: todo_list(s, a["include_completed"] || false)
def run(name, _a, s), do: {"Error: unknown tool '#{name}'.", s}
# --- action tool implementations -----------------------------------------
defp bash(cmd) do
{out, _code} = System.cmd("bash", ["-c", cmd], stderr_to_stdout: true)
if out == "", do: "(no output)", else: out
end
defp read_file(path, offset, limit) do
case File.read(path) do
{:ok, content} ->
content
|> String.split("\n")
|> Enum.drop(offset - 1)
|> Enum.take(limit)
|> Enum.with_index(offset)
|> Enum.map_join("\n", fn {line, n} -> "#{n}: #{line}" end)
{:error, reason} ->
"Error: cannot read #{path}: #{reason}"
end
end
defp write_file(path, content) do
File.mkdir_p!(Path.dirname(path))
File.write!(path, content)
"Wrote #{byte_size(content)} bytes to #{path}"
end
defp edit_file(path, old, new) do
case File.read(path) do
{:ok, content} ->
if String.contains?(content, old) do
File.write!(path, String.replace(content, old, new, global: false))
"Edited #{path}"
else
"Error: string not found in #{path}"
end
{:error, reason} ->
"Error: cannot read #{path}: #{reason}"
end
end
defp glob_files(pattern, path) do
(Path.wildcard(Path.join([path, "**", pattern])) ++ Path.wildcard(Path.join(path, pattern)))
|> Enum.uniq()
|> Enum.sort()
|> case do
[] -> "(no matches)"
matches -> Enum.join(matches, "\n")
end
end
defp grep(pattern, path, include) do
regex = Regex.compile!(pattern)
files =
(Path.wildcard(Path.join([path, "**", include])) ++ Path.wildcard(Path.join(path, include)))
|> Enum.uniq()
|> Enum.filter(&File.regular?/1)
results =
for f <- files,
{:ok, data} <- [File.read(f)],
{line, n} <- Enum.with_index(String.split(data, "\n"), 1),
Regex.match?(regex, line),
do: "#{f}:#{n}:#{line}"
if results == [], do: "(no matches)", else: Enum.join(results, "\n")
end
defp webfetch(url) do
case Req.get(url, headers: [{"user-agent", "agent/1.0"}], max_redirects: 5, receive_timeout: 20_000) do
{:ok, %{body: body}} ->
# Crude HTML -> text. For real use, reach for Floki; kept dep-free here.
(if is_binary(body), do: body, else: inspect(body))
|> String.replace(~r/<script.*?<\/script>/si, " ")
|> String.replace(~r/<style.*?<\/style>/si, " ")
|> String.replace(~r/<[^>]+>/, " ")
|> String.replace(~r/\n{3,}/, "\n\n")
|> String.replace(~r/[ \t]{2,}/, " ")
|> String.trim()
|> String.slice(0, 50_000)
{:error, reason} ->
"Error fetching #{url}: #{inspect(reason)}"
end
end
# --- to-do backends: a shared board (when present) or private state -------
# The pid clauses route to the shared Harness.TodoBoard process; the fallback
# clauses mutate the agent's own list. Both call Harness.Todo for the rules.
defp todo_append(%{board: board} = s, id, content, status) when is_pid(board),
do: {Harness.TodoBoard.append(board, id, content, status), s}
defp todo_append(s, id, content, status) do
case Harness.Todo.append(s.todos, id, content, status) do
{:ok, todos, msg} -> {msg, %{s | todos: todos}}
{:error, msg} -> {msg, s}
end
end
defp todo_update(%{board: board} = s, id, content, status) when is_pid(board),
do: {Harness.TodoBoard.update(board, id, content, status), s}
defp todo_update(s, id, content, status) do
case Harness.Todo.update(s.todos, id, content, status) do
{:ok, todos, msg} -> {msg, %{s | todos: todos}}
{:error, msg} -> {msg, s}
end
end
defp todo_list(%{board: board} = s, include_completed) when is_pid(board),
do: {Harness.TodoBoard.list(board, include_completed), s}
defp todo_list(s, include_completed),
do: {Harness.Todo.format(s.todos, include_completed), s}
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment