Last active
June 11, 2026 19:31
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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