by-framework-langgraph 0.0.3.dev2__tar.gz → 0.0.3.dev4__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.dev2 → by_framework_langgraph-0.0.3.dev4}/PKG-INFO +45 -2
- by_framework_langgraph-0.0.3.dev4/README.md +89 -0
- {by_framework_langgraph-0.0.3.dev2 → by_framework_langgraph-0.0.3.dev4}/pyproject.toml +1 -1
- {by_framework_langgraph-0.0.3.dev2 → by_framework_langgraph-0.0.3.dev4}/src/by_framework_langgraph/adapter.py +71 -4
- {by_framework_langgraph-0.0.3.dev2 → by_framework_langgraph-0.0.3.dev4}/tests/test_adapter.py +265 -7
- by_framework_langgraph-0.0.3.dev2/README.md +0 -46
- {by_framework_langgraph-0.0.3.dev2 → by_framework_langgraph-0.0.3.dev4}/.gitignore +0 -0
- {by_framework_langgraph-0.0.3.dev2 → by_framework_langgraph-0.0.3.dev4}/src/by_framework_langgraph/__init__.py +0 -0
- {by_framework_langgraph-0.0.3.dev2 → by_framework_langgraph-0.0.3.dev4}/src/by_framework_langgraph/_utils.py +0 -0
- {by_framework_langgraph-0.0.3.dev2 → by_framework_langgraph-0.0.3.dev4}/src/by_framework_langgraph/tools.py +0 -0
- {by_framework_langgraph-0.0.3.dev2 → by_framework_langgraph-0.0.3.dev4}/src/by_framework_langgraph/worker.py +0 -0
- {by_framework_langgraph-0.0.3.dev2 → by_framework_langgraph-0.0.3.dev4}/tests/test_tools.py +0 -0
- {by_framework_langgraph-0.0.3.dev2 → by_framework_langgraph-0.0.3.dev4}/tests/test_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: by-framework-langgraph
|
|
3
|
-
Version: 0.0.3.
|
|
3
|
+
Version: 0.0.3.dev4
|
|
4
4
|
Summary: LangGraph integration for by-framework
|
|
5
5
|
Requires-Python: >=3.12
|
|
6
6
|
Requires-Dist: by-framework>=0.2.1
|
|
@@ -67,3 +67,46 @@ class OrchestratorWorker(LangGraphWorker):
|
|
|
67
67
|
llm = ChatOpenAI(model="gpt-4o").bind_tools([poet, ask])
|
|
68
68
|
# ... build and return compiled graph
|
|
69
69
|
```
|
|
70
|
+
|
|
71
|
+
## Common Pitfalls
|
|
72
|
+
|
|
73
|
+
Both of these fail **silently** — no exception, just wrong or empty results — so they're easy to lose time to.
|
|
74
|
+
|
|
75
|
+
### `add_messages` must be imported as a function, not written as a string
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from langgraph.graph.message import add_messages
|
|
79
|
+
|
|
80
|
+
# Correct — the reducer actually runs; messages accumulate across nodes.
|
|
81
|
+
messages: Annotated[list, add_messages]
|
|
82
|
+
|
|
83
|
+
# Wrong — looks plausible (some older LangGraph docs used this form), but
|
|
84
|
+
# the reducer silently does nothing: each node only sees its own output,
|
|
85
|
+
# not the accumulated history. Your agent node ends up calling the model
|
|
86
|
+
# with just the latest ToolMessage and no prior context, and typically
|
|
87
|
+
# returns an empty or confused reply.
|
|
88
|
+
messages: Annotated[list, "add_messages"]
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### A routing function must return the `END` constant, not the string `"end"`
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from langgraph.graph import END
|
|
95
|
+
|
|
96
|
+
# Correct — the graph terminates properly; the agent node's final
|
|
97
|
+
# AIMessage is saved to state before the graph stops.
|
|
98
|
+
def should_continue(state):
|
|
99
|
+
...
|
|
100
|
+
return END
|
|
101
|
+
|
|
102
|
+
# Wrong — "end" looks like it should work as a node name, but LangGraph
|
|
103
|
+
# only recognizes the END constant (whose actual value is "__end__").
|
|
104
|
+
# LangGraph logs "wrote to unknown channel branch:to:end, ignoring it."
|
|
105
|
+
# and the graph doesn't route there — the agent's last turn is dropped,
|
|
106
|
+
# and `state["messages"][-1]` after execution can end up being something
|
|
107
|
+
# other than the model's real final answer (e.g. a stale ToolMessage from
|
|
108
|
+
# an earlier step), not the reply you expected.
|
|
109
|
+
def should_continue(state):
|
|
110
|
+
...
|
|
111
|
+
return "end"
|
|
112
|
+
```
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# by-framework-langgraph
|
|
2
|
+
|
|
3
|
+
LangGraph integration for by-framework. Provides two integration modes:
|
|
4
|
+
|
|
5
|
+
1. **Adapter Mode** — Plug existing LangGraph graphs into by-framework with one line
|
|
6
|
+
2. **Native Mode** — Build LangGraph workers with native `call_agent` / `ask_user` / `resume` support
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
uv add by-framework-langgraph
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Quick Start
|
|
15
|
+
|
|
16
|
+
### Adapter Mode — Plug in existing graphs
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
from by_framework.worker import ByaiWorker
|
|
20
|
+
from by_framework_langgraph import LangGraphAdapter
|
|
21
|
+
|
|
22
|
+
class MyWorker(ByaiWorker):
|
|
23
|
+
def get_agent_types(self):
|
|
24
|
+
return ["my-agent"]
|
|
25
|
+
|
|
26
|
+
async def process_command(self, command, context):
|
|
27
|
+
graph = build_my_existing_graph() # your existing LangGraph
|
|
28
|
+
adapter = LangGraphAdapter(graph, context)
|
|
29
|
+
return await adapter.run(command)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Native Mode — Framework-native LangGraph workers
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from by_framework_langgraph import LangGraphWorker, make_remote_agent_tool, make_ask_user_tool
|
|
36
|
+
|
|
37
|
+
class OrchestratorWorker(LangGraphWorker):
|
|
38
|
+
def get_agent_types(self):
|
|
39
|
+
return ["orchestrator"]
|
|
40
|
+
|
|
41
|
+
def build_graph(self, context, command):
|
|
42
|
+
poet = make_remote_agent_tool(context, "invoke_poet", "poet-agent", "调度诗人创作")
|
|
43
|
+
ask = make_ask_user_tool(context)
|
|
44
|
+
llm = ChatOpenAI(model="gpt-4o").bind_tools([poet, ask])
|
|
45
|
+
# ... build and return compiled graph
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Common Pitfalls
|
|
49
|
+
|
|
50
|
+
Both of these fail **silently** — no exception, just wrong or empty results — so they're easy to lose time to.
|
|
51
|
+
|
|
52
|
+
### `add_messages` must be imported as a function, not written as a string
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from langgraph.graph.message import add_messages
|
|
56
|
+
|
|
57
|
+
# Correct — the reducer actually runs; messages accumulate across nodes.
|
|
58
|
+
messages: Annotated[list, add_messages]
|
|
59
|
+
|
|
60
|
+
# Wrong — looks plausible (some older LangGraph docs used this form), but
|
|
61
|
+
# the reducer silently does nothing: each node only sees its own output,
|
|
62
|
+
# not the accumulated history. Your agent node ends up calling the model
|
|
63
|
+
# with just the latest ToolMessage and no prior context, and typically
|
|
64
|
+
# returns an empty or confused reply.
|
|
65
|
+
messages: Annotated[list, "add_messages"]
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### A routing function must return the `END` constant, not the string `"end"`
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from langgraph.graph import END
|
|
72
|
+
|
|
73
|
+
# Correct — the graph terminates properly; the agent node's final
|
|
74
|
+
# AIMessage is saved to state before the graph stops.
|
|
75
|
+
def should_continue(state):
|
|
76
|
+
...
|
|
77
|
+
return END
|
|
78
|
+
|
|
79
|
+
# Wrong — "end" looks like it should work as a node name, but LangGraph
|
|
80
|
+
# only recognizes the END constant (whose actual value is "__end__").
|
|
81
|
+
# LangGraph logs "wrote to unknown channel branch:to:end, ignoring it."
|
|
82
|
+
# and the graph doesn't route there — the agent's last turn is dropped,
|
|
83
|
+
# and `state["messages"][-1]` after execution can end up being something
|
|
84
|
+
# other than the model's real final answer (e.g. a stale ToolMessage from
|
|
85
|
+
# an earlier step), not the reply you expected.
|
|
86
|
+
def should_continue(state):
|
|
87
|
+
...
|
|
88
|
+
return "end"
|
|
89
|
+
```
|
|
@@ -18,7 +18,7 @@ from by_framework.core.protocol.commands import ResumeCommand
|
|
|
18
18
|
from by_framework.core.protocol.events import StreamChunkEvent
|
|
19
19
|
from by_framework.trace.span_recorder import str_to_uint64, str_to_uint128
|
|
20
20
|
from langchain_core.callbacks import BaseCallbackHandler
|
|
21
|
-
from langchain_core.messages import HumanMessage
|
|
21
|
+
from langchain_core.messages import AIMessage, HumanMessage
|
|
22
22
|
from langgraph.types import Command
|
|
23
23
|
|
|
24
24
|
from ._utils import extract_content_text, extract_resume_data
|
|
@@ -32,6 +32,26 @@ if TYPE_CHECKING:
|
|
|
32
32
|
LANGFUSE_OBSERVATION_ATTR = "_langfuse_observation"
|
|
33
33
|
|
|
34
34
|
|
|
35
|
+
def _last_ai_message_text(messages: list[Any]) -> str:
|
|
36
|
+
"""Find the last AIMessage's text content — not just messages[-1].
|
|
37
|
+
|
|
38
|
+
A correctly-terminating ReAct graph routes back to the agent node after
|
|
39
|
+
every tool call, so the state's last message should already be an
|
|
40
|
+
AIMessage. But a misconfigured graph (e.g. a routing function that
|
|
41
|
+
returns the string "end" instead of the END constant) can terminate
|
|
42
|
+
right after a tool call instead, leaving a ToolMessage as messages[-1].
|
|
43
|
+
Blindly using messages[-1].content would then surface a tool's raw
|
|
44
|
+
result as the "final answer" instead of the model's actual reply, so
|
|
45
|
+
this scans backwards for the last real AIMessage rather than trusting
|
|
46
|
+
positional order.
|
|
47
|
+
"""
|
|
48
|
+
for msg in reversed(messages):
|
|
49
|
+
if isinstance(msg, AIMessage):
|
|
50
|
+
content = msg.content
|
|
51
|
+
return content if isinstance(content, str) else str(content)
|
|
52
|
+
return ""
|
|
53
|
+
|
|
54
|
+
|
|
35
55
|
class _TokenAccumulatingCallbackHandler(BaseCallbackHandler):
|
|
36
56
|
"""LangChain callback handler that accumulates LLM token usage into AgentContext.
|
|
37
57
|
|
|
@@ -445,12 +465,38 @@ class LangGraphAdapter:
|
|
|
445
465
|
)
|
|
446
466
|
return {"status": AgentState.QUEUED.value}
|
|
447
467
|
|
|
468
|
+
# The checkpointed graph state's last message — not the streamed
|
|
469
|
+
# text — is the authoritative final answer, and takes priority
|
|
470
|
+
# whenever it's available (not just when full_response is empty).
|
|
471
|
+
# on_chat_model_stream isn't guaranteed to fire for every LLM call a
|
|
472
|
+
# graph makes: a model that narrates before calling a tool (e.g.
|
|
473
|
+
# "好的,我来帮你计算这个表达式!" + a tool_calls delta) can populate
|
|
474
|
+
# full_response with that preamble via a round that DID stream,
|
|
475
|
+
# while the actual final answer's round — after the tool
|
|
476
|
+
# executes — doesn't stream at all. full_response would then be
|
|
477
|
+
# non-empty but wrong (just the preamble), so an empty-only check
|
|
478
|
+
# can't catch it; graph state's messages[-1] is what the graph
|
|
479
|
+
# actually decided the final answer is, regardless of what
|
|
480
|
+
# streaming happened to capture.
|
|
481
|
+
state_text = self._extract_final_text_from_state()
|
|
482
|
+
if state_text:
|
|
483
|
+
full_response = state_text
|
|
484
|
+
|
|
448
485
|
# Emit final answer if using custom output handler
|
|
449
486
|
if self._output_handler and full_response:
|
|
450
487
|
await self._output_handler(self._context, full_response)
|
|
451
488
|
|
|
452
489
|
return full_response
|
|
453
490
|
|
|
491
|
+
def _extract_final_text_from_state(self) -> str:
|
|
492
|
+
"""Read the last AIMessage's text straight from the checkpointed
|
|
493
|
+
graph state — see the fallback's call site for why this exists."""
|
|
494
|
+
try:
|
|
495
|
+
snapshot = self._graph.get_state(self._state_config)
|
|
496
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
497
|
+
return ""
|
|
498
|
+
return _last_ai_message_text((snapshot.values or {}).get("messages", []))
|
|
499
|
+
|
|
454
500
|
async def _process_result(self, result: dict) -> Any:
|
|
455
501
|
"""Analyze graph result and determine suspended vs completed."""
|
|
456
502
|
if self._is_graph_suspended():
|
|
@@ -460,13 +506,12 @@ class LangGraphAdapter:
|
|
|
460
506
|
)
|
|
461
507
|
return {"status": AgentState.QUEUED.value}
|
|
462
508
|
|
|
463
|
-
# Extract final answer from last
|
|
509
|
+
# Extract final answer from the last AIMessage
|
|
464
510
|
messages = result.get("messages", [])
|
|
465
511
|
if not messages:
|
|
466
512
|
return result
|
|
467
513
|
|
|
468
|
-
|
|
469
|
-
answer = last_msg.content if hasattr(last_msg, "content") else str(last_msg)
|
|
514
|
+
answer = _last_ai_message_text(messages)
|
|
470
515
|
|
|
471
516
|
# Emit output
|
|
472
517
|
if self._output_handler:
|
|
@@ -525,6 +570,7 @@ class LangGraphAdapter:
|
|
|
525
570
|
self._context, "parent_message_id", ""
|
|
526
571
|
),
|
|
527
572
|
"by_framework_agent_id": getattr(self._context, "current_agent_id", ""),
|
|
573
|
+
"worker_id": getattr(self._context, "worker_id", ""),
|
|
528
574
|
"langgraph_thread_id": self._thread_id,
|
|
529
575
|
}
|
|
530
576
|
return {
|
|
@@ -538,10 +584,31 @@ class LangGraphAdapter:
|
|
|
538
584
|
|
|
539
585
|
with (
|
|
540
586
|
self._phoenix_context_manager(),
|
|
587
|
+
self._langfuse_attribute_propagation_context_manager(),
|
|
541
588
|
self._langfuse_callback_manager(callbacks),
|
|
542
589
|
):
|
|
543
590
|
yield callbacks
|
|
544
591
|
|
|
592
|
+
@contextmanager
|
|
593
|
+
def _langfuse_attribute_propagation_context_manager(self) -> Iterator[None]:
|
|
594
|
+
"""Propagate stable framework metadata to Langfuse child observations."""
|
|
595
|
+
worker_id = str(getattr(self._context, "worker_id", "") or "")
|
|
596
|
+
if not worker_id:
|
|
597
|
+
yield
|
|
598
|
+
return
|
|
599
|
+
|
|
600
|
+
try:
|
|
601
|
+
propagate_attributes = getattr(
|
|
602
|
+
import_module("langfuse"),
|
|
603
|
+
"propagate_attributes",
|
|
604
|
+
)
|
|
605
|
+
except (ImportError, AttributeError):
|
|
606
|
+
yield
|
|
607
|
+
return
|
|
608
|
+
|
|
609
|
+
with propagate_attributes(metadata={"worker_id": worker_id}):
|
|
610
|
+
yield
|
|
611
|
+
|
|
545
612
|
@contextmanager
|
|
546
613
|
def _phoenix_context_manager(self) -> Iterator[None]:
|
|
547
614
|
"""Prepare OpenTelemetry context for Phoenix tracing."""
|
{by_framework_langgraph-0.0.3.dev2 → by_framework_langgraph-0.0.3.dev4}/tests/test_adapter.py
RENAMED
|
@@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
7
7
|
import pytest
|
|
8
8
|
from by_framework.core.protocol.commands import AskAgentCommand, ResumeCommand
|
|
9
9
|
from by_framework.core.protocol.message_header import MessageHeader
|
|
10
|
+
from langchain_core.messages import AIMessage, ToolMessage
|
|
10
11
|
|
|
11
12
|
from by_framework_langgraph.adapter import (
|
|
12
13
|
LangGraphAdapter,
|
|
@@ -23,6 +24,7 @@ def _make_mock_context(session_id: str = "test-session"):
|
|
|
23
24
|
ctx.message_id = "msg-ctx"
|
|
24
25
|
ctx.parent_message_id = "parent-ctx"
|
|
25
26
|
ctx.current_agent_id = "planner"
|
|
27
|
+
ctx.worker_id = "worker-langgraph-1"
|
|
26
28
|
ctx.redis = AsyncMock()
|
|
27
29
|
ctx.emit_chunk = AsyncMock()
|
|
28
30
|
ctx.ask_user = AsyncMock()
|
|
@@ -116,7 +118,7 @@ class TestAdapterRun:
|
|
|
116
118
|
ctx = _make_mock_context()
|
|
117
119
|
graph = MagicMock()
|
|
118
120
|
graph.ainvoke = AsyncMock(
|
|
119
|
-
return_value={"messages": [
|
|
121
|
+
return_value={"messages": [AIMessage(content="done")]}
|
|
120
122
|
)
|
|
121
123
|
# Not suspended
|
|
122
124
|
snapshot = MagicMock()
|
|
@@ -142,7 +144,7 @@ class TestAdapterRun:
|
|
|
142
144
|
ctx = _make_mock_context()
|
|
143
145
|
graph = MagicMock()
|
|
144
146
|
graph.ainvoke = AsyncMock(
|
|
145
|
-
return_value={"messages": [
|
|
147
|
+
return_value={"messages": [AIMessage(content="hello")]}
|
|
146
148
|
)
|
|
147
149
|
snapshot = MagicMock()
|
|
148
150
|
snapshot.next = ()
|
|
@@ -165,7 +167,7 @@ class TestAdapterRun:
|
|
|
165
167
|
ctx = _make_mock_context()
|
|
166
168
|
graph = MagicMock()
|
|
167
169
|
graph.ainvoke = AsyncMock(
|
|
168
|
-
return_value={"messages": [
|
|
170
|
+
return_value={"messages": [AIMessage(content="partial")]}
|
|
169
171
|
)
|
|
170
172
|
snapshot = MagicMock()
|
|
171
173
|
snapshot.next = ("tools",)
|
|
@@ -182,6 +184,36 @@ class TestAdapterRun:
|
|
|
182
184
|
assert isinstance(result, dict)
|
|
183
185
|
assert result["status"] == "QUEUED"
|
|
184
186
|
|
|
187
|
+
@pytest.mark.asyncio
|
|
188
|
+
async def test_finds_the_last_ai_message_when_messages_end_on_a_tool_message(
|
|
189
|
+
self,
|
|
190
|
+
):
|
|
191
|
+
# Regression: a misconfigured graph (e.g. a routing function that
|
|
192
|
+
# returns the string "end" instead of the END constant) can
|
|
193
|
+
# terminate right after a tool call instead of looping back to the
|
|
194
|
+
# agent node — leaving a ToolMessage as messages[-1]. Blindly
|
|
195
|
+
# returning messages[-1].content would surface the tool's raw
|
|
196
|
+
# result as the "final answer" instead of the model's actual reply.
|
|
197
|
+
ctx = _make_mock_context()
|
|
198
|
+
graph = MagicMock()
|
|
199
|
+
graph.ainvoke = AsyncMock(
|
|
200
|
+
return_value={
|
|
201
|
+
"messages": [
|
|
202
|
+
AIMessage(content="计算结果为:300", tool_calls=[]),
|
|
203
|
+
ToolMessage(content="300", tool_call_id="call_1"),
|
|
204
|
+
]
|
|
205
|
+
}
|
|
206
|
+
)
|
|
207
|
+
snapshot = MagicMock()
|
|
208
|
+
snapshot.next = ()
|
|
209
|
+
graph.get_state.return_value = snapshot
|
|
210
|
+
|
|
211
|
+
adapter = LangGraphAdapter(graph, ctx, stream=False)
|
|
212
|
+
cmd = AskAgentCommand(header=_make_header(), content="算一下 25*(4+8)")
|
|
213
|
+
result = await adapter.run(cmd)
|
|
214
|
+
|
|
215
|
+
assert result == "计算结果为:300"
|
|
216
|
+
|
|
185
217
|
@pytest.mark.asyncio
|
|
186
218
|
async def test_includes_langfuse_callbacks_and_parent_trace_context(
|
|
187
219
|
self, monkeypatch
|
|
@@ -190,7 +222,7 @@ class TestAdapterRun:
|
|
|
190
222
|
ctx = _make_mock_context()
|
|
191
223
|
graph = MagicMock()
|
|
192
224
|
graph.ainvoke = AsyncMock(
|
|
193
|
-
return_value={"messages": [
|
|
225
|
+
return_value={"messages": [AIMessage(content="hello")]}
|
|
194
226
|
)
|
|
195
227
|
snapshot = MagicMock()
|
|
196
228
|
snapshot.next = ()
|
|
@@ -225,6 +257,7 @@ class TestAdapterRun:
|
|
|
225
257
|
assert config["metadata"]["langfuse_session_id"] == "test-session"
|
|
226
258
|
assert config["metadata"]["langfuse_user_id"] == "user-1"
|
|
227
259
|
assert config["metadata"]["by_framework_message_id"] == "msg-ctx"
|
|
260
|
+
assert config["metadata"]["worker_id"] == "worker-langgraph-1"
|
|
228
261
|
assert config["metadata"]["langgraph_thread_id"] == "test-session"
|
|
229
262
|
# Callbacks list includes the Langfuse handler(s) plus the token accumulator.
|
|
230
263
|
assert callback_handler in config["callbacks"]
|
|
@@ -239,6 +272,63 @@ class TestAdapterRun:
|
|
|
239
272
|
}
|
|
240
273
|
]
|
|
241
274
|
|
|
275
|
+
@pytest.mark.asyncio
|
|
276
|
+
async def test_propagates_worker_id_to_langfuse_child_observations(
|
|
277
|
+
self, monkeypatch
|
|
278
|
+
):
|
|
279
|
+
"""Langfuse attribute propagation covers nested LangGraph observations."""
|
|
280
|
+
ctx = _make_mock_context()
|
|
281
|
+
graph = MagicMock()
|
|
282
|
+
snapshot = MagicMock()
|
|
283
|
+
snapshot.next = ()
|
|
284
|
+
graph.get_state.return_value = snapshot
|
|
285
|
+
|
|
286
|
+
propagation_events: list[tuple[str, dict[str, str]]] = []
|
|
287
|
+
propagation_active = False
|
|
288
|
+
|
|
289
|
+
class FakePropagation:
|
|
290
|
+
|
|
291
|
+
def __init__(self, metadata):
|
|
292
|
+
self.metadata = metadata
|
|
293
|
+
|
|
294
|
+
def __enter__(self):
|
|
295
|
+
nonlocal propagation_active
|
|
296
|
+
propagation_active = True
|
|
297
|
+
propagation_events.append(("enter", self.metadata))
|
|
298
|
+
|
|
299
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
300
|
+
nonlocal propagation_active
|
|
301
|
+
propagation_events.append(("exit", self.metadata))
|
|
302
|
+
propagation_active = False
|
|
303
|
+
|
|
304
|
+
def fake_propagate_attributes(**kwargs):
|
|
305
|
+
return FakePropagation(metadata=kwargs["metadata"])
|
|
306
|
+
|
|
307
|
+
async def fake_ainvoke(input_data, *, config):
|
|
308
|
+
del input_data
|
|
309
|
+
assert config["metadata"]["worker_id"] == "worker-langgraph-1"
|
|
310
|
+
assert propagation_active is True
|
|
311
|
+
return {"messages": [AIMessage(content="hello")]}
|
|
312
|
+
|
|
313
|
+
graph.ainvoke = fake_ainvoke
|
|
314
|
+
monkeypatch.setitem(
|
|
315
|
+
sys.modules,
|
|
316
|
+
"langfuse",
|
|
317
|
+
SimpleNamespace(propagate_attributes=fake_propagate_attributes),
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
adapter = LangGraphAdapter(graph, ctx, stream=False)
|
|
321
|
+
|
|
322
|
+
result = await adapter.run(
|
|
323
|
+
AskAgentCommand(header=_make_header(), content="write a poem")
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
assert result == "hello"
|
|
327
|
+
assert propagation_events == [
|
|
328
|
+
("enter", {"worker_id": "worker-langgraph-1"}),
|
|
329
|
+
("exit", {"worker_id": "worker-langgraph-1"}),
|
|
330
|
+
]
|
|
331
|
+
|
|
242
332
|
@pytest.mark.asyncio
|
|
243
333
|
async def test_skips_langfuse_callback_when_provider_package_missing(
|
|
244
334
|
self, monkeypatch
|
|
@@ -275,7 +365,7 @@ class TestAdapterRun:
|
|
|
275
365
|
ctx = ContextWithoutProvider()
|
|
276
366
|
graph = MagicMock()
|
|
277
367
|
graph.ainvoke = AsyncMock(
|
|
278
|
-
return_value={"messages": [
|
|
368
|
+
return_value={"messages": [AIMessage(content="hello")]}
|
|
279
369
|
)
|
|
280
370
|
snapshot = MagicMock()
|
|
281
371
|
snapshot.next = ()
|
|
@@ -299,7 +389,7 @@ class TestAdapterRun:
|
|
|
299
389
|
ctx = _make_mock_context()
|
|
300
390
|
graph = MagicMock()
|
|
301
391
|
graph.ainvoke = AsyncMock(
|
|
302
|
-
return_value={"messages": [
|
|
392
|
+
return_value={"messages": [AIMessage(content="hello")]}
|
|
303
393
|
)
|
|
304
394
|
snapshot = MagicMock()
|
|
305
395
|
snapshot.next = ()
|
|
@@ -342,7 +432,7 @@ class TestAdapterRun:
|
|
|
342
432
|
ctx = _make_mock_context()
|
|
343
433
|
graph = MagicMock()
|
|
344
434
|
graph.ainvoke = AsyncMock(
|
|
345
|
-
return_value={"messages": [
|
|
435
|
+
return_value={"messages": [AIMessage(content="hello")]}
|
|
346
436
|
)
|
|
347
437
|
snapshot = MagicMock()
|
|
348
438
|
snapshot.next = ()
|
|
@@ -515,3 +605,171 @@ class TestTokenAccumulatingCallbackHandler:
|
|
|
515
605
|
assert any(
|
|
516
606
|
isinstance(cb, _TokenAccumulatingCallbackHandler) for cb in callbacks
|
|
517
607
|
)
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
async def _async_events(events):
|
|
611
|
+
"""Build an async generator yielding `events`, standing in for
|
|
612
|
+
`graph.astream_events(...)` in the streaming tests below."""
|
|
613
|
+
for event in events:
|
|
614
|
+
yield event
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
class TestStreamInvokeFallback:
|
|
618
|
+
"""Tests for _stream_invoke preferring the checkpointed graph state.
|
|
619
|
+
|
|
620
|
+
Regression: on_chat_model_stream isn't guaranteed to fire for every LLM
|
|
621
|
+
call a graph makes — a model that narrates before calling a tool can
|
|
622
|
+
populate full_response with just that preamble (a round that DID
|
|
623
|
+
stream), while the actual final answer's round, after the tool
|
|
624
|
+
executes, doesn't stream at all. full_response ends up non-empty but
|
|
625
|
+
wrong, so an empty-only fallback check can't catch it. The graph
|
|
626
|
+
state's messages[-1] — the canonical checkpointed state, not the
|
|
627
|
+
streamed text, which is only a best-effort UI side channel — must win
|
|
628
|
+
whenever it's available, not just when full_response is empty.
|
|
629
|
+
"""
|
|
630
|
+
|
|
631
|
+
@pytest.mark.asyncio
|
|
632
|
+
async def test_empty_stream_falls_back_to_graph_state(self):
|
|
633
|
+
ctx = _make_mock_context()
|
|
634
|
+
graph = MagicMock()
|
|
635
|
+
# No on_chat_model_stream events at all — e.g. a tool-call round
|
|
636
|
+
# (on_tool_start/on_tool_end only) whose follow-up answer never
|
|
637
|
+
# streamed through on_chat_model_stream.
|
|
638
|
+
graph.astream_events = MagicMock(return_value=_async_events([]))
|
|
639
|
+
snapshot = MagicMock()
|
|
640
|
+
snapshot.next = ()
|
|
641
|
+
snapshot.values = {"messages": [AIMessage(content="the final answer")]}
|
|
642
|
+
graph.get_state.return_value = snapshot
|
|
643
|
+
|
|
644
|
+
adapter = LangGraphAdapter(graph, ctx, stream=True)
|
|
645
|
+
cmd = AskAgentCommand(header=_make_header(), content="what's 1+1?")
|
|
646
|
+
result = await adapter.run(cmd)
|
|
647
|
+
|
|
648
|
+
assert result == "the final answer"
|
|
649
|
+
|
|
650
|
+
@pytest.mark.asyncio
|
|
651
|
+
async def test_finds_the_last_ai_message_when_state_ends_on_a_tool_message(self):
|
|
652
|
+
# Same regression as TestAdapterRun's equivalent, for the streaming
|
|
653
|
+
# path's graph-state extraction.
|
|
654
|
+
ctx = _make_mock_context()
|
|
655
|
+
graph = MagicMock()
|
|
656
|
+
graph.astream_events = MagicMock(return_value=_async_events([]))
|
|
657
|
+
snapshot = MagicMock()
|
|
658
|
+
snapshot.next = ()
|
|
659
|
+
snapshot.values = {
|
|
660
|
+
"messages": [
|
|
661
|
+
AIMessage(content="计算结果为:300", tool_calls=[]),
|
|
662
|
+
ToolMessage(content="300", tool_call_id="call_1"),
|
|
663
|
+
]
|
|
664
|
+
}
|
|
665
|
+
graph.get_state.return_value = snapshot
|
|
666
|
+
|
|
667
|
+
adapter = LangGraphAdapter(graph, ctx, stream=True)
|
|
668
|
+
cmd = AskAgentCommand(header=_make_header(), content="算一下 25*(4+8)")
|
|
669
|
+
result = await adapter.run(cmd)
|
|
670
|
+
|
|
671
|
+
assert result == "计算结果为:300"
|
|
672
|
+
|
|
673
|
+
@pytest.mark.asyncio
|
|
674
|
+
async def test_graph_state_overrides_a_non_empty_but_stale_preamble(self):
|
|
675
|
+
# Regression: the model narrates ("好的,我来帮你计算...") before
|
|
676
|
+
# calling a tool — that preamble streams fine via
|
|
677
|
+
# on_chat_model_stream, so full_response is non-empty — but the
|
|
678
|
+
# actual final answer's round (after the tool executes) never
|
|
679
|
+
# streams. The stale preamble must not win just because it's
|
|
680
|
+
# non-empty; graph state's last message is the real answer.
|
|
681
|
+
ctx = _make_mock_context()
|
|
682
|
+
graph = MagicMock()
|
|
683
|
+
graph.astream_events = MagicMock(
|
|
684
|
+
return_value=_async_events(
|
|
685
|
+
[
|
|
686
|
+
{
|
|
687
|
+
"event": "on_chat_model_stream",
|
|
688
|
+
"data": {
|
|
689
|
+
"chunk": SimpleNamespace(content="好的,我来帮你计算...")
|
|
690
|
+
},
|
|
691
|
+
},
|
|
692
|
+
]
|
|
693
|
+
)
|
|
694
|
+
)
|
|
695
|
+
snapshot = MagicMock()
|
|
696
|
+
snapshot.next = ()
|
|
697
|
+
snapshot.values = {
|
|
698
|
+
"messages": [AIMessage(content="计算结果为:25 x (4 + 8) = 300")]
|
|
699
|
+
}
|
|
700
|
+
graph.get_state.return_value = snapshot
|
|
701
|
+
|
|
702
|
+
adapter = LangGraphAdapter(graph, ctx, stream=True)
|
|
703
|
+
cmd = AskAgentCommand(header=_make_header(), content="算一下 25 * (4 + 8)")
|
|
704
|
+
result = await adapter.run(cmd)
|
|
705
|
+
|
|
706
|
+
assert result == "计算结果为:25 x (4 + 8) = 300"
|
|
707
|
+
|
|
708
|
+
@pytest.mark.asyncio
|
|
709
|
+
async def test_streamed_text_is_used_when_graph_state_has_no_messages(self):
|
|
710
|
+
ctx = _make_mock_context()
|
|
711
|
+
graph = MagicMock()
|
|
712
|
+
graph.astream_events = MagicMock(
|
|
713
|
+
return_value=_async_events(
|
|
714
|
+
[
|
|
715
|
+
{
|
|
716
|
+
"event": "on_chat_model_stream",
|
|
717
|
+
"data": {"chunk": SimpleNamespace(content="hello")},
|
|
718
|
+
},
|
|
719
|
+
{
|
|
720
|
+
"event": "on_chat_model_stream",
|
|
721
|
+
"data": {"chunk": SimpleNamespace(content=" world")},
|
|
722
|
+
},
|
|
723
|
+
]
|
|
724
|
+
)
|
|
725
|
+
)
|
|
726
|
+
snapshot = MagicMock()
|
|
727
|
+
snapshot.next = ()
|
|
728
|
+
snapshot.values = {}
|
|
729
|
+
graph.get_state.return_value = snapshot
|
|
730
|
+
|
|
731
|
+
adapter = LangGraphAdapter(graph, ctx, stream=True)
|
|
732
|
+
cmd = AskAgentCommand(header=_make_header(), content="hi")
|
|
733
|
+
result = await adapter.run(cmd)
|
|
734
|
+
|
|
735
|
+
assert result == "hello world"
|
|
736
|
+
|
|
737
|
+
@pytest.mark.asyncio
|
|
738
|
+
async def test_returns_empty_string_when_neither_streaming_nor_state_have_text(
|
|
739
|
+
self,
|
|
740
|
+
):
|
|
741
|
+
ctx = _make_mock_context()
|
|
742
|
+
graph = MagicMock()
|
|
743
|
+
graph.astream_events = MagicMock(return_value=_async_events([]))
|
|
744
|
+
snapshot = MagicMock()
|
|
745
|
+
snapshot.next = ()
|
|
746
|
+
snapshot.values = {}
|
|
747
|
+
graph.get_state.return_value = snapshot
|
|
748
|
+
|
|
749
|
+
adapter = LangGraphAdapter(graph, ctx, stream=True)
|
|
750
|
+
cmd = AskAgentCommand(header=_make_header(), content="hi")
|
|
751
|
+
result = await adapter.run(cmd)
|
|
752
|
+
|
|
753
|
+
assert result == ""
|
|
754
|
+
|
|
755
|
+
@pytest.mark.asyncio
|
|
756
|
+
async def test_falls_back_to_streamed_text_when_get_state_errors(self):
|
|
757
|
+
ctx = _make_mock_context()
|
|
758
|
+
graph = MagicMock()
|
|
759
|
+
graph.astream_events = MagicMock(
|
|
760
|
+
return_value=_async_events(
|
|
761
|
+
[
|
|
762
|
+
{
|
|
763
|
+
"event": "on_chat_model_stream",
|
|
764
|
+
"data": {"chunk": SimpleNamespace(content="hello")},
|
|
765
|
+
},
|
|
766
|
+
]
|
|
767
|
+
)
|
|
768
|
+
)
|
|
769
|
+
graph.get_state.side_effect = RuntimeError("no checkpoint")
|
|
770
|
+
|
|
771
|
+
adapter = LangGraphAdapter(graph, ctx, stream=True)
|
|
772
|
+
cmd = AskAgentCommand(header=_make_header(), content="hi")
|
|
773
|
+
result = await adapter.run(cmd)
|
|
774
|
+
|
|
775
|
+
assert result == "hello"
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
# by-framework-langgraph
|
|
2
|
-
|
|
3
|
-
LangGraph integration for by-framework. Provides two integration modes:
|
|
4
|
-
|
|
5
|
-
1. **Adapter Mode** — Plug existing LangGraph graphs into by-framework with one line
|
|
6
|
-
2. **Native Mode** — Build LangGraph workers with native `call_agent` / `ask_user` / `resume` support
|
|
7
|
-
|
|
8
|
-
## Installation
|
|
9
|
-
|
|
10
|
-
```bash
|
|
11
|
-
uv add by-framework-langgraph
|
|
12
|
-
```
|
|
13
|
-
|
|
14
|
-
## Quick Start
|
|
15
|
-
|
|
16
|
-
### Adapter Mode — Plug in existing graphs
|
|
17
|
-
|
|
18
|
-
```python
|
|
19
|
-
from by_framework.worker import ByaiWorker
|
|
20
|
-
from by_framework_langgraph import LangGraphAdapter
|
|
21
|
-
|
|
22
|
-
class MyWorker(ByaiWorker):
|
|
23
|
-
def get_agent_types(self):
|
|
24
|
-
return ["my-agent"]
|
|
25
|
-
|
|
26
|
-
async def process_command(self, command, context):
|
|
27
|
-
graph = build_my_existing_graph() # your existing LangGraph
|
|
28
|
-
adapter = LangGraphAdapter(graph, context)
|
|
29
|
-
return await adapter.run(command)
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
### Native Mode — Framework-native LangGraph workers
|
|
33
|
-
|
|
34
|
-
```python
|
|
35
|
-
from by_framework_langgraph import LangGraphWorker, make_remote_agent_tool, make_ask_user_tool
|
|
36
|
-
|
|
37
|
-
class OrchestratorWorker(LangGraphWorker):
|
|
38
|
-
def get_agent_types(self):
|
|
39
|
-
return ["orchestrator"]
|
|
40
|
-
|
|
41
|
-
def build_graph(self, context, command):
|
|
42
|
-
poet = make_remote_agent_tool(context, "invoke_poet", "poet-agent", "调度诗人创作")
|
|
43
|
-
ask = make_ask_user_tool(context)
|
|
44
|
-
llm = ChatOpenAI(model="gpt-4o").bind_tools([poet, ask])
|
|
45
|
-
# ... build and return compiled graph
|
|
46
|
-
```
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|