by-framework-langgraph 0.0.3.dev1__tar.gz → 0.0.3.dev3__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,8 +68,6 @@ Thumbs.db
68
68
  .claude/settings.local.json
69
69
  .claude/settings.json
70
70
 
71
- core
72
-
73
71
  .coderfleet-uploads/
74
72
 
75
73
  .claude/skills
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: by-framework-langgraph
3
- Version: 0.0.3.dev1
3
+ Version: 0.0.3.dev3
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.dev1"
3
+ version = "0.0.3.dev3"
4
4
  description = "LangGraph integration for by-framework"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -525,6 +525,7 @@ class LangGraphAdapter:
525
525
  self._context, "parent_message_id", ""
526
526
  ),
527
527
  "by_framework_agent_id": getattr(self._context, "current_agent_id", ""),
528
+ "worker_id": getattr(self._context, "worker_id", ""),
528
529
  "langgraph_thread_id": self._thread_id,
529
530
  }
530
531
  return {
@@ -538,10 +539,31 @@ class LangGraphAdapter:
538
539
 
539
540
  with (
540
541
  self._phoenix_context_manager(),
542
+ self._langfuse_attribute_propagation_context_manager(),
541
543
  self._langfuse_callback_manager(callbacks),
542
544
  ):
543
545
  yield callbacks
544
546
 
547
+ @contextmanager
548
+ def _langfuse_attribute_propagation_context_manager(self) -> Iterator[None]:
549
+ """Propagate stable framework metadata to Langfuse child observations."""
550
+ worker_id = str(getattr(self._context, "worker_id", "") or "")
551
+ if not worker_id:
552
+ yield
553
+ return
554
+
555
+ try:
556
+ propagate_attributes = getattr(
557
+ import_module("langfuse"),
558
+ "propagate_attributes",
559
+ )
560
+ except (ImportError, AttributeError):
561
+ yield
562
+ return
563
+
564
+ with propagate_attributes(metadata={"worker_id": worker_id}):
565
+ yield
566
+
545
567
  @contextmanager
546
568
  def _phoenix_context_manager(self) -> Iterator[None]:
547
569
  """Prepare OpenTelemetry context for Phoenix tracing."""
@@ -590,69 +612,47 @@ class LangGraphAdapter:
590
612
  # Always inject token accumulator — works regardless of Langfuse config.
591
613
  callbacks.append(_TokenAccumulatingCallbackHandler(self._context))
592
614
 
593
- # Prefer AgentContext's callback factory so trace and parent ids align.
594
- # Filter out auto-generated MagicMock attributes when tests use a mock
595
- # context — real callback objects always come from a non-test module.
596
- langfuse_callback_value = getattr(self._context, "langfuse_callback", None)
597
- is_real_callback = langfuse_callback_value is not None and type(
598
- langfuse_callback_value
599
- ).__module__ not in ("unittest.mock",)
600
-
601
- if is_real_callback:
602
- handler = (
603
- langfuse_callback_value()
604
- if callable(langfuse_callback_value)
605
- else langfuse_callback_value
606
- )
607
- if handler is not None:
608
- callbacks.append(handler)
609
- yield
610
- return
611
-
612
- # Fallback to local import if context method is missing
613
- # pylint: disable=import-outside-toplevel
614
615
  try:
615
- langfuse_config = import_module(
616
- "by_framework_trace_langfuse"
617
- ).LangfuseConfig
618
- if langfuse_config.from_env() is None:
619
- raise ImportError("Langfuse not configured")
620
-
621
- callback_handler = import_module("langfuse.langchain").CallbackHandler
622
- get_client = import_module("langfuse").get_client
616
+ build_langchain_callback = getattr(
617
+ import_module("by_framework_trace_langfuse"),
618
+ "build_langchain_callback",
619
+ )
623
620
  except (ImportError, AttributeError):
624
621
  yield
625
622
  return
626
623
 
627
- callbacks.append(callback_handler())
628
-
629
- framework_observation = getattr(self._context, LANGFUSE_OBSERVATION_ATTR, None)
630
- if framework_observation is None:
631
- yield
632
- return
624
+ get_parent_observation_id = getattr(
625
+ self._context,
626
+ "get_trace_parent_observation_id",
627
+ None,
628
+ )
629
+ parent_observation_id = (
630
+ str(get_parent_observation_id() or "")
631
+ if callable(get_parent_observation_id)
632
+ else ""
633
+ )
634
+ if not parent_observation_id:
635
+ framework_observation = getattr(
636
+ self._context, LANGFUSE_OBSERVATION_ATTR, None
637
+ )
638
+ parent_observation_id = getattr(framework_observation, "id", "") or ""
639
+ if not parent_observation_id:
640
+ execution_id = getattr(self._context, "execution_id", "")
641
+ message_id = getattr(self._context, "message_id", "")
642
+ raw_parent_id = (
643
+ f"{execution_id}:worker.execute"
644
+ if execution_id
645
+ else f"{message_id}:worker.execute"
646
+ )
647
+ parent_observation_id = f"{str_to_uint64(raw_parent_id):016x}"
633
648
 
634
- langfuse = get_client()
635
- with langfuse.start_as_current_observation(
636
- as_type="span",
637
- name=self._tracing.run_name,
638
- trace_context={
639
- "trace_id": getattr(self._context, "trace_id", ""),
640
- "parent_span_id": framework_observation.id,
641
- },
642
- metadata=self._default_metadata(),
643
- ):
644
- # Prevent the generated OTel span from being promoted to a trace root.
645
- # The native LangfusePlugin sets the same attribute on its own path
646
- # (via _SdkLangfuseTracer); this covers the LangGraph fallback path.
647
- try:
648
- from opentelemetry import trace
649
-
650
- current_span = trace.get_current_span()
651
- if current_span and hasattr(current_span, "set_attribute"):
652
- current_span.set_attribute("langfuse.internal.as_root", False)
653
- except Exception: # pylint: disable=broad-exception-caught
654
- pass
655
- yield
649
+ handler = build_langchain_callback(
650
+ trace_id=getattr(self._context, "trace_id", ""),
651
+ parent_observation_id=parent_observation_id,
652
+ )
653
+ if handler is not None:
654
+ callbacks.append(handler)
655
+ yield
656
656
 
657
657
  @staticmethod
658
658
  def _default_input_mapper(content: str) -> dict:
@@ -1,7 +1,6 @@
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
5
  from unittest.mock import AsyncMock, MagicMock, patch
7
6
 
@@ -24,6 +23,7 @@ def _make_mock_context(session_id: str = "test-session"):
24
23
  ctx.message_id = "msg-ctx"
25
24
  ctx.parent_message_id = "parent-ctx"
26
25
  ctx.current_agent_id = "planner"
26
+ ctx.worker_id = "worker-langgraph-1"
27
27
  ctx.redis = AsyncMock()
28
28
  ctx.emit_chunk = AsyncMock()
29
29
  ctx.ask_user = AsyncMock()
@@ -40,7 +40,7 @@ def _make_mock_context(session_id: str = "test-session"):
40
40
  metadata={"source": "test"},
41
41
  )
42
42
  )
43
- ctx._langfuse_observation = SimpleNamespace(id="obs-framework") # pylint: disable=protected-access
43
+ ctx.get_trace_parent_observation_id.return_value = "obs-framework"
44
44
  return ctx
45
45
 
46
46
 
@@ -187,7 +187,7 @@ class TestAdapterRun:
187
187
  async def test_includes_langfuse_callbacks_and_parent_trace_context(
188
188
  self, monkeypatch
189
189
  ):
190
- """Verify adapter wires Langfuse callback handler into LangGraph config."""
190
+ """Verify adapter gets Langfuse callback from the trace provider package."""
191
191
  ctx = _make_mock_context()
192
192
  graph = MagicMock()
193
193
  graph.ainvoke = AsyncMock(
@@ -197,45 +197,18 @@ class TestAdapterRun:
197
197
  snapshot.next = ()
198
198
  graph.get_state.return_value = snapshot
199
199
 
200
- observation_calls: list[dict] = []
201
- callback_instances: list[object] = []
200
+ callback_handler = object()
201
+ callback_calls: list[dict[str, str]] = []
202
202
 
203
- @contextmanager
204
- def fake_observation_scope(**kwargs):
205
- observation_calls.append(kwargs)
206
- yield SimpleNamespace(id="obs-langgraph")
207
-
208
- class FakeCallbackHandler: # pylint: disable=too-few-public-methods
209
- """Minimal callback handler stub for adapter config assertions."""
210
-
211
- def __init__(self):
212
- callback_instances.append(self)
213
-
214
- fake_langfuse_client = SimpleNamespace(
215
- start_as_current_observation=fake_observation_scope
216
- )
203
+ def fake_build_langchain_callback(**kwargs):
204
+ callback_calls.append(kwargs)
205
+ return callback_handler
217
206
 
218
- monkeypatch.setitem(
219
- sys.modules,
220
- "langfuse",
221
- SimpleNamespace(get_client=MagicMock(return_value=fake_langfuse_client)),
222
- )
223
- monkeypatch.setitem(
224
- sys.modules,
225
- "langfuse.langchain",
226
- SimpleNamespace(CallbackHandler=FakeCallbackHandler),
227
- )
228
207
  monkeypatch.setitem(
229
208
  sys.modules,
230
209
  "by_framework_trace_langfuse",
231
- SimpleNamespace(LangfuseConfig=MagicMock()),
210
+ SimpleNamespace(build_langchain_callback=fake_build_langchain_callback),
232
211
  )
233
- sys.modules[
234
- "by_framework_trace_langfuse"
235
- ].LangfuseConfig.from_env.return_value = object()
236
- monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-test")
237
- monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-test")
238
- monkeypatch.setenv("LANGFUSE_BASE_URL", "http://localhost:3000")
239
212
 
240
213
  adapter = LangGraphAdapter(graph, ctx, stream=False)
241
214
 
@@ -253,27 +226,86 @@ class TestAdapterRun:
253
226
  assert config["metadata"]["langfuse_session_id"] == "test-session"
254
227
  assert config["metadata"]["langfuse_user_id"] == "user-1"
255
228
  assert config["metadata"]["by_framework_message_id"] == "msg-ctx"
229
+ assert config["metadata"]["worker_id"] == "worker-langgraph-1"
256
230
  assert config["metadata"]["langgraph_thread_id"] == "test-session"
257
231
  # Callbacks list includes the Langfuse handler(s) plus the token accumulator.
258
- assert all(cb in config["callbacks"] for cb in callback_instances)
232
+ assert callback_handler in config["callbacks"]
259
233
  assert any(
260
234
  type(cb).__name__ == "_TokenAccumulatingCallbackHandler"
261
235
  for cb in config["callbacks"]
262
236
  )
263
- assert len(observation_calls) == 1
264
- assert observation_calls[0]["trace_context"] == {
265
- "trace_id": "trace-ctx",
266
- "parent_span_id": "obs-framework",
267
- }
268
- assert observation_calls[0]["name"] == "planner:langgraph"
237
+ assert callback_calls == [
238
+ {
239
+ "trace_id": "trace-ctx",
240
+ "parent_observation_id": "obs-framework",
241
+ }
242
+ ]
269
243
 
270
244
  @pytest.mark.asyncio
271
- async def test_uses_context_langfuse_callback_property(self):
272
- """Verify AgentContext.langfuse_callback property is used directly."""
273
- handler = object()
245
+ async def test_propagates_worker_id_to_langfuse_child_observations(
246
+ self, monkeypatch
247
+ ):
248
+ """Langfuse attribute propagation covers nested LangGraph observations."""
249
+ ctx = _make_mock_context()
250
+ graph = MagicMock()
251
+ snapshot = MagicMock()
252
+ snapshot.next = ()
253
+ graph.get_state.return_value = snapshot
254
+
255
+ propagation_events: list[tuple[str, dict[str, str]]] = []
256
+ propagation_active = False
257
+
258
+ class FakePropagation:
259
+
260
+ def __init__(self, metadata):
261
+ self.metadata = metadata
262
+
263
+ def __enter__(self):
264
+ nonlocal propagation_active
265
+ propagation_active = True
266
+ propagation_events.append(("enter", self.metadata))
267
+
268
+ def __exit__(self, exc_type, exc_val, exc_tb):
269
+ nonlocal propagation_active
270
+ propagation_events.append(("exit", self.metadata))
271
+ propagation_active = False
272
+
273
+ def fake_propagate_attributes(**kwargs):
274
+ return FakePropagation(metadata=kwargs["metadata"])
275
+
276
+ async def fake_ainvoke(input_data, *, config):
277
+ del input_data
278
+ assert config["metadata"]["worker_id"] == "worker-langgraph-1"
279
+ assert propagation_active is True
280
+ return {"messages": [MagicMock(content="hello")]}
281
+
282
+ graph.ainvoke = fake_ainvoke
283
+ monkeypatch.setitem(
284
+ sys.modules,
285
+ "langfuse",
286
+ SimpleNamespace(propagate_attributes=fake_propagate_attributes),
287
+ )
288
+
289
+ adapter = LangGraphAdapter(graph, ctx, stream=False)
290
+
291
+ result = await adapter.run(
292
+ AskAgentCommand(header=_make_header(), content="write a poem")
293
+ )
294
+
295
+ assert result == "hello"
296
+ assert propagation_events == [
297
+ ("enter", {"worker_id": "worker-langgraph-1"}),
298
+ ("exit", {"worker_id": "worker-langgraph-1"}),
299
+ ]
300
+
301
+ @pytest.mark.asyncio
302
+ async def test_skips_langfuse_callback_when_provider_package_missing(
303
+ self, monkeypatch
304
+ ):
305
+ """LangGraph stays provider-agnostic when trace-langfuse is not installed."""
274
306
 
275
307
  # pylint: disable=too-few-public-methods,missing-class-docstring,missing-function-docstring
276
- class ContextWithCallbackProperty:
308
+ class ContextWithoutProvider:
277
309
  session_id = "test-session"
278
310
  trace_id = "trace-ctx"
279
311
  message_id = "msg-ctx"
@@ -291,11 +323,15 @@ class TestAdapterRun:
291
323
  def __init__(self):
292
324
  self.emit_chunk = AsyncMock()
293
325
 
294
- @property
295
- def langfuse_callback(self):
296
- return handler
326
+ def fake_import_module(name):
327
+ if name == "by_framework_trace_langfuse":
328
+ raise ImportError(name)
329
+ raise AssertionError(f"unexpected import: {name}")
297
330
 
298
- ctx = ContextWithCallbackProperty()
331
+ monkeypatch.setattr(
332
+ "by_framework_langgraph.adapter.import_module", fake_import_module
333
+ )
334
+ ctx = ContextWithoutProvider()
299
335
  graph = MagicMock()
300
336
  graph.ainvoke = AsyncMock(
301
337
  return_value={"messages": [MagicMock(content="hello")]}
@@ -310,7 +346,6 @@ class TestAdapterRun:
310
346
  assert result == "hello"
311
347
  _, kwargs = graph.ainvoke.call_args
312
348
  callbacks = kwargs["config"]["callbacks"]
313
- assert handler in callbacks
314
349
  assert any(
315
350
  type(cb).__name__ == "_TokenAccumulatingCallbackHandler" for cb in callbacks
316
351
  )
@@ -335,11 +370,8 @@ class TestAdapterRun:
335
370
  monkeypatch.setitem(
336
371
  sys.modules,
337
372
  "by_framework_trace_langfuse",
338
- SimpleNamespace(LangfuseConfig=MagicMock()),
373
+ SimpleNamespace(build_langchain_callback=MagicMock(return_value=None)),
339
374
  )
340
- sys.modules[
341
- "by_framework_trace_langfuse"
342
- ].LangfuseConfig.from_env.return_value = None
343
375
 
344
376
  adapter = LangGraphAdapter(graph, ctx, stream=False)
345
377
 
@@ -382,11 +414,8 @@ class TestAdapterRun:
382
414
  monkeypatch.setitem(
383
415
  sys.modules,
384
416
  "by_framework_trace_langfuse",
385
- SimpleNamespace(LangfuseConfig=MagicMock()),
417
+ SimpleNamespace(build_langchain_callback=MagicMock(return_value=None)),
386
418
  )
387
- sys.modules[
388
- "by_framework_trace_langfuse"
389
- ].LangfuseConfig.from_env.return_value = None
390
419
 
391
420
  adapter = LangGraphAdapter(graph, ctx, stream=False)
392
421
 
@@ -530,7 +559,6 @@ class TestTokenAccumulatingCallbackHandler:
530
559
  def test_callback_injected_in_tracing_scope(self):
531
560
  """_langfuse_callback_manager injects _TokenAccumulatingCallbackHandler."""
532
561
  ctx = MagicMock()
533
- ctx.langfuse_callback = None
534
562
  graph = MagicMock()
535
563
  adapter = LangGraphAdapter(graph=graph, context=ctx)
536
564