by-framework-langgraph 0.0.3.dev2__py3-none-any.whl → 0.0.3.dev4__py3-none-any.whl

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.
@@ -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."""
@@ -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
+ ```
@@ -1,8 +1,8 @@
1
1
  by_framework_langgraph/__init__.py,sha256=x08wIaK4VRnUnUD_mbBxVgI2Zf7CvwAVbr3syP2NQps,1046
2
2
  by_framework_langgraph/_utils.py,sha256=A8eQ19YqyBiMKcPIlzmwggN_mK5iQvNJPTaTmsywWsw,1605
3
- by_framework_langgraph/adapter.py,sha256=b4WcK6R3B0Y7a52lIY6kMLtpp9mjXOkp4p6HcALAzbA,26689
3
+ by_framework_langgraph/adapter.py,sha256=3ZYzpcbWAo_-EmQ9QzxYE7e_dxnfThiHk0wDqsF5OiA,29859
4
4
  by_framework_langgraph/tools.py,sha256=l7LurtKKXA1Z4fyj15ffFWyWpt8drNCJ-mkGmlS7EVo,5561
5
5
  by_framework_langgraph/worker.py,sha256=wFalpBQLqFm-YzJugzAzkA3ciI9lUzxRTv4GmqVCv78,6156
6
- by_framework_langgraph-0.0.3.dev2.dist-info/METADATA,sha256=lzuVDa2l8CUp_af7-VnadrCq-wJgXCWJ2ldcuEwHKRw,2334
7
- by_framework_langgraph-0.0.3.dev2.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
8
- by_framework_langgraph-0.0.3.dev2.dist-info/RECORD,,
6
+ by_framework_langgraph-0.0.3.dev4.dist-info/METADATA,sha256=48vOtcoLYGH-XS9TT6Jg5D1CzxX7ro6auzAi8TlhGHk,3939
7
+ by_framework_langgraph-0.0.3.dev4.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
8
+ by_framework_langgraph-0.0.3.dev4.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.30.1
2
+ Generator: hatchling 1.32.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any