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.
Files changed (51) hide show
  1. agent_observability_trace_cli-0.1.2.dist-info/METADATA +336 -0
  2. agent_observability_trace_cli-0.1.2.dist-info/RECORD +51 -0
  3. agent_observability_trace_cli-0.1.2.dist-info/WHEEL +4 -0
  4. agent_observability_trace_cli-0.1.2.dist-info/entry_points.txt +2 -0
  5. agent_observability_trace_cli-0.1.2.dist-info/licenses/LICENSE +198 -0
  6. agent_trace/__init__.py +1182 -0
  7. agent_trace/_cli.py +1377 -0
  8. agent_trace/_inspect.py +2020 -0
  9. agent_trace/_replay/__init__.py +1 -0
  10. agent_trace/_replay/engine.py +268 -0
  11. agent_trace/_replay/fixture.py +868 -0
  12. agent_trace/core/__init__.py +1 -0
  13. agent_trace/core/clock.py +91 -0
  14. agent_trace/core/exceptions.py +51 -0
  15. agent_trace/core/span.py +271 -0
  16. agent_trace/core/trace.py +92 -0
  17. agent_trace/exporters/__init__.py +1 -0
  18. agent_trace/exporters/file.py +80 -0
  19. agent_trace/exporters/otlp.py +199 -0
  20. agent_trace/exporters/remote_fixture.py +307 -0
  21. agent_trace/exporters/stdout.py +258 -0
  22. agent_trace/integrations/__init__.py +69 -0
  23. agent_trace/integrations/agno.py +489 -0
  24. agent_trace/integrations/autogen.py +581 -0
  25. agent_trace/integrations/crewai.py +486 -0
  26. agent_trace/integrations/google_genai.py +731 -0
  27. agent_trace/integrations/haystack.py +254 -0
  28. agent_trace/integrations/langchain_core.py +361 -0
  29. agent_trace/integrations/langgraph.py +2093 -0
  30. agent_trace/integrations/langgraph_checkpoint.py +763 -0
  31. agent_trace/integrations/langgraph_state_diff.py +345 -0
  32. agent_trace/integrations/langgraph_stream_debug.py +202 -0
  33. agent_trace/integrations/llama_index.py +472 -0
  34. agent_trace/integrations/mcp.py +281 -0
  35. agent_trace/integrations/openai_agents.py +811 -0
  36. agent_trace/integrations/pydantic_ai.py +504 -0
  37. agent_trace/integrations/streaming.py +218 -0
  38. agent_trace/interceptor/__init__.py +64 -0
  39. agent_trace/interceptor/aiohttp_hook.py +152 -0
  40. agent_trace/interceptor/botocore_hook.py +223 -0
  41. agent_trace/interceptor/grpc_hook.py +651 -0
  42. agent_trace/interceptor/httpx_hook.py +866 -0
  43. agent_trace/interceptor/logging_hook.py +137 -0
  44. agent_trace/interceptor/requests_patch.py +184 -0
  45. agent_trace/interceptor/sse.py +176 -0
  46. agent_trace/interceptor/stdio_hook.py +245 -0
  47. agent_trace/interceptor/warnings_hook.py +132 -0
  48. agent_trace/interceptor/websocket_hook.py +323 -0
  49. agent_trace/plugins/__init__.py +35 -0
  50. agent_trace/plugins/base.py +111 -0
  51. agent_trace/py.typed +0 -0
@@ -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