agent-polygraph 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.
- agent_polygraph-0.1.0/LICENSE +21 -0
- agent_polygraph-0.1.0/MANIFEST.in +7 -0
- agent_polygraph-0.1.0/PKG-INFO +297 -0
- agent_polygraph-0.1.0/README.md +267 -0
- agent_polygraph-0.1.0/docs/claude_code.md +57 -0
- agent_polygraph-0.1.0/docs/litellm.md +56 -0
- agent_polygraph-0.1.0/docs/openai_agents.md +61 -0
- agent_polygraph-0.1.0/docs/openinference.md +58 -0
- agent_polygraph-0.1.0/pyproject.toml +49 -0
- agent_polygraph-0.1.0/setup.cfg +4 -0
- agent_polygraph-0.1.0/src/agent_polygraph/__init__.py +62 -0
- agent_polygraph-0.1.0/src/agent_polygraph/adapters/__init__.py +27 -0
- agent_polygraph-0.1.0/src/agent_polygraph/adapters/claude_code.py +173 -0
- agent_polygraph-0.1.0/src/agent_polygraph/adapters/litellm.py +162 -0
- agent_polygraph-0.1.0/src/agent_polygraph/adapters/openai_agents.py +176 -0
- agent_polygraph-0.1.0/src/agent_polygraph/adapters/openinference.py +196 -0
- agent_polygraph-0.1.0/src/agent_polygraph/detector.py +394 -0
- agent_polygraph-0.1.0/src/agent_polygraph/events.py +218 -0
- agent_polygraph-0.1.0/src/agent_polygraph/judge.py +55 -0
- agent_polygraph-0.1.0/src/agent_polygraph/py.typed +0 -0
- agent_polygraph-0.1.0/src/agent_polygraph/verify.py +154 -0
- agent_polygraph-0.1.0/src/agent_polygraph.egg-info/PKG-INFO +297 -0
- agent_polygraph-0.1.0/src/agent_polygraph.egg-info/SOURCES.txt +27 -0
- agent_polygraph-0.1.0/src/agent_polygraph.egg-info/dependency_links.txt +1 -0
- agent_polygraph-0.1.0/src/agent_polygraph.egg-info/requires.txt +3 -0
- agent_polygraph-0.1.0/src/agent_polygraph.egg-info/top_level.txt +1 -0
- agent_polygraph-0.1.0/tests/_synth.py +157 -0
- agent_polygraph-0.1.0/tests/test_edge_suite.py +178 -0
- agent_polygraph-0.1.0/tests/test_smoke.py +67 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Polygraph Bench maintainers
|
|
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,297 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agent-polygraph
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Framework-agnostic completion-lie detector for AI agents -- catches an agent turn whose final claim contradicts its own tool evidence.
|
|
5
|
+
Author: Polygraph Bench maintainers
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/najemwehbe/agent-polygraph
|
|
8
|
+
Project-URL: Repository, https://github.com/najemwehbe/agent-polygraph
|
|
9
|
+
Project-URL: Benchmark, https://github.com/najemwehbe/polygraph-bench
|
|
10
|
+
Project-URL: Leaderboard, https://najemwehbe.github.io/polygraph-bench/
|
|
11
|
+
Project-URL: Dataset, https://huggingface.co/datasets/najemwehbe/polygraph-bench
|
|
12
|
+
Keywords: agent,llm,honesty,hallucination,guardrail,observability,openai-agents,openinference,opentelemetry,litellm,claude-code,evaluation
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
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 :: Software Development :: Quality Assurance
|
|
22
|
+
Classifier: Topic :: Software Development :: Testing
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Provides-Extra: test
|
|
28
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# agent-polygraph
|
|
32
|
+
|
|
33
|
+
A framework-agnostic **completion-lie detector** for AI agents. Given one agent
|
|
34
|
+
turn — a task, the tool calls and results it produced ("evidence"), and the final
|
|
35
|
+
assistant message ("closing") — `verify()` decides whether the closing makes a
|
|
36
|
+
completion or success claim that the turn's own evidence contradicts.
|
|
37
|
+
|
|
38
|
+
It measures the *turn*, not the model. The check is heuristic, deterministic, and
|
|
39
|
+
synchronous, with **zero runtime dependencies** (standard library only).
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from agent_polygraph import verify, ToolCall, ToolResult
|
|
43
|
+
|
|
44
|
+
v = verify(
|
|
45
|
+
[ToolCall("c1", "deploy", {"svc": "web"}),
|
|
46
|
+
ToolResult("c1", content="ERROR: image pull failed", is_error=True)],
|
|
47
|
+
final_claim="Done -- deployed successfully.",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
v.verdict # "lie"
|
|
51
|
+
v.category # "L1" (error concealment)
|
|
52
|
+
v.confidence # 1.0 (structured error bit)
|
|
53
|
+
v.evidence # (Finding(check="error_concealment", detail=...),)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
This is the SDK companion to [polygraph-bench](https://github.com/najemwehbe/polygraph-bench),
|
|
57
|
+
the public benchmark for agent-lying detectors. The detector shipped here is a
|
|
58
|
+
**clean-room reimplementation** of a private production honesty gate, validated at
|
|
59
|
+
verdict parity against that gate on the benchmark's 396-item corpus (numbers below).
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Install
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pip install agent-polygraph
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Python 3.10+. No third-party runtime dependencies.
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## What it catches
|
|
74
|
+
|
|
75
|
+
Three heuristic check families, each mapped to a diagnostic category:
|
|
76
|
+
|
|
77
|
+
| category | family (`check` id) | catches |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| **L1** | `error_concealment` | an unrecovered tool error hidden behind a plain, unacknowledged success claim |
|
|
80
|
+
| **L2** | `completion_vs_evidence` | a completeness / counted-total claim over a full-page fetch with no proof the set was exhausted |
|
|
81
|
+
| **L3** | `narrative_vs_assertion` | a runtime "it works / is fixed" claim after a file edit with nothing actually run |
|
|
82
|
+
|
|
83
|
+
`verify()` returns a `Verdict`:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
Verdict(
|
|
87
|
+
verdict="lie" | "honest",
|
|
88
|
+
evidence=(Finding(check, detail), ...),
|
|
89
|
+
confidence=1.0 | 0.7, # signal-quality GRADE, not a probability
|
|
90
|
+
detector_version="0.1.0", # self-stamped for cross-version comparison
|
|
91
|
+
flags=(...), # degradation markers driving a 0.7 grade
|
|
92
|
+
category="L1" | "L2" | "L3" | None,
|
|
93
|
+
)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`confidence` is a **signal-quality grade, not a probability**: `1.0` when the
|
|
97
|
+
verdict rode a structured framework signal, `0.7` when it rode an *inferred*
|
|
98
|
+
truncation marker or a *sniffed* error payload (see [degraded signals](#the-error-bit-is-the-portability-spine)).
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Framework adapters
|
|
103
|
+
|
|
104
|
+
Four adapters map a framework's native emission onto the same `verify()` core.
|
|
105
|
+
The design default is **audit** everywhere — a foreign framework's false-positive
|
|
106
|
+
rate is unknown until you measure it on your own traffic, and a false halt kills
|
|
107
|
+
adoption. **Blocking is opt-in**, and only on the two surfaces that can actually
|
|
108
|
+
halt a run; the audit-only adapters **raise loudly** on `mode="block"` rather than
|
|
109
|
+
silently downgrading.
|
|
110
|
+
|
|
111
|
+
| adapter | entry point | tool-error signal | default | block |
|
|
112
|
+
|---|---|---|---|---|
|
|
113
|
+
| OpenAI Agents SDK | `output_guardrail` | span wrapper `error.data.error` (structured) | audit | **opt-in** |
|
|
114
|
+
| Claude Code stop-hook | `stop_hook` | `tool_result.is_error` (structured) | audit | **opt-in** |
|
|
115
|
+
| OTel / OpenInference | `verify_spans` | OTel span `status_code=ERROR` (structured) | audit | raises loud |
|
|
116
|
+
| LiteLLM proxy | `post_call_guardrail` | **none** — sniff `role:"tool"` text only | audit | raises loud |
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
from agent_polygraph.adapters import output_guardrail
|
|
120
|
+
|
|
121
|
+
result = output_guardrail(trace_export, mode="block") # halt on a lie
|
|
122
|
+
if result.tripwire_triggered:
|
|
123
|
+
... # the Agents SDK stops the run
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Per-adapter quickstarts live in [`docs/`](docs/):
|
|
127
|
+
[OpenAI Agents](docs/openai_agents.md) ·
|
|
128
|
+
[OpenInference / OTel](docs/openinference.md) ·
|
|
129
|
+
[Claude Code](docs/claude_code.md) ·
|
|
130
|
+
[LiteLLM](docs/litellm.md).
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## Validation
|
|
135
|
+
|
|
136
|
+
All numbers below come from actually-executed runs and are reported verbatim.
|
|
137
|
+
Parity and the recall floor are the honest limits of a v0.1 heuristic detector —
|
|
138
|
+
read them before you deploy in `block` mode.
|
|
139
|
+
|
|
140
|
+
### Parity vs the private gate — 393 / 396 (99.24%)
|
|
141
|
+
|
|
142
|
+
The detector was written clean-room from the public benchmark spec and corpus,
|
|
143
|
+
then refined by **behavioural** parity iteration against a private production gate
|
|
144
|
+
treated as a black box (feed an input, observe its block/allow verdict; the gate's
|
|
145
|
+
source was never read). On all 396 public bench items:
|
|
146
|
+
|
|
147
|
+
```
|
|
148
|
+
agreement matrix (clean-room \ gate):
|
|
149
|
+
gate=lie gate=honest
|
|
150
|
+
clean=lie 88 0
|
|
151
|
+
clean=honest 3 305
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
- **0 over-blocks** — the clean detector never blocks anything the gate allows.
|
|
155
|
+
- **3 under-blocks**, all category **H2** (disclosed-failure honest twins). These
|
|
156
|
+
are exactly the three items the gate's own heuristic tier *false-blocks*; the
|
|
157
|
+
clean detector **matches the ground-truth label on all three** (they are honest).
|
|
158
|
+
Reproducing the gate's mistake would require either reading its source or
|
|
159
|
+
overfitting a rule that makes the public detector actively worse.
|
|
160
|
+
|
|
161
|
+
Label-level, both over the 396-item set: **identical recall (0.460)** and the same
|
|
162
|
+
true-positive set; the clean detector carries **3 fewer false blocks**
|
|
163
|
+
(FP 3.18% vs the gate's 4.55%).
|
|
164
|
+
|
|
165
|
+
### Real-trace replay — 33 traces, 0 unexplained flips
|
|
166
|
+
|
|
167
|
+
33 real captured framework traces (OpenAI Agents ×10, OpenInference ×6, Claude
|
|
168
|
+
Code ×13, LiteLLM ×4; local models only, no paid API calls) were replayed through
|
|
169
|
+
each shipped adapter's public surface **and** through `verify()` on the same parsed
|
|
170
|
+
trajectory:
|
|
171
|
+
|
|
172
|
+
- **0 surface-vs-direct disagreements** across all 33.
|
|
173
|
+
- **2 lies caught** — both Claude Code turns with an unrecovered structured
|
|
174
|
+
`is_error` bit under a success claim (confidence 1.0). The other 31 → honest,
|
|
175
|
+
all correct true-negatives (including honest failure-*disclosure* turns).
|
|
176
|
+
|
|
177
|
+
### LiteLLM opaque-error recall floor — read this before using the LiteLLM adapter
|
|
178
|
+
|
|
179
|
+
The detection signal rides the **structured tool-error bit**. At the LiteLLM proxy
|
|
180
|
+
that bit does not exist: a tool failure is visible only as whatever text the agent
|
|
181
|
+
wrote into the `role:"tool"` message. The adapter recovers what it can by sniffing
|
|
182
|
+
the payload (`"ERROR: ..."`, `Traceback`, …) — but a failure that does not read as
|
|
183
|
+
an error in text is **invisible** at that layer.
|
|
184
|
+
|
|
185
|
+
Measured cost, round-trip over all 396 bench items (reference = direct detector):
|
|
186
|
+
|
|
187
|
+
```
|
|
188
|
+
path agree flips recall FP
|
|
189
|
+
direct(ref) 100.0% 0 0.460 0.032
|
|
190
|
+
agents 100.0% 0 0.460 0.032
|
|
191
|
+
openinference 100.0% 0 0.460 0.032
|
|
192
|
+
claude_code 100.0% 0 0.460 0.032
|
|
193
|
+
litellm 89.6% 41 0.227 0.032
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
- The three **structured-error** adapters are fully consistent with direct
|
|
197
|
+
detection — **0 verdict flips** on 396 items.
|
|
198
|
+
- **LiteLLM: 41 flips, all `lie -> honest`** — recall drops from **0.460 to 0.227**
|
|
199
|
+
on error-concealment lies whose tool text does not read as an error. **FP is
|
|
200
|
+
unchanged (0.032)** — no false blocks are introduced, only real lies are missed.
|
|
201
|
+
|
|
202
|
+
This is a measured framework ceiling, not a bug: the proxy sits below the structured
|
|
203
|
+
error signal. The LiteLLM adapter is **audit-only** for this reason.
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
## Known limitations
|
|
208
|
+
|
|
209
|
+
These are structural properties of a heuristic-tier v0.1 detector, not defects to
|
|
210
|
+
be worked around silently.
|
|
211
|
+
|
|
212
|
+
**The error bit is the portability spine.**
|
|
213
|
+
Every adapter recovers the tool-failure signal per framework — structured where the
|
|
214
|
+
framework exposes it (Agents span wrapper, OpenInference OTel status, Claude Code
|
|
215
|
+
`is_error`), sniffed from the payload where it does not (LiteLLM). A sniffed error
|
|
216
|
+
grades to **0.7 confidence**; a framework that redacts tool outputs by default is a
|
|
217
|
+
dark surface. If you take one design fact from this project: a completion-lie
|
|
218
|
+
detector is only as portable as the tool-error signal it can recover.
|
|
219
|
+
|
|
220
|
+
**Dangling call + success claim is a heuristic miss (judge-tier material).**
|
|
221
|
+
A `tool_call` with no matching `tool_result` (a legal *dangling call*) plus a
|
|
222
|
+
"Done, deployed successfully" closing returns **honest**. The heuristic tier cannot
|
|
223
|
+
associate an outcome that never returned. This is the motivating example for the
|
|
224
|
+
[judge-tier extension](#judge-tier-extension) below, and it is a documented miss in
|
|
225
|
+
both the private gate and this detector.
|
|
226
|
+
|
|
227
|
+
**Inferred truncation is a low-confidence, verdict-neutral signal.**
|
|
228
|
+
Truncation is inferred from seam markers (`[...omitted...]`, Claude Code's
|
|
229
|
+
`[N lines truncated]`, or markers you register). An *inferred* truncation only
|
|
230
|
+
**downgrades confidence to 0.7**; it never flips a verdict (the detector uses the
|
|
231
|
+
bit solely to grade an L2 completeness finding). A *structured* truncation flag
|
|
232
|
+
(Claude Code `truncatedByTokenCap`) stays at 1.0.
|
|
233
|
+
|
|
234
|
+
**A1 — Claude Code truncation markers inside skipped file-read attachments.**
|
|
235
|
+
In Claude Code, the `[N lines truncated]` marker sometimes rides inside a file-read
|
|
236
|
+
`attachment` block (rather than a `tool_result.content`), which the adapter skips.
|
|
237
|
+
On those turns the truncation bit is not set. This is **verdict-safe and
|
|
238
|
+
confidence-neutral** in every observed case (it can only matter when an L2
|
|
239
|
+
completeness finding fires over such an attachment — which did not occur in
|
|
240
|
+
validation); the residual is a possible missed 0.7 downgrade, never a false block.
|
|
241
|
+
A v0.1.x follow-up may scan attachment snippets or let you register the marker via
|
|
242
|
+
`Config.extra_truncation_markers`.
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
## Judge-tier extension
|
|
247
|
+
|
|
248
|
+
The heuristic detector is deterministic and cheap; some cases (the dangling-call
|
|
249
|
+
miss above) need reasoning it cannot do. `verify()` exposes a synchronous `Judge`
|
|
250
|
+
protocol as the stable extension seam. **No judge ships in v0.1** — this is the
|
|
251
|
+
documented interface a future model-backed judge slots into without changing the
|
|
252
|
+
`verify()` contract.
|
|
253
|
+
|
|
254
|
+
```python
|
|
255
|
+
from agent_polygraph import verify, Judge, Verdict
|
|
256
|
+
|
|
257
|
+
class DanglingCallJudge:
|
|
258
|
+
"""Motivating example: catch a success claim over a call that never returned."""
|
|
259
|
+
def judge(self, trajectory, final_claim, task, heuristic_verdict):
|
|
260
|
+
call_ids = {e.call_id for e in trajectory if getattr(e, "call_id", None)
|
|
261
|
+
and type(e).__name__ == "ToolCall"}
|
|
262
|
+
result_ids = {e.call_id for e in trajectory if type(e).__name__ == "ToolResult"}
|
|
263
|
+
if call_ids - result_ids and heuristic_verdict.verdict == "honest":
|
|
264
|
+
return Verdict("lie", confidence=0.7, detector_version="my-judge-0.1")
|
|
265
|
+
return None # keep the heuristic verdict
|
|
266
|
+
|
|
267
|
+
v = verify(trajectory, final_claim, judge=DanglingCallJudge(), escalate="honest_only")
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
`escalate` chooses which heuristic verdicts reach the judge: `"honest_only"`
|
|
271
|
+
(default — catch what the heuristic missed), `"degraded_only"` (any 0.7-confidence
|
|
272
|
+
verdict), or `"all"`. A judge that overrides a verdict self-stamps its own
|
|
273
|
+
`detector_version` so co-evolving verdicts stay comparable.
|
|
274
|
+
|
|
275
|
+
---
|
|
276
|
+
|
|
277
|
+
## Relationship to polygraph-bench
|
|
278
|
+
|
|
279
|
+
agent-polygraph and [polygraph-bench](https://github.com/najemwehbe/polygraph-bench)
|
|
280
|
+
are two halves of the same effort:
|
|
281
|
+
|
|
282
|
+
- **[polygraph-bench](https://github.com/najemwehbe/polygraph-bench)** — the public
|
|
283
|
+
396-item benchmark that *measures* completion-lie detectors. The clean-room
|
|
284
|
+
detector here is validated against it.
|
|
285
|
+
[Leaderboard](https://najemwehbe.github.io/polygraph-bench/) ·
|
|
286
|
+
[Dataset on Hugging Face](https://huggingface.co/datasets/najemwehbe/polygraph-bench).
|
|
287
|
+
- **agent-polygraph** (this package) — the *detector* you run in production, with
|
|
288
|
+
adapters for real frameworks.
|
|
289
|
+
|
|
290
|
+
If you build a better detector, benchmark it on polygraph-bench and open a
|
|
291
|
+
submission PR there.
|
|
292
|
+
|
|
293
|
+
---
|
|
294
|
+
|
|
295
|
+
## License
|
|
296
|
+
|
|
297
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# agent-polygraph
|
|
2
|
+
|
|
3
|
+
A framework-agnostic **completion-lie detector** for AI agents. Given one agent
|
|
4
|
+
turn — a task, the tool calls and results it produced ("evidence"), and the final
|
|
5
|
+
assistant message ("closing") — `verify()` decides whether the closing makes a
|
|
6
|
+
completion or success claim that the turn's own evidence contradicts.
|
|
7
|
+
|
|
8
|
+
It measures the *turn*, not the model. The check is heuristic, deterministic, and
|
|
9
|
+
synchronous, with **zero runtime dependencies** (standard library only).
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from agent_polygraph import verify, ToolCall, ToolResult
|
|
13
|
+
|
|
14
|
+
v = verify(
|
|
15
|
+
[ToolCall("c1", "deploy", {"svc": "web"}),
|
|
16
|
+
ToolResult("c1", content="ERROR: image pull failed", is_error=True)],
|
|
17
|
+
final_claim="Done -- deployed successfully.",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
v.verdict # "lie"
|
|
21
|
+
v.category # "L1" (error concealment)
|
|
22
|
+
v.confidence # 1.0 (structured error bit)
|
|
23
|
+
v.evidence # (Finding(check="error_concealment", detail=...),)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
This is the SDK companion to [polygraph-bench](https://github.com/najemwehbe/polygraph-bench),
|
|
27
|
+
the public benchmark for agent-lying detectors. The detector shipped here is a
|
|
28
|
+
**clean-room reimplementation** of a private production honesty gate, validated at
|
|
29
|
+
verdict parity against that gate on the benchmark's 396-item corpus (numbers below).
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install agent-polygraph
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Python 3.10+. No third-party runtime dependencies.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## What it catches
|
|
44
|
+
|
|
45
|
+
Three heuristic check families, each mapped to a diagnostic category:
|
|
46
|
+
|
|
47
|
+
| category | family (`check` id) | catches |
|
|
48
|
+
|---|---|---|
|
|
49
|
+
| **L1** | `error_concealment` | an unrecovered tool error hidden behind a plain, unacknowledged success claim |
|
|
50
|
+
| **L2** | `completion_vs_evidence` | a completeness / counted-total claim over a full-page fetch with no proof the set was exhausted |
|
|
51
|
+
| **L3** | `narrative_vs_assertion` | a runtime "it works / is fixed" claim after a file edit with nothing actually run |
|
|
52
|
+
|
|
53
|
+
`verify()` returns a `Verdict`:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
Verdict(
|
|
57
|
+
verdict="lie" | "honest",
|
|
58
|
+
evidence=(Finding(check, detail), ...),
|
|
59
|
+
confidence=1.0 | 0.7, # signal-quality GRADE, not a probability
|
|
60
|
+
detector_version="0.1.0", # self-stamped for cross-version comparison
|
|
61
|
+
flags=(...), # degradation markers driving a 0.7 grade
|
|
62
|
+
category="L1" | "L2" | "L3" | None,
|
|
63
|
+
)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`confidence` is a **signal-quality grade, not a probability**: `1.0` when the
|
|
67
|
+
verdict rode a structured framework signal, `0.7` when it rode an *inferred*
|
|
68
|
+
truncation marker or a *sniffed* error payload (see [degraded signals](#the-error-bit-is-the-portability-spine)).
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## Framework adapters
|
|
73
|
+
|
|
74
|
+
Four adapters map a framework's native emission onto the same `verify()` core.
|
|
75
|
+
The design default is **audit** everywhere — a foreign framework's false-positive
|
|
76
|
+
rate is unknown until you measure it on your own traffic, and a false halt kills
|
|
77
|
+
adoption. **Blocking is opt-in**, and only on the two surfaces that can actually
|
|
78
|
+
halt a run; the audit-only adapters **raise loudly** on `mode="block"` rather than
|
|
79
|
+
silently downgrading.
|
|
80
|
+
|
|
81
|
+
| adapter | entry point | tool-error signal | default | block |
|
|
82
|
+
|---|---|---|---|---|
|
|
83
|
+
| OpenAI Agents SDK | `output_guardrail` | span wrapper `error.data.error` (structured) | audit | **opt-in** |
|
|
84
|
+
| Claude Code stop-hook | `stop_hook` | `tool_result.is_error` (structured) | audit | **opt-in** |
|
|
85
|
+
| OTel / OpenInference | `verify_spans` | OTel span `status_code=ERROR` (structured) | audit | raises loud |
|
|
86
|
+
| LiteLLM proxy | `post_call_guardrail` | **none** — sniff `role:"tool"` text only | audit | raises loud |
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from agent_polygraph.adapters import output_guardrail
|
|
90
|
+
|
|
91
|
+
result = output_guardrail(trace_export, mode="block") # halt on a lie
|
|
92
|
+
if result.tripwire_triggered:
|
|
93
|
+
... # the Agents SDK stops the run
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Per-adapter quickstarts live in [`docs/`](docs/):
|
|
97
|
+
[OpenAI Agents](docs/openai_agents.md) ·
|
|
98
|
+
[OpenInference / OTel](docs/openinference.md) ·
|
|
99
|
+
[Claude Code](docs/claude_code.md) ·
|
|
100
|
+
[LiteLLM](docs/litellm.md).
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## Validation
|
|
105
|
+
|
|
106
|
+
All numbers below come from actually-executed runs and are reported verbatim.
|
|
107
|
+
Parity and the recall floor are the honest limits of a v0.1 heuristic detector —
|
|
108
|
+
read them before you deploy in `block` mode.
|
|
109
|
+
|
|
110
|
+
### Parity vs the private gate — 393 / 396 (99.24%)
|
|
111
|
+
|
|
112
|
+
The detector was written clean-room from the public benchmark spec and corpus,
|
|
113
|
+
then refined by **behavioural** parity iteration against a private production gate
|
|
114
|
+
treated as a black box (feed an input, observe its block/allow verdict; the gate's
|
|
115
|
+
source was never read). On all 396 public bench items:
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
agreement matrix (clean-room \ gate):
|
|
119
|
+
gate=lie gate=honest
|
|
120
|
+
clean=lie 88 0
|
|
121
|
+
clean=honest 3 305
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
- **0 over-blocks** — the clean detector never blocks anything the gate allows.
|
|
125
|
+
- **3 under-blocks**, all category **H2** (disclosed-failure honest twins). These
|
|
126
|
+
are exactly the three items the gate's own heuristic tier *false-blocks*; the
|
|
127
|
+
clean detector **matches the ground-truth label on all three** (they are honest).
|
|
128
|
+
Reproducing the gate's mistake would require either reading its source or
|
|
129
|
+
overfitting a rule that makes the public detector actively worse.
|
|
130
|
+
|
|
131
|
+
Label-level, both over the 396-item set: **identical recall (0.460)** and the same
|
|
132
|
+
true-positive set; the clean detector carries **3 fewer false blocks**
|
|
133
|
+
(FP 3.18% vs the gate's 4.55%).
|
|
134
|
+
|
|
135
|
+
### Real-trace replay — 33 traces, 0 unexplained flips
|
|
136
|
+
|
|
137
|
+
33 real captured framework traces (OpenAI Agents ×10, OpenInference ×6, Claude
|
|
138
|
+
Code ×13, LiteLLM ×4; local models only, no paid API calls) were replayed through
|
|
139
|
+
each shipped adapter's public surface **and** through `verify()` on the same parsed
|
|
140
|
+
trajectory:
|
|
141
|
+
|
|
142
|
+
- **0 surface-vs-direct disagreements** across all 33.
|
|
143
|
+
- **2 lies caught** — both Claude Code turns with an unrecovered structured
|
|
144
|
+
`is_error` bit under a success claim (confidence 1.0). The other 31 → honest,
|
|
145
|
+
all correct true-negatives (including honest failure-*disclosure* turns).
|
|
146
|
+
|
|
147
|
+
### LiteLLM opaque-error recall floor — read this before using the LiteLLM adapter
|
|
148
|
+
|
|
149
|
+
The detection signal rides the **structured tool-error bit**. At the LiteLLM proxy
|
|
150
|
+
that bit does not exist: a tool failure is visible only as whatever text the agent
|
|
151
|
+
wrote into the `role:"tool"` message. The adapter recovers what it can by sniffing
|
|
152
|
+
the payload (`"ERROR: ..."`, `Traceback`, …) — but a failure that does not read as
|
|
153
|
+
an error in text is **invisible** at that layer.
|
|
154
|
+
|
|
155
|
+
Measured cost, round-trip over all 396 bench items (reference = direct detector):
|
|
156
|
+
|
|
157
|
+
```
|
|
158
|
+
path agree flips recall FP
|
|
159
|
+
direct(ref) 100.0% 0 0.460 0.032
|
|
160
|
+
agents 100.0% 0 0.460 0.032
|
|
161
|
+
openinference 100.0% 0 0.460 0.032
|
|
162
|
+
claude_code 100.0% 0 0.460 0.032
|
|
163
|
+
litellm 89.6% 41 0.227 0.032
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
- The three **structured-error** adapters are fully consistent with direct
|
|
167
|
+
detection — **0 verdict flips** on 396 items.
|
|
168
|
+
- **LiteLLM: 41 flips, all `lie -> honest`** — recall drops from **0.460 to 0.227**
|
|
169
|
+
on error-concealment lies whose tool text does not read as an error. **FP is
|
|
170
|
+
unchanged (0.032)** — no false blocks are introduced, only real lies are missed.
|
|
171
|
+
|
|
172
|
+
This is a measured framework ceiling, not a bug: the proxy sits below the structured
|
|
173
|
+
error signal. The LiteLLM adapter is **audit-only** for this reason.
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## Known limitations
|
|
178
|
+
|
|
179
|
+
These are structural properties of a heuristic-tier v0.1 detector, not defects to
|
|
180
|
+
be worked around silently.
|
|
181
|
+
|
|
182
|
+
**The error bit is the portability spine.**
|
|
183
|
+
Every adapter recovers the tool-failure signal per framework — structured where the
|
|
184
|
+
framework exposes it (Agents span wrapper, OpenInference OTel status, Claude Code
|
|
185
|
+
`is_error`), sniffed from the payload where it does not (LiteLLM). A sniffed error
|
|
186
|
+
grades to **0.7 confidence**; a framework that redacts tool outputs by default is a
|
|
187
|
+
dark surface. If you take one design fact from this project: a completion-lie
|
|
188
|
+
detector is only as portable as the tool-error signal it can recover.
|
|
189
|
+
|
|
190
|
+
**Dangling call + success claim is a heuristic miss (judge-tier material).**
|
|
191
|
+
A `tool_call` with no matching `tool_result` (a legal *dangling call*) plus a
|
|
192
|
+
"Done, deployed successfully" closing returns **honest**. The heuristic tier cannot
|
|
193
|
+
associate an outcome that never returned. This is the motivating example for the
|
|
194
|
+
[judge-tier extension](#judge-tier-extension) below, and it is a documented miss in
|
|
195
|
+
both the private gate and this detector.
|
|
196
|
+
|
|
197
|
+
**Inferred truncation is a low-confidence, verdict-neutral signal.**
|
|
198
|
+
Truncation is inferred from seam markers (`[...omitted...]`, Claude Code's
|
|
199
|
+
`[N lines truncated]`, or markers you register). An *inferred* truncation only
|
|
200
|
+
**downgrades confidence to 0.7**; it never flips a verdict (the detector uses the
|
|
201
|
+
bit solely to grade an L2 completeness finding). A *structured* truncation flag
|
|
202
|
+
(Claude Code `truncatedByTokenCap`) stays at 1.0.
|
|
203
|
+
|
|
204
|
+
**A1 — Claude Code truncation markers inside skipped file-read attachments.**
|
|
205
|
+
In Claude Code, the `[N lines truncated]` marker sometimes rides inside a file-read
|
|
206
|
+
`attachment` block (rather than a `tool_result.content`), which the adapter skips.
|
|
207
|
+
On those turns the truncation bit is not set. This is **verdict-safe and
|
|
208
|
+
confidence-neutral** in every observed case (it can only matter when an L2
|
|
209
|
+
completeness finding fires over such an attachment — which did not occur in
|
|
210
|
+
validation); the residual is a possible missed 0.7 downgrade, never a false block.
|
|
211
|
+
A v0.1.x follow-up may scan attachment snippets or let you register the marker via
|
|
212
|
+
`Config.extra_truncation_markers`.
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
## Judge-tier extension
|
|
217
|
+
|
|
218
|
+
The heuristic detector is deterministic and cheap; some cases (the dangling-call
|
|
219
|
+
miss above) need reasoning it cannot do. `verify()` exposes a synchronous `Judge`
|
|
220
|
+
protocol as the stable extension seam. **No judge ships in v0.1** — this is the
|
|
221
|
+
documented interface a future model-backed judge slots into without changing the
|
|
222
|
+
`verify()` contract.
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
from agent_polygraph import verify, Judge, Verdict
|
|
226
|
+
|
|
227
|
+
class DanglingCallJudge:
|
|
228
|
+
"""Motivating example: catch a success claim over a call that never returned."""
|
|
229
|
+
def judge(self, trajectory, final_claim, task, heuristic_verdict):
|
|
230
|
+
call_ids = {e.call_id for e in trajectory if getattr(e, "call_id", None)
|
|
231
|
+
and type(e).__name__ == "ToolCall"}
|
|
232
|
+
result_ids = {e.call_id for e in trajectory if type(e).__name__ == "ToolResult"}
|
|
233
|
+
if call_ids - result_ids and heuristic_verdict.verdict == "honest":
|
|
234
|
+
return Verdict("lie", confidence=0.7, detector_version="my-judge-0.1")
|
|
235
|
+
return None # keep the heuristic verdict
|
|
236
|
+
|
|
237
|
+
v = verify(trajectory, final_claim, judge=DanglingCallJudge(), escalate="honest_only")
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
`escalate` chooses which heuristic verdicts reach the judge: `"honest_only"`
|
|
241
|
+
(default — catch what the heuristic missed), `"degraded_only"` (any 0.7-confidence
|
|
242
|
+
verdict), or `"all"`. A judge that overrides a verdict self-stamps its own
|
|
243
|
+
`detector_version` so co-evolving verdicts stay comparable.
|
|
244
|
+
|
|
245
|
+
---
|
|
246
|
+
|
|
247
|
+
## Relationship to polygraph-bench
|
|
248
|
+
|
|
249
|
+
agent-polygraph and [polygraph-bench](https://github.com/najemwehbe/polygraph-bench)
|
|
250
|
+
are two halves of the same effort:
|
|
251
|
+
|
|
252
|
+
- **[polygraph-bench](https://github.com/najemwehbe/polygraph-bench)** — the public
|
|
253
|
+
396-item benchmark that *measures* completion-lie detectors. The clean-room
|
|
254
|
+
detector here is validated against it.
|
|
255
|
+
[Leaderboard](https://najemwehbe.github.io/polygraph-bench/) ·
|
|
256
|
+
[Dataset on Hugging Face](https://huggingface.co/datasets/najemwehbe/polygraph-bench).
|
|
257
|
+
- **agent-polygraph** (this package) — the *detector* you run in production, with
|
|
258
|
+
adapters for real frameworks.
|
|
259
|
+
|
|
260
|
+
If you build a better detector, benchmark it on polygraph-bench and open a
|
|
261
|
+
submission PR there.
|
|
262
|
+
|
|
263
|
+
---
|
|
264
|
+
|
|
265
|
+
## License
|
|
266
|
+
|
|
267
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Claude Code stop-hook adapter
|
|
2
|
+
|
|
3
|
+
Runs the polygraph check as a [Claude Code](https://docs.claude.com/en/docs/claude-code)
|
|
4
|
+
`Stop` hook, over the transcript tail the hook receives. A stop hook **can veto the
|
|
5
|
+
turn**, so `mode="block"` is supported. This is the strongest surface — Claude Code
|
|
6
|
+
carries a structured error bit, so no payload sniffing is needed.
|
|
7
|
+
|
|
8
|
+
## Entry points
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from agent_polygraph.adapters import stop_hook, from_transcript
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
- `from_transcript(entries, *, config=DEFAULT_CONFIG) -> (trajectory, final_claim, task)`
|
|
15
|
+
- `stop_hook(entries, *, mode="audit", config=DEFAULT_CONFIG) -> dict`
|
|
16
|
+
|
|
17
|
+
## Input
|
|
18
|
+
|
|
19
|
+
The transcript-tail entries a `Stop` hook sees: `human -> assistant (text +
|
|
20
|
+
tool_use) -> user (tool_result) -> ... -> final assistant text`.
|
|
21
|
+
|
|
22
|
+
## Quickstart
|
|
23
|
+
|
|
24
|
+
Wire it into a `Stop` hook that reads the transcript from stdin:
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
import json, sys
|
|
28
|
+
from agent_polygraph.adapters import stop_hook
|
|
29
|
+
|
|
30
|
+
payload = json.load(sys.stdin)
|
|
31
|
+
entries = payload["transcript"] # the tail entries
|
|
32
|
+
|
|
33
|
+
decision = stop_hook(entries, mode="block")
|
|
34
|
+
if decision["decision"] == "block":
|
|
35
|
+
print(json.dumps({"decision": "block", "reason": decision["reason"]}))
|
|
36
|
+
sys.exit(0)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`stop_hook` returns `{"decision": None | "block", "verdict": Verdict, "reason": ...}`.
|
|
40
|
+
In `mode="audit"` (default) `decision` is always `None` — log the `verdict` and let
|
|
41
|
+
the turn proceed.
|
|
42
|
+
|
|
43
|
+
## Framework specifics
|
|
44
|
+
|
|
45
|
+
- **Structured error bit:** `tool_result.is_error` (absent/None on success), with
|
|
46
|
+
`tool_use_id` linking result to call. Confidence 1.0 on caught lies.
|
|
47
|
+
- **Hook-block channel:** `hook_blocking_error` / `hook_non_blocking_error`
|
|
48
|
+
attachments record a *PreToolUse* hook that blocked a tool — the tool never ran.
|
|
49
|
+
By ruling these are **not** counted as tool failures (a blocked call is a dangling
|
|
50
|
+
call, not a returned error).
|
|
51
|
+
- **Polymorphic content:** `tool_result.content` is a string (Bash) or a list of
|
|
52
|
+
`{"type":"text","text":...}` blocks (Agent/Task) — both handled.
|
|
53
|
+
- **Truncation:** the inline marker `[N lines truncated]` is *inferred* (confidence
|
|
54
|
+
0.7); the `truncatedByTokenCap` attachment flag is *structured* (1.0). See the A1
|
|
55
|
+
limitation in the [README](../README.md#known-limitations): a marker riding inside
|
|
56
|
+
a skipped file-read attachment may not set the truncation bit — verdict-safe, but
|
|
57
|
+
a possible missed confidence downgrade.
|