by-framework-langgraph 0.0.3.dev0__tar.gz → 0.0.3.dev2__tar.gz

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.
@@ -68,6 +68,6 @@ Thumbs.db
68
68
  .claude/settings.local.json
69
69
  .claude/settings.json
70
70
 
71
- core
72
-
73
71
  .coderfleet-uploads/
72
+
73
+ .claude/skills
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: by-framework-langgraph
3
- Version: 0.0.3.dev0
3
+ Version: 0.0.3.dev2
4
4
  Summary: LangGraph integration for by-framework
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: by-framework>=0.2.1
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "by-framework-langgraph"
3
- version = "0.0.3.dev0"
3
+ version = "0.0.3.dev2"
4
4
  description = "LangGraph integration for by-framework"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -16,7 +16,8 @@ from by_framework.common.logger import logger
16
16
  from by_framework.core.protocol.agent_state import AgentState
17
17
  from by_framework.core.protocol.commands import ResumeCommand
18
18
  from by_framework.core.protocol.events import StreamChunkEvent
19
- from by_framework.observability.span_recorder import (str_to_uint64, str_to_uint128)
19
+ from by_framework.trace.span_recorder import str_to_uint64, str_to_uint128
20
+ from langchain_core.callbacks import BaseCallbackHandler
20
21
  from langchain_core.messages import HumanMessage
21
22
  from langgraph.types import Command
22
23
 
@@ -31,6 +32,172 @@ if TYPE_CHECKING:
31
32
  LANGFUSE_OBSERVATION_ATTR = "_langfuse_observation"
32
33
 
33
34
 
35
+ class _TokenAccumulatingCallbackHandler(BaseCallbackHandler):
36
+ """LangChain callback handler that accumulates LLM token usage into AgentContext.
37
+
38
+ Extracts token usage from whichever location the provider populates:
39
+ - ``llm_output["token_usage"]`` (OpenAI-style via LangChain)
40
+ - ``llm_output["usage"]`` (Anthropic-style / raw provider mapping)
41
+ - ``generation.message.usage_metadata`` (LangChain >= 0.2 standard)
42
+ - ``generation.generation_info`` (some community integrations)
43
+
44
+ ``run_id`` deduplication prevents double-counting when both ``on_llm_end``
45
+ and ``on_chat_model_end`` fire for the same call (LangChain >= 0.2).
46
+ """
47
+
48
+ def __init__(self, context: Any) -> None:
49
+ super().__init__()
50
+ self._context = context
51
+ self._seen_run_ids: set = set()
52
+
53
+ # ------------------------------------------------------------------
54
+ # LangChain callback entry point
55
+ # ------------------------------------------------------------------
56
+
57
+ def on_llm_end(self, response: Any, *, run_id: Any = None, **_kwargs: Any) -> None:
58
+ # Guard: only mark run as seen when we actually extracted tokens so that
59
+ # the on_chat_model_end event path in _stream_invoke can still fire as a
60
+ # fallback when the callback found nothing (e.g. stream_options not set).
61
+ if run_id is not None and run_id in self._seen_run_ids:
62
+ return
63
+ self._handle_llm_result(response, run_id=run_id)
64
+
65
+ # ------------------------------------------------------------------
66
+ # Internal extraction logic
67
+ # ------------------------------------------------------------------
68
+
69
+ def _handle_llm_result(self, response: Any, *, run_id: Any = None) -> None:
70
+ context = self._context
71
+ if context is None:
72
+ return
73
+
74
+ prompt, completion = self._extract_tokens(response)
75
+
76
+ if not (prompt or completion):
77
+ # Log at WARNING (always visible) to help diagnose providers whose
78
+ # token format is not yet handled, or where stream_options is missing.
79
+ gens = getattr(response, "generations", []) or []
80
+ first = (gens[0] or [None])[0] if gens else None
81
+ msg_type = type(getattr(first, "message", None)).__name__
82
+ logger.warning(
83
+ "[TokenAccumulator] on_llm_end fired but extracted 0 tokens. "
84
+ "For OpenAI-compatible streaming APIs add "
85
+ "stream_options={'include_usage': True} to your ChatModel. "
86
+ "llm_output=%r first_gen_message_type=%s",
87
+ getattr(response, "llm_output", None),
88
+ msg_type,
89
+ )
90
+ # Do NOT mark run_id as seen — let the on_chat_model_end event path
91
+ # in _stream_invoke attempt extraction from the merged message.
92
+ return
93
+
94
+ if run_id is not None:
95
+ self._seen_run_ids.add(run_id)
96
+ try:
97
+ context.record_token_usage(
98
+ prompt_tokens=prompt,
99
+ completion_tokens=completion,
100
+ )
101
+ except Exception: # pylint: disable=broad-exception-caught
102
+ pass
103
+
104
+ @staticmethod
105
+ def _extract_tokens(response: Any) -> tuple[int, int]:
106
+ """Return (prompt_tokens, completion_tokens) from an LLMResult.
107
+
108
+ Checks every known location across providers:
109
+ 1. llm_output["token_usage"] — OpenAI via LangChain
110
+ 2. llm_output["usage"] — Anthropic / raw provider mapping
111
+ 3. message.usage_metadata — LangChain >= 0.2 standard
112
+ 4. message.response_metadata — some community integrations
113
+ 5. generation.generation_info — older / custom integrations
114
+ """
115
+ prompt, completion = 0, 0
116
+
117
+ llm_output = getattr(response, "llm_output", None) or {}
118
+ if isinstance(llm_output, dict):
119
+ for key in ("token_usage", "usage"):
120
+ usage = llm_output.get(key) or {}
121
+ if usage:
122
+ prompt = int(
123
+ usage.get("prompt_tokens") or usage.get("input_tokens") or 0
124
+ )
125
+ completion = int(
126
+ usage.get("completion_tokens")
127
+ or usage.get("output_tokens")
128
+ or 0
129
+ )
130
+ break
131
+
132
+ if prompt or completion:
133
+ return prompt, completion
134
+
135
+ # Iterate all generations
136
+ for gen_list in getattr(response, "generations", []) or []:
137
+ for gen in (gen_list if isinstance(gen_list, list) else [gen_list]):
138
+ msg = getattr(gen, "message", None)
139
+
140
+ # LangChain >= 0.2: message.usage_metadata
141
+ meta = getattr(msg, "usage_metadata", None)
142
+ if meta:
143
+ prompt += int(
144
+ meta.get("input_tokens") or meta.get("prompt_tokens") or 0
145
+ )
146
+ completion += int(
147
+ meta.get("output_tokens") or meta.get("completion_tokens") or 0
148
+ )
149
+ continue
150
+
151
+ # response_metadata (e.g. MiniMax, Qwen, some Chinese providers)
152
+ resp_meta = getattr(msg, "response_metadata", None) or {}
153
+ if isinstance(resp_meta, dict):
154
+ for key in ("token_usage", "usage"):
155
+ usage = resp_meta.get(key) or {}
156
+ if usage:
157
+ prompt += int(
158
+ usage.get("prompt_tokens")
159
+ or usage.get("input_tokens")
160
+ or 0
161
+ )
162
+ completion += int(
163
+ usage.get("completion_tokens")
164
+ or usage.get("output_tokens")
165
+ or 0
166
+ )
167
+ break
168
+ # Flat keys at root of response_metadata
169
+ if not (prompt or completion):
170
+ prompt += int(
171
+ resp_meta.get("prompt_tokens")
172
+ or resp_meta.get("input_tokens")
173
+ or 0
174
+ )
175
+ completion += int(
176
+ resp_meta.get("completion_tokens")
177
+ or resp_meta.get("output_tokens")
178
+ or 0
179
+ )
180
+ if prompt or completion:
181
+ continue
182
+
183
+ # generation_info fallback
184
+ info = getattr(gen, "generation_info", None) or {}
185
+ for key in ("token_usage", "usage"):
186
+ sub = info.get(key) or {}
187
+ if sub:
188
+ prompt += int(
189
+ sub.get("prompt_tokens") or sub.get("input_tokens") or 0
190
+ )
191
+ completion += int(
192
+ sub.get("completion_tokens")
193
+ or sub.get("output_tokens")
194
+ or 0
195
+ )
196
+ break
197
+
198
+ return prompt, completion
199
+
200
+
34
201
  @dataclass(frozen=True)
35
202
  class _AdapterTracingConfig:
36
203
  """Tracing-related adapter config kept separate from core graph handles."""
@@ -167,6 +334,47 @@ class LangGraphAdapter:
167
334
  await self._context.emit_chunk(
168
335
  chunk.content, content_type="text"
169
336
  )
337
+ elif kind == "on_chat_model_end":
338
+ # Fallback: capture token usage from the event's merged output
339
+ # message when the on_llm_end callback found nothing (e.g. the
340
+ # provider requires stream_options but it was not set).
341
+ # _TokenAccumulatingCallbackHandler marks run_id as seen only
342
+ # after a successful extraction, so this path fires only when
343
+ # the callback got 0 tokens.
344
+ run_id = event.get("run_id")
345
+ token_handler = next(
346
+ (
347
+ cb
348
+ for cb in scoped_callbacks
349
+ if isinstance(cb, _TokenAccumulatingCallbackHandler)
350
+ ),
351
+ None,
352
+ )
353
+ if token_handler is not None and run_id not in (
354
+ token_handler._seen_run_ids # pylint: disable=protected-access
355
+ ):
356
+ output = event["data"].get("output")
357
+ meta = getattr(output, "usage_metadata", None)
358
+ if meta:
359
+ prompt = int(
360
+ meta.get("input_tokens")
361
+ or meta.get("prompt_tokens")
362
+ or 0
363
+ )
364
+ completion = int(
365
+ meta.get("output_tokens")
366
+ or meta.get("completion_tokens")
367
+ or 0
368
+ )
369
+ if prompt or completion:
370
+ token_handler._seen_run_ids.add(run_id) # pylint: disable=protected-access
371
+ try:
372
+ self._context.record_token_usage(
373
+ prompt_tokens=prompt,
374
+ completion_tokens=completion,
375
+ )
376
+ except Exception: # pylint: disable=broad-exception-caught
377
+ pass
170
378
  elif kind == "on_tool_start":
171
379
  tool_name = event["name"]
172
380
  tool_input = event["data"].get("input")
@@ -184,7 +392,7 @@ class LangGraphAdapter:
184
392
  ),
185
393
  )
186
394
 
187
- # Use a stable logical ID from metadata if available, fallback to run_id
395
+ # Use stable metadata when available, otherwise fall back.
188
396
  stable_id = (
189
397
  event.get("metadata", {}).get("tool_call_id")
190
398
  or event.get("metadata", {}).get("checkpoint_ns")
@@ -379,69 +587,50 @@ class LangGraphAdapter:
379
587
  @contextmanager
380
588
  def _langfuse_callback_manager(self, callbacks: list[Any]) -> Iterator[None]:
381
589
  """Prepare Langfuse callback and observation for LangChain."""
382
- # Prefer AgentContext's callback factory so trace and parent ids align.
383
- # Filter out auto-generated MagicMock attributes when tests use a mock
384
- # context — real callback objects always come from a non-test module.
385
- langfuse_callback_value = getattr(self._context, "langfuse_callback", None)
386
- is_real_callback = langfuse_callback_value is not None and type(
387
- langfuse_callback_value
388
- ).__module__ not in ("unittest.mock",)
389
-
390
- if is_real_callback:
391
- handler = (
392
- langfuse_callback_value()
393
- if callable(langfuse_callback_value)
394
- else langfuse_callback_value
395
- )
396
- if handler is not None:
397
- callbacks.append(handler)
398
- yield
399
- return
590
+ # Always inject token accumulator works regardless of Langfuse config.
591
+ callbacks.append(_TokenAccumulatingCallbackHandler(self._context))
400
592
 
401
- # Fallback to local import if context method is missing
402
- # pylint: disable=import-outside-toplevel
403
593
  try:
404
- langfuse_config = import_module(
405
- "by_framework_trace_langfuse"
406
- ).LangfuseConfig
407
- if langfuse_config.from_env() is None:
408
- raise ImportError("Langfuse not configured")
409
-
410
- callback_handler = import_module("langfuse.langchain").CallbackHandler
411
- get_client = import_module("langfuse").get_client
594
+ build_langchain_callback = getattr(
595
+ import_module("by_framework_trace_langfuse"),
596
+ "build_langchain_callback",
597
+ )
412
598
  except (ImportError, AttributeError):
413
599
  yield
414
600
  return
415
601
 
416
- callbacks.append(callback_handler())
417
-
418
- framework_observation = getattr(self._context, LANGFUSE_OBSERVATION_ATTR, None)
419
- if framework_observation is None:
420
- yield
421
- return
602
+ get_parent_observation_id = getattr(
603
+ self._context,
604
+ "get_trace_parent_observation_id",
605
+ None,
606
+ )
607
+ parent_observation_id = (
608
+ str(get_parent_observation_id() or "")
609
+ if callable(get_parent_observation_id)
610
+ else ""
611
+ )
612
+ if not parent_observation_id:
613
+ framework_observation = getattr(
614
+ self._context, LANGFUSE_OBSERVATION_ATTR, None
615
+ )
616
+ parent_observation_id = getattr(framework_observation, "id", "") or ""
617
+ if not parent_observation_id:
618
+ execution_id = getattr(self._context, "execution_id", "")
619
+ message_id = getattr(self._context, "message_id", "")
620
+ raw_parent_id = (
621
+ f"{execution_id}:worker.execute"
622
+ if execution_id
623
+ else f"{message_id}:worker.execute"
624
+ )
625
+ parent_observation_id = f"{str_to_uint64(raw_parent_id):016x}"
422
626
 
423
- langfuse = get_client()
424
- with langfuse.start_as_current_observation(
425
- as_type="span",
426
- name=self._tracing.run_name,
427
- trace_context={
428
- "trace_id": getattr(self._context, "trace_id", ""),
429
- "parent_span_id": framework_observation.id,
430
- },
431
- metadata=self._default_metadata(),
432
- ):
433
- # Prevent the generated OTel span from being promoted to a trace root.
434
- # The native LangfusePlugin sets the same attribute on its own path
435
- # (via _SdkLangfuseTracer); this covers the LangGraph fallback path.
436
- try:
437
- from opentelemetry import trace
438
-
439
- current_span = trace.get_current_span()
440
- if current_span and hasattr(current_span, "set_attribute"):
441
- current_span.set_attribute("langfuse.internal.as_root", False)
442
- except Exception: # pylint: disable=broad-exception-caught
443
- pass
444
- yield
627
+ handler = build_langchain_callback(
628
+ trace_id=getattr(self._context, "trace_id", ""),
629
+ parent_observation_id=parent_observation_id,
630
+ )
631
+ if handler is not None:
632
+ callbacks.append(handler)
633
+ yield
445
634
 
446
635
  @staticmethod
447
636
  def _default_input_mapper(content: str) -> dict:
@@ -7,7 +7,7 @@ mechanism.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
- from typing import TYPE_CHECKING, Annotated
10
+ from typing import TYPE_CHECKING, Annotated, Any
11
11
 
12
12
  from langchain_core.tools import BaseTool, InjectedToolCallId, tool
13
13
  from langgraph.types import interrupt
@@ -16,6 +16,31 @@ if TYPE_CHECKING:
16
16
  from by_framework.worker.context import AgentContext
17
17
 
18
18
 
19
+ def _langfuse_observation_id_from_callbacks(callbacks: Any) -> str:
20
+ """Return the Langfuse observation id for the active LangChain tool run."""
21
+ run_id = getattr(callbacks, "run_id", None) or getattr(
22
+ callbacks,
23
+ "parent_run_id",
24
+ None,
25
+ )
26
+ if not run_id:
27
+ return ""
28
+
29
+ handlers = [
30
+ *list(getattr(callbacks, "handlers", []) or []),
31
+ *list(getattr(callbacks, "inheritable_handlers", []) or []),
32
+ ]
33
+ for handler in handlers:
34
+ runs = getattr(handler, "_runs", None)
35
+ if not isinstance(runs, dict):
36
+ continue
37
+ observation = runs.get(run_id)
38
+ observation_id = getattr(observation, "id", None)
39
+ if observation_id:
40
+ return str(observation_id)
41
+ return ""
42
+
43
+
19
44
  def make_remote_agent_tool(
20
45
  context: AgentContext,
21
46
  tool_name: str,
@@ -50,6 +75,7 @@ def make_remote_agent_tool(
50
75
  async def remote_agent_tool(
51
76
  topic: str,
52
77
  tool_call_id: Annotated[str, InjectedToolCallId],
78
+ callbacks: Any = None,
53
79
  ) -> str:
54
80
  # Idempotency guard: checkpoint restore replays tool execution,
55
81
  # but we must not re-dispatch the command.
@@ -57,9 +83,19 @@ def make_remote_agent_tool(
57
83
  is_dispatched = await context.redis.exists(redis_key)
58
84
 
59
85
  if not is_dispatched:
86
+ metadata = {}
87
+ langfuse_parent_observation_id = _langfuse_observation_id_from_callbacks(
88
+ callbacks
89
+ )
90
+ if langfuse_parent_observation_id:
91
+ metadata["langfuse_parent_observation_id"] = (
92
+ langfuse_parent_observation_id
93
+ )
94
+
60
95
  await context.call_agent(
61
96
  target_agent_type=target_agent_type,
62
97
  content=topic,
98
+ metadata=metadata,
63
99
  )
64
100
  await context.redis.set(redis_key, "1", ex=idempotency_ttl)
65
101
 
@@ -1,15 +1,17 @@
1
1
  """Tests for adapter and worker modules."""
2
2
 
3
3
  import sys
4
- from contextlib import contextmanager
5
4
  from types import SimpleNamespace
6
- from unittest.mock import AsyncMock, MagicMock
5
+ from unittest.mock import AsyncMock, MagicMock, patch
7
6
 
8
7
  import pytest
9
8
  from by_framework.core.protocol.commands import AskAgentCommand, ResumeCommand
10
9
  from by_framework.core.protocol.message_header import MessageHeader
11
10
 
12
- from by_framework_langgraph.adapter import LangGraphAdapter
11
+ from by_framework_langgraph.adapter import (
12
+ LangGraphAdapter,
13
+ _TokenAccumulatingCallbackHandler,
14
+ )
13
15
  from by_framework_langgraph.worker import LangGraphWorker
14
16
 
15
17
 
@@ -37,7 +39,7 @@ def _make_mock_context(session_id: str = "test-session"):
37
39
  metadata={"source": "test"},
38
40
  )
39
41
  )
40
- ctx._langfuse_observation = SimpleNamespace(id="obs-framework") # pylint: disable=protected-access
42
+ ctx.get_trace_parent_observation_id.return_value = "obs-framework"
41
43
  return ctx
42
44
 
43
45
 
@@ -184,7 +186,7 @@ class TestAdapterRun:
184
186
  async def test_includes_langfuse_callbacks_and_parent_trace_context(
185
187
  self, monkeypatch
186
188
  ):
187
- """Verify adapter wires Langfuse callback handler into LangGraph config."""
189
+ """Verify adapter gets Langfuse callback from the trace provider package."""
188
190
  ctx = _make_mock_context()
189
191
  graph = MagicMock()
190
192
  graph.ainvoke = AsyncMock(
@@ -194,45 +196,18 @@ class TestAdapterRun:
194
196
  snapshot.next = ()
195
197
  graph.get_state.return_value = snapshot
196
198
 
197
- observation_calls: list[dict] = []
198
- callback_instances: list[object] = []
199
+ callback_handler = object()
200
+ callback_calls: list[dict[str, str]] = []
199
201
 
200
- @contextmanager
201
- def fake_observation_scope(**kwargs):
202
- observation_calls.append(kwargs)
203
- yield SimpleNamespace(id="obs-langgraph")
202
+ def fake_build_langchain_callback(**kwargs):
203
+ callback_calls.append(kwargs)
204
+ return callback_handler
204
205
 
205
- class FakeCallbackHandler: # pylint: disable=too-few-public-methods
206
- """Minimal callback handler stub for adapter config assertions."""
207
-
208
- def __init__(self):
209
- callback_instances.append(self)
210
-
211
- fake_langfuse_client = SimpleNamespace(
212
- start_as_current_observation=fake_observation_scope
213
- )
214
-
215
- monkeypatch.setitem(
216
- sys.modules,
217
- "langfuse",
218
- SimpleNamespace(get_client=MagicMock(return_value=fake_langfuse_client)),
219
- )
220
- monkeypatch.setitem(
221
- sys.modules,
222
- "langfuse.langchain",
223
- SimpleNamespace(CallbackHandler=FakeCallbackHandler),
224
- )
225
206
  monkeypatch.setitem(
226
207
  sys.modules,
227
208
  "by_framework_trace_langfuse",
228
- SimpleNamespace(LangfuseConfig=MagicMock()),
209
+ SimpleNamespace(build_langchain_callback=fake_build_langchain_callback),
229
210
  )
230
- sys.modules[
231
- "by_framework_trace_langfuse"
232
- ].LangfuseConfig.from_env.return_value = object()
233
- monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-test")
234
- monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-test")
235
- monkeypatch.setenv("LANGFUSE_BASE_URL", "http://localhost:3000")
236
211
 
237
212
  adapter = LangGraphAdapter(graph, ctx, stream=False)
238
213
 
@@ -251,21 +226,27 @@ class TestAdapterRun:
251
226
  assert config["metadata"]["langfuse_user_id"] == "user-1"
252
227
  assert config["metadata"]["by_framework_message_id"] == "msg-ctx"
253
228
  assert config["metadata"]["langgraph_thread_id"] == "test-session"
254
- assert config["callbacks"] == callback_instances
255
- assert len(observation_calls) == 1
256
- assert observation_calls[0]["trace_context"] == {
257
- "trace_id": "trace-ctx",
258
- "parent_span_id": "obs-framework",
259
- }
260
- assert observation_calls[0]["name"] == "planner:langgraph"
229
+ # Callbacks list includes the Langfuse handler(s) plus the token accumulator.
230
+ assert callback_handler in config["callbacks"]
231
+ assert any(
232
+ type(cb).__name__ == "_TokenAccumulatingCallbackHandler"
233
+ for cb in config["callbacks"]
234
+ )
235
+ assert callback_calls == [
236
+ {
237
+ "trace_id": "trace-ctx",
238
+ "parent_observation_id": "obs-framework",
239
+ }
240
+ ]
261
241
 
262
242
  @pytest.mark.asyncio
263
- async def test_uses_context_langfuse_callback_property(self):
264
- """Verify AgentContext.langfuse_callback property value is used directly as a handler."""
265
- handler = object()
243
+ async def test_skips_langfuse_callback_when_provider_package_missing(
244
+ self, monkeypatch
245
+ ):
246
+ """LangGraph stays provider-agnostic when trace-langfuse is not installed."""
266
247
 
267
248
  # pylint: disable=too-few-public-methods,missing-class-docstring,missing-function-docstring
268
- class ContextWithCallbackProperty:
249
+ class ContextWithoutProvider:
269
250
  session_id = "test-session"
270
251
  trace_id = "trace-ctx"
271
252
  message_id = "msg-ctx"
@@ -283,11 +264,15 @@ class TestAdapterRun:
283
264
  def __init__(self):
284
265
  self.emit_chunk = AsyncMock()
285
266
 
286
- @property
287
- def langfuse_callback(self):
288
- return handler
267
+ def fake_import_module(name):
268
+ if name == "by_framework_trace_langfuse":
269
+ raise ImportError(name)
270
+ raise AssertionError(f"unexpected import: {name}")
289
271
 
290
- ctx = ContextWithCallbackProperty()
272
+ monkeypatch.setattr(
273
+ "by_framework_langgraph.adapter.import_module", fake_import_module
274
+ )
275
+ ctx = ContextWithoutProvider()
291
276
  graph = MagicMock()
292
277
  graph.ainvoke = AsyncMock(
293
278
  return_value={"messages": [MagicMock(content="hello")]}
@@ -301,7 +286,10 @@ class TestAdapterRun:
301
286
 
302
287
  assert result == "hello"
303
288
  _, kwargs = graph.ainvoke.call_args
304
- assert kwargs["config"]["callbacks"] == [handler]
289
+ callbacks = kwargs["config"]["callbacks"]
290
+ assert any(
291
+ type(cb).__name__ == "_TokenAccumulatingCallbackHandler" for cb in callbacks
292
+ )
305
293
 
306
294
  @pytest.mark.asyncio
307
295
  async def test_skips_langfuse_tracing_silently_when_not_configured(
@@ -323,11 +311,8 @@ class TestAdapterRun:
323
311
  monkeypatch.setitem(
324
312
  sys.modules,
325
313
  "by_framework_trace_langfuse",
326
- SimpleNamespace(LangfuseConfig=MagicMock()),
314
+ SimpleNamespace(build_langchain_callback=MagicMock(return_value=None)),
327
315
  )
328
- sys.modules[
329
- "by_framework_trace_langfuse"
330
- ].LangfuseConfig.from_env.return_value = None
331
316
 
332
317
  adapter = LangGraphAdapter(graph, ctx, stream=False)
333
318
 
@@ -340,7 +325,13 @@ class TestAdapterRun:
340
325
  assert result == "hello"
341
326
  _, kwargs = graph.ainvoke.call_args
342
327
  config = kwargs["config"]
343
- assert "callbacks" not in config
328
+ # Token accumulator is always present; only Langfuse callbacks should be absent.
329
+ non_token_callbacks = [
330
+ cb
331
+ for cb in config.get("callbacks", [])
332
+ if type(cb).__name__ != "_TokenAccumulatingCallbackHandler"
333
+ ]
334
+ assert non_token_callbacks == []
344
335
  assert "Langfuse" not in caplog.text
345
336
 
346
337
  @pytest.mark.asyncio
@@ -364,11 +355,8 @@ class TestAdapterRun:
364
355
  monkeypatch.setitem(
365
356
  sys.modules,
366
357
  "by_framework_trace_langfuse",
367
- SimpleNamespace(LangfuseConfig=MagicMock()),
358
+ SimpleNamespace(build_langchain_callback=MagicMock(return_value=None)),
368
359
  )
369
- sys.modules[
370
- "by_framework_trace_langfuse"
371
- ].LangfuseConfig.from_env.return_value = None
372
360
 
373
361
  adapter = LangGraphAdapter(graph, ctx, stream=False)
374
362
 
@@ -381,7 +369,12 @@ class TestAdapterRun:
381
369
  assert result == "hello"
382
370
  _, kwargs = graph.ainvoke.call_args
383
371
  config = kwargs["config"]
384
- assert "callbacks" not in config
372
+ non_token_callbacks = [
373
+ cb
374
+ for cb in config.get("callbacks", [])
375
+ if type(cb).__name__ != "_TokenAccumulatingCallbackHandler"
376
+ ]
377
+ assert non_token_callbacks == []
385
378
  assert "Langfuse" not in caplog.text
386
379
 
387
380
 
@@ -450,3 +443,75 @@ class TestLangGraphWorkerHooks: # pylint: disable=too-few-public-methods
450
443
  assert captured["run_name"] == "custom-run"
451
444
  assert captured["metadata"] == {"team": "alpha"}
452
445
  assert captured["callbacks"] == ["cb-1"]
446
+
447
+
448
+ class TestTokenAccumulatingCallbackHandler:
449
+
450
+ def _make_llm_result(self, prompt=10, completion=20, style="openai"):
451
+ """Build a mock LLMResult in either openai or usage_metadata style."""
452
+ result = MagicMock()
453
+ if style == "openai":
454
+ result.llm_output = {
455
+ "token_usage": {
456
+ "prompt_tokens": prompt,
457
+ "completion_tokens": completion,
458
+ }
459
+ }
460
+ result.generations = []
461
+ else:
462
+ result.llm_output = {}
463
+ gen = MagicMock()
464
+ gen.message.usage_metadata = {
465
+ "input_tokens": prompt,
466
+ "output_tokens": completion,
467
+ }
468
+ result.generations = [[gen]]
469
+ return result
470
+
471
+ def test_accumulates_openai_style(self):
472
+ ctx = MagicMock()
473
+ handler = _TokenAccumulatingCallbackHandler(ctx)
474
+ handler.on_llm_end(self._make_llm_result(10, 20, "openai"))
475
+ ctx.record_token_usage.assert_called_once_with(
476
+ prompt_tokens=10, completion_tokens=20
477
+ )
478
+
479
+ def test_accumulates_usage_metadata_style(self):
480
+ ctx = MagicMock()
481
+ handler = _TokenAccumulatingCallbackHandler(ctx)
482
+ handler.on_llm_end(self._make_llm_result(5, 15, "metadata"))
483
+ ctx.record_token_usage.assert_called_once_with(
484
+ prompt_tokens=5, completion_tokens=15
485
+ )
486
+
487
+ def test_no_call_on_zero_tokens(self):
488
+ ctx = MagicMock()
489
+ handler = _TokenAccumulatingCallbackHandler(ctx)
490
+ result = MagicMock()
491
+ result.llm_output = {}
492
+ result.generations = []
493
+ handler.on_llm_end(result)
494
+ ctx.record_token_usage.assert_not_called()
495
+
496
+ def test_none_context_does_not_raise(self):
497
+ handler = _TokenAccumulatingCallbackHandler(None)
498
+ handler.on_llm_end(self._make_llm_result())
499
+
500
+ def test_callback_injected_in_tracing_scope(self):
501
+ """_langfuse_callback_manager injects _TokenAccumulatingCallbackHandler."""
502
+ ctx = MagicMock()
503
+ graph = MagicMock()
504
+ adapter = LangGraphAdapter(graph=graph, context=ctx)
505
+
506
+ callbacks = []
507
+ with patch.object(
508
+ adapter,
509
+ "_langfuse_callback_manager",
510
+ wraps=adapter._langfuse_callback_manager,
511
+ ):
512
+ with adapter._langfuse_callback_manager(callbacks):
513
+ pass
514
+
515
+ assert any(
516
+ isinstance(cb, _TokenAccumulatingCallbackHandler) for cb in callbacks
517
+ )
@@ -1,8 +1,15 @@
1
1
  """Tests for tools module."""
2
2
 
3
+ from types import SimpleNamespace
3
4
  from unittest.mock import AsyncMock, MagicMock
4
5
 
5
- from by_framework_langgraph.tools import (make_ask_user_tool, make_remote_agent_tool)
6
+ import pytest
7
+
8
+ from by_framework_langgraph.tools import (
9
+ _langfuse_observation_id_from_callbacks,
10
+ make_ask_user_tool,
11
+ make_remote_agent_tool,
12
+ )
6
13
 
7
14
 
8
15
  def _make_mock_context(session_id: str = "test-session"):
@@ -32,6 +39,49 @@ class TestMakeRemoteAgentTool:
32
39
  )
33
40
  assert "Invoke the poet agent" in tool.description
34
41
 
42
+ def test_resolves_langfuse_observation_id_from_callbacks(self):
43
+ """The active tool observation can be read from Langfuse callback state."""
44
+ run_id = object()
45
+ handler = SimpleNamespace(_runs={run_id: SimpleNamespace(id="obs-tool")})
46
+ callbacks = SimpleNamespace(parent_run_id=run_id, handlers=[handler])
47
+
48
+ assert _langfuse_observation_id_from_callbacks(callbacks) == "obs-tool"
49
+
50
+ @pytest.mark.asyncio
51
+ async def test_passes_tool_observation_id_to_call_agent(self, monkeypatch):
52
+ """Remote calls are parented to the current LangGraph tool observation."""
53
+ ctx = _make_mock_context()
54
+ ctx.redis.exists.return_value = False
55
+ tool = make_remote_agent_tool(
56
+ ctx,
57
+ "query_weather",
58
+ "weather-agent",
59
+ "Query weather",
60
+ )
61
+ monkeypatch.setattr(
62
+ "by_framework_langgraph.tools.interrupt",
63
+ lambda _message: "queued",
64
+ )
65
+
66
+ run_id = object()
67
+ handler = SimpleNamespace(_runs={run_id: SimpleNamespace(id="obs-tool")})
68
+ callbacks = SimpleNamespace(parent_run_id=run_id, handlers=[handler])
69
+ run_manager = SimpleNamespace(get_child=lambda: callbacks)
70
+
71
+ result = await tool._arun(
72
+ "Beijing weather",
73
+ tool_call_id="tool-call-1",
74
+ run_manager=run_manager,
75
+ config={},
76
+ )
77
+
78
+ assert result == "queued"
79
+ ctx.call_agent.assert_awaited_once_with(
80
+ target_agent_type="weather-agent",
81
+ content="Beijing weather",
82
+ metadata={"langfuse_parent_observation_id": "obs-tool"},
83
+ )
84
+
35
85
 
36
86
  class TestMakeAskUserTool:
37
87
  """Tests for make_ask_user_tool."""