agent-outbox
Wrap a tool so an agent can retry it without running the side effect twice.
Same session + tool + args → first call runs, later calls replay.
uv add agent-outbox
# or
pip install agent-outbox
Until the first PyPI release lands, install from GitHub:
uv add git+https://github.com/adp811/agent-outbox
LangGraph extra (only needed for the sample graph): uv add "agent-outbox[langgraph]"
From this repo:
uv sync
uv run agent-outbox-demo # retries create_ticket 5 times, 1 ticket
uv run agent-outbox-graph # same storm, as a LangGraph agent → tools loop
uv run agent-outbox-eval # 23/23 retry-storm cases
Usage
The tool is a normal function or client method. You wrap the call with run:
from agent_outbox import ToolExecutor, OutboxStore
store = OutboxStore() # or OutboxStore("outbox.db") to persist
ex = ToolExecutor(store)
class TicketClient:
def create_ticket(
self,
title: str,
severity: str,
idempotency_key: str | None = None,
) -> dict:
return httpx.post(
"https://tickets.example/v1/tickets",
json={"title": title, "severity": severity},
headers={"Idempotency-Key": idempotency_key},
).json()
tickets = TicketClient()
args = {"title": "kserve replica crashloop", "severity": "high"}
session = "agent-session-1"
# first call: POSTs
a = ex.run("create_ticket", tickets.create_ticket, args, session)
# agent retries the same call: no second POST, same payload
b = ex.run("create_ticket", tickets.create_ticket, args, session)
run takes:
| arg | meaning |
|---|---|
tool |
name, part of the key |
fn |
the function to call once |
args |
kwargs passed to fn (key order does not matter) |
session_id |
one agent run / conversation |
Returned Execution: ok, replay, result, error.
A new session, tool name, or arg value is a new call and will fire again.
If fn has an idempotency_key parameter, run fills it in so the downstream API can also dedupe.
LangGraph tool node
The graph is a normal agent → tools loop. The agent is scripted (it keeps emitting the same create_ticket call — no LLM). The tools node is the wrap:
from langgraph.graph import END, START, StateGraph
from agent_outbox import ToolExecutor, OutboxStore
def tools(state):
execution = ex.run(
"create_ticket",
tickets.create_ticket,
{"title": state["title"], "severity": state["severity"]},
state["session_id"],
)
return {"replay": execution.replay, "result": execution.result}
graph = StateGraph(AgentState)
graph.add_node("agent", agent) # decides to retry
graph.add_node("tools", tools) # outbox absorbs the storm
graph.add_edge(START, "agent")
graph.add_conditional_edges("agent", route, {"tools": "tools", "__end__": END})
graph.add_edge("tools", "agent")
Five trips through tools, one create_ticket side effect. uv run agent-outbox-graph runs it.
If the tool fails
The first attempt is recorded as failed. Retries return that error and do not call fn again.
If a retry lands while the first call is still running
Waiters poll the outbox until the owner finishes (default wait_timeout=10s) and then replay the same result. They do not start a second side effect.
If the process dies mid-call
Pending rows have a lease (lease_seconds=30 by default). Heartbeats refresh it while fn is running. After the lease expires, another worker can reclaim and run fn again.
That reclaim is at-least-once: if the original worker already performed the side effect and crashed before complete(), a reclaim will fire twice. Downstream Idempotency-Key is how you close that gap.
Optional: write spans
from agent_outbox import JsonlTracer, ToolExecutor, OutboxStore
tracer = JsonlTracer("spans.jsonl")
ex = ToolExecutor(OutboxStore("outbox.db"), tracer)
Each attempt logs tool, key, session_id, replay, status.
Tests
uv sync
uv run pytest
uv run agent-outbox-eval
CI runs pytest, the 23-case eval, and a wheel install on Python 3.11–3.13.
Release to PyPI
CI is automatic on push. Publishing is a GitHub Release (v0.1.0).
One-time PyPI setup: pypi.org → Publishing → pending publisher. Project name agent-outbox, owner adp811, repo agent-outbox, workflow publish.yml. Then create a GitHub release tagged v0.1.1.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file agent_outbox-0.1.1.tar.gz.
File metadata
- Download URL: agent_outbox-0.1.1.tar.gz
- Upload date:
- Size: 97.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
uv/0.12.6 {"installer":{"name":"uv","version":"0.12.6","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
bc8d81cd1481e6c8471d91cbf9d52bf06edd2873817e28006c09cb0b9edad912
|
|
| MD5 |
20e9d0af1a8d320fb33ca2c3b105a2ff
|
|
| BLAKE2b-256 |
d78dc4d16b870a64e60d23ba6aebc82a374ff7fb2c432db4084b9e52a4462274
|
File details
Details for the file agent_outbox-0.1.1-py3-none-any.whl.
File metadata
- Download URL: agent_outbox-0.1.1-py3-none-any.whl
- Upload date:
- Size: 14.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
uv/0.12.6 {"installer":{"name":"uv","version":"0.12.6","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8224699f4e516bb1d853d911de15ed282806bef493f0f5b851cb8451008c4008
|
|
| MD5 |
f9f5587506820299770384d842ba7529
|
|
| BLAKE2b-256 |
2dd856f4c3ef883ea6cb6606064d933b386a839fb5dafa94d8e9ffeda8971a0c
|