agent-observability-trace-cli 0.1.2__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_observability_trace_cli-0.1.2.dist-info/METADATA +336 -0
- agent_observability_trace_cli-0.1.2.dist-info/RECORD +51 -0
- agent_observability_trace_cli-0.1.2.dist-info/WHEEL +4 -0
- agent_observability_trace_cli-0.1.2.dist-info/entry_points.txt +2 -0
- agent_observability_trace_cli-0.1.2.dist-info/licenses/LICENSE +198 -0
- agent_trace/__init__.py +1182 -0
- agent_trace/_cli.py +1377 -0
- agent_trace/_inspect.py +2020 -0
- agent_trace/_replay/__init__.py +1 -0
- agent_trace/_replay/engine.py +268 -0
- agent_trace/_replay/fixture.py +868 -0
- agent_trace/core/__init__.py +1 -0
- agent_trace/core/clock.py +91 -0
- agent_trace/core/exceptions.py +51 -0
- agent_trace/core/span.py +271 -0
- agent_trace/core/trace.py +92 -0
- agent_trace/exporters/__init__.py +1 -0
- agent_trace/exporters/file.py +80 -0
- agent_trace/exporters/otlp.py +199 -0
- agent_trace/exporters/remote_fixture.py +307 -0
- agent_trace/exporters/stdout.py +258 -0
- agent_trace/integrations/__init__.py +69 -0
- agent_trace/integrations/agno.py +489 -0
- agent_trace/integrations/autogen.py +581 -0
- agent_trace/integrations/crewai.py +486 -0
- agent_trace/integrations/google_genai.py +731 -0
- agent_trace/integrations/haystack.py +254 -0
- agent_trace/integrations/langchain_core.py +361 -0
- agent_trace/integrations/langgraph.py +2093 -0
- agent_trace/integrations/langgraph_checkpoint.py +763 -0
- agent_trace/integrations/langgraph_state_diff.py +345 -0
- agent_trace/integrations/langgraph_stream_debug.py +202 -0
- agent_trace/integrations/llama_index.py +472 -0
- agent_trace/integrations/mcp.py +281 -0
- agent_trace/integrations/openai_agents.py +811 -0
- agent_trace/integrations/pydantic_ai.py +504 -0
- agent_trace/integrations/streaming.py +218 -0
- agent_trace/interceptor/__init__.py +64 -0
- agent_trace/interceptor/aiohttp_hook.py +152 -0
- agent_trace/interceptor/botocore_hook.py +223 -0
- agent_trace/interceptor/grpc_hook.py +651 -0
- agent_trace/interceptor/httpx_hook.py +866 -0
- agent_trace/interceptor/logging_hook.py +137 -0
- agent_trace/interceptor/requests_patch.py +184 -0
- agent_trace/interceptor/sse.py +176 -0
- agent_trace/interceptor/stdio_hook.py +245 -0
- agent_trace/interceptor/warnings_hook.py +132 -0
- agent_trace/interceptor/websocket_hook.py +323 -0
- agent_trace/plugins/__init__.py +35 -0
- agent_trace/plugins/base.py +111 -0
- agent_trace/py.typed +0 -0
agent_trace/_inspect.py
ADDED
|
@@ -0,0 +1,2020 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pattern-check library backing ``agent-trace inspect`` and related CLI
|
|
3
|
+
diagnostics.
|
|
4
|
+
|
|
5
|
+
Every function here is a pure, read-only analysis over already-captured data
|
|
6
|
+
(``Fixture.all_exchanges()`` rows and/or ``trace.json`` span dicts) — none of
|
|
7
|
+
them touch the network, mutate the fixture, or require any framework to be
|
|
8
|
+
installed. They turn the manual "read raw JSON, notice the anomaly" step a
|
|
9
|
+
developer does today into an automated flag, closing a recurring gap seen
|
|
10
|
+
across a long list of real-world GitHub issues from downstream frameworks.
|
|
11
|
+
|
|
12
|
+
Every check function returns a ``list[dict]`` of "flags" — plain dicts with
|
|
13
|
+
at least a ``"check"`` key (this function's name) and a human-readable
|
|
14
|
+
``"detail"`` string, plus whatever exchange/span identifying fields make
|
|
15
|
+
sense for that check (``url``, ``method``, ``sequence_num``, ``span``, ...).
|
|
16
|
+
Checks never raise on malformed/unexpected input shapes — a body that isn't
|
|
17
|
+
valid JSON, or doesn't match the shape a check is looking for, is simply not
|
|
18
|
+
flagged, the same best-effort tradeoff the rest of agent-trace's interceptor
|
|
19
|
+
layer already makes.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import difflib
|
|
25
|
+
import hashlib
|
|
26
|
+
import itertools
|
|
27
|
+
import json
|
|
28
|
+
import logging
|
|
29
|
+
import re
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"check_action_name_not_registered",
|
|
36
|
+
"check_all_tool_calls_no_terminal_response",
|
|
37
|
+
"check_anthropic_thinking_in_tool_result",
|
|
38
|
+
"check_content_block_missing_type",
|
|
39
|
+
"check_duplicate_concurrent_tool_calls",
|
|
40
|
+
"check_duplicate_json_blocks",
|
|
41
|
+
"check_empty_content_not_final",
|
|
42
|
+
"check_endpoint_host_mismatch",
|
|
43
|
+
"check_forced_tool_call_unfulfilled",
|
|
44
|
+
"check_get_post_field_mismatch",
|
|
45
|
+
"check_json_schema_lookaround_or_anyof",
|
|
46
|
+
"check_malformed_tool_call_arguments",
|
|
47
|
+
"check_markdown_fenced_json_response",
|
|
48
|
+
"check_missing_extra_kwarg",
|
|
49
|
+
"check_missing_tool_call_id",
|
|
50
|
+
"check_non_ok_finish_reason",
|
|
51
|
+
"check_null_content_with_tool_calls",
|
|
52
|
+
"check_null_or_missing_sse_delta",
|
|
53
|
+
"check_orphaned_tool_call_ids",
|
|
54
|
+
"check_phantom_tool_call",
|
|
55
|
+
"check_reserved_kwarg_collision",
|
|
56
|
+
"check_restart_vs_resume",
|
|
57
|
+
"check_stream_merge_validity",
|
|
58
|
+
"check_system_prompt_dropped",
|
|
59
|
+
"check_tool_call_boundary_leak",
|
|
60
|
+
"check_tool_call_name_absent_from_request_tools",
|
|
61
|
+
"check_tool_call_name_dotted_compound",
|
|
62
|
+
"check_tool_call_name_fuzzy_match",
|
|
63
|
+
"check_tool_call_name_not_registered",
|
|
64
|
+
"check_tool_calling_disabled",
|
|
65
|
+
"check_tools_with_response_format",
|
|
66
|
+
"detect_response_shape_anomalies",
|
|
67
|
+
"field_present_on_wire_absent_downstream",
|
|
68
|
+
"find_near_duplicate_sibling_content",
|
|
69
|
+
"flag_4xx_5xx_exchanges",
|
|
70
|
+
"match_known_error_patterns",
|
|
71
|
+
"multi_block_llm_responses",
|
|
72
|
+
"check_orphaned_responses_api_call_ids",
|
|
73
|
+
"run_all_exchange_checks",
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _loads(body: str | None) -> Any:
|
|
78
|
+
"""``json.loads`` that returns ``None`` instead of raising on anything
|
|
79
|
+
that isn't valid JSON (empty body, plain text, truncated stream, ...)."""
|
|
80
|
+
if not body:
|
|
81
|
+
return None
|
|
82
|
+
try:
|
|
83
|
+
return json.loads(body)
|
|
84
|
+
except (json.JSONDecodeError, TypeError, ValueError):
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _flag(
|
|
89
|
+
check: str, exchange: dict[str, Any], detail: str, **extra: Any
|
|
90
|
+
) -> dict[str, Any]:
|
|
91
|
+
row = {
|
|
92
|
+
"check": check,
|
|
93
|
+
"url": exchange.get("url"),
|
|
94
|
+
"method": exchange.get("method"),
|
|
95
|
+
"sequence_num": exchange.get("sequence_num"),
|
|
96
|
+
"detail": detail,
|
|
97
|
+
}
|
|
98
|
+
row.update(extra)
|
|
99
|
+
return row
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
# 1. Orphaned tool_call_ids — requested (assistant tool_calls[].id) vs
|
|
104
|
+
# responded-to (tool-role messages' tool_call_id) within one exchange's
|
|
105
|
+
# request body message list. (#531)
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def check_orphaned_tool_call_ids(
|
|
110
|
+
exchanges: list[dict[str, Any]],
|
|
111
|
+
) -> list[dict[str, Any]]:
|
|
112
|
+
flags: list[dict[str, Any]] = []
|
|
113
|
+
for exchange in exchanges:
|
|
114
|
+
body = _loads(exchange.get("request_body"))
|
|
115
|
+
if not isinstance(body, dict):
|
|
116
|
+
continue
|
|
117
|
+
messages = body.get("messages")
|
|
118
|
+
if not isinstance(messages, list):
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
requested: set[str] = set()
|
|
122
|
+
responded: set[str] = set()
|
|
123
|
+
for msg in messages:
|
|
124
|
+
if not isinstance(msg, dict):
|
|
125
|
+
continue
|
|
126
|
+
for tc in msg.get("tool_calls") or []:
|
|
127
|
+
if isinstance(tc, dict) and isinstance(tc.get("id"), str):
|
|
128
|
+
requested.add(tc["id"])
|
|
129
|
+
if msg.get("role") == "tool" and isinstance(msg.get("tool_call_id"), str):
|
|
130
|
+
responded.add(msg["tool_call_id"])
|
|
131
|
+
|
|
132
|
+
orphaned = requested - responded
|
|
133
|
+
if orphaned:
|
|
134
|
+
flags.append(
|
|
135
|
+
_flag(
|
|
136
|
+
"orphaned_tool_call_ids",
|
|
137
|
+
exchange,
|
|
138
|
+
f"{len(orphaned)} tool_call_id(s) requested but never "
|
|
139
|
+
f"responded to: {sorted(orphaned)}",
|
|
140
|
+
orphaned_ids=sorted(orphaned),
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
return flags
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
# 1b. The same orphaned-id shape as above, but for the OpenAI Responses API's
|
|
148
|
+
# distinct message shape: a flat `input` list of `function_call`/
|
|
149
|
+
# `function_call_output` items keyed by `call_id`, instead of Chat
|
|
150
|
+
# Completions' nested `messages[].tool_calls[].id` /
|
|
151
|
+
# `messages[].tool_call_id` shape check_orphaned_tool_call_ids and
|
|
152
|
+
# check_missing_tool_call_id both assume (#33895 — "No call message
|
|
153
|
+
# found for call_*", the Responses-API-specific pairing failure neither
|
|
154
|
+
# of those two checks can see since they never look at `input`/
|
|
155
|
+
# `function_call`/`function_call_output` at all).
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def check_orphaned_responses_api_call_ids(
|
|
160
|
+
exchanges: list[dict[str, Any]],
|
|
161
|
+
) -> list[dict[str, Any]]:
|
|
162
|
+
flags: list[dict[str, Any]] = []
|
|
163
|
+
for exchange in exchanges:
|
|
164
|
+
body = _loads(exchange.get("request_body"))
|
|
165
|
+
if not isinstance(body, dict):
|
|
166
|
+
continue
|
|
167
|
+
items = body.get("input")
|
|
168
|
+
if not isinstance(items, list):
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
requested: set[str] = set()
|
|
172
|
+
responded: set[str] = set()
|
|
173
|
+
for item in items:
|
|
174
|
+
if not isinstance(item, dict):
|
|
175
|
+
continue
|
|
176
|
+
call_id = item.get("call_id")
|
|
177
|
+
if not isinstance(call_id, str):
|
|
178
|
+
continue
|
|
179
|
+
if item.get("type") == "function_call":
|
|
180
|
+
requested.add(call_id)
|
|
181
|
+
elif item.get("type") == "function_call_output":
|
|
182
|
+
responded.add(call_id)
|
|
183
|
+
|
|
184
|
+
orphaned = requested - responded
|
|
185
|
+
if orphaned:
|
|
186
|
+
flags.append(
|
|
187
|
+
_flag(
|
|
188
|
+
"orphaned_responses_api_call_ids",
|
|
189
|
+
exchange,
|
|
190
|
+
f"{len(orphaned)} Responses API call_id(s) requested "
|
|
191
|
+
f"(function_call) but never responded to "
|
|
192
|
+
f"(function_call_output): {sorted(orphaned)}",
|
|
193
|
+
orphaned_ids=sorted(orphaned),
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
return flags
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ---------------------------------------------------------------------------
|
|
200
|
+
# 2. Provider tool-call-boundary leak markers, e.g. "to=functions." (#7845)
|
|
201
|
+
# ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
_BOUNDARY_LEAK_MARKERS = ("to=functions.", "to=multi_tool_use.", "<|python_tag|>")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def check_tool_call_boundary_leak(
|
|
207
|
+
exchanges: list[dict[str, Any]],
|
|
208
|
+
) -> list[dict[str, Any]]:
|
|
209
|
+
flags: list[dict[str, Any]] = []
|
|
210
|
+
for exchange in exchanges:
|
|
211
|
+
body = exchange.get("response_body") or ""
|
|
212
|
+
for marker in _BOUNDARY_LEAK_MARKERS:
|
|
213
|
+
if marker in body:
|
|
214
|
+
flags.append(
|
|
215
|
+
_flag(
|
|
216
|
+
"tool_call_boundary_leak",
|
|
217
|
+
exchange,
|
|
218
|
+
f"provider tool-call-boundary marker {marker!r} leaked into "
|
|
219
|
+
"response body content",
|
|
220
|
+
marker=marker,
|
|
221
|
+
)
|
|
222
|
+
)
|
|
223
|
+
break
|
|
224
|
+
return flags
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ---------------------------------------------------------------------------
|
|
228
|
+
# 3. json.loads validity on reconstructed tool_calls[].function.arguments —
|
|
229
|
+
# catches concatenated/malformed streaming fragments. (#6843)
|
|
230
|
+
# ---------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def check_malformed_tool_call_arguments(
|
|
234
|
+
exchanges: list[dict[str, Any]],
|
|
235
|
+
) -> list[dict[str, Any]]:
|
|
236
|
+
flags: list[dict[str, Any]] = []
|
|
237
|
+
for exchange in exchanges:
|
|
238
|
+
body = _loads(exchange.get("response_body"))
|
|
239
|
+
if not isinstance(body, dict):
|
|
240
|
+
continue
|
|
241
|
+
choices = body.get("choices")
|
|
242
|
+
if not isinstance(choices, list):
|
|
243
|
+
continue
|
|
244
|
+
for choice in choices:
|
|
245
|
+
if not isinstance(choice, dict):
|
|
246
|
+
continue
|
|
247
|
+
message = choice.get("message")
|
|
248
|
+
if not isinstance(message, dict):
|
|
249
|
+
continue
|
|
250
|
+
for tc in message.get("tool_calls") or []:
|
|
251
|
+
if not isinstance(tc, dict):
|
|
252
|
+
continue
|
|
253
|
+
fn = tc.get("function") or {}
|
|
254
|
+
args = fn.get("arguments")
|
|
255
|
+
if not isinstance(args, str) or not args:
|
|
256
|
+
continue
|
|
257
|
+
if _loads(args) is None:
|
|
258
|
+
flags.append(
|
|
259
|
+
_flag(
|
|
260
|
+
"malformed_tool_call_arguments",
|
|
261
|
+
exchange,
|
|
262
|
+
f"tool_calls[].function.arguments for "
|
|
263
|
+
f"{fn.get('name', '<unnamed>')!r} is not valid JSON: "
|
|
264
|
+
f"{args[:120]!r}",
|
|
265
|
+
tool_name=fn.get("name"),
|
|
266
|
+
)
|
|
267
|
+
)
|
|
268
|
+
return flags
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# ---------------------------------------------------------------------------
|
|
272
|
+
# 4. Response message content is null/non-string alongside function_call /
|
|
273
|
+
# tool_calls field. (#6761)
|
|
274
|
+
# ---------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def check_null_content_with_tool_calls(
|
|
278
|
+
exchanges: list[dict[str, Any]],
|
|
279
|
+
) -> list[dict[str, Any]]:
|
|
280
|
+
flags: list[dict[str, Any]] = []
|
|
281
|
+
for exchange in exchanges:
|
|
282
|
+
body = _loads(exchange.get("response_body"))
|
|
283
|
+
if not isinstance(body, dict):
|
|
284
|
+
continue
|
|
285
|
+
for choice in body.get("choices") or []:
|
|
286
|
+
if not isinstance(choice, dict):
|
|
287
|
+
continue
|
|
288
|
+
message = choice.get("message")
|
|
289
|
+
if not isinstance(message, dict):
|
|
290
|
+
continue
|
|
291
|
+
has_calls = bool(message.get("tool_calls") or message.get("function_call"))
|
|
292
|
+
content = message.get("content")
|
|
293
|
+
if has_calls and content is not None and not isinstance(content, str):
|
|
294
|
+
flags.append(
|
|
295
|
+
_flag(
|
|
296
|
+
"null_content_with_tool_calls",
|
|
297
|
+
exchange,
|
|
298
|
+
"message.content is non-string while a function_call/"
|
|
299
|
+
"tool_calls field is present (content type: "
|
|
300
|
+
f"{type(content).__name__})",
|
|
301
|
+
)
|
|
302
|
+
)
|
|
303
|
+
return flags
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
# ---------------------------------------------------------------------------
|
|
307
|
+
# 4b. Request-side malformed content-array shape: a `messages[].content`
|
|
308
|
+
# list item that is a dict missing a `type` key — the exact shape a
|
|
309
|
+
# tool returning a raw list of dicts (e.g. Tavily's
|
|
310
|
+
# `[{"url": ..., "content": ...}]`) produces once it lands back in a
|
|
311
|
+
# tool-role message's `content` array without ever being normalized
|
|
312
|
+
# into a proper content block. Distinct from check_null_content_with_
|
|
313
|
+
# tool_calls (#6761), which inspects RESPONSE message content, not
|
|
314
|
+
# REQUEST-side tool-message content blocks. (#1069)
|
|
315
|
+
# ---------------------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def check_content_block_missing_type(
|
|
319
|
+
exchanges: list[dict[str, Any]],
|
|
320
|
+
) -> list[dict[str, Any]]:
|
|
321
|
+
"""Flag a captured request body where a `messages[].content` array
|
|
322
|
+
contains a dict item with no `type` key — the exact malformed shape
|
|
323
|
+
behind #1069's `messages[3].content[0].type` `BadRequestError`, produced
|
|
324
|
+
when a tool (there, Tavily) returns a plain list of dicts that gets
|
|
325
|
+
threaded straight into a tool-message's `content` array with no
|
|
326
|
+
normalization into a proper `{"type": ..., ...}` content block."""
|
|
327
|
+
flags: list[dict[str, Any]] = []
|
|
328
|
+
for exchange in exchanges:
|
|
329
|
+
body = _loads(exchange.get("request_body"))
|
|
330
|
+
if not isinstance(body, dict):
|
|
331
|
+
continue
|
|
332
|
+
messages = body.get("messages")
|
|
333
|
+
if not isinstance(messages, list):
|
|
334
|
+
continue
|
|
335
|
+
for msg_index, msg in enumerate(messages):
|
|
336
|
+
if not isinstance(msg, dict):
|
|
337
|
+
continue
|
|
338
|
+
content = msg.get("content")
|
|
339
|
+
if not isinstance(content, list):
|
|
340
|
+
continue
|
|
341
|
+
for block_index, block in enumerate(content):
|
|
342
|
+
if isinstance(block, dict) and "type" not in block:
|
|
343
|
+
flags.append(
|
|
344
|
+
_flag(
|
|
345
|
+
"content_block_missing_type",
|
|
346
|
+
exchange,
|
|
347
|
+
f"messages[{msg_index}].content[{block_index}] is a "
|
|
348
|
+
"dict content block with no `type` key — malformed "
|
|
349
|
+
"shape a naive json.loads()-then-forward of a raw "
|
|
350
|
+
"tool return value produces (e.g. a tool returning "
|
|
351
|
+
"`[{'url': ..., 'content': ...}]` with no `type` "
|
|
352
|
+
"field)",
|
|
353
|
+
message_index=msg_index,
|
|
354
|
+
content_index=block_index,
|
|
355
|
+
)
|
|
356
|
+
)
|
|
357
|
+
return flags
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
# ---------------------------------------------------------------------------
|
|
361
|
+
# 5. Request URL host doesn't match the framework's configured endpoint.
|
|
362
|
+
# (#5204)
|
|
363
|
+
# ---------------------------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def check_endpoint_host_mismatch(
|
|
367
|
+
exchanges: list[dict[str, Any]], configured_host: str
|
|
368
|
+
) -> list[dict[str, Any]]:
|
|
369
|
+
import httpx
|
|
370
|
+
|
|
371
|
+
flags: list[dict[str, Any]] = []
|
|
372
|
+
for exchange in exchanges:
|
|
373
|
+
url = exchange.get("url") or ""
|
|
374
|
+
try:
|
|
375
|
+
host = httpx.URL(url).host
|
|
376
|
+
except Exception:
|
|
377
|
+
logger.debug(
|
|
378
|
+
"agent-trace inspect: could not parse URL %r", url, exc_info=True
|
|
379
|
+
)
|
|
380
|
+
continue
|
|
381
|
+
if host and host != configured_host:
|
|
382
|
+
flags.append(
|
|
383
|
+
_flag(
|
|
384
|
+
"endpoint_host_mismatch",
|
|
385
|
+
exchange,
|
|
386
|
+
f"request host {host!r} does not match configured endpoint "
|
|
387
|
+
f"{configured_host!r}",
|
|
388
|
+
actual_host=host,
|
|
389
|
+
configured_host=configured_host,
|
|
390
|
+
)
|
|
391
|
+
)
|
|
392
|
+
return flags
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
# ---------------------------------------------------------------------------
|
|
396
|
+
# 6. Request body carries both `tools` and `response_format`/`response_model`
|
|
397
|
+
# simultaneously. (#5472)
|
|
398
|
+
# ---------------------------------------------------------------------------
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def check_tools_with_response_format(
|
|
402
|
+
exchanges: list[dict[str, Any]],
|
|
403
|
+
) -> list[dict[str, Any]]:
|
|
404
|
+
flags: list[dict[str, Any]] = []
|
|
405
|
+
for exchange in exchanges:
|
|
406
|
+
body = _loads(exchange.get("request_body"))
|
|
407
|
+
if not isinstance(body, dict):
|
|
408
|
+
continue
|
|
409
|
+
has_tools = bool(body.get("tools"))
|
|
410
|
+
has_response_shape = bool(
|
|
411
|
+
body.get("response_format") or body.get("response_model")
|
|
412
|
+
)
|
|
413
|
+
if has_tools and has_response_shape:
|
|
414
|
+
flags.append(
|
|
415
|
+
_flag(
|
|
416
|
+
"tools_with_response_format",
|
|
417
|
+
exchange,
|
|
418
|
+
"request body carries both `tools` and a `response_format`/"
|
|
419
|
+
"`response_model` constraint simultaneously",
|
|
420
|
+
)
|
|
421
|
+
)
|
|
422
|
+
return flags
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
# ---------------------------------------------------------------------------
|
|
426
|
+
# 7. Anthropic request body: a `type: "thinking"` block nested inside a
|
|
427
|
+
# `tool_result.content` array. (#4175)
|
|
428
|
+
# ---------------------------------------------------------------------------
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def check_anthropic_thinking_in_tool_result(
|
|
432
|
+
exchanges: list[dict[str, Any]],
|
|
433
|
+
) -> list[dict[str, Any]]:
|
|
434
|
+
flags: list[dict[str, Any]] = []
|
|
435
|
+
for exchange in exchanges:
|
|
436
|
+
body = _loads(exchange.get("request_body"))
|
|
437
|
+
if not isinstance(body, dict):
|
|
438
|
+
continue
|
|
439
|
+
for msg in body.get("messages") or []:
|
|
440
|
+
if not isinstance(msg, dict):
|
|
441
|
+
continue
|
|
442
|
+
content = msg.get("content")
|
|
443
|
+
if not isinstance(content, list):
|
|
444
|
+
continue
|
|
445
|
+
for block in content:
|
|
446
|
+
if not isinstance(block, dict) or block.get("type") != "tool_result":
|
|
447
|
+
continue
|
|
448
|
+
nested = block.get("content")
|
|
449
|
+
if not isinstance(nested, list):
|
|
450
|
+
continue
|
|
451
|
+
for inner in nested:
|
|
452
|
+
if isinstance(inner, dict) and inner.get("type") == "thinking":
|
|
453
|
+
flags.append(
|
|
454
|
+
_flag(
|
|
455
|
+
"anthropic_thinking_in_tool_result",
|
|
456
|
+
exchange,
|
|
457
|
+
"a `thinking` content block is nested inside a "
|
|
458
|
+
"`tool_result.content` array — malformed shape "
|
|
459
|
+
"Anthropic's API rejects with a 400",
|
|
460
|
+
)
|
|
461
|
+
)
|
|
462
|
+
return flags
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
# ---------------------------------------------------------------------------
|
|
466
|
+
# 8. Assistant-role message with empty content ([] or "") that isn't the
|
|
467
|
+
# final message in the array. (#3168)
|
|
468
|
+
# ---------------------------------------------------------------------------
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def check_empty_content_not_final(
|
|
472
|
+
exchanges: list[dict[str, Any]],
|
|
473
|
+
) -> list[dict[str, Any]]:
|
|
474
|
+
flags: list[dict[str, Any]] = []
|
|
475
|
+
for exchange in exchanges:
|
|
476
|
+
body = _loads(exchange.get("request_body"))
|
|
477
|
+
if not isinstance(body, dict):
|
|
478
|
+
continue
|
|
479
|
+
messages = body.get("messages")
|
|
480
|
+
if not isinstance(messages, list) or not messages:
|
|
481
|
+
continue
|
|
482
|
+
last_index = len(messages) - 1
|
|
483
|
+
for i, msg in enumerate(messages):
|
|
484
|
+
if not isinstance(msg, dict) or msg.get("role") != "assistant":
|
|
485
|
+
continue
|
|
486
|
+
content = msg.get("content")
|
|
487
|
+
is_empty = content in ("", [])
|
|
488
|
+
if is_empty and i != last_index:
|
|
489
|
+
flags.append(
|
|
490
|
+
_flag(
|
|
491
|
+
"empty_content_not_final",
|
|
492
|
+
exchange,
|
|
493
|
+
f"assistant message at index {i} has empty content and is "
|
|
494
|
+
"not the final message — Anthropic rejects this shape with "
|
|
495
|
+
'"all messages must have non-empty content"',
|
|
496
|
+
message_index=i,
|
|
497
|
+
)
|
|
498
|
+
)
|
|
499
|
+
return flags
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
# ---------------------------------------------------------------------------
|
|
503
|
+
# 9. Plain-text ReAct-style "Action: <name>" line naming a tool not in the
|
|
504
|
+
# run's registered tool list (formatting noise stripped first). (#22358)
|
|
505
|
+
# ---------------------------------------------------------------------------
|
|
506
|
+
|
|
507
|
+
_ACTION_LINE_RE = re.compile(r"^\s*Action:\s*(.+?)\s*$", re.MULTILINE)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _strip_formatting_noise(name: str) -> str:
|
|
511
|
+
return name.strip().strip("`'\"[]").strip()
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def check_action_name_not_registered(
|
|
515
|
+
exchanges: list[dict[str, Any]], registered_tools: set[str]
|
|
516
|
+
) -> list[dict[str, Any]]:
|
|
517
|
+
flags: list[dict[str, Any]] = []
|
|
518
|
+
for exchange in exchanges:
|
|
519
|
+
body = exchange.get("response_body") or ""
|
|
520
|
+
for match in _ACTION_LINE_RE.finditer(body):
|
|
521
|
+
name = _strip_formatting_noise(match.group(1))
|
|
522
|
+
if name and name not in registered_tools:
|
|
523
|
+
flags.append(
|
|
524
|
+
_flag(
|
|
525
|
+
"action_name_not_registered",
|
|
526
|
+
exchange,
|
|
527
|
+
f"plain-text `Action: {name}` line names a tool not in the "
|
|
528
|
+
"run's registered tool list",
|
|
529
|
+
action_name=name,
|
|
530
|
+
)
|
|
531
|
+
)
|
|
532
|
+
return flags
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
# ---------------------------------------------------------------------------
|
|
536
|
+
# 10. JSON-schema `pattern` containing regex lookaround, or an `anyOf` branch
|
|
537
|
+
# mixing incompatible JSON types under `strict: true`. (#5508)
|
|
538
|
+
# ---------------------------------------------------------------------------
|
|
539
|
+
|
|
540
|
+
_LOOKAROUND_MARKERS = ("(?=", "(?!", "(?<=", "(?<!")
|
|
541
|
+
|
|
542
|
+
_JSON_SCALAR_TYPES = {"string", "number", "integer", "boolean", "null"}
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _walk_schema(node: Any) -> Any:
|
|
546
|
+
if isinstance(node, dict):
|
|
547
|
+
yield node
|
|
548
|
+
for value in node.values():
|
|
549
|
+
yield from _walk_schema(value)
|
|
550
|
+
elif isinstance(node, list):
|
|
551
|
+
for item in node:
|
|
552
|
+
yield from _walk_schema(item)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def check_json_schema_lookaround_or_anyof(
|
|
556
|
+
exchanges: list[dict[str, Any]],
|
|
557
|
+
) -> list[dict[str, Any]]:
|
|
558
|
+
flags: list[dict[str, Any]] = []
|
|
559
|
+
for exchange in exchanges:
|
|
560
|
+
body = _loads(exchange.get("request_body"))
|
|
561
|
+
if not isinstance(body, dict):
|
|
562
|
+
continue
|
|
563
|
+
strict = bool(body.get("strict")) or "strict" in json.dumps(body).lower()
|
|
564
|
+
for node in _walk_schema(body):
|
|
565
|
+
pattern = node.get("pattern")
|
|
566
|
+
has_lookaround = isinstance(pattern, str) and any(
|
|
567
|
+
m in pattern for m in _LOOKAROUND_MARKERS
|
|
568
|
+
)
|
|
569
|
+
if has_lookaround:
|
|
570
|
+
flags.append(
|
|
571
|
+
_flag(
|
|
572
|
+
"json_schema_lookaround",
|
|
573
|
+
exchange,
|
|
574
|
+
"JSON-schema `pattern` uses regex lookaround syntax: "
|
|
575
|
+
f"{pattern!r} — OpenAI's structured-output path rejects "
|
|
576
|
+
"this construct",
|
|
577
|
+
pattern=pattern,
|
|
578
|
+
)
|
|
579
|
+
)
|
|
580
|
+
any_of = node.get("anyOf")
|
|
581
|
+
if strict and isinstance(any_of, list) and len(any_of) > 1:
|
|
582
|
+
types: set[str] = {
|
|
583
|
+
branch["type"]
|
|
584
|
+
for branch in any_of
|
|
585
|
+
if isinstance(branch, dict) and isinstance(branch.get("type"), str)
|
|
586
|
+
}
|
|
587
|
+
if len(types & _JSON_SCALAR_TYPES) > 1:
|
|
588
|
+
flags.append(
|
|
589
|
+
_flag(
|
|
590
|
+
"json_schema_anyof_type_mismatch",
|
|
591
|
+
exchange,
|
|
592
|
+
f"`anyOf` branch mixes incompatible JSON scalar types "
|
|
593
|
+
f"{sorted(types)} under strict mode",
|
|
594
|
+
types=sorted(types),
|
|
595
|
+
)
|
|
596
|
+
)
|
|
597
|
+
return flags
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
# ---------------------------------------------------------------------------
|
|
601
|
+
# 11. Near-identical/overlapping JSON blocks repeated within a single
|
|
602
|
+
# captured request/response body. (#4919)
|
|
603
|
+
# ---------------------------------------------------------------------------
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _top_level_blocks(body: Any) -> list[Any]:
|
|
607
|
+
"""Best-effort extraction of a list of "blocks" worth de-duplication
|
|
608
|
+
from a parsed JSON body — the `input`/`messages`/`content` array most
|
|
609
|
+
provider request shapes use for repeated structured items."""
|
|
610
|
+
if isinstance(body, dict):
|
|
611
|
+
for key in ("input", "messages", "content"):
|
|
612
|
+
value = body.get(key)
|
|
613
|
+
if isinstance(value, list):
|
|
614
|
+
return value
|
|
615
|
+
return []
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def check_duplicate_json_blocks(
|
|
619
|
+
exchanges: list[dict[str, Any]], min_repeats: int = 3
|
|
620
|
+
) -> list[dict[str, Any]]:
|
|
621
|
+
flags: list[dict[str, Any]] = []
|
|
622
|
+
for exchange in exchanges:
|
|
623
|
+
for field_name in ("request_body", "response_body"):
|
|
624
|
+
body = _loads(exchange.get(field_name))
|
|
625
|
+
blocks = _top_level_blocks(body)
|
|
626
|
+
if len(blocks) < min_repeats:
|
|
627
|
+
continue
|
|
628
|
+
counts: dict[str, int] = {}
|
|
629
|
+
for block in blocks:
|
|
630
|
+
try:
|
|
631
|
+
key = json.dumps(block, sort_keys=True)
|
|
632
|
+
except TypeError:
|
|
633
|
+
continue
|
|
634
|
+
counts[key] = counts.get(key, 0) + 1
|
|
635
|
+
duplicated = {k: v for k, v in counts.items() if v >= min_repeats}
|
|
636
|
+
if duplicated:
|
|
637
|
+
total_dupes = sum(duplicated.values())
|
|
638
|
+
flags.append(
|
|
639
|
+
_flag(
|
|
640
|
+
"duplicate_json_blocks",
|
|
641
|
+
exchange,
|
|
642
|
+
f"{field_name} contains {len(duplicated)} distinct block(s) "
|
|
643
|
+
f"repeated {total_dupes} times total out of {len(blocks)} "
|
|
644
|
+
"top-level blocks",
|
|
645
|
+
field=field_name,
|
|
646
|
+
distinct_duplicated_blocks=len(duplicated),
|
|
647
|
+
total_repeats=total_dupes,
|
|
648
|
+
)
|
|
649
|
+
)
|
|
650
|
+
return flags
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
# ---------------------------------------------------------------------------
|
|
654
|
+
# 12. Expected extra-config kwarg silently absent from the wire request.
|
|
655
|
+
# (#18635)
|
|
656
|
+
# ---------------------------------------------------------------------------
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _get_path(obj: Any, path: str) -> tuple[bool, Any]:
|
|
660
|
+
"""Return (found, value) for a dotted path like
|
|
661
|
+
'extra_body.chat_template_kwargs.thinking'.
|
|
662
|
+
|
|
663
|
+
Also resolves numeric segments as list indices, so a path like
|
|
664
|
+
'choices.0.message.reasoning_content' walks into a JSON array the same
|
|
665
|
+
way it would walk into a nested dict — needed for provider response
|
|
666
|
+
shapes where the field of interest is nested inside a list (e.g.
|
|
667
|
+
DeepSeek's ``choices[0].message.reasoning_content``, #5526)."""
|
|
668
|
+
current = obj
|
|
669
|
+
for part in path.split("."):
|
|
670
|
+
if isinstance(current, dict):
|
|
671
|
+
if part not in current:
|
|
672
|
+
return False, None
|
|
673
|
+
current = current[part]
|
|
674
|
+
elif isinstance(current, list):
|
|
675
|
+
if not re.fullmatch(r"-?\d+", part):
|
|
676
|
+
return False, None
|
|
677
|
+
index = int(part)
|
|
678
|
+
if not (-len(current) <= index < len(current)):
|
|
679
|
+
return False, None
|
|
680
|
+
current = current[index]
|
|
681
|
+
else:
|
|
682
|
+
return False, None
|
|
683
|
+
return True, current
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def check_missing_extra_kwarg(
|
|
687
|
+
exchanges: list[dict[str, Any]], kwarg_path: str
|
|
688
|
+
) -> list[dict[str, Any]]:
|
|
689
|
+
flags: list[dict[str, Any]] = []
|
|
690
|
+
for exchange in exchanges:
|
|
691
|
+
body = _loads(exchange.get("request_body"))
|
|
692
|
+
if not isinstance(body, dict):
|
|
693
|
+
continue
|
|
694
|
+
found, _value = _get_path(body, kwarg_path)
|
|
695
|
+
if not found:
|
|
696
|
+
flags.append(
|
|
697
|
+
_flag(
|
|
698
|
+
"missing_extra_kwarg",
|
|
699
|
+
exchange,
|
|
700
|
+
f"expected kwarg path {kwarg_path!r} is absent from the request "
|
|
701
|
+
"body that actually reached the wire",
|
|
702
|
+
kwarg_path=kwarg_path,
|
|
703
|
+
)
|
|
704
|
+
)
|
|
705
|
+
return flags
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
# ---------------------------------------------------------------------------
|
|
709
|
+
# 13/14. Tool-call name checks against a run's registered tool list: fuzzy
|
|
710
|
+
# (edit-distance) near-miss (#7170), and dotted-compound-of-two-
|
|
711
|
+
# registered-names (#9688).
|
|
712
|
+
# ---------------------------------------------------------------------------
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _edit_distance(a: str, b: str) -> int:
|
|
716
|
+
if a == b:
|
|
717
|
+
return 0
|
|
718
|
+
prev = list(range(len(b) + 1))
|
|
719
|
+
for i, ca in enumerate(a, 1):
|
|
720
|
+
cur = [i] + [0] * len(b)
|
|
721
|
+
for j, cb in enumerate(b, 1):
|
|
722
|
+
cur[j] = min(
|
|
723
|
+
prev[j] + 1,
|
|
724
|
+
cur[j - 1] + 1,
|
|
725
|
+
prev[j - 1] + (0 if ca == cb else 1),
|
|
726
|
+
)
|
|
727
|
+
prev = cur
|
|
728
|
+
return prev[-1]
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def _extract_tool_call_names(exchange: dict[str, Any]) -> list[str]:
|
|
732
|
+
body = _loads(exchange.get("response_body"))
|
|
733
|
+
names: list[str] = []
|
|
734
|
+
if not isinstance(body, dict):
|
|
735
|
+
return names
|
|
736
|
+
for choice in body.get("choices") or []:
|
|
737
|
+
if not isinstance(choice, dict):
|
|
738
|
+
continue
|
|
739
|
+
message = choice.get("message") or {}
|
|
740
|
+
for tc in message.get("tool_calls") or []:
|
|
741
|
+
if isinstance(tc, dict):
|
|
742
|
+
fn = tc.get("function") or {}
|
|
743
|
+
name = fn.get("name")
|
|
744
|
+
if isinstance(name, str):
|
|
745
|
+
names.append(name)
|
|
746
|
+
return names
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def check_tool_call_name_fuzzy_match(
|
|
750
|
+
exchanges: list[dict[str, Any]],
|
|
751
|
+
registered_tools: set[str],
|
|
752
|
+
max_distance: int = 3,
|
|
753
|
+
) -> list[dict[str, Any]]:
|
|
754
|
+
flags: list[dict[str, Any]] = []
|
|
755
|
+
for exchange in exchanges:
|
|
756
|
+
for name in _extract_tool_call_names(exchange):
|
|
757
|
+
if name in registered_tools:
|
|
758
|
+
continue
|
|
759
|
+
best = min(
|
|
760
|
+
registered_tools,
|
|
761
|
+
key=lambda r: _edit_distance(name, r),
|
|
762
|
+
default=None,
|
|
763
|
+
)
|
|
764
|
+
if best is None:
|
|
765
|
+
continue
|
|
766
|
+
dist = _edit_distance(name, best)
|
|
767
|
+
if 0 < dist <= max_distance:
|
|
768
|
+
flags.append(
|
|
769
|
+
_flag(
|
|
770
|
+
"tool_call_name_fuzzy_match",
|
|
771
|
+
exchange,
|
|
772
|
+
f"tool call name {name!r} is not registered — nearest "
|
|
773
|
+
f"registered name is {best!r} (edit distance {dist})",
|
|
774
|
+
called_name=name,
|
|
775
|
+
nearest_registered_name=best,
|
|
776
|
+
edit_distance=dist,
|
|
777
|
+
)
|
|
778
|
+
)
|
|
779
|
+
return flags
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def check_tool_call_name_dotted_compound(
|
|
783
|
+
exchanges: list[dict[str, Any]], registered_tools: set[str]
|
|
784
|
+
) -> list[dict[str, Any]]:
|
|
785
|
+
flags: list[dict[str, Any]] = []
|
|
786
|
+
for exchange in exchanges:
|
|
787
|
+
for name in _extract_tool_call_names(exchange):
|
|
788
|
+
if name in registered_tools or "." not in name:
|
|
789
|
+
continue
|
|
790
|
+
parts = name.split(".")
|
|
791
|
+
if len(parts) == 2 and all(p in registered_tools for p in parts):
|
|
792
|
+
flags.append(
|
|
793
|
+
_flag(
|
|
794
|
+
"tool_call_name_dotted_compound",
|
|
795
|
+
exchange,
|
|
796
|
+
f"tool call name {name!r} is a dotted compound of two "
|
|
797
|
+
f"registered tool names: {parts}",
|
|
798
|
+
called_name=name,
|
|
799
|
+
compound_parts=parts,
|
|
800
|
+
)
|
|
801
|
+
)
|
|
802
|
+
return flags
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
# ---------------------------------------------------------------------------
|
|
806
|
+
# 14b. Structured-JSON-field sibling of check_action_name_not_registered
|
|
807
|
+
# (which reads plain-text `Action: <name>` lines): flags ANY captured
|
|
808
|
+
# `tool_calls[].function.name` not present in the run's registered
|
|
809
|
+
# tool list, unconditionally — no edit-distance threshold. Distinct
|
|
810
|
+
# from check_tool_call_name_fuzzy_match's near-miss-only scope (#325 —
|
|
811
|
+
# no retry mechanism for ModelBehaviorError when the model hallucinates
|
|
812
|
+
# a nonexistent tool name; this surfaces every such hallucination, not
|
|
813
|
+
# just the ones close enough to a real name to be a plausible typo).
|
|
814
|
+
# ---------------------------------------------------------------------------
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def check_tool_call_name_not_registered(
|
|
818
|
+
exchanges: list[dict[str, Any]], registered_tools: set[str]
|
|
819
|
+
) -> list[dict[str, Any]]:
|
|
820
|
+
"""Flag every captured `tool_calls[].function.name` not present in
|
|
821
|
+
*registered_tools*, with no edit-distance/near-miss threshold — the
|
|
822
|
+
unconditional counterpart to check_tool_call_name_fuzzy_match (#325)."""
|
|
823
|
+
flags: list[dict[str, Any]] = []
|
|
824
|
+
for exchange in exchanges:
|
|
825
|
+
for name in _extract_tool_call_names(exchange):
|
|
826
|
+
if name not in registered_tools:
|
|
827
|
+
flags.append(
|
|
828
|
+
_flag(
|
|
829
|
+
"tool_call_name_not_registered",
|
|
830
|
+
exchange,
|
|
831
|
+
f"tool call name {name!r} is not in the run's "
|
|
832
|
+
"registered tool list",
|
|
833
|
+
called_name=name,
|
|
834
|
+
)
|
|
835
|
+
)
|
|
836
|
+
return flags
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
# ---------------------------------------------------------------------------
|
|
840
|
+
# 15. choices[].message.tool_calls[] entries missing an `id` key (or
|
|
841
|
+
# `id: null`) before the framework constructs a ToolMessage/ToolCall.
|
|
842
|
+
# (#3992)
|
|
843
|
+
# ---------------------------------------------------------------------------
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def check_missing_tool_call_id(exchanges: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
847
|
+
flags: list[dict[str, Any]] = []
|
|
848
|
+
for exchange in exchanges:
|
|
849
|
+
body = _loads(exchange.get("response_body"))
|
|
850
|
+
if not isinstance(body, dict):
|
|
851
|
+
continue
|
|
852
|
+
for choice in body.get("choices") or []:
|
|
853
|
+
if not isinstance(choice, dict):
|
|
854
|
+
continue
|
|
855
|
+
message = choice.get("message") or {}
|
|
856
|
+
for tc in message.get("tool_calls") or []:
|
|
857
|
+
if isinstance(tc, dict) and not tc.get("id"):
|
|
858
|
+
fn = tc.get("function") or {}
|
|
859
|
+
flags.append(
|
|
860
|
+
_flag(
|
|
861
|
+
"missing_tool_call_id",
|
|
862
|
+
exchange,
|
|
863
|
+
f"tool_calls[] entry for {fn.get('name', '<unnamed>')!r} "
|
|
864
|
+
"is missing an `id` (or `id` is null)",
|
|
865
|
+
tool_name=fn.get("name"),
|
|
866
|
+
)
|
|
867
|
+
)
|
|
868
|
+
return flags
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
# ---------------------------------------------------------------------------
|
|
872
|
+
# 16. A field retrieved via an earlier GET doesn't match the value sent for
|
|
873
|
+
# that same field in a later, causally-related POST referencing the same
|
|
874
|
+
# resource id. (#2620)
|
|
875
|
+
# ---------------------------------------------------------------------------
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def check_get_post_field_mismatch(
|
|
879
|
+
exchanges: list[dict[str, Any]],
|
|
880
|
+
field_path: str,
|
|
881
|
+
get_id_field: str = "id",
|
|
882
|
+
post_id_field: str | None = None,
|
|
883
|
+
) -> list[dict[str, Any]]:
|
|
884
|
+
"""*post_id_field* defaults to *get_id_field* — pass it explicitly when
|
|
885
|
+
the POST body references the resource under a different key than the
|
|
886
|
+
GET response used (e.g. GET .../assistants/{id} returns ``{"id": ...}``
|
|
887
|
+
while POST .../runs sends ``{"assistant_id": ...}`` referencing the same
|
|
888
|
+
resource — the real OpenAI Assistants API shape behind #2620)."""
|
|
889
|
+
resolved_post_id_field = post_id_field or get_id_field
|
|
890
|
+
flags: list[dict[str, Any]] = []
|
|
891
|
+
known_values: dict[str, Any] = {} # resource id -> value seen via GET
|
|
892
|
+
|
|
893
|
+
ordered = sorted(exchanges, key=lambda e: e.get("sequence_num", 0))
|
|
894
|
+
for exchange in ordered:
|
|
895
|
+
method = (exchange.get("method") or "").upper()
|
|
896
|
+
if method == "GET":
|
|
897
|
+
body = _loads(exchange.get("response_body"))
|
|
898
|
+
if not isinstance(body, dict):
|
|
899
|
+
continue
|
|
900
|
+
resource_id = body.get(get_id_field)
|
|
901
|
+
found, value = _get_path(body, field_path)
|
|
902
|
+
if resource_id is not None and found:
|
|
903
|
+
known_values[str(resource_id)] = value
|
|
904
|
+
elif method == "POST":
|
|
905
|
+
body = _loads(exchange.get("request_body"))
|
|
906
|
+
if not isinstance(body, dict):
|
|
907
|
+
continue
|
|
908
|
+
resource_id = body.get(resolved_post_id_field)
|
|
909
|
+
if resource_id is None:
|
|
910
|
+
continue
|
|
911
|
+
found, sent_value = _get_path(body, field_path)
|
|
912
|
+
key = str(resource_id)
|
|
913
|
+
if found and key in known_values and known_values[key] != sent_value:
|
|
914
|
+
flags.append(
|
|
915
|
+
_flag(
|
|
916
|
+
"get_post_field_mismatch",
|
|
917
|
+
exchange,
|
|
918
|
+
f"POST for resource {key!r} sends {field_path}="
|
|
919
|
+
f"{sent_value!r}, which differs from the value last seen "
|
|
920
|
+
f"via GET: {known_values[key]!r}",
|
|
921
|
+
resource_id=key,
|
|
922
|
+
field_path=field_path,
|
|
923
|
+
get_value=known_values[key],
|
|
924
|
+
post_value=sent_value,
|
|
925
|
+
)
|
|
926
|
+
)
|
|
927
|
+
return flags
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
# ---------------------------------------------------------------------------
|
|
931
|
+
# 17. More than one tool_calls[] entry naming the same tool within a single
|
|
932
|
+
# assistant turn — a candidate "non-reentrant tool invoked concurrently"
|
|
933
|
+
# pattern (#6882: AutoGen's parallel_tool_calls=True calling the same
|
|
934
|
+
# team/tool twice in one turn).
|
|
935
|
+
# ---------------------------------------------------------------------------
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def check_duplicate_concurrent_tool_calls(
|
|
939
|
+
exchanges: list[dict[str, Any]],
|
|
940
|
+
) -> list[dict[str, Any]]:
|
|
941
|
+
"""Flag an assistant-message response whose ``tool_calls[]`` names the
|
|
942
|
+
same tool more than once — the exact shape behind #6882, where a
|
|
943
|
+
maintainer confirmed the root cause was "calling the same team
|
|
944
|
+
concurrently" once ``parallel_tool_calls=True`` was enabled. Any tool/
|
|
945
|
+
wrapper that isn't safely reentrant under parallel tool calling can hit
|
|
946
|
+
this, not just AutoGen's team-as-tool pattern."""
|
|
947
|
+
flags: list[dict[str, Any]] = []
|
|
948
|
+
for exchange in exchanges:
|
|
949
|
+
body = _loads(exchange.get("response_body"))
|
|
950
|
+
if not isinstance(body, dict):
|
|
951
|
+
continue
|
|
952
|
+
for choice in body.get("choices") or []:
|
|
953
|
+
if not isinstance(choice, dict):
|
|
954
|
+
continue
|
|
955
|
+
message = choice.get("message") or {}
|
|
956
|
+
tool_calls = message.get("tool_calls")
|
|
957
|
+
if not isinstance(tool_calls, list) or len(tool_calls) < 2:
|
|
958
|
+
continue
|
|
959
|
+
|
|
960
|
+
names: dict[str, int] = {}
|
|
961
|
+
for tc in tool_calls:
|
|
962
|
+
if not isinstance(tc, dict):
|
|
963
|
+
continue
|
|
964
|
+
fn = tc.get("function") or {}
|
|
965
|
+
name = fn.get("name")
|
|
966
|
+
if isinstance(name, str) and name:
|
|
967
|
+
names[name] = names.get(name, 0) + 1
|
|
968
|
+
|
|
969
|
+
duplicated = {name: count for name, count in names.items() if count > 1}
|
|
970
|
+
if duplicated:
|
|
971
|
+
flags.append(
|
|
972
|
+
_flag(
|
|
973
|
+
"duplicate_concurrent_tool_calls",
|
|
974
|
+
exchange,
|
|
975
|
+
f"tool_calls[] in one assistant turn names the same "
|
|
976
|
+
f"tool more than once — candidate non-reentrant "
|
|
977
|
+
f"tool invoked concurrently: {duplicated}",
|
|
978
|
+
duplicated_tool_counts=duplicated,
|
|
979
|
+
)
|
|
980
|
+
)
|
|
981
|
+
return flags
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
# ---------------------------------------------------------------------------
|
|
985
|
+
# 18. A whole run consisting of nothing but tool-call-only responses, with
|
|
986
|
+
# no terminal (final, human-readable) response ever emitted — the
|
|
987
|
+
# infinite-tool-call-loop shape #3097 asked to auto-flag after the
|
|
988
|
+
# fact, distinct from RecordingTransport's live loop_guard_threshold
|
|
989
|
+
# (which only fires *during* an active recording session).
|
|
990
|
+
# ---------------------------------------------------------------------------
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
def check_all_tool_calls_no_terminal_response(
|
|
994
|
+
exchanges: list[dict[str, Any]],
|
|
995
|
+
min_exchanges: int = 2,
|
|
996
|
+
) -> list[dict[str, Any]]:
|
|
997
|
+
"""Flag a run where every captured chat-completion exchange is a
|
|
998
|
+
tool-call-only response (per the same heuristic RecordingTransport's
|
|
999
|
+
live loop guard uses) and none ever produced a terminal, non-tool-call
|
|
1000
|
+
response — i.e. the run's own recorded evidence shows it never
|
|
1001
|
+
resolved. Requires at least *min_exchanges* qualifying exchanges (a
|
|
1002
|
+
single in-flight turn isn't a loop)."""
|
|
1003
|
+
from agent_trace.interceptor.httpx_hook import _is_tool_call_only_response
|
|
1004
|
+
|
|
1005
|
+
tool_call_only_flags: list[bool] = []
|
|
1006
|
+
last_exchange: dict[str, Any] | None = None
|
|
1007
|
+
for exchange in exchanges:
|
|
1008
|
+
response_body = exchange.get("response_body")
|
|
1009
|
+
if not response_body:
|
|
1010
|
+
continue
|
|
1011
|
+
body = _loads(response_body)
|
|
1012
|
+
if not isinstance(body, dict):
|
|
1013
|
+
continue
|
|
1014
|
+
# Only consider exchanges that look like an LLM completion response
|
|
1015
|
+
# (has "choices" or an Anthropic-shaped "content"/"stop_reason").
|
|
1016
|
+
if "choices" not in body and "stop_reason" not in body:
|
|
1017
|
+
continue
|
|
1018
|
+
tool_call_only_flags.append(_is_tool_call_only_response(response_body))
|
|
1019
|
+
last_exchange = exchange
|
|
1020
|
+
|
|
1021
|
+
if len(tool_call_only_flags) < min_exchanges or last_exchange is None:
|
|
1022
|
+
return []
|
|
1023
|
+
|
|
1024
|
+
if all(tool_call_only_flags):
|
|
1025
|
+
return [
|
|
1026
|
+
_flag(
|
|
1027
|
+
"all_tool_calls_no_terminal_response",
|
|
1028
|
+
last_exchange,
|
|
1029
|
+
f"all {len(tool_call_only_flags)} captured LLM completion "
|
|
1030
|
+
f"exchange(s) in this run are tool-call-only responses — "
|
|
1031
|
+
f"no terminal (final, non-tool-call) response was ever "
|
|
1032
|
+
f"recorded, a candidate infinite-tool-call-loop shape",
|
|
1033
|
+
tool_call_only_exchange_count=len(tool_call_only_flags),
|
|
1034
|
+
)
|
|
1035
|
+
]
|
|
1036
|
+
return []
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
# ---------------------------------------------------------------------------
|
|
1040
|
+
# 19. A captured response's text content is wrapped in a markdown code
|
|
1041
|
+
# fence (e.g. "```json\n{...}\n```") when the calling code expects to
|
|
1042
|
+
# json.loads() that content directly — a naive parse breaks on the
|
|
1043
|
+
# fence markers. Seen on Gemini/google-genai even when a pure-JSON
|
|
1044
|
+
# response was requested, but not provider-specific (#4509).
|
|
1045
|
+
# ---------------------------------------------------------------------------
|
|
1046
|
+
|
|
1047
|
+
_MARKDOWN_FENCE_RE = re.compile(r"^\s*```(?:json)?\s*\n")
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def _texts_from_response_body(body: dict[str, Any]) -> list[str]:
|
|
1051
|
+
"""Extract every assistant-message text field worth checking for a
|
|
1052
|
+
markdown fence, across the two response shapes this check cares
|
|
1053
|
+
about: OpenAI/Groq-style `choices[].message.content` and
|
|
1054
|
+
Gemini-style `candidates[].content.parts[].text`."""
|
|
1055
|
+
texts: list[str] = []
|
|
1056
|
+
|
|
1057
|
+
for choice in body.get("choices") or []:
|
|
1058
|
+
if not isinstance(choice, dict):
|
|
1059
|
+
continue
|
|
1060
|
+
message = choice.get("message")
|
|
1061
|
+
if isinstance(message, dict):
|
|
1062
|
+
content = message.get("content")
|
|
1063
|
+
if isinstance(content, str):
|
|
1064
|
+
texts.append(content)
|
|
1065
|
+
|
|
1066
|
+
for candidate in body.get("candidates") or []:
|
|
1067
|
+
if not isinstance(candidate, dict):
|
|
1068
|
+
continue
|
|
1069
|
+
content = candidate.get("content")
|
|
1070
|
+
if not isinstance(content, dict):
|
|
1071
|
+
continue
|
|
1072
|
+
for part in content.get("parts") or []:
|
|
1073
|
+
if isinstance(part, dict) and isinstance(part.get("text"), str):
|
|
1074
|
+
texts.append(part["text"])
|
|
1075
|
+
|
|
1076
|
+
return texts
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
def check_markdown_fenced_json_response(
|
|
1080
|
+
exchanges: list[dict[str, Any]],
|
|
1081
|
+
) -> list[dict[str, Any]]:
|
|
1082
|
+
"""Flag a captured response whose assistant-message text content
|
|
1083
|
+
starts with a markdown code fence (```` ``` ```` or ```` ```json ````)
|
|
1084
|
+
— a shape that breaks a naive `json.loads(content)` downstream even
|
|
1085
|
+
though the *HTTP response envelope itself* is valid JSON. Not
|
|
1086
|
+
provider-specific: seen on Gemini/google-genai even when a pure-JSON
|
|
1087
|
+
response was explicitly requested (#4509), but the same fencing habit
|
|
1088
|
+
shows up from other providers/prompted models too."""
|
|
1089
|
+
flags: list[dict[str, Any]] = []
|
|
1090
|
+
for exchange in exchanges:
|
|
1091
|
+
body = _loads(exchange.get("response_body"))
|
|
1092
|
+
if not isinstance(body, dict):
|
|
1093
|
+
continue
|
|
1094
|
+
for text in _texts_from_response_body(body):
|
|
1095
|
+
if _MARKDOWN_FENCE_RE.match(text):
|
|
1096
|
+
flags.append(
|
|
1097
|
+
_flag(
|
|
1098
|
+
"markdown_fenced_json_response",
|
|
1099
|
+
exchange,
|
|
1100
|
+
"response content is wrapped in a markdown code "
|
|
1101
|
+
"fence (```/```json) — a naive json.loads() on "
|
|
1102
|
+
"this content will fail even though the HTTP "
|
|
1103
|
+
"response envelope itself is valid JSON",
|
|
1104
|
+
)
|
|
1105
|
+
)
|
|
1106
|
+
break # one flag per exchange is enough
|
|
1107
|
+
return flags
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
# ---------------------------------------------------------------------------
|
|
1111
|
+
# Companion diagnostics (same "raw capture with zero automated diagnosis"
|
|
1112
|
+
# gap that motivates the big `inspect` cluster above).
|
|
1113
|
+
# ---------------------------------------------------------------------------
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
def flag_4xx_5xx_exchanges(exchanges: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
1117
|
+
"""Auto-flag 4xx/5xx HTTP exchanges as errors instead of leaving them as
|
|
1118
|
+
undifferentiated raw rows indistinguishable from a normal 200."""
|
|
1119
|
+
flags: list[dict[str, Any]] = []
|
|
1120
|
+
for exchange in exchanges:
|
|
1121
|
+
status = exchange.get("response_status")
|
|
1122
|
+
if isinstance(status, int) and status >= 400:
|
|
1123
|
+
flags.append(
|
|
1124
|
+
_flag(
|
|
1125
|
+
"http_error_status",
|
|
1126
|
+
exchange,
|
|
1127
|
+
f"HTTP {status} response",
|
|
1128
|
+
status=status,
|
|
1129
|
+
)
|
|
1130
|
+
)
|
|
1131
|
+
return flags
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
def check_tool_calling_disabled(
|
|
1135
|
+
exchanges: list[dict[str, Any]],
|
|
1136
|
+
) -> list[dict[str, Any]]:
|
|
1137
|
+
"""Flag a captured LLM request where tool-calling was explicitly
|
|
1138
|
+
disabled (Gemini `tool_config.function_calling_config.mode`, or
|
|
1139
|
+
OpenAI/Anthropic `tool_choice`) while `tools` were also declared —
|
|
1140
|
+
the exact "tool calling silently disabled by the client itself" shape
|
|
1141
|
+
behind #18937."""
|
|
1142
|
+
flags: list[dict[str, Any]] = []
|
|
1143
|
+
for exchange in exchanges:
|
|
1144
|
+
body = _loads(exchange.get("request_body"))
|
|
1145
|
+
if not isinstance(body, dict):
|
|
1146
|
+
continue
|
|
1147
|
+
has_tools = bool(body.get("tools"))
|
|
1148
|
+
if not has_tools:
|
|
1149
|
+
continue
|
|
1150
|
+
|
|
1151
|
+
found, mode = _get_path(body, "tool_config.function_calling_config.mode")
|
|
1152
|
+
if found and isinstance(mode, str) and mode.upper() == "NONE":
|
|
1153
|
+
flags.append(
|
|
1154
|
+
_flag(
|
|
1155
|
+
"tool_calling_disabled",
|
|
1156
|
+
exchange,
|
|
1157
|
+
"request declares `tools` but "
|
|
1158
|
+
"`tool_config.function_calling_config.mode` is NONE — "
|
|
1159
|
+
"tool calling was explicitly disabled by the client",
|
|
1160
|
+
provider="gemini",
|
|
1161
|
+
)
|
|
1162
|
+
)
|
|
1163
|
+
|
|
1164
|
+
tool_choice = body.get("tool_choice")
|
|
1165
|
+
if isinstance(tool_choice, str) and tool_choice.lower() == "none":
|
|
1166
|
+
flags.append(
|
|
1167
|
+
_flag(
|
|
1168
|
+
"tool_calling_disabled",
|
|
1169
|
+
exchange,
|
|
1170
|
+
"request declares `tools` but `tool_choice` is 'none' — "
|
|
1171
|
+
"tool calling was explicitly disabled by the client",
|
|
1172
|
+
provider="openai/anthropic",
|
|
1173
|
+
)
|
|
1174
|
+
)
|
|
1175
|
+
return flags
|
|
1176
|
+
|
|
1177
|
+
|
|
1178
|
+
# ---------------------------------------------------------------------------
|
|
1179
|
+
# A response calls a tool name that was never declared in that *same*
|
|
1180
|
+
# exchange's own request `tools` list — the self-contained "the model
|
|
1181
|
+
# hallucinated a tool name the client never offered it" shape behind #6037's
|
|
1182
|
+
# `transfer_back_to_supervisor is not a valid tool` error inside a LangGraph
|
|
1183
|
+
# supervisor multi-agent topology. Unlike check_tool_call_name_fuzzy_match/
|
|
1184
|
+
# _dotted_compound (which need a caller-supplied `registered_tools` set
|
|
1185
|
+
# spanning the whole run, opted into via `--registered-tools`), this check
|
|
1186
|
+
# is entirely self-contained within a single exchange's request/response
|
|
1187
|
+
# pair, so it needs no CLI flag and is wired unconditionally into
|
|
1188
|
+
# run_all_exchange_checks().
|
|
1189
|
+
# ---------------------------------------------------------------------------
|
|
1190
|
+
|
|
1191
|
+
|
|
1192
|
+
def check_tool_call_name_absent_from_request_tools(
|
|
1193
|
+
exchanges: list[dict[str, Any]],
|
|
1194
|
+
) -> list[dict[str, Any]]:
|
|
1195
|
+
"""Flag a captured exchange whose response's `tool_calls[]` names a
|
|
1196
|
+
function not present in that exchange's own request `tools[]` list
|
|
1197
|
+
(OpenAI-shape: `tools[].function.name`) — the model calling a tool the
|
|
1198
|
+
client never declared in this request at all (#6037)."""
|
|
1199
|
+
flags: list[dict[str, Any]] = []
|
|
1200
|
+
for exchange in exchanges:
|
|
1201
|
+
request_body = _loads(exchange.get("request_body"))
|
|
1202
|
+
if not isinstance(request_body, dict):
|
|
1203
|
+
continue
|
|
1204
|
+
declared_tools: set[str] = set()
|
|
1205
|
+
for tool in request_body.get("tools") or []:
|
|
1206
|
+
if not isinstance(tool, dict):
|
|
1207
|
+
continue
|
|
1208
|
+
fn = tool.get("function")
|
|
1209
|
+
name = fn.get("name") if isinstance(fn, dict) else None
|
|
1210
|
+
if isinstance(name, str):
|
|
1211
|
+
declared_tools.add(name)
|
|
1212
|
+
if not declared_tools:
|
|
1213
|
+
# No declared tools at all to compare against — not this
|
|
1214
|
+
# check's concern (see check_tool_calling_disabled for that
|
|
1215
|
+
# shape).
|
|
1216
|
+
continue
|
|
1217
|
+
|
|
1218
|
+
for name in _extract_tool_call_names(exchange):
|
|
1219
|
+
if name not in declared_tools:
|
|
1220
|
+
flags.append(
|
|
1221
|
+
_flag(
|
|
1222
|
+
"tool_call_name_absent_from_request_tools",
|
|
1223
|
+
exchange,
|
|
1224
|
+
f"response tool call names {name!r}, which is not "
|
|
1225
|
+
"present in this exchange's own request `tools` "
|
|
1226
|
+
"list — the model called a tool the client never "
|
|
1227
|
+
"declared in this request",
|
|
1228
|
+
called_name=name,
|
|
1229
|
+
declared_tools=sorted(declared_tools),
|
|
1230
|
+
)
|
|
1231
|
+
)
|
|
1232
|
+
return flags
|
|
1233
|
+
|
|
1234
|
+
|
|
1235
|
+
# ---------------------------------------------------------------------------
|
|
1236
|
+
# Response finish/stop reason other than a normal-completion value — e.g.
|
|
1237
|
+
# 'length' (context/max-tokens truncation), 'content_filter', Gemini's
|
|
1238
|
+
# MALFORMED_FUNCTION_CALL, or Anthropic's 'max_tokens'/'refusal'. Today
|
|
1239
|
+
# nothing in agent-trace surfaces this without a developer manually reading
|
|
1240
|
+
# the raw response body — the exact manual step behind the intermittent,
|
|
1241
|
+
# hard-to-reproduce `LengthFinishReasonError` reports (#30924).
|
|
1242
|
+
# ---------------------------------------------------------------------------
|
|
1243
|
+
|
|
1244
|
+
_OK_FINISH_REASONS = {"stop", "tool_calls", "end_turn", "tool_use", "function_call"}
|
|
1245
|
+
|
|
1246
|
+
|
|
1247
|
+
def check_non_ok_finish_reason(
|
|
1248
|
+
exchanges: list[dict[str, Any]],
|
|
1249
|
+
) -> list[dict[str, Any]]:
|
|
1250
|
+
"""Flag a captured response whose finish/stop reason is not one of the
|
|
1251
|
+
normal-completion values (OpenAI-style `choices[].finish_reason`, or
|
|
1252
|
+
Anthropic's top-level `stop_reason`) — surfacing `'length'` (the
|
|
1253
|
+
response was truncated by max_tokens, #30924's `LengthFinishReasonError`
|
|
1254
|
+
shape), `'content_filter'`, Gemini's `MALFORMED_FUNCTION_CALL`, and any
|
|
1255
|
+
other non-`'stop'`/`'tool_calls'`/`'end_turn'` value a developer would
|
|
1256
|
+
otherwise only notice by manually reading the raw response body."""
|
|
1257
|
+
flags: list[dict[str, Any]] = []
|
|
1258
|
+
for exchange in exchanges:
|
|
1259
|
+
body = _loads(exchange.get("response_body"))
|
|
1260
|
+
if not isinstance(body, dict):
|
|
1261
|
+
continue
|
|
1262
|
+
|
|
1263
|
+
for choice in body.get("choices") or []:
|
|
1264
|
+
if not isinstance(choice, dict):
|
|
1265
|
+
continue
|
|
1266
|
+
reason = choice.get("finish_reason")
|
|
1267
|
+
if isinstance(reason, str) and reason.lower() not in _OK_FINISH_REASONS:
|
|
1268
|
+
flags.append(
|
|
1269
|
+
_flag(
|
|
1270
|
+
"non_ok_finish_reason",
|
|
1271
|
+
exchange,
|
|
1272
|
+
f"choices[].finish_reason={reason!r} is not a "
|
|
1273
|
+
"normal-completion value ('stop'/'tool_calls'/"
|
|
1274
|
+
"'end_turn') — the response was truncated/filtered "
|
|
1275
|
+
"rather than completed normally",
|
|
1276
|
+
finish_reason=reason,
|
|
1277
|
+
choice_index=choice.get("index"),
|
|
1278
|
+
)
|
|
1279
|
+
)
|
|
1280
|
+
|
|
1281
|
+
stop_reason = body.get("stop_reason")
|
|
1282
|
+
if (
|
|
1283
|
+
isinstance(stop_reason, str)
|
|
1284
|
+
and stop_reason.lower() not in _OK_FINISH_REASONS
|
|
1285
|
+
):
|
|
1286
|
+
flags.append(
|
|
1287
|
+
_flag(
|
|
1288
|
+
"non_ok_finish_reason",
|
|
1289
|
+
exchange,
|
|
1290
|
+
f"stop_reason={stop_reason!r} is not a normal-completion "
|
|
1291
|
+
"value ('stop'/'tool_calls'/'end_turn') — the response "
|
|
1292
|
+
"was truncated/filtered rather than completed normally",
|
|
1293
|
+
finish_reason=stop_reason,
|
|
1294
|
+
)
|
|
1295
|
+
)
|
|
1296
|
+
return flags
|
|
1297
|
+
|
|
1298
|
+
|
|
1299
|
+
# ---------------------------------------------------------------------------
|
|
1300
|
+
# A request declares a forced/single tool choice but the corresponding
|
|
1301
|
+
# response's message carries no (or empty) tool_calls — the framework asked
|
|
1302
|
+
# the provider to guarantee a tool call and the provider silently didn't
|
|
1303
|
+
# deliver one (#3153: ChatOllama accepts a forced tool_choice but Ollama
|
|
1304
|
+
# doesn't honor it, so downstream code that assumes `tool_calls[0]` exists
|
|
1305
|
+
# crashes with `TypeError: 'NoneType' object is not subscriptable`).
|
|
1306
|
+
# Distinct from check_tool_calling_disabled, which flags the opposite,
|
|
1307
|
+
# explicitly-disabled ('none') shape.
|
|
1308
|
+
# ---------------------------------------------------------------------------
|
|
1309
|
+
|
|
1310
|
+
_GENERIC_TOOL_CHOICE_KEYWORDS = {"auto", "none", "required", "any"}
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
def _forced_tool_choice_name(body: dict[str, Any]) -> str | None:
|
|
1314
|
+
"""Return the forced tool name if *body* declares a forced/single tool
|
|
1315
|
+
choice, else None. Handles OpenAI's dict shape (``{"type": "function",
|
|
1316
|
+
"function": {"name": ...}}``), Anthropic's dict shape (``{"type":
|
|
1317
|
+
"tool", "name": ...}``), a plain tool-name string (``tool_choice:
|
|
1318
|
+
"my_tool"``, distinct from the generic 'auto'/'none'/'required'/'any'
|
|
1319
|
+
keywords), and Gemini's equivalent forced-choice signal on its tool
|
|
1320
|
+
config (`function_calling_config.mode == 'ANY'` restricted to exactly
|
|
1321
|
+
one entry in `allowed_function_names`)."""
|
|
1322
|
+
tool_choice = body.get("tool_choice")
|
|
1323
|
+
if isinstance(tool_choice, dict):
|
|
1324
|
+
if tool_choice.get("type") == "function":
|
|
1325
|
+
fn = tool_choice.get("function")
|
|
1326
|
+
fn_name = fn.get("name") if isinstance(fn, dict) else None
|
|
1327
|
+
if isinstance(fn_name, str):
|
|
1328
|
+
return fn_name
|
|
1329
|
+
elif tool_choice.get("type") == "tool":
|
|
1330
|
+
name = tool_choice.get("name")
|
|
1331
|
+
if isinstance(name, str):
|
|
1332
|
+
return name
|
|
1333
|
+
elif isinstance(tool_choice, str) and tool_choice:
|
|
1334
|
+
if tool_choice.lower() not in _GENERIC_TOOL_CHOICE_KEYWORDS:
|
|
1335
|
+
return tool_choice
|
|
1336
|
+
|
|
1337
|
+
found, mode = _get_path(body, "tool_config.function_calling_config.mode")
|
|
1338
|
+
if found and isinstance(mode, str) and mode.upper() == "ANY":
|
|
1339
|
+
_, names = _get_path(
|
|
1340
|
+
body, "tool_config.function_calling_config.allowed_function_names"
|
|
1341
|
+
)
|
|
1342
|
+
if isinstance(names, list) and len(names) == 1 and isinstance(names[0], str):
|
|
1343
|
+
return names[0]
|
|
1344
|
+
return None
|
|
1345
|
+
|
|
1346
|
+
|
|
1347
|
+
def check_forced_tool_call_unfulfilled(
|
|
1348
|
+
exchanges: list[dict[str, Any]],
|
|
1349
|
+
) -> list[dict[str, Any]]:
|
|
1350
|
+
"""Flag a captured exchange where the request declared a forced/single
|
|
1351
|
+
tool choice but the response's message has no (or empty) `tool_calls` —
|
|
1352
|
+
"expected forced tool call, got none" (#3153)."""
|
|
1353
|
+
flags: list[dict[str, Any]] = []
|
|
1354
|
+
for exchange in exchanges:
|
|
1355
|
+
request_body = _loads(exchange.get("request_body"))
|
|
1356
|
+
if not isinstance(request_body, dict):
|
|
1357
|
+
continue
|
|
1358
|
+
forced_name = _forced_tool_choice_name(request_body)
|
|
1359
|
+
if not forced_name:
|
|
1360
|
+
continue
|
|
1361
|
+
|
|
1362
|
+
response_body = _loads(exchange.get("response_body"))
|
|
1363
|
+
if not isinstance(response_body, dict):
|
|
1364
|
+
continue
|
|
1365
|
+
choices = response_body.get("choices")
|
|
1366
|
+
if not isinstance(choices, list) or not choices:
|
|
1367
|
+
continue
|
|
1368
|
+
|
|
1369
|
+
got_tool_call = any(
|
|
1370
|
+
isinstance(choice, dict)
|
|
1371
|
+
and isinstance(choice.get("message"), dict)
|
|
1372
|
+
and choice["message"].get("tool_calls")
|
|
1373
|
+
for choice in choices
|
|
1374
|
+
)
|
|
1375
|
+
if not got_tool_call:
|
|
1376
|
+
flags.append(
|
|
1377
|
+
_flag(
|
|
1378
|
+
"forced_tool_call_unfulfilled",
|
|
1379
|
+
exchange,
|
|
1380
|
+
f"request declared a forced/single tool_choice "
|
|
1381
|
+
f"({forced_name!r}) but the response's message has no "
|
|
1382
|
+
"(or empty) tool_calls — expected forced tool call, "
|
|
1383
|
+
"got none",
|
|
1384
|
+
forced_tool_name=forced_name,
|
|
1385
|
+
)
|
|
1386
|
+
)
|
|
1387
|
+
return flags
|
|
1388
|
+
|
|
1389
|
+
|
|
1390
|
+
def match_known_error_patterns(spans: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
1391
|
+
"""Scan ERROR-status spans' exception.message text for recognizable
|
|
1392
|
+
cross-provider error shapes and surface a flagged summary line, starting
|
|
1393
|
+
with "Unsupported content block type" (#36531)."""
|
|
1394
|
+
patterns: dict[str, str] = {
|
|
1395
|
+
"Unsupported content block type": "cross_provider_content_block_mismatch",
|
|
1396
|
+
"all messages must have non-empty content": "empty_content_message",
|
|
1397
|
+
"invalid_request_error": "provider_invalid_request",
|
|
1398
|
+
}
|
|
1399
|
+
flags: list[dict[str, Any]] = []
|
|
1400
|
+
for span in spans:
|
|
1401
|
+
if span.get("status") != "ERROR":
|
|
1402
|
+
continue
|
|
1403
|
+
for event in span.get("events") or []:
|
|
1404
|
+
if not isinstance(event, dict) or event.get("name") != "exception":
|
|
1405
|
+
continue
|
|
1406
|
+
attrs = event.get("attributes") or {}
|
|
1407
|
+
message = str(attrs.get("exception.message", ""))
|
|
1408
|
+
for substring, label in patterns.items():
|
|
1409
|
+
if substring in message:
|
|
1410
|
+
flags.append(
|
|
1411
|
+
{
|
|
1412
|
+
"check": "known_error_pattern",
|
|
1413
|
+
"span": span.get("name"),
|
|
1414
|
+
"pattern": label,
|
|
1415
|
+
"detail": f"span {span.get('name')!r} exception text "
|
|
1416
|
+
f"matches known pattern {label!r} ({substring!r})",
|
|
1417
|
+
}
|
|
1418
|
+
)
|
|
1419
|
+
return flags
|
|
1420
|
+
|
|
1421
|
+
|
|
1422
|
+
_RESERVED_KWARGS = frozenset({"config", "runtime", "store", "writer", "state"})
|
|
1423
|
+
_MISSING_ARG_RE = re.compile(r"missing \d+ required positional argument[s]?: '(.+?)'")
|
|
1424
|
+
|
|
1425
|
+
|
|
1426
|
+
def check_reserved_kwarg_collision(spans: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
1427
|
+
"""Flag a failed tool call whose exception message shows a missing
|
|
1428
|
+
positional argument that is also a framework-reserved kwarg name AND was
|
|
1429
|
+
present in the tool's captured input — the "tool argument shadowed by a
|
|
1430
|
+
framework-injected kwarg" pattern (#34029)."""
|
|
1431
|
+
flags: list[dict[str, Any]] = []
|
|
1432
|
+
for span in spans:
|
|
1433
|
+
if span.get("status") != "ERROR":
|
|
1434
|
+
continue
|
|
1435
|
+
attrs = span.get("attributes") or {}
|
|
1436
|
+
input_str = str(attrs.get("tool.input_str", attrs.get("tool.input", "")))
|
|
1437
|
+
for event in span.get("events") or []:
|
|
1438
|
+
if not isinstance(event, dict) or event.get("name") != "exception":
|
|
1439
|
+
continue
|
|
1440
|
+
message = str((event.get("attributes") or {}).get("exception.message", ""))
|
|
1441
|
+
match = _MISSING_ARG_RE.search(message)
|
|
1442
|
+
if not match:
|
|
1443
|
+
continue
|
|
1444
|
+
missing_name = match.group(1)
|
|
1445
|
+
if missing_name in _RESERVED_KWARGS and missing_name in input_str:
|
|
1446
|
+
flags.append(
|
|
1447
|
+
{
|
|
1448
|
+
"check": "reserved_kwarg_collision",
|
|
1449
|
+
"span": span.get("name"),
|
|
1450
|
+
"reserved_kwarg": missing_name,
|
|
1451
|
+
"detail": f"span {span.get('name')!r}: missing positional "
|
|
1452
|
+
f"argument {missing_name!r} is a framework-reserved kwarg "
|
|
1453
|
+
"name that was also present in the tool's own input — "
|
|
1454
|
+
"likely shadowed by a framework-injected kwarg",
|
|
1455
|
+
}
|
|
1456
|
+
)
|
|
1457
|
+
return flags
|
|
1458
|
+
|
|
1459
|
+
|
|
1460
|
+
def multi_block_llm_responses(exchanges: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
1461
|
+
"""Flag a response whose content contains more than one top-level
|
|
1462
|
+
text/message block (e.g. OpenAI Responses API's `phase: commentary` /
|
|
1463
|
+
`phase: final_answer` pattern) — naive concatenation of these blocks
|
|
1464
|
+
produces invalid output (#36290)."""
|
|
1465
|
+
flags: list[dict[str, Any]] = []
|
|
1466
|
+
for exchange in exchanges:
|
|
1467
|
+
body = _loads(exchange.get("response_body"))
|
|
1468
|
+
if not isinstance(body, dict):
|
|
1469
|
+
continue
|
|
1470
|
+
output = body.get("output")
|
|
1471
|
+
if isinstance(output, list):
|
|
1472
|
+
text_blocks = [
|
|
1473
|
+
item
|
|
1474
|
+
for item in output
|
|
1475
|
+
if isinstance(item, dict) and item.get("type") in ("message", "text")
|
|
1476
|
+
]
|
|
1477
|
+
phases = {
|
|
1478
|
+
item.get("phase")
|
|
1479
|
+
for item in text_blocks
|
|
1480
|
+
if isinstance(item, dict) and item.get("phase")
|
|
1481
|
+
}
|
|
1482
|
+
if len(text_blocks) > 1 and (len(phases) > 1 or not phases):
|
|
1483
|
+
flags.append(
|
|
1484
|
+
_flag(
|
|
1485
|
+
"multi_block_response",
|
|
1486
|
+
exchange,
|
|
1487
|
+
f"response `output` contains {len(text_blocks)} top-level "
|
|
1488
|
+
"text/message blocks — naive concatenation would produce "
|
|
1489
|
+
"invalid output",
|
|
1490
|
+
block_count=len(text_blocks),
|
|
1491
|
+
phases=sorted(p for p in phases if p),
|
|
1492
|
+
)
|
|
1493
|
+
)
|
|
1494
|
+
return flags
|
|
1495
|
+
|
|
1496
|
+
|
|
1497
|
+
def check_stream_merge_validity(
|
|
1498
|
+
exchanges: list[dict[str, Any]],
|
|
1499
|
+
) -> list[dict[str, Any]]:
|
|
1500
|
+
"""Stream-vs-merged tool-call diff mode: reconstruct the merged tool
|
|
1501
|
+
call(s) from a captured SSE exchange's raw deltas and flag any merged
|
|
1502
|
+
`function.arguments` that doesn't parse as valid JSON — the exact "wire
|
|
1503
|
+
deltas were correct, your framework's merge logic mangled them" shape
|
|
1504
|
+
behind #5165."""
|
|
1505
|
+
from agent_trace.interceptor.sse import (
|
|
1506
|
+
is_sse_exchange,
|
|
1507
|
+
parse_sse_events,
|
|
1508
|
+
reconstruct_streamed_message,
|
|
1509
|
+
)
|
|
1510
|
+
|
|
1511
|
+
flags: list[dict[str, Any]] = []
|
|
1512
|
+
for exchange in exchanges:
|
|
1513
|
+
if not is_sse_exchange(exchange):
|
|
1514
|
+
continue
|
|
1515
|
+
events = parse_sse_events(exchange.get("response_body") or "")
|
|
1516
|
+
if not events:
|
|
1517
|
+
continue
|
|
1518
|
+
merged = reconstruct_streamed_message(events)
|
|
1519
|
+
for index, tool_call in sorted(merged.get("tool_calls", {}).items()):
|
|
1520
|
+
fn = tool_call.get("function") or {}
|
|
1521
|
+
args = fn.get("arguments")
|
|
1522
|
+
if isinstance(args, str) and args and _loads(args) is None:
|
|
1523
|
+
flags.append(
|
|
1524
|
+
_flag(
|
|
1525
|
+
"stream_merge_invalid_json",
|
|
1526
|
+
exchange,
|
|
1527
|
+
f"merged tool_call[{index}] ({fn.get('name', '<unnamed>')!r}) "
|
|
1528
|
+
f"arguments do not parse as valid JSON after stream-merge: "
|
|
1529
|
+
f"{args[:120]!r}",
|
|
1530
|
+
tool_call_index=index,
|
|
1531
|
+
tool_name=fn.get("name"),
|
|
1532
|
+
)
|
|
1533
|
+
)
|
|
1534
|
+
return flags
|
|
1535
|
+
|
|
1536
|
+
|
|
1537
|
+
def check_null_or_missing_sse_delta(
|
|
1538
|
+
exchanges: list[dict[str, Any]],
|
|
1539
|
+
) -> list[dict[str, Any]]:
|
|
1540
|
+
"""Flag a captured SSE exchange where a streamed event's
|
|
1541
|
+
`choices[0].delta` is null/missing while the event still carries other
|
|
1542
|
+
populated fields — e.g. Azure OpenAI's async content-filter shape,
|
|
1543
|
+
where a chunk arrives with `delta: null` alongside a populated
|
|
1544
|
+
`content_filter_offsets`/`content_filter_results` on the same choice.
|
|
1545
|
+
|
|
1546
|
+
`_delta_from_event()` (`interceptor/sse.py`) already treats a null/
|
|
1547
|
+
missing delta as "no delta" and silently skips it during stream
|
|
1548
|
+
reconstruction — correct behavior for merging, but it means a caller
|
|
1549
|
+
consuming only the reconstructed message never learns a chunk carrying
|
|
1550
|
+
real signal (a content-filter hit, a finish_reason) was dropped. This
|
|
1551
|
+
check is the automated flag for that dropped-chunk shape (#797)."""
|
|
1552
|
+
from agent_trace.interceptor.sse import is_sse_exchange, parse_sse_events
|
|
1553
|
+
|
|
1554
|
+
flags: list[dict[str, Any]] = []
|
|
1555
|
+
for exchange in exchanges:
|
|
1556
|
+
if not is_sse_exchange(exchange):
|
|
1557
|
+
continue
|
|
1558
|
+
events = parse_sse_events(exchange.get("response_body") or "")
|
|
1559
|
+
for event_index, event in enumerate(events):
|
|
1560
|
+
if not isinstance(event, dict):
|
|
1561
|
+
continue
|
|
1562
|
+
choices = event.get("choices")
|
|
1563
|
+
if not isinstance(choices, list) or not choices:
|
|
1564
|
+
continue
|
|
1565
|
+
choice = choices[0]
|
|
1566
|
+
if not isinstance(choice, dict):
|
|
1567
|
+
continue
|
|
1568
|
+
delta = choice.get("delta")
|
|
1569
|
+
if isinstance(delta, dict):
|
|
1570
|
+
continue # a real (possibly empty) delta — nothing dropped.
|
|
1571
|
+
other_populated = {
|
|
1572
|
+
key: value
|
|
1573
|
+
for key, value in choice.items()
|
|
1574
|
+
if key not in ("delta", "index") and value not in (None, "", [], {})
|
|
1575
|
+
}
|
|
1576
|
+
if other_populated:
|
|
1577
|
+
flags.append(
|
|
1578
|
+
_flag(
|
|
1579
|
+
"null_or_missing_sse_delta",
|
|
1580
|
+
exchange,
|
|
1581
|
+
"SSE event has a null/missing `choices[0].delta` while "
|
|
1582
|
+
f"carrying other populated fields ({sorted(other_populated)}) "
|
|
1583
|
+
"— the chunk's signal is silently dropped by naive stream "
|
|
1584
|
+
"merging instead of surfaced",
|
|
1585
|
+
event_index=event_index,
|
|
1586
|
+
populated_fields=sorted(other_populated),
|
|
1587
|
+
)
|
|
1588
|
+
)
|
|
1589
|
+
return flags
|
|
1590
|
+
|
|
1591
|
+
|
|
1592
|
+
def detect_response_shape_anomalies(
|
|
1593
|
+
exchanges: list[dict[str, Any]],
|
|
1594
|
+
) -> list[dict[str, Any]]:
|
|
1595
|
+
"""Flag captured exchanges where top-level JSON fields are null/missing
|
|
1596
|
+
while a populated nested dict field exists, and/or where repeated calls
|
|
1597
|
+
to the same URL return materially different top-level key sets across a
|
|
1598
|
+
run (#531, #3994, #1442)."""
|
|
1599
|
+
flags: list[dict[str, Any]] = []
|
|
1600
|
+
|
|
1601
|
+
shapes_by_url: dict[str, set[frozenset[str]]] = {}
|
|
1602
|
+
for exchange in exchanges:
|
|
1603
|
+
body = _loads(exchange.get("response_body"))
|
|
1604
|
+
if not isinstance(body, dict):
|
|
1605
|
+
continue
|
|
1606
|
+
url = str(exchange.get("url"))
|
|
1607
|
+
shapes_by_url.setdefault(url, set()).add(frozenset(body.keys()))
|
|
1608
|
+
|
|
1609
|
+
# null/missing top-level with populated nested dict.
|
|
1610
|
+
for key, value in body.items():
|
|
1611
|
+
if value not in (None,):
|
|
1612
|
+
continue
|
|
1613
|
+
for other_key, other_value in body.items():
|
|
1614
|
+
not_a_populated_dict = (
|
|
1615
|
+
not isinstance(other_value, dict) or not other_value
|
|
1616
|
+
)
|
|
1617
|
+
if other_key == key or not_a_populated_dict:
|
|
1618
|
+
continue
|
|
1619
|
+
flags.append(
|
|
1620
|
+
_flag(
|
|
1621
|
+
"null_field_with_populated_nested",
|
|
1622
|
+
exchange,
|
|
1623
|
+
f"top-level field {key!r} is null/missing while nested "
|
|
1624
|
+
f"dict field {other_key!r} is populated",
|
|
1625
|
+
null_field=key,
|
|
1626
|
+
populated_field=other_key,
|
|
1627
|
+
)
|
|
1628
|
+
)
|
|
1629
|
+
break
|
|
1630
|
+
|
|
1631
|
+
for url, shapes in shapes_by_url.items():
|
|
1632
|
+
if len(shapes) > 1:
|
|
1633
|
+
flags.append(
|
|
1634
|
+
{
|
|
1635
|
+
"check": "inconsistent_response_shape",
|
|
1636
|
+
"url": url,
|
|
1637
|
+
"detail": f"{len(shapes)} distinct top-level key sets seen "
|
|
1638
|
+
f"across responses to {url}",
|
|
1639
|
+
"distinct_shape_count": len(shapes),
|
|
1640
|
+
}
|
|
1641
|
+
)
|
|
1642
|
+
return flags
|
|
1643
|
+
|
|
1644
|
+
|
|
1645
|
+
def field_present_on_wire_absent_downstream(
|
|
1646
|
+
exchanges: list[dict[str, Any]], spans: list[dict[str, Any]], field_name: str
|
|
1647
|
+
) -> list[dict[str, Any]]:
|
|
1648
|
+
"""Diff/inspect: compare a raw captured LLM response field against the
|
|
1649
|
+
framework's final serialized span attributes, flagging when a field
|
|
1650
|
+
present on the wire never appears downstream (#3936 — "is Azure
|
|
1651
|
+
returning usage data that something downstream strips").
|
|
1652
|
+
|
|
1653
|
+
*field_name* may be a plain top-level key (``"usage"``) or a dotted/
|
|
1654
|
+
nested path resolved via ``_get_path`` (``"choices.0.message.
|
|
1655
|
+
reasoning_content"``) — needed for provider fields nested inside the
|
|
1656
|
+
response body rather than sitting at the top level, e.g. DeepSeek's
|
|
1657
|
+
``choices[0].message.reasoning_content`` (#5526), which a plain
|
|
1658
|
+
``field_name in body`` top-level-key check can never see."""
|
|
1659
|
+
wire_has_field = False
|
|
1660
|
+
for exchange in exchanges:
|
|
1661
|
+
body = _loads(exchange.get("response_body"))
|
|
1662
|
+
if isinstance(body, dict) and _get_path(body, field_name)[0]:
|
|
1663
|
+
wire_has_field = True
|
|
1664
|
+
break
|
|
1665
|
+
if not wire_has_field:
|
|
1666
|
+
return []
|
|
1667
|
+
|
|
1668
|
+
# Span attributes are flat (e.g. "llm.content", "llm.finish_reason"),
|
|
1669
|
+
# never namespaced by a full nested path, so match on the path's last
|
|
1670
|
+
# segment for the downstream-presence check.
|
|
1671
|
+
leaf_field = field_name.rsplit(".", 1)[-1]
|
|
1672
|
+
span_has_field = False
|
|
1673
|
+
for span in spans:
|
|
1674
|
+
attrs = span.get("attributes") or {}
|
|
1675
|
+
if any(str(k).startswith(f"llm.{leaf_field}") for k in attrs):
|
|
1676
|
+
span_has_field = True
|
|
1677
|
+
break
|
|
1678
|
+
|
|
1679
|
+
if wire_has_field and not span_has_field:
|
|
1680
|
+
return [
|
|
1681
|
+
{
|
|
1682
|
+
"check": "field_present_on_wire_absent_downstream",
|
|
1683
|
+
"field": field_name,
|
|
1684
|
+
"detail": f"field {field_name!r} is present in captured raw HTTP "
|
|
1685
|
+
"response bodies but no span attribute reflects it — likely "
|
|
1686
|
+
"stripped somewhere between the wire and the framework's final "
|
|
1687
|
+
"serialized message",
|
|
1688
|
+
}
|
|
1689
|
+
]
|
|
1690
|
+
return []
|
|
1691
|
+
|
|
1692
|
+
|
|
1693
|
+
def find_near_duplicate_sibling_content(
|
|
1694
|
+
spans: list[dict[str, Any]], threshold: float = 0.9
|
|
1695
|
+
) -> list[dict[str, Any]]:
|
|
1696
|
+
"""CLI content-diff view: compare a tool span's captured output text
|
|
1697
|
+
against its parent/sibling llm span's captured content text and flag
|
|
1698
|
+
near-identical/overlapping content as "same content surfaced via two
|
|
1699
|
+
message types" (#3062)."""
|
|
1700
|
+
by_parent: dict[str | None, list[dict[str, Any]]] = {}
|
|
1701
|
+
for span in spans:
|
|
1702
|
+
by_parent.setdefault(span.get("parent_id"), []).append(span)
|
|
1703
|
+
|
|
1704
|
+
flags: list[dict[str, Any]] = []
|
|
1705
|
+
for siblings in by_parent.values():
|
|
1706
|
+
tool_spans = [
|
|
1707
|
+
s
|
|
1708
|
+
for s in siblings
|
|
1709
|
+
if str(s.get("name", "")).startswith("tool:")
|
|
1710
|
+
and (s.get("attributes") or {}).get("tool.output")
|
|
1711
|
+
]
|
|
1712
|
+
llm_spans = [
|
|
1713
|
+
s
|
|
1714
|
+
for s in siblings
|
|
1715
|
+
if str(s.get("name", "")).startswith("llm:")
|
|
1716
|
+
and (s.get("attributes") or {}).get("llm.content")
|
|
1717
|
+
]
|
|
1718
|
+
for tool_span in tool_spans:
|
|
1719
|
+
tool_text = str((tool_span.get("attributes") or {}).get("tool.output", ""))
|
|
1720
|
+
for llm_span in llm_spans:
|
|
1721
|
+
llm_attrs = llm_span.get("attributes") or {}
|
|
1722
|
+
llm_text = str(llm_attrs.get("llm.content", ""))
|
|
1723
|
+
if not tool_text or not llm_text:
|
|
1724
|
+
continue
|
|
1725
|
+
ratio = difflib.SequenceMatcher(None, tool_text, llm_text).ratio()
|
|
1726
|
+
if ratio >= threshold:
|
|
1727
|
+
flags.append(
|
|
1728
|
+
{
|
|
1729
|
+
"check": "near_duplicate_sibling_content",
|
|
1730
|
+
"tool_span": tool_span.get("name"),
|
|
1731
|
+
"llm_span": llm_span.get("name"),
|
|
1732
|
+
"similarity": round(ratio, 3),
|
|
1733
|
+
"detail": f"{tool_span.get('name')!r} output and "
|
|
1734
|
+
f"{llm_span.get('name')!r} content are "
|
|
1735
|
+
f"{ratio * 100:.0f}% similar — likely the same content "
|
|
1736
|
+
"surfaced via two message types",
|
|
1737
|
+
}
|
|
1738
|
+
)
|
|
1739
|
+
return flags
|
|
1740
|
+
|
|
1741
|
+
|
|
1742
|
+
def check_system_prompt_dropped(
|
|
1743
|
+
spans: list[dict[str, Any]],
|
|
1744
|
+
) -> list[dict[str, Any]]:
|
|
1745
|
+
"""Flag a pydantic-ai `system_prompt`-vs-`message_history` regression
|
|
1746
|
+
within one trace (#3277): `agent.run(..., message_history=...)` silently
|
|
1747
|
+
drops the agent's configured `SystemPromptPart` on the follow-up call.
|
|
1748
|
+
|
|
1749
|
+
`integrations/pydantic_ai.py`'s `_open_llm_span` already persists
|
|
1750
|
+
`llm.has_system_prompt_part` (bool) per LLM span. This walks a trace's
|
|
1751
|
+
`llm:*` spans in call order (by `start_time`), grouped by
|
|
1752
|
+
`(agent.name, llm.model)`, and flags any transition where an earlier
|
|
1753
|
+
call had a system prompt part present and a later call for the same
|
|
1754
|
+
agent/model does not — surfacing the drop automatically instead of
|
|
1755
|
+
requiring a developer to already suspect it and hand-diff two
|
|
1756
|
+
fixtures."""
|
|
1757
|
+
llm_spans = [
|
|
1758
|
+
s
|
|
1759
|
+
for s in spans
|
|
1760
|
+
if str(s.get("name", "")).startswith("llm:")
|
|
1761
|
+
and "llm.has_system_prompt_part" in (s.get("attributes") or {})
|
|
1762
|
+
]
|
|
1763
|
+
llm_spans.sort(key=lambda s: s.get("start_time") or 0)
|
|
1764
|
+
|
|
1765
|
+
by_group: dict[tuple[Any, Any], list[dict[str, Any]]] = {}
|
|
1766
|
+
for span in llm_spans:
|
|
1767
|
+
attrs = span.get("attributes") or {}
|
|
1768
|
+
key = (attrs.get("agent.name"), attrs.get("llm.model"))
|
|
1769
|
+
by_group.setdefault(key, []).append(span)
|
|
1770
|
+
|
|
1771
|
+
flags: list[dict[str, Any]] = []
|
|
1772
|
+
for (agent_name, model), group_spans in by_group.items():
|
|
1773
|
+
for earlier, later in itertools.pairwise(group_spans):
|
|
1774
|
+
earlier_attrs = earlier.get("attributes") or {}
|
|
1775
|
+
later_attrs = later.get("attributes") or {}
|
|
1776
|
+
if earlier_attrs.get("llm.has_system_prompt_part") and not later_attrs.get(
|
|
1777
|
+
"llm.has_system_prompt_part"
|
|
1778
|
+
):
|
|
1779
|
+
flags.append(
|
|
1780
|
+
{
|
|
1781
|
+
"check": "system_prompt_dropped",
|
|
1782
|
+
"agent_name": agent_name,
|
|
1783
|
+
"model": model,
|
|
1784
|
+
"earlier_span": earlier.get("span_id"),
|
|
1785
|
+
"later_span": later.get("span_id"),
|
|
1786
|
+
"detail": f"agent {agent_name!r} (model {model!r}) sent a "
|
|
1787
|
+
"system prompt on an earlier call but a later call in the "
|
|
1788
|
+
"same trace has no SystemPromptPart — likely dropped when "
|
|
1789
|
+
"message_history was passed",
|
|
1790
|
+
}
|
|
1791
|
+
)
|
|
1792
|
+
return flags
|
|
1793
|
+
|
|
1794
|
+
|
|
1795
|
+
def check_phantom_tool_call(
|
|
1796
|
+
spans: list[dict[str, Any]], exchanges: list[dict[str, Any]]
|
|
1797
|
+
) -> list[dict[str, Any]]:
|
|
1798
|
+
"""Flag a claimed tool invocation with zero corresponding downstream HTTP
|
|
1799
|
+
exchanges in its time window (#13449 — a ReActAgent transcript claiming a
|
|
1800
|
+
tool ran when nothing actually executed).
|
|
1801
|
+
|
|
1802
|
+
Now that a tool-call span/event can be tied back to the HTTP exchanges
|
|
1803
|
+
it produced (`push_correlation_id(span.span_id)` at the point a tool
|
|
1804
|
+
span opens, recoverable via `Fixture.exchanges_for_correlation_id`; see
|
|
1805
|
+
`integrations/langgraph.py` and `integrations/llama_index.py`), this
|
|
1806
|
+
walks every span, identifies the ones representing a tool call — a
|
|
1807
|
+
LangGraph `tool:<name>` span, or a llama_index span carrying a
|
|
1808
|
+
`tool_call` event — and flags any whose `span_id` has *no* correlated
|
|
1809
|
+
HTTP exchange at all as a likely phantom/silently-skipped invocation:
|
|
1810
|
+
the exact thing a developer previously had to work out by hand from
|
|
1811
|
+
console logs.
|
|
1812
|
+
|
|
1813
|
+
Deliberately over-inclusive rather than silent: a genuinely pure-Python
|
|
1814
|
+
tool (no network call, e.g. a local calculator) will also have zero
|
|
1815
|
+
correlated exchanges and gets flagged too — callers who know a given
|
|
1816
|
+
tool never makes HTTP calls should filter this check's output by
|
|
1817
|
+
`tool_name` rather than treat every flag as proof of a bug."""
|
|
1818
|
+
correlated_ids: set[str] = {
|
|
1819
|
+
str(exchange["correlation_id"])
|
|
1820
|
+
for exchange in exchanges
|
|
1821
|
+
if exchange.get("correlation_id")
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
flags: list[dict[str, Any]] = []
|
|
1825
|
+
for span in spans:
|
|
1826
|
+
span_id = span.get("span_id")
|
|
1827
|
+
if not span_id:
|
|
1828
|
+
continue
|
|
1829
|
+
attrs = span.get("attributes") or {}
|
|
1830
|
+
events = span.get("events") or []
|
|
1831
|
+
tool_call_event = next(
|
|
1832
|
+
(e for e in events if isinstance(e, dict) and e.get("name") == "tool_call"),
|
|
1833
|
+
None,
|
|
1834
|
+
)
|
|
1835
|
+
is_tool_span = str(span.get("name", "")).startswith("tool:")
|
|
1836
|
+
if not is_tool_span and tool_call_event is None and "tool.name" not in attrs:
|
|
1837
|
+
continue
|
|
1838
|
+
|
|
1839
|
+
tool_name = attrs.get("tool.name")
|
|
1840
|
+
if tool_name is None and tool_call_event is not None:
|
|
1841
|
+
tool_name = (tool_call_event.get("attributes") or {}).get("tool.name")
|
|
1842
|
+
tool_name = tool_name or span.get("name")
|
|
1843
|
+
|
|
1844
|
+
if str(span_id) not in correlated_ids:
|
|
1845
|
+
flags.append(
|
|
1846
|
+
{
|
|
1847
|
+
"check": "phantom_tool_call",
|
|
1848
|
+
"span": span.get("name"),
|
|
1849
|
+
"span_id": span_id,
|
|
1850
|
+
"tool_name": tool_name,
|
|
1851
|
+
"detail": f"tool call {tool_name!r} (span {span.get('name')!r}) "
|
|
1852
|
+
"has zero correlated HTTP exchanges — the transcript claims "
|
|
1853
|
+
"this tool ran but no downstream network call was recorded "
|
|
1854
|
+
"for it",
|
|
1855
|
+
}
|
|
1856
|
+
)
|
|
1857
|
+
return flags
|
|
1858
|
+
|
|
1859
|
+
|
|
1860
|
+
# ---------------------------------------------------------------------------
|
|
1861
|
+
# `agent-trace diff`-style restart-vs-resume detection: a later run's ROOT
|
|
1862
|
+
# chain span (`parent_id` is None) carries the same LangGraph `thread_id` —
|
|
1863
|
+
# recovered from its captured `chain.metadata`, see LangGraphTracer.
|
|
1864
|
+
# on_chain_start — as an earlier run, but its `langgraph_step` does not
|
|
1865
|
+
# continue from the earlier run's last recorded step for that thread_id.
|
|
1866
|
+
# That is exactly the "new root span with no parent, same thread_id as a
|
|
1867
|
+
# prior interrupted run" shape #161 asked to auto-flag: the later run
|
|
1868
|
+
# started the graph over from scratch (a *restart*) rather than resuming it
|
|
1869
|
+
# from its last checkpoint (a *resume*). cmd_diff (which already loads both
|
|
1870
|
+
# runs' spans to compare exchanges) wires this in unconditionally.
|
|
1871
|
+
# ---------------------------------------------------------------------------
|
|
1872
|
+
|
|
1873
|
+
|
|
1874
|
+
def _chain_metadata(span: dict[str, Any]) -> dict[str, Any] | None:
|
|
1875
|
+
"""Best-effort parse of a span's captured `chain.metadata` attribute
|
|
1876
|
+
(set by LangGraphTracer.on_chain_start, JSON-serialized) back into a
|
|
1877
|
+
dict, or None if the span has no (or unparsable) chain metadata."""
|
|
1878
|
+
attrs = span.get("attributes")
|
|
1879
|
+
if not isinstance(attrs, dict):
|
|
1880
|
+
return None
|
|
1881
|
+
raw = attrs.get("chain.metadata")
|
|
1882
|
+
if not isinstance(raw, str):
|
|
1883
|
+
return None
|
|
1884
|
+
parsed = _loads(raw)
|
|
1885
|
+
return parsed if isinstance(parsed, dict) else None
|
|
1886
|
+
|
|
1887
|
+
|
|
1888
|
+
def _last_langgraph_step_by_thread(spans: list[dict[str, Any]]) -> dict[str, int]:
|
|
1889
|
+
"""The highest `langgraph_step` recorded for each `thread_id` across
|
|
1890
|
+
*every* chain span in a run — not just root spans — since a resumed run
|
|
1891
|
+
should pick up from wherever the earlier run's graph execution actually
|
|
1892
|
+
last got to, not just wherever its root span happened to be."""
|
|
1893
|
+
last_step: dict[str, int] = {}
|
|
1894
|
+
for span in spans:
|
|
1895
|
+
metadata = _chain_metadata(span)
|
|
1896
|
+
if metadata is None:
|
|
1897
|
+
continue
|
|
1898
|
+
thread_id = metadata.get("thread_id")
|
|
1899
|
+
step = metadata.get("langgraph_step")
|
|
1900
|
+
if not isinstance(thread_id, str) or not isinstance(step, int):
|
|
1901
|
+
continue
|
|
1902
|
+
if step > last_step.get(thread_id, -1):
|
|
1903
|
+
last_step[thread_id] = step
|
|
1904
|
+
return last_step
|
|
1905
|
+
|
|
1906
|
+
|
|
1907
|
+
def check_restart_vs_resume(
|
|
1908
|
+
spans_a: list[dict[str, Any]], spans_b: list[dict[str, Any]]
|
|
1909
|
+
) -> list[dict[str, Any]]:
|
|
1910
|
+
"""Flag a candidate LangGraph restart-vs-resume mismatch (#161): run
|
|
1911
|
+
*spans_b* (assumed to be the later/second of the two runs) has a root
|
|
1912
|
+
chain span (`parent_id` is None, with `chain.metadata` present) whose
|
|
1913
|
+
`thread_id` also appears in *spans_a* (the earlier/first run), but whose
|
|
1914
|
+
`langgraph_step` does not continue from *spans_a*'s last recorded step
|
|
1915
|
+
for that same `thread_id` — i.e. the later run's graph execution
|
|
1916
|
+
appears to have restarted from scratch instead of resuming the
|
|
1917
|
+
interrupted run from its last checkpoint."""
|
|
1918
|
+
last_steps_a = _last_langgraph_step_by_thread(spans_a)
|
|
1919
|
+
if not last_steps_a:
|
|
1920
|
+
return []
|
|
1921
|
+
|
|
1922
|
+
flags: list[dict[str, Any]] = []
|
|
1923
|
+
for span in spans_b:
|
|
1924
|
+
if span.get("parent_id") is not None:
|
|
1925
|
+
continue
|
|
1926
|
+
metadata = _chain_metadata(span)
|
|
1927
|
+
if metadata is None:
|
|
1928
|
+
continue
|
|
1929
|
+
thread_id = metadata.get("thread_id")
|
|
1930
|
+
step = metadata.get("langgraph_step")
|
|
1931
|
+
if not isinstance(thread_id, str) or not isinstance(step, int):
|
|
1932
|
+
continue
|
|
1933
|
+
prior_last_step = last_steps_a.get(thread_id)
|
|
1934
|
+
if prior_last_step is None:
|
|
1935
|
+
continue
|
|
1936
|
+
if step <= prior_last_step:
|
|
1937
|
+
flags.append(
|
|
1938
|
+
{
|
|
1939
|
+
"check": "restart_vs_resume",
|
|
1940
|
+
"span": span.get("name"),
|
|
1941
|
+
"thread_id": thread_id,
|
|
1942
|
+
"prior_last_step": prior_last_step,
|
|
1943
|
+
"root_langgraph_step": step,
|
|
1944
|
+
"detail": (
|
|
1945
|
+
f"root span {span.get('name')!r} in the later run "
|
|
1946
|
+
f"shares thread_id={thread_id!r} with an earlier "
|
|
1947
|
+
f"run, but its langgraph_step={step} does not "
|
|
1948
|
+
f"continue from that earlier run's last recorded "
|
|
1949
|
+
f"step ({prior_last_step}) for the same thread — "
|
|
1950
|
+
"candidate restart-vs-resume: the graph appears to "
|
|
1951
|
+
"have restarted from scratch rather than resumed "
|
|
1952
|
+
"from its last checkpoint"
|
|
1953
|
+
),
|
|
1954
|
+
}
|
|
1955
|
+
)
|
|
1956
|
+
return flags
|
|
1957
|
+
|
|
1958
|
+
|
|
1959
|
+
def content_hash(method: str, url: str, request_body: str) -> str:
|
|
1960
|
+
"""Stable identity for "this is the same logical request" — used to
|
|
1961
|
+
group retry attempts (see Fixture's attempt_group column).
|
|
1962
|
+
|
|
1963
|
+
sha1 here is a content-identity fingerprint, not a security boundary —
|
|
1964
|
+
usedforsecurity=False documents that and avoids FIPS-mode failures.
|
|
1965
|
+
"""
|
|
1966
|
+
digest = hashlib.sha1(usedforsecurity=False)
|
|
1967
|
+
digest.update(method.upper().encode("utf-8"))
|
|
1968
|
+
digest.update(b"\0")
|
|
1969
|
+
digest.update(url.encode("utf-8"))
|
|
1970
|
+
digest.update(b"\0")
|
|
1971
|
+
digest.update((request_body or "").encode("utf-8"))
|
|
1972
|
+
return digest.hexdigest()
|
|
1973
|
+
|
|
1974
|
+
|
|
1975
|
+
# ---------------------------------------------------------------------------
|
|
1976
|
+
# Orchestration: run every exchange-scoped check that needs no extra
|
|
1977
|
+
# framework-specific arguments (registered tool lists, configured hosts,
|
|
1978
|
+
# expected kwarg paths, ... are opt-in via dedicated CLI flags instead).
|
|
1979
|
+
# ---------------------------------------------------------------------------
|
|
1980
|
+
|
|
1981
|
+
|
|
1982
|
+
def run_all_exchange_checks(
|
|
1983
|
+
exchanges: list[dict[str, Any]],
|
|
1984
|
+
) -> dict[str, list[dict[str, Any]]]:
|
|
1985
|
+
"""Run every parameter-free exchange-scoped check and return
|
|
1986
|
+
``{check_name: [flags]}`` for every check that found at least one flag."""
|
|
1987
|
+
checks: dict[str, Any] = {
|
|
1988
|
+
"orphaned_tool_call_ids": check_orphaned_tool_call_ids,
|
|
1989
|
+
"orphaned_responses_api_call_ids": check_orphaned_responses_api_call_ids,
|
|
1990
|
+
"tool_call_boundary_leak": check_tool_call_boundary_leak,
|
|
1991
|
+
"malformed_tool_call_arguments": check_malformed_tool_call_arguments,
|
|
1992
|
+
"null_content_with_tool_calls": check_null_content_with_tool_calls,
|
|
1993
|
+
"content_block_missing_type": check_content_block_missing_type,
|
|
1994
|
+
"tools_with_response_format": check_tools_with_response_format,
|
|
1995
|
+
"tool_call_name_absent_from_request_tools": (
|
|
1996
|
+
check_tool_call_name_absent_from_request_tools
|
|
1997
|
+
),
|
|
1998
|
+
"anthropic_thinking_in_tool_result": check_anthropic_thinking_in_tool_result,
|
|
1999
|
+
"empty_content_not_final": check_empty_content_not_final,
|
|
2000
|
+
"json_schema_lookaround_or_anyof": check_json_schema_lookaround_or_anyof,
|
|
2001
|
+
"duplicate_json_blocks": check_duplicate_json_blocks,
|
|
2002
|
+
"duplicate_concurrent_tool_calls": check_duplicate_concurrent_tool_calls,
|
|
2003
|
+
"missing_tool_call_id": check_missing_tool_call_id,
|
|
2004
|
+
"http_error_status": flag_4xx_5xx_exchanges,
|
|
2005
|
+
"all_tool_calls_no_terminal_response": check_all_tool_calls_no_terminal_response,
|
|
2006
|
+
"markdown_fenced_json_response": check_markdown_fenced_json_response,
|
|
2007
|
+
"tool_calling_disabled": check_tool_calling_disabled,
|
|
2008
|
+
"forced_tool_call_unfulfilled": check_forced_tool_call_unfulfilled,
|
|
2009
|
+
"non_ok_finish_reason": check_non_ok_finish_reason,
|
|
2010
|
+
"multi_block_response": multi_block_llm_responses,
|
|
2011
|
+
"stream_merge_invalid_json": check_stream_merge_validity,
|
|
2012
|
+
"null_or_missing_sse_delta": check_null_or_missing_sse_delta,
|
|
2013
|
+
"response_shape_anomaly": detect_response_shape_anomalies,
|
|
2014
|
+
}
|
|
2015
|
+
results: dict[str, list[dict[str, Any]]] = {}
|
|
2016
|
+
for name, fn in checks.items():
|
|
2017
|
+
flags = fn(exchanges)
|
|
2018
|
+
if flags:
|
|
2019
|
+
results[name] = flags
|
|
2020
|
+
return results
|