Created
July 7, 2026 17:05
-
-
Save vinniefranco/bd54fc226b6d4504837b5552a8997c9b to your computer and use it in GitHub Desktop.
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
| Mix.install([ | |
| {:claudio, "~> 0.2"}, | |
| {:owl, "~> 0.12"} | |
| ]) | |
| defmodule Baud do | |
| @moduledoc """ | |
| A coding agent in one file. | |
| The whole thing is a loop: | |
| 1. Keep a list of messages (the conversation). | |
| 2. Send all of it to the model, stream the reply. | |
| 3. Look for a line like `tool: read_file({"path": "mix.exs"})` in the reply. | |
| 4. Found one? Run the tool, stick the result on the conversation, go to 2. | |
| The model never touches the disk. It asks, we do it. | |
| 5. No tool line? The model is done. Wait for the user to type more. | |
| Unless the reply sounds unfinished; then kick it once (step 2). | |
| That's it. The rest of this file is the tools (`Baud.Tools`) and | |
| terminal eye candy (spinner, colors). | |
| """ | |
| alias Claudio.Messages.Request | |
| alias Claudio.Messages.Stream, as: SSE | |
| # Using a local model on llama.cpp | |
| @client Claudio.Client.new(%{token: ""}, System.fetch_env!("LLM_BASE_URL")) | |
| @assistant_color IO.ANSI.green() | |
| @reset_color IO.ANSI.reset() | |
| @spinner :baud_spinner | |
| defmodule Tools do | |
| @moduledoc """ | |
| The model's hands. | |
| A model can only write text, so "calling a tool" is just an agreement | |
| in the system prompt: we describe the tools, the model writes | |
| `tool: name({...})` when it wants one. | |
| This module covers all three parts: | |
| * `@tool_registry` + `system_prompt/0` tell the model what it can use. | |
| * `extract_invocations/1` finds tool lines in replies. | |
| * `dispatch/2` runs the real Elixir function and returns a map. | |
| The loop JSON-encodes it and sends it back as the tool result. | |
| Errors go back the same way (a map with `:error`), so the model | |
| can see what broke and try again. | |
| """ | |
| @tool_registry %{ | |
| "read_file" => %{ | |
| description: "Reads the contents of a file provided by the user.", | |
| signature: "read_file(path)" | |
| }, | |
| "list_files" => %{ | |
| description: "Lists the files in a directory provided by the user.", | |
| signature: "list_files(path)" | |
| }, | |
| "edit_file" => %{ | |
| description: | |
| "Replaces first occurrence of old_str with new_str in file. If old_str is empty, creates/overwrites the file with new_str.", | |
| signature: "edit_file(path, old_str, new_str)" | |
| } | |
| } | |
| @doc """ | |
| Sent with every request. This is the whole protocol: which tools exist | |
| and the one-line format for calling them. The API knows nothing about | |
| tools here. It's all just text. | |
| """ | |
| def system_prompt do | |
| """ | |
| You are Baud, an expert coding agent. You complete coding tasks in the user's project by calling tools. | |
| # Tools | |
| #{tool_list()} | |
| # Tool call format | |
| To call a tool, reply with exactly one line and nothing else: | |
| tool: TOOL_NAME({"param": "value"}) | |
| Rules: | |
| - Arguments are ONE compact, single-line JSON object. Keys are the parameter names from the signature. Never pass positional arguments. | |
| - Inside JSON strings, escape newlines as \\n and double quotes as \\". | |
| - Plain text only: no markdown, no code fences, no backticks around the tool line, no text before or after it. | |
| - Call exactly one tool per reply, then wait for the tool_result(...) message before deciding your next step. | |
| - Never say you are about to use a tool. Output the tool: line instead. | |
| Examples: | |
| tool: list_files({"path": "lib"}) | |
| tool: read_file({"path": "mix.exs"}) | |
| tool: edit_file({"path": "lib/app.ex", "old_str": "def old_name", "new_str": "def new_name"}) | |
| tool: edit_file({"path": "hello.exs", "old_str": "", "new_str": "IO.puts(\\"hi\\")\\n"}) | |
| # Workflow | |
| 1. If you are unsure what exists, call list_files first. | |
| 2. Always read_file before you edit_file an existing file. Copy old_str exactly from the file contents, including whitespace and indentation. | |
| 3. Make small, targeted edits - one edit per tool call. | |
| 4. If a tool_result contains an "error" field ("enoent" means file not found; "old_str not found" means your old_str did not match; "identical" means your edit changes nothing, so make a real change or move on), fix your arguments and retry. Never invent file contents or results. | |
| 5. When the task is complete, reply with a short plain-text summary. A final answer must not contain a tool: line. | |
| Keep working autonomously until the task is done. Only ask the user a question if you are truly blocked. | |
| """ | |
| end | |
| @doc """ | |
| Runs a tool by name. Each head matches the args that tool needs, so a | |
| call with a missing arg falls to the last clause and comes back as an | |
| error instead of running with a bogus default. | |
| """ | |
| def dispatch("read_file", %{"path" => path}), do: read_file(path) | |
| def dispatch("list_files", %{"path" => path}), do: list_files(path) | |
| def dispatch("edit_file", %{"path" => path} = args) do | |
| edit_file(path, Map.get(args, "old_str", ""), Map.get(args, "new_str", "")) | |
| end | |
| def dispatch(name, _args), | |
| do: %{error: "unknown tool or missing required arguments", tool: name} | |
| def read_file(path) do | |
| full_path = Path.expand(path) | |
| case File.read(full_path) do | |
| {:ok, content} -> %{path: full_path, content: content} | |
| {:error, reason} -> %{path: full_path, error: reason} | |
| end | |
| end | |
| def list_files(path) do | |
| full_path = Path.expand(path) | |
| case File.ls(full_path) do | |
| {:ok, entries} -> | |
| %{path: full_path, files: Enum.map(entries, &entry_info(full_path, &1))} | |
| {:error, reason} -> | |
| %{path: full_path, error: reason} | |
| end | |
| end | |
| defp entry_info(dir, name) do | |
| type = if File.dir?(Path.join(dir, name)), do: "dir", else: "file" | |
| %{filename: name, type: type} | |
| end | |
| def edit_file(path, old_str, new_str) when old_str in [nil, ""] do | |
| full_path = Path.expand(path) | |
| File.write!(full_path, new_str) | |
| %{path: full_path, action: "created_file"} | |
| end | |
| # A no-op edit means the model is confused. Break the loop. | |
| def edit_file(path, same, same) do | |
| %{path: Path.expand(path), error: "old_str and new_str are identical; nothing to change"} | |
| end | |
| def edit_file(path, old_str, new_str) do | |
| full_path = Path.expand(path) | |
| case File.read(full_path) do | |
| {:ok, original} -> | |
| if String.contains?(original, old_str) do | |
| File.write!(full_path, String.replace(original, old_str, new_str, global: false)) | |
| %{path: full_path, action: "edited"} | |
| else | |
| %{path: full_path, error: "old_str not found"} | |
| end | |
| {:error, reason} -> | |
| %{path: full_path, error: reason} | |
| end | |
| end | |
| @doc """ | |
| Pulls every `tool: name({...})` line out of a reply as `{name, args}` | |
| pairs. Args have to be one compact JSON object on the same line (the | |
| system prompt says so). Lines that don't parse get skipped, so normal | |
| prose can't trigger a tool by accident. | |
| """ | |
| def extract_invocations(text) do | |
| text | |
| |> String.split("\n") | |
| |> Enum.map(&String.trim/1) | |
| |> Enum.flat_map(&parse_invocation/1) | |
| end | |
| # Local models leak chat-template tokens around the call line and | |
| # sometimes bend the prefix, e.g. | |
| # `<|tool_call>call:read_file({...})<tool_call|>`. Strip leading | |
| # tokens, accept `tool:` or `call:`, and let decode_args ignore | |
| # trailing junk. | |
| defp parse_invocation(line) do | |
| line = String.replace(line, ~r/^(?:<\|\w+\|?>|<\w+\|>)+/, "") | |
| with [_, _prefix, name, args_part] <- Regex.run(~r/^(tool|call):\s*(\w+)\s*\((.*)$/, line), | |
| {:ok, args} <- decode_args(args_part) do | |
| [{name, args}] | |
| else | |
| _ -> [] | |
| end | |
| end | |
| # Decode exactly one JSON object starting at the first `{` and | |
| # ignore whatever trails it (the closing paren, but also leaked | |
| # template tokens or stray braces). `:json.decode/3` tolerates | |
| # trailing data where `JSON.decode/1` rejects it. | |
| defp decode_args(args_part) do | |
| case String.split(args_part, "{", parts: 2) do | |
| [_before, rest] -> | |
| {args, :ok, _trailing} = :json.decode("{" <> rest, :ok, %{null: nil}) | |
| {:ok, args} | |
| _no_object -> | |
| :error | |
| end | |
| rescue | |
| _ -> :error | |
| end | |
| defp tool_list do | |
| Enum.map_join(@tool_registry, "\n", fn {name, tool} -> | |
| """ | |
| TOOL | |
| === | |
| Name: #{name} | |
| Description: #{tool.description} | |
| Signature: #{tool.signature} | |
| =============== | |
| """ | |
| end) | |
| end | |
| end | |
| @doc """ | |
| Sends the conversation to the model, returns the reply as one string. | |
| The API takes the system prompt separately from the messages, so we set | |
| it on each request instead of keeping it in the conversation. The reply | |
| streams in as server-sent events and gets printed as it arrives. | |
| """ | |
| def call_llm(conversation) do | |
| request = | |
| conversation | |
| |> Enum.reduce(Request.new(""), fn %{role: role, content: content}, request -> | |
| Request.add_message(request, role, content) | |
| end) | |
| |> Request.set_system(Tools.system_prompt()) | |
| |> Request.set_max_tokens(16_000) | |
| |> Request.enable_streaming() | |
| if tui?(), do: Owl.Spinner.start(id: @spinner, labels: [processing: "waiting for model…"]) | |
| {:ok, response} = Claudio.Messages.create(@client, request) | |
| {text_parts, stop_reason, ui} = | |
| response.body | |
| |> SSE.parse_events() | |
| |> Enum.reduce({[], nil, {:waiting, 0}}, &handle_sse_event/2) | |
| start_streaming(ui) | |
| IO.puts("") | |
| if stop_reason == "max_tokens" do | |
| Owl.IO.puts(Owl.Data.tag("[warning: response truncated at max_tokens]", :red)) | |
| end | |
| text_parts |> Enum.reverse() |> IO.iodata_to_binary() | |
| end | |
| # Accumulator is {text_parts, stop_reason, ui}: reply text so far | |
| # (reversed), why the model stopped, and whether we're still showing | |
| # the spinner or already printing text. | |
| defp handle_sse_event( | |
| {:ok, | |
| %{ | |
| event: "content_block_delta", | |
| data: %{"delta" => %{"type" => "text_delta", "text" => text}} | |
| }}, | |
| {parts, stop, ui} | |
| ) do | |
| ui = start_streaming(ui) | |
| IO.write(text) | |
| {[text | parts], stop, ui} | |
| end | |
| defp handle_sse_event( | |
| {:ok, | |
| %{event: "content_block_delta", data: %{"delta" => %{"type" => "thinking_delta"}}}}, | |
| {parts, stop, ui} | |
| ) do | |
| {parts, stop, tick_thinking(ui)} | |
| end | |
| defp handle_sse_event( | |
| {:ok, %{event: "message_delta", data: %{"delta" => %{"stop_reason" => stop}}}}, | |
| {parts, _stop, ui} | |
| ) do | |
| {parts, stop, ui} | |
| end | |
| defp handle_sse_event(_event, acc), do: acc | |
| # Owl.LiveScreen refuses to start when stdout is not a terminal (e.g. piped), | |
| # and spinner calls would deadlock against the missing server. | |
| defp tui?, do: Process.whereis(Owl.LiveScreen) != nil | |
| defp tick_thinking({:waiting, n}) do | |
| if tui?() and rem(n, 25) == 0 do | |
| Owl.Spinner.update_label(id: @spinner, label: "thinking… ~#{n} tokens") | |
| end | |
| {:waiting, n + 1} | |
| end | |
| defp tick_thinking(:streaming), do: :streaming | |
| defp start_streaming({:waiting, n}) do | |
| if tui?() do | |
| label = if n > 0, do: "thought for ~#{n} tokens", else: "model responding" | |
| Owl.Spinner.stop(id: @spinner, resolution: :ok, label: label) | |
| Owl.LiveScreen.await_render() | |
| end | |
| IO.write("#{@assistant_color}Assistant:#{@reset_color} ") | |
| :streaming | |
| end | |
| defp start_streaming(:streaming), do: :streaming | |
| def run_loop do | |
| Owl.IO.puts(Owl.Box.new("Baud - the local coding agent", padding_x: 2)) | |
| prompt_user([]) | |
| end | |
| # Read a line, run a turn, repeat. Ctrl-D ends the session. | |
| defp prompt_user(conversation) do | |
| prompt = Owl.Data.to_chardata(Owl.Data.tag("\nYou> ", :cyan)) | |
| case IO.gets(prompt) do | |
| :eof -> | |
| :ok | |
| {:error, _reason} -> | |
| :ok | |
| input -> | |
| case String.trim(input) do | |
| "" -> | |
| prompt_user(conversation) | |
| user_input -> | |
| conversation = | |
| run_assistant_turn(conversation ++ [%{role: :user, content: user_input}]) | |
| prompt_user(conversation) | |
| end | |
| end | |
| end | |
| @nudge "You stopped without calling a tool. Never announce a tool call; output the tool: line itself, or reply with your final summary if the task is done." | |
| # One "turn" can be several round trips: run the requested tools, append | |
| # the results, call the model again. Results use the :user role because | |
| # the messages API only has user and assistant. The turn ends when a | |
| # reply has no tool line, unless the reply sounds unfinished; sometimes | |
| # you need to kick llms. One kick per stall, so a done model can't get | |
| # stuck in a nudge loop. | |
| defp run_assistant_turn(conversation, nudged? \\ false) do | |
| assistant_response = call_llm(conversation) | |
| conversation = conversation ++ [%{role: :assistant, content: assistant_response}] | |
| case Tools.extract_invocations(assistant_response) do | |
| [] -> | |
| if nudged? or not unfinished?(assistant_response) do | |
| conversation | |
| else | |
| Owl.IO.puts(Owl.Data.tag("⚡ no tool call but sounds unfinished, kicking", :yellow)) | |
| run_assistant_turn(conversation ++ [%{role: :user, content: @nudge}], true) | |
| end | |
| invocations -> | |
| tool_results = | |
| Enum.map(invocations, fn {name, args} -> | |
| Owl.IO.puts([Owl.Data.tag("⚙ #{name} ", :yellow), inspect(args)]) | |
| result = Tools.dispatch(name, args) | |
| %{role: :user, content: "tool_result(#{JSON.encode!(result)})"} | |
| end) | |
| run_assistant_turn(conversation ++ tool_results) | |
| end | |
| end | |
| # Cheap heuristic: a final summary states what happened, a stalled model | |
| # narrates what it is about to do ("Let's create the smoke test."). Only | |
| # the last line counts. "let me know" is the classic sign-off, not intent. | |
| @doc false | |
| def unfinished?(reply) do | |
| case reply |> String.split("\n", trim: true) |> List.last() do | |
| nil -> false | |
| last -> Regex.match?(~r/\b(?:let's|let me(?! know)|i'll|i will|now i|next,? i)\b/i, last) | |
| end | |
| end | |
| end | |
| Baud.run_loop() |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
I used llama.cpp