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,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
|