auditable 0.1.1__tar.gz → 0.2.0__tar.gz
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.
- auditable-0.1.1/README.md → auditable-0.2.0/PKG-INFO +75 -10
- auditable-0.1.1/PKG-INFO → auditable-0.2.0/README.md +28 -53
- {auditable-0.1.1 → auditable-0.2.0}/examples/README.md +57 -2
- auditable-0.2.0/examples/example_langchain_capture.py +100 -0
- auditable-0.2.0/examples/example_langgraph_live_replay.py +154 -0
- auditable-0.2.0/examples/example_live_monitor.py +78 -0
- auditable-0.2.0/examples/example_mcp_capture.py +116 -0
- {auditable-0.1.1 → auditable-0.2.0}/examples/example_payment_audit.py +113 -113
- {auditable-0.1.1 → auditable-0.2.0}/examples/example_post_rank_run.py +99 -99
- auditable-0.2.0/examples/example_state_anomaly.py +123 -0
- {auditable-0.1.1 → auditable-0.2.0}/pyproject.toml +11 -4
- {auditable-0.1.1 → auditable-0.2.0}/src/auditable/__init__.py +79 -77
- {auditable-0.1.1 → auditable-0.2.0}/src/auditable/analysis.py +615 -369
- {auditable-0.1.1 → auditable-0.2.0}/src/auditable/data/__init__.py +35 -6
- auditable-0.2.0/src/auditable/data/inject.py +225 -0
- {auditable-0.1.1 → auditable-0.2.0}/src/auditable/graph/adapters/__init__.py +44 -44
- {auditable-0.1.1 → auditable-0.2.0}/src/auditable/graph/adapters/own_record.py +142 -142
- {auditable-0.1.1 → auditable-0.2.0}/src/auditable/graph/adapters/protocol.py +64 -64
- {auditable-0.1.1 → auditable-0.2.0}/src/auditable/graph/adapters/tau_bench.py +138 -138
- {auditable-0.1.1 → auditable-0.2.0}/src/auditable/graph/grounding.py +332 -332
- {auditable-0.1.1 → auditable-0.2.0}/src/auditable/graph/loader.py +250 -250
- {auditable-0.1.1 → auditable-0.2.0}/src/auditable/graph/risk.py +128 -128
- {auditable-0.1.1 → auditable-0.2.0}/src/auditable/graph/session.py +292 -292
- {auditable-0.1.1 → auditable-0.2.0}/src/auditable/graph/touch.py +122 -3
- {auditable-0.1.1 → auditable-0.2.0}/src/auditable/integrations/__init__.py +4 -1
- auditable-0.2.0/src/auditable/integrations/langchain.py +805 -0
- {auditable-0.1.1 → auditable-0.2.0}/src/auditable/integrations/langgraph.py +129 -9
- auditable-0.2.0/src/auditable/integrations/mcp.py +328 -0
- {auditable-0.1.1 → auditable-0.2.0}/tests/test_adapters.py +352 -352
- {auditable-0.1.1 → auditable-0.2.0}/tests/test_analysis.py +188 -188
- auditable-0.2.0/tests/test_data_inject.py +194 -0
- {auditable-0.1.1 → auditable-0.2.0}/tests/test_data_module.py +61 -1
- {auditable-0.1.1 → auditable-0.2.0}/tests/test_graph_loader.py +293 -293
- {auditable-0.1.1 → auditable-0.2.0}/tests/test_graph_risk.py +134 -134
- {auditable-0.1.1 → auditable-0.2.0}/tests/test_grounding.py +249 -249
- auditable-0.2.0/tests/test_langchain_integration.py +292 -0
- {auditable-0.1.1 → auditable-0.2.0}/tests/test_langgraph_integration.py +1 -1
- auditable-0.2.0/tests/test_langgraph_replay_bridge.py +132 -0
- auditable-0.2.0/tests/test_live_session.py +372 -0
- auditable-0.2.0/tests/test_mcp_integration.py +264 -0
- {auditable-0.1.1 → auditable-0.2.0}/tests/test_public_api.py +99 -92
- {auditable-0.1.1 → auditable-0.2.0}/tests/test_session_graph.py +157 -157
- {auditable-0.1.1 → auditable-0.2.0}/tests/test_session_graph_projection.py +141 -141
- auditable-0.2.0/tests/test_touch_replay_bridge.py +79 -0
- {auditable-0.1.1 → auditable-0.2.0}/.gitignore +0 -0
- {auditable-0.1.1 → auditable-0.2.0}/LICENSE +0 -0
- {auditable-0.1.1 → auditable-0.2.0}/examples/example_audit_report.py +0 -0
- {auditable-0.1.1 → auditable-0.2.0}/examples/example_end_to_end.py +0 -0
- {auditable-0.1.1 → auditable-0.2.0}/examples/example_langgraph_capture.py +0 -0
- {auditable-0.1.1 → auditable-0.2.0}/examples/example_langgraph_llm_agent.py +0 -0
- {auditable-0.1.1 → auditable-0.2.0}/examples/example_live_replay.py +0 -0
- {auditable-0.1.1 → auditable-0.2.0}/examples/example_pre_lint_plan.py +0 -0
- {auditable-0.1.1 → auditable-0.2.0}/examples/example_standalone_report.py +0 -0
- {auditable-0.1.1 → auditable-0.2.0}/examples/example_touch_capture.py +0 -0
- {auditable-0.1.1 → auditable-0.2.0}/src/auditable/_report_figures.py +0 -0
- {auditable-0.1.1 → auditable-0.2.0}/src/auditable/audit_report.py +0 -0
- {auditable-0.1.1 → auditable-0.2.0}/src/auditable/chain.py +0 -0
- {auditable-0.1.1 → auditable-0.2.0}/src/auditable/compound.py +0 -0
- {auditable-0.1.1 → auditable-0.2.0}/src/auditable/graph/__init__.py +0 -0
- {auditable-0.1.1 → auditable-0.2.0}/src/auditable/graph/adapters/declared_plan.py +0 -0
- {auditable-0.1.1 → auditable-0.2.0}/src/auditable/graph/features.py +0 -0
- {auditable-0.1.1 → auditable-0.2.0}/src/auditable/graph/pre.py +0 -0
- {auditable-0.1.1 → auditable-0.2.0}/src/auditable/harness/__init__.py +0 -0
- {auditable-0.1.1 → auditable-0.2.0}/src/auditable/model/__init__.py +0 -0
- {auditable-0.1.1 → auditable-0.2.0}/src/auditable/record.py +0 -0
- {auditable-0.1.1 → auditable-0.2.0}/src/auditable/report.py +0 -0
- {auditable-0.1.1 → auditable-0.2.0}/tests/test_audit_report.py +0 -0
- {auditable-0.1.1 → auditable-0.2.0}/tests/test_auditors.py +0 -0
- {auditable-0.1.1 → auditable-0.2.0}/tests/test_basic.py +0 -0
- {auditable-0.1.1 → auditable-0.2.0}/tests/test_compound.py +0 -0
- {auditable-0.1.1 → auditable-0.2.0}/tests/test_gate.py +0 -0
- {auditable-0.1.1 → auditable-0.2.0}/tests/test_graph.py +0 -0
- {auditable-0.1.1 → auditable-0.2.0}/tests/test_graph_execution_reach.py +0 -0
- {auditable-0.1.1 → auditable-0.2.0}/tests/test_graph_features.py +0 -0
- {auditable-0.1.1 → auditable-0.2.0}/tests/test_pre.py +0 -0
- {auditable-0.1.1 → auditable-0.2.0}/tests/test_pre_coverage.py +0 -0
- {auditable-0.1.1 → auditable-0.2.0}/tests/test_replay.py +0 -0
- {auditable-0.1.1 → auditable-0.2.0}/tests/test_report.py +0 -0
- {auditable-0.1.1 → auditable-0.2.0}/tests/test_sink.py +0 -0
- {auditable-0.1.1 → auditable-0.2.0}/tests/test_touch.py +0 -0
|
@@ -1,8 +1,55 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: auditable
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: An open-source system of record for AI-agent decisions: it captures what each decision relied on, replays it against live state, and rolls back the committed action when it no longer holds.
|
|
5
|
+
Project-URL: Homepage, https://github.com/yzhao062/auditable
|
|
6
|
+
Project-URL: Repository, https://github.com/yzhao062/auditable
|
|
7
|
+
Project-URL: Issues, https://github.com/yzhao062/auditable/issues
|
|
8
|
+
Author-email: Yue Zhao <yzhao010@usc.edu>
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agent,agent-reliability,ai-agents,ai-risk,ai-safety,anomaly-detection,audit,auditing,compliance,langchain,langgraph,llm,mcp,model-context-protocol,observability,replay,trustworthy-ai
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
22
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Provides-Extra: anomaly
|
|
25
|
+
Requires-Dist: pyod>=1.1.0; extra == 'anomaly'
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: build>=1.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: pyod>=1.1.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: twine>=5.0; extra == 'dev'
|
|
31
|
+
Provides-Extra: graph
|
|
32
|
+
Requires-Dist: networkx>=3.0; extra == 'graph'
|
|
33
|
+
Provides-Extra: langchain
|
|
34
|
+
Requires-Dist: langchain-core<2,>=0.3.0; extra == 'langchain'
|
|
35
|
+
Provides-Extra: langgraph
|
|
36
|
+
Requires-Dist: langgraph>=1.0; (python_version >= '3.10') and extra == 'langgraph'
|
|
37
|
+
Requires-Dist: networkx>=3.0; extra == 'langgraph'
|
|
38
|
+
Provides-Extra: llm
|
|
39
|
+
Requires-Dist: openai>=1.0; extra == 'llm'
|
|
40
|
+
Provides-Extra: mcp
|
|
41
|
+
Requires-Dist: mcp<2,>=1.28.1; (python_version >= '3.10') and extra == 'mcp'
|
|
42
|
+
Provides-Extra: report
|
|
43
|
+
Requires-Dist: fpdf2>=2.7; extra == 'report'
|
|
44
|
+
Requires-Dist: matplotlib>=3.5; extra == 'report'
|
|
45
|
+
Requires-Dist: networkx>=3.0; extra == 'report'
|
|
46
|
+
Description-Content-Type: text/markdown
|
|
47
|
+
|
|
1
48
|
<div align="center">
|
|
2
49
|
|
|
3
50
|
# auditable
|
|
4
51
|
|
|
5
|
-
|
|
52
|
+
**`auditable` is an open-source system of record for AI-agent decisions: it captures what each decision relied on, replays it against live state, and rolls back the committed action when it no longer holds.**
|
|
6
53
|
|
|
7
54
|
*Your logs show what the agent did. `auditable` shows what it relied on, replays it under live state, and rolls it back when it no longer holds.*
|
|
8
55
|
|
|
@@ -82,9 +129,11 @@ pip install "auditable[langgraph]"
|
|
|
82
129
|
python examples/example_langgraph_capture.py # a real LangGraph run -> observed=100%, keystone named
|
|
83
130
|
```
|
|
84
131
|
|
|
132
|
+
The same wrapped builder also yields **replayable** records, so the LIVE pillar runs on the captured run, not a hand-built dict. `builder.to_records(decisions={"approve": "vendor_payment"})` lowers a marked decision node into a `DecisionRecord` carrying the state it relied on, and `replay(record, live_state=...)` re-decides it under state that is live now and routes a rollback. See [`example_langgraph_live_replay.py`](examples/example_langgraph_live_replay.py): one real LangGraph run, captured, ranked (POST), then replayed and reversed under a drifted budget (LIVE).
|
|
133
|
+
|
|
85
134
|
Want a real model in the loop? [`example_langgraph_llm_agent.py`](examples/example_langgraph_llm_agent.py) runs the same capture with live LLM nodes against any OpenAI-compatible endpoint (a plain OpenAI key, a gateway, or a local vLLM or Ollama server). The captured edges are identical, because the capture sees the state channels a node read and wrote, not the model call inside it.
|
|
86
135
|
|
|
87
|
-
Not on LangGraph?
|
|
136
|
+
Not on LangGraph? A one-line callback captures any LangChain runnable (`auditable.integrations.langchain`), an in-place `instrument` records every tool call at an MCP server boundary (`auditable.integrations.mcp`), and the framework-agnostic `TouchRecorder` captures the same observed edges from any loop (a raw OpenAI or Anthropic agent, your own scheduler) by declaring each step's `reads()` and `writes()`. See [`examples/example_touch_capture.py`](examples/example_touch_capture.py). **Roadmap:** CrewAI and OpenTelemetry.
|
|
88
137
|
|
|
89
138
|
## Examples and Integrations
|
|
90
139
|
|
|
@@ -94,13 +143,17 @@ Point `auditable` at a scenario and it builds the same typed graph. Each row lin
|
|
|
94
143
|
|---|---|---|---|
|
|
95
144
|
| Capture a real LangGraph agent | LIVE → POST | a live `StateGraph` run (`instrument`) | [`example_langgraph_capture.py`](examples/example_langgraph_capture.py) |
|
|
96
145
|
| Capture a real LLM agent (live model) | LIVE → POST | a model-driven `StateGraph` run (`instrument` + OpenAI-compatible) | [`example_langgraph_llm_agent.py`](examples/example_langgraph_llm_agent.py) |
|
|
146
|
+
| Capture a real LangChain runnable | LIVE → POST | a callback on any runnable (`LangChainCallbackHandler`) | [`example_langchain_capture.py`](examples/example_langchain_capture.py) |
|
|
97
147
|
| Capture any tool loop by hand | LIVE → POST | declared resource touches (`TouchRecorder`) | [`example_touch_capture.py`](examples/example_touch_capture.py) |
|
|
148
|
+
| Capture MCP tool traffic at the server boundary | LIVE → POST | an instrumented FastMCP server (`instrument`) | [`example_mcp_capture.py`](examples/example_mcp_capture.py) |
|
|
149
|
+
| Capture a LangGraph agent, then replay and reverse it | LIVE → POST | a live `StateGraph` run lowered to replayable records (`instrument` + `to_records`) | [`example_langgraph_live_replay.py`](examples/example_langgraph_live_replay.py) |
|
|
98
150
|
| Lint a declared plan before deploy | PRE | a framework-agnostic plan dict (`declared_plan_v1`) | [`example_pre_lint_plan.py`](examples/example_pre_lint_plan.py) |
|
|
99
151
|
| Recover a payment as the budget drifts | LIVE | a decision captured live (`audit` + `replay`) | [`example_live_replay.py`](examples/example_live_replay.py) |
|
|
152
|
+
| Monitor a run as it streams, name the keystone live | LIVE | a run scored prefix by prefix (`LiveSession`) | [`example_live_monitor.py`](examples/example_live_monitor.py) |
|
|
100
153
|
| Rank a tau-bench run, name the keystone | POST | a tau-bench trajectory (`tau_bench_prior_db_reads_v1`) | [`example_post_rank_run.py`](examples/example_post_rank_run.py) |
|
|
101
154
|
| Walk one payment through every pillar | PRE, LIVE, POST | the full lifecycle (`own_record_v1` for POST) | [`example_end_to_end.py`](examples/example_end_to_end.py) |
|
|
102
155
|
|
|
103
|
-
The first
|
|
156
|
+
The first six rows capture a real run; see [Plug In Your Agent](#plug-in-your-agent) for the two-line setup. **Roadmap:** CrewAI and OpenTelemetry.
|
|
104
157
|
|
|
105
158
|
## The Lifecycle
|
|
106
159
|
|
|
@@ -122,9 +175,11 @@ The core is dependency-free and torch-free. Structural-graph analysis (`analyze_
|
|
|
122
175
|
|
|
123
176
|
```bash
|
|
124
177
|
pip install "auditable[graph]"
|
|
178
|
+
pip install "auditable[anomaly]"
|
|
125
179
|
```
|
|
126
180
|
|
|
127
|
-
The
|
|
181
|
+
The graph extra adds NetworkX. The anomaly extra adds the fitted `DataAuditor` path
|
|
182
|
+
through PyOD; freshness fallback and the LIVE example run on the core install alone.
|
|
128
183
|
|
|
129
184
|
Docs: https://auditable-ai.readthedocs.io/
|
|
130
185
|
|
|
@@ -250,7 +305,12 @@ These standalone auditors are inputs to the record, not the headline. The compos
|
|
|
250
305
|
|
|
251
306
|
**What ships today.** The full signed chain, replay under live state, executed recovery through a rail-neutral gate, two sinks (in-memory and append-only JSONL), the POST `analyze_run` ranking, the PRE `analyze_plan` lints plus preflight coverage report, and real-run capture into observed dependency edges (LangGraph via `instrument`, any loop via `TouchRecorder`).
|
|
252
307
|
|
|
253
|
-
The
|
|
308
|
+
The fitted data-anomaly path ships as an unpromoted module API, and `LiveSession`
|
|
309
|
+
prefix scoring ships. The release does **not** yet claim benchmark-grade detector
|
|
310
|
+
evidence, a calibrated model-trust score, or calibrated cross-layer risk. The POST
|
|
311
|
+
structural score is an uncalibrated ranking; the compound report is a transparent,
|
|
312
|
+
explicitly uncalibrated debug bundle. Calibrated compound scoring remains on the
|
|
313
|
+
[roadmap](#roadmap).
|
|
254
314
|
|
|
255
315
|
</details>
|
|
256
316
|
|
|
@@ -261,17 +321,22 @@ The release does **not** yet claim a learned data-anomaly method, a calibrated m
|
|
|
261
321
|
|
|
262
322
|
<br>
|
|
263
323
|
|
|
264
|
-
Shipping today: the full capture, replay, and recover chain; PRE plan lints; POST run ranking; signed, hash-chained records; two sinks. Next:
|
|
324
|
+
Shipping today: the full capture, replay, and recover chain; PRE plan lints; POST run ranking; `LiveSession` prefix scoring; signed, hash-chained records; two sinks. The fitted PyOD detector over dependency snapshots also ships as an unpromoted module API, with freshness as the fallback; promotion waits for benchmark-grade evidence. Next:
|
|
265
325
|
|
|
266
|
-
- **
|
|
267
|
-
- **One calibrated risk score.** Combine the data, model, and action signals into a single calibrated score, with the model treated as a first-class node in the graph.
|
|
268
|
-
- **Live scoring.** Score decisions on the prefix graph as the agent runs, not only after the run completes. (Capturing what each step read and wrote already ships for LangGraph and the `TouchRecorder`.)
|
|
326
|
+
- **One calibrated compound score.** Combine the data, model, and action signals into a single calibrated score, with the model treated as a first-class node in the graph.
|
|
269
327
|
- **Automatic fixes.** Refresh stale data, quarantine bad inputs, fall back to a safer model, or require human sign-off.
|
|
270
328
|
- **Built-in CI checks.** A table-stakes OWASP-Agentic and CWE rule floor for continuous integration.
|
|
271
|
-
- **Integrations (1.0).** LangGraph capture
|
|
329
|
+
- **Integrations (1.0).** LangGraph capture (`instrument`), the LangChain callback, and MCP tool-boundary capture ship now; next are a CrewAI hook, export to OpenTelemetry and LangSmith, downloadable evidence bundles, and a stable public API.
|
|
272
330
|
|
|
273
331
|
</details>
|
|
274
332
|
|
|
333
|
+
## Related Projects
|
|
334
|
+
|
|
335
|
+
`auditable` is part of a small family of open agent-reliability projects:
|
|
336
|
+
|
|
337
|
+
- **[awesome-auditable-ai](https://github.com/yzhao062/awesome-auditable-ai)** — a curated reading list on AI agent reliability and auditing.
|
|
338
|
+
- **[GRADE](https://arxiv.org/abs/2606.22741)** — the research behind the typed two-layer graph this library is built on (also in [References](#references)).
|
|
339
|
+
|
|
275
340
|
## References
|
|
276
341
|
|
|
277
342
|
<details>
|
|
@@ -1,51 +1,8 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: auditable
|
|
3
|
-
Version: 0.1.1
|
|
4
|
-
Summary: Audit any agent decision across its past, present, and future, on one typed graph.
|
|
5
|
-
Project-URL: Homepage, https://github.com/yzhao062/auditable
|
|
6
|
-
Project-URL: Repository, https://github.com/yzhao062/auditable
|
|
7
|
-
Project-URL: Issues, https://github.com/yzhao062/auditable/issues
|
|
8
|
-
Author-email: Yue Zhao <yzhao010@usc.edu>
|
|
9
|
-
License-Expression: Apache-2.0
|
|
10
|
-
License-File: LICENSE
|
|
11
|
-
Keywords: agent,agent-reliability,ai-agents,ai-risk,ai-safety,anomaly-detection,audit,auditing,compliance,llm,observability,replay,trustworthy-ai
|
|
12
|
-
Classifier: Development Status :: 3 - Alpha
|
|
13
|
-
Classifier: Intended Audience :: Developers
|
|
14
|
-
Classifier: License :: OSI Approved :: Apache Software License
|
|
15
|
-
Classifier: Programming Language :: Python :: 3
|
|
16
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
-
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
22
|
-
Classifier: Topic :: Software Development :: Quality Assurance
|
|
23
|
-
Requires-Python: >=3.9
|
|
24
|
-
Provides-Extra: dev
|
|
25
|
-
Requires-Dist: build>=1.0; extra == 'dev'
|
|
26
|
-
Requires-Dist: pyod>=1.1.0; extra == 'dev'
|
|
27
|
-
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
28
|
-
Requires-Dist: twine>=5.0; extra == 'dev'
|
|
29
|
-
Provides-Extra: graph
|
|
30
|
-
Requires-Dist: networkx>=3.0; extra == 'graph'
|
|
31
|
-
Provides-Extra: langchain
|
|
32
|
-
Requires-Dist: langchain-core>=0.3.0; extra == 'langchain'
|
|
33
|
-
Provides-Extra: langgraph
|
|
34
|
-
Requires-Dist: langgraph>=1.0; (python_version >= '3.10') and extra == 'langgraph'
|
|
35
|
-
Requires-Dist: networkx>=3.0; extra == 'langgraph'
|
|
36
|
-
Provides-Extra: llm
|
|
37
|
-
Requires-Dist: openai>=1.0; extra == 'llm'
|
|
38
|
-
Provides-Extra: report
|
|
39
|
-
Requires-Dist: fpdf2>=2.7; extra == 'report'
|
|
40
|
-
Requires-Dist: matplotlib>=3.5; extra == 'report'
|
|
41
|
-
Requires-Dist: networkx>=3.0; extra == 'report'
|
|
42
|
-
Description-Content-Type: text/markdown
|
|
43
|
-
|
|
44
1
|
<div align="center">
|
|
45
2
|
|
|
46
3
|
# auditable
|
|
47
4
|
|
|
48
|
-
|
|
5
|
+
**`auditable` is an open-source system of record for AI-agent decisions: it captures what each decision relied on, replays it against live state, and rolls back the committed action when it no longer holds.**
|
|
49
6
|
|
|
50
7
|
*Your logs show what the agent did. `auditable` shows what it relied on, replays it under live state, and rolls it back when it no longer holds.*
|
|
51
8
|
|
|
@@ -125,9 +82,11 @@ pip install "auditable[langgraph]"
|
|
|
125
82
|
python examples/example_langgraph_capture.py # a real LangGraph run -> observed=100%, keystone named
|
|
126
83
|
```
|
|
127
84
|
|
|
85
|
+
The same wrapped builder also yields **replayable** records, so the LIVE pillar runs on the captured run, not a hand-built dict. `builder.to_records(decisions={"approve": "vendor_payment"})` lowers a marked decision node into a `DecisionRecord` carrying the state it relied on, and `replay(record, live_state=...)` re-decides it under state that is live now and routes a rollback. See [`example_langgraph_live_replay.py`](examples/example_langgraph_live_replay.py): one real LangGraph run, captured, ranked (POST), then replayed and reversed under a drifted budget (LIVE).
|
|
86
|
+
|
|
128
87
|
Want a real model in the loop? [`example_langgraph_llm_agent.py`](examples/example_langgraph_llm_agent.py) runs the same capture with live LLM nodes against any OpenAI-compatible endpoint (a plain OpenAI key, a gateway, or a local vLLM or Ollama server). The captured edges are identical, because the capture sees the state channels a node read and wrote, not the model call inside it.
|
|
129
88
|
|
|
130
|
-
Not on LangGraph?
|
|
89
|
+
Not on LangGraph? A one-line callback captures any LangChain runnable (`auditable.integrations.langchain`), an in-place `instrument` records every tool call at an MCP server boundary (`auditable.integrations.mcp`), and the framework-agnostic `TouchRecorder` captures the same observed edges from any loop (a raw OpenAI or Anthropic agent, your own scheduler) by declaring each step's `reads()` and `writes()`. See [`examples/example_touch_capture.py`](examples/example_touch_capture.py). **Roadmap:** CrewAI and OpenTelemetry.
|
|
131
90
|
|
|
132
91
|
## Examples and Integrations
|
|
133
92
|
|
|
@@ -137,13 +96,17 @@ Point `auditable` at a scenario and it builds the same typed graph. Each row lin
|
|
|
137
96
|
|---|---|---|---|
|
|
138
97
|
| Capture a real LangGraph agent | LIVE → POST | a live `StateGraph` run (`instrument`) | [`example_langgraph_capture.py`](examples/example_langgraph_capture.py) |
|
|
139
98
|
| Capture a real LLM agent (live model) | LIVE → POST | a model-driven `StateGraph` run (`instrument` + OpenAI-compatible) | [`example_langgraph_llm_agent.py`](examples/example_langgraph_llm_agent.py) |
|
|
99
|
+
| Capture a real LangChain runnable | LIVE → POST | a callback on any runnable (`LangChainCallbackHandler`) | [`example_langchain_capture.py`](examples/example_langchain_capture.py) |
|
|
140
100
|
| Capture any tool loop by hand | LIVE → POST | declared resource touches (`TouchRecorder`) | [`example_touch_capture.py`](examples/example_touch_capture.py) |
|
|
101
|
+
| Capture MCP tool traffic at the server boundary | LIVE → POST | an instrumented FastMCP server (`instrument`) | [`example_mcp_capture.py`](examples/example_mcp_capture.py) |
|
|
102
|
+
| Capture a LangGraph agent, then replay and reverse it | LIVE → POST | a live `StateGraph` run lowered to replayable records (`instrument` + `to_records`) | [`example_langgraph_live_replay.py`](examples/example_langgraph_live_replay.py) |
|
|
141
103
|
| Lint a declared plan before deploy | PRE | a framework-agnostic plan dict (`declared_plan_v1`) | [`example_pre_lint_plan.py`](examples/example_pre_lint_plan.py) |
|
|
142
104
|
| Recover a payment as the budget drifts | LIVE | a decision captured live (`audit` + `replay`) | [`example_live_replay.py`](examples/example_live_replay.py) |
|
|
105
|
+
| Monitor a run as it streams, name the keystone live | LIVE | a run scored prefix by prefix (`LiveSession`) | [`example_live_monitor.py`](examples/example_live_monitor.py) |
|
|
143
106
|
| Rank a tau-bench run, name the keystone | POST | a tau-bench trajectory (`tau_bench_prior_db_reads_v1`) | [`example_post_rank_run.py`](examples/example_post_rank_run.py) |
|
|
144
107
|
| Walk one payment through every pillar | PRE, LIVE, POST | the full lifecycle (`own_record_v1` for POST) | [`example_end_to_end.py`](examples/example_end_to_end.py) |
|
|
145
108
|
|
|
146
|
-
The first
|
|
109
|
+
The first six rows capture a real run; see [Plug In Your Agent](#plug-in-your-agent) for the two-line setup. **Roadmap:** CrewAI and OpenTelemetry.
|
|
147
110
|
|
|
148
111
|
## The Lifecycle
|
|
149
112
|
|
|
@@ -165,9 +128,11 @@ The core is dependency-free and torch-free. Structural-graph analysis (`analyze_
|
|
|
165
128
|
|
|
166
129
|
```bash
|
|
167
130
|
pip install "auditable[graph]"
|
|
131
|
+
pip install "auditable[anomaly]"
|
|
168
132
|
```
|
|
169
133
|
|
|
170
|
-
The
|
|
134
|
+
The graph extra adds NetworkX. The anomaly extra adds the fitted `DataAuditor` path
|
|
135
|
+
through PyOD; freshness fallback and the LIVE example run on the core install alone.
|
|
171
136
|
|
|
172
137
|
Docs: https://auditable-ai.readthedocs.io/
|
|
173
138
|
|
|
@@ -293,7 +258,12 @@ These standalone auditors are inputs to the record, not the headline. The compos
|
|
|
293
258
|
|
|
294
259
|
**What ships today.** The full signed chain, replay under live state, executed recovery through a rail-neutral gate, two sinks (in-memory and append-only JSONL), the POST `analyze_run` ranking, the PRE `analyze_plan` lints plus preflight coverage report, and real-run capture into observed dependency edges (LangGraph via `instrument`, any loop via `TouchRecorder`).
|
|
295
260
|
|
|
296
|
-
The
|
|
261
|
+
The fitted data-anomaly path ships as an unpromoted module API, and `LiveSession`
|
|
262
|
+
prefix scoring ships. The release does **not** yet claim benchmark-grade detector
|
|
263
|
+
evidence, a calibrated model-trust score, or calibrated cross-layer risk. The POST
|
|
264
|
+
structural score is an uncalibrated ranking; the compound report is a transparent,
|
|
265
|
+
explicitly uncalibrated debug bundle. Calibrated compound scoring remains on the
|
|
266
|
+
[roadmap](#roadmap).
|
|
297
267
|
|
|
298
268
|
</details>
|
|
299
269
|
|
|
@@ -304,17 +274,22 @@ The release does **not** yet claim a learned data-anomaly method, a calibrated m
|
|
|
304
274
|
|
|
305
275
|
<br>
|
|
306
276
|
|
|
307
|
-
Shipping today: the full capture, replay, and recover chain; PRE plan lints; POST run ranking; signed, hash-chained records; two sinks. Next:
|
|
277
|
+
Shipping today: the full capture, replay, and recover chain; PRE plan lints; POST run ranking; `LiveSession` prefix scoring; signed, hash-chained records; two sinks. The fitted PyOD detector over dependency snapshots also ships as an unpromoted module API, with freshness as the fallback; promotion waits for benchmark-grade evidence. Next:
|
|
308
278
|
|
|
309
|
-
- **
|
|
310
|
-
- **One calibrated risk score.** Combine the data, model, and action signals into a single calibrated score, with the model treated as a first-class node in the graph.
|
|
311
|
-
- **Live scoring.** Score decisions on the prefix graph as the agent runs, not only after the run completes. (Capturing what each step read and wrote already ships for LangGraph and the `TouchRecorder`.)
|
|
279
|
+
- **One calibrated compound score.** Combine the data, model, and action signals into a single calibrated score, with the model treated as a first-class node in the graph.
|
|
312
280
|
- **Automatic fixes.** Refresh stale data, quarantine bad inputs, fall back to a safer model, or require human sign-off.
|
|
313
281
|
- **Built-in CI checks.** A table-stakes OWASP-Agentic and CWE rule floor for continuous integration.
|
|
314
|
-
- **Integrations (1.0).** LangGraph capture
|
|
282
|
+
- **Integrations (1.0).** LangGraph capture (`instrument`), the LangChain callback, and MCP tool-boundary capture ship now; next are a CrewAI hook, export to OpenTelemetry and LangSmith, downloadable evidence bundles, and a stable public API.
|
|
315
283
|
|
|
316
284
|
</details>
|
|
317
285
|
|
|
286
|
+
## Related Projects
|
|
287
|
+
|
|
288
|
+
`auditable` is part of a small family of open agent-reliability projects:
|
|
289
|
+
|
|
290
|
+
- **[awesome-auditable-ai](https://github.com/yzhao062/awesome-auditable-ai)** — a curated reading list on AI agent reliability and auditing.
|
|
291
|
+
- **[GRADE](https://arxiv.org/abs/2606.22741)** — the research behind the typed two-layer graph this library is built on (also in [References](#references)).
|
|
292
|
+
|
|
318
293
|
## References
|
|
319
294
|
|
|
320
295
|
<details>
|
|
@@ -21,16 +21,23 @@ the langgraph extra):
|
|
|
21
21
|
pip install "auditable[graph]"
|
|
22
22
|
pip install "auditable[langgraph]" # for example_langgraph_capture.py
|
|
23
23
|
pip install "auditable[langgraph,llm]" # for example_langgraph_llm_agent.py (a real model; needs an OpenAI-compatible key)
|
|
24
|
+
pip install "auditable[graph,langchain]" # for example_langchain_capture.py
|
|
25
|
+
pip install "auditable[graph,mcp]" # for example_mcp_capture.py (Python >= 3.10)
|
|
24
26
|
```
|
|
25
27
|
|
|
26
28
|
| Pillar | Example | When it runs | Benchmark number |
|
|
27
29
|
|--------|---------|--------------|------------------|
|
|
28
30
|
| PRE | `example_pre_lint_plan.py` | Design time, before any step executes | None (capability demo) |
|
|
29
31
|
| LIVE | `example_live_replay.py` | At decision time, on a committed action | None (capability demo) |
|
|
32
|
+
| LIVE | `example_live_monitor.py` | While a run streams, before it finishes | None (capability demo) |
|
|
30
33
|
| POST | `example_post_rank_run.py` | After a run finishes | GRADE corpus results (see below) |
|
|
31
34
|
| Capture | `example_langgraph_capture.py` | Capture a real LangGraph run, then rank it | None (capability demo) |
|
|
32
35
|
| Capture | `example_langgraph_llm_agent.py` | Capture a real LLM-driven LangGraph run, then rank it | None (capability demo) |
|
|
36
|
+
| Capture | `example_langchain_capture.py` | Capture a real LangChain runnable, then rank it | None (capability demo) |
|
|
37
|
+
| Capture + LIVE | `example_langgraph_live_replay.py` | Capture a real LangGraph run, then replay and reverse its decision under drift | None (capability demo) |
|
|
38
|
+
| Capture + Data | `example_state_anomaly.py` | Capture clean state history, fit from it, then score synthetic faults | None (concept demo) |
|
|
33
39
|
| Capture | `example_touch_capture.py` | Capture any tool loop by hand, then rank it | None (capability demo) |
|
|
40
|
+
| Capture | `example_mcp_capture.py` | Capture MCP tool traffic at the server boundary (ops-side mount) into an honest zero-coverage report | None (capability demo) |
|
|
34
41
|
|
|
35
42
|
## PRE: Lint a Declared Plan Before Deploy
|
|
36
43
|
|
|
@@ -81,6 +88,29 @@ Run it:
|
|
|
81
88
|
python examples/example_live_replay.py
|
|
82
89
|
```
|
|
83
90
|
|
|
91
|
+
## LIVE: Monitor a Run as It Streams
|
|
92
|
+
|
|
93
|
+
`example_live_monitor.py` is the other LIVE capability: a running structural score while
|
|
94
|
+
the run is still going. Where `example_live_replay.py` re-decides one committed action,
|
|
95
|
+
`LiveSession` watches the whole run grow. Feed each step as it happens
|
|
96
|
+
(`live.observe(step)`) and the running report names the keystone and its blast share over
|
|
97
|
+
the run seen so far, marked `completeness=prefix`, so a caller can flag, gate, or
|
|
98
|
+
checkpoint a high-blast step before the run ends. It is the same structural kernel as
|
|
99
|
+
POST, run on a growing graph.
|
|
100
|
+
|
|
101
|
+
The example replays the retail trajectory one step at a time so the score change is
|
|
102
|
+
visible: the first read carries no blast when it happens, becomes the keystone once the
|
|
103
|
+
first write lands on it, and its blast share then moves as later steps arrive. That
|
|
104
|
+
movement is the honest part. A prefix score is a live triage and gating signal, not a
|
|
105
|
+
validated early-warning probability; the blast structure can resolve late, so the keystone
|
|
106
|
+
may shift. This is a capability demo with NO benchmark percentage.
|
|
107
|
+
|
|
108
|
+
Run it:
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
python examples/example_live_monitor.py
|
|
112
|
+
```
|
|
113
|
+
|
|
84
114
|
## POST: Rank a Finished Run and Name the Keystone
|
|
85
115
|
|
|
86
116
|
`example_post_rank_run.py` reads a run after it completes, builds one typed
|
|
@@ -144,10 +174,35 @@ a pure function or a frontier model, which is the point the example makes. It re
|
|
|
144
174
|
plain OpenAI key or any compatible gateway or local server, and prints a notice and
|
|
145
175
|
exits cleanly when no key is set. It needs `pip install "auditable[langgraph,llm]"`.
|
|
146
176
|
|
|
177
|
+
`example_langgraph_live_replay.py` carries one real LangGraph run through both
|
|
178
|
+
pillars. The same wrapped builder that yields the POST ranking also lowers the
|
|
179
|
+
marked decision node into a replayable `DecisionRecord`. `to_records` takes the
|
|
180
|
+
budget the approval relied on into the snapshot, and `action_args` / `action_costs`
|
|
181
|
+
lift the recipient and the real payment cost onto the action itself, so the lowered
|
|
182
|
+
record is the executable payment rather than a state-only stub:
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
record = builder.to_records(
|
|
186
|
+
decisions={"approve": "vendor_payment"},
|
|
187
|
+
action_args={"approve": {"recipient": "recipient"}},
|
|
188
|
+
action_costs={"approve": "amount"},
|
|
189
|
+
)[0]
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
After capture, the example lowers the live budget below the approved amount (fault
|
|
193
|
+
injection, a controlled post-capture change, not drift the corpus supplied) and
|
|
194
|
+
`replay` re-decides on the live state with a cost-based policy: the action's cost
|
|
195
|
+
exceeds the budget that is live now, so it routes a `ROLLBACK` that the `ActionGate`
|
|
196
|
+
executes through a reference ledger. It needs `pip install "auditable[langgraph]"`.
|
|
197
|
+
It is a capability demo on one run, not the comparative replay-versus-baseline
|
|
198
|
+
experiment.
|
|
199
|
+
|
|
147
200
|
`example_touch_capture.py` does the same for a plain tool loop with no framework,
|
|
148
201
|
using the generic `TouchRecorder`: each step declares its `reads()` and `writes()`
|
|
149
|
-
and the same matcher produces the observed edges.
|
|
150
|
-
|
|
202
|
+
and the same matcher produces the observed edges. The recorder also carries the
|
|
203
|
+
relied-on values (`reads(..., value=...)`) and a `decides(...)` action, so
|
|
204
|
+
`rec.to_records()` lowers a consequential step into the same replayable record. This
|
|
205
|
+
is the framework-agnostic path for a raw OpenAI or Anthropic loop, or any scheduler.
|
|
151
206
|
|
|
152
207
|
Both are capability demos: the keystone ranking is a triage signal, not a
|
|
153
208
|
calibrated probability, and the OBSERVED grade is an observed touch match, not a
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Plug in a real LangChain runnable: capture its run into the typed decision graph.
|
|
2
|
+
|
|
3
|
+
What this shows, in plain terms: you attach one `auditable` callback handler to
|
|
4
|
+
an actual LangChain runnable you already built, run it as usual, and get back the
|
|
5
|
+
typed two-layer decision graph for that run. Chain/runnable and model callbacks
|
|
6
|
+
become decision steps, tool callbacks become tool-call steps, and `analyze_run`
|
|
7
|
+
ranks the run and names the keystone, exactly as the POST pillar does for corpus
|
|
8
|
+
traces.
|
|
9
|
+
|
|
10
|
+
This is a real-agent capture path: no hand-written plan dict, no corpus fixture,
|
|
11
|
+
no network, and no API key. The only change to your runnable call is the callback,
|
|
12
|
+
followed by one analysis line:
|
|
13
|
+
|
|
14
|
+
handler = LangChainCallbackHandler() # 1) make once
|
|
15
|
+
chain.invoke(request, config={"callbacks": [handler]}) # 2) attach
|
|
16
|
+
report = analyze_run(handler, adapter=handler) # 3) analyze
|
|
17
|
+
|
|
18
|
+
The scenario is a small payment approver. `fetch_budget` prepares the amount and
|
|
19
|
+
budget, the `approve_payment` tool decides whether the payment fits, and
|
|
20
|
+
`record_ledger` records the result. LangChain's RunnableSequence callback tags
|
|
21
|
+
declare both handoffs, and the adjacent output/input payload digests match, so
|
|
22
|
+
those two State-A value-flow edges are graded OBSERVED.
|
|
23
|
+
|
|
24
|
+
Honesty holds in the output. Callbacks expose State A -- runnable/model reasoning
|
|
25
|
+
and tool flow -- but not State B, the live version or contents of the external
|
|
26
|
+
systems behind a tool. This example therefore makes no observed claim about an
|
|
27
|
+
account database; its OBSERVED edges mean exact callback-level value handoffs,
|
|
28
|
+
not external-state reads or precise causality. The score is a triage ranking, not
|
|
29
|
+
a calibrated probability. The corpus-scale GRADE numbers (arXiv:2606.22741) are
|
|
30
|
+
not produced here; this is a capability demo on one offline run.
|
|
31
|
+
|
|
32
|
+
Needs the graph + langchain extras: pip install "auditable[graph,langchain]"
|
|
33
|
+
Run: python examples/example_langchain_capture.py
|
|
34
|
+
"""
|
|
35
|
+
try:
|
|
36
|
+
from langchain_core.runnables import RunnableLambda
|
|
37
|
+
from langchain_core.tools import tool
|
|
38
|
+
except ImportError:
|
|
39
|
+
print(
|
|
40
|
+
'example skipped: install langchain-core with pip install "auditable[graph,langchain]"'
|
|
41
|
+
)
|
|
42
|
+
raise SystemExit(0)
|
|
43
|
+
|
|
44
|
+
from auditable import analyze_run
|
|
45
|
+
from auditable.integrations.langchain import LangChainCallbackHandler
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def fetch_budget(request):
|
|
49
|
+
"""Prepare the payment and the illustrative budget it will be checked against."""
|
|
50
|
+
return {"amount": request["amount"], "budget": 5_000}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@tool
|
|
54
|
+
def approve_payment(amount: int, budget: int) -> bool:
|
|
55
|
+
"""Approve a payment only when it fits within the supplied budget."""
|
|
56
|
+
return amount <= budget
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def record_ledger(approved):
|
|
60
|
+
"""Record the tool's approval result."""
|
|
61
|
+
return {"approved": approved, "paid": approved}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def main():
|
|
65
|
+
chain = (
|
|
66
|
+
RunnableLambda(fetch_budget, name="fetch_budget")
|
|
67
|
+
| approve_payment
|
|
68
|
+
| RunnableLambda(record_ledger, name="record_ledger")
|
|
69
|
+
).with_config(run_name="payment_chain")
|
|
70
|
+
|
|
71
|
+
handler = LangChainCallbackHandler()
|
|
72
|
+
final = chain.invoke({"amount": 4_200}, config={"callbacks": [handler]})
|
|
73
|
+
print(f"agent result: approved={final['approved']}, paid={final['paid']}\n")
|
|
74
|
+
|
|
75
|
+
# One public call: the callback handler is its own source and adapter.
|
|
76
|
+
report = analyze_run(handler, adapter=handler)
|
|
77
|
+
print(report)
|
|
78
|
+
|
|
79
|
+
print("\nDependency edges captured from the live run:")
|
|
80
|
+
steps = {step.idx: step for step in handler.steps()}
|
|
81
|
+
for step in steps.values():
|
|
82
|
+
for edge in step.deps:
|
|
83
|
+
source = steps[edge.src_idx]
|
|
84
|
+
print(
|
|
85
|
+
f" {step.agent} -> {source.agent} "
|
|
86
|
+
f"({edge.grade.value}, {edge.evidence['relation']})"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
keystone = report.keystone
|
|
90
|
+
if keystone is not None:
|
|
91
|
+
print(
|
|
92
|
+
f"\nKeystone: step {keystone.idx} "
|
|
93
|
+
f"({keystone.node_attrs.get('runnable') or keystone.label}) -- "
|
|
94
|
+
"the approval and ledger result both rest on this captured handoff, "
|
|
95
|
+
"so it is the first step to review."
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
if __name__ == "__main__":
|
|
100
|
+
main()
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Capture a real LangGraph agent, then replay and reverse its decision under drift.
|
|
2
|
+
|
|
3
|
+
This joins the two halves auditable shipped separately. ``example_langgraph_capture.py``
|
|
4
|
+
captures a real LangGraph run for POST analysis (observed dependency edges). This
|
|
5
|
+
example does that AND lowers the captured decision into a replayable record, so the
|
|
6
|
+
LIVE pillar (``replay`` + routed recovery) runs on a real captured agent rather than
|
|
7
|
+
a hand-built dict. Wrap once, get both:
|
|
8
|
+
|
|
9
|
+
builder = instrument(StateGraph(State)) # 1) wrap; build / compile / invoke normally
|
|
10
|
+
report = analyze_run(builder, adapter=builder) # POST: rank the run, name the keystone
|
|
11
|
+
records = builder.to_records( # LIVE: replayable records
|
|
12
|
+
decisions={"approve": "vendor_payment"},
|
|
13
|
+
action_args={"approve": {"recipient": "recipient"}}, # action payload from captured fields
|
|
14
|
+
action_costs={"approve": "amount"}, # action cost from the captured amount
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
The scenario is a payment approver: ``fetch_budget`` reads the account budget,
|
|
18
|
+
``approve`` decides a vendor payment against it, and ``record_ledger`` rests on the
|
|
19
|
+
approval. The agent is real (a LangGraph ``StateGraph`` of plain function nodes); the
|
|
20
|
+
relied-on budget and the decision are captured from the run, not hand-written.
|
|
21
|
+
|
|
22
|
+
The drift is fault injection, stated plainly: after the run is captured, we lower the
|
|
23
|
+
live budget below the approved amount and ``replay`` re-decides on that live state.
|
|
24
|
+
This is a controlled post-capture change, not a fault the corpus supplied (real agent
|
|
25
|
+
corpora carry no intra-run state drift). It is a capability demo on one run, not the
|
|
26
|
+
comparative replay-versus-baseline experiment, which is not shown here. No API key, no
|
|
27
|
+
network.
|
|
28
|
+
|
|
29
|
+
Needs: pip install "auditable[langgraph]"
|
|
30
|
+
Run: python examples/example_langgraph_live_replay.py
|
|
31
|
+
"""
|
|
32
|
+
from typing import TypedDict
|
|
33
|
+
|
|
34
|
+
from auditable import Action, ActionGate, ReferenceLedger, analyze_run, replay
|
|
35
|
+
from auditable.integrations.langgraph import instrument
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
from langgraph.graph import END, START, StateGraph
|
|
39
|
+
except ImportError as exc: # pragma: no cover - exercised only without the extra
|
|
40
|
+
raise SystemExit('This example needs langgraph: pip install "auditable[langgraph]"') from exc
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class PaymentState(TypedDict):
|
|
44
|
+
invoice: str
|
|
45
|
+
recipient: str
|
|
46
|
+
amount: int
|
|
47
|
+
budget: int
|
|
48
|
+
allow_list: list
|
|
49
|
+
approved: bool
|
|
50
|
+
recorded: bool
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def budget_policy(state, action):
|
|
54
|
+
"""Re-decide the payment: the action's recipient must be allow-listed, and the action's
|
|
55
|
+
cost must fit the budget that is live now. The recipient and cost ride on the captured
|
|
56
|
+
action (``to_records(action_args=..., action_costs=...)``); the allow-list and budget come
|
|
57
|
+
from the dependency state. A cost-based check like this is why the lowered action must
|
|
58
|
+
carry the real cost, not ``0``."""
|
|
59
|
+
recipient = action.arguments.get("recipient")
|
|
60
|
+
allow_list = state.get("allow_list", [])
|
|
61
|
+
budget = state.get("budget", 0)
|
|
62
|
+
if recipient not in allow_list:
|
|
63
|
+
return False, f"recipient {recipient!r} not allow-listed"
|
|
64
|
+
if action.cost > budget:
|
|
65
|
+
return False, f"payment ${action.cost:,.0f} exceeds budget ${budget:,.0f}"
|
|
66
|
+
return True, f"payment ${action.cost:,.0f} within budget ${budget:,.0f}"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def build_agent():
|
|
70
|
+
"""A real LangGraph payment approver of plain function nodes, instrumented once."""
|
|
71
|
+
builder = instrument(StateGraph(PaymentState))
|
|
72
|
+
|
|
73
|
+
def fetch_budget(state):
|
|
74
|
+
"""Read the account budget the approval will rest on (the keystone source).
|
|
75
|
+
The allow-list is static config (read free in the initial state), so the budget
|
|
76
|
+
is the single fetched dependency the approval rests on."""
|
|
77
|
+
return {"budget": 10000}
|
|
78
|
+
|
|
79
|
+
def approve(state):
|
|
80
|
+
"""Decide the payment against the budget and allow-list this node read."""
|
|
81
|
+
ok = state["recipient"] in state["allow_list"] and state["amount"] <= state["budget"]
|
|
82
|
+
return {"approved": ok}
|
|
83
|
+
|
|
84
|
+
def record_ledger(state):
|
|
85
|
+
"""Rest on the approval the approve node wrote."""
|
|
86
|
+
return {"recorded": state["approved"]}
|
|
87
|
+
|
|
88
|
+
builder.add_node("fetch_budget", fetch_budget)
|
|
89
|
+
builder.add_node("approve", approve)
|
|
90
|
+
builder.add_node("record_ledger", record_ledger)
|
|
91
|
+
builder.add_edge(START, "fetch_budget")
|
|
92
|
+
builder.add_edge("fetch_budget", "approve")
|
|
93
|
+
builder.add_edge("approve", "record_ledger")
|
|
94
|
+
builder.add_edge("record_ledger", END)
|
|
95
|
+
return builder
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def main():
|
|
99
|
+
builder = build_agent()
|
|
100
|
+
graph = builder.compile()
|
|
101
|
+
final = graph.invoke(
|
|
102
|
+
{
|
|
103
|
+
"invoice": "INV-4471",
|
|
104
|
+
"recipient": "acme-supplies",
|
|
105
|
+
"amount": 4200,
|
|
106
|
+
"budget": 0,
|
|
107
|
+
"allow_list": ["acme-supplies"],
|
|
108
|
+
"approved": False,
|
|
109
|
+
"recorded": False,
|
|
110
|
+
}
|
|
111
|
+
)
|
|
112
|
+
print(f"agent run: approved={final['approved']}\n")
|
|
113
|
+
|
|
114
|
+
# POST: the same captured run, ranked. fetch_budget is the structural keystone.
|
|
115
|
+
report = analyze_run(builder, adapter=builder)
|
|
116
|
+
print(report)
|
|
117
|
+
k = report.keystone
|
|
118
|
+
if k is not None:
|
|
119
|
+
print(f"\nPOST keystone: step {k.idx} ({k.node_attrs.get('langgraph_node')})\n")
|
|
120
|
+
|
|
121
|
+
# LIVE: lower the captured approval into a replayable record. The relied-on budget,
|
|
122
|
+
# amount, recipient, and allow-list become its DependencySnapshot; action_args and
|
|
123
|
+
# action_costs lift the recipient and the real cost onto the action itself, so the
|
|
124
|
+
# lowered record is the executable payment, not a state-only stub.
|
|
125
|
+
record = builder.to_records(
|
|
126
|
+
decisions={"approve": "vendor_payment"},
|
|
127
|
+
action_args={"approve": {"recipient": "recipient"}},
|
|
128
|
+
action_costs={"approve": "amount"},
|
|
129
|
+
)[0]
|
|
130
|
+
snap = record.data.snapshot.state
|
|
131
|
+
print("captured decision record:")
|
|
132
|
+
print(f" action: {record.harness.action_type} -> {record.harness.arguments}")
|
|
133
|
+
print(f" cost: ${record.harness.cost:,.0f} (the captured amount)")
|
|
134
|
+
print(f" relied-on budget: ${snap['budget']:,}; amount: ${snap['amount']:,}\n")
|
|
135
|
+
|
|
136
|
+
# Commit the captured payment through a reference ledger (a demo rail, not a production
|
|
137
|
+
# rail). The action committed is the one the record carries, not a hand-rebuilt one.
|
|
138
|
+
ledger = ReferenceLedger(balance=10000)
|
|
139
|
+
gate = ActionGate(ledger)
|
|
140
|
+
pay = Action(record.harness.action_type, dict(record.harness.arguments), cost=record.harness.cost)
|
|
141
|
+
receipt = gate.commit(pay)
|
|
142
|
+
print(f"committed ${pay.cost:,.0f}; ledger balance now ${ledger.balance:,.0f}")
|
|
143
|
+
|
|
144
|
+
# Fault injection: after capture, the live budget drops below the approved amount.
|
|
145
|
+
# replay re-decides on the live state and routes a rollback; the gate executes it.
|
|
146
|
+
live_state = {**snap, "budget": 3000}
|
|
147
|
+
verdict = replay(record, live_state=live_state, policy=budget_policy)
|
|
148
|
+
gate.enforce_post_commit(verdict, receipt=receipt)
|
|
149
|
+
print(f"replay under live budget $3,000: {verdict.action.value.upper()} -- {verdict.reason}")
|
|
150
|
+
print(f"recovery executed; ledger balance restored to ${ledger.balance:,.0f}")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
if __name__ == "__main__":
|
|
154
|
+
main()
|