Packages

Run Claude Code jobs on an Oban queue: an Oban.Worker over claude_wrapper that maps claude's typed result/error onto Oban return values.

Current section

Files

Jump to
oban_claude examples agent_live.exs
Raw

examples/agent_live.exs

# examples/agent_live.exs
#
# The agent lifecycle spike against REAL claude calls -- this script COSTS
# MONEY (a few turns on sonnet, typically well under $1 total, each turn
# capped by max_budget_usd).
#
# Everything is real: the SQLite-backed Oban queue, the default
# ObanClaude.Agent.Job worker, claude itself. The agent works in a throwaway
# workspace under the tmp dir, so the approved edit lands there, not in a
# repo. Demonstrates the full loop:
#
# 1. a plain turn (directive none)
# 2. a permission-gated edit: request_permission -> approve, where
# :approved_args elevates that one turn to accept_edits so the approved
# edit actually lands on disk
# 3. an ask_user turn answered by the operator
#
# mix run examples/agent_live.exs
# ---------------------------------------------------------------------------
# 1. Throwaway workspace the agent runs in, with a file to work on.
# ---------------------------------------------------------------------------
workspace = Path.join(System.tmp_dir!(), "oban_claude_agent_live_ws")
File.rm_rf!(workspace)
File.mkdir_p!(workspace)
notes = Path.join(workspace, "notes.md")
File.write!(notes, """
# Project notes
- The queue procesing logic lives in the worker module.
- Results are classified onto Oban verdicts.
- Session ids allow resuming a conversation.
""")
IO.puts("Workspace: #{workspace}")
IO.puts("notes.md before:\n" <> File.read!(notes))
# ---------------------------------------------------------------------------
# 2. Throwaway SQLite repo + Oban + the agent tree, as in the other examples.
# ---------------------------------------------------------------------------
db_path = Path.join(System.tmp_dir!(), "oban_claude_agent_live.db")
for suffix <- ["", "-shm", "-wal"], do: File.rm(db_path <> suffix)
defmodule AgentLiveRepo do
use Ecto.Repo, otp_app: :oban_claude, adapter: Ecto.Adapters.SQLite3
end
Application.put_env(:oban_claude, AgentLiveRepo,
database: db_path,
pool_size: 1,
busy_timeout: 5_000,
log: false
)
defmodule AgentLiveMigration do
use Ecto.Migration
def up, do: Oban.Migrations.up()
def down, do: Oban.Migrations.down()
end
{:ok, _} = AgentLiveRepo.start_link()
Ecto.Migrator.up(AgentLiveRepo, 1, AgentLiveMigration, log: false)
{:ok, _} =
Oban.start_link(
repo: AgentLiveRepo,
engine: Oban.Engines.Lite,
notifier: Oban.Notifiers.PG,
peer: Oban.Peers.Isolated,
plugins: [],
queues: [agents: 1]
)
{:ok, _} = ObanClaude.Agent.Supervisor.start_link()
:telemetry.attach(
"agent-live-transitions",
[:oban_claude, :agent, :transition],
fn _event, _measurements, meta, _config ->
IO.puts(" [transition] #{meta.agent_id}: #{meta.from} -> #{meta.to}")
end,
nil
)
:telemetry.attach(
"agent-live-runs",
[:oban_claude, :run, :stop],
fn _event, meas, meta, _config ->
out = ObanClaude.structured(meta.result) || %{}
IO.puts(" [turn] $#{Float.round(meas.cost_usd, 4)} directive=#{out["directive"]}")
IO.puts(" [turn] #{out["summary"] || String.slice(meta.result.result || "", 0, 300)}")
end,
nil
)
# ---------------------------------------------------------------------------
# 3. The agent: real claude, directive schema, per-approval edit elevation.
# ---------------------------------------------------------------------------
schema =
Jason.encode!(%{
type: "object",
additionalProperties: false,
required: ["directive", "summary"],
properties: %{
directive: %{type: "string", enum: ["none", "ask_user", "request_permission"]},
summary: %{type: "string"},
question: %{type: "string", description: "set when directive=ask_user"},
action: %{type: "string", description: "set when directive=request_permission"}
}
})
args =
ObanClaude.Args.defaults(
model: "sonnet",
working_dir: workspace,
max_turns: 10,
max_budget_usd: 1.0,
timeout: 240_000,
json_schema: schema,
append_system_prompt: """
You are one turn of a long-running, operator-supervised agent. Always
return the structured output. Set directive=ask_user with a question when
you need information only the operator has. Set directive=request_permission
with a one-line action description before any file edit or other
consequential step, and do NOT take the step this turn. Otherwise
directive=none. Put your actual answer in summary.
"""
)
{:ok, _pid} =
ObanClaude.Agent.start_agent("live",
args: args,
approved_args: %{"permission_mode" => "accept_edits"},
job_timeout: 300_000
)
settled = [:idle, :awaiting_permission, :waiting_for_user]
turn_timeout = 300_000
# ---------------------------------------------------------------------------
# 4. Drive the loop.
# ---------------------------------------------------------------------------
IO.puts("\n== turn 1: plain prompt ==")
:processing = ObanClaude.Agent.submit_prompt("live", "Summarize notes.md in one sentence.")
{:ok, s1} = ObanClaude.Agent.await("live", settled, turn_timeout)
IO.puts(" settled at: #{inspect(s1)}")
IO.puts("\n== turn 2: permission-gated edit ==")
:processing =
ObanClaude.Agent.submit_prompt(
"live",
"notes.md contains a spelling mistake. Fix it. Request permission before editing."
)
case ObanClaude.Agent.await("live", settled, turn_timeout) do
{:ok, {:awaiting_permission, %{id: action_id, description: description}}} ->
IO.puts(" [operator] approving: #{description}")
:processing = ObanClaude.Agent.approve_action("live", action_id)
{:ok, s2} = ObanClaude.Agent.await("live", settled, turn_timeout)
IO.puts(" settled at: #{inspect(s2)}")
{:ok, other} ->
IO.puts(" expected :awaiting_permission, settled at: #{inspect(other)}")
end
IO.puts("\nnotes.md after:\n" <> File.read!(notes))
IO.puts("\n== turn 3: ask_user ==")
:processing =
ObanClaude.Agent.submit_prompt(
"live",
"I am considering splitting notes.md by topic. Ask me one clarifying question, then advise."
)
case ObanClaude.Agent.await("live", settled, turn_timeout) do
{:ok, {:waiting_for_user, question}} ->
IO.puts(" [operator] agent asks: #{question}")
IO.puts(" [operator] answering...")
:processing =
ObanClaude.Agent.submit_prompt(
"live",
"There will only ever be a handful of notes; simplicity matters most."
)
{:ok, s3} = ObanClaude.Agent.await("live", settled, turn_timeout)
IO.puts(" settled at: #{inspect(s3)}")
{:ok, other} ->
IO.puts(" expected :waiting_for_user, settled at: #{inspect(other)}")
end
# ---------------------------------------------------------------------------
# 5. The bill and the log.
# ---------------------------------------------------------------------------
{:ok, info} = ObanClaude.Agent.info("live")
IO.puts("""
Info:
state: #{inspect(info.state)}
session_id: #{info.session_id}
turns: #{info.turns}
cost_usd: $#{Float.round(info.cost_usd, 4)}
""")
{:ok, history} = ObanClaude.Agent.history("live")
IO.puts("History (oldest first):")
for entry <- history do
IO.puts(" " <> (entry |> inspect() |> String.slice(0, 160)))
end