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.
- commitlayer_crewai/__init__.py +83 -0
- commitlayer_inngest/__init__.py +31 -0
- commitlayer_inngest/steps.py +354 -0
- commitlayer_integrations/__init__.py +9 -0
- commitlayer_integrations/tools.py +244 -0
- commitlayer_integrations/verdict.py +119 -0
- commitlayer_integrations-0.1.0.dist-info/METADATA +84 -0
- commitlayer_integrations-0.1.0.dist-info/RECORD +18 -0
- commitlayer_integrations-0.1.0.dist-info/WHEEL +5 -0
- commitlayer_integrations-0.1.0.dist-info/licenses/LICENSE +21 -0
- commitlayer_integrations-0.1.0.dist-info/top_level.txt +7 -0
- commitlayer_langgraph/__init__.py +111 -0
- commitlayer_openai_agents/__init__.py +71 -0
- commitlayer_pydantic_ai/__init__.py +57 -0
- commitlayer_temporal/__init__.py +61 -0
- commitlayer_temporal/interceptor.py +127 -0
- commitlayer_temporal/mapping.py +207 -0
- commitlayer_temporal/verifier.py +108 -0
|
@@ -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,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,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
|