agenticrail 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.
- agenticrail/__init__.py +6 -0
- agenticrail/client.py +184 -0
- agenticrail/integrations/__init__.py +3 -0
- agenticrail/integrations/crewai.py +141 -0
- agenticrail/integrations/langgraph.py +115 -0
- agenticrail-0.1.0.dist-info/METADATA +304 -0
- agenticrail-0.1.0.dist-info/RECORD +8 -0
- agenticrail-0.1.0.dist-info/WHEEL +4 -0
agenticrail/__init__.py
ADDED
agenticrail/client.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""AgenticRail gate client — core HTTP implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import List, Optional
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RailDenied(Exception):
|
|
14
|
+
"""Raised when the gate returns DENY or HALT."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, decision: str, reasons: List[str], pack_id: str, step: str):
|
|
17
|
+
self.decision = decision
|
|
18
|
+
self.reasons = reasons
|
|
19
|
+
self.pack_id = pack_id
|
|
20
|
+
self.step = step
|
|
21
|
+
super().__init__(f"{decision} at '{step}': {reasons}")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class RailDecision:
|
|
26
|
+
decision: str # ALLOW | DENY | HALT
|
|
27
|
+
pack_id: str
|
|
28
|
+
reasons: List[str]
|
|
29
|
+
executed: bool
|
|
30
|
+
sequence_id: str
|
|
31
|
+
step: str
|
|
32
|
+
receipt: dict = field(default_factory=dict)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def allowed(self) -> bool:
|
|
36
|
+
return self.decision == "ALLOW"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class RailSequence:
|
|
40
|
+
"""
|
|
41
|
+
Stateful sequence helper. Tracks step_order and sends it only on the first
|
|
42
|
+
call — removes boilerplate from multi-step workflows.
|
|
43
|
+
|
|
44
|
+
Usage:
|
|
45
|
+
seq = client.sequence("my-seq-001", step_order=["step_a", "step_b", "step_c"])
|
|
46
|
+
seq.next("step_a")
|
|
47
|
+
seq.next("step_b")
|
|
48
|
+
seq.next("step_c") # seals sequence
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, client: "RailClient", sequence_id: str, step_order: List[str]):
|
|
52
|
+
self._client = client
|
|
53
|
+
self.sequence_id = sequence_id
|
|
54
|
+
self.step_order = step_order
|
|
55
|
+
|
|
56
|
+
def next(
|
|
57
|
+
self,
|
|
58
|
+
step: str,
|
|
59
|
+
*,
|
|
60
|
+
action_type: str = "CHECK_STATE",
|
|
61
|
+
action: Optional[str] = None,
|
|
62
|
+
inputs: Optional[dict] = None,
|
|
63
|
+
raise_on_deny: bool = True,
|
|
64
|
+
) -> RailDecision:
|
|
65
|
+
# step_order must be sent on every call — the gate reads it from each
|
|
66
|
+
# payload to resolve the step index; it does not persist it server-side.
|
|
67
|
+
return self._client.evaluate(
|
|
68
|
+
self.sequence_id,
|
|
69
|
+
step,
|
|
70
|
+
action_type=action_type,
|
|
71
|
+
action=action,
|
|
72
|
+
inputs=inputs,
|
|
73
|
+
step_order=self.step_order,
|
|
74
|
+
raise_on_deny=raise_on_deny,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class RailClient:
|
|
79
|
+
"""
|
|
80
|
+
Thin client for the AgenticRail gate API.
|
|
81
|
+
|
|
82
|
+
Usage:
|
|
83
|
+
client = RailClient(api_key="DEMO-AGENTICRAIL-PUBLIC-2026")
|
|
84
|
+
|
|
85
|
+
# Direct calls — manage step_order yourself
|
|
86
|
+
client.evaluate("seq-001", "verify_identity", step_order=[...])
|
|
87
|
+
client.evaluate("seq-001", "assess_risk")
|
|
88
|
+
|
|
89
|
+
# Or use RailSequence helper
|
|
90
|
+
seq = client.sequence("seq-001", step_order=["verify_identity", "assess_risk"])
|
|
91
|
+
seq.next("verify_identity")
|
|
92
|
+
seq.next("assess_risk")
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
DEFAULT_BASE_URL = "https://api.agenticrail.nz/v1/evaluate"
|
|
96
|
+
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
api_key: str,
|
|
100
|
+
*,
|
|
101
|
+
model_id: str = "agent",
|
|
102
|
+
base_url: Optional[str] = None,
|
|
103
|
+
timeout: int = 10,
|
|
104
|
+
):
|
|
105
|
+
self.api_key = api_key
|
|
106
|
+
self.model_id = model_id
|
|
107
|
+
self.base_url = (base_url or self.DEFAULT_BASE_URL).rstrip("/")
|
|
108
|
+
self.timeout = timeout
|
|
109
|
+
|
|
110
|
+
def evaluate(
|
|
111
|
+
self,
|
|
112
|
+
sequence_id: str,
|
|
113
|
+
step: str,
|
|
114
|
+
*,
|
|
115
|
+
action_type: str = "CHECK_STATE",
|
|
116
|
+
action: Optional[str] = None,
|
|
117
|
+
inputs: Optional[dict] = None,
|
|
118
|
+
step_order: Optional[List[str]] = None,
|
|
119
|
+
raise_on_deny: bool = True,
|
|
120
|
+
) -> RailDecision:
|
|
121
|
+
"""
|
|
122
|
+
Evaluate one step of a sequence against the AgenticRail gate.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
sequence_id: Unique identifier for this agent run.
|
|
126
|
+
step: Step name (must equal function; must match step_order).
|
|
127
|
+
action_type: One of CHECK_STATE, VALIDATE_INPUT, RECORD_RESULT, etc.
|
|
128
|
+
action: Human-readable action label (defaults to step name).
|
|
129
|
+
inputs: Arbitrary metadata attached to this step.
|
|
130
|
+
step_order: Full ordered list of step names — send only on the first step.
|
|
131
|
+
raise_on_deny: If True (default), raise RailDenied on DENY or HALT.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
RailDecision with decision, pack_id, receipt, and reasons.
|
|
135
|
+
|
|
136
|
+
Raises:
|
|
137
|
+
RailDenied: If decision is DENY or HALT and raise_on_deny is True.
|
|
138
|
+
requests.HTTPError: On non-2xx HTTP responses.
|
|
139
|
+
"""
|
|
140
|
+
payload: dict = {
|
|
141
|
+
"schema_version": "1.0",
|
|
142
|
+
"model_id": self.model_id,
|
|
143
|
+
"sequence_id": sequence_id,
|
|
144
|
+
"step": step,
|
|
145
|
+
"function": step,
|
|
146
|
+
"action_type": action_type,
|
|
147
|
+
"action": action if action is not None else step,
|
|
148
|
+
"inputs": inputs or {},
|
|
149
|
+
"nonce": str(uuid.uuid4()),
|
|
150
|
+
"ts_ms": int(time.time() * 1000),
|
|
151
|
+
}
|
|
152
|
+
if step_order is not None:
|
|
153
|
+
payload["step_order"] = step_order
|
|
154
|
+
|
|
155
|
+
resp = requests.post(
|
|
156
|
+
self.base_url,
|
|
157
|
+
json=payload,
|
|
158
|
+
headers={
|
|
159
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
160
|
+
"Content-Type": "application/json",
|
|
161
|
+
},
|
|
162
|
+
timeout=self.timeout,
|
|
163
|
+
)
|
|
164
|
+
resp.raise_for_status()
|
|
165
|
+
data = resp.json()
|
|
166
|
+
|
|
167
|
+
decision = RailDecision(
|
|
168
|
+
decision=data["decision"],
|
|
169
|
+
pack_id=data.get("pack_id", ""),
|
|
170
|
+
reasons=data.get("reasons", []),
|
|
171
|
+
executed=data.get("executed", False),
|
|
172
|
+
sequence_id=data.get("sequence_id", sequence_id),
|
|
173
|
+
step=data.get("step", step),
|
|
174
|
+
receipt=data.get("receipt", {}),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
if raise_on_deny and not decision.allowed:
|
|
178
|
+
raise RailDenied(decision.decision, decision.reasons, decision.pack_id, step)
|
|
179
|
+
|
|
180
|
+
return decision
|
|
181
|
+
|
|
182
|
+
def sequence(self, sequence_id: str, step_order: List[str]) -> RailSequence:
|
|
183
|
+
"""Create a stateful sequence helper that manages step_order automatically."""
|
|
184
|
+
return RailSequence(self, sequence_id, step_order)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""CrewAI integration for AgenticRail."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from typing import Any, List, Optional
|
|
7
|
+
|
|
8
|
+
from agenticrail.client import RailClient, RailDenied
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CrewRailGuard:
|
|
12
|
+
"""
|
|
13
|
+
Guards a CrewAI Crew by calling the AgenticRail gate before each task executes.
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
from agenticrail import RailClient
|
|
17
|
+
from agenticrail.integrations.crewai import CrewRailGuard
|
|
18
|
+
|
|
19
|
+
client = RailClient(api_key="YOUR_KEY")
|
|
20
|
+
guard = CrewRailGuard(
|
|
21
|
+
client,
|
|
22
|
+
step_order=["research_task", "analysis_task", "write_task"],
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
crew = Crew(
|
|
26
|
+
agents=[researcher, analyst, writer],
|
|
27
|
+
tasks=[research_task, analysis_task, write_task],
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# Call guard.kickoff() instead of crew.kickoff()
|
|
31
|
+
result = guard.kickoff(crew, inputs={"topic": "EU AI Act enforcement"})
|
|
32
|
+
|
|
33
|
+
How it works:
|
|
34
|
+
1. guard.kickoff() gates step[0] before crew starts. Raises RailDenied on DENY.
|
|
35
|
+
2. A task_callback is injected into the crew. After each task completes, the
|
|
36
|
+
callback gates the NEXT step before CrewAI moves to it. If the gate denies,
|
|
37
|
+
a flag is set and a RailDenied is raised after kickoff returns.
|
|
38
|
+
3. The sequence is sealed when all tasks complete (the step_order's last step
|
|
39
|
+
is evaluated at the start of the last task).
|
|
40
|
+
|
|
41
|
+
Note on timing:
|
|
42
|
+
CrewAI's task_callback fires synchronously between tasks. However, if CrewAI
|
|
43
|
+
swallows the exception from task_callback, the denied task may still execute.
|
|
44
|
+
In that case, guard.kickoff() raises RailDenied after crew.kickoff() returns,
|
|
45
|
+
preserving the gate decision in the audit trail even if execution wasn't blocked.
|
|
46
|
+
|
|
47
|
+
For hard blocking, wrap task execution directly via guard.gate() before each
|
|
48
|
+
task in a custom orchestration loop (see examples/crewai_example.py).
|
|
49
|
+
|
|
50
|
+
New sequence per run:
|
|
51
|
+
Each call to guard.kickoff() generates a fresh sequence_id unless you pass
|
|
52
|
+
sequence_id= explicitly. This ensures each crew run is independently tracked.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
client: RailClient,
|
|
58
|
+
step_order: List[str],
|
|
59
|
+
*,
|
|
60
|
+
sequence_id: Optional[str] = None,
|
|
61
|
+
action_type: str = "CHECK_STATE",
|
|
62
|
+
):
|
|
63
|
+
self._client = client
|
|
64
|
+
self.step_order = step_order
|
|
65
|
+
self._action_type = action_type
|
|
66
|
+
self._fixed_sequence_id = sequence_id
|
|
67
|
+
# Runtime state (reset on each kickoff)
|
|
68
|
+
self._sequence_id: str = ""
|
|
69
|
+
self._step_idx: int = 0
|
|
70
|
+
self._denied: Optional[RailDenied] = None
|
|
71
|
+
|
|
72
|
+
def _gate(self, step_name: str) -> None:
|
|
73
|
+
# step_order sent on every call — gate reads it from each payload
|
|
74
|
+
self._client.evaluate(
|
|
75
|
+
self._sequence_id,
|
|
76
|
+
step_name,
|
|
77
|
+
action_type=self._action_type,
|
|
78
|
+
action=f"execute {step_name}",
|
|
79
|
+
step_order=self.step_order,
|
|
80
|
+
raise_on_deny=True,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def gate(self, step_name: str) -> None:
|
|
84
|
+
"""
|
|
85
|
+
Explicitly gate a single step. Use this for custom orchestration loops
|
|
86
|
+
where you control task execution directly.
|
|
87
|
+
"""
|
|
88
|
+
if not self._sequence_id:
|
|
89
|
+
self._sequence_id = self._fixed_sequence_id or str(uuid.uuid4())
|
|
90
|
+
self._gate(step_name)
|
|
91
|
+
self._step_idx += 1
|
|
92
|
+
|
|
93
|
+
def reset(self, sequence_id: Optional[str] = None) -> None:
|
|
94
|
+
"""Reset guard state for a new crew run."""
|
|
95
|
+
self._sequence_id = sequence_id or self._fixed_sequence_id or str(uuid.uuid4())
|
|
96
|
+
self._step_idx = 0
|
|
97
|
+
self._denied = None
|
|
98
|
+
|
|
99
|
+
def kickoff(self, crew: Any, **kwargs: Any) -> Any:
|
|
100
|
+
"""
|
|
101
|
+
Gate-enabled replacement for crew.kickoff().
|
|
102
|
+
|
|
103
|
+
Gates step[0] before the crew starts. Injects a task_callback that gates
|
|
104
|
+
each subsequent step between tasks. Raises RailDenied if any step is denied.
|
|
105
|
+
"""
|
|
106
|
+
self.reset()
|
|
107
|
+
|
|
108
|
+
# Gate the first step — raises immediately on DENY/HALT
|
|
109
|
+
first_step = self.step_order[self._step_idx]
|
|
110
|
+
self._gate(first_step)
|
|
111
|
+
self._step_idx += 1
|
|
112
|
+
|
|
113
|
+
# Capture any existing callback so we can chain it
|
|
114
|
+
existing_callback = getattr(crew, "task_callback", None)
|
|
115
|
+
|
|
116
|
+
def _task_callback(task_output: Any) -> None:
|
|
117
|
+
if self._denied:
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
# Gate the next step (if any remain)
|
|
121
|
+
if self._step_idx < len(self.step_order):
|
|
122
|
+
next_step = self.step_order[self._step_idx]
|
|
123
|
+
self._step_idx += 1
|
|
124
|
+
try:
|
|
125
|
+
self._gate(next_step)
|
|
126
|
+
except RailDenied as e:
|
|
127
|
+
self._denied = e
|
|
128
|
+
# Cannot reliably stop CrewAI from inside a callback,
|
|
129
|
+
# but we record the denial and raise after kickoff.
|
|
130
|
+
|
|
131
|
+
if existing_callback is not None:
|
|
132
|
+
existing_callback(task_output)
|
|
133
|
+
|
|
134
|
+
crew.task_callback = _task_callback
|
|
135
|
+
|
|
136
|
+
result = crew.kickoff(**kwargs)
|
|
137
|
+
|
|
138
|
+
if self._denied:
|
|
139
|
+
raise self._denied
|
|
140
|
+
|
|
141
|
+
return result
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""LangGraph integration for AgenticRail."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import uuid
|
|
7
|
+
from functools import wraps
|
|
8
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from agenticrail.client import RailClient
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LangGraphRail:
|
|
14
|
+
"""
|
|
15
|
+
Guards a LangGraph StateGraph by calling the AgenticRail gate before each
|
|
16
|
+
node executes. Each graph invocation gets its own sequence_id derived from
|
|
17
|
+
LangGraph's thread_id, so concurrent runs are isolated.
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
from agenticrail import RailClient
|
|
21
|
+
from agenticrail.integrations.langgraph import LangGraphRail
|
|
22
|
+
|
|
23
|
+
client = RailClient(api_key="YOUR_KEY")
|
|
24
|
+
rail = LangGraphRail(client, step_order=["research", "analyze", "write"])
|
|
25
|
+
|
|
26
|
+
# Wrap your nodes at add_node time
|
|
27
|
+
builder.add_node("research", rail.wrap("research", research_fn))
|
|
28
|
+
builder.add_node("analyze", rail.wrap("analyze", analyze_fn))
|
|
29
|
+
builder.add_node("write", rail.wrap("write", write_fn))
|
|
30
|
+
|
|
31
|
+
graph = builder.compile()
|
|
32
|
+
|
|
33
|
+
# thread_id becomes the sequence_id — each run is isolated
|
|
34
|
+
result = graph.invoke(
|
|
35
|
+
{"messages": [...]},
|
|
36
|
+
config={"configurable": {"thread_id": "run-abc-001"}},
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
Gate enforcement:
|
|
40
|
+
- ALLOW → node function executes normally.
|
|
41
|
+
- DENY → RailDenied raised; LangGraph halts the graph.
|
|
42
|
+
- HALT → RailDenied raised; LangGraph halts the graph.
|
|
43
|
+
|
|
44
|
+
Sequence isolation:
|
|
45
|
+
Each unique thread_id gets its own AgenticRail sequence. If you invoke
|
|
46
|
+
the same graph twice with different thread_ids, both are tracked
|
|
47
|
+
independently. If thread_id is not provided in config, a random UUID is
|
|
48
|
+
used (not recommended — you lose the audit trail correlation).
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
client: RailClient,
|
|
54
|
+
step_order: List[str],
|
|
55
|
+
*,
|
|
56
|
+
fallback_sequence_id: Optional[str] = None,
|
|
57
|
+
):
|
|
58
|
+
self._client = client
|
|
59
|
+
self.step_order = step_order
|
|
60
|
+
self._fallback_sequence_id = fallback_sequence_id or str(uuid.uuid4())
|
|
61
|
+
|
|
62
|
+
def _resolve_sequence_id(self, config: Any) -> str:
|
|
63
|
+
if config and isinstance(config, dict):
|
|
64
|
+
configurable = config.get("configurable") or {}
|
|
65
|
+
thread_id = (
|
|
66
|
+
configurable.get("thread_id")
|
|
67
|
+
or configurable.get("sequence_id")
|
|
68
|
+
)
|
|
69
|
+
if thread_id:
|
|
70
|
+
return str(thread_id)
|
|
71
|
+
return self._fallback_sequence_id
|
|
72
|
+
|
|
73
|
+
def wrap(
|
|
74
|
+
self,
|
|
75
|
+
step_name: str,
|
|
76
|
+
fn: Callable,
|
|
77
|
+
*,
|
|
78
|
+
action_type: str = "CHECK_STATE",
|
|
79
|
+
) -> Callable:
|
|
80
|
+
"""
|
|
81
|
+
Wrap a LangGraph node function with AgenticRail pre-execution gate enforcement.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
step_name: Must match the corresponding entry in step_order.
|
|
85
|
+
fn: The original node function (state -> dict, or state, config -> dict).
|
|
86
|
+
action_type: Gate action type (default CHECK_STATE).
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
Wrapped node function with identical signature.
|
|
90
|
+
"""
|
|
91
|
+
# Check if the original function accepts a config argument
|
|
92
|
+
try:
|
|
93
|
+
sig = inspect.signature(fn)
|
|
94
|
+
_fn_takes_config = len(sig.parameters) > 1
|
|
95
|
+
except (ValueError, TypeError):
|
|
96
|
+
_fn_takes_config = False
|
|
97
|
+
|
|
98
|
+
@wraps(fn)
|
|
99
|
+
def guarded(state: Any, config: Any = None) -> Any:
|
|
100
|
+
seq_id = self._resolve_sequence_id(config)
|
|
101
|
+
# step_order sent on every call — gate reads it from each payload
|
|
102
|
+
self._client.evaluate(
|
|
103
|
+
seq_id,
|
|
104
|
+
step_name,
|
|
105
|
+
action_type=action_type,
|
|
106
|
+
action=f"execute {step_name}",
|
|
107
|
+
step_order=self.step_order,
|
|
108
|
+
raise_on_deny=True,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if _fn_takes_config and config is not None:
|
|
112
|
+
return fn(state, config)
|
|
113
|
+
return fn(state)
|
|
114
|
+
|
|
115
|
+
return guarded
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agenticrail
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: AgenticRail SDK — deterministic sequence enforcement for AI agents
|
|
5
|
+
Project-URL: Homepage, https://agenticrail.nz
|
|
6
|
+
Project-URL: Documentation, https://agenticrail.nz/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/MSMD-RUA/agenticrail-sdk
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/MSMD-RUA/agenticrail-sdk/issues
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: ai-agents,compliance,crewai,enforcement,langgraph,sequence
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Requires-Dist: requests>=2.28
|
|
22
|
+
Provides-Extra: all
|
|
23
|
+
Requires-Dist: crewai>=0.60; extra == 'all'
|
|
24
|
+
Requires-Dist: langchain-core>=0.1; extra == 'all'
|
|
25
|
+
Requires-Dist: langgraph>=0.1; extra == 'all'
|
|
26
|
+
Provides-Extra: crewai
|
|
27
|
+
Requires-Dist: crewai>=0.60; extra == 'crewai'
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest-mock; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
31
|
+
Requires-Dist: responses; extra == 'dev'
|
|
32
|
+
Provides-Extra: langgraph
|
|
33
|
+
Requires-Dist: langchain-core>=0.1; extra == 'langgraph'
|
|
34
|
+
Requires-Dist: langgraph>=0.1; extra == 'langgraph'
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
# agenticrail
|
|
38
|
+
|
|
39
|
+
AgenticRail Python SDK — deterministic sequence enforcement for AI agents.
|
|
40
|
+
|
|
41
|
+
Gate every step of your agent workflow before it executes. Cryptographic receipts. Zero enforcement failures.
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
pip install agenticrail
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## What it does
|
|
50
|
+
|
|
51
|
+
AgenticRail sits beneath your agent and enforces that steps run in the right order, with no skips, no replays, and no re-entry after a sequence is sealed. Every decision — ALLOW, DENY, or HALT — produces a cryptographically signed receipt stored at the edge.
|
|
52
|
+
|
|
53
|
+
Three verdicts:
|
|
54
|
+
- **ALLOW** — step is clear, your agent code executes
|
|
55
|
+
- **DENY** — enforcement violation (wrong order, replay, sealed), execution blocked
|
|
56
|
+
- **HALT** — hard stop (injection attempt, poison payload detected), execution blocked
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Install
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
# Core client only
|
|
64
|
+
pip install agenticrail
|
|
65
|
+
|
|
66
|
+
# With LangGraph support
|
|
67
|
+
pip install "agenticrail[langgraph]"
|
|
68
|
+
|
|
69
|
+
# With CrewAI support
|
|
70
|
+
pip install "agenticrail[crewai]"
|
|
71
|
+
|
|
72
|
+
# Everything
|
|
73
|
+
pip install "agenticrail[all]"
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Quick start — any framework
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from agenticrail import RailClient
|
|
82
|
+
|
|
83
|
+
client = RailClient(api_key="DEMO-AGENTICRAIL-PUBLIC-2026")
|
|
84
|
+
|
|
85
|
+
# RailSequence sends step_order on every call — the gate reads it from each payload
|
|
86
|
+
seq = client.sequence(
|
|
87
|
+
sequence_id="my-agent-run-001",
|
|
88
|
+
step_order=["verify_identity", "assess_risk", "execute_transfer", "audit_ledger"],
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
seq.next("verify_identity") # → ALLOW
|
|
92
|
+
seq.next("assess_risk") # → ALLOW
|
|
93
|
+
seq.next("execute_transfer") # → ALLOW
|
|
94
|
+
seq.next("audit_ledger") # → ALLOW (seals sequence)
|
|
95
|
+
|
|
96
|
+
# Any out-of-order, replay, or post-seal call raises RailDenied
|
|
97
|
+
seq.next("verify_identity") # → raises RailDenied: SEALED_SEQUENCE
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Get a production API key at [agenticrail.nz](https://agenticrail.nz).
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## LangGraph integration
|
|
105
|
+
|
|
106
|
+
Wrap each node at `add_node` time. Uses `thread_id` from LangGraph config as the sequence_id — each graph invocation is isolated.
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from langgraph.graph import StateGraph, END
|
|
110
|
+
from agenticrail import RailClient
|
|
111
|
+
from agenticrail.integrations.langgraph import LangGraphRail
|
|
112
|
+
|
|
113
|
+
client = RailClient(api_key="YOUR_KEY")
|
|
114
|
+
rail = LangGraphRail(
|
|
115
|
+
client,
|
|
116
|
+
step_order=["research", "analyze", "write_report", "review"],
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
builder = StateGraph(AgentState)
|
|
120
|
+
|
|
121
|
+
# Wrap each node — gate fires before node execution
|
|
122
|
+
builder.add_node("research", rail.wrap("research", research_fn))
|
|
123
|
+
builder.add_node("analyze", rail.wrap("analyze", analyze_fn))
|
|
124
|
+
builder.add_node("write_report", rail.wrap("write_report", write_report_fn))
|
|
125
|
+
builder.add_node("review", rail.wrap("review", review_fn))
|
|
126
|
+
|
|
127
|
+
builder.set_entry_point("research")
|
|
128
|
+
builder.add_edge("research", "analyze")
|
|
129
|
+
builder.add_edge("analyze", "write_report")
|
|
130
|
+
builder.add_edge("write_report", "review")
|
|
131
|
+
builder.add_edge("review", END)
|
|
132
|
+
|
|
133
|
+
graph = builder.compile()
|
|
134
|
+
|
|
135
|
+
# thread_id → sequence_id: each invocation is independently tracked and verifiable
|
|
136
|
+
result = graph.invoke(
|
|
137
|
+
{"query": "EU AI Act compliance checklist"},
|
|
138
|
+
config={"configurable": {"thread_id": "run-2026-001"}},
|
|
139
|
+
)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
**What happens on DENY or HALT:**
|
|
143
|
+
`rail.wrap()` raises `RailDenied` inside the node. LangGraph catches unhandled node exceptions and halts graph execution. No subsequent nodes run.
|
|
144
|
+
|
|
145
|
+
**Concurrent runs:**
|
|
146
|
+
Each `thread_id` is a separate sequence. Concurrent graph invocations with different thread IDs do not interfere — the gate tracks them independently via Durable Objects.
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## CrewAI integration
|
|
151
|
+
|
|
152
|
+
Use `guard.kickoff()` instead of `crew.kickoff()`. Step[0] is gated before the crew starts; subsequent steps are gated via a task callback injected between tasks.
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
from crewai import Crew
|
|
156
|
+
from agenticrail import RailClient
|
|
157
|
+
from agenticrail.integrations.crewai import CrewRailGuard
|
|
158
|
+
|
|
159
|
+
client = RailClient(api_key="YOUR_KEY")
|
|
160
|
+
guard = CrewRailGuard(
|
|
161
|
+
client,
|
|
162
|
+
step_order=["research_task", "analysis_task", "write_task"],
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
crew = Crew(
|
|
166
|
+
agents=[researcher, analyst, writer],
|
|
167
|
+
tasks=[research_task, analysis_task, write_task],
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# Drop-in replacement for crew.kickoff()
|
|
171
|
+
# Each call generates a fresh sequence_id — each run is independently tracked
|
|
172
|
+
result = guard.kickoff(crew, inputs={"topic": "AI governance"})
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
**Note on blocking:** CrewAI's `task_callback` fires synchronously between tasks, so the gate can block task N before task N starts. However, if CrewAI swallows exceptions in callbacks, a denied task may still execute. In that case, `guard.kickoff()` raises `RailDenied` after the crew finishes, preserving the denial in the audit trail.
|
|
176
|
+
|
|
177
|
+
For hard blocking (guaranteed pre-execution), use `guard.gate()` in a custom orchestration loop:
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
guard.reset()
|
|
181
|
+
for step, task_fn in zip(STEP_ORDER, task_functions):
|
|
182
|
+
guard.gate(step) # raises RailDenied immediately on DENY
|
|
183
|
+
task_fn()
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## API reference
|
|
189
|
+
|
|
190
|
+
### `RailClient`
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
client = RailClient(
|
|
194
|
+
api_key="...", # Required. Bearer token for the wrapper API.
|
|
195
|
+
model_id="agent", # Optional. Identifies your agent in receipts.
|
|
196
|
+
base_url=None, # Optional. Defaults to api.agenticrail.nz/v1/evaluate.
|
|
197
|
+
timeout=10, # Optional. Request timeout in seconds.
|
|
198
|
+
)
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
#### `client.evaluate(sequence_id, step, *, ...)`
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
decision = client.evaluate(
|
|
205
|
+
sequence_id="my-run-001",
|
|
206
|
+
step="verify_identity",
|
|
207
|
+
action_type="CHECK_STATE", # Optional. Default: CHECK_STATE.
|
|
208
|
+
action="verify user identity", # Optional. Human-readable label.
|
|
209
|
+
inputs={"user_id": "u123"}, # Optional. Metadata attached to receipt.
|
|
210
|
+
step_order=[...], # Required on every call — gate reads it from each payload.
|
|
211
|
+
raise_on_deny=True, # Optional. Default True.
|
|
212
|
+
)
|
|
213
|
+
# decision.decision → "ALLOW" | "DENY" | "HALT"
|
|
214
|
+
# decision.allowed → True if ALLOW
|
|
215
|
+
# decision.pack_id → SHA-256 receipt hash
|
|
216
|
+
# decision.receipt → full signed receipt dict
|
|
217
|
+
# decision.reasons → list of reason codes on DENY/HALT
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
#### `client.sequence(sequence_id, step_order)`
|
|
221
|
+
|
|
222
|
+
Returns a `RailSequence` that tracks `step_order` state — call `.next(step)` for each step without managing `step_order` yourself.
|
|
223
|
+
|
|
224
|
+
### `RailDenied`
|
|
225
|
+
|
|
226
|
+
```python
|
|
227
|
+
try:
|
|
228
|
+
seq.next("execute_transfer")
|
|
229
|
+
except RailDenied as e:
|
|
230
|
+
print(e.decision) # "DENY" or "HALT"
|
|
231
|
+
print(e.reasons) # ["SEQUENCE_VIOLATION"]
|
|
232
|
+
print(e.pack_id) # receipt hash for audit
|
|
233
|
+
print(e.step) # "execute_transfer"
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
### `LangGraphRail`
|
|
237
|
+
|
|
238
|
+
```python
|
|
239
|
+
rail = LangGraphRail(
|
|
240
|
+
client,
|
|
241
|
+
step_order=["step_a", "step_b", "step_c"],
|
|
242
|
+
fallback_sequence_id=None, # Used when thread_id not in config
|
|
243
|
+
)
|
|
244
|
+
wrapped_fn = rail.wrap("step_a", original_fn, action_type="CHECK_STATE")
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
### `CrewRailGuard`
|
|
248
|
+
|
|
249
|
+
```python
|
|
250
|
+
guard = CrewRailGuard(
|
|
251
|
+
client,
|
|
252
|
+
step_order=["task_1", "task_2", "task_3"],
|
|
253
|
+
sequence_id=None, # Optional. Auto-generated per kickoff() call if None.
|
|
254
|
+
action_type="CHECK_STATE",
|
|
255
|
+
)
|
|
256
|
+
result = guard.kickoff(crew, inputs={...})
|
|
257
|
+
guard.gate("task_1") # Explicit gate for custom loops
|
|
258
|
+
guard.reset() # Reset state for a new run
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
---
|
|
262
|
+
|
|
263
|
+
## Compliance reports
|
|
264
|
+
|
|
265
|
+
Every `pack_id` in a `RailDecision` is a verifiable receipt. Paste your sequence ID into the report generator or call it programmatically:
|
|
266
|
+
|
|
267
|
+
```bash
|
|
268
|
+
curl -X POST https://api.agenticrail.nz/v1/report \
|
|
269
|
+
-H "Authorization: Bearer YOUR_KEY" \
|
|
270
|
+
-H "Content-Type: application/json" \
|
|
271
|
+
-d '{"sequence_id": "my-run-001", "format": "json"}'
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Returns: chain proof, HMAC verification, enforcement log, AI-written compliance narrative (EU AI Act Article 11).
|
|
275
|
+
|
|
276
|
+
Demo sequences (using `DEMO-AGENTICRAIL-PUBLIC-2026`) are verifiable at [report.agenticrail.nz](https://report.agenticrail.nz) — no auth needed.
|
|
277
|
+
|
|
278
|
+
---
|
|
279
|
+
|
|
280
|
+
## Action types
|
|
281
|
+
|
|
282
|
+
| Type | Use when |
|
|
283
|
+
|------|----------|
|
|
284
|
+
| `CHECK_STATE` | Reading or observing state (default) |
|
|
285
|
+
| `VALIDATE_INPUT` | Verifying inputs before acting |
|
|
286
|
+
| `RECORD_RESULT` | Writing or committing an outcome |
|
|
287
|
+
| `CLARIFY_NEXT_STEP` | Asking for clarification |
|
|
288
|
+
| `SELECT_NEXT_STEP` | Choosing a path |
|
|
289
|
+
| `WAIT_FOR_SIGNAL` | Pausing for an external trigger |
|
|
290
|
+
| `PAUSE_CYCLE` | Deliberate suspension |
|
|
291
|
+
| `REDUCE_STIMULUS` | Backing off |
|
|
292
|
+
|
|
293
|
+
---
|
|
294
|
+
|
|
295
|
+
## Links
|
|
296
|
+
|
|
297
|
+
- **API docs:** [agenticrail.nz/docs](https://agenticrail.nz/docs)
|
|
298
|
+
- **Interactive demo:** [agenticrail.nz/demo](https://agenticrail.nz/demo)
|
|
299
|
+
- **Compliance matrix (60+ frameworks):** [agenticrail.nz/compliance](https://agenticrail.nz/compliance)
|
|
300
|
+
- **Verify a sequence:** [report.agenticrail.nz](https://report.agenticrail.nz)
|
|
301
|
+
|
|
302
|
+
---
|
|
303
|
+
|
|
304
|
+
*TUARA KURI LIMITED — trading as AgenticRail. Hokianga, New Zealand.*
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
agenticrail/__init__.py,sha256=LxWNeoEQj5e1bWAG52099Dj2G12q0uAmg3epsRkNvv0,253
|
|
2
|
+
agenticrail/client.py,sha256=ADtKEUuMBtJqRSP9qhtMDCYMHjKNWG4dKChChFV8E2s,5854
|
|
3
|
+
agenticrail/integrations/__init__.py,sha256=kDk9Dna5jzlt8-MY_djjEmb3cfOPvmQiqbAnFijHfrk,177
|
|
4
|
+
agenticrail/integrations/crewai.py,sha256=tOyFEUkpMkK26N_0jGGHwFQ2qCubuRI4evQxaUGC3WY,5114
|
|
5
|
+
agenticrail/integrations/langgraph.py,sha256=bakC2At2vo2B3NYdBEi7O8DcYBNxqdvZ50YE-fxuxSQ,3970
|
|
6
|
+
agenticrail-0.1.0.dist-info/METADATA,sha256=FHb-bYNOf7sNrTuYWSIgYHXx_J6ntPUlMg6NldQiQxU,10100
|
|
7
|
+
agenticrail-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
8
|
+
agenticrail-0.1.0.dist-info/RECORD,,
|