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.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: by-framework-langgraph
3
- Version: 0.0.3.dev2
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
+ ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "by-framework-langgraph"
3
- version = "0.0.3.dev2"
3
+ version = "0.0.3.dev4"
4
4
  description = "LangGraph integration for by-framework"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -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 message
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
- last_msg = messages[-1]
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."""
@@ -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": [MagicMock(content="done")]}
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": [MagicMock(content="hello")]}
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": [MagicMock(content="partial")]}
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": [MagicMock(content="hello")]}
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": [MagicMock(content="hello")]}
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": [MagicMock(content="hello")]}
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": [MagicMock(content="hello")]}
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
- ```