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.
- agent_polygraph/__init__.py +62 -0
- agent_polygraph/adapters/__init__.py +27 -0
- agent_polygraph/adapters/claude_code.py +173 -0
- agent_polygraph/adapters/litellm.py +162 -0
- agent_polygraph/adapters/openai_agents.py +176 -0
- agent_polygraph/adapters/openinference.py +196 -0
- agent_polygraph/detector.py +394 -0
- agent_polygraph/events.py +218 -0
- agent_polygraph/judge.py +55 -0
- agent_polygraph/py.typed +0 -0
- agent_polygraph/verify.py +154 -0
- agent_polygraph-0.1.0.dist-info/METADATA +297 -0
- agent_polygraph-0.1.0.dist-info/RECORD +16 -0
- agent_polygraph-0.1.0.dist-info/WHEEL +5 -0
- agent_polygraph-0.1.0.dist-info/licenses/LICENSE +21 -0
- agent_polygraph-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
agent_polygraph/judge.py
ADDED
|
@@ -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"
|
agent_polygraph/py.typed
ADDED
|
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
|