call4agents 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,322 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 Salah Eddine Makboul
3
+ """Call4Agents v0.1 - once-only side effects and approvals bound to exact arguments,
4
+ for any Python agent. Standard library only.
5
+
6
+ import call4agents as c4a
7
+ c4a.init() # ledger at ./.c4a/ledger.sqlite
8
+
9
+ @c4a.effect(approval=True) # once-only + needs a "yes" for THESE arguments
10
+ def send_email(to, body): ...
11
+
12
+ with c4a.run("thread-42"): # the id your framework resumes with
13
+ send_email(to="bob", body="hi")
14
+ """
15
+
16
+ import contextlib
17
+ import contextvars
18
+ import functools
19
+ import inspect
20
+ import time
21
+ import warnings
22
+ from typing import Any, Callable, Dict, Optional
23
+
24
+ from call4agents import codec as _codec
25
+ from call4agents import replay as _replay
26
+ from call4agents.ledger import Ledger, canonical, effect_key, intent_key
27
+ from call4agents.replay import ReplayMismatch, ReplayMissing, diff, recorded # noqa: F401
28
+ from call4agents.proof import prove # noqa: F401 (module named proof: a 'prove' module would be hidden)
29
+
30
+ __all__ = ["init", "run", "effect", "approve", "deny", "current_ledger", "idempotency_key", "idempotency_headers", "recorded", "diff", "prove",
31
+ "ReplayMismatch", "ReplayMissing", "ApprovalRequired", "EffectInDoubt", "Ledger"]
32
+ __version__ = "0.1.0"
33
+
34
+ _LEDGER: Optional[Ledger] = None
35
+ _RUN: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar("c4a_run", default=None)
36
+
37
+
38
+ def current_run() -> str:
39
+ """The run id set by c4a.run(...). Outside a run this is an error, not a shared default run: a
40
+ thread pool or a callback that lost the context would otherwise mix runs silently."""
41
+ run_id = _RUN.get()
42
+ if run_id is None:
43
+ raise RuntimeError("no current run: call this inside `with c4a.run(<your thread/session id>):` "
44
+ "(new threads do not inherit it: use contextvars.copy_context().run or enter c4a.run there)")
45
+ return run_id
46
+
47
+
48
+ class ApprovalRequired(Exception):
49
+ """The effect needs a "yes" for exactly these arguments, and none matches."""
50
+
51
+ def __init__(self, tool: str, args: Dict[str, Any]) -> None:
52
+ super().__init__(f"{tool} needs approval for exactly {canonical(args)}")
53
+ self.tool, self.call_args = tool, args # not self.args: that is Exception's own message tuple
54
+
55
+
56
+ class EffectInDoubt(Exception):
57
+ """The intent was recorded but no result: the process died DURING the effect.
58
+ We cannot know if it happened, so we refuse to repeat it (no silent duplicate)."""
59
+
60
+ def __init__(self, tool: str, args: Dict[str, Any], why: str = "crash during the effect") -> None:
61
+ super().__init__(f"{tool}{canonical(args)} may already have run ({why}); "
62
+ "pass reconcile= to check the outside world, or resolve it by hand")
63
+ self.tool, self.call_args = tool, args
64
+
65
+
66
+ def init(path: str = ".c4a/ledger.sqlite", secret: Optional[bytes] = None,
67
+ replay_from: Optional[str] = None, old_secrets: Optional[list] = None,
68
+ allow_network_drive: bool = False) -> Ledger:
69
+ """replay_from: a recorded run record. Model calls and effects then come from it (offline).
70
+ old_secrets: secrets you rotated away from; approvals they signed still count (C4A_OLD_SECRETS works too)."""
71
+ global _LEDGER
72
+ _LEDGER = Ledger(path, secret, old_secrets=old_secrets, allow_network_drive=allow_network_drive)
73
+ _replay.set_source(replay_from)
74
+ return _LEDGER
75
+
76
+
77
+ def current_ledger() -> Ledger:
78
+ # Not named ledger(): that name would hide the call4agents.ledger module.
79
+ if _LEDGER is None:
80
+ raise RuntimeError("call c4a.init() first")
81
+ return _LEDGER
82
+
83
+
84
+ @contextlib.contextmanager
85
+ def run(run_id: str):
86
+ token = _RUN.set(run_id)
87
+ try:
88
+ yield
89
+ finally:
90
+ _RUN.reset(token)
91
+
92
+
93
+ def approve(tool: Any, **args: Any) -> str:
94
+ """Record a "yes" for this tool with exactly these arguments (in the current run)."""
95
+ name, args = _tool_and_args(tool, args)
96
+ return current_ledger().record_approval(current_run(), name, args)
97
+
98
+
99
+ def deny(tool: Any, **args: Any) -> str:
100
+ """Record a "no" for this tool with exactly these arguments: earlier "yes" answers no longer count."""
101
+ name, args = _tool_and_args(tool, args)
102
+ return current_ledger().record_denial(current_run(), name, args)
103
+
104
+
105
+ def _bound_args(fn: Callable, args: tuple, kwargs: dict) -> Dict[str, Any]:
106
+ """The call's arguments by name, defaults filled in. `self`/`cls` are left out: an object's address
107
+ changes per process, so it cannot be part of a key that must survive a restart."""
108
+ bound = inspect.signature(fn).bind(*args, **kwargs)
109
+ bound.apply_defaults()
110
+ out = dict(bound.arguments)
111
+ first = next(iter(inspect.signature(fn).parameters), None)
112
+ if first in ("self", "cls"):
113
+ out.pop(first, None)
114
+ return out
115
+
116
+
117
+ def _tool_name(f: Callable) -> str:
118
+ """Module + qualified name: `email.send` and `slack.send` are two different tools."""
119
+ return f"{getattr(f, '__module__', '?')}.{getattr(f, '__qualname__', getattr(f, '__name__', repr(f)))}"
120
+
121
+
122
+ def _tool_and_args(tool: Any, args: Dict[str, Any]):
123
+ if isinstance(tool, str):
124
+ return tool, args
125
+ name = getattr(tool, "c4a_name", None) or _tool_name(tool)
126
+ fn = getattr(tool, "c4a_fn", None)
127
+ if fn is not None: # bind like the call will: defaults filled in, so approve(pay, amount=10, to=x)
128
+ bound = inspect.signature(fn).bind_partial(**args) # matches pay(10, x) with currency="EUR" by default
129
+ bound.apply_defaults()
130
+ args = {k: v for k, v in bound.arguments.items() if k not in ("self", "cls")}
131
+ return name, args
132
+
133
+
134
+ _EFFECT_KEY: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar("c4a_effect_key", default=None)
135
+
136
+
137
+ def idempotency_key() -> str:
138
+ """Inside an @effect: the stable key of this effect - the same on every retry, resume or
139
+ re-run of the same (run, tool, arguments). Send it to the outside API."""
140
+ k = _EFFECT_KEY.get()
141
+ if k is None:
142
+ raise RuntimeError("idempotency_key() is only available inside a function decorated with @c4a.effect")
143
+ return k
144
+
145
+
146
+ def idempotency_headers(header: str = "Idempotency-Key") -> Dict[str, str]:
147
+ """{header: key} for HTTP APIs (Stripe/Adyen: Idempotency-Key; PayPal: PayPal-Request-Id)."""
148
+ return {header: idempotency_key()}
149
+
150
+
151
+ def effect(fn: Optional[Callable] = None, *, approval: bool = False, name: Optional[str] = None,
152
+ reconcile: Optional[Callable[..., Any]] = None, key: Optional[Callable[..., str]] = None,
153
+ wait: float = 2.0, idempotent_for: Optional[float] = None, retry_on: tuple = ()):
154
+ """Mark a function that changes the outside world.
155
+
156
+ - Once-only per (run, tool, exact arguments): a repeat returns the recorded result.
157
+ - Crash between intent and result: EffectInDoubt, unless reconcile(**args) returns the
158
+ result (the effect happened) or None (it did not; then it runs once).
159
+ - approval=True: runs only if c4a.approve(...) recorded a "yes" for these exact arguments.
160
+ One "yes" covers ONE action: a retry or resume of the same action reuses it, a deliberate
161
+ second action (new c4a_key) needs a new "yes". c4a.deny(...) cancels earlier "yes" answers,
162
+ also for an action that is in doubt. Callers racing with a single "yes": one runs, the
163
+ others get ApprovalRequired (nothing runs twice).
164
+ - Explicit key: pass c4a_key="..." at call time, or key=lambda **args: ... here.
165
+ A deliberate second identical action uses a new key. Default: same run + tool + arguments.
166
+ - Callers racing on the same effect: exactly one runs it; the others wait up to `wait`
167
+ seconds for its result, then raise EffectInDoubt.
168
+ - idempotent_for=SECONDS: the function passes c4a.idempotency_key() to an outside API
169
+ that removes duplicate requests with the same key, for that long (Stripe v1: 24 h). An
170
+ in-doubt effect is then retried with the SAME key instead of raising (each later call
171
+ within that time may resend it; the API, not luck, prevents the double charge). After that
172
+ time the key may be forgotten by the API, so the effect stays in doubt (a late retry could
173
+ charge twice).
174
+ - The function raises: the action may or may not have happened (a timeout after the charge
175
+ raises too), so the next call raises EffectInDoubt at once, naming the error. retry_on=
176
+ (SomeError, ...) lists errors that prove nothing happened: after those, the next call runs
177
+ the action again.
178
+ - The tool name is module + qualified name; for methods, `self` is not part of the key (pass
179
+ c4a_key= to tell two objects apart). Arguments without a stable text form raise TypeError.
180
+ """
181
+ def wrap(f: Callable) -> Callable:
182
+ if inspect.isgeneratorfunction(f) or inspect.isasyncgenfunction(f):
183
+ raise TypeError(f"@c4a.effect cannot wrap the generator {f.__qualname__}: its work would run after the "
184
+ "result is recorded. Do the side effect in a normal function and stream around it.")
185
+ if name is None and getattr(f, "__name__", "") == "<lambda>":
186
+ raise TypeError("@c4a.effect on a lambda needs name=...: two lambdas in one place would share one key")
187
+ tool = name or _tool_name(f)
188
+
189
+ def base_key(run_id: str, call_args: Dict[str, Any], explicit: Optional[str]) -> str:
190
+ if explicit is None and key is not None:
191
+ explicit = str(key(**call_args))
192
+ if explicit is not None:
193
+ return effect_key(run_id, tool, {"__c4a_key__": explicit})
194
+ return effect_key(run_id, tool, call_args)
195
+
196
+ def check(call_args: Dict[str, Any], explicit: Optional[str]) -> Dict[str, Any]:
197
+ lg, run_id = current_ledger(), current_run()
198
+ k = base_key(run_id, call_args, explicit)
199
+ if approval and _replay.SOURCE is not None:
200
+ if not lg.approved(run_id, tool, call_args):
201
+ raise ApprovalRequired(tool, call_args)
202
+ elif approval and lg.latest("tool_done", k) is None: # it may still run: it needs a live "yes"
203
+ spent = lg.latest("effect_approved", k)
204
+ if spent is not None:
205
+ # this effect already spent its "yes" (a retry or a resume): a later "no" still stops it
206
+ if lg.last_denial(run_id, tool, call_args) > spent["seq"]:
207
+ raise ApprovalRequired(tool, call_args)
208
+ elif not lg.use_approval(run_id, tool, call_args, k):
209
+ # no unused "yes" for exactly these arguments; one "yes" = one action (K17)
210
+ raise ApprovalRequired(tool, call_args)
211
+ if _replay.SOURCE is not None: # replay: never touch the outside world
212
+ done = _replay.SOURCE.done(k)
213
+ if done is None:
214
+ raise ReplayMissing(f"{tool}{canonical(call_args)} has no recorded result")
215
+ if lg.latest("tool_done", k) is None: # keep the replayed run comparable with diff()
216
+ lg.claim(run_id, intent_key(k, 1), {"tool": tool, "args": call_args, "replayed": True})
217
+ lg.append(run_id, "tool_done", k, {"result": done["result"], "replayed": True})
218
+ return {"replay": _codec.decode(done["result"])}
219
+ for _ in range(2):
220
+ done = lg.latest("tool_done", k)
221
+ if done is not None:
222
+ return {"replay": _codec.decode(done["payload"]["result"])}
223
+ attempt = lg.count("tool_abandoned", k) + lg.count("tool_retry_same_key", k) + 1
224
+ if lg.claim(run_id, intent_key(k, attempt), {"tool": tool, "args": call_args}):
225
+ return {"key": k, "run_id": run_id, "attempt": attempt}
226
+ err = lg.latest("tool_error", k)
227
+ failed = err is not None and err["payload"].get("attempt") == attempt
228
+ why = f"it raised {err['payload'].get('error')}" if failed else "crash during the effect"
229
+ deadline = time.monotonic() + (0 if failed else wait) # someone else may hold it: wait
230
+ while time.monotonic() < deadline:
231
+ done = lg.latest("tool_done", k)
232
+ if done is not None:
233
+ return {"replay": _codec.decode(done["payload"]["result"])}
234
+ time.sleep(0.02)
235
+ if idempotent_for is not None and reconcile is None:
236
+ first = lg.first_intent_time(k)
237
+ if first is not None and time.time() - first <= idempotent_for:
238
+ # The outside API still remembers this key: resending with the SAME key cannot
239
+ # create a second effect. Record the retry, then claim the next attempt.
240
+ lg.append(run_id, "tool_retry_same_key", k, {"attempt": attempt})
241
+ if lg.claim(run_id, intent_key(k, attempt + 1), {"tool": tool, "args": call_args,
242
+ "same_key_retry": True}):
243
+ return {"key": k, "run_id": run_id, "attempt": attempt + 1}
244
+ raise EffectInDoubt(tool, call_args, why)
245
+ if reconcile is None:
246
+ raise EffectInDoubt(tool, call_args, why)
247
+ found = reconcile(**call_args)
248
+ if found is not None:
249
+ lg.append(run_id, "tool_done", k, {"result": _codec.encode(found), "reconciled": True})
250
+ return {"replay": found}
251
+ lg.append(run_id, "tool_abandoned", k, {"attempt": attempt}) # it did not happen: one new try
252
+ raise EffectInDoubt(tool, call_args)
253
+
254
+ def record_result(plan: Dict[str, Any], result: Any) -> None:
255
+ """Store the result. If this attempt was given up while it ran (a person resolved it as
256
+ "not done", or reconcile said so) and a newer attempt exists, the effect may have happened
257
+ twice: say so loudly, record it, never hide it."""
258
+ lg = current_ledger()
259
+ try:
260
+ encoded = _codec.encode(result)
261
+ except TypeError:
262
+ lg.append(plan["run_id"], "tool_error", plan["key"], {"attempt": plan["attempt"],
263
+ "error": "result could not be recorded"})
264
+ raise
265
+ lg.append(plan["run_id"], "tool_done", plan["key"], {"result": encoded, "attempt": plan["attempt"]})
266
+ given_up = [e for e in lg.entries_for_key(plan["key"], "tool_abandoned") if e["payload"].get("attempt") == plan["attempt"]]
267
+ if given_up:
268
+ lg.append(plan["run_id"], "late_result", plan["key"], {"attempt": plan["attempt"]})
269
+ warnings.warn(f"{tool}: attempt {plan['attempt']} returned AFTER it was given up; "
270
+ "the action may have happened twice - check `call4agents doubt`", stacklevel=3)
271
+
272
+ def failed(plan: Dict[str, Any], exc: BaseException) -> None:
273
+ lg = current_ledger()
274
+ if retry_on and isinstance(exc, retry_on): # the caller says: this error means "did not happen"
275
+ lg.append(plan["run_id"], "tool_abandoned", plan["key"], {"attempt": plan["attempt"], "error": repr(exc)[:200]})
276
+ else:
277
+ lg.append(plan["run_id"], "tool_error", plan["key"], {"attempt": plan["attempt"], "error": repr(exc)[:200]})
278
+
279
+ if inspect.iscoroutinefunction(f):
280
+ @functools.wraps(f)
281
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
282
+ import asyncio
283
+ explicit = kwargs.pop("c4a_key", None)
284
+ call_args = _bound_args(f, args, kwargs)
285
+ # in a worker thread: waiting for another caller must not block the event loop
286
+ plan = await asyncio.to_thread(contextvars.copy_context().run, check, call_args, explicit)
287
+ if "replay" in plan:
288
+ return plan["replay"]
289
+ token = _EFFECT_KEY.set(plan["key"])
290
+ try:
291
+ result = await f(*args, **kwargs)
292
+ except BaseException as exc:
293
+ failed(plan, exc)
294
+ raise
295
+ finally:
296
+ _EFFECT_KEY.reset(token)
297
+ record_result(plan, result)
298
+ return result
299
+ async_wrapper.c4a_name, async_wrapper.c4a_fn = tool, f
300
+ return async_wrapper
301
+
302
+ @functools.wraps(f)
303
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
304
+ explicit = kwargs.pop("c4a_key", None)
305
+ call_args = _bound_args(f, args, kwargs)
306
+ plan = check(call_args, explicit)
307
+ if "replay" in plan:
308
+ return plan["replay"]
309
+ token = _EFFECT_KEY.set(plan["key"])
310
+ try:
311
+ result = f(*args, **kwargs)
312
+ except BaseException as exc:
313
+ failed(plan, exc)
314
+ raise
315
+ finally:
316
+ _EFFECT_KEY.reset(token)
317
+ record_result(plan, result)
318
+ return result
319
+ wrapper.c4a_name, wrapper.c4a_fn = tool, f
320
+ return wrapper
321
+
322
+ return wrap(fn) if fn is not None else wrap
@@ -0,0 +1,217 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 Salah Eddine Makboul
3
+ """python -m call4agents demo - offline, no keys, standard library only.
4
+
5
+ A tiny stand-in for an agent framework: it runs steps and saves a checkpoint AFTER each step
6
+ (like LangGraph). We kill the process right after the email is sent, before the checkpoint,
7
+ then resume in a new process.
8
+ """
9
+
10
+ import json
11
+ import shutil
12
+ import os
13
+ import subprocess
14
+ import sys
15
+ import tempfile
16
+ from pathlib import Path
17
+
18
+ STEPS = ["plan", "send_email", "reply"]
19
+
20
+
21
+ def framework_run(work: Path, use_ledger: bool, crash_at: str) -> None:
22
+ """One process of the stand-in framework. Resumes from work/checkpoint.json if present."""
23
+ import call4agents as c4a
24
+ ckpt = work / "checkpoint.json"
25
+ done = json.loads(ckpt.read_text())["done"] if ckpt.exists() else []
26
+ outbox = work / "outbox.txt"
27
+ c4a.init(str(work / ".c4a" / "ledger.sqlite"))
28
+
29
+ def send_email(to: str, body: str) -> str:
30
+ with open(outbox, "a") as f:
31
+ f.write(f"to={to} body={body}\n")
32
+ if crash_at == "during":
33
+ os._exit(17) # crash inside the tool, after the email left
34
+ return "sent"
35
+ if use_ledger:
36
+ send_email = c4a.effect(send_email)
37
+
38
+ with c4a.run("thread-1"):
39
+ for step in STEPS:
40
+ if step in done:
41
+ continue
42
+ if step == "send_email":
43
+ try:
44
+ send_email(to="bob", body="your invoice")
45
+ except c4a.EffectInDoubt:
46
+ print("IN_DOUBT") # refused to send again; a human or reconcile() decides
47
+ if crash_at == "after":
48
+ os._exit(17) # crash after the tool returned, before the checkpoint
49
+ done.append(step)
50
+ ckpt.write_text(json.dumps({"done": done}))
51
+
52
+
53
+ def approval_run(work: Path, phase: str) -> None:
54
+ import call4agents as c4a
55
+ c4a.init(str(work / ".c4a" / "ledger.sqlite"))
56
+ paid = work / "payments.txt"
57
+ pending = work / "pending_call.json"
58
+
59
+ @c4a.effect(approval=True)
60
+ def pay(amount: int, to: str) -> str:
61
+ with open(paid, "a") as f:
62
+ f.write(f"{amount} -> {to}\n")
63
+ return "paid"
64
+
65
+ with c4a.run("thread-2"):
66
+ if phase == "ask":
67
+ call = {"amount": 10, "to": "alice"}
68
+ pending.write_text(json.dumps(call)) # what the user is shown
69
+ c4a.approve(pay, **call) # the user says "yes" to exactly this
70
+ else:
71
+ call = json.loads(pending.read_text())
72
+ try:
73
+ pay(**call)
74
+ print("PAID", call)
75
+ except c4a.ApprovalRequired:
76
+ print("REFUSED", call)
77
+
78
+
79
+ def child(args):
80
+ work = Path(args[1])
81
+ if args[0] == "framework":
82
+ framework_run(work, args[2] == "1", args[3])
83
+ else:
84
+ approval_run(work, args[2])
85
+
86
+
87
+ def run_child(*args) -> subprocess.CompletedProcess:
88
+ return subprocess.run([sys.executable, "-m", "call4agents", "_child", *args], capture_output=True, text=True,
89
+ env={**os.environ, "PYTHONPATH": str(Path(__file__).resolve().parents[1])})
90
+
91
+
92
+ def count(path: Path) -> int:
93
+ return len(path.read_text().splitlines()) if path.exists() else 0
94
+
95
+
96
+ def demo() -> dict:
97
+ results = {}
98
+ for label, use_ledger, crash_at in (("without the run record", "0", "after"),
99
+ ("with the run record (crash after the tool)", "1", "after"),
100
+ ("with the run record (crash inside the tool)", "1", "during")):
101
+ work = Path(tempfile.mkdtemp(prefix="c4a-demo-"))
102
+ run_child("framework", str(work), use_ledger, crash_at) # dies after the email
103
+ resumed = run_child("framework", str(work), use_ledger, "none")
104
+ results[label] = {"emails": count(work / "outbox.txt"), "in_doubt": "IN_DOUBT" in resumed.stdout}
105
+ shutil.rmtree(work, ignore_errors=True)
106
+ work = Path(tempfile.mkdtemp(prefix="c4a-demo-"))
107
+ run_child("approval", str(work), "ask")
108
+ pending = work / "pending_call.json"
109
+ pending.write_text(json.dumps({"amount": 9999, "to": "mallory"})) # changed after the "yes"
110
+ changed = run_child("approval", str(work), "pay").stdout.strip()
111
+ pending.write_text(json.dumps({"amount": 10, "to": "alice"}))
112
+ original = run_child("approval", str(work), "pay").stdout.strip()
113
+ results["approval"] = {"changed_payment": changed, "approved_payment": original,
114
+ "payments": count(work / "payments.txt")}
115
+ shutil.rmtree(work, ignore_errors=True)
116
+ return results
117
+
118
+
119
+ def approval_ok(a: dict) -> bool:
120
+ """The changed call refused, the approved one paid, one payment. Checking only the count passed
121
+ with the approval binding removed."""
122
+ return (a["changed_payment"].startswith("REFUSED") and a["approved_payment"].startswith("PAID")
123
+ and a["payments"] == 1)
124
+
125
+
126
+ def doctor() -> str:
127
+ """Versions for a bug report. Printed only: nothing is sent anywhere, no secret value is shown."""
128
+ import platform
129
+ import sqlite3
130
+ from importlib import metadata
131
+
132
+ def ver(dist: str) -> str:
133
+ try:
134
+ return metadata.version(dist)
135
+ except metadata.PackageNotFoundError:
136
+ return "not installed"
137
+ import call4agents
138
+ lines = [
139
+ f"call4agents {ver('call4agents')} (module {call4agents.__version__})",
140
+ f"python {platform.python_version()} ({platform.python_implementation()})",
141
+ f"system {platform.system()} {platform.release()} {platform.machine()}",
142
+ f"sqlite {sqlite3.sqlite_version}",
143
+ ]
144
+ for dist in ("langgraph", "langgraph-checkpoint-sqlite", "langchain-core", "pydantic-ai", "openai-agents",
145
+ "openai", "pydantic", "mcp"):
146
+ lines.append(f"{dist:<28} {ver(dist)}")
147
+ lines.append(f"C4A_SECRET {'set' if os.environ.get('C4A_SECRET') else 'not set'} (value never shown)")
148
+ lines.append(f"C4A_OLD_SECRETS {'set' if os.environ.get('C4A_OLD_SECRETS') else 'not set'}")
149
+ return "\n".join(lines)
150
+
151
+
152
+ def selftest() -> int:
153
+ """The full offline checks from a repository checkout: kit, production, stress and idempotency suites."""
154
+ root = Path(__file__).resolve().parents[1]
155
+ if not (root / "tests" / "test_c4a_kit.py").exists() and (Path.cwd() / "tests" / "test_c4a_kit.py").exists():
156
+ root = Path.cwd() # installed command, run from a checkout
157
+ suites = ["tests.test_c4a_kit", "tests.test_c4a_production", "tests.stress_c4a_ledger", "tests.test_idempotency"]
158
+ if not (root / "tests" / "test_c4a_kit.py").exists():
159
+ print("selftest needs a repository checkout (the tests/ folder); running the demo checks only")
160
+ r = demo()
161
+ ok = [r[k]["emails"] for k in list(r)[:3]] == [2, 1, 1] and approval_ok(r["approval"])
162
+ print("demo checks pass" if ok else "DEMO CHECKS FAILED")
163
+ return 0 if ok else 1
164
+ failed = []
165
+ for s in suites:
166
+ p = subprocess.run([sys.executable, "-m", s], cwd=root, capture_output=True, text=True)
167
+ last = (p.stdout.strip().splitlines() or [""])[-1]
168
+ print(f" {'PASS' if p.returncode == 0 else 'FAIL'} {s:28} {last}")
169
+ if p.returncode:
170
+ failed.append(s)
171
+ print("selftest: all checks pass" if not failed else f"selftest: FAILED {failed}")
172
+ return 1 if failed else 0
173
+
174
+
175
+ def main() -> int:
176
+ if len(sys.argv) > 1 and sys.argv[1] == "_child":
177
+ child(sys.argv[2:])
178
+ return 0
179
+ if len(sys.argv) > 1 and sys.argv[1] == "quickstart":
180
+ from call4agents.quickstart import main as quickstart
181
+ return quickstart()
182
+ if len(sys.argv) > 1 and sys.argv[1] == "selftest":
183
+ return selftest()
184
+ if len(sys.argv) > 1 and sys.argv[1] == "doctor":
185
+ print(doctor())
186
+ print("\nPaste this into your issue. call4agents sent nothing; it never sends anything.")
187
+ return 0
188
+ if len(sys.argv) > 1 and sys.argv[1] in ("status", "doubt", "resolve", "archive", "verify"):
189
+ from call4agents.ops import main as ops_main
190
+ return ops_main(sys.argv[1:])
191
+ if len(sys.argv) > 2 and sys.argv[1] == "prove":
192
+ from call4agents.proof import main as prove_main
193
+ return prove_main(sys.argv[2])
194
+ if len(sys.argv) > 3 and sys.argv[1] == "diff":
195
+ from call4agents.replay import diff
196
+ print(json.dumps(diff(sys.argv[2], sys.argv[3], sys.argv[4] if len(sys.argv) > 4 else None), indent=1))
197
+ return 0
198
+ if len(sys.argv) < 2 or sys.argv[1] != "demo":
199
+ print("usage: python -m call4agents quickstart | demo | selftest | doctor | status|doubt|verify <record> | "
200
+ "resolve <record> <key> done|not-done [note] | archive <record> <archive> <days> | "
201
+ "prove <spec.json> | diff <a.sqlite> <b.sqlite> [run_id]")
202
+ return 2
203
+ r = demo()
204
+ print("Crash right after the email is sent, then resume:")
205
+ for label in list(r)[:3]:
206
+ extra = " (refused to resend: in doubt)" if r[label]["in_doubt"] else ""
207
+ print(f" {label:45} emails sent: {r[label]['emails']}{extra}")
208
+ a = r["approval"]
209
+ print('"yes" to pay(10, alice), then the stored call is changed to pay(9999, mallory):')
210
+ print(f" changed call -> {a['changed_payment']}")
211
+ print(f" approved call -> {a['approved_payment']}")
212
+ print(f" payments made: {a['payments']}")
213
+ return 0
214
+
215
+
216
+ if __name__ == "__main__":
217
+ sys.exit(main())
@@ -0,0 +1,3 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 Salah Eddine Makboul
3
+ """Framework adapters. Each one imports its framework lazily; the core needs none of them."""
@@ -0,0 +1,61 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 Salah Eddine Makboul
3
+ """LangGraph / LangChain adapter.
4
+
5
+ The run id is LangGraph's thread id, so a resumed thread finds its own effects and approvals.
6
+
7
+ from call4agents.adapters import langgraph as c4a_lg
8
+
9
+ @c4a_lg.effect(approval=True) # inside any node or tool
10
+ def pay(amount: int, to: str): ...
11
+
12
+ # when the human says yes to the interrupt, bind the yes to the arguments they SAW:
13
+ c4a_lg.approve(pay, config, amount=10, to="alice")
14
+ graph.invoke(Command(resume=...), config)
15
+ """
16
+
17
+ import functools
18
+ import inspect
19
+ from typing import Any, Callable, Dict, Optional
20
+
21
+ import call4agents as c4a
22
+
23
+
24
+ def thread_id(config: Optional[Dict[str, Any]] = None) -> str:
25
+ if config is None:
26
+ from langgraph.config import get_config # lazy: only when LangGraph is in use
27
+ config = get_config()
28
+ return str(config["configurable"]["thread_id"])
29
+
30
+
31
+ def effect(fn: Optional[Callable] = None, **options: Any):
32
+ """c4a.effect, with the run id taken from the current LangGraph thread."""
33
+ def wrap(f: Callable) -> Callable:
34
+ inner = c4a.effect(f, **options)
35
+ if inspect.iscoroutinefunction(f):
36
+ @functools.wraps(f)
37
+ async def acall(*args: Any, **kwargs: Any) -> Any:
38
+ with c4a.run(thread_id()): # await INSIDE the run, or the run id is lost
39
+ return await inner(*args, **kwargs)
40
+ acall.c4a_name, acall.c4a_fn = inner.c4a_name, f
41
+ return acall
42
+
43
+ @functools.wraps(f)
44
+ def call(*args: Any, **kwargs: Any) -> Any:
45
+ with c4a.run(thread_id()):
46
+ return inner(*args, **kwargs)
47
+ call.c4a_name, call.c4a_fn = inner.c4a_name, f
48
+ return call
49
+ return wrap(fn) if fn is not None else wrap
50
+
51
+
52
+ def approve(tool: Any, config: Dict[str, Any], **args: Any) -> str:
53
+ """Record the human's yes for exactly these arguments, in this thread."""
54
+ with c4a.run(thread_id(config)):
55
+ return c4a.approve(tool, **args)
56
+
57
+
58
+ def deny(tool: Any, config: Dict[str, Any], **args: Any) -> str:
59
+ """Record the human's no for exactly these arguments, in this thread (cancels an earlier yes)."""
60
+ with c4a.run(thread_id(config)):
61
+ return c4a.deny(tool, **args)