actionrail 0.1.0b7__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.
- actionrail/__init__.py +29 -0
- actionrail/cli.py +82 -0
- actionrail/mcp_quickstart.py +193 -0
- actionrail/quickstart.py +207 -0
- actionrail/sdk/__init__.py +47 -0
- actionrail/sdk/action_tests.py +276 -0
- actionrail/sdk/actions.py +86 -0
- actionrail/sdk/config.py +123 -0
- actionrail/sdk/config_cache.py +92 -0
- actionrail/sdk/context.py +66 -0
- actionrail/sdk/enforce.py +310 -0
- actionrail/sdk/grounding/__init__.py +18 -0
- actionrail/sdk/grounding/base.py +23 -0
- actionrail/sdk/grounding/http.py +88 -0
- actionrail/sdk/grounding/match.py +164 -0
- actionrail/sdk/grounding/mcp.py +222 -0
- actionrail/sdk/grounding/mysql.py +139 -0
- actionrail/sdk/grounding/postgres.py +123 -0
- actionrail/sdk/grounding/sqlite.py +39 -0
- actionrail/sdk/instrument.py +59 -0
- actionrail/sdk/lifecycle.py +83 -0
- actionrail/sdk/numeric.py +24 -0
- actionrail/sdk/outbox.py +81 -0
- actionrail/sdk/pipeline.py +230 -0
- actionrail/sdk/policy.py +52 -0
- actionrail/sdk/privacy.py +30 -0
- actionrail/sdk/report.py +332 -0
- actionrail/sdk/review.py +107 -0
- actionrail/sdk/rule_validation.py +406 -0
- actionrail/sdk/runtime.py +428 -0
- actionrail/sdk/runtime_config.py +172 -0
- actionrail/sdk/safewrite.py +36 -0
- actionrail/sdk/scanner.py +212 -0
- actionrail/sdk/state.py +28 -0
- actionrail/sdk/telemetry.py +131 -0
- actionrail-0.1.0b7.dist-info/METADATA +483 -0
- actionrail-0.1.0b7.dist-info/RECORD +42 -0
- actionrail-0.1.0b7.dist-info/WHEEL +5 -0
- actionrail-0.1.0b7.dist-info/entry_points.txt +4 -0
- actionrail-0.1.0b7.dist-info/licenses/LICENSE +201 -0
- actionrail-0.1.0b7.dist-info/licenses/NOTICE +6 -0
- actionrail-0.1.0b7.dist-info/top_level.txt +1 -0
actionrail/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""ActionRail — grounded actions for AI agents."""
|
|
2
|
+
|
|
3
|
+
from .sdk.action_tests import ActionTestError, ActionTestReport, run_action_tests
|
|
4
|
+
from .sdk.actions import ActionDefinition
|
|
5
|
+
from .sdk.context import current_trusted_context, trusted_context
|
|
6
|
+
from .sdk.enforce import enforce
|
|
7
|
+
from .sdk.instrument import instrument
|
|
8
|
+
from .sdk.lifecycle import close_reporting, flush_reporting, get_runtime_health
|
|
9
|
+
from .sdk.pipeline import Decision
|
|
10
|
+
from .sdk.runtime import ActionBlocked, ActionHeld, ActionNotAllowed, ActionRuntime
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"ActionTestError",
|
|
14
|
+
"ActionDefinition",
|
|
15
|
+
"ActionTestReport",
|
|
16
|
+
"run_action_tests",
|
|
17
|
+
"instrument",
|
|
18
|
+
"enforce",
|
|
19
|
+
"trusted_context",
|
|
20
|
+
"current_trusted_context",
|
|
21
|
+
"flush_reporting",
|
|
22
|
+
"close_reporting",
|
|
23
|
+
"get_runtime_health",
|
|
24
|
+
"Decision",
|
|
25
|
+
"ActionRuntime",
|
|
26
|
+
"ActionNotAllowed",
|
|
27
|
+
"ActionBlocked",
|
|
28
|
+
"ActionHeld",
|
|
29
|
+
]
|
actionrail/cli.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Command-line interface for ActionRail framework tooling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from .sdk.action_tests import ActionTestError, run_action_tests
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _package_version() -> str:
|
|
14
|
+
try:
|
|
15
|
+
return version("actionrail")
|
|
16
|
+
except PackageNotFoundError:
|
|
17
|
+
return "development"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _parser() -> argparse.ArgumentParser:
|
|
21
|
+
parser = argparse.ArgumentParser(
|
|
22
|
+
prog="actionrail",
|
|
23
|
+
description="Test and operate ActionRail action contracts.",
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {_package_version()}")
|
|
26
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
27
|
+
test = commands.add_parser(
|
|
28
|
+
"test",
|
|
29
|
+
help="run deterministic allow, hold, and block regression cases",
|
|
30
|
+
)
|
|
31
|
+
test.add_argument("config", nargs="?", default="actionrail.yaml", help="rules file")
|
|
32
|
+
test.add_argument(
|
|
33
|
+
"--cases",
|
|
34
|
+
default="actionrail.cases.yaml",
|
|
35
|
+
help="Action Tests document (default: actionrail.cases.yaml)",
|
|
36
|
+
)
|
|
37
|
+
test.add_argument(
|
|
38
|
+
"--live-sources",
|
|
39
|
+
action="store_true",
|
|
40
|
+
help="allow cases without fixtures to use Sources from the rules file",
|
|
41
|
+
)
|
|
42
|
+
test.add_argument("--format", choices=("text", "json"), default="text")
|
|
43
|
+
return parser
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _print_text(report) -> None:
|
|
47
|
+
print("ActionRail Action Tests")
|
|
48
|
+
print(f"Rules: {report.config_path}")
|
|
49
|
+
print(f"Cases: {report.cases_path}\n")
|
|
50
|
+
for result in report.results:
|
|
51
|
+
mark = "PASS" if result.passed else "FAIL"
|
|
52
|
+
print(f"{mark} {result.name}")
|
|
53
|
+
print(f" {result.tool}: expected {result.expected}, got {result.actual}")
|
|
54
|
+
for reason in result.reasons:
|
|
55
|
+
print(f" {reason}")
|
|
56
|
+
if result.error:
|
|
57
|
+
print(f" {result.error}")
|
|
58
|
+
print(f"\n{report.passed} passed · {report.failed} failed")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def main(argv: list[str] | None = None) -> int:
|
|
62
|
+
args = _parser().parse_args(argv)
|
|
63
|
+
if args.command != "test":
|
|
64
|
+
return 2
|
|
65
|
+
try:
|
|
66
|
+
report = run_action_tests(
|
|
67
|
+
args.config,
|
|
68
|
+
args.cases,
|
|
69
|
+
live_sources=args.live_sources,
|
|
70
|
+
)
|
|
71
|
+
except ActionTestError as exc:
|
|
72
|
+
print(f"Action Tests configuration error: {exc}", file=sys.stderr)
|
|
73
|
+
return 2
|
|
74
|
+
if args.format == "json":
|
|
75
|
+
print(json.dumps(report.to_dict(), indent=2))
|
|
76
|
+
else:
|
|
77
|
+
_print_text(report)
|
|
78
|
+
return 0 if report.successful else 1
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
if __name__ == "__main__":
|
|
82
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Run a local MCP Streamable HTTP grounding example.
|
|
2
|
+
|
|
3
|
+
The example starts a temporary read-only MCP server, calls it through the real
|
|
4
|
+
ActionRail MCP Source adapter, and proves that a cross-customer payment is
|
|
5
|
+
blocked before the simulated refund function executes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import socket
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Iterator
|
|
16
|
+
|
|
17
|
+
from mcp.types import CallToolResult, ToolAnnotations
|
|
18
|
+
|
|
19
|
+
from actionrail.sdk.config import config_from_dict
|
|
20
|
+
from actionrail.sdk.grounding import MCPSource
|
|
21
|
+
from actionrail.sdk.pipeline import decide
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
CALLER_CUSTOMER_ID = "CUSTOMER-100"
|
|
25
|
+
OWN_PAYMENT_ID = "PAYMENT-OWN"
|
|
26
|
+
OTHER_PAYMENT_ID = "PAYMENT-OTHER"
|
|
27
|
+
|
|
28
|
+
_PAYMENTS = {
|
|
29
|
+
OWN_PAYMENT_ID: {
|
|
30
|
+
"payment_id": OWN_PAYMENT_ID,
|
|
31
|
+
"customer_id": CALLER_CUSTOMER_ID,
|
|
32
|
+
"status": "captured",
|
|
33
|
+
},
|
|
34
|
+
OTHER_PAYMENT_ID: {
|
|
35
|
+
"payment_id": OTHER_PAYMENT_ID,
|
|
36
|
+
"customer_id": "CUSTOMER-OTHER",
|
|
37
|
+
"status": "captured",
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class _Gateway:
|
|
44
|
+
endpoint: str
|
|
45
|
+
server: object
|
|
46
|
+
thread: threading.Thread
|
|
47
|
+
socket: socket.socket
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@contextmanager
|
|
51
|
+
def _local_gateway() -> Iterator[_Gateway]:
|
|
52
|
+
import uvicorn
|
|
53
|
+
from mcp.server.fastmcp import FastMCP
|
|
54
|
+
|
|
55
|
+
mcp = FastMCP(
|
|
56
|
+
"ActionRail MCP quickstart",
|
|
57
|
+
stateless_http=True,
|
|
58
|
+
json_response=True,
|
|
59
|
+
log_level="ERROR",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
63
|
+
def lookup_payment(payment_id: str) -> CallToolResult:
|
|
64
|
+
"""Return one payment record without modifying the billing system."""
|
|
65
|
+
|
|
66
|
+
return CallToolResult(
|
|
67
|
+
content=[],
|
|
68
|
+
structuredContent={"payment": _PAYMENTS.get(payment_id)},
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
listener = socket.socket()
|
|
72
|
+
listener.bind(("127.0.0.1", 0))
|
|
73
|
+
host, port = listener.getsockname()
|
|
74
|
+
server = uvicorn.Server(
|
|
75
|
+
uvicorn.Config(
|
|
76
|
+
mcp.streamable_http_app(),
|
|
77
|
+
log_level="error",
|
|
78
|
+
lifespan="on",
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
thread = threading.Thread(
|
|
82
|
+
target=server.run,
|
|
83
|
+
kwargs={"sockets": [listener]},
|
|
84
|
+
daemon=True,
|
|
85
|
+
)
|
|
86
|
+
thread.start()
|
|
87
|
+
deadline = time.monotonic() + 5
|
|
88
|
+
while not server.started and thread.is_alive() and time.monotonic() < deadline:
|
|
89
|
+
time.sleep(0.01)
|
|
90
|
+
if not server.started:
|
|
91
|
+
server.should_exit = True
|
|
92
|
+
thread.join(timeout=2)
|
|
93
|
+
listener.close()
|
|
94
|
+
raise RuntimeError("the local MCP server did not start")
|
|
95
|
+
|
|
96
|
+
gateway = _Gateway(f"http://{host}:{port}/mcp", server, thread, listener)
|
|
97
|
+
try:
|
|
98
|
+
yield gateway
|
|
99
|
+
finally:
|
|
100
|
+
server.should_exit = True
|
|
101
|
+
thread.join(timeout=5)
|
|
102
|
+
listener.close()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def run_quickstart() -> dict:
|
|
106
|
+
config = config_from_dict({
|
|
107
|
+
"schema_version": 1,
|
|
108
|
+
"tools": {
|
|
109
|
+
"refund_payment": {
|
|
110
|
+
"kind": "consequential",
|
|
111
|
+
"args": {
|
|
112
|
+
"payment_id": {
|
|
113
|
+
"ground": {
|
|
114
|
+
"checks": [{
|
|
115
|
+
"source": "billing-mcp",
|
|
116
|
+
"match": [
|
|
117
|
+
{"column": "customer_id", "ctx": "customer_id"},
|
|
118
|
+
{"column": "status", "value": "captured"},
|
|
119
|
+
],
|
|
120
|
+
}],
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
executed: list[str] = []
|
|
129
|
+
|
|
130
|
+
def refund_payment(payment_id: str) -> None:
|
|
131
|
+
executed.append(payment_id)
|
|
132
|
+
|
|
133
|
+
with _local_gateway() as gateway:
|
|
134
|
+
source = MCPSource(
|
|
135
|
+
endpoint=gateway.endpoint,
|
|
136
|
+
tool="lookup_payment",
|
|
137
|
+
arguments={"payment_id": "{value}"},
|
|
138
|
+
select="payment",
|
|
139
|
+
)
|
|
140
|
+
sources = {"billing-mcp": source}
|
|
141
|
+
context = {"customer_id": CALLER_CUSTOMER_ID}
|
|
142
|
+
|
|
143
|
+
allowed = decide(
|
|
144
|
+
"refund_payment",
|
|
145
|
+
{"payment_id": OWN_PAYMENT_ID},
|
|
146
|
+
config,
|
|
147
|
+
sources,
|
|
148
|
+
context,
|
|
149
|
+
)
|
|
150
|
+
if allowed.outcome == "allow":
|
|
151
|
+
refund_payment(OWN_PAYMENT_ID)
|
|
152
|
+
|
|
153
|
+
blocked = decide(
|
|
154
|
+
"refund_payment",
|
|
155
|
+
{"payment_id": OTHER_PAYMENT_ID},
|
|
156
|
+
config,
|
|
157
|
+
sources,
|
|
158
|
+
context,
|
|
159
|
+
)
|
|
160
|
+
if blocked.outcome == "allow":
|
|
161
|
+
refund_payment(OTHER_PAYMENT_ID)
|
|
162
|
+
|
|
163
|
+
if allowed.outcome != "allow":
|
|
164
|
+
raise RuntimeError(f"expected owned payment to be allowed, got {allowed.outcome}")
|
|
165
|
+
if blocked.outcome != "block":
|
|
166
|
+
raise RuntimeError(f"expected cross-customer payment to be blocked, got {blocked.outcome}")
|
|
167
|
+
if executed != [OWN_PAYMENT_ID]:
|
|
168
|
+
raise RuntimeError(f"unexpected refund executions: {executed}")
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
"allowed": allowed.outcome,
|
|
172
|
+
"blocked": blocked.outcome,
|
|
173
|
+
"executed": executed,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def main() -> int:
|
|
178
|
+
try:
|
|
179
|
+
result = run_quickstart()
|
|
180
|
+
except (OSError, RuntimeError) as exc:
|
|
181
|
+
print(f"ActionRail MCP quickstart failed: {exc}")
|
|
182
|
+
return 1
|
|
183
|
+
|
|
184
|
+
print("✓ Started a local MCP Streamable HTTP server")
|
|
185
|
+
print("✓ Discovered a verification tool marked read-only")
|
|
186
|
+
print(f"✓ Owned payment decision: {result['allowed']}")
|
|
187
|
+
print(f"✓ Cross-customer payment decision: {result['blocked']}")
|
|
188
|
+
print("✓ The blocked refund function did not execute")
|
|
189
|
+
return 0
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
if __name__ == "__main__":
|
|
193
|
+
raise SystemExit(main())
|
actionrail/quickstart.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Run the deterministic ActionRail + LangGraph quickstart.
|
|
2
|
+
|
|
3
|
+
The command creates a local LangGraph agent, registers it and a SQLite Source
|
|
4
|
+
with the ActionRail Console, installs a grounding rule, and proves that a
|
|
5
|
+
cross-customer refund is blocked before the refund tool executes.
|
|
6
|
+
|
|
7
|
+
Start ``actionrail-console`` first, then run ``actionrail-quickstart``. No
|
|
8
|
+
model API key or repository checkout is required.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import sqlite3
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
from langchain_core.messages import AIMessage, HumanMessage
|
|
20
|
+
from langchain_core.tools import tool
|
|
21
|
+
|
|
22
|
+
from actionrail import close_reporting, enforce
|
|
23
|
+
|
|
24
|
+
ORDER_ID = "ORDER-OTHER-CUSTOMER"
|
|
25
|
+
CALLER_CUSTOMER_ID = "CUSTOMER-100"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _langgraph_types():
|
|
29
|
+
try:
|
|
30
|
+
from langgraph.graph import END, START, MessagesState, StateGraph
|
|
31
|
+
from langgraph.prebuilt import ToolNode
|
|
32
|
+
except ImportError as exc: # pragma: no cover - exercised by installed-package smoke tests
|
|
33
|
+
raise RuntimeError(
|
|
34
|
+
'LangGraph is not installed. Run: python -m pip install "actionrail[agents]"'
|
|
35
|
+
) from exc
|
|
36
|
+
return END, START, MessagesState, StateGraph, ToolNode
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _build_database(path: Path) -> None:
|
|
40
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
connection = sqlite3.connect(path)
|
|
42
|
+
try:
|
|
43
|
+
connection.execute(
|
|
44
|
+
"CREATE TABLE IF NOT EXISTS orders "
|
|
45
|
+
"(order_id TEXT PRIMARY KEY, customer_id TEXT, status TEXT)"
|
|
46
|
+
)
|
|
47
|
+
connection.execute("DELETE FROM orders")
|
|
48
|
+
connection.executemany(
|
|
49
|
+
"INSERT INTO orders VALUES (?, ?, ?)",
|
|
50
|
+
[
|
|
51
|
+
("ORDER-MINE", CALLER_CUSTOMER_ID, "delivered"),
|
|
52
|
+
(ORDER_ID, "CUSTOMER-OTHER", "delivered"),
|
|
53
|
+
],
|
|
54
|
+
)
|
|
55
|
+
connection.commit()
|
|
56
|
+
finally:
|
|
57
|
+
connection.close()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _build_agent(executed: list[dict]):
|
|
61
|
+
END, START, MessagesState, StateGraph, ToolNode = _langgraph_types()
|
|
62
|
+
|
|
63
|
+
@tool
|
|
64
|
+
def issue_refund(order_id: str, amount: float) -> str:
|
|
65
|
+
"""Issue a real customer refund for a delivered order."""
|
|
66
|
+
executed.append({"order_id": order_id, "amount": amount})
|
|
67
|
+
return f"Refunded {amount} for {order_id}"
|
|
68
|
+
|
|
69
|
+
def request_refund(_state: MessagesState):
|
|
70
|
+
return {"messages": [AIMessage(
|
|
71
|
+
content="",
|
|
72
|
+
tool_calls=[{
|
|
73
|
+
"name": "issue_refund",
|
|
74
|
+
"args": {"order_id": ORDER_ID, "amount": 250},
|
|
75
|
+
"id": "quickstart-refund",
|
|
76
|
+
"type": "tool_call",
|
|
77
|
+
}],
|
|
78
|
+
)]}
|
|
79
|
+
|
|
80
|
+
graph = StateGraph(MessagesState)
|
|
81
|
+
graph.add_node("agent", request_refund)
|
|
82
|
+
graph.add_node("tools", ToolNode([issue_refund]))
|
|
83
|
+
graph.add_edge(START, "agent")
|
|
84
|
+
graph.add_edge("agent", "tools")
|
|
85
|
+
graph.add_edge("tools", END)
|
|
86
|
+
return graph.compile()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _register(client: httpx.Client, database: Path, agent_name: str) -> tuple[str, str, str]:
|
|
90
|
+
created = client.post("/api/agents", json={"name": agent_name})
|
|
91
|
+
created.raise_for_status()
|
|
92
|
+
identity = created.json()
|
|
93
|
+
agent_id = identity["agent_id"]
|
|
94
|
+
api_key = identity["api_key"]
|
|
95
|
+
source_name = f"quickstart-billing-{agent_id[:8]}"
|
|
96
|
+
|
|
97
|
+
source = client.post("/api/sources", json={
|
|
98
|
+
"name": source_name,
|
|
99
|
+
"adapter": "sqlite",
|
|
100
|
+
"config": {"path": str(database)},
|
|
101
|
+
})
|
|
102
|
+
source.raise_for_status()
|
|
103
|
+
|
|
104
|
+
rules = {"schema_version": 1, "tools": {"issue_refund": {
|
|
105
|
+
"kind": "consequential",
|
|
106
|
+
"args": {
|
|
107
|
+
"order_id": {"ground": {"checks": [{
|
|
108
|
+
"source": source_name,
|
|
109
|
+
"query": (
|
|
110
|
+
"SELECT customer_id, status FROM orders "
|
|
111
|
+
"WHERE order_id = :value"
|
|
112
|
+
),
|
|
113
|
+
"match": [
|
|
114
|
+
{"column": "customer_id", "ctx": "customer_id"},
|
|
115
|
+
{"column": "status", "value": "delivered"},
|
|
116
|
+
],
|
|
117
|
+
}]}},
|
|
118
|
+
"amount": {"policy": "amount <= 1000"},
|
|
119
|
+
},
|
|
120
|
+
# Runtime values stay local. Activity shows a useful redacted preview;
|
|
121
|
+
# add a field to report_args only after deciding it is safe to export.
|
|
122
|
+
"write": {
|
|
123
|
+
"preview": "Refund order {order_id} for {amount}",
|
|
124
|
+
"report_args": [],
|
|
125
|
+
},
|
|
126
|
+
}}}
|
|
127
|
+
saved = client.put(f"/api/agents/{agent_id}/rules", json={"rules": rules})
|
|
128
|
+
saved.raise_for_status()
|
|
129
|
+
return agent_id, api_key, source_name
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def run_quickstart(
|
|
133
|
+
*,
|
|
134
|
+
endpoint: str = "http://127.0.0.1:8020",
|
|
135
|
+
database: Path | None = None,
|
|
136
|
+
agent_name: str = "LangGraph refund quickstart",
|
|
137
|
+
state_dir: Path | None = None,
|
|
138
|
+
) -> dict:
|
|
139
|
+
endpoint = endpoint.rstrip("/")
|
|
140
|
+
database = (database or Path.home() / ".actionrail" / "quickstart-billing.sqlite3").resolve()
|
|
141
|
+
_build_database(database)
|
|
142
|
+
|
|
143
|
+
with httpx.Client(base_url=endpoint, timeout=5.0) as client:
|
|
144
|
+
agent_id, api_key, source_name = _register(client, database, agent_name)
|
|
145
|
+
executed = []
|
|
146
|
+
agent = _build_agent(executed)
|
|
147
|
+
agent = enforce(
|
|
148
|
+
agent,
|
|
149
|
+
agent_id=agent_id,
|
|
150
|
+
api_key=api_key,
|
|
151
|
+
endpoint=endpoint,
|
|
152
|
+
ctx={"customer_id": CALLER_CUSTOMER_ID},
|
|
153
|
+
state_dir=state_dir,
|
|
154
|
+
)
|
|
155
|
+
result = agent.invoke({"messages": [HumanMessage(content="Refund the order")]})
|
|
156
|
+
blocked_message = str(result["messages"][-1].content)
|
|
157
|
+
|
|
158
|
+
if executed:
|
|
159
|
+
raise RuntimeError("quickstart safety check failed: the refund tool executed")
|
|
160
|
+
if "BLOCKED" not in blocked_message:
|
|
161
|
+
raise RuntimeError(f"expected a blocked tool result, received: {blocked_message}")
|
|
162
|
+
if not close_reporting(agent, timeout=5):
|
|
163
|
+
raise RuntimeError("the ActionRail reporting queue did not flush cleanly")
|
|
164
|
+
|
|
165
|
+
feed = client.get("/api/decisions", params={"agent": agent_id, "page_size": 5})
|
|
166
|
+
feed.raise_for_status()
|
|
167
|
+
activity = feed.json().get("decisions", [])
|
|
168
|
+
if not activity:
|
|
169
|
+
raise RuntimeError("the blocked decision did not appear in Activity")
|
|
170
|
+
decision = activity[0]
|
|
171
|
+
serialized = json.dumps(decision)
|
|
172
|
+
if ORDER_ID in serialized or "CUSTOMER-OTHER" in serialized:
|
|
173
|
+
raise RuntimeError("Activity unexpectedly contains private runtime values")
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
"agent_id": agent_id,
|
|
177
|
+
"source_name": source_name,
|
|
178
|
+
"blocked_message": blocked_message,
|
|
179
|
+
"activity": decision,
|
|
180
|
+
"executed": executed,
|
|
181
|
+
"database": str(database),
|
|
182
|
+
"endpoint": endpoint,
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def main(argv: list[str] | None = None) -> int:
|
|
187
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
188
|
+
parser.add_argument("--endpoint", default="http://127.0.0.1:8020")
|
|
189
|
+
parser.add_argument("--database", type=Path)
|
|
190
|
+
args = parser.parse_args(argv)
|
|
191
|
+
try:
|
|
192
|
+
result = run_quickstart(endpoint=args.endpoint, database=args.database)
|
|
193
|
+
except (httpx.HTTPError, OSError, RuntimeError) as exc:
|
|
194
|
+
print(f"ActionRail quickstart failed: {exc}")
|
|
195
|
+
print(f"Is actionrail-console running at {args.endpoint.rstrip('/')}?")
|
|
196
|
+
return 1
|
|
197
|
+
|
|
198
|
+
print(f"✓ Registered agent {result['agent_id']}")
|
|
199
|
+
print(f"✓ Connected local source {result['source_name']}")
|
|
200
|
+
print("✓ ActionRail blocked the cross-customer refund before the tool executed")
|
|
201
|
+
print("✓ The value-free decision is visible in Activity")
|
|
202
|
+
print(f"Open {result['endpoint']}/activity")
|
|
203
|
+
return 0
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
if __name__ == "__main__":
|
|
207
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""ActionRail runtime SDK."""
|
|
2
|
+
|
|
3
|
+
from .action_tests import ActionTestError, ActionTestReport, run_action_tests
|
|
4
|
+
from .actions import ActionDefinition
|
|
5
|
+
from .context import current_trusted_context, trusted_context
|
|
6
|
+
from .enforce import enforce
|
|
7
|
+
from .instrument import instrument
|
|
8
|
+
from .lifecycle import close_reporting, flush_reporting, get_runtime_health
|
|
9
|
+
from .report import Reporter
|
|
10
|
+
from .pipeline import Decision
|
|
11
|
+
from .runtime import ActionBlocked, ActionHeld, ActionNotAllowed, ActionRuntime
|
|
12
|
+
from .scanner import (
|
|
13
|
+
ToolInfo,
|
|
14
|
+
SurfaceMap,
|
|
15
|
+
calls_from_messages,
|
|
16
|
+
enumerate_tools,
|
|
17
|
+
observe_run,
|
|
18
|
+
build_surface_map,
|
|
19
|
+
print_surface_map,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"ActionTestError",
|
|
24
|
+
"ActionDefinition",
|
|
25
|
+
"ActionTestReport",
|
|
26
|
+
"run_action_tests",
|
|
27
|
+
"instrument",
|
|
28
|
+
"enforce",
|
|
29
|
+
"trusted_context",
|
|
30
|
+
"current_trusted_context",
|
|
31
|
+
"Reporter",
|
|
32
|
+
"Decision",
|
|
33
|
+
"ActionRuntime",
|
|
34
|
+
"ActionNotAllowed",
|
|
35
|
+
"ActionBlocked",
|
|
36
|
+
"ActionHeld",
|
|
37
|
+
"flush_reporting",
|
|
38
|
+
"close_reporting",
|
|
39
|
+
"get_runtime_health",
|
|
40
|
+
"ToolInfo",
|
|
41
|
+
"SurfaceMap",
|
|
42
|
+
"calls_from_messages",
|
|
43
|
+
"enumerate_tools",
|
|
44
|
+
"observe_run",
|
|
45
|
+
"build_surface_map",
|
|
46
|
+
"print_surface_map",
|
|
47
|
+
]
|