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,245 @@
1
+ """
2
+ MCP stdio-transport capture layer.
3
+
4
+ ``agent-trace``'s only capture layer historically has been HTTP-level
5
+ (``httpx``/``requests`` — see ``httpx_hook.py``/``requests_patch.py``). MCP's
6
+ ``stdio`` transport communicates over subprocess stdin/stdout pipes instead —
7
+ zero HTTP traffic touches the wire, so an MCP-stdio-related failure (e.g. a
8
+ startup/tool-loading crash) is completely invisible to those interceptors,
9
+ regardless of which framework wraps the MCP client.
10
+
11
+ ``recording_stdio_client`` wraps ``mcp.client.stdio.stdio_client`` the same
12
+ way ``RecordingTransport`` wraps a real ``httpx`` transport: the real
13
+ subprocess/streams are used unmodified, every JSON-RPC frame that flows
14
+ through is persisted to a :class:`~agent_trace._replay.fixture.Fixture`
15
+ *before* being handed back to the caller, and the caller (typically an MCP
16
+ ``ClientSession``) is none the wiser.
17
+
18
+ Usage::
19
+
20
+ from mcp import ClientSession, StdioServerParameters
21
+ from agent_trace.interceptor.stdio_hook import recording_stdio_client
22
+
23
+ server = StdioServerParameters(command="my-mcp-server", args=[])
24
+ async with recording_stdio_client(server, fixture) as (read, write):
25
+ async with ClientSession(read, write) as session:
26
+ await session.initialize()
27
+ tools = await session.list_tools()
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import logging
33
+ import sys
34
+ from collections.abc import AsyncIterator
35
+ from contextlib import asynccontextmanager
36
+ from typing import TYPE_CHECKING, Any, TextIO
37
+
38
+ if TYPE_CHECKING:
39
+ from agent_trace._replay.fixture import Fixture
40
+
41
+ __all__ = ["recording_stdio_client"]
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+ _INSTALL_HINT = (
46
+ "MCP stdio recording requires the mcp package.\n"
47
+ "Install it with:\n\n"
48
+ " pip install mcp\n"
49
+ )
50
+
51
+
52
+ def _require_mcp() -> Any:
53
+ """Lazy import guard — raises a clear error if mcp is absent."""
54
+ try:
55
+ import mcp
56
+
57
+ return mcp
58
+ except ImportError as exc:
59
+ raise ImportError(_INSTALL_HINT) from exc
60
+
61
+
62
+ def _classify_frame(root: Any) -> tuple[str, str | None, str | None]:
63
+ """Classify a ``JSONRPCMessage.root`` union member.
64
+
65
+ Returns ``(frame_type, rpc_id, method)`` where ``frame_type`` is one of
66
+ ``"request"``, ``"notification"``, ``"response"``, ``"error"``, or
67
+ ``"unknown"``.
68
+
69
+ Distinguishing the four ``mcp.types`` JSON-RPC frame shapes:
70
+
71
+ - ``JSONRPCRequest``: has ``method`` *and* ``id``.
72
+ - ``JSONRPCNotification``: has ``method``, no ``id``.
73
+ - ``JSONRPCResponse``: has ``id`` and ``result``, no ``method``.
74
+ - ``JSONRPCError``: has ``id`` and ``error``, no ``method``.
75
+ """
76
+ rpc_id = str(root.id) if getattr(root, "id", None) is not None else None
77
+ method = getattr(root, "method", None)
78
+ if getattr(root, "error", None) is not None:
79
+ return "error", rpc_id, method
80
+ if hasattr(root, "result"):
81
+ return "response", rpc_id, method
82
+ if method is not None and rpc_id is not None:
83
+ return "request", rpc_id, method
84
+ if method is not None:
85
+ return "notification", rpc_id, method
86
+ return "unknown", rpc_id, method # pragma: no cover — defensive fallback
87
+
88
+
89
+ class _RecordingReceiveStream:
90
+ """Tees every ``SessionMessage`` read from *inner* into a Fixture.
91
+
92
+ Duck-types anyio's ``ObjectReceiveStream`` interface (``receive``,
93
+ ``aclose``, ``__aiter__``/``__anext__``, async-context-manager) so it can
94
+ be handed directly to ``ClientSession`` in place of the real stream.
95
+ """
96
+
97
+ def __init__(
98
+ self,
99
+ inner: Any,
100
+ fixture: Fixture,
101
+ server_command: str,
102
+ ) -> None:
103
+ self._inner = inner
104
+ self._fixture = fixture
105
+ self._server_command = server_command
106
+
107
+ def __aiter__(self) -> _RecordingReceiveStream:
108
+ return self
109
+
110
+ async def __anext__(self) -> Any:
111
+ import anyio
112
+
113
+ try:
114
+ return await self.receive()
115
+ except anyio.EndOfStream:
116
+ raise StopAsyncIteration from None
117
+
118
+ async def receive(self) -> Any:
119
+ item = await self._inner.receive()
120
+ self._record(item)
121
+ return item
122
+
123
+ def _record(self, item: Any) -> None:
124
+ from mcp.shared.message import SessionMessage
125
+
126
+ # read_stream carries `SessionMessage | Exception` — malformed frames
127
+ # the real client failed to parse arrive as bare exceptions; nothing
128
+ # to record on the wire for those.
129
+ if not isinstance(item, SessionMessage):
130
+ return
131
+ try:
132
+ root = item.message.root
133
+ frame_type, rpc_id, method = _classify_frame(root)
134
+ payload = item.message.model_dump_json(by_alias=True, exclude_none=True)
135
+ self._fixture.record_mcp_frame(
136
+ server_command=self._server_command,
137
+ direction="from_server",
138
+ frame_type=frame_type,
139
+ rpc_id=rpc_id,
140
+ method=method,
141
+ payload=payload,
142
+ )
143
+ except Exception:
144
+ logger.debug(
145
+ "agent-trace: failed to record inbound MCP frame", exc_info=True
146
+ )
147
+
148
+ async def aclose(self) -> None:
149
+ await self._inner.aclose()
150
+
151
+ async def __aenter__(self) -> _RecordingReceiveStream:
152
+ return self
153
+
154
+ async def __aexit__(self, *exc_info: Any) -> None:
155
+ await self.aclose()
156
+
157
+
158
+ class _RecordingSendStream:
159
+ """Tees every ``SessionMessage`` sent through *inner* into a Fixture."""
160
+
161
+ def __init__(
162
+ self,
163
+ inner: Any,
164
+ fixture: Fixture,
165
+ server_command: str,
166
+ ) -> None:
167
+ self._inner = inner
168
+ self._fixture = fixture
169
+ self._server_command = server_command
170
+
171
+ async def send(self, item: Any) -> None:
172
+ self._record(item)
173
+ await self._inner.send(item)
174
+
175
+ def _record(self, item: Any) -> None:
176
+ from mcp.shared.message import SessionMessage
177
+
178
+ if not isinstance(item, SessionMessage):
179
+ return # pragma: no cover — write_stream is SessionMessage-only
180
+ try:
181
+ root = item.message.root
182
+ frame_type, rpc_id, method = _classify_frame(root)
183
+ payload = item.message.model_dump_json(by_alias=True, exclude_none=True)
184
+ self._fixture.record_mcp_frame(
185
+ server_command=self._server_command,
186
+ direction="to_server",
187
+ frame_type=frame_type,
188
+ rpc_id=rpc_id,
189
+ method=method,
190
+ payload=payload,
191
+ )
192
+ except Exception:
193
+ logger.debug(
194
+ "agent-trace: failed to record outbound MCP frame", exc_info=True
195
+ )
196
+
197
+ async def aclose(self) -> None:
198
+ await self._inner.aclose()
199
+
200
+ async def __aenter__(self) -> _RecordingSendStream:
201
+ return self
202
+
203
+ async def __aexit__(self, *exc_info: Any) -> None:
204
+ await self.aclose()
205
+
206
+
207
+ @asynccontextmanager
208
+ async def recording_stdio_client(
209
+ server: Any,
210
+ fixture: Fixture,
211
+ errlog: TextIO | None = None,
212
+ ) -> AsyncIterator[tuple[_RecordingReceiveStream, _RecordingSendStream]]:
213
+ """Wrap ``mcp.client.stdio.stdio_client`` to record every JSON-RPC frame.
214
+
215
+ Parameters
216
+ ----------
217
+ server:
218
+ An ``mcp.StdioServerParameters`` instance describing the subprocess
219
+ to launch (``command``, ``args``, ``env``, ``cwd``).
220
+ fixture:
221
+ Open :class:`~agent_trace._replay.fixture.Fixture` to record into.
222
+ errlog:
223
+ Passed through to ``stdio_client`` for the child process's stderr
224
+ (defaults to ``sys.stderr``, matching the real client's default).
225
+
226
+ Yields
227
+ ------
228
+ tuple[_RecordingReceiveStream, _RecordingSendStream]
229
+ Drop-in replacements for the ``(read_stream, write_stream)`` pair
230
+ ``stdio_client`` normally yields — pass these straight into
231
+ ``mcp.ClientSession``.
232
+ """
233
+ _require_mcp()
234
+ from mcp.client.stdio import stdio_client
235
+
236
+ command = f"{server.command} {' '.join(server.args)}".strip()
237
+ effective_errlog: TextIO = errlog if errlog is not None else sys.stderr
238
+
239
+ async with stdio_client(server, errlog=effective_errlog) as (
240
+ read_stream,
241
+ write_stream,
242
+ ):
243
+ recording_read = _RecordingReceiveStream(read_stream, fixture, command)
244
+ recording_write = _RecordingSendStream(write_stream, fixture, command)
245
+ yield recording_read, recording_write
@@ -0,0 +1,132 @@
1
+ """
2
+ Python `warnings`-capture layer for library-raised runtime warnings.
3
+
4
+ Some real, reproducible failures never raise an exception and never touch
5
+ the network at all — e.g. langgraph#5628's ``RuntimeWarning: Failed to trim
6
+ messages to fit within max_tokens limit before summarization``, raised
7
+ entirely inside local token-counting logic before any HTTP call is
8
+ attempted. Nothing in agent-trace previously captured arbitrary
9
+ library-raised ``warnings.warn()`` calls (the only prior ``warnings.warn()``
10
+ call sites in the codebase were agent-trace's own internal warnings in
11
+ ``httpx_hook.py``/``requests_patch.py``) — this is a related but distinct
12
+ gap from ``agent_trace.interceptor.logging_hook`` (that one targets
13
+ Python's ``logging`` module for a different subsystem's warning path —
14
+ LangGraph's pregel-scheduler log line for langgraph#5464; this one targets
15
+ Python's ``warnings`` module, requiring a ``warnings.catch_warnings()``/
16
+ ``showwarning`` override instead of a ``logging.Handler``).
17
+
18
+ Usage::
19
+
20
+ from agent_trace import tracer
21
+ from agent_trace.interceptor.warnings_hook import capture_warnings
22
+
23
+ with tracer.start_trace("my_graph") as trace:
24
+ with capture_warnings(tracer):
25
+ graph.invoke(state, config={"callbacks": [...]})
26
+
27
+ Every ``UserWarning``/``RuntimeWarning`` (or whatever *categories* is
28
+ passed) raised anywhere during the ``with`` block is persisted as a
29
+ ``runtime_warning`` event on a dedicated ``warnings:capture`` span —
30
+ independent of, and in addition to, whatever HTTP/callback capture is also
31
+ active.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import contextlib
37
+ import logging
38
+ import warnings
39
+ from collections.abc import Generator
40
+ from typing import TYPE_CHECKING, Any
41
+
42
+ from agent_trace.core.span import Span, SpanStatus
43
+
44
+ if TYPE_CHECKING:
45
+ from agent_trace import Tracer
46
+
47
+ __all__ = ["capture_warnings"]
48
+
49
+ logger = logging.getLogger(__name__)
50
+
51
+ # Bounds — this is diagnostic capture, not a full warnings mirror. A
52
+ # pathological hot loop emitting thousands of warnings must not grow a
53
+ # single span's event list unboundedly; warnings.captured_count keeps
54
+ # counting past the cap.
55
+ _MAX_ATTR_LEN = 4_000
56
+ _MAX_WARNING_EVENTS = 200
57
+
58
+ _DEFAULT_CATEGORIES: tuple[type[Warning], ...] = (UserWarning, RuntimeWarning)
59
+
60
+
61
+ @contextlib.contextmanager
62
+ def capture_warnings(
63
+ tracer: Tracer,
64
+ *,
65
+ categories: tuple[type[Warning], ...] = _DEFAULT_CATEGORIES,
66
+ span_name: str = "warnings:capture",
67
+ ) -> Generator[Span, None, None]:
68
+ """Install a ``warnings.catch_warnings()`` context with a custom
69
+ ``showwarning`` override for the lifetime of the ``with`` block,
70
+ persisting every caught warning matching *categories* onto a dedicated
71
+ ``warnings:capture`` span (opened via *tracer*).
72
+
73
+ Uses ``warnings.simplefilter("always")`` inside the ``catch_warnings()``
74
+ context so a warning that Python would otherwise only show once per
75
+ location (the default ``"default"`` filter action) is still captured on
76
+ every occurrence, without changing any filter state outside this
77
+ ``with`` block — ``catch_warnings()`` saves and restores
78
+ ``warnings.filters``/``showwarning`` on exit regardless of how the block
79
+ exits.
80
+
81
+ The span closes ``OK`` when the block exits normally (warning capture
82
+ is a diagnostic side-channel, not a success/failure signal in itself)
83
+ or ``ERROR`` if the block itself raises (the exception is recorded onto
84
+ the span, then re-raised unchanged).
85
+ """
86
+ span = tracer.start_span(span_name)
87
+ count = 0
88
+
89
+ def _showwarning(
90
+ message: Warning | str,
91
+ category: type[Warning],
92
+ filename: str,
93
+ lineno: int,
94
+ file: Any = None,
95
+ line: str | None = None,
96
+ ) -> None:
97
+ nonlocal count
98
+ if not (isinstance(category, type) and issubclass(category, categories)):
99
+ return
100
+ count += 1
101
+ if count > _MAX_WARNING_EVENTS:
102
+ return
103
+ try:
104
+ span.add_event(
105
+ "runtime_warning",
106
+ attributes={
107
+ "warning.category": category.__name__,
108
+ "warning.message": str(message)[:_MAX_ATTR_LEN],
109
+ "warning.filename": str(filename),
110
+ "warning.lineno": int(lineno),
111
+ },
112
+ )
113
+ except Exception:
114
+ logger.debug(
115
+ "agent-trace: failed to record captured warning event",
116
+ exc_info=True,
117
+ )
118
+
119
+ try:
120
+ with warnings.catch_warnings():
121
+ warnings.simplefilter("always")
122
+ warnings.showwarning = _showwarning
123
+ yield span
124
+ except Exception as exc:
125
+ span.record_exception(exc)
126
+ if span.end_time is None:
127
+ span.end(SpanStatus.ERROR)
128
+ raise
129
+ else:
130
+ span.set_attribute("warnings.captured_count", count)
131
+ if span.end_time is None:
132
+ span.end(SpanStatus.OK)
@@ -0,0 +1,323 @@
1
+ """
2
+ WebSocket connection wrappers for recording and replaying Realtime-API-style
3
+ duplex sessions (e.g. the OpenAI Agents SDK's Realtime API, which opens a
4
+ persistent ``websockets.asyncio.client.ClientConnection`` and exchanges many
5
+ JSON events over it — tool calls, handoffs, audio deltas — rather than the
6
+ discrete request/response model ``RecordingTransport``/``ReplayTransport``
7
+ (``httpx_hook.py``) assume).
8
+
9
+ RecordingWebSocketConnection wraps a real connection object (anything
10
+ exposing the ``websockets`` client protocol: async ``send``, async ``recv``,
11
+ async iteration, and ``close``): frames flow through unmodified in both
12
+ directions while each one is teed into the fixture store as it passes.
13
+
14
+ ReplayWebSocketConnection never touches the network. It serves previously
15
+ recorded inbound ("recv") frames from the fixture, in the order they were
16
+ captured, so a Realtime session can be replayed offline at zero API cost.
17
+ Frames sent by the caller during replay are accepted but not forwarded
18
+ anywhere — there is no live peer to send them to, matching how
19
+ ``ReplayTransport`` ignores request bodies and only replays responses keyed
20
+ by (method, url).
21
+
22
+ RecordingConnect is a drop-in stand-in for ``websockets.connect(...)`` (the
23
+ callable this module patches) that supports both calling conventions the
24
+ ``websockets`` library provides::
25
+
26
+ ws = await websockets.connect(uri, **kwargs)
27
+ async with websockets.connect(uri, **kwargs) as ws:
28
+
29
+ and returns a RecordingWebSocketConnection either way.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import logging
35
+ import uuid
36
+ import warnings
37
+ from collections.abc import AsyncIterator
38
+ from typing import TYPE_CHECKING, Any
39
+
40
+ if TYPE_CHECKING:
41
+ from agent_trace._replay.fixture import Fixture
42
+
43
+ from agent_trace.core.exceptions import NetworkGuardError, guard_active
44
+
45
+ __all__ = [
46
+ "NetworkGuardError",
47
+ "RecordingConnect",
48
+ "RecordingWebSocketConnection",
49
+ "ReplayWebSocketConnection",
50
+ "WebSocketReplayExhaustedError",
51
+ ]
52
+
53
+ logger = logging.getLogger(__name__)
54
+
55
+ DIRECTION_SEND = "send"
56
+ DIRECTION_RECV = "recv"
57
+
58
+
59
+ class WebSocketReplayExhaustedError(EOFError):
60
+ """Raised by :meth:`ReplayWebSocketConnection.recv` when no more recorded
61
+ inbound frames remain and ``AGENT_TRACE_NETWORK_GUARD`` is not set.
62
+
63
+ Unlike ``ReplayTransport``'s HTTP fallback (which can fall through to a
64
+ real request), there is no live peer to fall back to for a duplex
65
+ WebSocket session during replay, so this is always the terminal signal —
66
+ the caller should treat it the way it would treat the underlying library's
67
+ own "connection closed" exception.
68
+ """
69
+
70
+
71
+ def _decode_for_recording(message: Any) -> tuple[str, str]:
72
+ """Return (payload_as_text, frame_type) for a frame about to be recorded."""
73
+ if isinstance(message, (bytes, bytearray)):
74
+ return message.decode("utf-8", errors="replace"), "binary"
75
+ return str(message), "text"
76
+
77
+
78
+ def _encode_from_frame(frame: dict[str, Any]) -> Any:
79
+ """Inverse of _decode_for_recording — reconstruct the original message shape."""
80
+ if frame["frame_type"] == "binary":
81
+ return frame["payload"].encode("utf-8")
82
+ return frame["payload"]
83
+
84
+
85
+ class RecordingWebSocketConnection:
86
+ """Wraps a real WebSocket client connection, recording every frame.
87
+
88
+ Duck-types the subset of ``websockets.asyncio.client.ClientConnection``
89
+ that agent-trace's supported callers use — ``send``, ``recv``, async
90
+ iteration (``async for message in ws``), and ``close`` — and forwards
91
+ every other attribute access to the wrapped connection (e.g.
92
+ ``close_code``, ``state``, ``request``, ``response``), so instrumented
93
+ code can use this exactly like the real connection.
94
+
95
+ Parameters
96
+ ----------
97
+ inner:
98
+ The real, already-connected WebSocket client connection.
99
+ fixture:
100
+ Open Fixture instance where frames will be written.
101
+ url:
102
+ The URL the connection was opened against — stored alongside every
103
+ frame so a fixture holding multiple connections can be filtered.
104
+ connection_id:
105
+ Identifier grouping frames from this connection in the fixture.
106
+ Auto-generated if not supplied.
107
+ """
108
+
109
+ def __init__(
110
+ self,
111
+ inner: Any,
112
+ fixture: Fixture,
113
+ url: str,
114
+ connection_id: str | None = None,
115
+ ) -> None:
116
+ self._inner = inner
117
+ self._fixture = fixture
118
+ self._url = url
119
+ self.connection_id: str = connection_id or uuid.uuid4().hex[:16]
120
+
121
+ async def send(self, message: Any, *args: Any, **kwargs: Any) -> None:
122
+ """Send *message*, recording it as an outbound ("send") frame first.
123
+
124
+ Recording before the awaited send (rather than after) means a frame
125
+ that raises mid-send (e.g. connection dropped) is still captured —
126
+ matching the intent, if not the exact ordering, of
127
+ ``RecordingTransport`` which always records a completed exchange.
128
+ """
129
+ payload, frame_type = _decode_for_recording(message)
130
+ self._fixture.record_ws_frame(
131
+ connection_id=self.connection_id,
132
+ url=self._url,
133
+ direction=DIRECTION_SEND,
134
+ payload=payload,
135
+ frame_type=frame_type,
136
+ )
137
+ await self._inner.send(message, *args, **kwargs)
138
+
139
+ async def recv(self, *args: Any, **kwargs: Any) -> Any:
140
+ """Receive the next message, recording it as an inbound ("recv") frame."""
141
+ message = await self._inner.recv(*args, **kwargs)
142
+ payload, frame_type = _decode_for_recording(message)
143
+ self._fixture.record_ws_frame(
144
+ connection_id=self.connection_id,
145
+ url=self._url,
146
+ direction=DIRECTION_RECV,
147
+ payload=payload,
148
+ frame_type=frame_type,
149
+ )
150
+ return message
151
+
152
+ def __aiter__(self) -> AsyncIterator[Any]:
153
+ return self._iter_messages()
154
+
155
+ async def _iter_messages(self) -> AsyncIterator[Any]:
156
+ async for message in self._inner:
157
+ payload, frame_type = _decode_for_recording(message)
158
+ self._fixture.record_ws_frame(
159
+ connection_id=self.connection_id,
160
+ url=self._url,
161
+ direction=DIRECTION_RECV,
162
+ payload=payload,
163
+ frame_type=frame_type,
164
+ )
165
+ yield message
166
+
167
+ async def close(self, *args: Any, **kwargs: Any) -> None:
168
+ await self._inner.close(*args, **kwargs)
169
+
170
+ async def __aenter__(self) -> RecordingWebSocketConnection:
171
+ return self
172
+
173
+ async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
174
+ await self.close()
175
+
176
+ def __getattr__(self, name: str) -> Any:
177
+ # Passthrough for anything not explicitly wrapped above (close_code,
178
+ # state, request, response, ping, pong, ...).
179
+ return getattr(self._inner, name)
180
+
181
+
182
+ class ReplayWebSocketConnection:
183
+ """Serves recorded WS frames from a Fixture without any network I/O.
184
+
185
+ Parameters
186
+ ----------
187
+ fixture:
188
+ Open Fixture instance to serve recorded frames from.
189
+ url:
190
+ The URL the connection would have been opened against — used only
191
+ for NetworkGuardError messages.
192
+ connection_id:
193
+ Identifier selecting which connection's frames to serve. Must match
194
+ the ``connection_id`` used when the session was recorded.
195
+ clock:
196
+ Optional FixtureClock to advance with each frame's recorded_at
197
+ timestamp, reproducing original timing during replay.
198
+ """
199
+
200
+ def __init__(
201
+ self,
202
+ fixture: Fixture,
203
+ url: str,
204
+ connection_id: str,
205
+ clock: Any | None = None,
206
+ ) -> None:
207
+ self._fixture = fixture
208
+ self._url = url
209
+ self.connection_id = connection_id
210
+ self._clock = clock
211
+ self.close_code: int | None = None
212
+
213
+ async def send(self, message: Any, *args: Any, **kwargs: Any) -> None:
214
+ """Accept a send during replay without forwarding it anywhere.
215
+
216
+ There is no live peer in replay mode — this mirrors how
217
+ ``ReplayTransport`` ignores the outbound request body and only ever
218
+ replays canned responses.
219
+ """
220
+ return None
221
+
222
+ async def recv(self, *args: Any, **kwargs: Any) -> Any:
223
+ """Return the next recorded inbound frame, or raise/warn if exhausted."""
224
+ frame = self._fixture.next_ws_frame(self.connection_id, DIRECTION_RECV)
225
+ if frame is None:
226
+ return self._on_exhausted()
227
+ return self._deliver(frame)
228
+
229
+ def __aiter__(self) -> AsyncIterator[Any]:
230
+ return self._iter_messages()
231
+
232
+ async def _iter_messages(self) -> AsyncIterator[Any]:
233
+ while True:
234
+ frame = self._fixture.next_ws_frame(self.connection_id, DIRECTION_RECV)
235
+ if frame is None:
236
+ # Mirrors a real connection closing: the async-for loop ends
237
+ # normally, exactly like `_listen_for_messages`'s
238
+ # `async for message in self._websocket:` does on
239
+ # ConnectionClosedOK.
240
+ return
241
+ yield self._deliver(frame)
242
+
243
+ async def close(self, code: int = 1000, reason: str = "") -> None:
244
+ self.close_code = code
245
+
246
+ async def __aenter__(self) -> ReplayWebSocketConnection:
247
+ return self
248
+
249
+ async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
250
+ await self.close()
251
+
252
+ def _deliver(self, frame: dict[str, Any]) -> Any:
253
+ if self._clock is not None:
254
+ self._clock.advance(float(frame["recorded_at"]))
255
+ return _encode_from_frame(frame)
256
+
257
+ def _on_exhausted(self) -> Any:
258
+ if guard_active():
259
+ raise NetworkGuardError(
260
+ f"No recorded inbound WS frame for connection "
261
+ f"{self.connection_id!r} ({self._url}) and "
262
+ "AGENT_TRACE_NETWORK_GUARD=1 is set. Run in recording mode "
263
+ "first to capture this session."
264
+ )
265
+ warnings.warn(
266
+ f"agent-trace: no more recorded WS frames for connection "
267
+ f"{self.connection_id!r} ({self._url}); there is no live "
268
+ "connection to fall back to during replay. Set "
269
+ "AGENT_TRACE_NETWORK_GUARD=1 to make this an error.",
270
+ stacklevel=2,
271
+ )
272
+ raise WebSocketReplayExhaustedError(
273
+ f"agent-trace: WS fixture exhausted for connection "
274
+ f"{self.connection_id!r} ({self._url})"
275
+ )
276
+
277
+
278
+ class RecordingConnect:
279
+ """Drop-in stand-in for ``websockets.connect(...)`` that records frames.
280
+
281
+ Constructed with the *real* ``connect`` callable/class (so the patch
282
+ installer keeps full control of what "real" means, exactly like
283
+ ``RecordingTransport`` accepts an ``inner`` transport) plus the fixture
284
+ to record into. Supports both calling conventions ``websockets.connect``
285
+ provides:
286
+
287
+ ws = await websockets.connect(uri, **kwargs)
288
+ async with websockets.connect(uri, **kwargs) as ws:
289
+
290
+ and returns a :class:`RecordingWebSocketConnection` either way.
291
+ """
292
+
293
+ def __init__(
294
+ self,
295
+ real_connect: Any,
296
+ fixture: Fixture,
297
+ uri: str,
298
+ *args: Any,
299
+ connection_id: str | None = None,
300
+ **kwargs: Any,
301
+ ) -> None:
302
+ self._real = real_connect(uri, *args, **kwargs)
303
+ self._fixture = fixture
304
+ self._uri = uri
305
+ self._connection_id = connection_id
306
+ self._wrapped: RecordingWebSocketConnection | None = None
307
+
308
+ def __await__(self) -> Any:
309
+ return self._connect().__await__()
310
+
311
+ async def _connect(self) -> RecordingWebSocketConnection:
312
+ inner = await self._real
313
+ self._wrapped = RecordingWebSocketConnection(
314
+ inner, self._fixture, self._uri, connection_id=self._connection_id
315
+ )
316
+ return self._wrapped
317
+
318
+ async def __aenter__(self) -> RecordingWebSocketConnection:
319
+ return await self._connect()
320
+
321
+ async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
322
+ if self._wrapped is not None:
323
+ await self._wrapped.close()