deer-flow/backend/tests/test_worker_stream_subgraph_namespace.py
rayhpeng cd35363a05
fix(history): early user messages vanish or jump mid-run when pagination and context compaction overlap (#4696)
* fix(history): stop dropping user messages that fall outside the loaded page window

Two independent paths made a user's own message disappear from a long thread
(#4666, #4508, #4363). Both are reproduced by a real two-round run: once the
thread passes the 50-row `/messages/page` window AND context compaction fires,
the two sources of truth stop overlapping at the head.

1. Middleware-answered tool results never reached the event store. A middleware
   that short-circuits a tool call (e.g. ReadBeforeWriteMiddleware's blocked
   write) returns a user-visible ToolMessage, but LangChain never emits
   `on_tool_end`, so RunJournal never persisted it — the user saw it during the
   run and it vanished on reload. RunJournal already reconciles final-output
   tool messages, but only for an `ask_clarification` allowlist. The allowlist
   is removed; scope stays bounded by the three conditions that actually matter
   (visible, this run's lead agent, not already persisted), so subagent results
   still stay in their own step feed.

2. mergeMessages discarded the checkpoint prefix before the first shared anchor.
   #4065 correctly established that a summarization-rescued early message must
   not be appended to the tail, and suppressed it instead. That suppression is
   what deletes the message when the first history page no longer reaches back
   to it. It is now woven in before the first shared anchor — the one position
   both the checkpoint and seq-sorted history agree on — so #4065's invariant
   (never the tail) still holds. A collapsed unloaded gap is recoverable by
   paging; a dropped message is not.

Verified against real captured payloads from the reproducing run: the first user
message returns to the transcript. Its exact position is still approximate —
after compaction the live window carries too few anchors to place it precisely,
which only seq-based ordering can close.

Backend: 10809 passed (baseline 10808; same 15 pre-existing failures in
browser/crawler community tools). Frontend: 986 passed, typecheck + eslint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(events): look up a persisted message's seq by identity

Groundwork for placing checkpoint messages in the seq-ordered thread feed
(#4666). A checkpoint carries no seq of its own and loses messages to
summarization, so once the feed's 50-row page window no longer reaches back to a
surviving old message, a client has nothing to place it by. The seq already
exists in run_events keyed by the message id — this exposes it without paging
the whole feed.

`message_identity` is the backend half of the identity rule the frontend applies
in `hooks.ts::messageIdentity`: a ToolMessage is keyed by `tool_call_id`, and
DynamicContextMiddleware's `X` / `X__user` human copies collapse to one identity.
The two halves must stay in sync — a mismatch is silent, degrading placement
rather than raising.

`get_message_seqs` is implemented for all three stores. Misses are absent from
the result rather than an error, so callers degrade to their own placement rule;
the earliest seq wins when one identity resolves to several rows, so a
re-persisted message keeps the position it first occupied. The DB store decodes
rows in Python because `content` is a TEXT column holding a JSON string, not a
JSON column — the identity fields cannot be projected in SQL.

Nothing consumes this yet; no behavior change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(runtime): carry each persisted message's feed seq on values frames

Attaches `additional_kwargs.deerflow_seq` to messages in a root `values` frame
that the thread feed already holds, so a client can place a message the
checkpoint kept but its loaded history page window no longer reaches (#4666).
Nothing is written back to the checkpoint: the seq is added when the frame is
serialized and belongs to that frame only.

Cost is bounded to frames introducing identities the run has not resolved yet.
Messages this run produces are not in the feed while streaming, so they are
looked up once, recorded as misses, and never retried — in a real run the only
frame that pays for a query is the one where compaction brings older messages
back into view. Measured on a reproducing two-round run: 1 lookup across 25
values frames.

The stamper is built once per run rather than per `_stream_once`, or a goal
continuation would discard the resolved seqs. Subgraph frames are not stamped:
a subagent's snapshot is not part of this thread's feed ordering. A lookup
failure logs and leaves the frame unstamped rather than failing it — placement
is an enhancement and clients fall back to their own ordering rule.

Frontend does not read the field yet; no behavior change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(gateway): strip the server-owned message seq from untrusted input

`deerflow_seq` is display metadata the Gateway attaches when it serializes a
values frame. A client replaying messages (regenerate / edit-and-rerun) would
otherwise write it into the checkpoint, where it becomes wrong the moment the
thread is forked — a branch re-seeds its feed and reassigns seq (#4380).

Joins the existing server-owned key set, so it follows the same trusted-internal
rule as the dynamic-context and view-image markers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(frontend): place a checkpoint message by its feed seq, not its nearest anchor

Completes #4666. Weaving a compaction-rescued message before the first shared
anchor keeps it in the transcript, but not in the right place: after compaction
the live window carries too few anchors, and the nearest one can sit deep inside
the loaded page window — measured at row 25 of 50 on a reproducing run, which is
why the first user turn rendered mid-transcript instead of at the head.

Both sides now carry the backend's thread-global seq. `buildVisibleHistoryMessages`
copies each row's `seq` onto the message (same shape as the existing `run_id`),
and the Gateway stamps it onto `values` frame messages it has already persisted.
A live message whose seq is below the loaded window's lower bound is placed ahead
of everything on screen rather than before the nearest anchor. A message with no
seq — still streaming, so not in the feed yet — keeps the weaving path, since the
tail is already its correct position.

Verified against the captured payloads of the reproducing run: the first user
message goes from absent, to #13 (behind the second question), to #0.

Frontend: 988 passed, typecheck + eslint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(frontend): place a pre-window checkpoint message even when no anchor is shared

Also #4666. Placing a compaction-rescued message by its feed seq was gated on
reaching a shared anchor, because the split ran inside the anchor walk. When the
loaded page and the live checkpoint share no identity at all, that walk never
runs and the message fell through to `[...canonical, ...live]` — appended after
the entire window, the one arrangement #4065 proved wrong, with its seq known
the whole time.

That is not a corner case. Open an old, already-summarized conversation and send
a message: the page on screen is the newest rows from before that turn, while
the checkpoint holds the rescued first user turn plus steps of the new run that
are not in the feed yet. On a reproducing run the two sides shared zero anchors
and the user's own first question rendered at row 50 of 50 — the reported
"first message jumps to the bottom".

Split `beforeWindow` out of `live` before walking anchors, walk `liveInWindow`,
and use it for the no-anchor branch as well, so a message routed ahead of the
window is not re-appended at the tail by dedup.

Measured on captured payloads of a reproducing run (real gateway, real
compaction), first user message position:

  no shared anchor:  row 50 -> row 0, seq order monotonic again
  shared anchors:    row 0 -> row 0 (unchanged)
  paged to the top:  row 0 -> row 0 (unchanged)

Regression test verified red-green: reverting the fix fails it with the message
rendered after the window.

Frontend: 989 passed, eslint + tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(gateway): stamp the message feed seq on checkpoint reads, not only on stream frames

Completes #4666. `_MessageSeqStamper` sits on the streaming publish path, so a
client that joins a live run learns where a summarization-rescued turn belongs
while a client that merely opens the conversation does not — and opening is the
common case. `GET /threads/{id}/state` and `POST /threads/{id}/history` returned
the checkpoint with no seq at all, so the merge fell back to the nearest shared
anchor, which after summarization sits deep inside the loaded page.

Reproduced in a browser against a real gateway, on a thread that had already
compacted: the user's first question rendered at row 320 of 389, behind the
newest question instead of at the head. Both reads showed 0 of 13 messages
carrying a seq. That is the reported symptom, still present after the streaming
fix.

Add `stamp_messages_with_seq`, the request-scoped counterpart of the stamper:
everything a checkpoint still holds is already persisted, so one batched lookup
resolves the whole list and there is nothing to retry later. Resolve the store
through `_optional_run_event_store` rather than `get_run_event_store`, because
seq is placement metadata — a deployment without a feed must still be able to
read a thread.

After the fix, on the same thread in the same browser: 13 of 13 messages carry a
seq and the first question renders at the head, ahead of the newest one.

Backend: ruff clean, 326 passed across the touched suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(harness): move the injected-user-id suffix helpers to utils.messages to break an import cycle

message_identity imported strip_injected_user_message_id_suffix from the
dynamic-context middleware, closing a cycle (middleware -> deerflow.runtime
-> worker -> events -> middleware) that only stayed hidden while an earlier
import happened to break it. Define INJECTED_USER_MESSAGE_ID_SUFFIX and the
strip helper in deerflow.utils.messages and re-export them from the
middleware so existing importers keep working.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(docs): improve formatting and clarity in AGENTS.md and message-merge.test.ts

* perf(events): stop the seq scan once every wanted identity is resolved

Rows past the last wanted seq can only be re-persisted copies that
already lose the earliest-seq-wins tiebreak, so all three stores now
break out of the scan (and the db store out of its per-row JSON
decoding) once found covers wanted. Matters most for /state and
/history reads of long threads, where this lookup runs with no run
cache and a typically tiny wanted set.

Raised by review on #4696.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(events): share the seq-stamping expression between the two stampers

The walrus-plus-merge expression was duplicated verbatim between
stamp_messages_with_seq and _MessageSeqStamper.stamp — two counterparts
of one rule where silent divergence is the likely failure mode if only
one side is edited. Both now call attach_message_seq next to
MESSAGE_SEQ_KEY in message_identity.py. The trailing
isinstance(message, Mapping) guard was unreachable (a non-Mapping entry
already got identity = None) and is gone with the extraction.

Raised by review on #4696.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(events): seq stamping survives launch paths without user context

The db store's get_message_seqs defaults to user_id=AUTO, which raises
when no user is in the contextvar — the first strict-AUTO read ever
called from the worker context. On a launch path that never inherits
the auth context (e.g. a null-owner scheduled task), stamp()'s except
clause swallowed that into a per-frame warning and silently disabled
seq stamping for exactly the background runs that need it.

The stamper now soft-resolves the user id once at build time — the
same rule as the worker's write paths beside it (unset -> no filter)
— and passes it explicitly. jsonl/memory stores gain the same
user_id kwarg the base list_messages contract already carries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf(events): SQL-prefilter the message seq lookup's candidate rows

get_message_seqs scanned and JSON-decoded every message row of the
thread: the early exit never fires when a wanted identity is absent
from the feed (a message still streaming, or checkpoint-only), and
/state / /history reads want the newest messages, so the ascending
scan traversed essentially the whole feed — with the content column
carrying full tool outputs, that is heavy I/O plus N JSON parses on
exactly the long threads this lookup exists for.

A LIKE prefilter now keeps that cost in SQL: only rows containing a
wanted raw id as a substring are fetched and decoded. False positives
are re-checked by message_identity; LIKE wildcards are escaped; an id
json.dumps would escape (breaking the verbatim-substring guarantee)
falls the whole set back to the full scan rather than silently
missing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(agents): sink runtime mechanism docs below the gateway guidance budget

Merging main pushed backend/app/gateway/AGENTS.md past its 40KB soft
budget (main had left 81 bytes of headroom). Per the nearest-file rule,
move the mechanism detail of the message-seq stamping and run-delivery
receipt sections — both owned by runtime/ code — into
packages/harness/deerflow/runtime/AGENTS.md, leaving the gateway file
the REST-surface summary and a pointer. The seq section also documents
the stamper's build-time soft user-id resolution and the db store's SQL
prefilter from the review follow-ups.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(agents): sink durable-MCP task detail below the backend guidance budget

Merging main pushed backend/AGENTS.md past its 24KB module soft budget
(main itself is at 24762 after #4848 — this branch adds zero net bytes
to the file). Per the nearest-file rule, move the two durable-MCP task
runtime bullets' mechanism detail into
packages/harness/deerflow/mcp/AGENTS.md, leaving summaries and
pointers; this also restores ~2KB of headroom so the next merge does
not trip the same wire.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(events): re-ask a message-seq miss once the feed advances

The run-scoped stamper cached lookup misses for the whole run. A message
this run produces reaches a values frame before RunJournal flushes it, so
its first lookup legitimately misses — and the journal persists it moments
later, giving it a feed seq the stamper never asks for again. A long run
that afterwards rolls past the history page and compacts then carries that
message unstamped, back to the approximate anchor placement this stamper
exists to replace (#4666). A transient store error had the same permanent
effect, since the except clause degrades to an empty result.

A miss is now provisional while a hit stays final: RunJournal counts its
successful event-store writes as `feed_generation`, and the stamper re-asks
a missed identity only once that counter moves. Retrying is therefore
bounded by feed writes rather than by frames — the per-frame query the
run-scoped cache was built to avoid — and a failed lookup costs one
generation instead of the run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 22:04:17 +08:00

908 lines
38 KiB
Python

"""Subgraph stream frames must not impersonate root-graph frames (#4399).
The gateway worker drives ``agent.astream(subgraphs=...)`` and publishes each
frame to the StreamBridge. Delegated subagent graphs inherit the parent's
checkpoint namespace (``subagents/executor.py``), so with ``subgraphs=True``
their values snapshots and token chunks arrive interleaved with root frames.
Publishing them under bare event names lets a subagent's values snapshot
replace the whole thread view in SDK clients and floods the parent message
stream with the subagent's token chunks. The namespace must ride the SSE event
name (LangGraph Platform style ``mode|ns1|ns2``) and namespaced frames must
bypass the root-only consumers (file-tool chunk batcher, subagent event
persistence).
"""
import asyncio
import importlib
import sys
from importlib.metadata import version as package_version
from types import SimpleNamespace
import pytest
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from packaging.version import Version
from deerflow.runtime.runs import worker
from deerflow.runtime.runs.manager import RunRecord, RunStartOutcome
from deerflow.runtime.runs.schemas import DisconnectMode, RunStatus
from deerflow.runtime.runs.worker import (
_compose_sse_event,
_publish_stream_item,
_unpack_stream_item,
)
from deerflow.runtime.stream_bridge.memory import MemoryStreamBridge
SUBAGENT_NS = ("tools:call_subagent_1",)
# Delegated graphs inherit the parent checkpoint namespace (and therefore
# stream as subgraphs) only on LangGraph >= 1.2.6 — same gate as
# tests/test_subagent_executor.py::TestSubagentCheckpointLineage.
_LANGGRAPH_INHERITS_SUBGRAPH_NAMESPACE = Version(package_version("langgraph")) >= Version("1.2.6")
class _FakeBridge:
def __init__(self) -> None:
self.published: list[tuple[str, str, object]] = []
async def publish(self, run_id: str, event: str, payload: object) -> None:
self.published.append((run_id, event, payload))
class _FakeSubagentEvents:
def __init__(self) -> None:
self.added: list[object] = []
async def add(self, chunk: object) -> None:
self.added.append(chunk)
class _SpyBatcher:
"""Observable stand-in for _LargeFileToolChunkBatcher."""
def __init__(self) -> None:
self.pushed: list[object] = []
self.finish_calls = 0
self.flush_calls = 0
def push(self, chunk: object) -> list[object]:
self.pushed.append(chunk)
return [chunk]
def finish(self) -> list[object]:
self.finish_calls += 1
return []
def flush(self) -> list[object]:
self.flush_calls += 1
return []
class TestUnpackStreamItem:
def test_root_frame_with_subgraphs_has_empty_namespace(self):
mode, chunk, namespace = _unpack_stream_item(((), "values", {"messages": []}), ["values"], True)
assert mode == "values"
assert namespace == ()
def test_subgraph_frame_preserves_namespace(self):
mode, chunk, namespace = _unpack_stream_item((SUBAGENT_NS, "values", {"messages": []}), ["values"], True)
assert mode == "values"
assert namespace == SUBAGENT_NS
def test_nested_subgraph_namespace_is_preserved_in_order(self):
ns = ("tools:call_a", "model_request:xyz")
_mode, _chunk, namespace = _unpack_stream_item((ns, "messages", object()), ["messages"], True)
assert namespace == ns
def test_two_tuple_under_subgraphs_is_root(self):
mode, _chunk, namespace = _unpack_stream_item(("custom", {"type": "task_started"}), ["custom"], True)
assert mode == "custom"
assert namespace == ()
def test_without_subgraphs_frames_are_root(self):
mode, _chunk, namespace = _unpack_stream_item(("values", {}), ["values"], False)
assert mode == "values"
assert namespace == ()
def test_single_mode_fallback_is_root(self):
mode, chunk, namespace = _unpack_stream_item({"messages": []}, ["values"], False)
assert mode == "values"
assert chunk == {"messages": []}
assert namespace == ()
def test_unparsable_item_under_subgraphs(self):
mode, chunk, namespace = _unpack_stream_item("garbage", ["values"], True)
assert mode is None
assert chunk is None
assert namespace == ()
class TestComposeSseEvent:
def test_root_frame_keeps_bare_event_name(self):
assert _compose_sse_event("values", ()) == "values"
def test_subgraph_frame_gets_namespace_qualified_name(self):
assert _compose_sse_event("values", SUBAGENT_NS) == "values|tools:call_subagent_1"
def test_nested_namespace_joins_all_segments(self):
assert _compose_sse_event("messages", ("tools:call_a", "model_request:xyz")) == "messages|tools:call_a|model_request:xyz"
class TestPublishStreamItem:
@pytest.mark.asyncio
async def test_subagent_values_snapshot_is_never_published_as_bare_values(self):
# The #4399 regression: a delegated subagent's values snapshot published
# as bare "values" replaces the whole thread view in SDK clients.
bridge = _FakeBridge()
await _publish_stream_item(
bridge=bridge,
run_id="run-1",
mode="values",
chunk={"messages": [{"type": "human", "content": "subagent task prompt"}]},
namespace=SUBAGENT_NS,
file_tool_chunk_batcher=None,
subagent_events=_FakeSubagentEvents(),
)
assert [event for _run, event, _payload in bridge.published] == ["values|tools:call_subagent_1"]
@pytest.mark.asyncio
async def test_root_values_snapshot_keeps_bare_event_name(self):
bridge = _FakeBridge()
await _publish_stream_item(
bridge=bridge,
run_id="run-1",
mode="values",
chunk={"messages": []},
namespace=(),
file_tool_chunk_batcher=None,
subagent_events=_FakeSubagentEvents(),
)
assert [event for _run, event, _payload in bridge.published] == ["values"]
@pytest.mark.asyncio
async def test_subagent_message_chunks_are_namespaced(self):
bridge = _FakeBridge()
await _publish_stream_item(
bridge=bridge,
run_id="run-1",
mode="messages",
chunk=({"content": "token"}, {"langgraph_node": "model"}),
namespace=SUBAGENT_NS,
file_tool_chunk_batcher=_SpyBatcher(),
subagent_events=_FakeSubagentEvents(),
)
assert [event for _run, event, _payload in bridge.published] == ["messages|tools:call_subagent_1"]
@pytest.mark.asyncio
async def test_root_custom_event_is_persisted_for_subagent_history(self):
bridge = _FakeBridge()
subagent_events = _FakeSubagentEvents()
chunk = {"type": "task_started", "task_id": "call_1"}
await _publish_stream_item(
bridge=bridge,
run_id="run-1",
mode="custom",
chunk=chunk,
namespace=(),
file_tool_chunk_batcher=None,
subagent_events=subagent_events,
)
assert [event for _run, event, _payload in bridge.published] == ["custom"]
assert subagent_events.added == [chunk]
@pytest.mark.asyncio
async def test_subgraph_custom_event_is_not_persisted(self):
bridge = _FakeBridge()
subagent_events = _FakeSubagentEvents()
await _publish_stream_item(
bridge=bridge,
run_id="run-1",
mode="custom",
chunk={"type": "noise"},
namespace=SUBAGENT_NS,
file_tool_chunk_batcher=None,
subagent_events=subagent_events,
)
assert [event for _run, event, _payload in bridge.published] == ["custom|tools:call_subagent_1"]
assert subagent_events.added == []
@pytest.mark.asyncio
async def test_only_root_frames_drive_the_file_tool_batcher(self):
bridge = _FakeBridge()
batcher = _SpyBatcher()
# A subagent values frame must not finish() a pending root batch...
await _publish_stream_item(
bridge=bridge,
run_id="run-1",
mode="values",
chunk={"messages": []},
namespace=SUBAGENT_NS,
file_tool_chunk_batcher=batcher,
subagent_events=_FakeSubagentEvents(),
)
assert batcher.finish_calls == 0
assert batcher.pushed == []
# ...while a root values frame does.
await _publish_stream_item(
bridge=bridge,
run_id="run-1",
mode="values",
chunk={"messages": []},
namespace=(),
file_tool_chunk_batcher=batcher,
subagent_events=_FakeSubagentEvents(),
)
assert batcher.finish_calls == 1
@pytest.mark.asyncio
async def test_root_message_chunks_go_through_the_batcher(self):
bridge = _FakeBridge()
batcher = _SpyBatcher()
chunk = ({"content": "token"}, {"langgraph_node": "model"})
await _publish_stream_item(
bridge=bridge,
run_id="run-1",
mode="messages",
chunk=chunk,
namespace=(),
file_tool_chunk_batcher=batcher,
subagent_events=_FakeSubagentEvents(),
)
assert batcher.pushed == [chunk]
assert [event for _run, event, _payload in bridge.published] == ["messages"]
# ---------------------------------------------------------------------------
# Production-shaped integration: SubagentExecutor -> astream(subgraphs=...)
# -> run_agent stream loop -> StreamBridge. The namespace must originate from
# LangGraph's own delegation routing (checkpoint-namespace inheritance), not
# be hand-fed to the publishing helper — the #4399 regression lived in that
# interaction, not in any helper in isolation.
# ---------------------------------------------------------------------------
_CHILD_MESSAGE_IDS = frozenset(
{
"child-task-sentinel",
"child-ai-sentinel",
"child-tool-sentinel",
"child-final-sentinel",
}
)
_PARENT_FINAL_ID = "parent-final-sentinel"
_THREAD_ID = "thread-subgraph-stream-integration"
@pytest.fixture
def real_executor_module():
"""Swap the conftest MagicMock for the real subagent executor module.
conftest.py mocks ``deerflow.subagents.executor`` to break a package-init
import cycle; by the time this fixture runs every other deerflow module is
already imported, so a fresh import of the real module is safe.
"""
original = sys.modules.get("deerflow.subagents.executor")
sys.modules.pop("deerflow.subagents.executor", None)
subagents_pkg = sys.modules.get("deerflow.subagents")
if subagents_pkg is not None and hasattr(subagents_pkg, "executor"):
delattr(subagents_pkg, "executor")
module = importlib.import_module("deerflow.subagents.executor")
# Hermetic in CI (no config.yaml) — same defaults as test_subagent_executor.
module.get_app_config = lambda: SimpleNamespace(tool_search=SimpleNamespace(enabled=False))
module.build_tracing_callbacks = lambda: []
yield module
if original is not None:
sys.modules["deerflow.subagents.executor"] = original
else:
sys.modules.pop("deerflow.subagents.executor", None)
subagents_pkg = sys.modules.get("deerflow.subagents")
if subagents_pkg is not None and hasattr(subagents_pkg, "executor"):
delattr(subagents_pkg, "executor")
class _RecordingStreamBridge(MemoryStreamBridge):
"""Real in-memory bridge that also records (event, payload) pairs."""
def __init__(self) -> None:
super().__init__()
self.published: list[tuple[str, object]] = []
async def publish(self, run_id: str, event: str, payload: object) -> None:
self.published.append((event, payload))
await super().publish(run_id, event, payload)
class _IntegrationRunManager:
def __init__(self, record: RunRecord) -> None:
self._record = record
async def try_start(self, _run_id):
self._record.status = RunStatus.running
return RunStartOutcome.started
async def wait_for_prior_finalizing(self, *_args, **_kwargs):
return None
async def set_status(self, _run_id, status, **_kwargs):
self._record.status = status
async def set_status_if_not_cancelled(self, _run_id, status, **kwargs):
await self.set_status(_run_id, status, **kwargs)
return None
async def update_model_name(self, *_args, **_kwargs):
return None
async def update_run_completion(self, *_args, **_kwargs):
return None
async def has_later_started_run(self, *_args, **_kwargs):
return False
async def set_finalizing(self, *_args, **_kwargs):
return None
async def cleanup(self, *_args, **_kwargs):
return None
def _collect_ids(payload: object) -> set[str]:
"""All string ``id`` values anywhere in a serialized stream payload."""
ids: set[str] = set()
def walk(node: object) -> None:
if isinstance(node, dict):
node_id = node.get("id")
if isinstance(node_id, str):
ids.add(node_id)
for value in node.values():
walk(value)
elif isinstance(node, (list, tuple)):
for value in node:
walk(value)
walk(payload)
return ids
def _build_delegating_parent_graph(executor_module, monkeypatch, *, child_emits_error_fallback: bool = False):
"""Real parent graph whose node delegates a scripted child through the
real ``SubagentExecutor`` and emits ``task_*`` custom events the way the
production task tool does (root-graph ``get_stream_writer``).
With ``child_emits_error_fallback`` the child stream contains an assistant
message carrying the ``deerflow_error_fallback`` marker (not as its final
message, so the delegation itself still completes) — the shape whose leak
would mark the *parent* run as errored (#4399).
"""
from langgraph.config import get_stream_writer
from langgraph.graph import END, START, MessagesState, StateGraph
from deerflow.subagents.config import SubagentConfig
child_builder = StateGraph(MessagesState)
child_builder.add_node(
"child_model",
lambda _state: {
"messages": [
AIMessage(
content="",
id="child-ai-sentinel",
tool_calls=[{"name": "child_tool", "args": {}, "id": "child-tool-call", "type": "tool_call"}],
)
]
},
)
child_builder.add_node(
"child_tool",
lambda _state: {"messages": [ToolMessage(content="child tool output", name="child_tool", tool_call_id="child-tool-call", id="child-tool-sentinel")]},
)
child_builder.add_node(
"child_fallback",
lambda _state: {
"messages": [
AIMessage(
content="child provider failed after retries",
id="child-fallback-sentinel",
additional_kwargs={"deerflow_error_fallback": True},
)
]
},
)
child_builder.add_node(
"child_final",
lambda _state: {"messages": [AIMessage(content="child final answer", id="child-final-sentinel")]},
)
child_builder.add_edge(START, "child_model")
child_builder.add_edge("child_model", "child_tool")
if child_emits_error_fallback:
child_builder.add_edge("child_tool", "child_fallback")
child_builder.add_edge("child_fallback", "child_final")
else:
child_builder.add_edge("child_tool", "child_final")
child_builder.add_edge("child_final", END)
child_graph = child_builder.compile(checkpointer=False)
executor = executor_module.SubagentExecutor(
config=SubagentConfig(
name="general-purpose",
description="Namespace integration test agent",
system_prompt="You are a namespace integration test agent.",
max_turns=5,
timeout_seconds=30,
),
tools=[],
parent_model="test-model",
thread_id=_THREAD_ID,
trace_id="trace-namespace-integration",
)
async def build_initial_state(task):
return ({"messages": [HumanMessage(content=task, id="child-task-sentinel")]}, [], None)
monkeypatch.setattr(executor, "_build_initial_state", build_initial_state)
monkeypatch.setattr(executor, "_create_agent", lambda *_args, **_kwargs: child_graph)
async def delegate(_state):
writer = get_stream_writer()
task_id = executor.execute_async("run the delegated child graph")
writer({"type": "task_started", "task_id": task_id})
try:
deadline = asyncio.get_running_loop().time() + 10
while True:
result = executor_module.get_background_task_result(task_id)
if result is not None and result.status.is_terminal:
break
if asyncio.get_running_loop().time() >= deadline:
pytest.fail("delegated subagent did not complete")
await asyncio.sleep(0.001)
assert result.status.value == "completed", f"delegation failed: {result.error}"
finally:
executor_module.cleanup_background_task(task_id)
writer({"type": "task_completed", "task_id": task_id})
return {"messages": [AIMessage(content="parent final answer", id=_PARENT_FINAL_ID)]}
parent_builder = StateGraph(MessagesState)
parent_builder.add_node("delegate", delegate)
parent_builder.add_edge(START, "delegate")
parent_builder.add_edge("delegate", END)
return parent_builder.compile()
async def _run_delegation_through_worker(executor_module, monkeypatch, *, stream_subgraphs: bool, child_emits_error_fallback: bool = False) -> tuple[RunRecord, _RecordingStreamBridge]:
from langgraph.checkpoint.memory import InMemorySaver
parent_graph = _build_delegating_parent_graph(executor_module, monkeypatch, child_emits_error_fallback=child_emits_error_fallback)
bridge = _RecordingStreamBridge()
record = RunRecord(
run_id=f"run-ns-int-{int(stream_subgraphs)}",
thread_id=_THREAD_ID,
assistant_id="lead-agent",
status=RunStatus.pending,
on_disconnect=DisconnectMode.cancel,
model_name=None,
)
record.abort_event = asyncio.Event()
await worker.run_agent(
bridge,
_IntegrationRunManager(record),
record,
ctx=worker.RunContext(checkpointer=InMemorySaver()),
agent_factory=lambda config: parent_graph,
graph_input={"messages": [HumanMessage(content="delegate to the subagent")]},
config={"configurable": {"thread_id": _THREAD_ID}},
stream_modes=["values", "messages-tuple", "custom"],
stream_subgraphs=stream_subgraphs,
)
return record, bridge
@pytest.mark.skipif(
not _LANGGRAPH_INHERITS_SUBGRAPH_NAMESPACE,
reason="delegated graphs stream as namespaced subgraphs only on LangGraph >= 1.2.6",
)
class TestWorkerSubgraphStreamIntegration:
@pytest.mark.asyncio
async def test_stream_subgraphs_publishes_delegated_frames_namespaced_never_bare(self, real_executor_module, monkeypatch):
record, bridge = await _run_delegation_through_worker(real_executor_module, monkeypatch, stream_subgraphs=True)
assert record.status == RunStatus.success, f"run failed: {bridge.published}"
events = bridge.published
bare_values = [payload for event, payload in events if event == "values"]
bare_messages = [payload for event, payload in events if event == "messages"]
namespaced_values = [(event, payload) for event, payload in events if event.startswith("values|")]
namespaced_messages = [(event, payload) for event, payload in events if event.startswith("messages|")]
# The #4399 takeover: a delegated values snapshot must never be
# published as bare "values" (SDK clients replace the thread view).
for payload in bare_values:
assert not (_collect_ids(payload) & _CHILD_MESSAGE_IDS), f"delegated messages leaked into a bare values frame: {payload}"
for payload in bare_messages:
assert not (_collect_ids(payload) & _CHILD_MESSAGE_IDS), f"delegated message chunk leaked into the bare messages stream: {payload}"
# The delegated frames must actually arrive — namespaced by LangGraph,
# not silently dropped (guards against a vacuous pass).
assert any(_collect_ids(payload) & _CHILD_MESSAGE_IDS for _event, payload in namespaced_values), f"expected namespaced delegated values frames, got events: {[event for event, _ in events]}"
assert any(_collect_ids(payload) & _CHILD_MESSAGE_IDS for _event, payload in namespaced_messages), f"expected namespaced delegated message chunks, got events: {[event for event, _ in events]}"
for event, _payload in namespaced_values + namespaced_messages:
segments = event.split("|")[1:]
assert segments and all(segments), f"namespaced event name has empty namespace segments: {event}"
# Root frames stay bare and intact.
assert any(_PARENT_FINAL_ID in _collect_ids(payload) for payload in bare_values)
custom_types = [payload.get("type") for event, payload in events if event == "custom" and isinstance(payload, dict)]
assert "task_started" in custom_types and "task_completed" in custom_types
@pytest.mark.asyncio
async def test_delegated_error_fallback_does_not_mark_the_parent_run_as_error(self, real_executor_module, monkeypatch):
record, bridge = await _run_delegation_through_worker(real_executor_module, monkeypatch, stream_subgraphs=True, child_emits_error_fallback=True)
# A delegated subagent's LLM error fallback is the executor's to map
# (task_failed); it must not decide the parent run's status.
assert record.status == RunStatus.success, "delegated error fallback leaked into the parent run status"
assert not [payload for event, payload in bridge.published if event == "error"]
# Non-vacuous: the marked child message really rode the stream —
# namespaced, where the root-only fallback detector must ignore it.
namespaced_payloads = [payload for event, payload in bridge.published if event.startswith(("values|", "messages|"))]
assert any("child-fallback-sentinel" in _collect_ids(payload) for payload in namespaced_payloads)
@pytest.mark.asyncio
async def test_without_stream_subgraphs_delegated_frames_stay_out_while_task_events_remain(self, real_executor_module, monkeypatch):
record, bridge = await _run_delegation_through_worker(real_executor_module, monkeypatch, stream_subgraphs=False)
assert record.status == RunStatus.success, f"run failed: {bridge.published}"
events = bridge.published
# No delegated frame of any mode reaches the parent stream...
for event, payload in events:
assert not (_collect_ids(payload) & _CHILD_MESSAGE_IDS), f"delegated messages leaked into event {event!r}: {payload}"
assert not [event for event, _payload in events if "|" in event]
# ...while the parent's own frames and the task_* progress contract
# (what the web frontend relies on instead of the flag) still hold.
bare_values = [payload for event, payload in events if event == "values"]
assert any(_PARENT_FINAL_ID in _collect_ids(payload) for payload in bare_values)
custom_types = [payload.get("type") for event, payload in events if event == "custom" and isinstance(payload, dict)]
assert "task_started" in custom_types and "task_completed" in custom_types
class TestMessageSeqStamping:
"""A values frame carries the feed seq of messages already persisted.
The checkpoint has no seq of its own and loses messages to summarization,
so a client merging it with the seq-ordered feed cannot place a surviving
old message once the feed's loaded page window no longer reaches back to it
(#4666). The worker already holds the event store, so it attaches the seq
that store assigned. Nothing is written back to the checkpoint.
"""
@staticmethod
async def _seeded_store():
from deerflow.runtime.events.store.memory import MemoryRunEventStore
store = MemoryRunEventStore()
await store.put(
thread_id="t1",
run_id="r1",
event_type="llm.human.input",
category="message",
content={"type": "human", "id": "u1__user", "content": "MARK-FIRST"},
)
return store
@pytest.mark.asyncio
async def test_root_values_frame_stamps_a_persisted_message(self):
from deerflow.runtime.runs.worker import _MessageSeqStamper
bridge = _FakeBridge()
await _publish_stream_item(
bridge=bridge,
run_id="run-1",
mode="values",
chunk={"messages": [{"type": "human", "id": "u1__user", "content": "MARK-FIRST"}]},
namespace=(),
file_tool_chunk_batcher=None,
subagent_events=_FakeSubagentEvents(),
seq_stamper=_MessageSeqStamper(await self._seeded_store(), "t1"),
)
_run, _event, payload = bridge.published[0]
assert payload["messages"][0]["additional_kwargs"]["deerflow_seq"] == 1
@pytest.mark.asyncio
async def test_a_message_not_in_the_feed_is_left_unstamped(self):
"""A message still streaming has no seq yet — and needs none: appending
it at the tail is already its correct position."""
from deerflow.runtime.runs.worker import _MessageSeqStamper
bridge = _FakeBridge()
await _publish_stream_item(
bridge=bridge,
run_id="run-1",
mode="values",
chunk={"messages": [{"type": "ai", "id": "not-persisted-yet", "content": ""}]},
namespace=(),
file_tool_chunk_batcher=None,
subagent_events=_FakeSubagentEvents(),
seq_stamper=_MessageSeqStamper(await self._seeded_store(), "t1"),
)
_run, _event, payload = bridge.published[0]
assert "deerflow_seq" not in (payload["messages"][0].get("additional_kwargs") or {})
@pytest.mark.asyncio
async def test_subgraph_frames_are_not_stamped(self):
"""A subagent frame does not belong to the thread feed's ordering."""
from deerflow.runtime.runs.worker import _MessageSeqStamper
bridge = _FakeBridge()
await _publish_stream_item(
bridge=bridge,
run_id="run-1",
mode="values",
chunk={"messages": [{"type": "human", "id": "u1__user", "content": "MARK-FIRST"}]},
namespace=SUBAGENT_NS,
file_tool_chunk_batcher=None,
subagent_events=_FakeSubagentEvents(),
seq_stamper=_MessageSeqStamper(await self._seeded_store(), "t1"),
)
_run, _event, payload = bridge.published[0]
assert "deerflow_seq" not in (payload["messages"][0].get("additional_kwargs") or {})
@pytest.mark.asyncio
async def test_no_stamper_publishes_the_frame_unchanged(self):
bridge = _FakeBridge()
await _publish_stream_item(
bridge=bridge,
run_id="run-1",
mode="values",
chunk={"messages": [{"type": "human", "id": "u1__user", "content": "MARK-FIRST"}]},
namespace=(),
file_tool_chunk_batcher=None,
subagent_events=_FakeSubagentEvents(),
)
_run, _event, payload = bridge.published[0]
assert "deerflow_seq" not in (payload["messages"][0].get("additional_kwargs") or {})
@pytest.mark.asyncio
async def test_a_resolved_identity_is_not_looked_up_twice(self):
"""Only a frame carrying messages it has not seen costs a query — in a
real run that is the compaction frame, not every frame."""
from deerflow.runtime.runs.worker import _MessageSeqStamper
store = await self._seeded_store()
calls: list[list[str]] = []
original = store.get_message_seqs
async def counting(thread_id, identities, **kwargs):
calls.append(list(identities))
return await original(thread_id, identities, **kwargs)
store.get_message_seqs = counting # type: ignore[method-assign]
stamper = _MessageSeqStamper(store, "t1")
frame = {"messages": [{"type": "human", "id": "u1__user", "content": "MARK-FIRST"}]}
for _ in range(3):
await _publish_stream_item(
bridge=_FakeBridge(),
run_id="run-1",
mode="values",
chunk=dict(frame),
namespace=(),
file_tool_chunk_batcher=None,
subagent_events=_FakeSubagentEvents(),
seq_stamper=stamper,
)
assert len(calls) == 1
@pytest.mark.asyncio
async def test_a_miss_is_retried_once_the_feed_advances(self):
"""A miss is provisional: the same message can be persisted later in the run.
A message this run produces reaches a ``values`` frame before
``RunJournal`` flushes it, so its first lookup misses. The journal then
writes it and it does have a feed seq. Caching that miss permanently
would leave it unstamped for the rest of the run — and a long run that
afterwards rolls past the history page and compacts is exactly the
misplacement this stamper exists to prevent (#4696 review).
"""
from deerflow.runtime.runs.worker import _MessageSeqStamper
store = await self._seeded_store()
generation = 0
stamper = _MessageSeqStamper(store, "t1", feed_generation=lambda: generation)
frame = {"messages": [{"type": "ai", "id": "a1", "content": ""}]}
first = await stamper.stamp(dict(frame))
assert "deerflow_seq" not in (first["messages"][0].get("additional_kwargs") or {})
await store.put(
thread_id="t1",
run_id="r1",
event_type="llm.ai.output",
category="message",
content={"type": "ai", "id": "a1", "content": ""},
)
generation += 1
second = await stamper.stamp(dict(frame))
assert second["messages"][0]["additional_kwargs"]["deerflow_seq"] == 2
@pytest.mark.asyncio
async def test_a_miss_is_not_retried_while_the_feed_is_unchanged(self):
"""Retrying is bounded by feed writes, not by frames.
Without the generation gate the retry would run on every frame that
carries a streaming message — a query per frame on exactly the long
threads this stamper is careful to cost one query in.
"""
from deerflow.runtime.runs.worker import _MessageSeqStamper
store = await self._seeded_store()
calls: list[list[str]] = []
original = store.get_message_seqs
async def counting(thread_id, identities, **kwargs):
calls.append(list(identities))
return await original(thread_id, identities, **kwargs)
store.get_message_seqs = counting # type: ignore[method-assign]
stamper = _MessageSeqStamper(store, "t1", feed_generation=lambda: 7)
frame = {"messages": [{"type": "ai", "id": "never-persisted", "content": ""}]}
for _ in range(3):
await stamper.stamp(dict(frame))
assert len(calls) == 1
@pytest.mark.asyncio
async def test_a_failed_lookup_is_retried_once_the_feed_advances(self):
"""A transient store error must not disable stamping for the whole run.
The except clause degrades the frame to "no seq"; treating that answer
as final would make one failed query as permanent as a real miss.
"""
from deerflow.runtime.runs.worker import _MessageSeqStamper
store = await self._seeded_store()
generation = 0
original = store.get_message_seqs
failed = False
async def failing_once(thread_id, identities, **kwargs):
nonlocal failed
if not failed:
failed = True
raise RuntimeError("transient store failure")
return await original(thread_id, identities, **kwargs)
store.get_message_seqs = failing_once # type: ignore[method-assign]
stamper = _MessageSeqStamper(store, "t1", feed_generation=lambda: generation)
frame = {"messages": [{"type": "human", "id": "u1__user", "content": "MARK-FIRST"}]}
first = await stamper.stamp(dict(frame))
assert "deerflow_seq" not in (first["messages"][0].get("additional_kwargs") or {})
generation += 1
second = await stamper.stamp(dict(frame))
assert second["messages"][0]["additional_kwargs"]["deerflow_seq"] == 1
@pytest.mark.asyncio
async def test_a_resolved_identity_survives_a_feed_advance(self):
"""Only misses are provisional — a resolved seq is never looked up again.
The feed's earliest-seq-wins rule makes a resolved answer final, so an
advancing feed must not turn the positive cache into a per-write query.
"""
from deerflow.runtime.runs.worker import _MessageSeqStamper
store = await self._seeded_store()
calls: list[list[str]] = []
original = store.get_message_seqs
async def counting(thread_id, identities, **kwargs):
calls.append(list(identities))
return await original(thread_id, identities, **kwargs)
store.get_message_seqs = counting # type: ignore[method-assign]
generation = 0
stamper = _MessageSeqStamper(store, "t1", feed_generation=lambda: generation)
frame = {"messages": [{"type": "human", "id": "u1__user", "content": "MARK-FIRST"}]}
await stamper.stamp(dict(frame))
generation += 1
stamped = await stamper.stamp(dict(frame))
assert len(calls) == 1
assert stamped["messages"][0]["additional_kwargs"]["deerflow_seq"] == 1
@pytest.mark.asyncio
async def test_the_run_stamper_re_asks_after_the_journal_writes(self):
"""The wiring itself: the run's stamper reads the journal's feed writes.
Miss-retrying is only reachable if the stamper the worker builds is the
one connected to the journal, and a lambda pointing at the wrong object
fails silently — the stamper simply keeps every miss.
"""
from deerflow.runtime.journal import RunJournal
from deerflow.runtime.runs.worker import _build_seq_stamper
store = await self._seeded_store()
journal = RunJournal("r1", "t1", store, flush_threshold=100)
stamper = _build_seq_stamper(store, "t1", journal)
frame = {"messages": [{"type": "ai", "id": "a1", "content": ""}]}
first = await stamper.stamp(dict(frame))
assert "deerflow_seq" not in (first["messages"][0].get("additional_kwargs") or {})
journal._put(
event_type="llm.ai.response",
category="message",
content={"type": "ai", "id": "a1", "content": ""},
)
await journal.flush()
second = await stamper.stamp(dict(frame))
assert second["messages"][0]["additional_kwargs"]["deerflow_seq"] == 2
@pytest.mark.asyncio
async def test_a_run_without_a_journal_still_builds_a_stamper(self):
"""No writer to report feed growth is not a reason to stop stamping."""
from deerflow.runtime.runs.worker import _build_seq_stamper
stamper = _build_seq_stamper(await self._seeded_store(), "t1", None)
stamped = await stamper.stamp({"messages": [{"type": "human", "id": "u1__user", "content": "MARK-FIRST"}]})
assert stamped["messages"][0]["additional_kwargs"]["deerflow_seq"] == 1
@pytest.mark.asyncio
@pytest.mark.no_auto_user
async def test_stamping_survives_a_launch_path_without_user_context(self, tmp_path):
"""A launch path that never inherits the auth contextvar (e.g. a
null-owner scheduled task) must not silently disable stamping.
The db store's ``user_id=AUTO`` default raises without a user in
context, and ``stamp``'s except clause would swallow that into a
per-frame warning — a graceful degrade that turns the fix off in
exactly the background runs that need it. The stamper therefore
soft-resolves the id once, when it is built, the same way the
worker's write paths beside it do (unset → no filter)."""
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
from deerflow.runtime.events.store.db import DbRunEventStore
from deerflow.runtime.runs.worker import _MessageSeqStamper
url = f"sqlite+aiosqlite:///{tmp_path / 'seqs.db'}"
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
try:
store = DbRunEventStore(get_session_factory())
# Written without a user in context, as the worker's own
# human_message put does on such a path: the row's user_id is NULL.
await store.put(
thread_id="t1",
run_id="r1",
event_type="llm.human.input",
category="message",
content={"type": "human", "id": "u1__user", "content": "MARK-FIRST"},
)
bridge = _FakeBridge()
await _publish_stream_item(
bridge=bridge,
run_id="run-1",
mode="values",
chunk={"messages": [{"type": "human", "id": "u1__user", "content": "MARK-FIRST"}]},
namespace=(),
file_tool_chunk_batcher=None,
subagent_events=_FakeSubagentEvents(),
seq_stamper=_MessageSeqStamper(store, "t1"),
)
_run, _event, payload = bridge.published[0]
assert payload["messages"][0]["additional_kwargs"]["deerflow_seq"] == 1
finally:
await close_engine()