by-framework-langgraph 0.0.3.dev3__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.dev3 → 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.dev3 → by_framework_langgraph-0.0.3.dev4}/pyproject.toml +1 -1
- {by_framework_langgraph-0.0.3.dev3 → by_framework_langgraph-0.0.3.dev4}/src/by_framework_langgraph/adapter.py +49 -4
- {by_framework_langgraph-0.0.3.dev3 → by_framework_langgraph-0.0.3.dev4}/tests/test_adapter.py +207 -8
- by_framework_langgraph-0.0.3.dev3/README.md +0 -46
- {by_framework_langgraph-0.0.3.dev3 → by_framework_langgraph-0.0.3.dev4}/.gitignore +0 -0
- {by_framework_langgraph-0.0.3.dev3 → by_framework_langgraph-0.0.3.dev4}/src/by_framework_langgraph/__init__.py +0 -0
- {by_framework_langgraph-0.0.3.dev3 → by_framework_langgraph-0.0.3.dev4}/src/by_framework_langgraph/_utils.py +0 -0
- {by_framework_langgraph-0.0.3.dev3 → by_framework_langgraph-0.0.3.dev4}/src/by_framework_langgraph/tools.py +0 -0
- {by_framework_langgraph-0.0.3.dev3 → by_framework_langgraph-0.0.3.dev4}/src/by_framework_langgraph/worker.py +0 -0
- {by_framework_langgraph-0.0.3.dev3 → by_framework_langgraph-0.0.3.dev4}/tests/test_tools.py +0 -0
- {by_framework_langgraph-0.0.3.dev3 → 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:
|
{by_framework_langgraph-0.0.3.dev3 → 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,
|
|
@@ -117,7 +118,7 @@ class TestAdapterRun:
|
|
|
117
118
|
ctx = _make_mock_context()
|
|
118
119
|
graph = MagicMock()
|
|
119
120
|
graph.ainvoke = AsyncMock(
|
|
120
|
-
return_value={"messages": [
|
|
121
|
+
return_value={"messages": [AIMessage(content="done")]}
|
|
121
122
|
)
|
|
122
123
|
# Not suspended
|
|
123
124
|
snapshot = MagicMock()
|
|
@@ -143,7 +144,7 @@ class TestAdapterRun:
|
|
|
143
144
|
ctx = _make_mock_context()
|
|
144
145
|
graph = MagicMock()
|
|
145
146
|
graph.ainvoke = AsyncMock(
|
|
146
|
-
return_value={"messages": [
|
|
147
|
+
return_value={"messages": [AIMessage(content="hello")]}
|
|
147
148
|
)
|
|
148
149
|
snapshot = MagicMock()
|
|
149
150
|
snapshot.next = ()
|
|
@@ -166,7 +167,7 @@ class TestAdapterRun:
|
|
|
166
167
|
ctx = _make_mock_context()
|
|
167
168
|
graph = MagicMock()
|
|
168
169
|
graph.ainvoke = AsyncMock(
|
|
169
|
-
return_value={"messages": [
|
|
170
|
+
return_value={"messages": [AIMessage(content="partial")]}
|
|
170
171
|
)
|
|
171
172
|
snapshot = MagicMock()
|
|
172
173
|
snapshot.next = ("tools",)
|
|
@@ -183,6 +184,36 @@ class TestAdapterRun:
|
|
|
183
184
|
assert isinstance(result, dict)
|
|
184
185
|
assert result["status"] == "QUEUED"
|
|
185
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
|
+
|
|
186
217
|
@pytest.mark.asyncio
|
|
187
218
|
async def test_includes_langfuse_callbacks_and_parent_trace_context(
|
|
188
219
|
self, monkeypatch
|
|
@@ -191,7 +222,7 @@ class TestAdapterRun:
|
|
|
191
222
|
ctx = _make_mock_context()
|
|
192
223
|
graph = MagicMock()
|
|
193
224
|
graph.ainvoke = AsyncMock(
|
|
194
|
-
return_value={"messages": [
|
|
225
|
+
return_value={"messages": [AIMessage(content="hello")]}
|
|
195
226
|
)
|
|
196
227
|
snapshot = MagicMock()
|
|
197
228
|
snapshot.next = ()
|
|
@@ -277,7 +308,7 @@ class TestAdapterRun:
|
|
|
277
308
|
del input_data
|
|
278
309
|
assert config["metadata"]["worker_id"] == "worker-langgraph-1"
|
|
279
310
|
assert propagation_active is True
|
|
280
|
-
return {"messages": [
|
|
311
|
+
return {"messages": [AIMessage(content="hello")]}
|
|
281
312
|
|
|
282
313
|
graph.ainvoke = fake_ainvoke
|
|
283
314
|
monkeypatch.setitem(
|
|
@@ -334,7 +365,7 @@ class TestAdapterRun:
|
|
|
334
365
|
ctx = ContextWithoutProvider()
|
|
335
366
|
graph = MagicMock()
|
|
336
367
|
graph.ainvoke = AsyncMock(
|
|
337
|
-
return_value={"messages": [
|
|
368
|
+
return_value={"messages": [AIMessage(content="hello")]}
|
|
338
369
|
)
|
|
339
370
|
snapshot = MagicMock()
|
|
340
371
|
snapshot.next = ()
|
|
@@ -358,7 +389,7 @@ class TestAdapterRun:
|
|
|
358
389
|
ctx = _make_mock_context()
|
|
359
390
|
graph = MagicMock()
|
|
360
391
|
graph.ainvoke = AsyncMock(
|
|
361
|
-
return_value={"messages": [
|
|
392
|
+
return_value={"messages": [AIMessage(content="hello")]}
|
|
362
393
|
)
|
|
363
394
|
snapshot = MagicMock()
|
|
364
395
|
snapshot.next = ()
|
|
@@ -401,7 +432,7 @@ class TestAdapterRun:
|
|
|
401
432
|
ctx = _make_mock_context()
|
|
402
433
|
graph = MagicMock()
|
|
403
434
|
graph.ainvoke = AsyncMock(
|
|
404
|
-
return_value={"messages": [
|
|
435
|
+
return_value={"messages": [AIMessage(content="hello")]}
|
|
405
436
|
)
|
|
406
437
|
snapshot = MagicMock()
|
|
407
438
|
snapshot.next = ()
|
|
@@ -574,3 +605,171 @@ class TestTokenAccumulatingCallbackHandler:
|
|
|
574
605
|
assert any(
|
|
575
606
|
isinstance(cb, _TokenAccumulatingCallbackHandler) for cb in callbacks
|
|
576
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
|