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 │
│ │
└───────────────────────────────────────────────────────────────────────────────────┘
| 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.
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
endEach 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.
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
endCreates 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
enddefmodule 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
enddefmodule 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
enddefmodule 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
endThe 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:
- Emits a
SpawnAgentdirective for the:fixertag - The runtime starts a
FixerAgentchild process - On
jido.agent.child.started, the runnable is sent to the child - The child executes the
Investigateaction within its ReAct loop - The child emits its result back to the parent orchestrator
- 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
endThis 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
endThe 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.
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
endRuns 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
endSkills 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).
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
enddefmodule 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
enddefmodule 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
endAfter the FixerAgent finishes, control returns to the Runic workflow. The remaining nodes are deterministic.
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
enddefmodule 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
endGH_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
| 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 |
Within the FixerAgent's ReAct loop:
- If
mix testfails, the LLM retries with Claude Code to fix the new failures - If
claude_runtimes 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.
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
endV2 supports keeping the Sprite alive after triage for follow-up work. When keep_sprite: true:
- Teardown returns the
session_idinstead of destroying - The caller gets a handle back
- 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)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
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]}
]
endNote: 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.
| 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 |
| 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 |
- 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
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
mix new jido_issue_bot+ depsTriageRequestZoi schema (reuse from V1)SpriteCommandshelperProvisionSprite,FetchIssue,CloneRepo,SetupEnv,Teardownactions- Unit tests with mocked shell backend
SpriteExecandClaudeRuntools- Three skill modules
FixerAgentwith ReAct loopHandleWorkaction for child delegation- Unit tests with mocked LLM + tools
Investigatebridge actionPushResultsactionOrchestratorwith Runic DAG + child delegation- Wire up the full pipeline
- End-to-end test with mocked Sprite backend
- Mix task with option parsing
- Telemetry trace handler (Runic node events + ReAct tool calls)
- Integration test with real Sprite + GitHub (
@tag :integration)
Total: ~3.5 days to a working bot.
-
FixerAgent as child worker — The
HandleWorkaction needs to bridge between Runic'sRunnableExecutionand the AI agent'sask_sync. Doesask_syncwork when called from within the same process that's running as a child agent? May need to spawn a Task or useJido.AgentServer.call/2instead. -
Data threading through Runic nodes — Each node needs to pass through data it didn't produce (e.g.,
session_idflows from ProvisionSprite through every subsequent node). Runic's fact system merges input facts, but need to verify this works cleanly for 7 chained nodes. -
Setup commands — Should
SetupEnvbe configurable per-project (via TriageRequest), or should the FixerAgent detect and run setup? For V2, using configurable commands. Can move to ReAct if needed. -
Claude CLI vs jido_claude SDK — V2 uses
claudeCLI viasprite_execinstead ofjido_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. -
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?