commitlayer-integrations 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,83 @@
1
+ """CommitLayer for CrewAI: record a crew's tool calls as actions before the tools run.
2
+
3
+ CrewAI exposes a ``before_tool_call`` hook that fires with the tool's name and its input before every tool
4
+ call in the process. :func:`install` registers a recording hook built on
5
+ :class:`commitlayer_integrations.tools.ToolRecorder`; :func:`uninstall` removes it. The hook never blocks a
6
+ call and never alters its input or result: it declares the intent, and CommitLayer verifies the outcome
7
+ later from the systems of record.
8
+
9
+ from commitlayer import Client
10
+ from commitlayer_integrations.tools import ToolRecorder
11
+ import commitlayer_crewai
12
+
13
+ recorder = ToolRecorder(Client(agent_id="ops-crew"), MAPPING)
14
+ commitlayer_crewai.install(recorder) # before the crew kicks off
15
+ crew.kickoff()
16
+ recorder.wait("refund:order_10231") # the completion gate, when the crew is done
17
+
18
+ ``crewai`` is imported only inside :func:`install`, so this module imports without it.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from collections.abc import Callable
24
+ from typing import Any
25
+
26
+ from commitlayer_integrations.tools import ToolRecorder
27
+
28
+ __all__ = ["install", "uninstall", "make_hook", "CHANNEL"]
29
+
30
+ CHANNEL = "crewai"
31
+
32
+
33
+ def make_hook(recorder: ToolRecorder) -> Callable[[Any], None]:
34
+ """The ``before_tool_call`` hook: records the call from the context's ``tool_name`` and ``tool_input`` and
35
+ returns ``None`` so the call proceeds. Exposed for tests and for callers who register hooks themselves."""
36
+ if recorder.channel == "agent":
37
+ recorder.channel = CHANNEL
38
+
39
+ def before_tool_call(ctx: Any) -> None:
40
+ name = getattr(ctx, "tool_name", None)
41
+ args = getattr(ctx, "tool_input", None)
42
+ if not name:
43
+ return None
44
+ task = getattr(ctx, "task", None)
45
+ run_id = str(getattr(task, "id", "") or "") or None
46
+ recorder.record(str(name), dict(args or {}), run_id=run_id)
47
+ return None # never block the call
48
+
49
+ before_tool_call.__name__ = "commitlayer_before_tool_call"
50
+ return before_tool_call
51
+
52
+
53
+ _installed: dict[int, Callable[[Any], None]] = {}
54
+
55
+
56
+ def install(
57
+ recorder: ToolRecorder, *, tools: list[str] | None = None, agents: list[str] | None = None
58
+ ) -> Callable[[Any], None]:
59
+ """Register the recording hook with CrewAI (``crewai.hooks.register_before_tool_call_hook``). ``tools`` and
60
+ ``agents`` narrow it to tool names or agent roles, as CrewAI's own decorator does; by default the hook sees
61
+ every call and the recorder's mapping decides what is recorded."""
62
+ from crewai.hooks import register_before_tool_call_hook # imported here: optional dependency
63
+
64
+ hook = make_hook(recorder)
65
+ if tools or agents:
66
+ # CrewAI's filtered registration goes through its decorator; apply it to the hook
67
+ from crewai.hooks import before_tool_call
68
+
69
+ hook = before_tool_call(tools=tools, agents=agents)(hook)
70
+ else:
71
+ register_before_tool_call_hook(hook)
72
+ _installed[id(recorder)] = hook
73
+ return hook
74
+
75
+
76
+ def uninstall(recorder: ToolRecorder) -> bool:
77
+ """Remove the hook :func:`install` registered for this recorder. Returns whether one was removed."""
78
+ hook = _installed.pop(id(recorder), None)
79
+ if hook is None:
80
+ return False
81
+ from crewai.hooks import unregister_before_tool_call_hook
82
+
83
+ return bool(unregister_before_tool_call_hook(hook))
@@ -0,0 +1,31 @@
1
+ """CommitLayer for Inngest: record a step as an action, and gate completion on the verdict.
2
+
3
+ Inngest makes the function durable. CommitLayer independently verifies the result.
4
+
5
+ The helpers are duck-typed over the Inngest ``ctx`` / ``step`` objects, so this package imports without the
6
+ ``inngest`` SDK; install ``commitlayer-integrations[inngest]`` for the SDK itself.
7
+ """
8
+
9
+ from .steps import (
10
+ CHANNEL,
11
+ DEFAULT_ACTION_ID,
12
+ CommitLayerSteps,
13
+ RecordSpec,
14
+ build_action_body,
15
+ recorded_step,
16
+ recorded_step_async,
17
+ wait_for_verdict,
18
+ wait_for_verdict_async,
19
+ )
20
+
21
+ __all__ = [
22
+ "CHANNEL",
23
+ "DEFAULT_ACTION_ID",
24
+ "CommitLayerSteps",
25
+ "RecordSpec",
26
+ "build_action_body",
27
+ "recorded_step",
28
+ "recorded_step_async",
29
+ "wait_for_verdict",
30
+ "wait_for_verdict_async",
31
+ ]
@@ -0,0 +1,354 @@
1
+ """Record an Inngest step as a CommitLayer action, and wait for its verdict as steps.
2
+
3
+ Nothing here imports ``inngest``: the helpers take the function's ``ctx`` (``inngest.Context`` or
4
+ ``inngest.ContextSync``: anything with ``.step`` and ``.run_id``) and call ``step.run`` / ``step.sleep`` on it,
5
+ so the package imports without the SDK and the tests run against fakes.
6
+
7
+ Why the action is recorded *inside* the step function: Inngest memoises each step, so a replayed function
8
+ invocation returns the stored output without running the step again, and the action is not re-recorded. If a
9
+ step is retried before it succeeded, the same ``action_id`` (``inngest:{run_id}:{step_id}``) makes the retries
10
+ one action, because the API is idempotent on it.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import inspect
17
+ import logging
18
+ import math
19
+ from collections.abc import Callable, Mapping
20
+ from dataclasses import dataclass, field
21
+ from datetime import datetime, timedelta, timezone
22
+ from typing import Any
23
+
24
+ from commitlayer import Client
25
+ from commitlayer_integrations.verdict import Verdict, fetch_verification, summarize_verification
26
+
27
+ __all__ = [
28
+ "CHANNEL",
29
+ "DEFAULT_ACTION_ID",
30
+ "CommitLayerSteps",
31
+ "RecordSpec",
32
+ "build_action_body",
33
+ "recorded_step",
34
+ "recorded_step_async",
35
+ "wait_for_verdict",
36
+ "wait_for_verdict_async",
37
+ ]
38
+
39
+ log = logging.getLogger("commitlayer.inngest")
40
+
41
+ CHANNEL = "inngest"
42
+ DEFAULT_ACTION_ID = "inngest:{run_id}:{step_id}"
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class RecordSpec:
47
+ """What to record for one step: the agent's declared intent, taken from the function's own inputs.
48
+
49
+ ``action_id`` is a template over ``{run_id}``, ``{step_id}``, ``{subject_id}`` and every param by name.
50
+ """
51
+
52
+ contract_id: str
53
+ subject_id: str
54
+ params: Mapping[str, Any] = field(default_factory=dict)
55
+ action_id: str | None = None
56
+ action_type: str | None = None
57
+ subject_kind: str | None = None
58
+ workflow_id: str | None = None
59
+ agent_id: str | None = None
60
+ compensates: str | None = None
61
+ contract_version: int | None = None
62
+
63
+ @classmethod
64
+ def coerce(cls, value: RecordSpec | Mapping[str, Any]) -> RecordSpec:
65
+ if isinstance(value, RecordSpec):
66
+ return value
67
+ unknown = set(value) - set(cls.__dataclass_fields__)
68
+ if unknown:
69
+ raise ValueError(f"unknown record keys: {sorted(unknown)}")
70
+ return cls(**dict(value))
71
+
72
+
73
+ class _Fields(dict):
74
+ def __missing__(self, key: str) -> str:
75
+ raise ValueError(f"action_id template: unknown placeholder {{{key}}}")
76
+
77
+
78
+ def _now() -> str:
79
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") # noqa: UP017 - Python 3.10
80
+
81
+
82
+ def build_action_body(
83
+ record: RecordSpec | Mapping[str, Any],
84
+ *,
85
+ run_id: str,
86
+ step_id: str,
87
+ agent_id: str | None = None,
88
+ workflow_id: str | None = None,
89
+ requested_at: str | None = None,
90
+ ) -> dict[str, Any]:
91
+ """The exact ``POST /v1/actions`` body for one step. Pure; asserted as-is in the tests.
92
+
93
+ ``workflow_id`` / ``agent_id`` are defaults; values on ``record`` win. ``params`` always carries
94
+ ``inngest_run_id`` so the action can be traced back to the run.
95
+ """
96
+ r = RecordSpec.coerce(record)
97
+ if not r.subject_id:
98
+ raise ValueError("record.subject_id is required")
99
+ params = dict(r.params)
100
+ fields = _Fields(params, run_id=run_id, step_id=step_id, subject_id=str(r.subject_id))
101
+ action_id = (r.action_id or DEFAULT_ACTION_ID).format_map(fields)
102
+ if not action_id or len(action_id) > 120:
103
+ raise ValueError(f"action_id {action_id!r} must be 1-120 characters")
104
+ body: dict[str, Any] = {
105
+ "action_id": action_id,
106
+ "contract_id": r.contract_id,
107
+ "subject_id": str(r.subject_id),
108
+ "requested_at": requested_at or _now(),
109
+ "params": {**params, "inngest_run_id": run_id},
110
+ "channel": CHANNEL,
111
+ }
112
+ optional = (
113
+ ("workflow_id", r.workflow_id or workflow_id),
114
+ ("agent_id", r.agent_id or agent_id),
115
+ ("action_type", r.action_type),
116
+ ("subject_kind", r.subject_kind),
117
+ ("compensates", r.compensates),
118
+ ("contract_version", r.contract_version),
119
+ )
120
+ for key, value in optional:
121
+ if value is not None:
122
+ body[key] = value
123
+ return body
124
+
125
+
126
+ def _split(ctx_or_step: Any, run_id: str | None) -> tuple[Any, str]:
127
+ """``(step, run_id)`` from an Inngest context, or from a bare step plus an explicit ``run_id``."""
128
+ if hasattr(ctx_or_step, "step") and hasattr(ctx_or_step, "run_id"):
129
+ return ctx_or_step.step, run_id or str(ctx_or_step.run_id)
130
+ if run_id is None:
131
+ raise ValueError("pass the Inngest ctx, or a step together with run_id=")
132
+ return ctx_or_step, run_id
133
+
134
+
135
+ def _record(
136
+ client: Client,
137
+ body: dict[str, Any],
138
+ *,
139
+ step_id: str,
140
+ fail_open: bool,
141
+ on_recorded: Callable[[dict[str, Any]], None] | None,
142
+ ) -> dict[str, Any] | None:
143
+ """``POST /v1/actions`` (the SDK's ``_call``, so ``contract_version`` is kept). Fail-open by default."""
144
+ try:
145
+ rec = client._call("POST", "/v1/actions", body)
146
+ except Exception as e: # noqa: BLE001 - the business step must not depend on the verifier
147
+ if not fail_open:
148
+ raise
149
+ log.warning("commitlayer: action not recorded for step %s: %s", step_id, e)
150
+ return None
151
+ if on_recorded:
152
+ on_recorded(rec)
153
+ return rec
154
+
155
+
156
+ def recorded_step(
157
+ ctx_or_step: Any,
158
+ step_id: str,
159
+ fn: Callable[..., Any],
160
+ *args: Any,
161
+ client: Client,
162
+ record: RecordSpec | Mapping[str, Any],
163
+ agent_id: str | None = None,
164
+ workflow_id: str | None = None,
165
+ fail_open: bool = True,
166
+ on_recorded: Callable[[dict[str, Any]], None] | None = None,
167
+ run_id: str | None = None,
168
+ ) -> Any:
169
+ """``step.run(step_id, fn, *args)`` for a sync Inngest function, with the action recorded first.
170
+
171
+ The record happens inside the step, before ``fn``, so the request time precedes every effect and a
172
+ memoised replay records nothing. ``fn``'s return value is the step's output, unchanged; it is never sent
173
+ to CommitLayer. With ``fail_open=False`` a recording failure raises inside the step (Inngest retries it).
174
+ """
175
+ step, rid = _split(ctx_or_step, run_id)
176
+ spec = RecordSpec.coerce(record)
177
+
178
+ def handler() -> Any:
179
+ body = build_action_body(
180
+ spec, run_id=rid, step_id=step_id, agent_id=agent_id, workflow_id=workflow_id
181
+ )
182
+ _record(client, body, step_id=step_id, fail_open=fail_open, on_recorded=on_recorded)
183
+ return fn(*args)
184
+
185
+ return step.run(step_id, handler)
186
+
187
+
188
+ async def recorded_step_async(
189
+ ctx_or_step: Any,
190
+ step_id: str,
191
+ fn: Callable[..., Any],
192
+ *args: Any,
193
+ client: Client,
194
+ record: RecordSpec | Mapping[str, Any],
195
+ agent_id: str | None = None,
196
+ workflow_id: str | None = None,
197
+ fail_open: bool = True,
198
+ on_recorded: Callable[[dict[str, Any]], None] | None = None,
199
+ run_id: str | None = None,
200
+ ) -> Any:
201
+ """:func:`recorded_step` for an async Inngest function; ``fn`` may be sync or async."""
202
+ step, rid = _split(ctx_or_step, run_id)
203
+ spec = RecordSpec.coerce(record)
204
+
205
+ async def handler() -> Any:
206
+ body = build_action_body(
207
+ spec, run_id=rid, step_id=step_id, agent_id=agent_id, workflow_id=workflow_id
208
+ )
209
+ await asyncio.get_running_loop().run_in_executor(
210
+ None,
211
+ lambda: _record(client, body, step_id=step_id, fail_open=fail_open, on_recorded=on_recorded),
212
+ )
213
+ out = fn(*args)
214
+ return await out if inspect.isawaitable(out) else out
215
+
216
+ return await step.run(step_id, handler)
217
+
218
+
219
+ def _poll_plan(timeout_seconds: float, poll_seconds: float) -> tuple[int, timedelta]:
220
+ if poll_seconds <= 0:
221
+ raise ValueError("poll_seconds must be positive")
222
+ return max(1, math.ceil(timeout_seconds / poll_seconds)), timedelta(seconds=poll_seconds)
223
+
224
+
225
+ def wait_for_verdict(
226
+ ctx_or_step: Any,
227
+ action_ref: str,
228
+ *,
229
+ client: Client,
230
+ timeout_seconds: float = 900.0,
231
+ poll_seconds: float = 15.0,
232
+ step_prefix: str | None = None,
233
+ ) -> Verdict:
234
+ """Wait, as Inngest steps, until the verification of ``action_ref`` settles or the timeout passes.
235
+
236
+ Each poll is its own ``step.run`` (memoised, so a replay never re-fetches a settled verdict) followed by a
237
+ ``step.sleep`` of ``poll_seconds`` that suspends the function run instead of holding a worker. The wait is
238
+ ``ceil(timeout_seconds / poll_seconds)`` polls, so the timeout is a lower bound: scheduler latency on each
239
+ resume adds to it. Returns a :class:`~commitlayer_integrations.verdict.Verdict`; ``passed`` is true only
240
+ for a settled PASS under both lenses, ``timed_out`` when the deadline passed while still pending.
241
+ """
242
+ step, _ = _split(ctx_or_step, run_id="")
243
+ prefix = step_prefix or f"commitlayer_verify:{action_ref}"
244
+ polls, pause = _poll_plan(timeout_seconds, poll_seconds)
245
+ verdict: Verdict | None = None
246
+ for i in range(polls):
247
+ verdict = step.run(
248
+ f"{prefix}:poll:{i}",
249
+ lambda: dict(summarize_verification(fetch_verification(client, action_ref, poll_seconds))),
250
+ )
251
+ if verdict["settled"]:
252
+ return verdict
253
+ if i < polls - 1:
254
+ step.sleep(f"{prefix}:sleep:{i}", pause)
255
+ assert verdict is not None
256
+ verdict["timed_out"] = True
257
+ return verdict
258
+
259
+
260
+ async def wait_for_verdict_async(
261
+ ctx_or_step: Any,
262
+ action_ref: str,
263
+ *,
264
+ client: Client,
265
+ timeout_seconds: float = 900.0,
266
+ poll_seconds: float = 15.0,
267
+ step_prefix: str | None = None,
268
+ ) -> Verdict:
269
+ """:func:`wait_for_verdict` for an async Inngest function."""
270
+ step, _ = _split(ctx_or_step, run_id="")
271
+ prefix = step_prefix or f"commitlayer_verify:{action_ref}"
272
+ polls, pause = _poll_plan(timeout_seconds, poll_seconds)
273
+
274
+ async def fetch() -> dict[str, Any]:
275
+ payload = await asyncio.get_running_loop().run_in_executor(
276
+ None, fetch_verification, client, action_ref, poll_seconds
277
+ )
278
+ return dict(summarize_verification(payload))
279
+
280
+ verdict: Verdict | None = None
281
+ for i in range(polls):
282
+ verdict = await step.run(f"{prefix}:poll:{i}", fetch)
283
+ if verdict["settled"]:
284
+ return verdict
285
+ if i < polls - 1:
286
+ await step.sleep(f"{prefix}:sleep:{i}", pause)
287
+ assert verdict is not None
288
+ verdict["timed_out"] = True
289
+ return verdict
290
+
291
+
292
+ class CommitLayerSteps:
293
+ """A client plus defaults, bound once per Inngest function.
294
+
295
+ ``fn_id`` is the Inngest function id and becomes the default ``workflow_id`` (the SDK's ``ctx`` does not
296
+ carry it, so it is given here). ``run``/``run_async`` wrap :func:`recorded_step`;
297
+ ``wait_for_verdict``/``wait_for_verdict_async`` wrap the verdict wait.
298
+ """
299
+
300
+ def __init__(
301
+ self,
302
+ client: Client,
303
+ *,
304
+ fn_id: str | None = None,
305
+ agent_id: str | None = None,
306
+ fail_open: bool = True,
307
+ default_timeout_seconds: float = 900.0,
308
+ poll_seconds: float = 15.0,
309
+ on_recorded: Callable[[dict[str, Any]], None] | None = None,
310
+ ) -> None:
311
+ self.client = client
312
+ self.fn_id = fn_id
313
+ self.agent_id = agent_id or client.agent_id
314
+ self.fail_open = fail_open
315
+ self.default_timeout_seconds = default_timeout_seconds
316
+ self.poll_seconds = poll_seconds
317
+ self.on_recorded = on_recorded
318
+
319
+ def _kw(self) -> dict[str, Any]:
320
+ return {
321
+ "client": self.client,
322
+ "agent_id": self.agent_id,
323
+ "workflow_id": self.fn_id,
324
+ "fail_open": self.fail_open,
325
+ "on_recorded": self.on_recorded,
326
+ }
327
+
328
+ def run(self, ctx: Any, step_id: str, fn: Callable[..., Any], *args: Any, **record: Any) -> Any:
329
+ return recorded_step(ctx, step_id, fn, *args, record=record, **self._kw())
330
+
331
+ async def run_async(
332
+ self, ctx: Any, step_id: str, fn: Callable[..., Any], *args: Any, **record: Any
333
+ ) -> Any:
334
+ return await recorded_step_async(ctx, step_id, fn, *args, record=record, **self._kw())
335
+
336
+ def wait_for_verdict(self, ctx: Any, action_ref: str, *, timeout_seconds: float | None = None) -> Verdict:
337
+ return wait_for_verdict(
338
+ ctx,
339
+ action_ref,
340
+ client=self.client,
341
+ timeout_seconds=self.default_timeout_seconds if timeout_seconds is None else timeout_seconds,
342
+ poll_seconds=self.poll_seconds,
343
+ )
344
+
345
+ async def wait_for_verdict_async(
346
+ self, ctx: Any, action_ref: str, *, timeout_seconds: float | None = None
347
+ ) -> Verdict:
348
+ return await wait_for_verdict_async(
349
+ ctx,
350
+ action_ref,
351
+ client=self.client,
352
+ timeout_seconds=self.default_timeout_seconds if timeout_seconds is None else timeout_seconds,
353
+ poll_seconds=self.poll_seconds,
354
+ )
@@ -0,0 +1,9 @@
1
+ """Code shared by the CommitLayer runtime integrations (``commitlayer_temporal``, ``commitlayer_inngest``).
2
+
3
+ Nothing here imports a workflow SDK; it only shapes what the CommitLayer API returns.
4
+ """
5
+
6
+ from .verdict import Verdict, poll_until_settled, summarize_verification
7
+
8
+ __all__ = ["Verdict", "poll_until_settled", "summarize_verification", "__version__"]
9
+ __version__ = "0.1.0"