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,62 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ agent-polygraph -- a framework-agnostic completion-lie detector for AI agents.
4
+
5
+ Given one agent turn -- a task, the tool calls and results it produced, and the
6
+ final assistant message ("closing") -- ``verify()`` decides whether the closing
7
+ makes a completion or success claim that the turn's own evidence contradicts.
8
+
9
+ from agent_polygraph import verify, ToolCall, ToolResult
10
+
11
+ v = verify(
12
+ [ToolCall("c1", "deploy", {"svc": "web"}),
13
+ ToolResult("c1", content="ERROR: image pull failed", is_error=True)],
14
+ final_claim="Done -- deployed successfully.",
15
+ )
16
+ v.verdict # "lie"
17
+ v.category # "L1" (error concealment)
18
+
19
+ The detector is the clean-room, heuristic-tier reimplementation validated at
20
+ verdict parity with a private production gate (see README). Four framework
21
+ adapters map native emissions onto the same ``verify()`` core:
22
+
23
+ from agent_polygraph.adapters import (
24
+ output_guardrail, # OpenAI Agents SDK
25
+ verify_spans, # OTel / OpenInference
26
+ stop_hook, # Claude Code
27
+ post_call_guardrail, # LiteLLM
28
+ )
29
+
30
+ Pure, synchronous, zero runtime dependencies (standard library only).
31
+ """
32
+ from __future__ import annotations
33
+
34
+ from .events import (Message, ToolCall, ToolResult, Event,
35
+ from_bench, to_bench, to_item, normalize_event)
36
+ from .verify import (verify, Verdict, Finding, Config, DEFAULT_CONFIG,
37
+ DETECTOR_VERSION)
38
+ from .judge import Judge
39
+
40
+ # Adapter entry points, surfaced at the top level for discoverability.
41
+ from .adapters.openai_agents import output_guardrail, from_openai_agents
42
+ from .adapters.openinference import verify_spans, from_openinference
43
+ from .adapters.claude_code import stop_hook, from_transcript
44
+ from .adapters.litellm import post_call_guardrail, from_litellm
45
+
46
+ __version__ = "0.1.0"
47
+ # detector_version tracks the package version 1:1 (verdicts self-stamp it).
48
+ detector_version = DETECTOR_VERSION
49
+
50
+ __all__ = [
51
+ # core
52
+ "verify", "Verdict", "Finding", "Config", "DEFAULT_CONFIG",
53
+ "Judge", "detector_version", "DETECTOR_VERSION",
54
+ # event model
55
+ "Message", "ToolCall", "ToolResult", "Event",
56
+ "from_bench", "to_bench", "to_item", "normalize_event",
57
+ # adapter entry points
58
+ "output_guardrail", "from_openai_agents",
59
+ "verify_spans", "from_openinference",
60
+ "stop_hook", "from_transcript",
61
+ "post_call_guardrail", "from_litellm",
62
+ ]
@@ -0,0 +1,27 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Framework adapters for agent-polygraph.
4
+
5
+ Each adapter maps one framework's native emission shape into the locked SDK
6
+ trajectory model and calls ``verify()``. Import the entry point you need:
7
+
8
+ * ``openai_agents.output_guardrail`` -- OpenAI Agents SDK output guardrail (audit + block)
9
+ * ``openinference.verify_spans`` -- OTel / OpenInference span consumer (audit only)
10
+ * ``claude_code.stop_hook`` -- Claude Code Stop hook (audit + block)
11
+ * ``litellm.post_call_guardrail`` -- LiteLLM post-call guardrail (audit only)
12
+
13
+ The four modules are also re-exported here for convenience.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from .openai_agents import from_openai_agents, output_guardrail, GuardrailResult
18
+ from .openinference import from_openinference, verify_spans
19
+ from .claude_code import from_transcript, stop_hook
20
+ from .litellm import from_litellm, post_call_guardrail
21
+
22
+ __all__ = [
23
+ "from_openai_agents", "output_guardrail", "GuardrailResult",
24
+ "from_openinference", "verify_spans",
25
+ "from_transcript", "stop_hook",
26
+ "from_litellm", "post_call_guardrail",
27
+ ]
@@ -0,0 +1,173 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Adapter 3 -- Claude Code stop-hook.
4
+
5
+ Consumes the transcript-tail entries a Stop hook sees (human -> assistant
6
+ text+tool_use -> user tool_result -> ... -> final assistant text) and maps them
7
+ to an SDK trajectory. Blocking IS allowed (a stop hook can veto the turn).
8
+
9
+ Real-emission deltas honoured (CAPTURE-NOTES) -- this is the strongest surface:
10
+
11
+ * Structured error bit: ``tool_result.is_error == true`` (absent/None on
12
+ success), with ``tool_use_id`` linking result <-> call. No sniffing needed.
13
+ * A SECOND error channel: ``attachment`` entries ``hook_blocking_error`` /
14
+ ``hook_non_blocking_error`` record a PreToolUse hook that BLOCKED a tool (it
15
+ never ran). These are NOT tool_results; by ruling they are NOT counted as
16
+ tool failures (a blocked call is a dangling call, not a returned error).
17
+ * ``tool_result.content`` is polymorphic: a string (Bash) or a list of
18
+ ``{"type":"text","text":...}`` blocks (Agent/Task). Both handled.
19
+ * Truncation is STRUCTURED here: the inline marker is ``[N lines truncated]``
20
+ (NOT the bench ``[...omitted...]``) and file-read attachments carry
21
+ ``truncatedByTokenCap: true`` with line counts. The marker match is INFERRED
22
+ (degraded); the ``truncatedByTokenCap`` flag is STRUCTURED (NOT degraded).
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import re
27
+ from typing import Any, Dict, List, Optional, Tuple
28
+
29
+ from ..events import Message, ToolCall, ToolResult, matches_truncation, redact
30
+ from ..verify import Config, DEFAULT_CONFIG, verify
31
+
32
+ # Claude Code's real inline truncation marker: "... [N lines truncated] ...".
33
+ _CC_TRUNC_MARKER = re.compile(r"\[\d+\s+lines truncated\]")
34
+ _DEFAULT_TRUNC_MARKERS: Tuple[Any, ...] = (_CC_TRUNC_MARKER,)
35
+
36
+
37
+ def _text_from_content(content: Any) -> str:
38
+ """Flatten polymorphic tool_result / message content to text."""
39
+ if isinstance(content, str):
40
+ return content
41
+ if isinstance(content, list):
42
+ parts = []
43
+ for b in content:
44
+ if isinstance(b, dict):
45
+ if b.get("type") == "text":
46
+ parts.append(b.get("text") or "")
47
+ elif "text" in b:
48
+ parts.append(b.get("text") or "")
49
+ elif isinstance(b, str):
50
+ parts.append(b)
51
+ return "\n".join(p for p in parts if p)
52
+ if content is None:
53
+ return ""
54
+ return str(content)
55
+
56
+
57
+ def _message(entry: Dict[str, Any]) -> Dict[str, Any]:
58
+ m = entry.get("message")
59
+ return m if isinstance(m, dict) else {}
60
+
61
+
62
+ def from_transcript(entries: List[Dict[str, Any]], *,
63
+ config: Config = DEFAULT_CONFIG
64
+ ) -> Tuple[List[Any], str, str]:
65
+ """Claude Code transcript entries -> (trajectory, final_claim, task).
66
+
67
+ final_claim = the LAST assistant text block(s). task = the FIRST human user
68
+ text (best-effort; the detector does not consume task).
69
+ """
70
+ markers = _DEFAULT_TRUNC_MARKERS + tuple(config.extra_truncation_markers)
71
+ task = ""
72
+ traj: List[Any] = []
73
+ last_assistant_text = ""
74
+ # collect truncatedByTokenCap tool_use_ids from file-read attachments
75
+ structured_trunc_ids: set = set()
76
+
77
+ # first pass: structured truncation flags on attachments
78
+ for e in entries or []:
79
+ if not isinstance(e, dict) or e.get("type") != "attachment":
80
+ continue
81
+ att = e.get("attachment") or {}
82
+ if att.get("truncatedByTokenCap"):
83
+ tid = att.get("toolUseID") or att.get("tool_use_id")
84
+ if tid:
85
+ structured_trunc_ids.add(tid)
86
+
87
+ for e in entries or []:
88
+ if not isinstance(e, dict) or "_meta" in e:
89
+ continue
90
+ etype = e.get("type")
91
+ if etype == "attachment":
92
+ continue # side channel; hook-block errors are NOT tool failures (ruling)
93
+
94
+ msg = _message(e)
95
+ role = msg.get("role")
96
+ content = msg.get("content")
97
+
98
+ if etype == "user":
99
+ # a user entry is either the human task (string) or tool_result blocks
100
+ if isinstance(content, str):
101
+ if not task and content.strip():
102
+ task = content
103
+ continue
104
+ if isinstance(content, list):
105
+ for b in content:
106
+ if isinstance(b, dict) and b.get("type") == "tool_result":
107
+ cid = b.get("tool_use_id") or ""
108
+ body = _text_from_content(b.get("content"))
109
+ is_error = bool(b.get("is_error")) # None/absent -> False
110
+ # truncation: structured flag OR inline marker (inferred)
111
+ inferred = matches_truncation(body, markers)
112
+ structured = cid in structured_trunc_ids
113
+ truncated = inferred or structured
114
+ if config.redact_paths:
115
+ body = redact(body)
116
+ traj.append(ToolResult(
117
+ cid, content=body, is_error=is_error,
118
+ truncated=truncated,
119
+ error_sniffed=False, # structured bit
120
+ truncation_inferred=(inferred and not structured)))
121
+
122
+ elif etype == "assistant":
123
+ texts = []
124
+ if isinstance(content, list):
125
+ for b in content:
126
+ if not isinstance(b, dict):
127
+ continue
128
+ bt = b.get("type")
129
+ if bt == "text":
130
+ texts.append(b.get("text") or "")
131
+ elif bt == "tool_use":
132
+ traj.append(ToolCall(
133
+ b.get("id") or "", name=b.get("name") or "?",
134
+ arguments=b.get("input", "")))
135
+ # 'thinking' blocks ignored
136
+ elif isinstance(content, str):
137
+ texts.append(content)
138
+ joined = "\n".join(t for t in texts if t)
139
+ if joined.strip():
140
+ # emit as a mid-trajectory message AND track as running final claim
141
+ traj.append(Message(redact(joined) if config.redact_paths else joined))
142
+ last_assistant_text = joined
143
+
144
+ final_claim = redact(last_assistant_text) if config.redact_paths else last_assistant_text
145
+ # the final assistant text is the claim, not a mid-trajectory message: drop
146
+ # the trailing duplicate Message so the closing isn't double-counted.
147
+ if traj and isinstance(traj[-1], Message) and traj[-1].text == final_claim:
148
+ traj.pop()
149
+ return traj, final_claim, task
150
+
151
+
152
+ # ---------------------------------------------------------------------------
153
+ # stop-hook surface (block allowed)
154
+ # ---------------------------------------------------------------------------
155
+ def stop_hook(entries: List[Dict[str, Any]], *,
156
+ mode: str = "audit",
157
+ config: Config = DEFAULT_CONFIG) -> Dict[str, Any]:
158
+ """Run the polygraph check as a Claude Code Stop hook.
159
+
160
+ Returns a hook-decision dict. ``mode="block"`` emits ``{"decision":"block",
161
+ "reason":...}`` on a lie (Claude Code halts / feeds the reason back);
162
+ ``mode="audit"`` never blocks. ``verdict`` rides along for logging.
163
+ """
164
+ if mode not in ("audit", "block"):
165
+ raise ValueError(f"mode must be 'audit' or 'block', got {mode!r}")
166
+ traj, claim, task = from_transcript(entries, config=config)
167
+ v = verify(traj, claim, task)
168
+ out: Dict[str, Any] = {"decision": None, "verdict": v}
169
+ if mode == "block" and v.is_lie:
170
+ reason = "; ".join(f.detail for f in v.evidence) or "honesty gate: lie detected"
171
+ out["decision"] = "block"
172
+ out["reason"] = reason
173
+ return out
@@ -0,0 +1,162 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Adapter 4 -- LiteLLM post-call guardrail (AUDIT-ONLY per ruling).
4
+
5
+ Reconstructs the trajectory from a single late call's ``data["messages"]`` (the
6
+ "last-call-has-everything" trick: full-history-resend frameworks put the whole
7
+ conversation -- assistant tool-call turns and ``role:"tool"`` results -- on every
8
+ request). Pairs it with the final response as the claim. By ruling this is
9
+ audit-only; ``mode="block"`` raises loud.
10
+
11
+ Real-emission deltas honoured (CAPTURE-NOTES + ps-litellm-depth):
12
+
13
+ * The FINAL call's ``messages`` = the full trajectory (system, user,
14
+ assistant(tool_calls), tool, ...). Feed the LAST call.
15
+ * Tool calls are OpenAI-standard ``assistant.tool_calls[].function.{name,
16
+ arguments}`` + ``role:"tool"`` messages with ``tool_call_id``/``name``/
17
+ ``content``.
18
+ * OPAQUE tool errors: there is NO structured error field anywhere. A tool
19
+ failure is visible ONLY as text the agent wrote into the ``role:"tool"``
20
+ content (e.g. ``"ERROR: ..."``). Recovery is payload-sniff ONLY -> every
21
+ recovered error is DEGRADED (0.7). A silently-failing tool is invisible.
22
+ * ``assistant.tool_calls[].function`` may arrive as a proper dict OR (capture
23
+ artifact / some SDK reprs) a ``Function(arguments='...', name='...')`` repr
24
+ string -- both are parsed defensively.
25
+ * No truncation flag anywhere; only caller extras / bench seam, always INFERRED.
26
+ * A ``tool_call`` with no matching ``role:"tool"`` message is a dangling call
27
+ -- preserved, never fabricated.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import re
32
+ from typing import Any, Dict, List, Tuple
33
+
34
+ from ..events import (Message, ToolCall, ToolResult, looks_like_error,
35
+ matches_truncation, redact)
36
+ from ..verify import Config, DEFAULT_CONFIG, Verdict, verify
37
+
38
+ # LiteLLM proxy has NO truncation signal (CAPTURE-NOTES). No default marker;
39
+ # supply one via Config.extra_truncation_markers. Any such mark is INFERRED.
40
+ _DEFAULT_TRUNC_MARKERS: Tuple[Any, ...] = ()
41
+ _FUNC_REPR = re.compile(r"name=(['\"])(?P<name>.*?)\1")
42
+ _FUNC_ARGS = re.compile(r"arguments=(['\"])(?P<args>.*?)\1")
43
+
44
+
45
+ def _parse_function(fn: Any) -> Tuple[str, Any]:
46
+ """(name, arguments) from a tool_call.function that may be a dict or a
47
+ ``Function(arguments=..., name=...)`` repr string."""
48
+ if isinstance(fn, dict):
49
+ return fn.get("name") or "?", fn.get("arguments", "")
50
+ if isinstance(fn, str):
51
+ name_m = _FUNC_REPR.search(fn)
52
+ args_m = _FUNC_ARGS.search(fn)
53
+ return (name_m.group("name") if name_m else "?",
54
+ args_m.group("args") if args_m else "")
55
+ return "?", ""
56
+
57
+
58
+ def _final_claim(response: Any) -> str:
59
+ """Extract assistant text from a response / response_message object or dict."""
60
+ if response is None:
61
+ return ""
62
+ if isinstance(response, str):
63
+ return response
64
+ if isinstance(response, dict):
65
+ if "content" in response and isinstance(response["content"], str):
66
+ return response["content"]
67
+ choices = response.get("choices")
68
+ if isinstance(choices, list) and choices:
69
+ msg = choices[0].get("message") if isinstance(choices[0], dict) else None
70
+ if isinstance(msg, dict):
71
+ return msg.get("content") or ""
72
+ return ""
73
+
74
+
75
+ def from_litellm(messages: List[Dict[str, Any]],
76
+ response: Any = None, *,
77
+ config: Config = DEFAULT_CONFIG
78
+ ) -> Tuple[List[Any], str, str]:
79
+ """A single late call's ``messages`` array (+ its response) ->
80
+ (trajectory, final_claim, task)."""
81
+ markers = _DEFAULT_TRUNC_MARKERS + tuple(config.extra_truncation_markers)
82
+ task = ""
83
+ traj: List[Any] = []
84
+ seen_tool_ids: set = set()
85
+
86
+ for m in messages or []:
87
+ if not isinstance(m, dict):
88
+ continue
89
+ role = m.get("role")
90
+ content = m.get("content")
91
+
92
+ if role == "user":
93
+ if not task and isinstance(content, str) and content.strip():
94
+ task = content
95
+ elif role == "assistant":
96
+ if isinstance(content, str) and content.strip():
97
+ traj.append(Message(redact(content) if config.redact_paths else content))
98
+ for tc in m.get("tool_calls") or []:
99
+ if not isinstance(tc, dict):
100
+ continue
101
+ cid = tc.get("id") or ""
102
+ name, args = _parse_function(tc.get("function"))
103
+ traj.append(ToolCall(cid, name=name, arguments=args))
104
+ elif role == "tool":
105
+ cid = m.get("tool_call_id") or ""
106
+ body = content if isinstance(content, str) else _stringify(content)
107
+ seen_tool_ids.add(cid)
108
+ is_error = error_sniffed = False
109
+ # OPAQUE errors: sniff-only recovery (always degraded)
110
+ if config.error_sniff and looks_like_error(body, config.error_patterns):
111
+ is_error = error_sniffed = True
112
+ truncated = matches_truncation(body, markers)
113
+ if config.redact_paths:
114
+ body = redact(body)
115
+ traj.append(ToolResult(cid, content=body, is_error=is_error,
116
+ truncated=truncated,
117
+ error_sniffed=error_sniffed,
118
+ truncation_inferred=truncated))
119
+ # 'system' ignored
120
+
121
+ final_claim = _final_claim(response)
122
+ if config.redact_paths:
123
+ final_claim = redact(final_claim)
124
+ return traj, final_claim, task
125
+
126
+
127
+ def _stringify(v: Any) -> str:
128
+ if isinstance(v, str):
129
+ return v
130
+ if v is None:
131
+ return ""
132
+ try:
133
+ import json
134
+ return json.dumps(v, ensure_ascii=False)
135
+ except (TypeError, ValueError):
136
+ return str(v)
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # post-call guardrail surface (audit-only; block raises loud)
141
+ # ---------------------------------------------------------------------------
142
+ def post_call_guardrail(data: Dict[str, Any],
143
+ response: Any = None, *,
144
+ mode: str = "audit",
145
+ config: Config = DEFAULT_CONFIG) -> Verdict:
146
+ """Audit hook shaped like ``async_post_call_success_hook``: ``data`` is the
147
+ request body (``data["messages"]`` = full resent history), ``response`` the
148
+ completion. ``mode="block"`` raises loud (ruling: post-call audit-only)."""
149
+ if mode == "block":
150
+ raise ValueError(
151
+ "LiteLLM adapter is audit-only (v1 ruling): tool-error status is "
152
+ "opaque at the proxy and blocking sits in the request critical path. "
153
+ "Use audit mode; escalate to a blocking surface elsewhere."
154
+ )
155
+ if mode != "audit":
156
+ raise ValueError(f"mode must be 'audit', got {mode!r}")
157
+ messages = (data or {}).get("messages") or []
158
+ resp = response
159
+ if resp is None:
160
+ resp = (data or {}).get("response") or (data or {}).get("response_message")
161
+ traj, claim, task = from_litellm(messages, resp, config=config)
162
+ return verify(traj, claim, task)
@@ -0,0 +1,176 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Adapter 1 -- OpenAI Agents SDK output guardrail.
4
+
5
+ Consumes the span-export dicts a ``TracingProcessor`` receives (each span's
6
+ native ``Span.export()``), maps them to an SDK trajectory, and exposes an
7
+ ``@output_guardrail``-shaped surface. Blocking IS allowed here (the SDK guardrail
8
+ tripwire can halt a run), so ``mode="block"`` is supported.
9
+
10
+ Real-emission deltas honoured (CAPTURE-NOTES, vs. the throwaway prototype which
11
+ was WRONG about all of these):
12
+
13
+ * ``type`` is nested at ``span_data.type`` -- NOT top-level.
14
+ * The agent span carries NO task/output; task comes from the first
15
+ ``generation`` span's input (the user message), final claim from the LAST
16
+ ``generation`` span's non-empty assistant ``output[0].content``.
17
+ * A tool failure is on the span WRAPPER ``error`` (``SpanError{message,data}``),
18
+ NOT in ``span_data``; the function ``output`` is replaced with a generic
19
+ retry string, so the real error text survives only in ``error.data.error``.
20
+ We read the wrapper first; payload-sniff is the fallback.
21
+ * ``generation`` assistant tool-call turns have ``content=""`` with the call in
22
+ ``tool_calls`` -- those empty turns are NOT the final claim.
23
+ * ``function`` spans always carry an ``output`` (SDK guarantees result-or-error)
24
+ so a genuine dangling call is essentially non-inducible; we still preserve
25
+ ``output is None`` + no wrapper error as a dangling call and never fabricate.
26
+ * ``custom`` spans (turn/task) and the Responses-path caveat (export drops
27
+ content) are documented; ``custom`` spans are ignored.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ from dataclasses import dataclass
32
+ from typing import Any, Dict, List, Optional, Tuple
33
+
34
+ from ..events import (ToolCall, ToolResult, looks_like_error,
35
+ matches_truncation, redact)
36
+ from ..verify import Config, DEFAULT_CONFIG, Verdict, verify
37
+
38
+ # Agents SDK has NO truncation signal at all (CAPTURE-NOTES). No default marker;
39
+ # a deployment supplies one via Config.extra_truncation_markers. Any such mark is
40
+ # INFERRED (degraded). The verdict is truncation-independent anyway (the detector
41
+ # uses the bit only to grade confidence), so losing it never flips a verdict.
42
+ _DEFAULT_TRUNC_MARKERS: Tuple[Any, ...] = ()
43
+
44
+
45
+ def _span_type(sp: Dict[str, Any]) -> Optional[str]:
46
+ sd = sp.get("span_data")
47
+ if isinstance(sd, dict):
48
+ return sd.get("type")
49
+ return None
50
+
51
+
52
+ def _generation_final_text(sp: Dict[str, Any]) -> Optional[str]:
53
+ """Non-empty assistant text from a generation span, or None (tool-call turn)."""
54
+ sd = sp.get("span_data") or {}
55
+ for msg in sd.get("output") or []:
56
+ if isinstance(msg, dict) and msg.get("role") == "assistant":
57
+ content = msg.get("content")
58
+ if isinstance(content, str) and content.strip():
59
+ return content
60
+ return None
61
+
62
+
63
+ def _generation_user_task(sp: Dict[str, Any]) -> Optional[str]:
64
+ sd = sp.get("span_data") or {}
65
+ for msg in sd.get("input") or []:
66
+ if isinstance(msg, dict) and msg.get("role") == "user":
67
+ content = msg.get("content")
68
+ if isinstance(content, str) and content.strip():
69
+ return content
70
+ return None
71
+
72
+
73
+ def from_openai_agents(trace_export: List[Dict[str, Any]], *,
74
+ config: Config = DEFAULT_CONFIG
75
+ ) -> Tuple[List[Any], str, str]:
76
+ """List of span-export dicts (a trace-processor stream, ``_meta``/``trace``
77
+ envelope lines tolerated) -> (trajectory, final_claim, task)."""
78
+ markers = _DEFAULT_TRUNC_MARKERS + tuple(config.extra_truncation_markers)
79
+ task, final_claim = "", ""
80
+ traj: List[Any] = []
81
+ call_n = 0
82
+
83
+ for sp in trace_export or []:
84
+ if not isinstance(sp, dict):
85
+ continue
86
+ # tolerate the capture envelope lines (_meta / {"object":"trace"})
87
+ if "_meta" in sp or sp.get("object") == "trace":
88
+ continue
89
+ k = _span_type(sp)
90
+ sd = sp.get("span_data") or {}
91
+
92
+ if k == "generation":
93
+ t = _generation_user_task(sp)
94
+ if t and not task:
95
+ task = t
96
+ final = _generation_final_text(sp)
97
+ if final is not None:
98
+ final_claim = final # last non-empty assistant turn wins
99
+
100
+ elif k == "function":
101
+ call_n += 1
102
+ cid = f"fn{call_n}"
103
+ traj.append(ToolCall(cid, name=sd.get("name") or "?",
104
+ arguments=sd.get("input", "")))
105
+ out = sd.get("output")
106
+ wrapper_err = sp.get("error") # SpanError{message,data} or None
107
+ if out is None and not wrapper_err:
108
+ # genuine dangling call (SDK rarely produces this) -- no result
109
+ continue
110
+ # error text: prefer wrapper error.data.error, else the (generic) output
111
+ is_error = False
112
+ error_sniffed = False
113
+ content = out if isinstance(out, str) else ("" if out is None else str(out))
114
+ if wrapper_err:
115
+ is_error = True
116
+ data = wrapper_err.get("data") if isinstance(wrapper_err, dict) else None
117
+ real = None
118
+ if isinstance(data, dict):
119
+ real = data.get("error")
120
+ content = real or (wrapper_err.get("message") if isinstance(wrapper_err, dict) else None) or content
121
+ elif config.error_sniff and looks_like_error(content, config.error_patterns):
122
+ # no structured wrapper error -> sniff the payload (degraded)
123
+ is_error = True
124
+ error_sniffed = True
125
+
126
+ truncated = matches_truncation(content, markers)
127
+ if config.redact_paths:
128
+ content = redact(content)
129
+ traj.append(ToolResult(cid, content=content, is_error=is_error,
130
+ truncated=truncated,
131
+ error_sniffed=error_sniffed,
132
+ truncation_inferred=truncated))
133
+
134
+ elif k == "agent":
135
+ # agent span carries no task/output; a MaxTurnsExceeded / run error
136
+ # lands here but is not a tool-error bit -- ignored for detection.
137
+ continue
138
+ # 'custom' / 'response' / other -> ignored (documented)
139
+
140
+ return traj, final_claim, task
141
+
142
+
143
+ # ---------------------------------------------------------------------------
144
+ # @output_guardrail-shaped surface (tripwire; block allowed)
145
+ # ---------------------------------------------------------------------------
146
+ @dataclass
147
+ class GuardrailResult:
148
+ """Mirrors the Agents SDK ``GuardrailFunctionOutput`` contract."""
149
+ tripwire_triggered: bool
150
+ output_info: Verdict
151
+
152
+ @property
153
+ def verdict(self) -> Verdict:
154
+ return self.output_info
155
+
156
+
157
+ def output_guardrail(trace_export: List[Dict[str, Any]],
158
+ final_claim: Optional[str] = None,
159
+ task: Optional[str] = None,
160
+ *,
161
+ mode: str = "audit",
162
+ config: Config = DEFAULT_CONFIG) -> GuardrailResult:
163
+ """Run the polygraph check as an Agents-SDK output guardrail.
164
+
165
+ ``mode="audit"`` (default) always returns tripwire=False (log-only).
166
+ ``mode="block"`` sets tripwire on a lie -> the SDK halts the run.
167
+ Explicit ``final_claim``/``task`` override what is parsed from the spans
168
+ (useful on the Responses path where export() drops the final content).
169
+ """
170
+ if mode not in ("audit", "block"):
171
+ raise ValueError(f"mode must be 'audit' or 'block', got {mode!r}")
172
+ traj, parsed_claim, parsed_task = from_openai_agents(trace_export, config=config)
173
+ v = verify(traj, final_claim if final_claim is not None else parsed_claim,
174
+ task if task is not None else parsed_task)
175
+ tripwire = (mode == "block" and v.is_lie)
176
+ return GuardrailResult(tripwire_triggered=tripwire, output_info=v)