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,345 @@
1
+ """
2
+ Per-superstep state-merge diagnostics for parallel Command(graph=PARENT)
3
+ routing.
4
+
5
+ Wraps any ``BaseCheckpointSaver`` so that when N parallel tasks in the same
6
+ Pregel superstep each propose a write to the same channel — the exact shape
7
+ behind issue #7129, where 2 of 3 parallel tool calls each returning
8
+ ``Command(graph=Command.PARENT, update={...})`` had their update silently
9
+ discarded by LangGraph's own parent-graph merge — the drop shows up as an
10
+ explicit, countable fact on a span instead of leaving the developer to
11
+ already suspect LangGraph's internal Pregel/``Command.PARENT`` semantics
12
+ before they can even look for it.
13
+
14
+ Why the checkpointer, not a BaseCallbackHandler hook: the actual merge
15
+ happens inside Pregel's own scheduler, a layer no ``on_chain_*``/``on_llm_*``/
16
+ ``on_tool_*`` callback (what ``LangGraphTracer`` implements) can ever
17
+ observe — confirmed via reading ``langgraph/graph/state.py``
18
+ (``_control_branch``) and ``langgraph/errors.py`` (``ParentCommand``), which
19
+ show the parent-graph update is delivered via an internal control-flow
20
+ exception raised per task, not via any traced Runnable. The checkpointer is
21
+ the one place *every* superstep's proposed writes (``put_writes``, one call
22
+ per task) and finalized state (``put``, once per superstep) both pass
23
+ through, regardless of which internal mechanism produced them.
24
+
25
+ Usage::
26
+
27
+ from agent_trace import tracer
28
+ from agent_trace.integrations.langgraph_state_diff import wrap_checkpointer
29
+ from langgraph.checkpoint.memory import InMemorySaver
30
+
31
+ checkpointer = wrap_checkpointer(InMemorySaver(), tracer=tracer)
32
+ graph = builder.compile(checkpointer=checkpointer)
33
+
34
+ with tracer.start_trace("my-graph") as trace:
35
+ graph.invoke(input, config={"configurable": {"thread_id": "t1"}})
36
+ # trace now carries a "checkpoint:superstep_merge" span (if any superstep
37
+ # had >1 task propose a write to the same channel) with a
38
+ # "superstep_state_merge" event per affected channel.
39
+
40
+ Heuristic, not a full re-implementation of Pregel's channel-merge algorithm:
41
+ after a superstep's checkpoint is persisted, a proposed value counts as
42
+ "survived" if it equals the persisted channel value outright, or if the
43
+ persisted value is a list/tuple containing it (covers reducer channels like
44
+ ``add_messages`` that append rather than overwrite). Anything else is
45
+ reported as dropped. False negatives are possible for exotic custom
46
+ reducers — this module's job is to turn "developer has to already suspect
47
+ this" into "here's a countable fact to check", not to replace reading the
48
+ persisted state yourself for a novel reducer.
49
+ """
50
+
51
+ from __future__ import annotations
52
+
53
+ import logging
54
+ import threading
55
+ from typing import TYPE_CHECKING, Any
56
+
57
+ from agent_trace.core.span import SpanStatus
58
+
59
+ if TYPE_CHECKING:
60
+ from langgraph.checkpoint.base import BaseCheckpointSaver
61
+
62
+ from agent_trace import Tracer
63
+
64
+ __all__ = ["wrap_checkpointer"]
65
+
66
+ logger = logging.getLogger(__name__)
67
+
68
+ _INSTALL_HINT = (
69
+ "wrap_checkpointer() requires langgraph.\nInstall it with:\n\n"
70
+ " pip install langgraph\n"
71
+ )
72
+
73
+ _SUPERSTEP_MERGE_SPAN_NAME = "checkpoint:superstep_merge"
74
+
75
+
76
+ def _superstep_key(config: Any) -> tuple[Any, Any, Any] | None:
77
+ """(thread_id, checkpoint_ns, checkpoint_id) — the same key
78
+ ``BaseCheckpointSaver.put_writes``/``put`` implementations use to scope
79
+ a superstep's proposed writes (confirmed against the real
80
+ ``InMemorySaver.put_writes``/``put`` implementations). Returns None if
81
+ ``config`` doesn't carry a ``thread_id`` (e.g. a checkpointer used
82
+ outside a real Pregel run)."""
83
+ conf = (config or {}).get("configurable") or {}
84
+ thread_id = conf.get("thread_id")
85
+ if thread_id is None:
86
+ return None
87
+ checkpoint_ns = conf.get("checkpoint_ns", "")
88
+ checkpoint_id = conf.get("checkpoint_id")
89
+ return (thread_id, checkpoint_ns, checkpoint_id)
90
+
91
+
92
+ def _safe_eq(a: Any, b: Any) -> bool:
93
+ try:
94
+ return bool(a == b)
95
+ except Exception:
96
+ return False
97
+
98
+
99
+ def _value_survived(proposed: Any, final: Any) -> bool:
100
+ """True if *proposed* is reflected in the persisted *final* channel
101
+ value — either directly (last-value-wins channel) or as a member of a
102
+ list/tuple (an append/reducer-style channel)."""
103
+ if _safe_eq(proposed, final):
104
+ return True
105
+ if isinstance(final, (list, tuple)):
106
+ return any(_safe_eq(proposed, item) for item in final)
107
+ return False
108
+
109
+
110
+ class _TracingCheckpointSaverMixin:
111
+ """Shared diagnostic logic for the sync and async wrapped methods.
112
+
113
+ Not usable on its own — combined with a real ``BaseCheckpointSaver``
114
+ subclass by :func:`wrap_checkpointer`, which builds the concrete class
115
+ lazily once ``BaseCheckpointSaver`` is importable (mirrors the
116
+ ``_get_tracer_class`` pattern in ``integrations/langgraph.py``).
117
+ """
118
+
119
+ def _diagnostic_init(self, inner: Any, tracer: Tracer) -> None:
120
+ self._inner = inner
121
+ self._tracer = tracer
122
+ self._pending: dict[tuple[Any, Any, Any], list[tuple[str, str, Any]]] = {}
123
+ self._pending_lock = threading.Lock()
124
+ # Forward every method this wrapper doesn't itself override straight
125
+ # to the wrapped saver, bound to its own instance — avoids having to
126
+ # hand-reimplement BaseCheckpointSaver's full surface area (get,
127
+ # list, delete_thread, copy_thread, prune, ... and their async
128
+ # counterparts) just to pass isinstance(checkpointer,
129
+ # BaseCheckpointSaver) checks.
130
+ for attr_name in (
131
+ "get",
132
+ "aget",
133
+ "get_tuple",
134
+ "aget_tuple",
135
+ "list",
136
+ "alist",
137
+ "delete_thread",
138
+ "adelete_thread",
139
+ "delete_for_runs",
140
+ "adelete_for_runs",
141
+ "copy_thread",
142
+ "acopy_thread",
143
+ "prune",
144
+ "aprune",
145
+ "get_next_version",
146
+ "config_specs",
147
+ ):
148
+ value = getattr(inner, attr_name, None)
149
+ if value is None:
150
+ continue
151
+ try:
152
+ object.__setattr__(self, attr_name, value)
153
+ except AttributeError:
154
+ # A handful of BaseCheckpointSaver members (e.g.
155
+ # config_specs) are read-only @property descriptors on the
156
+ # class — they can't be shadowed by an instance attribute.
157
+ # Falling back to the class's own (default) behavior for
158
+ # those is fine; only put/put_writes/aput/aput_writes need
159
+ # this wrapper's own logic.
160
+ logger.debug(
161
+ "agent-trace: could not forward checkpointer.%s "
162
+ "(read-only property) — using the wrapper's own "
163
+ "default implementation instead",
164
+ attr_name,
165
+ )
166
+
167
+ def _record_proposed_writes(
168
+ self, config: Any, writes: Any, task_id: str
169
+ ) -> None:
170
+ key = _superstep_key(config)
171
+ if key is None:
172
+ return
173
+ with self._pending_lock:
174
+ bucket = self._pending.setdefault(key, [])
175
+ for channel, value in writes:
176
+ bucket.append((task_id, channel, value))
177
+
178
+ def _diagnose_superstep(self, config: Any, checkpoint: Any) -> None:
179
+ """Called after the wrapped saver's put() persists a superstep's
180
+ checkpoint. Diffs every channel that had >1 distinct task propose a
181
+ write against the persisted value, emitting a span event for any
182
+ channel where at least one proposal didn't survive."""
183
+ conf = (config or {}).get("configurable") or {}
184
+ thread_id = conf.get("thread_id")
185
+ if thread_id is None:
186
+ return
187
+ # The incoming `config`'s checkpoint_id is the *parent* checkpoint —
188
+ # the same id put_writes() calls for this superstep were scoped
189
+ # under (confirmed against InMemorySaver.put/put_writes: both key
190
+ # off config["configurable"]["checkpoint_id"], and put()'s config
191
+ # argument is the pre-finalization config carrying the parent id).
192
+ key = (thread_id, conf.get("checkpoint_ns", ""), conf.get("checkpoint_id"))
193
+ with self._pending_lock:
194
+ proposals = self._pending.pop(key, None)
195
+ if not proposals:
196
+ return
197
+
198
+ channel_values = (checkpoint or {}).get("channel_values") or {}
199
+ by_channel: dict[str, list[tuple[str, Any]]] = {}
200
+ for task_id, channel, value in proposals:
201
+ by_channel.setdefault(channel, []).append((task_id, value))
202
+
203
+ affected: list[dict[str, Any]] = []
204
+ for channel, entries in by_channel.items():
205
+ distinct_tasks = {task_id for task_id, _ in entries}
206
+ if len(distinct_tasks) < 2:
207
+ continue # only one task proposed this channel — no merge to diagnose
208
+ final_value = channel_values.get(channel)
209
+ dropped_tasks = [
210
+ task_id
211
+ for task_id, value in entries
212
+ if not _value_survived(value, final_value)
213
+ ]
214
+ if dropped_tasks:
215
+ affected.append(
216
+ {
217
+ "channel": channel,
218
+ "proposed_count": len(entries),
219
+ "survived_count": len(entries) - len(dropped_tasks),
220
+ "dropped_count": len(dropped_tasks),
221
+ "dropped_task_ids": dropped_tasks,
222
+ }
223
+ )
224
+
225
+ if not affected:
226
+ return
227
+
228
+ try:
229
+ span = self._tracer.start_span(_SUPERSTEP_MERGE_SPAN_NAME)
230
+ span.set_attribute("checkpoint.thread_id", str(thread_id))
231
+ for fact in affected:
232
+ span.add_event(
233
+ "superstep_state_merge",
234
+ attributes={
235
+ "channel": str(fact["channel"]),
236
+ "proposed_count": int(fact["proposed_count"]),
237
+ "survived_count": int(fact["survived_count"]),
238
+ "dropped_count": int(fact["dropped_count"]),
239
+ "dropped_task_ids": ",".join(fact["dropped_task_ids"]),
240
+ },
241
+ )
242
+ span.end(SpanStatus.OK)
243
+ except Exception:
244
+ logger.debug(
245
+ "agent-trace: failed to record superstep state-merge "
246
+ "diagnostic",
247
+ exc_info=True,
248
+ )
249
+
250
+ # -- sync -----------------------------------------------------------
251
+
252
+ def put(
253
+ self, config: Any, checkpoint: Any, metadata: Any, new_versions: Any
254
+ ) -> Any:
255
+ result = self._inner.put(config, checkpoint, metadata, new_versions)
256
+ try:
257
+ self._diagnose_superstep(config, checkpoint)
258
+ except Exception:
259
+ logger.debug(
260
+ "agent-trace: superstep diagnostic failed in put()", exc_info=True
261
+ )
262
+ return result
263
+
264
+ def put_writes(
265
+ self, config: Any, writes: Any, task_id: str, task_path: str = ""
266
+ ) -> None:
267
+ self._inner.put_writes(config, writes, task_id, task_path)
268
+ try:
269
+ self._record_proposed_writes(config, writes, task_id)
270
+ except Exception:
271
+ logger.debug(
272
+ "agent-trace: failed to record proposed superstep writes",
273
+ exc_info=True,
274
+ )
275
+
276
+ # -- async ------------------------------------------------------------
277
+
278
+ async def aput(
279
+ self, config: Any, checkpoint: Any, metadata: Any, new_versions: Any
280
+ ) -> Any:
281
+ result = await self._inner.aput(config, checkpoint, metadata, new_versions)
282
+ try:
283
+ self._diagnose_superstep(config, checkpoint)
284
+ except Exception:
285
+ logger.debug(
286
+ "agent-trace: superstep diagnostic failed in aput()", exc_info=True
287
+ )
288
+ return result
289
+
290
+ async def aput_writes(
291
+ self, config: Any, writes: Any, task_id: str, task_path: str = ""
292
+ ) -> None:
293
+ await self._inner.aput_writes(config, writes, task_id, task_path)
294
+ try:
295
+ self._record_proposed_writes(config, writes, task_id)
296
+ except Exception:
297
+ logger.debug(
298
+ "agent-trace: failed to record proposed superstep writes",
299
+ exc_info=True,
300
+ )
301
+
302
+
303
+ _TracingCheckpointSaverClass: type | None = None
304
+ _class_lock = threading.Lock()
305
+
306
+
307
+ def _get_wrapper_class() -> type:
308
+ """Lazily build the concrete wrapper class, with ``BaseCheckpointSaver``
309
+ as a genuine base at definition time (LangGraph's ``ensure_valid_checkpointer``
310
+ requires ``isinstance(checkpointer, BaseCheckpointSaver)``)."""
311
+ global _TracingCheckpointSaverClass # noqa: PLW0603
312
+ if _TracingCheckpointSaverClass is not None:
313
+ return _TracingCheckpointSaverClass
314
+
315
+ with _class_lock:
316
+ if _TracingCheckpointSaverClass is not None:
317
+ return _TracingCheckpointSaverClass
318
+
319
+ try:
320
+ from langgraph.checkpoint.base import BaseCheckpointSaver
321
+ except ImportError as exc:
322
+ raise ImportError(_INSTALL_HINT) from exc
323
+
324
+ class _TracingCheckpointSaver(
325
+ _TracingCheckpointSaverMixin, BaseCheckpointSaver[Any]
326
+ ):
327
+ def __init__(self, inner: Any, tracer: Tracer) -> None:
328
+ super().__init__(serde=getattr(inner, "serde", None))
329
+ self._diagnostic_init(inner, tracer)
330
+
331
+ _TracingCheckpointSaverClass = _TracingCheckpointSaver
332
+ return _TracingCheckpointSaverClass
333
+
334
+
335
+ def wrap_checkpointer(checkpointer: BaseCheckpointSaver[Any], *, tracer: Tracer) -> Any:
336
+ """Wrap *checkpointer* so agent-trace records per-superstep state-merge
337
+ diagnostics onto *tracer*'s active trace.
338
+
339
+ The returned object is itself a ``BaseCheckpointSaver`` (satisfies
340
+ LangGraph's ``ensure_valid_checkpointer`` isinstance check) that forwards
341
+ every call to *checkpointer* unchanged — recording is purely additive
342
+ and never alters what gets persisted or returned.
343
+ """
344
+ wrapper_cls = _get_wrapper_class()
345
+ return wrapper_cls(checkpointer, tracer)
@@ -0,0 +1,202 @@
1
+ """
2
+ Optional deep instrumentation of LangGraph's internal
3
+ ``langgraph.pregel._messages.StreamMessagesHandler`` — the private class
4
+ that actually implements ``stream_mode="messages"``.
5
+
6
+ Background
7
+ ----------
8
+
9
+ ``StreamMessagesHandler`` decides, per chat-model call, whether that call's
10
+ tokens get pushed into a ``stream_mode="messages"`` iterator at all —
11
+ governed entirely by whether ``TAG_NOSTREAM`` ("nostream") is present in the
12
+ *runtime* ``tags`` list ``on_chat_model_start`` receives for that call (see
13
+ ``StreamMessagesHandler.on_chat_model_start``: ``if metadata and (not tags
14
+ or (TAG_NOSTREAM not in tags))``). This is a LangGraph-internal mechanism,
15
+ not reachable via any public configuration surface — a developer who
16
+ declares ``tags=["nostream"]`` on a node's own action (see
17
+ ``agent_trace.integrations.langgraph._get_declared_node_tags``) is trusting
18
+ that the tag actually survives, unmodified, all the way to that runtime
19
+ check. When it doesn't (a tag-propagation bug elsewhere in
20
+ LangChain/LangGraph strips or fails to merge it before the callback manager
21
+ fires), the symptom is silent: content the developer explicitly tried to
22
+ suppress from the stream shows up anyway (issue #7509).
23
+
24
+ This module patches ``StreamMessagesHandler.on_chat_model_start`` to record,
25
+ for every chat-model call it processes, the node name and the exact
26
+ suppress/allow decision it made — the identical signal
27
+ ``StreamMessagesHandler`` itself acts on — so that decision becomes
28
+ inspectable instead of buried inside a private class with no logging and no
29
+ public hook. Cross-referenced against a node's *declared* tags (captured
30
+ separately via ``LangGraphTracer``'s ``langgraph.declared_tags`` span
31
+ attribute, when a ``graph=`` was supplied), :func:`flag_inconsistencies`
32
+ turns "declared nostream but LangGraph still decided to stream it" into an
33
+ automated flag rather than something a developer discovers by reading
34
+ private LangGraph source and comparing it against their own graph
35
+ definition by hand.
36
+
37
+ Usage::
38
+
39
+ from agent_trace.integrations.langgraph_stream_debug import (
40
+ install_stream_debug_patch,
41
+ get_stream_decisions,
42
+ flag_inconsistencies,
43
+ )
44
+
45
+ # Call once, before any graph.stream(..., stream_mode="messages") run:
46
+ install_stream_debug_patch()
47
+ ...
48
+ decisions = get_stream_decisions()
49
+ flagged = flag_inconsistencies(decisions, declared_nostream_nodes={"my_node"})
50
+
51
+ Best-effort by design, matching the monkeypatch pattern already used in
52
+ ``agent_trace.integrations.langgraph`` (``_install_runtime_capture_patch``,
53
+ ``_install_branch_exception_capture_patch``): if this LangGraph version's
54
+ private internals don't match what this patch expects,
55
+ ``install_stream_debug_patch()`` logs at DEBUG and returns False rather than
56
+ raising — activating this instrumentation must never be able to break a
57
+ real graph run.
58
+ """
59
+
60
+ from __future__ import annotations
61
+
62
+ import logging
63
+ import threading
64
+ from dataclasses import dataclass, field
65
+ from typing import Any
66
+
67
+ __all__ = [
68
+ "StreamDecision",
69
+ "flag_inconsistencies",
70
+ "get_stream_decisions",
71
+ "install_stream_debug_patch",
72
+ "reset_stream_decisions",
73
+ ]
74
+
75
+ logger = logging.getLogger(__name__)
76
+
77
+ _lock = threading.Lock()
78
+ _installed = False
79
+ _decisions: list[StreamDecision] = []
80
+ _MAX_DECISIONS = 5_000 # bounded, matching the defensive-cap pattern elsewhere
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class StreamDecision:
85
+ """One ``StreamMessagesHandler.on_chat_model_start`` suppress/allow
86
+ decision, as it actually happened at runtime."""
87
+
88
+ node_name: str | None
89
+ run_id: str
90
+ tags: tuple[str, ...] = field(default_factory=tuple)
91
+ suppressed: bool = False
92
+ """True if TAG_NOSTREAM was present in the runtime tags list — i.e.
93
+ StreamMessagesHandler decided NOT to register this call for streaming,
94
+ so none of its tokens will reach a stream_mode="messages" consumer."""
95
+
96
+
97
+ def get_stream_decisions() -> list[StreamDecision]:
98
+ """Return every recorded streaming suppress/allow decision so far."""
99
+ with _lock:
100
+ return list(_decisions)
101
+
102
+
103
+ def reset_stream_decisions() -> None:
104
+ """Clear recorded decisions — call between separate runs/tests sharing
105
+ one process so results from an earlier run don't bleed into the next."""
106
+ with _lock:
107
+ _decisions.clear()
108
+
109
+
110
+ def flag_inconsistencies(
111
+ decisions: list[StreamDecision],
112
+ declared_nostream_nodes: set[str],
113
+ ) -> list[StreamDecision]:
114
+ """Given recorded per-call streaming decisions and the set of node names
115
+ known (from a separate source — e.g. ``LangGraphTracer``'s
116
+ ``langgraph.declared_tags`` span attribute) to have declared a
117
+ ``"nostream"`` tag at graph-construction time, return every decision
118
+ where that declared intent was NOT honored — the node declared
119
+ ``nostream`` but ``StreamMessagesHandler`` still decided to stream that
120
+ call's tokens (``suppressed=False``). This is the exact tag/stream
121
+ inconsistency behind issue #7509."""
122
+ return [
123
+ d
124
+ for d in decisions
125
+ if d.node_name in declared_nostream_nodes and not d.suppressed
126
+ ]
127
+
128
+
129
+ def install_stream_debug_patch() -> bool:
130
+ """Monkeypatch ``StreamMessagesHandler.on_chat_model_start`` to record
131
+ every suppress/allow decision it makes. Idempotent — safe to call more
132
+ than once; only the first call actually installs anything.
133
+
134
+ Returns True if the patch installed (this LangGraph version's private
135
+ internals matched what was expected), False if it didn't and this
136
+ instrumentation is a no-op on the installed version.
137
+ """
138
+ global _installed # noqa: PLW0603
139
+ if _installed:
140
+ return True
141
+ with _lock:
142
+ if _installed:
143
+ return True
144
+ try:
145
+ from langgraph.constants import TAG_NOSTREAM
146
+ from langgraph.pregel._messages import StreamMessagesHandler
147
+ except Exception:
148
+ logger.debug(
149
+ "agent-trace: StreamMessagesHandler deep-instrumentation "
150
+ "unavailable on this LangGraph version (private module "
151
+ "shape not as expected); stream/tag inconsistency "
152
+ "detection will not be available.",
153
+ exc_info=True,
154
+ )
155
+ _installed = True # don't retry every call
156
+ return False
157
+
158
+ original_on_chat_model_start = StreamMessagesHandler.on_chat_model_start
159
+
160
+ def _patched_on_chat_model_start(
161
+ self: Any,
162
+ serialized: dict[str, Any],
163
+ messages: Any,
164
+ *,
165
+ run_id: Any,
166
+ parent_run_id: Any = None,
167
+ tags: list[str] | None = None,
168
+ metadata: dict[str, Any] | None = None,
169
+ **kwargs: Any,
170
+ ) -> Any:
171
+ try:
172
+ node_name = (metadata or {}).get("langgraph_node")
173
+ decision = StreamDecision(
174
+ node_name=str(node_name) if node_name is not None else None,
175
+ run_id=str(run_id),
176
+ tags=tuple(tags or ()),
177
+ suppressed=bool(tags and TAG_NOSTREAM in tags),
178
+ )
179
+ with _lock:
180
+ if len(_decisions) < _MAX_DECISIONS:
181
+ _decisions.append(decision)
182
+ except Exception:
183
+ logger.debug(
184
+ "agent-trace: failed to record stream suppress/allow "
185
+ "decision for run %r",
186
+ run_id,
187
+ exc_info=True,
188
+ )
189
+ return original_on_chat_model_start(
190
+ self,
191
+ serialized,
192
+ messages,
193
+ run_id=run_id,
194
+ parent_run_id=parent_run_id,
195
+ tags=tags,
196
+ metadata=metadata,
197
+ **kwargs,
198
+ )
199
+
200
+ StreamMessagesHandler.on_chat_model_start = _patched_on_chat_model_start # type: ignore[method-assign]
201
+ _installed = True
202
+ return True