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.
- {by_framework_langgraph-0.0.3.dev0 → by_framework_langgraph-0.0.3.dev2}/.gitignore +2 -2
- {by_framework_langgraph-0.0.3.dev0 → by_framework_langgraph-0.0.3.dev2}/PKG-INFO +1 -1
- {by_framework_langgraph-0.0.3.dev0 → by_framework_langgraph-0.0.3.dev2}/pyproject.toml +1 -1
- {by_framework_langgraph-0.0.3.dev0 → by_framework_langgraph-0.0.3.dev2}/src/by_framework_langgraph/adapter.py +247 -58
- {by_framework_langgraph-0.0.3.dev0 → by_framework_langgraph-0.0.3.dev2}/src/by_framework_langgraph/tools.py +37 -1
- {by_framework_langgraph-0.0.3.dev0 → by_framework_langgraph-0.0.3.dev2}/tests/test_adapter.py +129 -64
- {by_framework_langgraph-0.0.3.dev0 → by_framework_langgraph-0.0.3.dev2}/tests/test_tools.py +51 -1
- {by_framework_langgraph-0.0.3.dev0 → by_framework_langgraph-0.0.3.dev2}/README.md +0 -0
- {by_framework_langgraph-0.0.3.dev0 → by_framework_langgraph-0.0.3.dev2}/src/by_framework_langgraph/__init__.py +0 -0
- {by_framework_langgraph-0.0.3.dev0 → by_framework_langgraph-0.0.3.dev2}/src/by_framework_langgraph/_utils.py +0 -0
- {by_framework_langgraph-0.0.3.dev0 → by_framework_langgraph-0.0.3.dev2}/src/by_framework_langgraph/worker.py +0 -0
- {by_framework_langgraph-0.0.3.dev0 → by_framework_langgraph-0.0.3.dev2}/tests/test_utils.py +0 -0
|
@@ -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.
|
|
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
|
|
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
|
-
#
|
|
383
|
-
|
|
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
|
-
|
|
405
|
-
"by_framework_trace_langfuse"
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
|
{by_framework_langgraph-0.0.3.dev0 → by_framework_langgraph-0.0.3.dev2}/tests/test_adapter.py
RENAMED
|
@@ -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
|
|
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.
|
|
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
|
|
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
|
-
|
|
198
|
-
|
|
199
|
+
callback_handler = object()
|
|
200
|
+
callback_calls: list[dict[str, str]] = []
|
|
199
201
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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(
|
|
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
|
-
|
|
255
|
-
assert
|
|
256
|
-
assert
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
assert
|
|
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
|
|
264
|
-
|
|
265
|
-
|
|
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
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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."""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|