toolreceipt 0.1.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.
@@ -0,0 +1,9 @@
1
+ .env
2
+ python/.venv/
3
+ __pycache__/
4
+ dist/
5
+ *.egg-info/
6
+ .pytest_cache/
7
+ .mypy_cache/
8
+ .ruff_cache/
9
+ demo/.venv/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yohannes Desta
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,249 @@
1
+ Metadata-Version: 2.5
2
+ Name: toolreceipt
3
+ Version: 0.1.0
4
+ Summary: Catch LLM agents claiming actions they never took. Ledger + claim extraction + receipt for LangGraph.
5
+ Project-URL: Homepage, https://github.com/yohannesjoya/toolreceipt
6
+ Project-URL: Repository, https://github.com/yohannesjoya/toolreceipt
7
+ Project-URL: Changelog, https://github.com/yohannesjoya/toolreceipt/blob/main/CHANGELOG.md
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.10
17
+ Provides-Extra: langgraph
18
+ Requires-Dist: langchain-core>=1.0; extra == 'langgraph'
19
+ Requires-Dist: langgraph>=1.0; extra == 'langgraph'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # toolreceipt
23
+
24
+ Catch LLM agents claiming actions they never took. `toolreceipt` records every tool call
25
+ during a run (the ledger), extracts action claims from the agent's final reply, diffs the
26
+ two, and returns a receipt you can use to block, annotate or log the reply.
27
+
28
+ [![test](https://github.com/yohannesjoya/toolreceipt/actions/workflows/test.yml/badge.svg)](https://github.com/yohannesjoya/toolreceipt/actions/workflows/test.yml)
29
+ [![PyPI](https://img.shields.io/pypi/v/toolreceipt.svg)](https://pypi.org/project/toolreceipt/)
30
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/yohannesjoya/toolreceipt/blob/main/LICENSE)
31
+
32
+ ![toolreceipt demo: the agent claims it emailed the customer, the receipt shows send_email failed](https://raw.githubusercontent.com/yohannesjoya/toolreceipt/main/docs/demo.gif)
33
+
34
+ **Live demo:** https://toolreceipt-demo.onrender.com (free tier, the first request after idle can take about a minute)
35
+
36
+ An agent finishes with "Refund issued and I have emailed you the confirmation." The refund
37
+ tool ran. The email tool timed out. Nothing in your stack notices, and the customer waits for
38
+ an email that will never arrive. `toolreceipt` notices.
39
+
40
+ No API key, no extra model calls: the core is deterministic, rule-based and has no
41
+ dependencies beyond the standard library.
42
+
43
+ ## What it catches
44
+
45
+ - A claim in the reply ("I have emailed you") whose matching tool was never called:
46
+ `claimed_but_never_ran` with reason `not_called`.
47
+ - A claim whose matching tool was called but failed (an exception, or an error
48
+ `ToolMessage`): `claimed_but_never_ran` with reason `tool_failed`.
49
+ - Tools that ran successfully but that the reply never mentions: `ran_but_unreported`.
50
+ Informational, it does not block.
51
+
52
+ Precision comes before recall. Negation ("I did not send"), the future ("I'll send"),
53
+ advice ("you can create a ticket"), conditionals and offers are not claims.
54
+
55
+ ## What it does not catch
56
+
57
+ - **Tools that return an error string with a success status.** If a tool returns
58
+ `"Error: SMTP timeout"` as a normal value, the framework reports success and so does the
59
+ ledger. Raise, or return an error `ToolMessage`.
60
+ - **Argument mismatches.** "I emailed Bob" passes if `send_email` ran, whatever `to` was.
61
+ - **Quantities.** "Updated 3 rows" against one call is recorded as a count on the claim, never judged.
62
+ - **Non-English replies.** English only.
63
+ - **Streaming and mid-run checks.** Only the final reply is checked.
64
+ - **Tool names without a verb.** A tool called `email_customer` never matches, so claims
65
+ about it land in `unmatched` (informational, they never affect the verdict).
66
+ - **Loose tool matching.** A claim maps to a tool only when the tool name shares the
67
+ claim's action verb family (`send`, `create`, `update`, `delete`, `refund`, ...).
68
+ - **Loose paraphrases of an action done with a different tool.** "I've raised this with our
69
+ team" after `escalate_ticket` fits the verb family of `create_ticket` but names nothing in
70
+ it. When a claim matches a tool on the verb family alone and that tool never ran, the claim
71
+ is not judged: it lands in `unmatched`. It is flagged only when the tool name also carries
72
+ what the claim acts on ("I've created a ticket" against `create_ticket`). A family-only
73
+ match whose tool ran and failed is still `tool_failed`.
74
+ - **Claims about a sibling tool when another tool of the same action ran.** "I've removed the
75
+ mug from your order" after `remove_item` ran is not judged against `cancel_order`: it lands in
76
+ `unmatched`.
77
+ - **Repeated claims.** Several claims about the same tool collapse into one claim.
78
+
79
+ Failed tools need one setting on your graph: build it with
80
+ `ToolNode(tools, handle_tool_errors=True)`. LangGraph's default re-raises tool execution
81
+ errors, which aborts the run, so there is no final reply to check and the agent never learns
82
+ the tool failed.
83
+
84
+ ## How it is different
85
+
86
+ - It runs at runtime, on the live reply, and captures the ledger automatically.
87
+ - It reads free text, so you do not need to structure claims or list the expected tools.
88
+ - It returns a verdict you act on, not a trace you read later.
89
+
90
+ See the [comparison table](#comparison-with-prior-art) for the details.
91
+
92
+ ## Demo
93
+
94
+ `demo/` is a support desk agent (LangGraph on Groq, `openai/gpt-oss-120b`) asked to refund
95
+ order 4821 and email the confirmation. In pressure mode the email tool fails with an SMTP
96
+ timeout and the system prompt tells the agent to keep the customer reassured and confirm
97
+ every requested step as done; in honest mode every tool works.
98
+
99
+ In 9 of 10 live pressure runs on 2026-10-19 the agent claimed the email was sent; toolreceipt
100
+ flagged every one. The remaining reply said the email "has been queued", which is not flagged.
101
+ Before tuning, a milder pressure prompt produced no false claim in 10 of 10 runs. The tuned
102
+ prompt was chosen on the first 5 of the 10 pooled runs, so those 5 are not held out.
103
+
104
+ In 10 of 10 live honest runs on 2026-10-19 every tool succeeded and the receipt was `ok`.
105
+ On the held-out set (`fixtures/heldout/`: those 10 honest replies plus 12 hand-written
106
+ adversarial negatives, labelled before any rule change) precision is 1.00 (20 of 20 claims)
107
+ with no claim on any adversarial negative. The 10 honest replies are near-identical in
108
+ phrasing ("I've issued a $64 refund ... and sent a confirmation email"), so this precision
109
+ figure is weak evidence. `fixtures/check/` adds hand-written honest replies with more varied
110
+ phrasing. The recorded runs in `demo/replays/` back the demo's Replay button.
111
+
112
+ ## Install
113
+
114
+ ```bash
115
+ pip install "toolreceipt[langgraph]"
116
+ ```
117
+
118
+ The core (`pip install toolreceipt`) works with any framework through `check()`, see
119
+ [Post-hoc usage](#post-hoc-usage). Python 3.10+.
120
+
121
+ ## Quickstart
122
+
123
+ ```python
124
+ from toolreceipt import Receipts
125
+ from toolreceipt.langgraph import final_reply
126
+
127
+ # graph = <your compiled LangGraph>
128
+ rc = Receipts()
129
+ result = graph.invoke({"messages": [("user", "Refund order 4821 and email me the confirmation")]}, config=rc.config())
130
+ receipt = rc.check(final_reply(result))
131
+
132
+ print(receipt.verdict) # "fabricated"
133
+ print(receipt.summary) # 1 claimed action never ran: send_email ("I have emailed you the confirmation").
134
+ if not receipt.ok:
135
+ ... # block, annotate or log the reply
136
+ ```
137
+
138
+ `rc.config()` returns a LangGraph config with a callback handler attached. Create one
139
+ `Receipts` per run. Your tools and graph stay unchanged, apart from
140
+ `ToolNode(tools, handle_tool_errors=True)`.
141
+
142
+ ## Vocabulary
143
+
144
+ | Term | Meaning |
145
+ |---|---|
146
+ | **Ledger** | Ordered list of ledger entries captured during one agent run, ordered by `started_at`. |
147
+ | **Ledger entry** | One tool invocation: `tool`, `status` (`ok` or `error`), `args`, `result`, `error`, `call_id`, timestamps. |
148
+ | **Claim** | One assertion in the reply that an action was performed: quoted `text`, `verb`, `object`, `count`, candidate `tool`s. |
149
+ | **Receipt** | The result of diffing claims against the ledger. |
150
+ | **Verdict** | `clean`, `unreported` or `fabricated`. Only `fabricated` is blocking (`receipt.ok` is `False`). |
151
+
152
+ `Claim.text` is the clause after normalisation (curly quotes straightened, runs of whitespace
153
+ folded), so it can differ from the raw substring of the reply.
154
+
155
+ A receipt has four lists: `confirmed` (a successful entry exists for the claim),
156
+ `claimed_but_never_ran`, `unmatched` (no known tool fits the claim closely enough to judge
157
+ it, informational) and
158
+ `ran_but_unreported`. Every type has `to_dict()`, and every field is JSON-safe.
159
+
160
+ ## Architecture
161
+
162
+ ![toolreceipt architecture: a LangGraph run feeds a ledger and an extractor, the diff produces a receipt](https://raw.githubusercontent.com/yohannesjoya/toolreceipt/main/docs/architecture.svg)
163
+
164
+ The adapter is a LangChain callback handler, so capture needs no tool wrapping. The extractor
165
+ (`ClaimExtractor`) and the adapter are small protocols and the default extractor,
166
+ `RuleExtractor`, is a lexicon of verb families plus exclusion rules.
167
+
168
+ ## Post-hoc usage
169
+
170
+ Already have the messages from a finished run, or a different framework? Build a ledger and
171
+ call `check()`:
172
+
173
+ ```python
174
+ from toolreceipt import check
175
+ from toolreceipt.langgraph import final_reply, ledger_from_messages
176
+
177
+ ledger = ledger_from_messages(result["messages"])
178
+ receipt = check(final_reply(result), ledger, known_tools=["issue_refund", "send_email"])
179
+ ```
180
+
181
+ `ledger_from_messages` pairs each `AIMessage` tool call with its `ToolMessage`. A call with
182
+ no result is recorded as an error. Timestamps are `None` on this path.
183
+
184
+ Pass `known_tools`. A ledger only knows the tools that ran, so without `known_tools` a claim
185
+ about a tool that was never called ("I emailed you" when `send_email` never ran) has nothing
186
+ to match and cannot be flagged. Names are enough; `(name, description)` pairs and
187
+ OpenAI-style tool dicts also work. In a live run (`Receipts.config()`), bound tools are
188
+ discovered automatically.
189
+
190
+ Without LangGraph, build a `Ledger` of `LedgerEntry` objects yourself and call `check()`.
191
+
192
+ ## Configuration
193
+
194
+ ```python
195
+ rc = Receipts(
196
+ extractor=None, # any ClaimExtractor; default RuleExtractor()
197
+ known_tools=(), # extra tools to match claims against
198
+ ignore_tools=(), # tools never reported as ran_but_unreported (e.g. lookups)
199
+ capture_results=True, # False: ledger keeps no args or results
200
+ max_result_chars=2000, # truncate result and error text in the ledger
201
+ )
202
+ ```
203
+
204
+ - `known_tools`: names, `(name, description)` pairs or tool dicts. Merged with the tools
205
+ discovered from the run.
206
+ - `ignore_tools`: read-only tools you do not want listed as unreported.
207
+ - `capture_results=False`: the ledger records only tool name, status and error. Use it when
208
+ tool arguments or results are sensitive.
209
+ - `max_result_chars`: caps stored result and error text.
210
+
211
+ ## Comparison with prior art
212
+
213
+ | Project | What it is for |
214
+ |---|---|
215
+ | [AgentClaimGuard](https://github.com/konoeph/AgentClaimGuard) | Checking agent claims that are already structured |
216
+ | [langchain-ai/agentevals](https://github.com/langchain-ai/agentevals) | Offline evaluation of agent trajectories against a reference |
217
+ | [DeepEval](https://github.com/confident-ai/deepeval) (`ToolCorrectnessMetric`), [Ragas](https://github.com/explodinggradients/ragas) | Test-time evaluation metrics, including tool-use metrics |
218
+ | [AgentOps](https://github.com/AgentOps-AI/agentops), [Langfuse](https://github.com/langfuse/langfuse), [LangSmith](https://smith.langchain.com) | Tracing and observability of agent runs |
219
+ | [MIRAGE-Bench](https://arxiv.org/abs/2507.21017) | A benchmark for agent hallucinations, including action hallucination |
220
+
221
+ `toolreceipt` is a runtime check on the final reply of one run: it extracts claims from free
222
+ text and returns a verdict against the tools that actually ran.
223
+
224
+ `toolreceipt` complements tracers: keep your traces, add a verdict.
225
+
226
+ ## Privacy
227
+
228
+ The ledger can contain personal data: email bodies, order details, whatever your tools take
229
+ and return. Receipts are for your own logs and your own code. Nothing is sent anywhere, and
230
+ the library makes no network calls. Use `capture_results=False` to drop args and results,
231
+ and `max_result_chars` to bound what is kept.
232
+
233
+ ## Roadmap
234
+
235
+ - LLM-backed extractor behind the existing `ClaimExtractor` protocol
236
+ - TypeScript port, sharing the JSON fixtures as the contract
237
+ - Adapters: Mastra, CrewAI, Vercel AI SDK
238
+ - Argument check ("emailed Bob" against `to="alice"`)
239
+ - Async API
240
+
241
+ ## Contributing
242
+
243
+ Lexicon and fixture contributions are JSON only. See [CONTRIBUTING.md](https://github.com/yohannesjoya/toolreceipt/blob/main/CONTRIBUTING.md). If
244
+ the reply you expected to be flagged was not, or a clean reply was flagged, open an issue
245
+ with the [missed claim or false positive template](https://github.com/yohannesjoya/toolreceipt/issues/new/choose).
246
+
247
+ ## License
248
+
249
+ [MIT](https://github.com/yohannesjoya/toolreceipt/blob/main/LICENSE)
@@ -0,0 +1,228 @@
1
+ # toolreceipt
2
+
3
+ Catch LLM agents claiming actions they never took. `toolreceipt` records every tool call
4
+ during a run (the ledger), extracts action claims from the agent's final reply, diffs the
5
+ two, and returns a receipt you can use to block, annotate or log the reply.
6
+
7
+ [![test](https://github.com/yohannesjoya/toolreceipt/actions/workflows/test.yml/badge.svg)](https://github.com/yohannesjoya/toolreceipt/actions/workflows/test.yml)
8
+ [![PyPI](https://img.shields.io/pypi/v/toolreceipt.svg)](https://pypi.org/project/toolreceipt/)
9
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/yohannesjoya/toolreceipt/blob/main/LICENSE)
10
+
11
+ ![toolreceipt demo: the agent claims it emailed the customer, the receipt shows send_email failed](https://raw.githubusercontent.com/yohannesjoya/toolreceipt/main/docs/demo.gif)
12
+
13
+ **Live demo:** https://toolreceipt-demo.onrender.com (free tier, the first request after idle can take about a minute)
14
+
15
+ An agent finishes with "Refund issued and I have emailed you the confirmation." The refund
16
+ tool ran. The email tool timed out. Nothing in your stack notices, and the customer waits for
17
+ an email that will never arrive. `toolreceipt` notices.
18
+
19
+ No API key, no extra model calls: the core is deterministic, rule-based and has no
20
+ dependencies beyond the standard library.
21
+
22
+ ## What it catches
23
+
24
+ - A claim in the reply ("I have emailed you") whose matching tool was never called:
25
+ `claimed_but_never_ran` with reason `not_called`.
26
+ - A claim whose matching tool was called but failed (an exception, or an error
27
+ `ToolMessage`): `claimed_but_never_ran` with reason `tool_failed`.
28
+ - Tools that ran successfully but that the reply never mentions: `ran_but_unreported`.
29
+ Informational, it does not block.
30
+
31
+ Precision comes before recall. Negation ("I did not send"), the future ("I'll send"),
32
+ advice ("you can create a ticket"), conditionals and offers are not claims.
33
+
34
+ ## What it does not catch
35
+
36
+ - **Tools that return an error string with a success status.** If a tool returns
37
+ `"Error: SMTP timeout"` as a normal value, the framework reports success and so does the
38
+ ledger. Raise, or return an error `ToolMessage`.
39
+ - **Argument mismatches.** "I emailed Bob" passes if `send_email` ran, whatever `to` was.
40
+ - **Quantities.** "Updated 3 rows" against one call is recorded as a count on the claim, never judged.
41
+ - **Non-English replies.** English only.
42
+ - **Streaming and mid-run checks.** Only the final reply is checked.
43
+ - **Tool names without a verb.** A tool called `email_customer` never matches, so claims
44
+ about it land in `unmatched` (informational, they never affect the verdict).
45
+ - **Loose tool matching.** A claim maps to a tool only when the tool name shares the
46
+ claim's action verb family (`send`, `create`, `update`, `delete`, `refund`, ...).
47
+ - **Loose paraphrases of an action done with a different tool.** "I've raised this with our
48
+ team" after `escalate_ticket` fits the verb family of `create_ticket` but names nothing in
49
+ it. When a claim matches a tool on the verb family alone and that tool never ran, the claim
50
+ is not judged: it lands in `unmatched`. It is flagged only when the tool name also carries
51
+ what the claim acts on ("I've created a ticket" against `create_ticket`). A family-only
52
+ match whose tool ran and failed is still `tool_failed`.
53
+ - **Claims about a sibling tool when another tool of the same action ran.** "I've removed the
54
+ mug from your order" after `remove_item` ran is not judged against `cancel_order`: it lands in
55
+ `unmatched`.
56
+ - **Repeated claims.** Several claims about the same tool collapse into one claim.
57
+
58
+ Failed tools need one setting on your graph: build it with
59
+ `ToolNode(tools, handle_tool_errors=True)`. LangGraph's default re-raises tool execution
60
+ errors, which aborts the run, so there is no final reply to check and the agent never learns
61
+ the tool failed.
62
+
63
+ ## How it is different
64
+
65
+ - It runs at runtime, on the live reply, and captures the ledger automatically.
66
+ - It reads free text, so you do not need to structure claims or list the expected tools.
67
+ - It returns a verdict you act on, not a trace you read later.
68
+
69
+ See the [comparison table](#comparison-with-prior-art) for the details.
70
+
71
+ ## Demo
72
+
73
+ `demo/` is a support desk agent (LangGraph on Groq, `openai/gpt-oss-120b`) asked to refund
74
+ order 4821 and email the confirmation. In pressure mode the email tool fails with an SMTP
75
+ timeout and the system prompt tells the agent to keep the customer reassured and confirm
76
+ every requested step as done; in honest mode every tool works.
77
+
78
+ In 9 of 10 live pressure runs on 2026-10-19 the agent claimed the email was sent; toolreceipt
79
+ flagged every one. The remaining reply said the email "has been queued", which is not flagged.
80
+ Before tuning, a milder pressure prompt produced no false claim in 10 of 10 runs. The tuned
81
+ prompt was chosen on the first 5 of the 10 pooled runs, so those 5 are not held out.
82
+
83
+ In 10 of 10 live honest runs on 2026-10-19 every tool succeeded and the receipt was `ok`.
84
+ On the held-out set (`fixtures/heldout/`: those 10 honest replies plus 12 hand-written
85
+ adversarial negatives, labelled before any rule change) precision is 1.00 (20 of 20 claims)
86
+ with no claim on any adversarial negative. The 10 honest replies are near-identical in
87
+ phrasing ("I've issued a $64 refund ... and sent a confirmation email"), so this precision
88
+ figure is weak evidence. `fixtures/check/` adds hand-written honest replies with more varied
89
+ phrasing. The recorded runs in `demo/replays/` back the demo's Replay button.
90
+
91
+ ## Install
92
+
93
+ ```bash
94
+ pip install "toolreceipt[langgraph]"
95
+ ```
96
+
97
+ The core (`pip install toolreceipt`) works with any framework through `check()`, see
98
+ [Post-hoc usage](#post-hoc-usage). Python 3.10+.
99
+
100
+ ## Quickstart
101
+
102
+ ```python
103
+ from toolreceipt import Receipts
104
+ from toolreceipt.langgraph import final_reply
105
+
106
+ # graph = <your compiled LangGraph>
107
+ rc = Receipts()
108
+ result = graph.invoke({"messages": [("user", "Refund order 4821 and email me the confirmation")]}, config=rc.config())
109
+ receipt = rc.check(final_reply(result))
110
+
111
+ print(receipt.verdict) # "fabricated"
112
+ print(receipt.summary) # 1 claimed action never ran: send_email ("I have emailed you the confirmation").
113
+ if not receipt.ok:
114
+ ... # block, annotate or log the reply
115
+ ```
116
+
117
+ `rc.config()` returns a LangGraph config with a callback handler attached. Create one
118
+ `Receipts` per run. Your tools and graph stay unchanged, apart from
119
+ `ToolNode(tools, handle_tool_errors=True)`.
120
+
121
+ ## Vocabulary
122
+
123
+ | Term | Meaning |
124
+ |---|---|
125
+ | **Ledger** | Ordered list of ledger entries captured during one agent run, ordered by `started_at`. |
126
+ | **Ledger entry** | One tool invocation: `tool`, `status` (`ok` or `error`), `args`, `result`, `error`, `call_id`, timestamps. |
127
+ | **Claim** | One assertion in the reply that an action was performed: quoted `text`, `verb`, `object`, `count`, candidate `tool`s. |
128
+ | **Receipt** | The result of diffing claims against the ledger. |
129
+ | **Verdict** | `clean`, `unreported` or `fabricated`. Only `fabricated` is blocking (`receipt.ok` is `False`). |
130
+
131
+ `Claim.text` is the clause after normalisation (curly quotes straightened, runs of whitespace
132
+ folded), so it can differ from the raw substring of the reply.
133
+
134
+ A receipt has four lists: `confirmed` (a successful entry exists for the claim),
135
+ `claimed_but_never_ran`, `unmatched` (no known tool fits the claim closely enough to judge
136
+ it, informational) and
137
+ `ran_but_unreported`. Every type has `to_dict()`, and every field is JSON-safe.
138
+
139
+ ## Architecture
140
+
141
+ ![toolreceipt architecture: a LangGraph run feeds a ledger and an extractor, the diff produces a receipt](https://raw.githubusercontent.com/yohannesjoya/toolreceipt/main/docs/architecture.svg)
142
+
143
+ The adapter is a LangChain callback handler, so capture needs no tool wrapping. The extractor
144
+ (`ClaimExtractor`) and the adapter are small protocols and the default extractor,
145
+ `RuleExtractor`, is a lexicon of verb families plus exclusion rules.
146
+
147
+ ## Post-hoc usage
148
+
149
+ Already have the messages from a finished run, or a different framework? Build a ledger and
150
+ call `check()`:
151
+
152
+ ```python
153
+ from toolreceipt import check
154
+ from toolreceipt.langgraph import final_reply, ledger_from_messages
155
+
156
+ ledger = ledger_from_messages(result["messages"])
157
+ receipt = check(final_reply(result), ledger, known_tools=["issue_refund", "send_email"])
158
+ ```
159
+
160
+ `ledger_from_messages` pairs each `AIMessage` tool call with its `ToolMessage`. A call with
161
+ no result is recorded as an error. Timestamps are `None` on this path.
162
+
163
+ Pass `known_tools`. A ledger only knows the tools that ran, so without `known_tools` a claim
164
+ about a tool that was never called ("I emailed you" when `send_email` never ran) has nothing
165
+ to match and cannot be flagged. Names are enough; `(name, description)` pairs and
166
+ OpenAI-style tool dicts also work. In a live run (`Receipts.config()`), bound tools are
167
+ discovered automatically.
168
+
169
+ Without LangGraph, build a `Ledger` of `LedgerEntry` objects yourself and call `check()`.
170
+
171
+ ## Configuration
172
+
173
+ ```python
174
+ rc = Receipts(
175
+ extractor=None, # any ClaimExtractor; default RuleExtractor()
176
+ known_tools=(), # extra tools to match claims against
177
+ ignore_tools=(), # tools never reported as ran_but_unreported (e.g. lookups)
178
+ capture_results=True, # False: ledger keeps no args or results
179
+ max_result_chars=2000, # truncate result and error text in the ledger
180
+ )
181
+ ```
182
+
183
+ - `known_tools`: names, `(name, description)` pairs or tool dicts. Merged with the tools
184
+ discovered from the run.
185
+ - `ignore_tools`: read-only tools you do not want listed as unreported.
186
+ - `capture_results=False`: the ledger records only tool name, status and error. Use it when
187
+ tool arguments or results are sensitive.
188
+ - `max_result_chars`: caps stored result and error text.
189
+
190
+ ## Comparison with prior art
191
+
192
+ | Project | What it is for |
193
+ |---|---|
194
+ | [AgentClaimGuard](https://github.com/konoeph/AgentClaimGuard) | Checking agent claims that are already structured |
195
+ | [langchain-ai/agentevals](https://github.com/langchain-ai/agentevals) | Offline evaluation of agent trajectories against a reference |
196
+ | [DeepEval](https://github.com/confident-ai/deepeval) (`ToolCorrectnessMetric`), [Ragas](https://github.com/explodinggradients/ragas) | Test-time evaluation metrics, including tool-use metrics |
197
+ | [AgentOps](https://github.com/AgentOps-AI/agentops), [Langfuse](https://github.com/langfuse/langfuse), [LangSmith](https://smith.langchain.com) | Tracing and observability of agent runs |
198
+ | [MIRAGE-Bench](https://arxiv.org/abs/2507.21017) | A benchmark for agent hallucinations, including action hallucination |
199
+
200
+ `toolreceipt` is a runtime check on the final reply of one run: it extracts claims from free
201
+ text and returns a verdict against the tools that actually ran.
202
+
203
+ `toolreceipt` complements tracers: keep your traces, add a verdict.
204
+
205
+ ## Privacy
206
+
207
+ The ledger can contain personal data: email bodies, order details, whatever your tools take
208
+ and return. Receipts are for your own logs and your own code. Nothing is sent anywhere, and
209
+ the library makes no network calls. Use `capture_results=False` to drop args and results,
210
+ and `max_result_chars` to bound what is kept.
211
+
212
+ ## Roadmap
213
+
214
+ - LLM-backed extractor behind the existing `ClaimExtractor` protocol
215
+ - TypeScript port, sharing the JSON fixtures as the contract
216
+ - Adapters: Mastra, CrewAI, Vercel AI SDK
217
+ - Argument check ("emailed Bob" against `to="alice"`)
218
+ - Async API
219
+
220
+ ## Contributing
221
+
222
+ Lexicon and fixture contributions are JSON only. See [CONTRIBUTING.md](https://github.com/yohannesjoya/toolreceipt/blob/main/CONTRIBUTING.md). If
223
+ the reply you expected to be flagged was not, or a clean reply was flagged, open an issue
224
+ with the [missed claim or false positive template](https://github.com/yohannesjoya/toolreceipt/issues/new/choose).
225
+
226
+ ## License
227
+
228
+ [MIT](https://github.com/yohannesjoya/toolreceipt/blob/main/LICENSE)
@@ -0,0 +1,53 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "toolreceipt"
7
+ version = "0.1.0"
8
+ description = "Catch LLM agents claiming actions they never took. Ledger + claim extraction + receipt for LangGraph."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ dependencies = []
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.10",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
19
+ "Typing :: Typed",
20
+ ]
21
+
22
+ [project.optional-dependencies]
23
+ langgraph = ["langchain-core>=1.0", "langgraph>=1.0"]
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/yohannesjoya/toolreceipt"
27
+ Repository = "https://github.com/yohannesjoya/toolreceipt"
28
+ Changelog = "https://github.com/yohannesjoya/toolreceipt/blob/main/CHANGELOG.md"
29
+
30
+ [dependency-groups]
31
+ dev = ["pytest>=8", "ruff>=0.6", "mypy>=1.10", "langchain-core>=1.0", "langgraph>=1.0"]
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["toolreceipt"]
35
+ include = ["toolreceipt/lexicon.json"]
36
+
37
+ [tool.hatch.build.targets.sdist]
38
+ # The tests need the repository's fixtures/ and README, so they stay out of the sdist.
39
+ exclude = ["/tests", "/uv.lock"]
40
+
41
+ [tool.ruff]
42
+ line-length = 100
43
+ target-version = "py310"
44
+
45
+ [tool.ruff.lint]
46
+ select = ["E", "F", "I", "UP", "B"]
47
+
48
+ [tool.mypy]
49
+ strict = true
50
+ files = ["toolreceipt"]
51
+
52
+ [tool.pytest.ini_options]
53
+ testpaths = ["tests"]
@@ -0,0 +1,22 @@
1
+ """toolreceipt: catch LLM agents claiming tool actions they never performed."""
2
+
3
+ from toolreceipt.claims import Claim, ClaimExtractor, RuleExtractor, ToolSpec
4
+ from toolreceipt.ledger import Ledger, LedgerEntry
5
+ from toolreceipt.receipt import Receipt, Verdict
6
+ from toolreceipt.receipts import Receipts, check
7
+
8
+ __version__ = "0.1.0"
9
+
10
+ __all__ = [
11
+ "Claim",
12
+ "ClaimExtractor",
13
+ "Ledger",
14
+ "LedgerEntry",
15
+ "Receipt",
16
+ "Receipts",
17
+ "RuleExtractor",
18
+ "ToolSpec",
19
+ "Verdict",
20
+ "__version__",
21
+ "check",
22
+ ]