Skip to main content

lib/mix/tasks/skill_kit.ralph.ex

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