agent-polygraph 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,218 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Locked SDK event model for agent-polygraph v0.1.
4
+
5
+ Three dataclasses -- ``Message`` / ``ToolCall`` / ``ToolResult`` -- mirror the
6
+ polygraph-bench interchange FORMAT v1.0 event shapes 1:1, so an SDK trajectory
7
+ serialises straight back to a bench item and a bench item deserialises straight
8
+ into a trajectory (``from_bench`` / ``to_bench``). A ``verify()`` trajectory may
9
+ mix dataclasses and raw bench dicts freely: ``normalize_event`` is the
10
+ dict-accepting boundary that folds either into a dataclass.
11
+
12
+ Bench FORMAT v1.0 (the only fields that cross the bench boundary):
13
+
14
+ * message ``{"type": "message", "text": str}``
15
+ * tool_call ``{"type": "tool_call", "call_id": str, "name": str, "arguments": Any}``
16
+ * tool_result ``{"type": "tool_result", "call_id": str, "is_error": bool,
17
+ "truncated": bool, "content": str}``
18
+
19
+ ``ToolResult`` additionally carries two *SDK-internal provenance* flags,
20
+ ``error_sniffed`` and ``truncation_inferred``. These record *how* an adapter
21
+ recovered a degraded signal (payload sniff / marker inference vs. a structured
22
+ framework bit). They are NEVER emitted to bench (``to_bench`` drops them) and are
23
+ absent from ``from_bench`` output; they exist only so ``verify()`` can grade
24
+ confidence 1.0 (structured) vs 0.7 (degraded). See BUILD-NOTES.md.
25
+
26
+ Pure, standard-library only, no I/O, no globals.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ from dataclasses import dataclass
31
+ from typing import Any, Dict, List, Tuple, Union
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # event dataclasses (bench FORMAT v1.0, 1:1)
36
+ # ---------------------------------------------------------------------------
37
+ @dataclass
38
+ class Message:
39
+ """An assistant/model text turn. Empty text is preserved, never dropped."""
40
+ text: str = ""
41
+
42
+ def to_bench(self) -> Dict[str, Any]:
43
+ return {"type": "message", "text": self.text or ""}
44
+
45
+
46
+ @dataclass
47
+ class ToolCall:
48
+ """A tool invocation. A ToolCall with no following ToolResult is a legal
49
+ *dangling call* -- adapters MUST preserve it and MUST NOT fabricate a
50
+ result for it."""
51
+ call_id: str
52
+ name: str = ""
53
+ arguments: Any = ""
54
+
55
+ def to_bench(self) -> Dict[str, Any]:
56
+ return {
57
+ "type": "tool_call",
58
+ "call_id": self.call_id,
59
+ "name": self.name or "",
60
+ "arguments": self.arguments if self.arguments is not None else "",
61
+ }
62
+
63
+
64
+ @dataclass
65
+ class ToolResult:
66
+ """A tool result.
67
+
68
+ ``is_error`` / ``truncated`` are the bench-visible bits. ``error_sniffed``
69
+ and ``truncation_inferred`` are SDK-internal provenance: True means the bit
70
+ was recovered from an unstructured payload sniff / marker inference (a
71
+ DEGRADED signal -> 0.7 confidence), False means it came from a structured
72
+ framework signal (or was never set).
73
+ """
74
+ call_id: str
75
+ content: str = ""
76
+ is_error: bool = False
77
+ truncated: bool = False
78
+ # provenance -- not part of bench FORMAT, dropped by to_bench()
79
+ error_sniffed: bool = False
80
+ truncation_inferred: bool = False
81
+
82
+ def to_bench(self) -> Dict[str, Any]:
83
+ return {
84
+ "type": "tool_result",
85
+ "call_id": self.call_id,
86
+ "is_error": bool(self.is_error),
87
+ "truncated": bool(self.truncated),
88
+ "content": self.content or "",
89
+ }
90
+
91
+
92
+ Event = Union[Message, ToolCall, ToolResult]
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # bench <-> dataclass boundary
97
+ # ---------------------------------------------------------------------------
98
+ def from_bench(d: Dict[str, Any]) -> Event:
99
+ """One bench event dict -> the matching dataclass. Unknown/None types raise."""
100
+ if not isinstance(d, dict):
101
+ raise TypeError(f"bench event must be a dict, got {type(d).__name__}")
102
+ t = d.get("type")
103
+ if t == "message":
104
+ return Message(text=d.get("text") or "")
105
+ if t == "tool_call":
106
+ return ToolCall(
107
+ call_id=d.get("call_id"),
108
+ name=d.get("name") or "",
109
+ arguments=d.get("arguments", ""),
110
+ )
111
+ if t == "tool_result":
112
+ return ToolResult(
113
+ call_id=d.get("call_id"),
114
+ content=d.get("content") or "",
115
+ is_error=bool(d.get("is_error")),
116
+ truncated=bool(d.get("truncated")),
117
+ )
118
+ raise ValueError(f"unknown bench event type: {t!r}")
119
+
120
+
121
+ def normalize_event(x: Union[Event, Dict[str, Any]]) -> Event:
122
+ """Dict-accepting boundary: pass a dataclass through, fold a dict via
123
+ ``from_bench``. This is what lets ``verify()`` accept a mixed trajectory."""
124
+ if isinstance(x, (Message, ToolCall, ToolResult)):
125
+ return x
126
+ return from_bench(x)
127
+
128
+
129
+ def to_bench(trajectory: List[Union[Event, Dict[str, Any]]]) -> List[Dict[str, Any]]:
130
+ """Whole trajectory -> a list of bench event dicts (provenance stripped)."""
131
+ return [normalize_event(e).to_bench() for e in (trajectory or [])]
132
+
133
+
134
+ def to_item(trajectory: List[Union[Event, Dict[str, Any]]],
135
+ final_claim: str = "", task: str = "") -> Dict[str, Any]:
136
+ """Build the interchange ITEM the clean-room detector consumes:
137
+ ``{"task", "events", "closing"}``."""
138
+ return {
139
+ "task": task or "",
140
+ "events": to_bench(trajectory),
141
+ "closing": final_claim or "",
142
+ }
143
+
144
+
145
+ # ---------------------------------------------------------------------------
146
+ # shared adapter helpers: host-path redaction, error-payload sniffing,
147
+ # truncation-marker matching. Kept here so every adapter shares one
148
+ # implementation and one default-marker registry.
149
+ # ---------------------------------------------------------------------------
150
+ import re # noqa: E402 (kept adjacent to the helpers that use it)
151
+
152
+ # Host-path shapes to scrub from ANYTHING an adapter re-emits (real OpenInference
153
+ # tracebacks were found leaking absolute host paths -- CAPTURE-NOTES privacy note).
154
+ # We scrub the PATHS only, never the words "Error"/"Traceback", so error-sniffing
155
+ # still works on redacted content.
156
+ _PATH_PATTERNS: Tuple[re.Pattern, ...] = (
157
+ re.compile(r"[A-Za-z]:\\(?:[^\s\"'<>|]+\\?)+"), # Windows C:\a\b\c
158
+ re.compile(r"(?<![\w.])/(?:home|Users|root|mnt|opt|var|tmp)/[^\s\"'<>|:]+"), # POSIX
159
+ re.compile(r'File "[^"]*"'), # traceback frame path
160
+ )
161
+ _REDACTED = "<path>"
162
+
163
+
164
+ def redact(text: Any) -> Any:
165
+ """Scrub absolute host paths / traceback frame paths from a string. Non-str
166
+ passes through untouched (so structured arguments survive)."""
167
+ if not isinstance(text, str) or not text:
168
+ return text
169
+ out = text
170
+ for pat in _PATH_PATTERNS:
171
+ out = pat.sub(_REDACTED, out)
172
+ return out
173
+
174
+
175
+ # --- error-payload sniffing (recovery for the error-bit collapse) ------------
176
+ # Conservative built-in patterns; a tool result whose body matches is treated as
177
+ # a failure ONLY when the framework gave no structured error bit. Overridable via
178
+ # Config.error_patterns.
179
+ DEFAULT_ERROR_PATTERNS: Tuple[str, ...] = (
180
+ r"^\s*error\b[:\s]",
181
+ r"^\s*exception\b[:\s]",
182
+ r"traceback \(most recent call last\)",
183
+ r"^\s*fatal\b[:\s]",
184
+ )
185
+
186
+
187
+ def looks_like_error(content: str, patterns: Tuple[str, ...] = DEFAULT_ERROR_PATTERNS) -> bool:
188
+ """True iff a tool-result body reads as a failure payload."""
189
+ if not content:
190
+ return False
191
+ low = content.lower()
192
+ return any(re.search(p, low) for p in patterns)
193
+
194
+
195
+ # --- truncation-marker matching ---------------------------------------------
196
+ # Per-framework default markers live in each adapter; this matches a content
197
+ # string against a set of literal-or-regex markers plus caller-supplied extras.
198
+ # The bench universal seam is included so seam-bearing bench content round-trips.
199
+ BENCH_SEAM = "[...omitted...]"
200
+
201
+
202
+ def matches_truncation(content: str, markers: Tuple[Any, ...] = ()) -> bool:
203
+ """True iff content bears any truncation marker. ``markers`` items may be
204
+ plain substrings, ``re.Pattern`` objects, or regex strings starting with the
205
+ sentinel ``re:``."""
206
+ if not content:
207
+ return False
208
+ for m in markers:
209
+ if isinstance(m, re.Pattern):
210
+ if m.search(content):
211
+ return True
212
+ elif isinstance(m, str) and m.startswith("re:"):
213
+ if re.search(m[3:], content):
214
+ return True
215
+ elif isinstance(m, str):
216
+ if m in content:
217
+ return True
218
+ return False
@@ -0,0 +1,55 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Judge-tier extension point for agent-polygraph v0.1 (locked ps-api-shape).
4
+
5
+ The heuristic detector (``detector.py``) is deterministic and cheap, but it
6
+ cannot reach the cases that need genuine reasoning over the trajectory -- the
7
+ motivating example is the *dangling call + success claim* miss: a ``tool_call``
8
+ with no matching ``tool_result`` plus a "Done, deployed successfully" closing is
9
+ returned ``honest`` by the heuristic tier, because a result that never came back
10
+ cannot be associated with the claim. Catching that class needs a judge.
11
+
12
+ ``Judge`` is the sync protocol a caller implements to plug one in; ``escalate``
13
+ decides *which* heuristic verdicts get handed to it. ZERO judges ship in v0.1 --
14
+ this module is the stable seam a future ``AsyncJudge`` / model-backed judge slots
15
+ into without changing the ``verify()`` contract. A judge that overrides a verdict
16
+ self-stamps its own ``detector_version`` so co-evolving verdicts stay comparable.
17
+
18
+ Pure, standard-library only, no I/O, no globals.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ from typing import Any, List, Optional, Protocol, runtime_checkable
23
+
24
+
25
+ @runtime_checkable
26
+ class Judge(Protocol):
27
+ """A pluggable second-tier verifier.
28
+
29
+ Implement ``judge(...)`` to inspect the trajectory the heuristic tier could
30
+ only pattern-match, and return either a replacement ``Verdict`` (which MUST
31
+ self-stamp its own ``detector_version``) or ``None`` to keep the heuristic
32
+ verdict unchanged.
33
+ """
34
+
35
+ def judge(self, trajectory: List[Any], final_claim: str, task: str,
36
+ heuristic_verdict: Any) -> Optional[Any]:
37
+ ...
38
+
39
+
40
+ def should_escalate(escalate: str, verdict: Any) -> bool:
41
+ """Escalation policy: does this heuristic ``verdict`` get handed to the judge?
42
+
43
+ * ``"honest_only"`` (default) -- escalate only heuristic *honest* verdicts
44
+ (the judge exists to catch what the heuristic missed; blocks it already
45
+ caught are trusted).
46
+ * ``"degraded_only"`` -- escalate any verdict that leaned on a degraded
47
+ signal (a non-empty ``flags`` list).
48
+ * ``"all"`` -- escalate every verdict.
49
+ """
50
+ if escalate == "all":
51
+ return True
52
+ if escalate == "degraded_only":
53
+ return bool(getattr(verdict, "flags", ()))
54
+ # default: honest_only
55
+ return getattr(verdict, "verdict", None) == "honest"
File without changes
@@ -0,0 +1,154 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Locked public verify() contract for agent-polygraph v0.1 (ps-api-shape).
4
+
5
+ verify(trajectory, final_claim, task="") -> Verdict
6
+
7
+ Sync, pure, thread-safe, no I/O, no env reads, no global mutable state. One
8
+ detector per package (the clean-room ``detector.py``); this module wires it once
9
+ at import and exposes its version as ``DETECTOR_VERSION`` (== the package
10
+ version).
11
+
12
+ Verdict schema (locked):
13
+ * ``verdict`` -- "lie" | "honest"
14
+ * ``evidence`` -- list[Finding{check, detail}]
15
+ * ``confidence`` -- signal-quality GRADE, not a probability: 1.0 when the
16
+ verdict rode a structured signal, 0.7 when it rode an
17
+ inferred truncation or a sniffed error payload.
18
+ * ``detector_version`` -- self-stamped so verdicts stay comparable across
19
+ detector co-evolution.
20
+ (``flags`` and ``category`` ride along as documented diagnostics.)
21
+
22
+ Confidence grading is computed HERE from adapter provenance, not read from the
23
+ detector's internal flags: a structured truncation bit (e.g. Claude Code's
24
+ ``truncatedByTokenCap``) must NOT degrade confidence, while a marker-*inferred*
25
+ truncation must -- a distinction the detector alone cannot make.
26
+
27
+ Config: frozen dataclass, explicit kwargs. NO env vars, NO global registry.
28
+ Judge tier: sync ``Judge`` protocol + ``escalate`` policy; ZERO judges shipped.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ from dataclasses import dataclass
33
+ from typing import Any, List, Optional, Tuple, Union
34
+
35
+ from . import detector as _DETECTOR
36
+ from .events import (Message, ToolCall, ToolResult, normalize_event, to_item,
37
+ DEFAULT_ERROR_PATTERNS)
38
+ from .judge import Judge, should_escalate as _should_escalate
39
+
40
+ # The one detector per package; detector.py is never modified. Its version is
41
+ # the package version (wired in pyproject via detector.__version__).
42
+ DETECTOR_VERSION: str = getattr(_DETECTOR, "__version__", "0.0.0")
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # result + config types
47
+ # ---------------------------------------------------------------------------
48
+ @dataclass(frozen=True)
49
+ class Finding:
50
+ """One check family's contribution to a lie verdict."""
51
+ check: str
52
+ detail: str
53
+
54
+ def as_dict(self) -> dict:
55
+ return {"check": self.check, "detail": self.detail}
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class Verdict:
60
+ """Locked verdict schema (+ diagnostic flags/category)."""
61
+ verdict: str # "lie" | "honest"
62
+ evidence: Tuple[Finding, ...] = ()
63
+ confidence: float = 1.0 # 1.0 structured | 0.7 degraded
64
+ detector_version: str = DETECTOR_VERSION
65
+ flags: Tuple[str, ...] = () # degradation markers driving a 0.7 grade
66
+ category: Optional[str] = None # L1/L2/L3 diagnostic
67
+
68
+ @property
69
+ def is_lie(self) -> bool:
70
+ return self.verdict == "lie"
71
+
72
+ def as_dict(self) -> dict:
73
+ return {
74
+ "verdict": self.verdict,
75
+ "evidence": [f.as_dict() for f in self.evidence],
76
+ "confidence": self.confidence,
77
+ "detector_version": self.detector_version,
78
+ "flags": list(self.flags),
79
+ "category": self.category,
80
+ }
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class Config:
85
+ """Adapter behaviour knobs. Frozen; explicit; no env, no globals.
86
+
87
+ * ``error_sniff`` -- recover a lost tool-error bit by sniffing the
88
+ result payload when no structured bit exists
89
+ (ON by default; sniffed -> 0.7 grade).
90
+ * ``error_patterns`` -- override the built-in sniff patterns.
91
+ * ``extra_truncation_markers`` -- extra markers appended to each adapter's
92
+ per-framework defaults (literal substrings, or
93
+ ``re:``-prefixed regexes).
94
+ * ``redact_paths`` -- scrub host paths / traceback frames from
95
+ re-emitted content (ON by default).
96
+ """
97
+ error_sniff: bool = True
98
+ error_patterns: Tuple[str, ...] = DEFAULT_ERROR_PATTERNS
99
+ extra_truncation_markers: Tuple[Any, ...] = ()
100
+ redact_paths: bool = True
101
+
102
+
103
+ DEFAULT_CONFIG = Config()
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # the locked entry point
108
+ # ---------------------------------------------------------------------------
109
+ def verify(trajectory: List[Union[Message, ToolCall, ToolResult, dict]],
110
+ final_claim: str = "",
111
+ task: str = "",
112
+ *,
113
+ judge: Optional[Judge] = None,
114
+ escalate: str = "honest_only") -> Verdict:
115
+ """Does ``final_claim`` contradict the trajectory's own tool evidence?
116
+
117
+ Pure and synchronous. ``trajectory`` may mix SDK event dataclasses and raw
118
+ bench dicts. ``judge``/``escalate`` are the documented judge-tier extension
119
+ point (no judge ships in v0.1; the dangling-call miss is its motivating case).
120
+ """
121
+ events = [normalize_event(e) for e in (trajectory or [])]
122
+ item = to_item(events, final_claim, task)
123
+ det = _DETECTOR.detect(item)
124
+
125
+ findings = tuple(Finding(f.check, f.detail) for f in det.findings)
126
+
127
+ # SDK-level confidence grading from ADAPTER provenance (not detector flags):
128
+ # a firing family degrades only if it leaned on a sniffed/inferred signal.
129
+ checks = {f.check for f in findings}
130
+ flags: List[str] = []
131
+ if "error_concealment" in checks and any(
132
+ isinstance(e, ToolResult) and e.error_sniffed for e in events
133
+ ):
134
+ flags.append("error_payload_sniffed")
135
+ if "completion_vs_evidence" in checks and any(
136
+ isinstance(e, ToolResult) and e.truncation_inferred for e in events
137
+ ):
138
+ flags.append("truncation_inferred")
139
+
140
+ confidence = 0.7 if (findings and flags) else 1.0
141
+ result = Verdict(
142
+ verdict=det.verdict,
143
+ evidence=findings,
144
+ confidence=confidence,
145
+ detector_version=DETECTOR_VERSION,
146
+ flags=tuple(flags),
147
+ category=det.category,
148
+ )
149
+
150
+ if judge is not None and _should_escalate(escalate, result):
151
+ replacement = judge.judge(events, final_claim or "", task or "", result)
152
+ if replacement is not None:
153
+ return replacement
154
+ return result