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.
- toolreceipt-0.1.0/.gitignore +9 -0
- toolreceipt-0.1.0/LICENSE +21 -0
- toolreceipt-0.1.0/PKG-INFO +249 -0
- toolreceipt-0.1.0/README.md +228 -0
- toolreceipt-0.1.0/pyproject.toml +53 -0
- toolreceipt-0.1.0/toolreceipt/__init__.py +22 -0
- toolreceipt-0.1.0/toolreceipt/claims.py +688 -0
- toolreceipt-0.1.0/toolreceipt/diff.py +88 -0
- toolreceipt-0.1.0/toolreceipt/langgraph.py +313 -0
- toolreceipt-0.1.0/toolreceipt/ledger.py +116 -0
- toolreceipt-0.1.0/toolreceipt/lexicon.json +71 -0
- toolreceipt-0.1.0/toolreceipt/py.typed +0 -0
- toolreceipt-0.1.0/toolreceipt/receipt.py +62 -0
- toolreceipt-0.1.0/toolreceipt/receipts.py +117 -0
|
@@ -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
|
+
[](https://github.com/yohannesjoya/toolreceipt/actions/workflows/test.yml)
|
|
29
|
+
[](https://pypi.org/project/toolreceipt/)
|
|
30
|
+
[](https://github.com/yohannesjoya/toolreceipt/blob/main/LICENSE)
|
|
31
|
+
|
|
32
|
+

|
|
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
|
+

|
|
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
|
+
[](https://github.com/yohannesjoya/toolreceipt/actions/workflows/test.yml)
|
|
8
|
+
[](https://pypi.org/project/toolreceipt/)
|
|
9
|
+
[](https://github.com/yohannesjoya/toolreceipt/blob/main/LICENSE)
|
|
10
|
+
|
|
11
|
+

|
|
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
|
+

|
|
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
|
+
]
|