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,866 @@
1
+ """
2
+ httpx transports for recording and replaying HTTP exchanges.
3
+
4
+ RecordingTransport / AsyncRecordingTransport wrap a real transport: they let
5
+ the request go through, read the full response body, and save the exchange to
6
+ the fixture before returning a reconstructed Response to the caller.
7
+
8
+ ReplayTransport / AsyncReplayTransport never touch the network. They look up
9
+ the next recorded response for the requested (method, url) from the fixture.
10
+ If no fixture entry is found and AGENT_TRACE_NETWORK_GUARD=1, they raise
11
+ NetworkGuardError so that test suites catch accidental live calls.
12
+
13
+ Both sync and async variants are provided because:
14
+ - httpx.Client (sync) requires httpx.BaseTransport (handle_request).
15
+ - httpx.AsyncClient (async) requires httpx.AsyncBaseTransport (handle_async_request).
16
+ Passing a sync transport to an async client fails silently at init and raises
17
+ AttributeError on the first request.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import contextvars
23
+ import json
24
+ import logging
25
+ import time
26
+ import warnings
27
+ from collections.abc import AsyncIterator, Callable, Iterator
28
+ from contextlib import contextmanager
29
+ from typing import TYPE_CHECKING, Any
30
+
31
+ import httpx
32
+
33
+ if TYPE_CHECKING:
34
+ from collections.abc import Generator
35
+
36
+ from agent_trace._replay.fixture import Fixture
37
+
38
+ from agent_trace.core.exceptions import (
39
+ NetworkGuardError,
40
+ RunawayToolCallLoopError,
41
+ guard_active,
42
+ )
43
+
44
+ __all__ = [
45
+ "AsyncRecordingTransport",
46
+ "AsyncReplayTransport",
47
+ "NetworkGuardError",
48
+ "RecordingTransport",
49
+ "ReplayTransport",
50
+ "RunawayToolCallLoopError",
51
+ "correlation_context",
52
+ "current_correlation_id",
53
+ "pop_correlation_id",
54
+ "push_correlation_id",
55
+ "raise_on_loop_detected",
56
+ "warn_on_loop_detected",
57
+ ]
58
+
59
+ logger = logging.getLogger(__name__)
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Every reconstructed httpx.Response below is built from body bytes that are
64
+ # already fully decoded (httpx's own Response.read()/.text/.content transparently
65
+ # gunzips/brotli-decodes based on Content-Encoding before we ever see them, and
66
+ # fixture-replayed bodies are the plain text persisted by record_exchange).
67
+ # Carrying the *original* response's Content-Encoding/Content-Length headers
68
+ # into that reconstruction is wrong on both counts: httpx.Response.__init__
69
+ # calls self.read() immediately, which re-applies the decoder implied by
70
+ # Content-Encoding to already-plain bytes and raises
71
+ # `httpx.DecodingError: Error -3 while decompressing data: incorrect header
72
+ # check` — reproduced live against the real (gzip-compressing-by-default)
73
+ # Gemini API, so this broke both recording (RecordingTransport/
74
+ # AsyncRecordingTransport) and replay (ReplayTransport/AsyncReplayTransport)
75
+ # for any upstream that compresses responses, not just Gemini. Content-Length
76
+ # is equally stale (it described the compressed byte count) and left in place
77
+ # would mislead any caller that trusts it over the actual body length.
78
+ # ---------------------------------------------------------------------------
79
+
80
+ _STALE_BODY_HEADERS = {"content-encoding", "content-length"}
81
+
82
+
83
+ def _strip_stale_body_headers(headers: dict[str, str]) -> dict[str, str]:
84
+ """Drop headers that describe the *original* (possibly compressed) wire
85
+ body, not the already-decoded bytes being used to reconstruct a Response."""
86
+ return {k: v for k, v in headers.items() if k.lower() not in _STALE_BODY_HEADERS}
87
+
88
+
89
+ # ---------------------------------------------------------------------------
90
+ # Batch-input / graph-node correlation — ties a recorded HTTP exchange back
91
+ # to whichever concurrent batch input (e.g. LangChain's abatch(config={
92
+ # "max_concurrency": N})) or graph node it originated from, instead of
93
+ # leaving N interleaved exchanges with no way to tell them apart short of
94
+ # manually diffing recorded request bodies against the original input list
95
+ # (#30924, #6037, #13449).
96
+ #
97
+ # A plain contextvars.ContextVar rather than thread-local state: each
98
+ # asyncio.Task created via asyncio.create_task()/asyncio.gather() gets its
99
+ # own copy of the current Context at creation time, so setting a distinct
100
+ # correlation id per concurrently-scheduled coroutine correctly isolates
101
+ # each batch input's exchanges from its siblings' — the same isolation
102
+ # mechanism Tracer._active_trace_var/_active_fixture_var already rely on
103
+ # elsewhere in this codebase for overlapping start_trace() contexts.
104
+ # ---------------------------------------------------------------------------
105
+
106
+ _correlation_id_var: contextvars.ContextVar[str | None] = contextvars.ContextVar(
107
+ "agent_trace_correlation_id", default=None
108
+ )
109
+
110
+
111
+ @contextmanager
112
+ def correlation_context(correlation_id: str) -> Generator[None, None, None]:
113
+ """Tag every HTTP exchange recorded while this context is active with
114
+ *correlation_id*.
115
+
116
+ Usage — tagging each item of a concurrent batch call so its recorded
117
+ exchanges are distinguishable afterwards::
118
+
119
+ async def _call_one(i, item):
120
+ with correlation_context(f"batch-item-{i}"):
121
+ return await chain.ainvoke(item)
122
+
123
+ await asyncio.gather(*(_call_one(i, x) for i, x in enumerate(items)))
124
+
125
+ Recover the grouped exchanges afterwards via
126
+ ``Fixture.exchanges_for_correlation_id(correlation_id)`` or
127
+ ``Fixture.correlation_ids()``. Nesting is supported — the innermost
128
+ active context wins, and the previous value (possibly None) is restored
129
+ on exit.
130
+ """
131
+ token = _correlation_id_var.set(correlation_id)
132
+ try:
133
+ yield
134
+ finally:
135
+ _correlation_id_var.reset(token)
136
+
137
+
138
+ def current_correlation_id() -> str | None:
139
+ """Return the correlation id set by the innermost active
140
+ ``correlation_context()``, or None if none is active."""
141
+ return _correlation_id_var.get()
142
+
143
+
144
+ def push_correlation_id(correlation_id: str) -> contextvars.Token[str | None]:
145
+ """Manual ``set()`` counterpart to ``correlation_context()`` for
146
+ callers that must set/reset across two separate call sites — e.g. a
147
+ callback-based framework integration's ``on_X_start``/``on_X_end`` pair
148
+ — rather than within a single enclosing ``with`` block.
149
+
150
+ Pair every call with ``pop_correlation_id(token)`` once the
151
+ corresponding span closes. See
152
+ ``agent_trace.integrations.langgraph.LangGraphTracer`` for the actual
153
+ usage: every span it opens (node/LLM/tool) pushes its own ``span_id``
154
+ as the correlation id for the duration that span is open, so an HTTP
155
+ exchange made anywhere inside it — including inside a supervisor
156
+ topology's sub-agent node — is automatically tagged with the
157
+ originating span, closing the "which node produced this HTTP call"
158
+ gap (#6037) the same way ``correlation_context()`` closes it for a
159
+ manually-tagged concurrent batch input (#30924).
160
+ """
161
+ return _correlation_id_var.set(correlation_id)
162
+
163
+
164
+ def pop_correlation_id(token: contextvars.Token[str | None]) -> None:
165
+ """Reset counterpart to ``push_correlation_id()``."""
166
+ _correlation_id_var.reset(token)
167
+
168
+
169
+ # ---------------------------------------------------------------------------
170
+ # Runaway-tool-call-loop guard — optional, opt-in via
171
+ # RecordingTransport(..., loop_guard_threshold=N). Counts *consecutive*
172
+ # tool-call-only responses (no final, tool-call-free assistant message) seen
173
+ # for the same host during the life of this transport instance and calls
174
+ # on_loop_detected(host, count) once the count reaches the threshold (and
175
+ # again on every subsequent response while it stays at/above threshold).
176
+ #
177
+ # This is a live, in-the-loop signal — not a replay-time diagnostic — for
178
+ # issue #3097 (a model that never stops emitting tool_calls, burning the
179
+ # full context window before anyone notices). Disabled by default
180
+ # (loop_guard_threshold=None) so it costs nothing for callers who don't want
181
+ # it: no per-response JSON parsing, no per-host bookkeeping.
182
+ # ---------------------------------------------------------------------------
183
+
184
+
185
+ def _response_host(url: str) -> str:
186
+ """Best-effort hostname for *url*; falls back to the raw url string on
187
+ any parse failure so the guard never raises for this reason alone."""
188
+ try:
189
+ host = httpx.URL(url).host
190
+ return host or url
191
+ except Exception:
192
+ return url
193
+
194
+
195
+ def _is_tool_call_only_response(response_body: str) -> bool:
196
+ """Best-effort detection of a chat-completion response that carries
197
+ tool call(s) and nothing else — no final, human-readable assistant
198
+ message alongside them.
199
+
200
+ Recognizes two response shapes:
201
+ - OpenAI/Groq/Azure-OpenAI-style Chat Completions:
202
+ ``choices[0].message.tool_calls`` non-empty and
203
+ ``choices[0].message.content`` empty/null, or
204
+ ``choices[0].finish_reason == "tool_calls"``.
205
+ - Anthropic Messages API: ``stop_reason == "tool_use"`` and no
206
+ ``content`` block of type ``"text"`` carries non-empty text.
207
+
208
+ Best-effort by design (same tradeoff as the rest of this module): a
209
+ response body that isn't valid JSON, or doesn't match either shape,
210
+ simply isn't flagged — this never raises for a malformed/unrecognized
211
+ body, it just returns False.
212
+ """
213
+ try:
214
+ data = json.loads(response_body)
215
+ except (json.JSONDecodeError, TypeError, ValueError):
216
+ return False
217
+ if not isinstance(data, dict):
218
+ return False
219
+
220
+ choices = data.get("choices")
221
+ if isinstance(choices, list) and choices:
222
+ first = choices[0]
223
+ if isinstance(first, dict):
224
+ message = first.get("message")
225
+ if isinstance(message, dict):
226
+ tool_calls = message.get("tool_calls")
227
+ has_tool_calls = isinstance(tool_calls, list) and len(tool_calls) > 0
228
+ if has_tool_calls:
229
+ content = message.get("content")
230
+ empty_content = content in (None, "", [])
231
+ if empty_content or first.get("finish_reason") == "tool_calls":
232
+ return True
233
+
234
+ if data.get("stop_reason") == "tool_use":
235
+ content_blocks = data.get("content")
236
+ if isinstance(content_blocks, list) and content_blocks:
237
+ has_text = any(
238
+ isinstance(block, dict)
239
+ and block.get("type") == "text"
240
+ and block.get("text")
241
+ for block in content_blocks
242
+ )
243
+ if not has_text:
244
+ return True
245
+
246
+ return False
247
+
248
+
249
+ def warn_on_loop_detected(host: str, count: int) -> None:
250
+ """Default ``on_loop_detected`` callback — emits a ``UserWarning`` and
251
+ lets the caller continue (does not raise). Recording, and the run being
252
+ recorded, both continue unaffected."""
253
+ warnings.warn(
254
+ f"agent-trace: {count} consecutive tool-call-only responses recorded "
255
+ f"for host {host!r} — possible runaway tool-call loop (see "
256
+ "https://github.com/langchain-ai/langgraph/issues/3097). Pass "
257
+ "on_loop_detected=agent_trace.interceptor.httpx_hook."
258
+ "raise_on_loop_detected to abort the run instead of warning.",
259
+ stacklevel=3,
260
+ )
261
+
262
+
263
+ def raise_on_loop_detected(host: str, count: int) -> None:
264
+ """Opt-in ``on_loop_detected`` callback that raises
265
+ :class:`RunawayToolCallLoopError` instead of warning — use this when you
266
+ want a suspected runaway tool-call loop to actually stop the run rather
267
+ than merely being logged."""
268
+ raise RunawayToolCallLoopError(
269
+ f"{count} consecutive tool-call-only responses recorded for host "
270
+ f"{host!r} — aborting (see "
271
+ "https://github.com/langchain-ai/langgraph/issues/3097)."
272
+ )
273
+
274
+
275
+ class _LoopGuardMixin:
276
+ """Shared consecutive-tool-call-only-response bookkeeping for
277
+ RecordingTransport and AsyncRecordingTransport. Not a public class —
278
+ both transports compose this state directly (see their __init__) rather
279
+ than inheriting, so it stays a plain implementation detail."""
280
+
281
+ def _init_loop_guard(
282
+ self,
283
+ loop_guard_threshold: int | None,
284
+ on_loop_detected: Callable[[str, int], None] | None,
285
+ ) -> None:
286
+ self._loop_guard_threshold = loop_guard_threshold
287
+ self._on_loop_detected = on_loop_detected or warn_on_loop_detected
288
+ self._consecutive_tool_call_counts: dict[str, int] = {}
289
+
290
+ def _check_loop_guard(self, url: str, response_body: str) -> None:
291
+ if self._loop_guard_threshold is None:
292
+ return
293
+ host = _response_host(url)
294
+ if _is_tool_call_only_response(response_body):
295
+ count = self._consecutive_tool_call_counts.get(host, 0) + 1
296
+ self._consecutive_tool_call_counts[host] = count
297
+ if count >= self._loop_guard_threshold:
298
+ self._on_loop_detected(host, count)
299
+ else:
300
+ self._consecutive_tool_call_counts[host] = 0
301
+
302
+
303
+ # ---------------------------------------------------------------------------
304
+ # Pass-through streaming tee — used by RecordingTransport/
305
+ # AsyncRecordingTransport when constructed with stream=True.
306
+ # ---------------------------------------------------------------------------
307
+ #
308
+ # The default (stream=False) recording path calls response.read()/aread()
309
+ # before returning anything to the caller: it fully drains the response body
310
+ # first, then reconstructs a fully-buffered httpx.Response. That destroys
311
+ # real incremental delivery for every request made while recording is
312
+ # active, not just the one that eventually reproduces a bug — a real cost
313
+ # for continuous/production recording of a streaming endpoint.
314
+ #
315
+ # The classes below wrap the real response's own byte iterator so the
316
+ # wrapped caller receives each chunk as soon as it arrives off the wire (the
317
+ # returned httpx.Response is constructed with stream=<tee>, never
318
+ # content=<fully-buffered bytes>), while the exact same bytes and their
319
+ # arrival timestamps are tee'd off to an on_complete callback once the
320
+ # stream is fully consumed (or explicitly closed) — that's the point the
321
+ # exchange actually gets written to the fixture.
322
+ #
323
+ # Best-effort by design: if a caller partially iterates the response and
324
+ # then simply drops the reference (never calling .read()/.iter_bytes() to
325
+ # exhaustion and never calling .close()), on_complete never fires and that
326
+ # exchange is not recorded — the same class of best-effort tradeoff the rest
327
+ # of this module already makes (e.g. ReplayTransport's network-guard
328
+ # fallback). httpx.Response.close() (auto-invoked by iter_raw() once its own
329
+ # source stream is exhausted; see httpx._models.Response.iter_raw) always
330
+ # reaches our close() in the normal full-read case.
331
+
332
+
333
+ class _TeeSyncByteStream(httpx.SyncByteStream):
334
+ """Sync pass-through tee — see module docstring above."""
335
+
336
+ def __init__(
337
+ self,
338
+ inner_response: httpx.Response,
339
+ on_complete: Callable[[bytes, list[float]], None],
340
+ ) -> None:
341
+ self._inner_response = inner_response
342
+ self._on_complete = on_complete
343
+ self._chunks: list[bytes] = []
344
+ self._chunk_offsets_s: list[float] = []
345
+ self._start = time.monotonic()
346
+ self._finished = False
347
+
348
+ def __iter__(self) -> Iterator[bytes]:
349
+ try:
350
+ for chunk in self._inner_response.iter_bytes():
351
+ self._chunk_offsets_s.append(time.monotonic() - self._start)
352
+ self._chunks.append(chunk)
353
+ yield chunk
354
+ finally:
355
+ self._finish()
356
+
357
+ def close(self) -> None:
358
+ self._inner_response.close()
359
+ self._finish()
360
+
361
+ def _finish(self) -> None:
362
+ if self._finished:
363
+ return
364
+ self._finished = True
365
+ self._on_complete(b"".join(self._chunks), list(self._chunk_offsets_s))
366
+
367
+
368
+ class _TeeAsyncByteStream(httpx.AsyncByteStream):
369
+ """Async pass-through tee — see module docstring above."""
370
+
371
+ def __init__(
372
+ self,
373
+ inner_response: httpx.Response,
374
+ on_complete: Callable[[bytes, list[float]], None],
375
+ ) -> None:
376
+ self._inner_response = inner_response
377
+ self._on_complete = on_complete
378
+ self._chunks: list[bytes] = []
379
+ self._chunk_offsets_s: list[float] = []
380
+ self._start = time.monotonic()
381
+ self._finished = False
382
+
383
+ async def __aiter__(self) -> AsyncIterator[bytes]:
384
+ try:
385
+ async for chunk in self._inner_response.aiter_bytes():
386
+ self._chunk_offsets_s.append(time.monotonic() - self._start)
387
+ self._chunks.append(chunk)
388
+ yield chunk
389
+ finally:
390
+ self._finish()
391
+
392
+ async def aclose(self) -> None:
393
+ await self._inner_response.aclose()
394
+ self._finish()
395
+
396
+ def _finish(self) -> None:
397
+ if self._finished:
398
+ return
399
+ self._finished = True
400
+ self._on_complete(b"".join(self._chunks), list(self._chunk_offsets_s))
401
+
402
+
403
+ class RecordingTransport(httpx.BaseTransport, _LoopGuardMixin):
404
+ """httpx transport that records every exchange to a Fixture.
405
+
406
+ Parameters
407
+ ----------
408
+ fixture:
409
+ Open Fixture instance where exchanges will be written.
410
+ inner:
411
+ The real transport to use for outbound requests. Defaults to
412
+ ``httpx.HTTPTransport()`` when None, which honours proxy/SSL settings
413
+ from the surrounding httpx.Client.
414
+ stream:
415
+ When False (the default), the historical eager-buffering behavior:
416
+ the full response body is read before this transport returns
417
+ anything to the caller, and the fixture is written synchronously
418
+ inside handle_request(). When True, opt into pass-through streaming
419
+ capture: the caller receives each chunk as it actually arrives off
420
+ the wire (no full-body buffering before the first byte reaches the
421
+ caller) while the same bytes and their per-chunk arrival timestamps
422
+ are tee'd into the fixture once the response stream is fully
423
+ consumed or closed. Use stream=True for always-on recording of
424
+ streaming/SSE endpoints, where the default mode's full-buffering
425
+ would otherwise destroy real incremental token delivery for every
426
+ request made while recording is active.
427
+ loop_guard_threshold:
428
+ Opt-in runaway-tool-call-loop guard, disabled by default (None).
429
+ When set to an integer N, this transport counts *consecutive*
430
+ tool-call-only responses (see ``_is_tool_call_only_response``)
431
+ recorded for the same host and calls ``on_loop_detected(host,
432
+ count)`` once that count reaches N — the live-recording signal for
433
+ issue #3097 (a model that never stops emitting tool_calls, burning
434
+ the full context window before anyone notices). A tool-call-only
435
+ response is one whose assistant message carries tool call(s) and no
436
+ other final content (OpenAI/Groq-style ``tool_calls`` with empty
437
+ ``content``, or Anthropic ``stop_reason: "tool_use"`` with no text
438
+ block) — any other response resets the count for that host to 0.
439
+ on_loop_detected:
440
+ Callback invoked as ``on_loop_detected(host, count)`` once
441
+ ``loop_guard_threshold`` is reached. Defaults to
442
+ :func:`warn_on_loop_detected` (emits a ``UserWarning``, does not
443
+ interrupt the run). Pass :func:`raise_on_loop_detected` to raise
444
+ :class:`~agent_trace.core.exceptions.RunawayToolCallLoopError`
445
+ instead — the exchange is still recorded to the fixture before the
446
+ exception propagates to the caller.
447
+ """
448
+
449
+ def __init__(
450
+ self,
451
+ fixture: Fixture,
452
+ inner: httpx.BaseTransport | None = None,
453
+ *,
454
+ stream: bool = False,
455
+ loop_guard_threshold: int | None = None,
456
+ on_loop_detected: Callable[[str, int], None] | None = None,
457
+ ) -> None:
458
+ self._fixture = fixture
459
+ self._inner: httpx.BaseTransport = (
460
+ inner if inner is not None else httpx.HTTPTransport()
461
+ )
462
+ self._stream = stream
463
+ self._init_loop_guard(loop_guard_threshold, on_loop_detected)
464
+
465
+ def handle_request(self, request: httpx.Request) -> httpx.Response:
466
+ """Forward the request, record the exchange, return the response.
467
+
468
+ A pre-response exception (connection refused, DNS failure, TLS
469
+ failure, an httpx.UnsupportedProtocol from a malformed/missing-scheme
470
+ URL, ...) is recorded too — as a failed-before-response exchange
471
+ (error_type/error_message, no response_status) — instead of being
472
+ silently lost, then re-raised unchanged so the caller sees the exact
473
+ same failure it would without recording active.
474
+ """
475
+ url = str(request.url)
476
+ method = request.method
477
+ req_headers = dict(request.headers)
478
+ req_body = request.content.decode("utf-8", errors="replace")
479
+
480
+ start = time.monotonic()
481
+ try:
482
+ response = self._inner.handle_request(request)
483
+ except Exception as exc:
484
+ duration_ms = (time.monotonic() - start) * 1000
485
+ self._fixture.record_exchange(
486
+ url=url,
487
+ method=method,
488
+ request_headers=req_headers,
489
+ request_body=req_body,
490
+ duration_ms=duration_ms,
491
+ error_type=type(exc).__qualname__,
492
+ error_message=str(exc),
493
+ correlation_id=current_correlation_id(),
494
+ )
495
+ raise
496
+
497
+ if self._stream:
498
+ return self._handle_stream_response(
499
+ request, response, url, method, req_headers, req_body, start
500
+ )
501
+
502
+ duration_ms = (time.monotonic() - start) * 1000
503
+
504
+ # Read the body eagerly so we can persist it; httpx streams lazily by
505
+ # default and the caller may never fully read it otherwise.
506
+ response.read()
507
+
508
+ resp_status = response.status_code
509
+ resp_headers = dict(response.headers)
510
+ resp_body = response.text
511
+
512
+ self._fixture.record_exchange(
513
+ url=url,
514
+ method=method,
515
+ request_headers=req_headers,
516
+ request_body=req_body,
517
+ response_status=resp_status,
518
+ response_headers=resp_headers,
519
+ response_body=resp_body,
520
+ duration_ms=duration_ms,
521
+ correlation_id=current_correlation_id(),
522
+ )
523
+ self._check_loop_guard(url, resp_body)
524
+
525
+ # Reconstruct so the caller receives a fully-read response with the
526
+ # same status, headers, and body as the original. response.content is
527
+ # already fully decoded, so the reconstructed Response must not carry
528
+ # the original Content-Encoding/Content-Length headers (see
529
+ # _strip_stale_body_headers).
530
+ return httpx.Response(
531
+ status_code=resp_status,
532
+ headers=_strip_stale_body_headers(resp_headers),
533
+ content=response.content,
534
+ request=request,
535
+ )
536
+
537
+ def _handle_stream_response(
538
+ self,
539
+ request: httpx.Request,
540
+ response: httpx.Response,
541
+ url: str,
542
+ method: str,
543
+ req_headers: dict[str, str],
544
+ req_body: str,
545
+ start: float,
546
+ ) -> httpx.Response:
547
+ """stream=True path: tee the response body to the caller and the
548
+ fixture simultaneously instead of buffering it first."""
549
+ resp_status = response.status_code
550
+ resp_headers = dict(response.headers)
551
+
552
+ def _on_complete(body_bytes: bytes, chunk_offsets_s: list[float]) -> None:
553
+ duration_ms = (time.monotonic() - start) * 1000
554
+ decoded_body = body_bytes.decode("utf-8", errors="replace")
555
+ self._fixture.record_exchange(
556
+ url=url,
557
+ method=method,
558
+ request_headers=req_headers,
559
+ request_body=req_body,
560
+ response_status=resp_status,
561
+ response_headers=resp_headers,
562
+ response_body=decoded_body,
563
+ duration_ms=duration_ms,
564
+ chunk_timestamps=chunk_offsets_s,
565
+ correlation_id=current_correlation_id(),
566
+ )
567
+ self._check_loop_guard(url, decoded_body)
568
+
569
+ tee = _TeeSyncByteStream(response, _on_complete)
570
+ return httpx.Response(
571
+ status_code=resp_status,
572
+ headers=_strip_stale_body_headers(resp_headers),
573
+ stream=tee,
574
+ request=request,
575
+ )
576
+
577
+ def close(self) -> None:
578
+ self._inner.close()
579
+
580
+
581
+ class ReplayTransport(httpx.BaseTransport):
582
+ """httpx transport that serves responses from a Fixture without network I/O.
583
+
584
+ Limitation — replay cannot simulate a *modified* request: this transport
585
+ only ever serves back the exact recorded ``response_body`` for a matching
586
+ ``(method, url)`` pair (see ``handle_request`` below). It never re-derives
587
+ what a request with different parameters would have returned — a
588
+ different model, a different ``model_settings.reasoning_effort``/
589
+ ``verbosity``, a different tool schema, a different prompt, etc. all
590
+ still replay the *original* recorded response verbatim. If the change
591
+ you're validating is itself a request parameter (e.g. switching
592
+ ``reasoning_effort`` to fix a tool-calling regression), record a fresh
593
+ run after making that change — replay only re-serves history, it does
594
+ not re-run inference.
595
+
596
+ Parameters
597
+ ----------
598
+ fixture:
599
+ Open Fixture instance to serve recorded exchanges from.
600
+ clock:
601
+ Optional FixtureClock to advance with each exchange's recorded_at
602
+ timestamp, reproducing original execution timing during replay.
603
+ """
604
+
605
+ def __init__(
606
+ self,
607
+ fixture: Fixture,
608
+ clock: Any | None = None,
609
+ ) -> None:
610
+ self._fixture = fixture
611
+ self._clock = clock
612
+
613
+ def handle_request(self, request: httpx.Request) -> httpx.Response:
614
+ """Return the next recorded response for *(method, url)*."""
615
+ url = str(request.url)
616
+ method = request.method
617
+ exchange: dict[str, Any] | None = self._fixture.next_exchange(url, method)
618
+
619
+ if exchange is None:
620
+ if guard_active():
621
+ raise NetworkGuardError(
622
+ f"No recorded exchange for {method} {url} and "
623
+ "AGENT_TRACE_NETWORK_GUARD=1 is set. "
624
+ "Run in recording mode first to capture this request."
625
+ )
626
+ # Guard is off: fall back to real network with a warning so
627
+ # developers notice in CI logs without failing immediately.
628
+ warnings.warn(
629
+ f"agent-trace: no fixture entry for {method} {url}; "
630
+ "falling through to live network. Set AGENT_TRACE_NETWORK_GUARD=1 "
631
+ "to make this an error.",
632
+ stacklevel=2,
633
+ )
634
+ fallback = httpx.HTTPTransport()
635
+ try:
636
+ return fallback.handle_request(request)
637
+ finally:
638
+ fallback.close()
639
+
640
+ # Advance the replay clock to the recorded wall time so that spans
641
+ # created during replay carry meaningful relative timestamps.
642
+ if self._clock is not None:
643
+ self._clock.advance(float(exchange["recorded_at"]))
644
+
645
+ return httpx.Response(
646
+ status_code=int(exchange["response_status"]),
647
+ headers=_strip_stale_body_headers(exchange["response_headers"]),
648
+ content=exchange["response_body"].encode("utf-8"),
649
+ request=request,
650
+ )
651
+
652
+ def close(self) -> None:
653
+ pass # No resources to release; fixture lifecycle is managed externally.
654
+
655
+
656
+ class AsyncRecordingTransport(httpx.AsyncBaseTransport, _LoopGuardMixin):
657
+ """httpx async transport that records every exchange to a Fixture.
658
+
659
+ Use this with ``httpx.AsyncClient`` — the sync ``RecordingTransport``
660
+ cannot be used with async clients because ``AsyncClient`` requires
661
+ ``handle_async_request``.
662
+
663
+ Parameters
664
+ ----------
665
+ fixture:
666
+ Open Fixture instance where exchanges will be written.
667
+ inner:
668
+ The real async transport to use for outbound requests. Defaults to
669
+ ``httpx.AsyncHTTPTransport()`` when None.
670
+ stream:
671
+ Same opt-in pass-through streaming capture mode as
672
+ ``RecordingTransport(..., stream=True)`` — see that class's
673
+ docstring. Defaults to False (the historical eager-buffering
674
+ behavior).
675
+ loop_guard_threshold, on_loop_detected:
676
+ Same opt-in runaway-tool-call-loop guard as
677
+ ``RecordingTransport`` — see that class's docstring.
678
+ """
679
+
680
+ def __init__(
681
+ self,
682
+ fixture: Fixture,
683
+ inner: httpx.AsyncBaseTransport | None = None,
684
+ *,
685
+ stream: bool = False,
686
+ loop_guard_threshold: int | None = None,
687
+ on_loop_detected: Callable[[str, int], None] | None = None,
688
+ ) -> None:
689
+ self._fixture = fixture
690
+ self._inner: httpx.AsyncBaseTransport = (
691
+ inner if inner is not None else httpx.AsyncHTTPTransport()
692
+ )
693
+ self._stream = stream
694
+ self._init_loop_guard(loop_guard_threshold, on_loop_detected)
695
+
696
+ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
697
+ """Forward the request async, record the exchange, return the response.
698
+
699
+ A pre-response exception is recorded too — see
700
+ ``RecordingTransport.handle_request``'s docstring for the sync
701
+ equivalent of this behavior.
702
+ """
703
+ url = str(request.url)
704
+ method = request.method
705
+ req_headers = dict(request.headers)
706
+ req_body = request.content.decode("utf-8", errors="replace")
707
+
708
+ start = time.monotonic()
709
+ try:
710
+ response = await self._inner.handle_async_request(request)
711
+ except Exception as exc:
712
+ duration_ms = (time.monotonic() - start) * 1000
713
+ self._fixture.record_exchange(
714
+ url=url,
715
+ method=method,
716
+ request_headers=req_headers,
717
+ request_body=req_body,
718
+ duration_ms=duration_ms,
719
+ error_type=type(exc).__qualname__,
720
+ error_message=str(exc),
721
+ correlation_id=current_correlation_id(),
722
+ )
723
+ raise
724
+
725
+ if self._stream:
726
+ return self._handle_stream_response(
727
+ request, response, url, method, req_headers, req_body, start
728
+ )
729
+
730
+ duration_ms = (time.monotonic() - start) * 1000
731
+ await response.aread()
732
+
733
+ resp_status = response.status_code
734
+ resp_headers = dict(response.headers)
735
+ resp_body = response.text
736
+
737
+ self._fixture.record_exchange(
738
+ url=url,
739
+ method=method,
740
+ request_headers=req_headers,
741
+ request_body=req_body,
742
+ response_status=resp_status,
743
+ response_headers=resp_headers,
744
+ response_body=resp_body,
745
+ duration_ms=duration_ms,
746
+ correlation_id=current_correlation_id(),
747
+ )
748
+ self._check_loop_guard(url, resp_body)
749
+
750
+ return httpx.Response(
751
+ status_code=resp_status,
752
+ headers=_strip_stale_body_headers(resp_headers),
753
+ content=response.content,
754
+ request=request,
755
+ )
756
+
757
+ def _handle_stream_response(
758
+ self,
759
+ request: httpx.Request,
760
+ response: httpx.Response,
761
+ url: str,
762
+ method: str,
763
+ req_headers: dict[str, str],
764
+ req_body: str,
765
+ start: float,
766
+ ) -> httpx.Response:
767
+ """stream=True path: tee the response body to the caller and the
768
+ fixture simultaneously instead of buffering it first."""
769
+ resp_status = response.status_code
770
+ resp_headers = dict(response.headers)
771
+
772
+ def _on_complete(body_bytes: bytes, chunk_offsets_s: list[float]) -> None:
773
+ duration_ms = (time.monotonic() - start) * 1000
774
+ decoded_body = body_bytes.decode("utf-8", errors="replace")
775
+ self._fixture.record_exchange(
776
+ url=url,
777
+ method=method,
778
+ request_headers=req_headers,
779
+ request_body=req_body,
780
+ response_status=resp_status,
781
+ response_headers=resp_headers,
782
+ response_body=decoded_body,
783
+ duration_ms=duration_ms,
784
+ chunk_timestamps=chunk_offsets_s,
785
+ correlation_id=current_correlation_id(),
786
+ )
787
+ self._check_loop_guard(url, decoded_body)
788
+
789
+ tee = _TeeAsyncByteStream(response, _on_complete)
790
+ return httpx.Response(
791
+ status_code=resp_status,
792
+ headers=_strip_stale_body_headers(resp_headers),
793
+ stream=tee,
794
+ request=request,
795
+ )
796
+
797
+ async def aclose(self) -> None:
798
+ await self._inner.aclose()
799
+
800
+
801
+ class AsyncReplayTransport(httpx.AsyncBaseTransport):
802
+ """httpx async transport that serves responses from a Fixture without network I/O.
803
+
804
+ Use this with ``httpx.AsyncClient`` — the sync ``ReplayTransport`` cannot
805
+ be used with async clients because ``AsyncClient`` requires
806
+ ``handle_async_request``.
807
+
808
+ Same limitation as ``ReplayTransport``: it replays the exact recorded
809
+ bytes for a matching ``(method, url)`` and cannot simulate what a
810
+ *modified* request (different model, ``model_settings``, tool schema,
811
+ prompt, ...) would have returned. See ``ReplayTransport``'s docstring.
812
+
813
+ Parameters
814
+ ----------
815
+ fixture:
816
+ Open Fixture instance to serve recorded exchanges from.
817
+ clock:
818
+ Optional FixtureClock to advance with each exchange's recorded_at
819
+ timestamp, reproducing original execution timing during replay.
820
+ """
821
+
822
+ def __init__(
823
+ self,
824
+ fixture: Fixture,
825
+ clock: Any | None = None,
826
+ ) -> None:
827
+ self._fixture = fixture
828
+ self._clock = clock
829
+
830
+ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
831
+ """Return the next recorded response for *(method, url)*."""
832
+ url = str(request.url)
833
+ method = request.method
834
+ exchange: dict[str, Any] | None = self._fixture.next_exchange(url, method)
835
+
836
+ if exchange is None:
837
+ if guard_active():
838
+ raise NetworkGuardError(
839
+ f"No recorded exchange for {method} {url} and "
840
+ "AGENT_TRACE_NETWORK_GUARD=1 is set. "
841
+ "Run in recording mode first to capture this request."
842
+ )
843
+ warnings.warn(
844
+ f"agent-trace: no fixture entry for {method} {url}; "
845
+ "falling through to live network. Set AGENT_TRACE_NETWORK_GUARD=1 "
846
+ "to make this an error.",
847
+ stacklevel=2,
848
+ )
849
+ fallback = httpx.AsyncHTTPTransport()
850
+ try:
851
+ return await fallback.handle_async_request(request)
852
+ finally:
853
+ await fallback.aclose()
854
+
855
+ if self._clock is not None:
856
+ self._clock.advance(float(exchange["recorded_at"]))
857
+
858
+ return httpx.Response(
859
+ status_code=int(exchange["response_status"]),
860
+ headers=_strip_stale_body_headers(exchange["response_headers"]),
861
+ content=exchange["response_body"].encode("utf-8"),
862
+ request=request,
863
+ )
864
+
865
+ async def aclose(self) -> None:
866
+ pass # No resources to release; fixture lifecycle is managed externally.