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.
- call4agents/__init__.py +322 -0
- call4agents/__main__.py +217 -0
- call4agents/adapters/__init__.py +3 -0
- call4agents/adapters/langgraph.py +61 -0
- call4agents/codec.py +114 -0
- call4agents/examples/__init__.py +2 -0
- call4agents/examples/crash_resume.py +138 -0
- call4agents/ledger.py +326 -0
- call4agents/ops.py +185 -0
- call4agents/proof.py +75 -0
- call4agents/quickstart.py +95 -0
- call4agents/replay.py +153 -0
- call4agents-0.1.0.dist-info/METADATA +265 -0
- call4agents-0.1.0.dist-info/RECORD +19 -0
- call4agents-0.1.0.dist-info/WHEEL +5 -0
- call4agents-0.1.0.dist-info/entry_points.txt +2 -0
- call4agents-0.1.0.dist-info/licenses/LICENSE +202 -0
- call4agents-0.1.0.dist-info/licenses/NOTICE +6 -0
- call4agents-0.1.0.dist-info/top_level.txt +1 -0
call4agents/__init__.py
ADDED
|
@@ -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
|
call4agents/__main__.py
ADDED
|
@@ -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,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)
|