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,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)
|