# ───────────────────────────────────────────────────────────────────────────── # Recipe: wrap an agent run in an Oban job in YOUR app. # # This file is documentation, not a runnable example: it sketches a durable # Oban wrapper for agent execution. It requires `:oban` + Postgres in your app # (not in this library — the framework stays DB-free on purpose; durability is # an application concern you opt into). # # Add to your app: # # # mix.exs # {:oban, "~> 2.18"} # # # config/config.exs # config :my_app, Oban, # repo: MyApp.Repo, # queues: [agents: 10] # # # lib/my_app/application.ex — start Oban in your supervisor tree: # children = [..., {Oban, Application.fetch_env!(:my_app, Oban)}] # ───────────────────────────────────────────────────────────────────────────── # ── 1. A checkpoint store (PG column `history text`): ──────────────────────── # # defmodule MyApp.AgentRun do # use Ecto.Schema # schema "agent_runs" do # field :key, :string # unique run key # field :history, :string # Message.to_json([...]) best-effort checkpoint # field :output, :string # optional final text output # field :status, :string, default: "running" # timestamps() # end # end # # # migration excerpt: # create unique_index(:agent_runs, [:key]) # # ── 2. The durable worker. It skips completed runs, loads any saved history, # runs the agent, then persists final history/output. This does not # checkpoint before every side effect; make tools idempotent or guard # them with your own DB uniqueness/locking. ───────────────────────────── # # defmodule MyApp.AgentWorker do # use Oban.Worker, queue: :agents, max_attempts: 5 # # alias ExAgent.{Message} # # @impl true # def perform(%Oban.Job{args: %{"key" => key, "prompt" => prompt}}) do # # In production, fetch/lock this row in a transaction if duplicate jobs # # can run concurrently. Side-effectful tools need their own idempotency keys. # run = MyApp.Repo.get_by!(MyApp.AgentRun, key: key) # # if run.status == "done" do # :ok # else # history = # case run.history do # nil -> [] # json -> {:ok, msgs} = Message.from_json(json); msgs # end # # agent = ExAgent.new(model: "openai:gpt-4o", tools: MyApp.Tools.tools()) # # case ExAgent.run(agent, prompt, message_history: history) do # {:ok, %{output: output, messages: messages}} -> # # persist final history for audit/replay; this is not per-tool checkpointing # run # |> Ecto.Changeset.change( # history: Message.to_json(messages), # status: "done", # output: output # ) # |> MyApp.Repo.update!() # :ok # # {:error, reason} -> # # re-raise so Oban retries with backoff; checkpoint kept as-is # raise "agent failed: #{inspect(reason)}" # end # end # end # end # # ── 3. Enqueue (Oban unique reduces duplicates; keep the DB unique index): ─── # # %{key: "support-#{ticket_id}", prompt: "Help with order ##{order_id}"} # |> Oban.Job.new(worker: MyApp.AgentWorker, unique: [period: 60]) # |> Oban.insert(MyApp.Repo) # # ── Human-in-the-loop workflows ────────────────────────────────────────────── # ExAgent.run/3 runs to completion and capability callbacks do not suspend. # Manage approvals as application state instead: persist the conversation with # Message.to_json/1, record a pending approval in your own tables, and enqueue # a new job with the restored `history` when the human responds. # # ── Why this lives in YOUR app, not in the library ─────────────────────────── # - Keeps the framework dependency-light (no forced Postgres). # - You own idempotency keys, retry policy, queue sizing and the schema. # - The library only needs to provide (de)serialization — which it does via # `Message.to_json/1` and `Message.from_json/1`. IO.puts(""" This is a documentation recipe (see the source). It shows an Oban wrapper plus best-effort history persistence with ExAgent.Message.to_json/from_json. The framework itself stays DB-free. """) # Sanity-check the (de)serialization primitives the recipe relies on: alias ExAgent.Message, as: M alias ExAgent.Message.Part conv = [ M.new_request([%Part.User{content: "hello"}], timestamp: nil), M.new_response([%Part.Text{content: "hi back"}], model_name: "m", timestamp: nil) ] {:ok, decoded} = M.from_json(M.to_json(conv)) IO.puts( "Checkpoint round-trip OK: #{length(decoded)} messages decoded back to #{inspect(hd(hd(decoded).parts).__struct__)}." )