agent-observability-trace-cli 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_observability_trace_cli-0.1.2.dist-info/METADATA +336 -0
- agent_observability_trace_cli-0.1.2.dist-info/RECORD +51 -0
- agent_observability_trace_cli-0.1.2.dist-info/WHEEL +4 -0
- agent_observability_trace_cli-0.1.2.dist-info/entry_points.txt +2 -0
- agent_observability_trace_cli-0.1.2.dist-info/licenses/LICENSE +198 -0
- agent_trace/__init__.py +1182 -0
- agent_trace/_cli.py +1377 -0
- agent_trace/_inspect.py +2020 -0
- agent_trace/_replay/__init__.py +1 -0
- agent_trace/_replay/engine.py +268 -0
- agent_trace/_replay/fixture.py +868 -0
- agent_trace/core/__init__.py +1 -0
- agent_trace/core/clock.py +91 -0
- agent_trace/core/exceptions.py +51 -0
- agent_trace/core/span.py +271 -0
- agent_trace/core/trace.py +92 -0
- agent_trace/exporters/__init__.py +1 -0
- agent_trace/exporters/file.py +80 -0
- agent_trace/exporters/otlp.py +199 -0
- agent_trace/exporters/remote_fixture.py +307 -0
- agent_trace/exporters/stdout.py +258 -0
- agent_trace/integrations/__init__.py +69 -0
- agent_trace/integrations/agno.py +489 -0
- agent_trace/integrations/autogen.py +581 -0
- agent_trace/integrations/crewai.py +486 -0
- agent_trace/integrations/google_genai.py +731 -0
- agent_trace/integrations/haystack.py +254 -0
- agent_trace/integrations/langchain_core.py +361 -0
- agent_trace/integrations/langgraph.py +2093 -0
- agent_trace/integrations/langgraph_checkpoint.py +763 -0
- agent_trace/integrations/langgraph_state_diff.py +345 -0
- agent_trace/integrations/langgraph_stream_debug.py +202 -0
- agent_trace/integrations/llama_index.py +472 -0
- agent_trace/integrations/mcp.py +281 -0
- agent_trace/integrations/openai_agents.py +811 -0
- agent_trace/integrations/pydantic_ai.py +504 -0
- agent_trace/integrations/streaming.py +218 -0
- agent_trace/interceptor/__init__.py +64 -0
- agent_trace/interceptor/aiohttp_hook.py +152 -0
- agent_trace/interceptor/botocore_hook.py +223 -0
- agent_trace/interceptor/grpc_hook.py +651 -0
- agent_trace/interceptor/httpx_hook.py +866 -0
- agent_trace/interceptor/logging_hook.py +137 -0
- agent_trace/interceptor/requests_patch.py +184 -0
- agent_trace/interceptor/sse.py +176 -0
- agent_trace/interceptor/stdio_hook.py +245 -0
- agent_trace/interceptor/warnings_hook.py +132 -0
- agent_trace/interceptor/websocket_hook.py +323 -0
- agent_trace/plugins/__init__.py +35 -0
- agent_trace/plugins/base.py +111 -0
- agent_trace/py.typed +0 -0
|
@@ -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.
|