defmodule Mix.Tasks.SkillKit.Ralph do
@moduledoc """
Run a Ralph loop against a TODO file.
# Loop on an existing TODO.md in the current directory
mix skill_kit.ralph TODO.md
# Generate TODO.md from a prompt, then loop
mix skill_kit.ralph TODO.md --prompt "Add JSON parsing to lib/foo.ex with tests"
# Choose a different agent (default: ralph)
mix skill_kit.ralph TODO.md --agent some-other-ralph
# Operate in a different working directory
mix skill_kit.ralph TODO.md --cwd path/to/project
## How it works
This task is a thin driver. The Ralph contract lives in skills:
* `examples/agents/ralph/AGENT.md` — agent identity, routes user
requests to the right skill.
* `examples/agents/ralph/skills/plan/SKILL.md` — generates a TODO
file from a goal.
* `examples/agents/ralph/skills/iterate/SKILL.md` — does one
iteration: pick top unchecked item, do it, mark [x], commit.
The task sends a trigger message per turn — "Plan a TODO file at
PATH for: GOAL" or "Iterate on PATH" — and waits for the agent to
echo the skill's final word: `PLANNED`, `CONTINUE`, or `DONE`. The
loop exits on `DONE` (or on an `%Event.Error{}`). There is no
iteration cap and no per-turn timeout — pacing is handled by
`Anthropic.Client`'s 429 retry.
See `guides/ralph-loop.md` for the design.
"""
use Mix.Task
alias Mix.SkillKit.Dotenv
alias SkillKit.Event.Delta
alias SkillKit.Event.Error, as: EventError
alias SkillKit.Event.ToolCallComplete
alias SkillKit.Types.AssistantMessage
@shortdoc "Run a Ralph loop on a TODO file"
@switches [agent: :string, prompt: :string, cwd: :string]
@impl true
def run(args) do
Dotenv.load()
Mix.Task.run("app.start")
{opts, positional, _} = OptionParser.parse(args, switches: @switches)
cwd = opts[:cwd] || File.cwd!()
todo_path = positional |> todo_path_from() |> Path.expand(cwd)
agent_dir = locate_agent!(opts[:agent] || "ralph")
{:ok, agent} =
SkillKit.start_agent(agent_dir,
tools: [{SkillKit.Tools.Shell, cwd: cwd}],
caller: self()
)
maybe_plan(agent, todo_path, opts[:prompt])
ensure_todo!(todo_path, agent)
result = loop(agent, todo_path, 1)
SkillKit.stop_agent(agent)
report(result)
end
defp todo_path_from([path | _]), do: path
defp todo_path_from([]), do: "TODO.md"
defp locate_agent!(name) do
agents_dir = System.get_env("SKILL_KIT_AGENTS", "examples/agents")
dir = Path.join(agents_dir, name)
case File.dir?(dir) do
true -> dir
false -> agent_missing!(dir)
end
end
defp agent_missing!(dir) do
Mix.shell().error("Agent not found at #{dir}")
exit({:shutdown, 1})
end
defp maybe_plan(_agent, _todo_path, nil), do: :ok
defp maybe_plan(agent, todo_path, goal) do
IO.puts(IO.ANSI.format([:bright, "\n--- planning ---", :reset]))
trigger = "Plan a TODO file at #{todo_path} for this goal: #{goal}"
:ok = SkillKit.send_message(agent, trigger)
case run_turn(agent.name) do
%AssistantMessage{} -> :ok
%EventError{reason: reason} -> abort!(agent, "planning failed: #{inspect(reason)}")
end
end
defp ensure_todo!(todo_path, agent) do
case File.exists?(todo_path) do
true -> :ok
false -> abort!(agent, "TODO file not found at #{todo_path}. Use --prompt to generate one.")
end
end
defp abort!(agent, reason) do
SkillKit.stop_agent(agent)
Mix.shell().error(reason)
exit({:shutdown, 1})
end
defp loop(agent, todo_path, iter) do
IO.puts(IO.ANSI.format([:bright, :magenta, "\n--- iter #{iter} ---", :reset]))
:ok = SkillKit.send_message(agent, "Iterate on #{todo_path}.")
case run_turn(agent.name) do
%AssistantMessage{content: content} -> next_step(content, agent, todo_path, iter)
%EventError{reason: reason} -> {:error, reason}
end
end
defp run_turn(agent_name) do
agent_name
|> SkillKit.Stream.stream(timeout: :infinity)
|> Stream.each(&print/1)
|> Enum.reduce(nil, fn event, _ -> event end)
end
defp next_step(content, agent, todo_path, iter) do
case String.trim(content) do
"DONE" -> :done
_ -> loop(agent, todo_path, iter + 1)
end
end
defp print(%Delta{text: text}), do: IO.write(text)
defp print(%ToolCallComplete{name: name, input: input}) do
trace = " ↳ #{name}(#{format_input(input)})"
IO.puts(IO.ANSI.format([:faint, "\n" <> trace, :reset]))
end
defp print(_event), do: :ok
defp format_input(%{"command" => command}), do: command
defp format_input(%{"name" => name}), do: name
defp format_input(input) when map_size(input) == 0, do: ""
defp format_input(input), do: inspect(input, limit: 3)
defp report(:done) do
IO.puts(IO.ANSI.format([:green, "\nRalph converged.\n", :reset]))
end
defp report({:error, reason}) do
IO.puts(IO.ANSI.format([:red, "\nRalph failed: ", :reset, inspect(reason), "\n"]))
exit({:shutdown, 1})
end
end