mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-25 22:16:19 +00:00
fix(client): emit text a later node appends to an AI message already sent (#5479)
* fix(client): emit text a later node appends to an AI message already sent Loop detection, the token budget, safety termination, subagent limits and the terminal-response fallback rewrite the last AI message under the same id in their own after_model node. stream() skipped every id it had seen, so chat(), the TUI and --print kept the text from before the rewrite: no [FORCED STOP] notice, no fallback error, and token_usage_attribution added after the model node never arrived. Look again at a known id when a snapshot holds a different message object, emit only the text appended to what was already sent, and send new additional_kwargs as the existing metadata-only follow-up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(client): pin that a non-extending replacement is not re-sent Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
de02eeb856
commit
d87a1c1193
@ -851,7 +851,10 @@ class DeerFlowClient:
|
|||||||
message. ``values`` events continue to carry full state snapshots
|
message. ``values`` events continue to carry full state snapshots
|
||||||
after each graph node finishes; AI text already delivered via the
|
after each graph node finishes; AI text already delivered via the
|
||||||
``messages`` stream is **not** re-synthesized from the snapshot to
|
``messages`` stream is **not** re-synthesized from the snapshot to
|
||||||
avoid duplicate deliveries.
|
avoid duplicate deliveries. When a later node replaces a delivered AI
|
||||||
|
message under the same id and appends to its text (a guard's stop
|
||||||
|
notice), only the appended text is emitted, as one more delta for that
|
||||||
|
id; new ``additional_kwargs`` arrive as a metadata-only follow-up.
|
||||||
|
|
||||||
Why not reuse Gateway's ``run_agent``?
|
Why not reuse Gateway's ``run_agent``?
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
@ -969,7 +972,13 @@ class DeerFlowClient:
|
|||||||
if self._agent_name:
|
if self._agent_name:
|
||||||
context["agent_name"] = self._agent_name
|
context["agent_name"] = self._agent_name
|
||||||
|
|
||||||
seen_ids: set[str] = set()
|
# Last message object seen per id in ``values`` snapshots. A later node,
|
||||||
|
# such as a guard's ``after_model``, can replace a message under the same
|
||||||
|
# id, so a different object for a known id is looked at again.
|
||||||
|
seen_messages: dict[str, Any] = {}
|
||||||
|
# AI text already emitted per id. A replacement that appends to it (a
|
||||||
|
# guard's stop notice) only needs the part that was added.
|
||||||
|
sent_text_by_id: dict[str, str] = {}
|
||||||
# Cross-mode handoff: ids already streamed via LangGraph ``messages``
|
# Cross-mode handoff: ids already streamed via LangGraph ``messages``
|
||||||
# mode so the ``values`` path skips re-synthesis of the same message.
|
# mode so the ``values`` path skips re-synthesis of the same message.
|
||||||
streamed_ids: set[str] = set()
|
streamed_ids: set[str] = set()
|
||||||
@ -1060,6 +1069,7 @@ class DeerFlowClient:
|
|||||||
if text:
|
if text:
|
||||||
if msg_id:
|
if msg_id:
|
||||||
streamed_ids.add(msg_id)
|
streamed_ids.add(msg_id)
|
||||||
|
sent_text_by_id[msg_id] = sent_text_by_id.get(msg_id, "") + text
|
||||||
additional_kwargs_delta = _unsent_additional_kwargs(msg_id, additional_kwargs)
|
additional_kwargs_delta = _unsent_additional_kwargs(msg_id, additional_kwargs)
|
||||||
yield self._ai_text_event(
|
yield self._ai_text_event(
|
||||||
msg_id,
|
msg_id,
|
||||||
@ -1095,10 +1105,24 @@ class DeerFlowClient:
|
|||||||
|
|
||||||
for msg in messages:
|
for msg in messages:
|
||||||
msg_id = getattr(msg, "id", None)
|
msg_id = getattr(msg, "id", None)
|
||||||
if msg_id and msg_id in seen_ids:
|
if msg_id and msg_id in seen_messages:
|
||||||
|
if seen_messages[msg_id] is msg:
|
||||||
|
continue
|
||||||
|
seen_messages[msg_id] = msg
|
||||||
|
if isinstance(msg, AIMessage):
|
||||||
|
# Replaced after it was sent. Emit text appended to what
|
||||||
|
# was already sent, plus any new metadata.
|
||||||
|
text = self._extract_text(msg.content)
|
||||||
|
sent_text = sent_text_by_id.get(msg_id, "")
|
||||||
|
additional_kwargs_delta = _unsent_additional_kwargs(msg_id, self._serialize_additional_kwargs(msg))
|
||||||
|
if len(text) > len(sent_text) and text.startswith(sent_text):
|
||||||
|
sent_text_by_id[msg_id] = text
|
||||||
|
yield self._ai_text_event(msg_id, text[len(sent_text) :], None, additional_kwargs_delta)
|
||||||
|
elif additional_kwargs_delta:
|
||||||
|
yield self._ai_text_event(msg_id, "", None, additional_kwargs_delta)
|
||||||
continue
|
continue
|
||||||
if msg_id:
|
if msg_id:
|
||||||
seen_ids.add(msg_id)
|
seen_messages[msg_id] = msg
|
||||||
|
|
||||||
# Already streamed via ``messages`` mode; only (defensively)
|
# Already streamed via ``messages`` mode; only (defensively)
|
||||||
# capture usage here and skip re-synthesizing the event.
|
# capture usage here and skip re-synthesizing the event.
|
||||||
@ -1134,6 +1158,8 @@ class DeerFlowClient:
|
|||||||
|
|
||||||
text = self._extract_text(msg.content)
|
text = self._extract_text(msg.content)
|
||||||
if text:
|
if text:
|
||||||
|
if msg_id:
|
||||||
|
sent_text_by_id[msg_id] = text
|
||||||
additional_kwargs_delta = None if sent_additional_kwargs else _unsent_additional_kwargs(msg_id, additional_kwargs)
|
additional_kwargs_delta = None if sent_additional_kwargs else _unsent_additional_kwargs(msg_id, additional_kwargs)
|
||||||
yield self._ai_text_event(
|
yield self._ai_text_event(
|
||||||
msg_id,
|
msg_id,
|
||||||
|
|||||||
@ -704,6 +704,68 @@ class TestStream:
|
|||||||
assert any(event.data.get("content") == "Hello!" for event in ai_events)
|
assert any(event.data.get("content") == "Hello!" for event in ai_events)
|
||||||
assert any(event.data.get("additional_kwargs", {}).get("token_usage_attribution", {}).get("kind") == "final_answer" for event in ai_events)
|
assert any(event.data.get("additional_kwargs", {}).get("token_usage_attribution", {}).get("kind") == "final_answer" for event in ai_events)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("streamed", [True, False])
|
||||||
|
def test_stream_emits_text_a_later_node_appends_to_a_sent_ai_message(self, client, streamed):
|
||||||
|
"""A guard's ``after_model`` replaces the message under the same id after it was sent."""
|
||||||
|
call = {"name": "bash", "args": {"command": "ls"}, "id": "call-1"}
|
||||||
|
sent = AIMessage(content="Checking again.", id="ai-1", tool_calls=[call])
|
||||||
|
stopped = AIMessage(content="Checking again.\n\n[FORCED STOP] Repeated tool calls exceeded the safety limit.", id="ai-1")
|
||||||
|
chunks = [
|
||||||
|
("values", {"messages": [HumanMessage(content="hi", id="h-1"), sent]}),
|
||||||
|
("values", {"messages": [HumanMessage(content="hi", id="h-1"), stopped]}),
|
||||||
|
("values", {"messages": [HumanMessage(content="hi", id="h-1"), stopped]}),
|
||||||
|
]
|
||||||
|
if streamed:
|
||||||
|
chunks.insert(0, ("messages", (AIMessageChunk(content="Checking again.", id="ai-1"), {})))
|
||||||
|
agent = _make_agent_mock(chunks)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(client, "_ensure_agent"),
|
||||||
|
patch.object(client, "_agent", agent),
|
||||||
|
):
|
||||||
|
events = list(client.stream("hi", thread_id="t-stream-replaced"))
|
||||||
|
|
||||||
|
assert [event.data["content"] for event in _ai_events(events)] == ["Checking again.", "\n\n[FORCED STOP] Repeated tool calls exceeded the safety limit."]
|
||||||
|
|
||||||
|
def test_stream_does_not_resend_a_replacement_that_does_not_extend_the_sent_text(self, client):
|
||||||
|
"""Only appended text is sent; a rewrite of what was already sent would duplicate output."""
|
||||||
|
sent = AIMessage(content="Let me look.", id="ai-1")
|
||||||
|
rewritten = AIMessage(content="The model returned no final response.", id="ai-1")
|
||||||
|
agent = _make_agent_mock(
|
||||||
|
[
|
||||||
|
("values", {"messages": [HumanMessage(content="hi", id="h-1"), sent]}),
|
||||||
|
("values", {"messages": [HumanMessage(content="hi", id="h-1"), rewritten]}),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(client, "_ensure_agent"),
|
||||||
|
patch.object(client, "_agent", agent),
|
||||||
|
):
|
||||||
|
events = list(client.stream("hi", thread_id="t-stream-rewritten"))
|
||||||
|
|
||||||
|
assert [event.data["content"] for event in _ai_events(events)] == ["Let me look."]
|
||||||
|
|
||||||
|
def test_stream_emits_metadata_a_later_node_adds_to_a_sent_ai_message(self, client):
|
||||||
|
attribution = {"version": 1, "kind": "final_answer", "shared_attribution": False, "actions": []}
|
||||||
|
sent = AIMessage(content="Hello!", id="ai-1")
|
||||||
|
attributed = AIMessage(content="Hello!", id="ai-1", additional_kwargs={"token_usage_attribution": attribution})
|
||||||
|
agent = _make_agent_mock(
|
||||||
|
[
|
||||||
|
("values", {"messages": [HumanMessage(content="hi", id="h-1"), sent]}),
|
||||||
|
("values", {"messages": [HumanMessage(content="hi", id="h-1"), attributed]}),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(client, "_ensure_agent"),
|
||||||
|
patch.object(client, "_agent", agent),
|
||||||
|
):
|
||||||
|
events = list(client.stream("hi", thread_id="t-stream-attributed"))
|
||||||
|
|
||||||
|
ai_events = [event for event in events if event.type == "messages-tuple" and event.data.get("type") == "ai"]
|
||||||
|
assert [(event.data["content"], event.data.get("additional_kwargs")) for event in ai_events] == [("Hello!", None), ("", {"token_usage_attribution": attribution})]
|
||||||
|
|
||||||
def test_stream_emits_new_additional_kwargs_after_prior_metadata(self, client):
|
def test_stream_emits_new_additional_kwargs_after_prior_metadata(self, client):
|
||||||
"""stream() emits later attribution metadata even after earlier kwargs for the same id."""
|
"""stream() emits later attribution metadata even after earlier kwargs for the same id."""
|
||||||
attribution = {
|
attribution = {
|
||||||
@ -1132,6 +1194,40 @@ class TestChat:
|
|||||||
|
|
||||||
assert result == "final answer"
|
assert result == "final answer"
|
||||||
|
|
||||||
|
def test_returns_the_loop_detection_stop_notice(self, client):
|
||||||
|
"""Real graph: the hard stop rewrites the last AI message after it was sent."""
|
||||||
|
from langchain.agents import create_agent
|
||||||
|
from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel
|
||||||
|
from langchain_core.tools import tool
|
||||||
|
|
||||||
|
from deerflow.agents.middlewares.loop_detection_middleware import LoopDetectionMiddleware
|
||||||
|
|
||||||
|
class _ToolCallingFakeModel(FakeMessagesListChatModel):
|
||||||
|
def bind_tools(self, tools, **kwargs):
|
||||||
|
return self
|
||||||
|
|
||||||
|
@tool
|
||||||
|
def bash(command: str) -> str:
|
||||||
|
"""Run a command."""
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
responses = [AIMessage(content="Checking again.", id=f"ai-{i}", tool_calls=[{"name": "bash", "args": {"command": "ls"}, "id": f"call-{i}"}]) for i in range(3)]
|
||||||
|
graph = create_agent(
|
||||||
|
model=_ToolCallingFakeModel(responses=[*responses, AIMessage(content="unreachable", id="ai-end")]),
|
||||||
|
tools=[bash],
|
||||||
|
middleware=[LoopDetectionMiddleware(warn_threshold=10, hard_limit=3)],
|
||||||
|
state_schema=ThreadState,
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(client, "_ensure_agent"),
|
||||||
|
patch.object(client, "_agent", graph),
|
||||||
|
):
|
||||||
|
result = client.chat("q", thread_id="t-loop-stop")
|
||||||
|
|
||||||
|
assert result.startswith("Checking again.")
|
||||||
|
assert "[FORCED STOP]" in result
|
||||||
|
|
||||||
def test_empty_response(self, client):
|
def test_empty_response(self, client):
|
||||||
"""chat() returns empty string if no AI message produced."""
|
"""chat() returns empty string if no AI message produced."""
|
||||||
chunks = [{"messages": []}]
|
chunks = [{"messages": []}]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user