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,244 @@
1
+ """Record an agent framework's tool call as a CommitLayer action, before the tool runs.
2
+
3
+ The framework adapters (``commitlayer_langgraph``, ``commitlayer_crewai``, ``commitlayer_openai_agents``,
4
+ ``commitlayer_pydantic_ai``) all do the same thing at different hook points: when the agent is about to call a
5
+ tool whose name is mapped, post the intent as an action and let the call proceed. This module is that one
6
+ thing, with no framework imported, so the body a tool call produces can be asserted in a unit test.
7
+
8
+ The agent may declare its intent. It may read the verdict. It must never control the independent facts, the
9
+ governing contract, or the final evidence. Recording is fail-open by default: a verifier that is down must not
10
+ stop the business action, and the tool's own result is never sent anywhere.
11
+
12
+ Mapping
13
+ -------
14
+ ``tools`` maps a tool name to what to record for it, in the same shape the MCP recorder's mapping file uses::
15
+
16
+ {
17
+ "refund_payment": {
18
+ "contract_id": "refund_workflow",
19
+ "action_type": "refund_order",
20
+ "subject": "arguments.order_id",
21
+ "subject_kind": "order",
22
+ "params": {"amount": "arguments.amount", "currency": "arguments.currency"},
23
+ "action_id": "refund:{arguments.order_id}"
24
+ }
25
+ }
26
+
27
+ Paths (``subject``, every ``params`` value, ``compensates``) are dotted paths into the call: ``arguments.x.y``
28
+ reads the tool's arguments; a bare ``x`` is shorthand for ``arguments.x``; ``tool``, ``call_id`` and
29
+ ``run_id`` are the call's own fields. ``action_id`` is a template whose ``{...}`` placeholders are the same
30
+ paths; without one the id is ``{channel}:{tool}:{call_id}`` so a retried call updates the one record.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import logging
36
+ import re
37
+ import uuid
38
+ from collections.abc import Callable, Mapping, Sequence
39
+ from dataclasses import dataclass, field
40
+ from datetime import datetime, timezone
41
+ from typing import Any
42
+
43
+ log = logging.getLogger("commitlayer.integrations")
44
+
45
+ __all__ = ["ToolMapping", "ToolRecorder", "MappingError", "build_tool_action", "resolve_path"]
46
+
47
+
48
+ class MappingError(LookupError):
49
+ """A path or template could not be resolved against the tool call."""
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class ToolMapping:
54
+ contract_id: str
55
+ subject: str
56
+ params: Mapping[str, str] = field(default_factory=dict)
57
+ action_id: str | None = None
58
+ action_type: str | None = None
59
+ subject_kind: str | None = None
60
+ compensates: str | None = None
61
+ contract_version: int | None = None
62
+
63
+ @classmethod
64
+ def coerce(cls, value: ToolMapping | Mapping[str, Any]) -> ToolMapping:
65
+ if isinstance(value, ToolMapping):
66
+ return value
67
+ unknown = set(value) - set(cls.__dataclass_fields__)
68
+ if unknown:
69
+ raise MappingError(f"unknown mapping keys: {sorted(unknown)}")
70
+ if "contract_id" not in value or "subject" not in value:
71
+ raise MappingError("a tool mapping needs contract_id and subject")
72
+ return cls(**dict(value))
73
+
74
+
75
+ def _step(value: Any, key: str, path: str) -> Any:
76
+ if isinstance(value, Mapping):
77
+ if key in value:
78
+ return value[key]
79
+ raise MappingError(f"{path!r}: no key {key!r}")
80
+ if isinstance(value, Sequence) and not isinstance(value, str | bytes):
81
+ try:
82
+ return value[int(key)]
83
+ except (ValueError, IndexError) as e:
84
+ raise MappingError(f"{path!r}: no index {key!r}") from e
85
+ if hasattr(value, key):
86
+ return getattr(value, key)
87
+ raise MappingError(f"{path!r}: {type(value).__name__} has no attribute {key!r}")
88
+
89
+
90
+ def resolve_path(path: str, call: Mapping[str, Any]) -> Any:
91
+ """``arguments.x.y`` into the call; ``x.y`` means ``arguments.x.y``; ``tool``/``call_id``/``run_id`` are the
92
+ call's own fields."""
93
+ if not isinstance(path, str) or not path:
94
+ raise MappingError(f"path must be a non-empty string, got {path!r}")
95
+ parts = path.split(".")
96
+ if parts[0] not in ("arguments", "tool", "call_id", "run_id"):
97
+ parts = ["arguments", *parts]
98
+ value: Any = call
99
+ for key in parts:
100
+ value = _step(value, key, path)
101
+ return value
102
+
103
+
104
+ _PLACEHOLDER = re.compile(r"\{([^{}]+)\}")
105
+
106
+
107
+ def _now() -> str:
108
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") # noqa: UP017 - Python 3.10
109
+
110
+
111
+ def build_tool_action(
112
+ mapping: ToolMapping | Mapping[str, Any],
113
+ *,
114
+ tool: str,
115
+ arguments: Mapping[str, Any] | None,
116
+ call_id: str | None = None,
117
+ run_id: str | None = None,
118
+ channel: str = "agent",
119
+ agent_id: str | None = None,
120
+ workflow_id: str | None = None,
121
+ requested_at: str | None = None,
122
+ ) -> dict[str, Any]:
123
+ """The exact ``POST /v1/actions`` body for one tool call. Pure."""
124
+ m = ToolMapping.coerce(mapping)
125
+ call = {
126
+ "tool": tool,
127
+ "arguments": dict(arguments or {}),
128
+ "call_id": call_id or "",
129
+ "run_id": run_id or "",
130
+ }
131
+ subject = resolve_path(m.subject, call)
132
+ if subject is None or subject == "":
133
+ raise MappingError(f"subject path {m.subject!r} resolved to nothing")
134
+ params = {name: resolve_path(p, call) for name, p in m.params.items()}
135
+ if m.action_id:
136
+ action_id = _PLACEHOLDER.sub(lambda mm: str(resolve_path(mm.group(1), call)), m.action_id)
137
+ else:
138
+ action_id = f"{channel}:{tool}:{call_id or uuid.uuid4().hex}"
139
+ if not action_id or len(action_id) > 120:
140
+ raise MappingError(f"action_id {action_id!r} must be 1-120 characters")
141
+ body: dict[str, Any] = {
142
+ "action_id": action_id,
143
+ "contract_id": m.contract_id,
144
+ "subject_id": str(subject),
145
+ "requested_at": requested_at or _now(),
146
+ "params": {**params, "tool": tool, **({"tool_call_id": call_id} if call_id else {})},
147
+ "channel": channel,
148
+ }
149
+ for k, v in (
150
+ ("agent_id", agent_id),
151
+ ("workflow_id", workflow_id or (run_id or None)),
152
+ ("action_type", m.action_type),
153
+ ("subject_kind", m.subject_kind),
154
+ ("compensates", resolve_path(m.compensates, call) if m.compensates else None),
155
+ ("contract_version", m.contract_version),
156
+ ):
157
+ if v is not None:
158
+ body[k] = v
159
+ return body
160
+
161
+
162
+ class ToolRecorder:
163
+ """Records mapped tool calls through a :class:`commitlayer.Client`.
164
+
165
+ ``client`` is the SDK client holding a *recorder* key. ``tools`` is the mapping described in the module
166
+ docstring; a key with a server prefix (``stripe.refund_payment``) also matches the bare name. ``channel``
167
+ names the framework and is stored on every action. With ``fail_open`` (the default) a failure to record is
168
+ logged and the tool call goes ahead; with ``fail_open=False`` the exception reaches the caller, which
169
+ frameworks turn into a tool error. ``on_recorded`` receives the API's answer (``{action, verification,
170
+ allowance}``) for every recorded call.
171
+ """
172
+
173
+ def __init__(
174
+ self,
175
+ client: Any,
176
+ tools: Mapping[str, ToolMapping | Mapping[str, Any]],
177
+ *,
178
+ channel: str = "agent",
179
+ agent_id: str | None = None,
180
+ workflow_id: str | None = None,
181
+ fail_open: bool = True,
182
+ warn_unmapped: bool = False,
183
+ on_recorded: Callable[[dict[str, Any]], None] | None = None,
184
+ ):
185
+ self.client = client
186
+ self.tools = {name: ToolMapping.coerce(m) for name, m in tools.items()}
187
+ self.channel = channel
188
+ self.agent_id = agent_id
189
+ self.workflow_id = workflow_id
190
+ self.fail_open = fail_open
191
+ self.warn_unmapped = warn_unmapped
192
+ self.on_recorded = on_recorded
193
+ self.recorded: list[dict[str, Any]] = [] # the API's answers, newest last
194
+ self._warned: set[str] = set()
195
+
196
+ def mapping_for(self, tool: str) -> ToolMapping | None:
197
+ if tool in self.tools:
198
+ return self.tools[tool]
199
+ for name, m in self.tools.items():
200
+ if "." in name and name.rsplit(".", 1)[1] == tool:
201
+ return m
202
+ return None
203
+
204
+ def record(
205
+ self,
206
+ tool: str,
207
+ arguments: Mapping[str, Any] | None,
208
+ *,
209
+ call_id: str | None = None,
210
+ run_id: str | None = None,
211
+ ) -> dict[str, Any] | None:
212
+ """Record one tool call if its name is mapped. Returns the API's answer, or ``None`` when the tool is
213
+ unmapped or (fail-open) recording failed."""
214
+ m = self.mapping_for(tool)
215
+ if m is None:
216
+ if self.warn_unmapped and tool not in self._warned:
217
+ self._warned.add(tool)
218
+ log.warning("commitlayer: tool %r is not mapped; not recorded", tool)
219
+ return None
220
+ try:
221
+ body = build_tool_action(
222
+ m,
223
+ tool=tool,
224
+ arguments=arguments,
225
+ call_id=call_id,
226
+ run_id=run_id,
227
+ channel=self.channel,
228
+ agent_id=self.agent_id,
229
+ workflow_id=self.workflow_id,
230
+ )
231
+ out = self.client.request("POST", "/v1/actions", body)
232
+ except Exception as e: # noqa: BLE001 - the business action must not depend on the verifier
233
+ if not self.fail_open:
234
+ raise
235
+ log.warning("commitlayer: tool call %r not recorded: %s", tool, e)
236
+ return None
237
+ self.recorded.append(out)
238
+ if self.on_recorded:
239
+ self.on_recorded(out)
240
+ return out
241
+
242
+ def wait(self, action_id: str, *, timeout: float = 300.0) -> dict[str, Any]:
243
+ """The settled verification for a recorded action (``GET /v1/actions/{id}/wait``), through the SDK."""
244
+ return self.client.wait_for_settlement(action_id, timeout=timeout)
@@ -0,0 +1,119 @@
1
+ """The verdict a workflow waits for, reduced to what a completion gate needs.
2
+
3
+ ``GET /v1/actions/{ref}`` returns a full ``ActionVerification`` (lenses, checks, evidence, facts). A workflow
4
+ engine stores activity and step results in its history, so the integrations hand back a small, JSON-serialisable
5
+ summary instead: both verdicts, the settlement status, the reason codes and the headline. The evidence stays
6
+ on the CommitLayer side, where it belongs: the workflow reads the verdict, it never carries the proof.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import time
12
+ from collections.abc import Callable
13
+ from typing import Any, TypedDict
14
+
15
+
16
+ class Verdict(TypedDict):
17
+ action_id: str | None
18
+ contract_id: str | None
19
+ contract_version: int | None
20
+ status: str # "pending" | "settled" | "unverified"
21
+ settled: bool
22
+ outcome: str | None # "PASS" | "FAIL" | "UNKNOWN"
23
+ policy: str | None # "PASS" | "FAIL" | "UNKNOWN"
24
+ passed: bool # both lenses PASS and settled
25
+ reason_codes: dict[str, list[str]]
26
+ reason_labels: dict[str, list[str]]
27
+ headline: str | None
28
+ settles_at: str | None
29
+ verdict_hash: str | None
30
+ timed_out: bool
31
+
32
+
33
+ def _lens(lenses: Any, name: str) -> dict[str, Any]:
34
+ lens = lenses.get(name) if isinstance(lenses, dict) else None
35
+ return lens if isinstance(lens, dict) else {}
36
+
37
+
38
+ def summarize_verification(payload: dict[str, Any]) -> Verdict:
39
+ """Reduce an ``ActionVerification`` (or a bare ``Action``) to a :class:`Verdict`.
40
+
41
+ ``status`` is ``settled`` once the window has closed and every source has caught up, ``pending`` while the
42
+ verdict is provisional, and ``unverified`` when the action has never been verified (``latest`` is null).
43
+ ``passed`` is true only for a **settled** verdict that is PASS under both lenses; a provisional PASS is not
44
+ a completion signal.
45
+ """
46
+ action = payload.get("action") if isinstance(payload.get("action"), dict) else payload
47
+ lenses = payload.get("lenses") or {}
48
+ outcome, policy = _lens(lenses, "outcome"), _lens(lenses, "policy")
49
+ latest = action.get("latest") if isinstance(action.get("latest"), dict) else {}
50
+ outcome_verdict = outcome.get("verdict") or latest.get("outcome")
51
+ policy_verdict = policy.get("verdict") or latest.get("policy")
52
+ status = payload.get("status") or latest.get("status") or ("unverified" if not lenses else "pending")
53
+ settled = status == "settled"
54
+ contract = payload.get("contract") if isinstance(payload.get("contract"), dict) else {}
55
+ summary = payload.get("summary") if isinstance(payload.get("summary"), dict) else {}
56
+ return Verdict(
57
+ action_id=action.get("action_id"),
58
+ contract_id=action.get("contract_id") or contract.get("id"),
59
+ contract_version=action.get("contract_version") or contract.get("version"),
60
+ status=status,
61
+ settled=settled,
62
+ outcome=outcome_verdict,
63
+ policy=policy_verdict,
64
+ passed=bool(settled and outcome_verdict == "PASS" and policy_verdict == "PASS"),
65
+ reason_codes={
66
+ "outcome": list(outcome.get("reason_codes") or []),
67
+ "policy": list(policy.get("reason_codes") or []),
68
+ },
69
+ reason_labels={
70
+ "outcome": list(outcome.get("reason_labels") or []),
71
+ "policy": list(policy.get("reason_labels") or []),
72
+ },
73
+ headline=summary.get("headline"),
74
+ settles_at=payload.get("settles_at") or latest.get("settles_at"),
75
+ verdict_hash=policy.get("verdict_hash"),
76
+ timed_out=False,
77
+ )
78
+
79
+
80
+ def fetch_verification(client: Any, action_ref: str, hold_seconds: float = 0.0) -> dict[str, Any]:
81
+ """One read of the verification. With ``hold_seconds`` the API's completion gate
82
+ (``GET /v1/actions/{ref}/wait?timeout=``) holds the request until the verdict settles or that many seconds
83
+ pass (60 at most), so a poll returns the moment the last expected effect lands; without it, a plain
84
+ ``GET /v1/actions/{ref}``."""
85
+ hold = max(0, min(60, int(hold_seconds)))
86
+ if hold <= 0:
87
+ return client.get_action(action_ref)
88
+ return client.request("GET", f"/v1/actions/{action_ref}/wait?timeout={hold}")
89
+
90
+
91
+ def poll_until_settled(
92
+ fetch: Callable[[], dict[str, Any]],
93
+ *,
94
+ timeout_seconds: float,
95
+ poll_seconds: float = 5.0,
96
+ sleep: Callable[[float], None] = time.sleep,
97
+ clock: Callable[[], float] = time.monotonic,
98
+ on_poll: Callable[[Verdict], None] | None = None,
99
+ should_stop: Callable[[], bool] | None = None,
100
+ ) -> Verdict:
101
+ """Call ``fetch`` (``GET /v1/actions/{ref}``) until the verification settles or ``timeout_seconds`` pass.
102
+
103
+ Returns the last :class:`Verdict` seen, with ``timed_out`` set when the deadline passed first. ``on_poll``
104
+ runs after every fetch (a Temporal heartbeat, a log line); ``should_stop`` is checked before every sleep and
105
+ ends the wait early with ``timed_out: True`` (a cancellation request). Errors from ``fetch`` propagate: a
106
+ verifier that cannot be reached is not a verdict.
107
+ """
108
+ deadline = clock() + timeout_seconds
109
+ while True:
110
+ verdict = summarize_verification(fetch())
111
+ if on_poll:
112
+ on_poll(verdict)
113
+ if verdict["settled"]:
114
+ return verdict
115
+ remaining = deadline - clock()
116
+ if remaining <= 0 or (should_stop and should_stop()):
117
+ verdict["timed_out"] = True
118
+ return verdict
119
+ sleep(min(poll_seconds, remaining))
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.4
2
+ Name: commitlayer-integrations
3
+ Version: 0.1.0
4
+ Summary: CommitLayer integrations for agent frameworks and workflow runtimes: LangGraph, CrewAI, OpenAI Agents SDK, Pydantic AI, Temporal and Inngest
5
+ Author-email: "Asterfold Labs, Inc." <hello@commitlayer.ai>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://commitlayer.ai
8
+ Project-URL: Documentation, https://commitlayer.ai/docs
9
+ Project-URL: Changelog, https://commitlayer.ai/changelog
10
+ Project-URL: Source, https://github.com/kobikwelu/stateprf-svcs/tree/main/integrations/python
11
+ Keywords: ai-agents,verification,audit,mcp,observability
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Libraries
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: commitlayer>=0.1.0
24
+ Provides-Extra: temporal
25
+ Requires-Dist: temporalio>=1.5; extra == "temporal"
26
+ Provides-Extra: inngest
27
+ Requires-Dist: inngest>=0.4; extra == "inngest"
28
+ Provides-Extra: langgraph
29
+ Requires-Dist: langgraph>=1.0; extra == "langgraph"
30
+ Requires-Dist: langchain-core>=1.0; extra == "langgraph"
31
+ Provides-Extra: crewai
32
+ Requires-Dist: crewai>=1.0; extra == "crewai"
33
+ Provides-Extra: openai-agents
34
+ Requires-Dist: openai-agents>=0.4; extra == "openai-agents"
35
+ Provides-Extra: pydantic-ai
36
+ Requires-Dist: pydantic-ai-slim>=1.0; extra == "pydantic-ai"
37
+ Provides-Extra: dev
38
+ Requires-Dist: pytest>=8; extra == "dev"
39
+ Requires-Dist: ruff>=0.6; extra == "dev"
40
+ Requires-Dist: temporalio>=1.5; extra == "dev"
41
+ Requires-Dist: inngest>=0.4; extra == "dev"
42
+ Dynamic: license-file
43
+
44
+ # commitlayer-integrations (Python)
45
+
46
+ CommitLayer for agent frameworks and durable-workflow runtimes. One distribution, six optional packages, built on the
47
+ [`commitlayer` SDK](../../sdk/python/README.md):
48
+
49
+ | Package | Framework or runtime | Install |
50
+ |---|---|---|
51
+ | [`commitlayer_langgraph`](commitlayer_langgraph/README.md) | LangGraph, LangChain v1 agents | `pip install 'commitlayer-integrations[langgraph]'` |
52
+ | [`commitlayer_crewai`](commitlayer_crewai/README.md) | CrewAI | `pip install 'commitlayer-integrations[crewai]'` |
53
+ | [`commitlayer_openai_agents`](commitlayer_openai_agents/README.md) | OpenAI Agents SDK | `pip install 'commitlayer-integrations[openai-agents]'` |
54
+ | [`commitlayer_pydantic_ai`](commitlayer_pydantic_ai/README.md) | Pydantic AI | `pip install 'commitlayer-integrations[pydantic-ai]'` |
55
+ | [`commitlayer_temporal`](commitlayer_temporal/README.md) | Temporal (`temporalio`) | `pip install 'commitlayer-integrations[temporal]'` |
56
+ | [`commitlayer_inngest`](commitlayer_inngest/README.md) | Inngest (`inngest`) | `pip install 'commitlayer-integrations[inngest]'` |
57
+
58
+ The four framework packages share one mapping and one recorder (`commitlayer_integrations.tools.ToolRecorder`):
59
+ a tool name maps to a contract, the argument that holds the subject, and the arguments that are the action's
60
+ parameters, in the same shape the MCP recorder's mapping file uses. Each framework package is the hook at
61
+ which that recorder runs: a `wrap_tool_call` wrapper for LangGraph, a `before_tool_call` hook for CrewAI,
62
+ `RunHooks` for the OpenAI Agents SDK, a `WrapperToolset` for Pydantic AI. None of them imports its framework
63
+ at module load, so the tests run without any of them installed.
64
+
65
+ Each one does the same two things in its runtime's own terms:
66
+
67
+ 1. **Record the agent's intent before the work runs** (an activity, a step) as an action under a contract:
68
+ `POST /v1/actions` with the contract, subject and parameters taken from the call's own arguments. The
69
+ result of the work is never sent.
70
+ 2. **Gate completion on the verdict**: wait for `GET /v1/actions/{ref}` to report a *settled* verification and
71
+ branch on PASS / FAIL / UNKNOWN. The workflow does not tell the customer "done" until the verdict is PASS.
72
+
73
+ The workflow engine makes the process durable. CommitLayer independently verifies the result, from the
74
+ systems of record, after the fact. The agent may declare its intent. It may read the verdict. It must never
75
+ control the independent facts, the governing contract, or the final evidence.
76
+
77
+ Python 3.10+. Both packages import without their runtime SDK installed (the SDK is imported lazily), so a
78
+ service that uses only one of them does not need the other.
79
+
80
+ ```bash
81
+ pip install ./sdk/python ./integrations/python[temporal,inngest] # from this repository
82
+ export COMMITLAYER_API_KEY=spk_... # never log or print it
83
+ .venv/bin/python -m pytest integrations/python/tests -q # no Temporal server, no Inngest dev server
84
+ ```
@@ -0,0 +1,18 @@
1
+ commitlayer_crewai/__init__.py,sha256=2VGPEOM1FXhFp-dyNrDMwRFXtGZ7MH-6TIkxgqmAT_I,3389
2
+ commitlayer_inngest/__init__.py,sha256=d-qOYpcowojmr-oOdPIKGHofkp6eNSKXhwtysh91NT8,811
3
+ commitlayer_inngest/steps.py,sha256=Mq5gNTKr1TXjqWECOpGUuFDQDflPZf8-AEK3y9MA9QQ,12960
4
+ commitlayer_integrations/__init__.py,sha256=URPBvFvt7UCJP8jiZXpo4kfazwHAKRHhYOef_vhrqJA,381
5
+ commitlayer_integrations/tools.py,sha256=VVBkPFDQm4l25XrG2Bx58jthJ0Rb-SIgMibDOiVkGho,9690
6
+ commitlayer_integrations/verdict.py,sha256=2EESrr9xnQTVkfJPn8Ic3Gs8rTTwp5vk-zswq4Oi0sg,5445
7
+ commitlayer_integrations-0.1.0.dist-info/licenses/LICENSE,sha256=0vhmIhIFNhgH1c9Yg3fPGr0tYr606CAcOisVbgKTbb8,1077
8
+ commitlayer_langgraph/__init__.py,sha256=-A7mSMTX4smbIceNsSiEJGfn_91nmsGJQ8MRID1EgUg,5398
9
+ commitlayer_openai_agents/__init__.py,sha256=P5jKsEbv0Edem225WylZqS3jGrnYuJ-D8NeUpx974Jk,3042
10
+ commitlayer_pydantic_ai/__init__.py,sha256=wW7i5rXXIWqO50-cVbqAIHJe6_ZgYvlV0LH8_noP-Ak,2439
11
+ commitlayer_temporal/__init__.py,sha256=lHDjZcfiubPcWb21YtmKVJxfc-DIjTdbp2Sg6OCyY4U,1663
12
+ commitlayer_temporal/interceptor.py,sha256=TMdgtfSEQeMIGYUt4qcQRsxgMi8_N10Btx_rvbpQHmY,5420
13
+ commitlayer_temporal/mapping.py,sha256=wwhJP7QbYI4quIS29E7Xo9W25thV2C3J79W9tr0KpTI,7673
14
+ commitlayer_temporal/verifier.py,sha256=z_Bino7UdV56NKIEXDLQuzCEgX1geMLoaLbs8e886ZU,4785
15
+ commitlayer_integrations-0.1.0.dist-info/METADATA,sha256=ny0-llKqq6xjYGCb7Gw0Z_8CafKGwaN8qh8aiwp2vmg,4871
16
+ commitlayer_integrations-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
17
+ commitlayer_integrations-0.1.0.dist-info/top_level.txt,sha256=sM14akMmwD692F8a02p68hRtWXAm4IRO4d_P4zYWLyM,157
18
+ commitlayer_integrations-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Asterfold Labs, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,7 @@
1
+ commitlayer_crewai
2
+ commitlayer_inngest
3
+ commitlayer_integrations
4
+ commitlayer_langgraph
5
+ commitlayer_openai_agents
6
+ commitlayer_pydantic_ai
7
+ commitlayer_temporal
@@ -0,0 +1,111 @@
1
+ """CommitLayer for LangGraph and LangChain agents: record a tool call as an action before the tool runs.
2
+
3
+ Two entry points, both built on :class:`commitlayer_integrations.tools.ToolRecorder`:
4
+
5
+ * :func:`record_tool_calls` returns a ``wrap_tool_call`` function for ``langgraph.prebuilt.ToolNode`` and for
6
+ the LangChain v1 ``create_agent`` middleware list. It records the mapped call and then hands it to the
7
+ framework's handler unchanged; the tool's result is never sent to CommitLayer.
8
+ * :func:`verdict_gate_node` returns a graph node that waits for a recorded action's verdict to settle and
9
+ writes it into the state, so the graph branches on PASS / FAIL / UNKNOWN instead of on the tool's own word.
10
+
11
+ Nothing here imports LangGraph or LangChain: the wrapper and the node are plain callables with the shapes
12
+ those libraries call, so this module works with any version that keeps them, and can be unit-tested without
13
+ either installed.
14
+
15
+ from commitlayer import Client
16
+ from commitlayer_langgraph import record_tool_calls
17
+ from langgraph.prebuilt import ToolNode
18
+
19
+ recorder = ToolRecorder(Client(agent_id="support-bot"), MAPPING)
20
+ tools = ToolNode([refund_payment, close_ticket], wrap_tool_call=record_tool_calls(recorder))
21
+
22
+ # or, with LangChain v1 agents:
23
+ from langchain.agents import create_agent
24
+ agent = create_agent(model, tools=[...], middleware=[record_tool_calls(recorder)])
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from collections.abc import Callable, Mapping
30
+ from typing import Any
31
+
32
+ from commitlayer_integrations.tools import ToolRecorder
33
+
34
+ __all__ = ["record_tool_calls", "verdict_gate_node", "CHANNEL"]
35
+
36
+ CHANNEL = "langgraph"
37
+
38
+
39
+ def _call_fields(request: Any) -> tuple[str, dict[str, Any], str | None, str | None]:
40
+ """Tool name, arguments, tool call id and run id from a ``ToolCallRequest`` (or anything shaped like one)."""
41
+ tc = getattr(request, "tool_call", None) or {}
42
+ name = tc.get("name") if isinstance(tc, Mapping) else getattr(tc, "name", None)
43
+ args = tc.get("args") if isinstance(tc, Mapping) else getattr(tc, "args", None)
44
+ call_id = tc.get("id") if isinstance(tc, Mapping) else getattr(tc, "id", None)
45
+ run_id = None
46
+ runtime = getattr(request, "runtime", None)
47
+ config = getattr(runtime, "config", None) if runtime is not None else None
48
+ if isinstance(config, Mapping):
49
+ run_id = (config.get("metadata") or {}).get("thread_id") or (config.get("configurable") or {}).get(
50
+ "thread_id"
51
+ )
52
+ return str(name or ""), dict(args or {}), call_id, run_id
53
+
54
+
55
+ def record_tool_calls(recorder: ToolRecorder) -> Callable[[Any, Callable[[Any], Any]], Any]:
56
+ """A ``wrap_tool_call(request, handler)`` function that records the mapped call, then runs it.
57
+
58
+ Works as the ``wrap_tool_call`` argument of ``ToolNode`` and as an item of ``create_agent``'s
59
+ ``middleware`` list (LangChain accepts a bare function there through its ``@wrap_tool_call`` decorator;
60
+ pass ``langchain.agents.middleware.wrap_tool_call(record_tool_calls(recorder))`` on versions that require
61
+ the decorator). The recorder's ``channel`` is set to ``langgraph`` unless it already names something else.
62
+ """
63
+ if recorder.channel == "agent":
64
+ recorder.channel = CHANNEL
65
+
66
+ def wrap_tool_call(request: Any, handler: Callable[[Any], Any]) -> Any:
67
+ name, args, call_id, run_id = _call_fields(request)
68
+ if name:
69
+ recorder.record(name, args, call_id=call_id, run_id=run_id)
70
+ return handler(request)
71
+
72
+ wrap_tool_call.__name__ = "commitlayer_record_tool_calls"
73
+ return wrap_tool_call
74
+
75
+
76
+ def verdict_gate_node(
77
+ recorder: ToolRecorder,
78
+ *,
79
+ action_id: Callable[[Mapping[str, Any]], str | None] | str = "commitlayer_action_id",
80
+ key: str = "commitlayer_verdict",
81
+ timeout: float = 300.0,
82
+ ) -> Callable[[Mapping[str, Any]], dict[str, Any]]:
83
+ """A node that waits for a recorded action's verdict to settle and writes it into the state.
84
+
85
+ ``action_id`` is the state key holding the action id (default ``commitlayer_action_id``) or a callable
86
+ over the state returning it; when it is missing, the node uses the last action the recorder recorded.
87
+ The node returns ``{key: {"status", "outcome", "policy", "headline", "verification"}}``; a conditional
88
+ edge then routes PASS to the happy path and FAIL or UNKNOWN to recovery. A timeout is reported as
89
+ ``status: "pending"`` and is the graph's decision, never a success.
90
+ """
91
+
92
+ def node(state: Mapping[str, Any]) -> dict[str, Any]:
93
+ aid = action_id(state) if callable(action_id) else state.get(action_id)
94
+ if not aid and recorder.recorded:
95
+ aid = (recorder.recorded[-1].get("action") or {}).get("action_id")
96
+ if not aid:
97
+ return {key: {"status": "unrecorded", "outcome": None, "policy": None, "headline": None}}
98
+ v = recorder.wait(str(aid), timeout=timeout)
99
+ lenses = v.get("lenses") or {}
100
+ return {
101
+ key: {
102
+ "status": "pending" if v.get("timed_out") else v.get("status"),
103
+ "outcome": (lenses.get("outcome") or {}).get("verdict"),
104
+ "policy": (lenses.get("policy") or {}).get("verdict"),
105
+ "headline": (v.get("summary") or {}).get("headline"),
106
+ "verification": v,
107
+ }
108
+ }
109
+
110
+ node.__name__ = "commitlayer_verdict_gate"
111
+ return node