Skip to content

Instantly share code, notes, and snippets.

@mikehostetler
Created February 18, 2026 12:30
Show Gist options
  • Select an option

  • Save mikehostetler/862f4d4cd1ffa2016700362ede2ee077 to your computer and use it in GitHub Desktop.

Select an option

Save mikehostetler/862f4d4cd1ffa2016700362ede2ee077 to your computer and use it in GitHub Desktop.
Jido Issue Bot V2 — Runic Spine + ReAct Brain

Jido Issue Bot V2 — Runic Spine + ReAct Brain

Philosophy

V1 had two extremes: the fully-structured Runic DAG (deterministic but brittle) and the pure ReAct agent (adaptive but unreliable on lifecycle). V2 takes the best of both:

Runic owns the lifecycle. ReAct owns the investigation.

The outer Runic workflow guarantees that Sprites get created, repos get cloned, results get pushed to GitHub, and Sprites get destroyed — deterministically, with per-step observability and retry semantics. The inner ReAct agent gets delegated the creative, adaptive work: directing Claude Code to actually fix the bug, iterating on test failures, and composing the investigation report.

mix jido.triage https://github.com/agentjido/jido/issues/42

┌─────────────────────── Runic Workflow (deterministic spine) ──────────────────────┐
│                                                                                   │
│  ProvisionSprite ──→ FetchIssue ──→ CloneRepo ──→ SetupEnv                       │
│                                                     │                             │
│                                            ┌────────▼─────────┐                  │
│                                            │  Investigate      │                  │
│                                            │  (child: :fixer)  │  ← delegated    │
│                                            │                   │    to ReAct      │
│                                            │  ReAct Agent w/   │    child agent   │
│                                            │  Skills + Tools   │                  │
│                                            └────────┬─────────┘                  │
│                                                     │                             │
│                                   PushResults ──→ Teardown                        │
│                                                                                   │
└───────────────────────────────────────────────────────────────────────────────────┘

1. The Split

Concern Owner Why
Create Sprite Runic node Deterministic. Retry on Fly.io transient failure.
Fetch issue metadata Runic node One gh command. No reasoning needed.
Clone repo Runic node One git command. Retry once on network error.
Setup environment Runic node Run mix deps.get, etc. Configurable.
Investigate + fix the bug ReAct child agent Adaptive. LLM decides what to look at, what to run, how to iterate with Claude Code.
Push results to GitHub Runic node One gh command. Deterministic. Retry twice.
Destroy Sprite Runic node Guaranteed cleanup. Not up to the LLM.

The key insight: the investigation step is the only part that benefits from LLM reasoning. Everything else is just "do this, then that." V1's pure ReAct approach risked the LLM forgetting to clean up, skipping the GitHub post, or looping on provisioning. V1's pure Runic approach couldn't adapt when Claude's output needed follow-up questions or the project setup was non-obvious.

V2 puts each concern where it belongs.


2. The Orchestrator — Runic Workflow Agent

The orchestrator is a Jido.Agent with Jido.Runic.Strategy. It runs a linear DAG where most nodes execute locally (shell commands in the Sprite), but the Investigate node is delegated to a ReAct child agent via executor: {:child, :fixer}.

defmodule Jido.IssueTriage.Orchestrator do
  @moduledoc """
  Runic workflow orchestrator for issue triage.

  Drives a deterministic pipeline: provision → fetch → clone → setup → investigate → push → teardown.
  The investigate step is delegated to a ReAct child agent that uses Skills and
  Claude Code to actually fix the bug.
  """

  use Jido.Agent,
    name: "issue_triage_orchestrator",
    strategy:
      {Jido.Runic.Strategy,
       workflow_fn: &__MODULE__.build_workflow/0,
       child_modules: %{
         fixer: Jido.IssueTriage.FixerAgent
       }},
    schema: []

  alias Jido.Runic.ActionNode
  alias Runic.Workflow

  alias Jido.IssueTriage.Actions.{
    ProvisionSprite,
    FetchIssue,
    CloneRepo,
    SetupEnv,
    Investigate,
    PushResults,
    Teardown
  }

  def build_workflow do
    # The investigate node delegates to the ReAct child agent
    investigate = ActionNode.new(Investigate, %{}, name: :investigate, executor: {:child, :fixer})

    Workflow.new(name: :issue_triage)
    |> Workflow.add(ProvisionSprite)
    |> Workflow.add(FetchIssue, to: :provision_sprite)
    |> Workflow.add(CloneRepo, to: :fetch_issue)
    |> Workflow.add(SetupEnv, to: :clone_repo)
    |> Workflow.add(investigate, to: :setup_env)
    |> Workflow.add(PushResults, to: :investigate)
    |> Workflow.add(Teardown, to: :push_results)
  end

  @doc "Run the full triage pipeline for a request."
  def run(%Jido.IssueTriage.TriageRequest{} = request, opts \\ []) do
    jido = Keyword.fetch!(opts, :jido)
    timeout = Keyword.get(opts, :timeout, 600_000)

    {:ok, pid} = Jido.AgentServer.start_link(agent: __MODULE__, jido: jido)

    feed_signal =
      Jido.Signal.new!(
        "runic.feed",
        %{data: Map.from_struct(request)},
        source: "/triage/orchestrator"
      )

    Jido.AgentServer.cast(pid, feed_signal)

    case Jido.AgentServer.await_completion(pid, timeout: timeout) do
      {:ok, %{status: :completed}} ->
        {:ok, server_state} = Jido.AgentServer.state(pid)
        strat = Jido.Agent.Strategy.State.get(server_state.agent)
        productions = Runic.Workflow.raw_productions(strat.workflow)
        {:ok, List.last(productions)}

      {:ok, %{status: :failed}} ->
        {:error, :pipeline_failed}

      {:error, reason} ->
        {:error, reason}
    end
  end
end

3. Runic Nodes — Deterministic Actions

Each node is a Jido.Action that runs a shell command in the Sprite via the owner session. They're intentionally simple — no LLM, no adaptation. If they fail, Runic retries them or aborts the pipeline with a clear error.

All nodes share a SpriteCommands helper for executing commands in the Sprite session.

Shared Helper

defmodule Jido.IssueTriage.SpriteCommands do
  @moduledoc "Execute shell commands in a Sprite via the owner session."

  def exec(session_id, command, opts \\ []) do
    timeout = Keyword.get(opts, :timeout, 60_000)

    case Jido.Shell.Agent.run(session_id, command, timeout: timeout) do
      {:ok, output} -> {:ok, String.trim(output)}
      {:error, reason} -> {:error, reason}
    end
  end

  def exec!(session_id, command, opts \\ []) do
    case exec(session_id, command, opts) do
      {:ok, output} -> output
      {:error, reason} -> raise "Command failed: #{inspect(reason)}"
    end
  end
end

Node 1: ProvisionSprite

Creates the Sprite and establishes the owner session. All subsequent nodes use the session_id it returns.

defmodule Jido.IssueTriage.Actions.ProvisionSprite do
  use Jido.Action,
    name: "provision_sprite",
    description: "Create an ephemeral Sprite VM and start the owner session",
    schema: [
      run_id: [type: :string, required: true],
      sprite: [type: :map, required: true],
      github: [type: :map, required: true],
      claude: [type: :map, required: true]
    ]

  @impl true
  def run(params, _context) do
    sprite_name = "triage-#{params.run_id}"

    env = %{
      "GH_PROMPT_DISABLED" => "1",
      "GH_TOKEN" => params.github.token,
      "ANTHROPIC_API_KEY" => params.claude.api_key
    }

    session_opts = [
      backend: {Jido.Shell.Backend.Sprite, %{
        sprite_name: sprite_name,
        token: params.sprite.token,
        create: true
      }},
      cwd: "/",
      env: env
    ]

    case Jido.Shell.ShellSession.start_with_vfs("triage-#{params.run_id}", session_opts) do
      {:ok, session_id} ->
        {:ok, %{
          session_id: session_id,
          sprite_name: sprite_name,
          owner: params[:owner],
          repo: params[:repo],
          issue_number: params[:issue_number],
          run_id: params.run_id,
          github: params.github,
          claude: params.claude,
          setup: params[:setup]
        }}

      {:error, reason} ->
        {:error, {:sprite_provision_failed, reason}}
    end
  end
end

Node 2: FetchIssue

defmodule Jido.IssueTriage.Actions.FetchIssue do
  use Jido.Action,
    name: "fetch_issue",
    description: "Fetch issue details via gh CLI",
    schema: [
      owner: [type: :string, required: true],
      repo: [type: :string, required: true],
      issue_number: [type: :integer, required: true],
      session_id: [type: :string, required: true]
    ]

  alias Jido.IssueTriage.SpriteCommands

  @impl true
  def run(params, _context) do
    cmd = "gh issue view #{params.issue_number} " <>
          "--repo #{params.owner}/#{params.repo} " <>
          "--json title,body,labels,author,state"

    with {:ok, stdout} <- SpriteCommands.exec(params.session_id, cmd) do
      issue = Jason.decode!(stdout)

      {:ok, Map.merge(params_to_pass_through(params), %{
        issue_title: issue["title"],
        issue_body: issue["body"] || "",
        issue_labels: Enum.map(issue["labels"] || [], & &1["name"]),
        issue_author: get_in(issue, ["author", "login"])
      })}
    end
  end

  defp params_to_pass_through(params) do
    Map.take(params, [:session_id, :sprite_name, :owner, :repo, :issue_number,
                       :run_id, :github, :claude, :setup])
  end
end

Node 3: CloneRepo

defmodule Jido.IssueTriage.Actions.CloneRepo do
  use Jido.Action,
    name: "clone_repo",
    description: "Clone the repository into the Sprite",
    schema: [
      owner: [type: :string, required: true],
      repo: [type: :string, required: true],
      session_id: [type: :string, required: true]
    ]

  alias Jido.IssueTriage.SpriteCommands

  @impl true
  def run(params, _context) do
    url = "https://github.com/#{params.owner}/#{params.repo}.git"
    cmd = "git clone --depth 1 #{url} /repo"

    case SpriteCommands.exec(params.session_id, cmd, timeout: 120_000) do
      {:ok, _} ->
        {:ok, Map.put(params, :repo_dir, "/repo")}

      {:error, reason} ->
        {:error, {:clone_failed, reason}}
    end
  end
end

Node 4: SetupEnv

defmodule Jido.IssueTriage.Actions.SetupEnv do
  use Jido.Action,
    name: "setup_env",
    description: "Run setup commands (deps, compile, etc.) in the Sprite",
    schema: [
      session_id: [type: :string, required: true],
      repo_dir: [type: :string, required: true],
      setup: [type: :map, default: %{}]
    ]

  alias Jido.IssueTriage.SpriteCommands

  @impl true
  def run(params, _context) do
    commands = get_in(params, [:setup, :commands]) || []

    Enum.each(commands, fn cmd ->
      SpriteCommands.exec!(params.session_id, "cd #{params.repo_dir} && #{cmd}", timeout: 180_000)
    end)

    {:ok, params}
  end
end

4. The Investigate Node — Bridge to ReAct

The Investigate action is the bridge between the Runic spine and the ReAct brain. It packages the context (issue details, session handle, repo path) into a query that the FixerAgent can work with.

When this node runs as a {:child, :fixer} delegated node, Runic's strategy:

  1. Emits a SpawnAgent directive for the :fixer tag
  2. The runtime starts a FixerAgent child process
  3. On jido.agent.child.started, the runnable is sent to the child
  4. The child executes the Investigate action within its ReAct loop
  5. The child emits its result back to the parent orchestrator
  6. The orchestrator applies the result and advances to PushResults
defmodule Jido.IssueTriage.Actions.Investigate do
  @moduledoc """
  Bridge action between Runic workflow and ReAct child agent.

  When executed by the FixerAgent (a ReAct child), this action's params
  become the initial context for the ReAct loop. The FixerAgent uses its
  tools (sprite_exec, claude_run) and skills to investigate and fix the bug.

  When executed via Runic child delegation, the FixerAgent receives a
  Runnable containing these params and runs its ReAct loop to completion.
  """

  use Jido.Action,
    name: "investigate",
    description: "Investigate and fix a bug using Claude Code in the Sprite",
    schema: [
      session_id: [type: :string, required: true],
      sprite_name: [type: :string, required: true],
      owner: [type: :string, required: true],
      repo: [type: :string, required: true],
      issue_number: [type: :integer, required: true],
      issue_title: [type: :string, required: true],
      issue_body: [type: :string, required: true],
      issue_labels: [type: {:list, :string}, default: []],
      repo_dir: [type: :string, default: "/repo"],
      claude: [type: :map, required: true]
    ]

  @impl true
  def run(params, _context) do
    # This is called by the FixerAgent's ReAct execution.
    # The FixerAgent wraps this in its own ReAct loop with tools + skills.
    # See FixerAgent for how this is orchestrated.

    {:ok, %{
      investigation: params[:investigation] || "Investigation delegated to FixerAgent",
      branch_name: params[:branch_name],
      pr_url: params[:pr_url],
      session_id: params.session_id,
      sprite_name: params.sprite_name,
      owner: params.owner,
      repo: params.repo,
      issue_number: params.issue_number,
      issue_title: params.issue_title,
      run_id: params[:run_id],
      github: params[:github]
    }}
  end
end

5. The Fixer Agent — ReAct Child

This is where the LLM magic happens. The FixerAgent is a Jido.AI.Agent that receives work from the parent orchestrator and runs a ReAct loop with tools and skills to investigate and fix the bug.

It does not manage the Sprite lifecycle — that's the orchestrator's job. It only gets a session_id and uses sprite_exec and claude_run to work within the already-provisioned environment.

defmodule Jido.IssueTriage.FixerAgent do
  @moduledoc """
  ReAct child agent that investigates and fixes bugs inside a Sprite.

  Receives a Runnable from the parent Runic orchestrator containing:
  - session_id (Sprite is already running)
  - issue metadata (title, body, labels)
  - repo_dir (repo is already cloned)
  - claude config (model, max_turns)

  Uses tools to run shell commands and Claude Code inside the Sprite.
  Uses skills to know how to investigate issues and use Claude effectively.

  Does NOT create or destroy Sprites. Does NOT push to GitHub.
  Those are the orchestrator's responsibility.
  """

  use Jido.AI.Agent,
    name: "issue_fixer",
    description: "Investigates and fixes bugs using Claude Code in a pre-provisioned Sprite",
    tools: [
      Jido.IssueTriage.Tools.SpriteExec,
      Jido.IssueTriage.Tools.ClaudeRun
    ],
    system_prompt: """
    You are a bug fixer. You've been given access to a development environment
    (Sprite) with a cloned repository and a GitHub issue to investigate.

    Your job:
    1. Understand the issue from the provided context
    2. Use Claude Code to investigate the codebase and identify the root cause
    3. Have Claude Code write a fix (code changes + tests)
    4. Verify the fix by running tests
    5. Create a branch and commit the changes
    6. Return a summary of what you found and what you fixed

    You have Skills that explain your workflow in detail. Follow them.

    IMPORTANT: You do NOT manage the Sprite lifecycle. It's already running.
    You do NOT push to GitHub. The orchestrator handles that.
    Focus only on investigation, fixing, and committing locally.
    """,
    max_iterations: 25,
    tool_timeout_ms: 300_000

  @skills [
    Jido.IssueTriage.Skills.BugInvestigation,
    Jido.IssueTriage.Skills.ClaudeCodeWorkflow,
    Jido.IssueTriage.Skills.GitWorkflow
  ]

  @impl true
  def on_before_cmd(agent, {:ai_react_start, %{query: query} = params} = _action) do
    skill_prompt = Jido.AI.Skill.Prompt.render(@skills)
    existing_context = Map.get(params, :tool_context, %{})

    # Pass session_id through tool_context so tools can access it
    tool_context = Map.merge(existing_context, %{
      session_id: agent.state[:session_id],
      sprite_name: agent.state[:sprite_name],
      repo_dir: agent.state[:repo_dir] || "/repo"
    })

    updated_params = Map.put(params, :tool_context, tool_context)

    agent = %{agent | state: agent.state
      |> Map.put(:last_query, query)
      |> Map.put(:completed, false)
      |> Map.put(:last_answer, "")
    }

    {:ok, agent, {:ai_react_start, updated_params}}
  end

  @impl true
  def on_before_cmd(agent, action), do: {:ok, agent, action}

  @doc """
  Handle incoming work from the parent orchestrator.

  When the Runic strategy delegates the Investigate node to this agent,
  we receive the runnable's params and kick off a ReAct loop.
  """
  def signal_routes(_ctx) do
    [
      {"runic.child.execute", __MODULE__.HandleWork}
    ]
  end
end

defmodule Jido.IssueTriage.FixerAgent.HandleWork do
  @moduledoc """
  Receives a delegated Runnable from the parent orchestrator,
  extracts investigation context, runs the ReAct loop, and
  returns results to the parent.
  """

  use Jido.Action,
    name: "handle_fixer_work",
    schema: [
      runnable: [type: :any, required: true],
      runnable_id: [type: :any, required: true],
      tag: [type: :any, required: true]
    ]

  alias Jido.Agent.Directive
  alias Jido.Runic.RunnableExecution

  @impl true
  def run(%{runnable: runnable, runnable_id: runnable_id}, context) do
    # Extract the investigation params from the runnable's input fact
    params = runnable.input_fact.value

    # Build the query for the ReAct loop
    query = """
    Investigate and fix this GitHub issue:

    **#{params.issue_title}** (##{params.issue_number})

    #{params.issue_body}

    Labels: #{Enum.join(params[:issue_labels] || [], ", ")}

    The repo is cloned at #{params[:repo_dir] || "/repo"}.
    Use session_id "#{params.session_id}" for all sprite_exec commands.
    Use sprite_name "#{params.sprite_name}" for claude_run commands.

    Create a branch named "fix/issue-#{params.issue_number}" for your changes.
    Commit your fix with a descriptive message referencing the issue.
    """

    # Run the ReAct loop via the AI agent machinery
    # The FixerAgent's ask_sync handles the full ReAct cycle
    case run_react_investigation(query, params, context) do
      {:ok, result} ->
        # Complete the runnable with investigation results
        result_value = %{
          investigation: result.answer,
          branch_name: result[:branch_name] || "fix/issue-#{params.issue_number}",
          pr_url: result[:pr_url],
          session_id: params.session_id,
          sprite_name: params.sprite_name,
          owner: params.owner,
          repo: params.repo,
          issue_number: params.issue_number,
          issue_title: params.issue_title,
          run_id: params[:run_id],
          github: params[:github]
        }

        executed = RunnableExecution.complete_with_value(runnable, result_value)
        result_signal = RunnableExecution.completion_signal(executed, source: "/triage/fixer")
        emit_directive = Directive.emit_to_parent(%Jido.Agent{state: context.state}, result_signal)

        {:ok, %{}, List.wrap(emit_directive)}

      {:error, reason} ->
        executed = RunnableExecution.fail(runnable, reason)
        result_signal = RunnableExecution.completion_signal(executed, source: "/triage/fixer")
        emit_directive = Directive.emit_to_parent(%Jido.Agent{state: context.state}, result_signal)

        {:ok, %{}, List.wrap(emit_directive)}
    end
  end

  defp run_react_investigation(query, params, context) do
    # The ReAct loop runs within this process via the AI agent's ask mechanism.
    # Tools have access to session_id and sprite_name through tool_context.
    Jido.IssueTriage.FixerAgent.ask_sync(self(), query, timeout: 300_000)
  end
end

6. Fixer Tools — Two Primitives

The FixerAgent has only two tools. It doesn't need sprite_create or sprite_destroy — the orchestrator handles those. It works within an already-running Sprite.

Tool 1: sprite_exec

Same as V1, but simpler — the session_id comes from tool_context, not from the LLM's memory.

defmodule Jido.IssueTriage.Tools.SpriteExec do
  @moduledoc "Execute a shell command inside the active Sprite session."

  use Jido.Action,
    name: "sprite_exec",
    description: """
    Runs a shell command inside the Sprite and returns the output.
    Use this for file inspection, running tests, git operations, etc.
    The session_id is pre-configured — you don't need to provide it.
    """,
    schema: [
      command: [type: :string, required: true,
                doc: "The shell command to execute (e.g. 'cd /repo && mix test')"],
      timeout: [type: :integer, default: 60_000,
                doc: "Timeout in milliseconds (default: 60000)"]
    ]

  alias Jido.IssueTriage.SpriteCommands

  @impl true
  def run(params, context) do
    session_id = context[:session_id] || raise "No session_id in tool context"

    case SpriteCommands.exec(session_id, params.command, timeout: params.timeout) do
      {:ok, output} ->
        {:ok, %{output: truncate(output, 8_000), exit: "success"}}

      {:error, error} ->
        {:ok, %{output: inspect(error), exit: "error"}}
    end
  end

  defp truncate(text, max) when byte_size(text) <= max, do: text
  defp truncate(text, max) do
    head = binary_part(text, 0, div(max, 2))
    tail = binary_part(text, byte_size(text) - div(max, 2), div(max, 2))
    head <> "\n\n... [truncated #{byte_size(text) - max} bytes] ...\n\n" <> tail
  end
end

Tool 2: claude_run

Runs Claude Code inside the Sprite. The sprite_name comes from tool_context.

defmodule Jido.IssueTriage.Tools.ClaudeRun do
  @moduledoc "Run Claude Code inside the Sprite to investigate and fix code."

  use Jido.Action,
    name: "claude_run",
    description: """
    Runs Claude Code (the CLI agent) inside the Sprite.
    Claude will read files, run commands, and reason about the codebase.
    Returns Claude's final output text.

    The Sprite already has the repo cloned and dependencies installed.
    Claude's API key is pre-configured.

    Use this for deep investigation, writing fixes, and running tests.
    This is a long-running operation — it may take several minutes.
    """,
    schema: [
      prompt: [type: :string, required: true,
               doc: "What to ask Claude Code to do"],
      max_turns: [type: :integer, default: 25,
                  doc: "Maximum conversation turns for Claude"],
      output_file: [type: :string, default: "/tmp/claude_output.md",
                    doc: "File path where Claude should write its report"]
    ]

  alias Jido.IssueTriage.SpriteCommands

  @impl true
  def run(params, context) do
    session_id = context[:session_id] || raise "No session_id in tool context"
    repo_dir = context[:repo_dir] || "/repo"

    # Build the Claude CLI command
    prompt = String.replace(params.prompt, "'", "'\\''")

    cmd = "cd #{repo_dir} && claude -p '#{prompt}' " <>
          "--max-turns #{params.max_turns} " <>
          "--output-format text " <>
          "2>&1 | tee #{params.output_file}"

    case SpriteCommands.exec(session_id, cmd, timeout: 600_000) do
      {:ok, output} ->
        {:ok, %{result: truncate(output, 12_000)}}

      {:error, reason} ->
        # Try to read partial output
        case SpriteCommands.exec(session_id, "cat #{params.output_file} 2>/dev/null") do
          {:ok, partial} when partial != "" ->
            {:ok, %{result: truncate(partial, 12_000), partial: true}}
          _ ->
            {:error, "Claude Code failed: #{inspect(reason)}"}
        end
    end
  end

  defp truncate(text, max) when byte_size(text) <= max, do: text
  defp truncate(text, max) do
    head = binary_part(text, 0, div(max, 2))
    tail = binary_part(text, byte_size(text) - div(max, 2), div(max, 2))
    head <> "\n\n... [truncated #{byte_size(text) - max} bytes] ...\n\n" <> tail
  end
end

7. Skills — What the Fixer Agent Knows

Skills are pure text instructions that get rendered into the system prompt. They tell the LLM how to approach the investigation, not what to do (the LLM figures that out).

Skill 1: Bug Investigation

defmodule Jido.IssueTriage.Skills.BugInvestigation do
  use Jido.AI.Skill,
    name: "bug_investigation",
    description: "How to investigate and fix bugs in a codebase"

  @impl true
  def body do
    """
    ## Bug Investigation Workflow

    1. **Understand the issue**: Read the issue title and body carefully.
       Identify what's failing, what the expected behavior is, and any
       reproduction steps.

    2. **Locate the code**: Use `sprite_exec` to find relevant files:
       - `grep -rn "function_name" /repo/lib/`
       - `find /repo -name "*.ex" | head -20` to understand project structure
       - `cat /repo/mix.exs` to understand dependencies

    3. **Run existing tests**: `sprite_exec(command: "cd /repo && mix test")`
       to see what's already passing/failing.

    4. **Investigate with Claude Code**: Use `claude_run` with a focused prompt:
       - Tell Claude about the specific issue
       - Ask it to read the relevant files
       - Ask it to write a fix AND a test for the fix
       - Ask it to run the tests to verify

    5. **Verify the fix**: After Claude makes changes, run `mix test` again
       to confirm the fix works and doesn't break other tests.

    6. **Commit the changes**: Use `sprite_exec` to:
       - `git checkout -b fix/issue-{number}`
       - `git add -A`
       - `git commit -m "fix: {description} (closes #{number})"`

    If Claude's first attempt doesn't work, iterate! Run Claude again with
    the test output to let it refine the fix.
    """
  end
end

Skill 2: Claude Code Workflow

defmodule Jido.IssueTriage.Skills.ClaudeCodeWorkflow do
  use Jido.AI.Skill,
    name: "claude_code_workflow",
    description: "How to use Claude Code effectively inside the Sprite"

  @impl true
  def body do
    """
    ## Using Claude Code

    Claude Code is a CLI agent that can read files, write code, and run
    commands. It's running inside the same Sprite as you.

    ### Good prompts for claude_run:
    - "Read lib/foo/bar.ex and explain how the `process/2` function works"
    - "The function `Widget.call/1` crashes on nil input. Find where it's
       defined, write a fix that handles nil gracefully, and add a test."
    - "Run `mix test test/foo_test.exs` and fix any failures"

    ### Tips:
    - Be specific about file paths when you know them
    - Include the error message or stack trace from the issue
    - Ask Claude to write tests alongside fixes
    - If Claude's output is truncated, use sprite_exec to read the output file:
      `cat /tmp/claude_output.md`

    ### Iterating:
    - If the first fix doesn't pass tests, call claude_run again with the
      test failure output
    - You can call claude_run multiple times with different prompts
    - Each call is independent — Claude doesn't remember previous calls
    """
  end
end

Skill 3: Git Workflow

defmodule Jido.IssueTriage.Skills.GitWorkflow do
  use Jido.AI.Skill,
    name: "git_workflow",
    description: "How to manage git branches and commits in the Sprite"

  @impl true
  def body do
    """
    ## Git Workflow

    After fixing the bug, you need to commit the changes on a branch.
    The orchestrator will push the branch and create a PR later.

    ### Steps:
    1. Create a branch: `git checkout -b fix/issue-{number}`
    2. Stage changes: `git add -A`
    3. Check what changed: `git diff --cached --stat`
    4. Commit: `git commit -m "fix: {description}

       Closes #{number}"`

    ### Rules:
    - Branch name format: `fix/issue-{number}`
    - Use conventional commit messages: `fix:`, `feat:`, `docs:`, etc.
    - Reference the issue number in the commit message
    - Do NOT push — the orchestrator handles that
    """
  end
end

8. Post-Investigation Nodes — Back to Runic

After the FixerAgent finishes, control returns to the Runic workflow. The remaining nodes are deterministic.

Node 6: PushResults

defmodule Jido.IssueTriage.Actions.PushResults do
  @moduledoc """
  Push the fix branch and post results to GitHub.

  Creates a PR if there's a branch with commits, or posts an investigation
  comment if no fix was produced.
  """

  use Jido.Action,
    name: "push_results",
    description: "Push fix branch and/or post investigation results to GitHub",
    schema: [
      session_id: [type: :string, required: true],
      owner: [type: :string, required: true],
      repo: [type: :string, required: true],
      issue_number: [type: :integer, required: true],
      issue_title: [type: :string, required: true],
      run_id: [type: :string, required: true],
      investigation: [type: :string, required: true],
      branch_name: [type: :string, default: nil],
      github: [type: :map, required: true]
    ]

  alias Jido.IssueTriage.SpriteCommands

  @impl true
  def run(params, _context) do
    result = %{
      session_id: params.session_id,
      sprite_name: params[:sprite_name],
      run_id: params.run_id,
      owner: params.owner,
      repo: params.repo,
      issue_number: params.issue_number
    }

    # Check if there's a fix branch with commits
    branch = params[:branch_name] || "fix/issue-#{params.issue_number}"

    has_branch? =
      case SpriteCommands.exec(params.session_id, "cd /repo && git branch --list #{branch}") do
        {:ok, output} -> String.trim(output) != ""
        _ -> false
      end

    if has_branch? do
      # Push the branch and create a PR
      push_cmd = "cd /repo && git push origin #{branch}"
      SpriteCommands.exec!(params.session_id, push_cmd, timeout: 120_000)

      pr_body = """
      ## 🤖 Automated Fix

      #{params.investigation}

      ---
      <!-- jido-triage:#{params.run_id} -->
      """

      # Write PR body to file to avoid shell escaping issues
      SpriteCommands.exec!(params.session_id,
        "cat > /tmp/pr_body.md << 'JIDO_EOF'\n#{pr_body}\nJIDO_EOF")

      pr_cmd = "cd /repo && gh pr create " <>
               "--title 'fix: #{params.issue_title}' " <>
               "--body-file /tmp/pr_body.md " <>
               "--head #{branch} " <>
               "--repo #{params.owner}/#{params.repo}"

      case SpriteCommands.exec(params.session_id, pr_cmd) do
        {:ok, pr_url} ->
          {:ok, Map.put(result, :pr_url, String.trim(pr_url))}

        {:error, _} ->
          # PR creation failed — fall through to comment
          post_comment(params, result)
      end
    else
      # No fix branch — post investigation as a comment
      post_comment(params, result)
    end
  end

  defp post_comment(params, result) do
    comment = """
    ## 🔍 Automated Investigation

    #{params.investigation}

    ---
    <!-- jido-triage:#{params.run_id} -->
    """

    SpriteCommands.exec!(params.session_id,
      "cat > /tmp/comment.md << 'JIDO_EOF'\n#{comment}\nJIDO_EOF")

    cmd = "gh issue comment #{params.issue_number} " <>
          "--repo #{params.owner}/#{params.repo} " <>
          "--body-file /tmp/comment.md"

    case SpriteCommands.exec(params.session_id, cmd) do
      {:ok, url} -> {:ok, Map.put(result, :comment_url, String.trim(url))}
      {:error, reason} -> {:error, {:comment_failed, reason}}
    end
  end
end

Node 7: Teardown

defmodule Jido.IssueTriage.Actions.Teardown do
  @moduledoc """
  Destroy the Sprite or return a session handle for reuse.

  If `keep_sprite` is true in the original request, returns the session_id
  instead of destroying the Sprite. This allows chaining multiple triage
  runs on the same environment.
  """

  use Jido.Action,
    name: "teardown",
    description: "Clean up the Sprite environment",
    schema: [
      session_id: [type: :string, required: true],
      sprite_name: [type: :string, default: nil],
      run_id: [type: :string, required: true],
      keep_sprite: [type: :boolean, default: false],
      pr_url: [type: :string, default: nil],
      comment_url: [type: :string, default: nil]
    ]

  @impl true
  def run(params, _context) do
    result = %{
      run_id: params.run_id,
      pr_url: params[:pr_url],
      comment_url: params[:comment_url],
      status: :completed
    }

    if params[:keep_sprite] do
      {:ok, Map.merge(result, %{
        session_id: params.session_id,
        sprite_name: params[:sprite_name],
        message: "Sprite preserved for reuse"
      })}
    else
      case Jido.Shell.ShellSession.stop(params.session_id) do
        :ok ->
          {:ok, Map.put(result, :message, "Sprite destroyed")}

        {:error, reason} ->
          # Best-effort cleanup — don't fail the pipeline
          {:ok, Map.put(result, :message, "Cleanup attempted: #{inspect(reason)}")}
      end
    end
  end
end

9. What Actually Happens at Runtime

GH_TOKEN=ghp_xxx FLY_API_TOKEN=fly_xxx ANTHROPIC_API_KEY=sk-ant-xxx \
  mix jido.triage https://github.com/agentjido/jido/issues/42
🔍 Triaging agentjido/jido#42

[Runic] ProvisionSprite ──────────────── ✅ (2.3s)
  Sprite "triage-a1b2c3" running

[Runic] FetchIssue ───────────────────── ✅ (0.8s)
  "Bug: Widget.call/1 crashes on nil"

[Runic] CloneRepo ────────────────────── ✅ (3.1s)
  Cloned to /repo

[Runic] SetupEnv ─────────────────────── ✅ (12.4s)
  Ran: mix deps.get, mix compile

[Runic] Investigate → child:fixer ────── 🔧 delegating to FixerAgent...

  ┌─── ReAct Loop (FixerAgent) ────────────────────────────────────┐
  │                                                                 │
  │  Thought: Let me understand the issue. Widget.call/1 crashes    │
  │           on nil input. Let me find the code.                   │
  │                                                                 │
  │  🔧 sprite_exec: grep -rn "def call" /repo/lib/                │
  │  ✅ Found lib/jido/widget.ex:47                                 │
  │                                                                 │
  │  🔧 sprite_exec: cat /repo/lib/jido/widget.ex                  │
  │  ✅ Got source (82 lines)                                       │
  │                                                                 │
  │  Thought: I see the issue — no nil guard on line 47.            │
  │           Let me have Claude write the fix + test.              │
  │                                                                 │
  │  🔧 claude_run: "Fix Widget.call/1 to handle nil input..."     │
  │  ✅ Claude wrote fix + test (4.2min)                            │
  │                                                                 │
  │  🔧 sprite_exec: cd /repo && mix test                          │
  │  ✅ 47 tests, 0 failures                                       │
  │                                                                 │
  │  🔧 sprite_exec: git checkout -b fix/issue-42                  │
  │  🔧 sprite_exec: git add -A && git commit -m "fix: ..."       │
  │  ✅ Committed on fix/issue-42                                   │
  │                                                                 │
  │  Answer: Fixed Widget.call/1 nil crash. Added guard clause     │
  │          and test. All 47 tests pass.                           │
  └─────────────────────────────────────────────────────────────────┘

[Runic] Investigate ──────────────────── ✅ (5.1min)

[Runic] PushResults ──────────────────── ✅ (1.2s)
  PR created: https://github.com/agentjido/jido/pull/43

[Runic] Teardown ─────────────────────── ✅ (0.5s)
  Sprite destroyed

✅ Triage complete (5min 22s)
   PR: https://github.com/agentjido/jido/pull/43

10. Error Handling — Best of Both Worlds

Runic-level (deterministic, structured)

Node On Failure Recovery
ProvisionSprite Abort pipeline Fly.io down or quota exceeded
FetchIssue Abort Bad URL, missing issue, or token invalid
CloneRepo Retry once Transient network error
SetupEnv Abort with log Setup command failed
Investigate Retry once FixerAgent timeout; on 2nd failure, post partial
PushResults Retry twice GitHub API transient; final failure → print to stdout
Teardown Best-effort Log warning, don't fail pipeline

ReAct-level (adaptive, LLM-driven)

Within the FixerAgent's ReAct loop:

  • If mix test fails, the LLM retries with Claude Code to fix the new failures
  • If claude_run times out, the LLM reads partial output from /tmp/claude_output.md
  • If the project uses a different build tool, the LLM adapts (npm test, cargo test, etc.)
  • If there's no obvious fix, the LLM produces an investigation report instead

The FixerAgent's max_iterations: 25 caps the ReAct loop. The orchestrator's Runic timeout caps the total investigation time. The Sprite TTL provides a final safety net.

Guaranteed Cleanup

The biggest win over V1's pure ReAct: the Teardown node always runs. Even if the FixerAgent crashes, the Runic pipeline's error handling ensures the Sprite gets destroyed. This is a billing concern solved by architecture, not by hoping the LLM remembers.

For extra safety, the orchestrator's terminate/1 callback and Fly.io Sprite TTL provide defense-in-depth:

# In the Orchestrator module
@impl true
def terminate(_reason, agent) do
  # Best-effort cleanup if the pipeline didn't reach Teardown
  case agent.state[:session_id] do
    nil -> :ok
    session_id -> Jido.Shell.ShellSession.stop(session_id)
  end
end

11. The Sprite Handle — Reuse Pattern

V2 supports keeping the Sprite alive after triage for follow-up work. When keep_sprite: true:

  1. Teardown returns the session_id instead of destroying
  2. The caller gets a handle back
  3. A subsequent triage run (or a different workflow) can reuse the same Sprite

This enables chaining:

# First run: investigate and fix
{:ok, result} = Orchestrator.run(request, jido: my_jido)

# Sprite is still alive — run another investigation in the same environment
request2 = %{request | issue_number: 43, keep_sprite: true,
             sprite: %{request.sprite | session_id: result.session_id}}

{:ok, result2} = Orchestrator.run(request2, jido: my_jido)

# Done — destroy the sprite explicitly
Jido.Shell.ShellSession.stop(result2.session_id)

12. File Structure

jido_issue_bot/
├── lib/
│   ├── jido_issue_bot.ex                          # Top-level API
│   ├── jido/issue_triage/
│   │   ├── triage_request.ex                      # Zoi input schema
│   │   ├── orchestrator.ex                        # Runic workflow agent
│   │   ├── fixer_agent.ex                         # ReAct child agent
│   │   ├── sprite_commands.ex                     # Shared exec helper
│   │   ├── actions/
│   │   │   ├── provision_sprite.ex                # Runic node — create Sprite
│   │   │   ├── fetch_issue.ex                     # Runic node — gh issue view
│   │   │   ├── clone_repo.ex                      # Runic node — git clone
│   │   │   ├── setup_env.ex                       # Runic node — deps/compile
│   │   │   ├── investigate.ex                     # Runic node — bridge to ReAct
│   │   │   ├── push_results.ex                    # Runic node — PR/comment
│   │   │   └── teardown.ex                        # Runic node — destroy Sprite
│   │   ├── tools/
│   │   │   ├── sprite_exec.ex                     # ReAct tool — shell commands
│   │   │   └── claude_run.ex                      # ReAct tool — Claude Code
│   │   └── skills/
│   │       ├── bug_investigation.ex               # Skill — investigation workflow
│   │       ├── claude_code_workflow.ex             # Skill — using Claude effectively
│   │       └── git_workflow.ex                     # Skill — branch/commit
│   └── mix/tasks/
│       └── jido.triage.ex                         # Mix task entry point
├── test/
│   ├── jido/issue_triage/
│   │   ├── orchestrator_test.exs                  # Full pipeline with mocked Sprite
│   │   ├── fixer_agent_test.exs                   # ReAct agent with mocked tools
│   │   ├── actions/
│   │   │   ├── provision_sprite_test.exs
│   │   │   ├── fetch_issue_test.exs
│   │   │   ├── clone_repo_test.exs
│   │   │   ├── push_results_test.exs
│   │   │   └── teardown_test.exs
│   │   └── tools/
│   │       ├── sprite_exec_test.exs
│   │       └── claude_run_test.exs
│   ├── integration/
│   │   └── triage_integration_test.exs            # @tag :integration
│   └── support/
│       └── fixtures/                              # Sample gh JSON, Claude output
├── mix.exs
└── .env.example

13. Dependencies

defp deps do
  [
    {:jido, "~> 1.0"},
    {:jido_ai, path: "../jido_ai"},
    {:jido_runic, path: "../jido_runic"},
    {:jido_shell, path: "../jido_shell"},
    {:zoi, "~> 0.16"},
    {:jason, "~> 1.4"},
    {:dotenvy, "~> 0.8", only: [:dev, :test]}
  ]
end

Note: jido_claude is no longer a direct dependency. Claude Code is invoked via claude CLI in the Sprite (through sprite_exec), not through the Elixir SDK. This eliminates the nested-agent problem from V1.


14. Tradeoffs — V2 vs V1

V2 wins

Advantage Detail
Guaranteed cleanup Sprite teardown is a Runic node, not an LLM hope
Per-step observability Each Runic node has timing, status, retry count
Structured retry CloneRepo retries once, PushResults retries twice — deterministic
Simpler fixer agent 2 tools instead of 4 — no lifecycle management
Reusable Sprites Handle return pattern enables chaining
Cheaper LLM only reasons during investigation, not during git clone
No nested agents Claude Code via CLI, not via jido_claude SDK

V2 tradeoffs

Tradeoff Detail
More code 7 action modules + 2 tools + 3 skills vs V1's 4 tools + 3 skills
Runic dependency Adds jido_runic to the dependency graph
Less adaptive setup If project setup is unusual, Runic node fails; V1's LLM could adapt
Child delegation complexity Runic → spawn → execute → emit_to_parent is more moving parts

When V2 isn't enough

  • Unknown project types: If you can't predict setup commands, move SetupEnv into the FixerAgent's ReAct loop
  • Multi-issue batching: Add a parallel fan-out in the Runic DAG (multiple Investigate children)
  • PR review loop: Add a ReviewPR node after PushResults that runs another ReAct agent to review the diff

15. Diagram — Data Flow

                    TriageRequest
                         │
              ┌──────────▼──────────┐
              │   ProvisionSprite   │  → session_id, sprite_name
              └──────────┬──────────┘
                         │
              ┌──────────▼──────────┐
              │     FetchIssue      │  → issue_title, issue_body, labels
              └──────────┬──────────┘
                         │
              ┌──────────▼──────────┐
              │     CloneRepo       │  → repo_dir
              └──────────┬──────────┘
                         │
              ┌──────────▼──────────┐
              │     SetupEnv        │  → (deps installed, compiled)
              └──────────┬──────────┘
                         │
              ╔══════════▼══════════╗
              ║    Investigate      ║  ← delegated to FixerAgent (ReAct)
              ║  {:child, :fixer}   ║
              ║                     ║  → investigation report, branch_name,
              ║  ┌───────────────┐  ║    committed changes on branch
              ║  │ ReAct Loop    │  ║
              ║  │ sprite_exec   │  ║
              ║  │ claude_run    │  ║
              ║  │ Skills guide  │  ║
              ║  │ the approach  │  ║
              ║  └───────────────┘  ║
              ╚══════════╤══════════╝
                         │
              ┌──────────▼──────────┐
              │    PushResults      │  → pr_url or comment_url
              └──────────┬──────────┘
                         │
              ┌──────────▼──────────┐
              │     Teardown        │  → sprite destroyed (or handle returned)
              └──────────┬──────────┘
                         │
                   TriageResult

16. Implementation Roadmap

Phase 1: Scaffold + Deterministic Nodes (~1 day)

  1. mix new jido_issue_bot + deps
  2. TriageRequest Zoi schema (reuse from V1)
  3. SpriteCommands helper
  4. ProvisionSprite, FetchIssue, CloneRepo, SetupEnv, Teardown actions
  5. Unit tests with mocked shell backend

Phase 2: Fixer Agent + Tools (~1 day)

  1. SpriteExec and ClaudeRun tools
  2. Three skill modules
  3. FixerAgent with ReAct loop
  4. HandleWork action for child delegation
  5. Unit tests with mocked LLM + tools

Phase 3: Orchestrator + Bridge (~1 day)

  1. Investigate bridge action
  2. PushResults action
  3. Orchestrator with Runic DAG + child delegation
  4. Wire up the full pipeline
  5. End-to-end test with mocked Sprite backend

Phase 4: Mix Task + Integration (~half day)

  1. Mix task with option parsing
  2. Telemetry trace handler (Runic node events + ReAct tool calls)
  3. Integration test with real Sprite + GitHub (@tag :integration)

Total: ~3.5 days to a working bot.


17. Open Questions

  1. FixerAgent as child worker — The HandleWork action needs to bridge between Runic's RunnableExecution and the AI agent's ask_sync. Does ask_sync work when called from within the same process that's running as a child agent? May need to spawn a Task or use Jido.AgentServer.call/2 instead.

  2. Data threading through Runic nodes — Each node needs to pass through data it didn't produce (e.g., session_id flows from ProvisionSprite through every subsequent node). Runic's fact system merges input facts, but need to verify this works cleanly for 7 chained nodes.

  3. Setup commands — Should SetupEnv be configurable per-project (via TriageRequest), or should the FixerAgent detect and run setup? For V2, using configurable commands. Can move to ReAct if needed.

  4. Claude CLI vs jido_claude SDK — V2 uses claude CLI via sprite_exec instead of jido_claude's Elixir SDK. This is simpler but loses structured event streaming. If we need to observe Claude's intermediate steps, switch back to the SDK approach.

  5. Sprite TTL safety net — Even with guaranteed Teardown, Sprites should have a TTL (e.g., 30 minutes) as defense-in-depth. Is this a Fly.io Machines config, or do we need a sweeper process?

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