Skip to content

Instantly share code, notes, and snippets.

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

  • Save josefrichter/82ec86edd4b57a146437fea79f1e4131 to your computer and use it in GitHub Desktop.

Select an option

Save josefrichter/82ec86edd4b57a146437fea79f1e4131 to your computer and use it in GitHub Desktop.
Minimal Part 3 in Elixir: a GenServer that holds the agent scratchpad + to-do list, with the planning tools calling into it; talks to local Ollama.
# planning_memory.exs — a long-task planning agent whose working memory (the
# SCRATCHPAD + TO-DO LIST) lives in its own GenServer, talking to a local Ollama.
#
# Part 3 of the "build an agent" tutorials gives the agent a scratchpad (to think)
# and a to-do list (to track multi-step work). In the Python original those are
# module-level globals. The Elixir-shaped home for that working memory is a
# GenServer: `MiniAgent.Memory` below — one process that OWNS the state. The
# planning tools are just calls into it.
#
# New to Elixir? The comments call out the language-specific bits, and mark the
# machinery you can skip with "(boilerplate)".
#
# Run: ollama serve # and: ollama pull llama3.1
# elixir planning_memory.exs
Mix.install([:req]) # pull in the Req HTTP client (used to call Ollama)
# ===========================================================================
# The agent's working memory: one GenServer holding the scratchpad + to-do list.
#
# A GenServer is just a process (a tiny, isolated server) that holds some state
# and handles messages one at a time. The functions below come in TWO halves:
#
# * the "public API" (read_scratchpad, add_todo, ...) runs in the CALLER and
# simply sends a message to the process with GenServer.call, then waits for
# the reply.
# * the "handle_call" callbacks run INSIDE the process. They receive that
# message, read or update the state, and reply. They're the ONLY code that
# touches the state — which is why there are never any race conditions.
#
# That split is the whole GenServer pattern. `mem`, passed around everywhere
# below, is the process id — a handle to one running memory.
# ===========================================================================
defmodule MiniAgent.Memory do
use GenServer # (boilerplate) brings in the GenServer machinery
# The shape of our state: a scratchpad string and a list of to-do maps. Each
# to-do is a map like %{id: "1", content: "...", status: "pending"}.
defstruct scratchpad: "(empty)", todos: []
# Start a new memory process. Returns {:ok, pid}.
def start_link(opts \\ []), do: GenServer.start_link(__MODULE__, %__MODULE__{}, opts)
@impl true
def init(state), do: {:ok, state} # (boilerplate) the process starts with this state
# --- public API: these run in the CALLER and just send a message -----------
def read_scratchpad(mem), do: GenServer.call(mem, :read_scratchpad)
def write_scratchpad(mem, text), do: GenServer.call(mem, {:write_scratchpad, text})
def add_todo(mem, id, content), do: GenServer.call(mem, {:add_todo, id, content})
def set_status(mem, id, status), do: GenServer.call(mem, {:set_status, id, status})
def list_todos(mem), do: GenServer.call(mem, :list_todos)
# --- callbacks: these run INSIDE the process and touch the state ------------
# Each one matches a message sent above and returns {:reply, value, new_state}.
# (`_from` is "who called us"; we don't need it — don't worry about it.)
@impl true
def handle_call(:read_scratchpad, _from, state) do
{:reply, state.scratchpad, state}
end
def handle_call({:write_scratchpad, text}, _from, state) do
# `%{state | scratchpad: text}` means "a COPY of state with one field changed".
# Elixir values are immutable — you never edit in place, you make a new value.
{:reply, :ok, %{state | scratchpad: text}}
end
def handle_call({:add_todo, id, content}, _from, state) do
new_todo = %{id: id, content: content, status: "pending"}
# `++` joins two lists, so this is "the existing todos plus the new one".
{:reply, :ok, %{state | todos: state.todos ++ [new_todo]}}
end
def handle_call({:set_status, id, status}, _from, state) do
{:reply, :ok, %{state | todos: update_todo_status(state.todos, id, status)}}
end
def handle_call(:list_todos, _from, state) do
{:reply, state.todos, state}
end
# Return a NEW to-do list where the matching to-do has the new status and every
# other to-do is left alone. We build a new list rather than edit one, because
# the data is immutable. (Enum.map runs the function once per item and collects
# the results into a new list.)
defp update_todo_status(todos, id, new_status) do
Enum.map(todos, fn todo ->
if todo.id == id do
%{todo | status: new_status} # the one we're changing → a copy with the new status
else
todo # everyone else → unchanged
end
end)
end
end
# ===========================================================================
# The loop + tools + the Ollama call. This part is a plain module of functions
# (it has no process of its own); it talks TO the memory process above.
# ===========================================================================
defmodule MiniAgent do
alias MiniAgent.Memory # so we can write `Memory.add_todo` instead of the full name
@model System.get_env("AGENT_MODEL") || "llama3.1"
@url "http://localhost:11434/v1/chat/completions"
# The instructions we send the model on every turn (its "personality" + how to plan).
@system """
You work on long, multi-step tasks. You have a scratchpad and a to-do list.
Plan in the scratchpad first (write_scratchpad), break the work into todos
(add_todo), mark one in_progress before doing it and done right after
(update_todo), do the work with run_bash, and check list_todos before you
finish. For a trivial question, just answer.
"""
# One round: ask the model, then either run the tools it requested and loop, or
# return its final answer. `mem` is the memory process; `messages` is the running
# conversation. Returns {answer, updated_messages}.
def run(mem, messages) do
message = call_llm(messages)
# keep the model's reply in the history (it holds the tool requests, if any)
messages = messages ++ [message]
# `case` checks the value against each pattern top to bottom and runs the match.
case message["tool_calls"] do
# The model asked to use one or more tools (a non-empty list). Run each one,
# turn each result into a "tool" message, append them all, and loop so the
# model can react to the results.
calls when is_list(calls) and calls != [] ->
tool_messages = Enum.map(calls, fn call -> handle_tool_call(mem, call) end)
run(mem, messages ++ tool_messages)
# Anything else (no tools requested) → this is the final answer.
_ ->
{message["content"] || "", messages}
end
end
# Run a single tool call and build the message that reports its result back to
# the model. Reads top to bottom: read the request, run it, wrap the result.
defp handle_tool_call(mem, call) do
name = tool_name(call)
args = tool_args(call)
log_tool_call(name, args)
result = run_tool(mem, name, args)
tool_message(call["id"], result)
end
defp tool_name(call), do: call["function"]["name"]
# The model sends a tool's arguments as a JSON string; Jason.decode! parses it
# into a map. (The trailing `!` means "raise if it's not valid JSON".)
defp tool_args(call), do: Jason.decode!(call["function"]["arguments"])
# The OpenAI/Ollama protocol expects a {"role" => "tool"} message whose
# "tool_call_id" matches the call it answers.
defp tool_message(id, result),
do: %{"role" => "tool", "tool_call_id" => id, "content" => to_string(result)}
defp log_tool_call(name, args),
do: IO.puts(IO.ANSI.faint() <> " · #{name} #{inspect(args)}" <> IO.ANSI.reset())
# --- the tools -------------------------------------------------------------
# Elixir picks a `run_tool` definition by PATTERN MATCHING the arguments: it
# tries them top to bottom and runs the first whose patterns fit. So a call
# named "write_scratchpad" lands in the first clause, "read_scratchpad" in the
# second, and so on. The planning tools are just calls into the Memory process.
defp run_tool(mem, "write_scratchpad", %{"content" => content}) do
Memory.write_scratchpad(mem, content)
"saved"
end
defp run_tool(mem, "read_scratchpad", _args), do: Memory.read_scratchpad(mem)
defp run_tool(mem, "add_todo", %{"id" => id, "content" => content}) do
Memory.add_todo(mem, id, content)
"added #{id}"
end
defp run_tool(mem, "update_todo", %{"id" => id, "status" => status}) do
Memory.set_status(mem, id, status)
"#{id} -> #{status}"
end
defp run_tool(mem, "list_todos", _args), do: inspect(Memory.list_todos(mem))
# The one non-planning tool: actually run a shell command.
defp run_tool(_mem, "run_bash", %{"command" => cmd}) do
{output, _exit_code} = System.cmd("bash", ["-c", cmd], stderr_to_stdout: true)
if String.trim(output) == "", do: "(no output)", else: output
end
# Fallback clause: matches any name the clauses above didn't.
defp run_tool(_mem, name, _args), do: "unknown tool '#{name}'"
# --- the Ollama call (OpenAI-compatible endpoint) ---------------------------
# `|>` is the pipe operator: it feeds the value on the left in as the first
# argument of the function on the right. So this reads top to bottom — build the
# request, post it, pull out the model's reply.
defp call_llm(messages) do
messages
|> request_body()
|> post_to_ollama()
|> extract_message()
end
defp request_body(messages) do
%{"model" => @model, "messages" => messages, "tools" => tools(), "temperature" => 0.7}
end
defp post_to_ollama(body) do
Req.post!(@url, json: body, receive_timeout: 120_000)
end
defp extract_message(response) do
# Pull the message out of the JSON response. This pattern says "the body is a
# map with a 'choices' list; take the first choice's 'message'".
%{"choices" => [%{"message" => message} | _]} = response.body
message
end
# --- tool schemas: describe each tool to the model -------------------------
# (boilerplate) The model needs to know what tools exist and what arguments
# they take. This is just that list, in the JSON shape the API expects.
defp tools do
[
tool("write_scratchpad", "Overwrite your private scratchpad.", %{"content" => "string"}, ["content"]),
tool("read_scratchpad", "Read your private scratchpad.", %{}, []),
tool("add_todo", "Add a task (starts pending).", %{"id" => "string", "content" => "string"}, ["id", "content"]),
tool("update_todo", "Set a task's status: pending | in_progress | done.", %{"id" => "string", "status" => "string"}, ["id", "status"]),
tool("list_todos", "List all tasks with their status.", %{}, []),
tool("run_bash", "Run a bash command and return its output.", %{"command" => "string"}, ["command"])
]
end
defp tool(name, desc, props, required) do
properties = Map.new(props, fn {key, type} -> {key, %{"type" => type}} end)
%{"type" => "function",
"function" => %{"name" => name, "description" => desc,
"parameters" => %{"type" => "object", "properties" => properties, "required" => required}}}
end
# small helpers to build the two message shapes we create ourselves
def system_message, do: %{"role" => "system", "content" => @system}
def user_message(content), do: %{"role" => "user", "content" => content}
end
# ===========================================================================
# A tiny REPL: start ONE memory process and keep talking to it. Each line you type
# is a goal; the conversation (`messages`) is threaded across turns so the agent
# remembers, and the same `mem` process keeps its scratchpad + todos the whole time.
# ===========================================================================
defmodule CLI do
def loop(mem, messages) do
case IO.gets("\nYou: ") do
:eof ->
:ok
line ->
case String.trim(line) do
cmd when cmd in ["/exit", "exit", "quit"] ->
IO.puts("bye")
"" ->
loop(mem, messages)
goal ->
{answer, messages} = MiniAgent.run(mem, messages ++ [MiniAgent.user_message(goal)])
IO.puts("\nAssistant: " <> answer)
loop(mem, messages)
end
end
end
end
{:ok, mem} = MiniAgent.Memory.start_link()
IO.puts("planning agent — give it a multi-step task, or /exit to quit.")
CLI.loop(mem, [MiniAgent.system_message()])
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment