Skip to content

Instantly share code, notes, and snippets.

@vinniefranco
Created July 7, 2026 17:05
Show Gist options
  • Select an option

  • Save vinniefranco/bd54fc226b6d4504837b5552a8997c9b to your computer and use it in GitHub Desktop.

Select an option

Save vinniefranco/bd54fc226b6d4504837b5552a8997c9b to your computer and use it in GitHub Desktop.
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()
@vinniefranco

vinniefranco commented Jul 7, 2026

Copy link
Copy Markdown
Author

I used llama.cpp

llama serve \
  -hc unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q3_K_M \
  -c 131072 \
  --cache-type-k q8_0 \
  --cache-type-v q8_0 \
  -fa on \
  -ngl 999 \
  --temp 1 \
  --top-k 64 \
  --top-p 0.95 \
  --host 0.0.0.0 \
  --port 8080

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment