Native Shell
Native Shell은 모든 플랫폼의 앱 안에서 만들어지는 AI UI입니다
AI 에이전트 호출을 안전하게 재시도하도록 하는 Python 라이브러리, 단일 실행·인수 기반 승인·취소 지원
Python library that makes AI agent tool calls safe to retry: once-only actions, approvals bound to exact arguments, undo. LangGraph, Pydantic AI, OpenAI SDK, MCP.
개발 도구PythonGitHub @salahmakboul온라인운영자 미확인
써 보셨어요? 한 번만 눌러 주세요. 로그인은 필요 없어요.
Stop AI agents repeating actions after a crash: approved tool calls run at most once. Python, no dependencies. Works with LangGraph, Pydantic AI, the OpenAI Agents SDK, MCP and plain Python.
저장소 README에서 가져옴
README 전체를 불러오는 중입니다.
README 전문 보기 ↗Call4Agents makes the actions of a Python agent safe to retry. You add it to the agent you already have: LangGraph, Pydantic AI, the OpenAI Agents SDK, an MCP server, or plain Python. It does not replace any of them.
Does your agent repeat an action after a crash? This kills it at every step, restarts it, and tells you which action ran twice. Your agent does not need to use our guard first.
pip install call4agents
call4agents crashtest --demo # a small agent, without and with the one-line fix
call4agents crashtest your_agent.py # yours (experimental; every action really runs, so use test accounts)
Then the kit makes it stop:
pay(10, alice) cannot run pay(9999, mallory).
A "yes" covers one action, and a later "no" is enforced.undo=), and c4a.undo() reverses a run newest first,
each undo once only, even across crashes.Version 0.6, early. Standard library only, zero dependencies (PostgreSQL for several machines is an optional extra). Apache-2.0.
# pip install call4agents
import call4agents as c4a
c4a.init(".c4a/first.sqlite")
@c4a.effect(approval=True)
def pay(amount, to):
print(f"paying {amount} to {to}")
return "paid"
with c4a.run("order-42"):
c4a.approve(pay, amount=10, to="alice") # the person said yes to this exact call
print(pay(10, "alice")) # runs
print(pay(10, "alice")) # a retry: recorded result, no second payment
pay(9999, "mallory") # not approved: raises, nothing is paid
paying 10 to alice
paid
paid
call4agents.ApprovalRequired: __main__.pay needs approval for exactly {"amount":9999,"to":"mallory"}
Run it a second time: pay prints nothing, because the record already holds the result.
Guides with runnable code for each framework: salahmakboul.github.io/call4agents/guides.
Tested on every change (tests.yml). These are the only systems we claim:
| Python 3.10 | Python 3.11 | Python 3.12 | Python 3.13 | |
|---|---|---|---|---|
Linux (GitHub ubuntu-latest) | ✓ | ✓ | ✓ | ✓ |
macOS (GitHub macos-latest) | ✓ | ✓ | ✓ | ✓ |
Windows (GitHub windows-latest) | ✓ | ✓ | ✓ | ✓ |
With the real frameworks, on all three systems with Python 3.12: LangGraph (+ LangChain), Pydantic AI,
the OpenAI Agents SDK and the MCP Python SDK. CI installs the latest release of each, and each run prints
the exact versions with call4agents doctor.
pip install call4agents
call4agents incident
Each incident runs in real processes with real crashes, once without the kit and once with it. The bank and the
agent loop are small local stand-ins; call4agents quickstart below shows the crash in the real frameworks.
pip install call4agents
call4agents quickstart
quickstart runs a plain-Python demo. Then, for each of LangGraph, Pydantic AI and the OpenAI
Agents SDK that is installed, it runs a real crash and resume in that framework, using a fake model.
Each framework is run once without Call4Agents and once with it. Output on our machine (Windows 10,
Python 3.12, all three installed):
Plain Python - crash right after an email is sent, then resume:
without the run record emails sent: 2
with the run record (crash after the tool) emails sent: 1
with the run record (crash inside the tool) emails sent: 1 (refused to resend: in doubt)
"yes" to pay(10, alice), call changed to pay(9999, mallory): REFUSED; approved call: PAID; payments: 1
Frameworks found: LangGraph, Pydantic AI, OpenAI Agents SDK
LangGraph crash after the email, resume: without call4agents 2 emails, with call4agents 1
Pydantic AI crash after the email, resume: without call4agents 2 emails, with call4agents 1
OpenAI Agents SDK crash after the email, resume: without call4agents 2 emails, with call4agents 1
With no framework installed, only the plain-Python part runs (about 5 seconds on our machine). To see
the framework rows, install them first: pip install langgraph langgraph-checkpoint-sqlite pydantic-ai openai-agents (about 75 seconds in a fresh virtual environment).
call4agents selftest checks the demo results. Run it from the root of a repository checkout to run the
full offline suites instead (kit, production, stress and idempotency checks).
Found a problem, or something missing? call4agents report prints your system and framework versions
and a link to the issue form with them
filled in. It sends nothing and opens nothing: you read it and decide. There is no tracking of any kind.
Idempotency-Key (Stripe, Adyen and others) and want a crash
half-way through to be retried safely instead of needing a person.You probably do not need it if your agent only reads data, or if every action already runs inside a durable workflow engine that gives you these guarantees.
import call4agents as c4a
c4a.init() # creates .c4a/ledger.sqlite and .c4a/secret
@c4a.effect # once per (run, tool, exact arguments)
def send_email(to, body): ...
@c4a.effect(approval=True) # also needs a "yes" for these exact arguments
def pay(amount, to): ...
@c4a.effect(idempotent_for=24 * 3600) # the API remembers keys for 24 h (Stripe v1)
def charge(amount, to):
return requests.post(URL, json={"amount": amount, "to": to},
headers=c4a.idempotency_headers()).json()
@c4a.recorded # model calls, for replay and diff
def ask_model(prompt): ...
with c4a.run("thread-42"): # use the id your framework resumes with
send_email("bob", "hi")
send_email("bob", "hi") # same action: returns the recorded result, sends nothing
send_email("bob", "hi", c4a_key="second") # a deliberate second identical action
c4a.approve(pay, amount=10, to="alice") # when the person says yes to what they SAW
pay(amount=10, to="alice")
Rules worth knowing before you start:
c4a.run(...). An action or approval outside a run raises RuntimeError. New threads
do not inherit the run, so enter c4a.run in the thread (or use contextvars.copy_context().run).billing.charge). If you move or rename the
function between deploys, a resumed run sees a new tool. Give long-lived tools a fixed name="charge".TypeError; pass c4a_key= instead. For methods,
self is not part of the key.EffectInDoubt and names the error. List errors that prove nothing
happened with retry_on=(ConnectionRefusedError,); after those, the next call runs the action again.LangGraph and LangChain.
from call4agents.adapters import langgraph as c4a_lg.@c4a_lg.effect in nodes or tools.c4a_lg.approve(tool, config, **shown_args) when an interrupt is answered, or c4a_lg.deny(...).Pydantic AI, the OpenAI Agents SDK, MCP. Each has an adapter. It leaves the framework's context
argument (RunContext, RunContextWrapper, MCP's Context) out of the key, because that object
changes on every call and plain @c4a.effect refuses it. It also finds the run id for you. The
framework's own decorator goes on top:
from call4agents.adapters import pydantic_ai as c4a_pai # run id: the run's conversation_id
@agent.tool
@c4a_pai.effect
def send_email(ctx: RunContext[Deps], to: str, body: str) -> str: ...
from call4agents.adapters import openai_agents as c4a_oa # run id: c4a.run(...) or your context's session_id
@function_tool
@c4a_oa.effect
def send_email(ctx: RunContextWrapper[dict], to: str, body: str) -> str: ...
from call4agents.adapters import mcp as c4a_mcp # run id: the Mcp-Session-Id, or run=lambda ctx: ...
@server.tool()
@c4a_mcp.effect
def create_ticket(title: str, ctx: Context) -> str: ...
Each has a copy-and-run example (fake model, no key), tested in CI with the real framework. Each one prints 1 where the same code without the kit prints 2:
python -m call4agents.examples.pydantic_ai_tool # pip install pydantic-ai
python -m call4agents.examples.openai_agents_tool # pip install openai-agents
python -m call4agents.examples.mcp_tool # pip install mcp
Plain Python. Decorate the function with @c4a.effect and wrap the work in c4a.run(<id>).
Undo a run. Register how to reverse each action, then reverse a whole run, newest first:
@c4a.effect(undo=lambda amount, to, result: refunds.create(charge=result["id"]))
def charge(amount, to): ...
@c4a.effect(undo=lambda day: hotel.cancel(day))
def book(day): ...
with c4a.run("trip-42"):
c4a.undo() # cancel the booking, then refund the charge; each undo runs once only
Each undo is itself a once-only action. If the process dies half-way, calling c4a.undo() again
continues where it stopped. An undo that crashed or raised is "in doubt": undo stops there, and
nothing older is touched until a person checks and runs call4agents resolve. Actions without
undo= are listed in the result, never skipped silently.
The kit's whole state is one local SQLite file, which we call the run record. Entries are only ever added to it, never changed or deleted.
EffectInDoubt instead of guessing. You can resolve it in two ways:
reconcile= asks the outside system whether the action happened;idempotent_for= lets the kit resend it with the same key while the API still remembers that key
(docs/IDEMPOTENCY.md).deny
cancels it.c4a.init(replay_from="old.sqlite") returns recorded model calls and results without
calling anything. They come back as the same Python types, including Pydantic models and dataclasses.
call4agents diff a.sqlite b.sqlite shows the step where two runs start to differ.| Command | What it does |
|---|---|
call4agents quickstart | the demo plus a crash and resume in each installed framework |
call4agents incident | three approval incidents, without and with the kit |
call4agents crashtest <script> | kills your agent at every step, restarts it, reports what ran twice or without approval (experimental) |
call4agents scan <path> | lists tools that send, pay, delete or write with no approval or once-only guard; runs nothing (experimental) |
call4agents audit <record> <out.json> [--run id] | a signed export: who approved what, when, for which arguments, and what ran (experimental) |
call4agents audit-verify <out.json> | exit 0 only if nothing in the export was changed |
call4agents demo | the plain-Python demo only |
call4agents selftest | the kit's checks (in a repository checkout, the full offline suites) |
call4agents doctor | system and framework versions for a bug report; prints only, sends nothing |
call4agents report | the same, plus a link to the issue form with it filled in; prints only, sends nothing |
call4agents status <record> | rows, actions, actions in doubt, integrity |
call4agents doubt <record> [--fail-if-any] | every action that may or may not have happened (exit 3 if any, for alerts) |
call4agents resolve <record> <key> done|not-done "note" | decide one in-doubt action; stored as a new row with your note |
call4agents archive <record> <archive> <days> | move finished runs older than N days to another file |
call4agents verify <record> | integrity, format version, late results |
call4agents diff a.sqlite b.sqlite [run_id] | the step where two recorded runs start to differ |
call4agents prove spec.json | puts each bug back and checks that your guard catches it |
python -m call4agents ... works the same way. <record> is a file, or a postgresql:// address for a shared record.
call4agents crashtest your_agent.py runs your script in a new empty folder, once cleanly, then once per crash
point. It kills the process just before and just after each action, and just before each write saved to SQLite or
to a file in that folder. Then it starts the script again and compares what ran with the clean run:
CRASHTEST_RESUME=1, so your
script can act out a changed call.Your script must be able to restart: a fixed run or thread id, and saved state in the current folder. Actions are
found by reading the script (names like pay or send_email, and calls that post, delete or run commands). Add
your own with --watch name,name. Every action really runs, many times, with your environment: use test accounts.
It does not see writes to PostgreSQL, Redis or a remote service as crash points, and it tests only the path your script takes. A clean result covers that path and those crash points, nothing more.
call4agents scan <path> reads Python source and runs nothing. It lists functions given to a model as tools whose
code sends, pays, deletes or writes, with no approval or once-only guard on them. It cannot see a guard in another
file or service, tools built at runtime, or what a call does past one helper in the same file. Measured results
are in docs/SCAN_RESULTS.md.
call4agents audit <record> out.json writes who approved what, when, for which exact arguments, and what ran. It
uses the record's secret (C4A_SECRET or its secret file) and never creates one. call4agents audit-verify out.json fails if any value was changed or any entry was removed, added or moved.
Limits: anyone with the secret can sign a false export, so this shows the file has not changed since a holder of the
secret signed it. It does not show that the record was honest before the export, and it is not proof for an outside
party. A name appears only where one was recorded (c4a.decide(ticket, "approve", by="sam")). Action results are
left out, but arguments are included, so treat the file as sensitive.
docs/PRODUCTION.md covers the rest:
When your agent pauses for a person and resumes later, the approval travels through a UI, a queue or a saved state. Tickets make sure the "yes" that comes back belongs to a pause your server really made, for the exact call the person saw:
with c4a.run("order-42"):
ticket = c4a.pause(pay, amount=10, to="alice") # recorded; show the ticket with the call
# ... later, when the person answers (any process that opens the same record):
c4a.decide(ticket, "approve", by="sam") # or "deny"
The ticket holds no arguments, only a random id and a signature. decide reads the call from the record, so
nothing the client sends back can change what runs. A ticket the record did not issue, or one that was changed,
expired or already decided, raises TicketRefused. The decision is stored with who made it. The action still
runs only if it is called with the approved arguments, so a "deny" holds even if a framework runs the tool anyway.
With Pydantic AI (requires_approval=True tools):
from call4agents.adapters import pydantic_ai as c4a_pai
@agent.tool(requires_approval=True)
@c4a_pai.effect(approval=True)
def pay(ctx: RunContext, amount: int, to: str) -> str: ...
result = agent.run_sync("pay alice 10", conversation_id="order-42") # ends with DeferredToolRequests
tickets = c4a_pai.tickets(result, [pay]) # {tool_call_id: ticket}: show each with its call
results = c4a_pai.results({ticket: "approve"}, by="sam") # the person's answers -> DeferredToolResults
agent.run_sync(message_history=result.all_messages(), deferred_tool_results=results,
conversation_id="order-42")
With the OpenAI Agents SDK (needs_approval=True tools):
from call4agents.adapters import openai_agents as c4a_oa
pay_fn = c4a_oa.effect(pay, approval=True) # keep this one: tickets() needs it
agent = Agent(name="payer", tools=[function_tool(pay_fn, needs_approval=True)], ...)
result = await Runner.run(agent, "pay alice 10", context={"session_id": "order-42"})
tickets = c4a_oa.tickets(result, [pay_fn]) # {call_id: ticket}
state = c4a_oa.apply(result.to_state(), {ticket: "approve"}, by="sam") # state.approve / state.reject
await Runner.run(agent, state)
With LangGraph (the ticket goes in the interrupt; a node that re-runs on resume gets the same ticket back):
from call4agents.adapters import langgraph as c4a_lg
def ask(state, config):
ticket = c4a_lg.pause(pay, config, **state["call"])
interrupt({"call": state["call"], "ticket": ticket})
# when the person answers:
c4a.decide(ticket, "approve", by="sam")
graph.invoke(Command(resume="decided"), config)
Checks T1-T13 (python -m tests.test_tickets) cover a forged or edited ticket, a pause edited in the record, a
ticket from another record, a second decision, expiry, a denied call that the framework runs anyway, and a call
with other arguments than the approved ones. With the real frameworks they cover, for each of Pydantic AI, the
OpenAI Agents SDK and LangGraph: the approved call runs once, a denied call does not run, and a call that the
client or the saved state changed after the pause does not run. (In the OpenAI Agents SDK, changed arguments
under the same call id are already refused by the SDK itself; the check also covers a call replaced with a new
id, which only the ticket catches.) Experimental: no real user has tried it yet, and the API may change.
| You see | What it means | What to do |
|---|---|---|
ApprovalRequired | no unused "yes" for exactly these arguments (or a later "no") | ask the person, then c4a.approve(...) or c4a.decide(ticket, ...) |
EffectInDoubt | the process stopped during the action; nobody knows if it happened | get an authoritative answer from the provider (a read that finds nothing can lag), then call4agents resolve <record> <key> done|not-done "what you checked" |
TicketRefused | the ticket was not issued here, or was changed, expired or already decided | pause again to ask the person again |
KeyConflict | a c4a_key (or key=) that already names one call was used with arguments that mean something else; nothing ran | use a new key for a different action |
LedgerError | the record cannot be used safely (wrong file, newer format, network drive, missing secret) | the message says which; docs/PRODUCTION.md lists every message |
Still stuck? call4agents report prints your versions and a link to the issue form. It sends nothing.
Beta. Tested with separate processes and one local PostgreSQL server on Linux, macOS and Windows. Not yet used on real networks or in production; the options may still change.
When workers or replicas on different machines can pick up the same run, they need one shared record. Point every machine at the same PostgreSQL database and give them the same secret:
# pip install "call4agents[postgres]"
c4a.init("postgresql://user:password@db-host/agents", secret=os.environ["C4A_SECRET"].encode())
The table and the UNIQUE indexes are the same as in the SQLite file, so the same guarantees hold across
machines. This covers the case in langgraph#7417,
where a long tool call is sent to a second worker while the first is still running it. The second worker gets
EffectInDoubt instead of running the call again, and a later caller gets the recorded result.
The secret has to be passed in, because a local secret file would be different on each machine.
The on-call commands take the address instead of a file: call4agents doubt postgresql://db-host/agents.
Put the password in PGPASSWORD, not in the address, because command lines can be seen by other users.
archive copies finished runs to a SQLite file and then removes them from the database. A crash between the
two steps leaves those runs in both places; nothing is lost.
You can rerun every number in this table. The tests use fake models, fake tools and real process kills.
| Case | LangGraph | Pydantic AI | OpenAI Agents SDK | How to check |
|---|---|---|---|---|
| Crash after the email, then resume: emails sent without the kit | 2 | 2 | 2 | call4agents quickstart |
| The same with Call4Agents | 1 | 1 | 1 | call4agents quickstart |
| With Call4Agents: timeout retry after the email | 1 | – | – | python -m tests.framework_kill_langgraph |
| With Call4Agents: model asked again after a restart (approved 10, now says 12) | refused | – | – | same |
| With Call4Agents: LangChain human-in-the-loop, saved state edited to 9999 → mallory | refused | – | – | same |
A dash means the case was not tested in that framework.
Proven by tests in this repository (tests/):
python -m tests.bigtest_c4a). 12 processes are killed at random while they charge
300 orders through a payment API that drops and delays answers. The result is 0 double charges.
Without the kit, the same run charges 244 of 300 orders twice or more;python -m tests.test_crash_matrix, 412 crashes): no double charge and no unapproved payment;call4agents crashtest finds the repeat in a small real agent on LangGraph, Pydantic AI and the OpenAI Agents
SDK, and finds none with the guard (python -m tests.test_crashtest --require-frameworks); with our old approval
bypass put back, its check fails;python -m tests.test_shared_record), with each machine played by a separate
process: 8 machines call one action at once and it runs once; a long action still running on one machine
is not started again by a second one; after a crash on one machine, the next raises EffectInDoubt;
a "yes" given on one machine counts on another, only for the same arguments and the same secret.Each guard is mutation-tested: when its bug is put back, its check fails. A no-op control is run
next to every batch of mutations. Rerun them with python -m tests.mutations.run_kit_mutations.
Not proven:
crashtest and scan on real production agents;EffectInDoubt unless you use reconcile=
or idempotent_for=. Most frameworks would simply run the action again.approve with the arguments the person saw.c4a.approve(...) before the replayed call).10 becomes 10.00), the call is treated as a new
action. Pass c4a_key= (for example, an order id) when that can happen. A key names one action: calling it again with arguments that mean something else raises KeyConflict
and nothing runs (numbers are compared by value, so 10, 10.0 and Decimal("10.00") are the same amount)..c4a/secret protects approvals from anyone who cannot read that file. It does not
protect them from someone who can (see docs/THREAT_MODEL.md).Apache-2.0. See LICENSE and NOTICE. Copyright 2026 Salah Eddine Makboul. "Call4Agents" is the project's name. The licence does not grant the right to use it for other products (Apache-2.0, section 6).