deer-flow/backend/tests/test_compaction_event_identity.py
Nan Gao 1dd6d05627
fix(extensions): hand compaction observers the live task store and name the task in CompactionEvent (#7831)
* feat(extension-api): add task identity fields to CompactionEvent (0.2.10)

CompactionEvent carried the transform, its hashes and its counts, but
nothing that names the task, run or thread the compaction happened in.
An observer that wants to relate a compaction to the run that produced it
had to match hashes across sessions, which is unreliable.

Append three optional fields, task_id, run_id and thread_id (str | None,
default None). Existing keyword and positional construction is unchanged,
so already-released extensions keep working. The host fills them in the
next commit.

Bump deerflow-extension-api to 0.2.10 and the harness's exact pin with it.

* fix(extensions): hand compaction observers the live task store and its identity

The summarization middleware already recovers the live task store from
the runtime to observe its summary system call, and the registry
allocates that store precisely because a compaction observer is
registered. notify_context_compacted() then discarded it and handed every
observer a detached store, with a docstring claiming there was no live
task.

- The lead run worker and the subagent executor now seed every task store
  they allocate with the task's TaskInfo, not only when a lifecycle
  contributor is registered; the lifecycle notification reuses the seeded
  object. A subagent without a parent run_id still gets no TaskInfo.
- compact_state() and acompact_state() pass the store from
  task_store_from_runtime() to _record_compaction().
- notify_context_compacted() takes an optional task_store. A live store
  goes to the observers and fills the event's task_id/run_id/thread_id
  from its TaskInfo; without one (manual /compact runs outside any run)
  observers still receive a detached store and the ids stay None.

For a background subagent the store keeps its public scope id (the
provider tool_call_id) while TaskInfo.task_id stays the execution id, as
lifecycle contributors already see it; the event follows TaskInfo.

* docs(extensions): document compaction event identity and seeded TaskInfo

The extensions guide and the developer manual still said compaction
observers always receive a detached store because no task is live at the
call site. Describe what the host does now: lead runs and subagents with a
parent run seed every task store with TaskInfo, compaction observers get
the live store, and CompactionEvent's task_id/run_id/thread_id come from
it; only manual /compact falls back to a detached store with None ids.
Adds the 0.2.10 row to the contract version table (PR number pending).

* docs(changelog): note compaction observers receive the live task identity

* docs(extensions): list compaction observers among task-store triggers

* docs: reference #7831 for the compaction event identity change

* docs(extensions): backfill contract history rows 0.2.5 to 0.2.9

The version table jumped from 0.2.4 to 0.2.10 although 0.2.5-0.2.9
shipped. Add those rows, attributing names merged without a bump to
the first release that carried them (#5796 in 0.2.5, #6920 and #5645
in 0.2.8, #7729 in 0.2.10). The page intro now says it documents
0.2.4 plus the 0.2.10 CompactionEvent fields, and points to the
history for the rest.
2026-11-03 15:06:52 +08:00

287 lines
11 KiB
Python

"""A compaction event names the task, run and thread it happened in.
The host already holds the live task store when summarization runs: the summary
system call is observed with it. A compaction observer that cannot name its task
can only correlate a compaction with the run that produced it by matching hashes
across sessions, which is unreliable, so the host hands the observer the store it
already has and copies the seeded ``TaskInfo`` identity onto the event.
"""
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from deerflow_extension_api import EXTENSION_TASK_STORE_KEY, CompactionEvent, ExtensionData, TaskInfo
from langchain_core.messages import AIMessage, HumanMessage
from langgraph.checkpoint.memory import InMemorySaver
from deerflow.agents.middlewares.summarization_middleware import DeerFlowSummarizationMiddleware
from deerflow.extensions import notify
from deerflow.extensions.registry import ExtensionRegistry
from deerflow.runtime import context_compaction
from deerflow.runtime.runs.manager import RunManager
from deerflow.runtime.runs.schemas import RunStatus
from deerflow.runtime.runs.worker import RunContext, run_agent
class _CompactionRecorder:
def __init__(self) -> None:
self.calls: list[tuple[ExtensionData, CompactionEvent]] = []
self._arrived = asyncio.Event()
async def on_context_compacted(self, app_store, task_store, event):
self.calls.append((task_store, event))
self._arrived.set()
async def received(self) -> list[tuple[ExtensionData, CompactionEvent]]:
# Dispatch is fire-and-forget onto the notify loop, so the observer may
# still be pending when the compaction call returns.
await asyncio.wait_for(self._arrived.wait(), 5)
return self.calls
class _LifecycleRecorder:
def __init__(self) -> None:
self.infos: list[TaskInfo] = []
async def on_task_start(self, app_store, task_store, info):
self.infos.append(info)
async def on_task_stop(self, app_store, task_store, info, outcome):
return None
def _extensions(observer, *, lifecycle=None):
registry = ExtensionRegistry()
with registry.attributed_to("identity:install"):
registry.context_compaction_observer(observer)
if lifecycle is not None:
registry.task_lifecycle(lifecycle)
return registry.build()
def _messages() -> list:
return [
HumanMessage(content="user-1"),
AIMessage(content="assistant-1"),
HumanMessage(content="user-2"),
AIMessage(content="assistant-2"),
]
def _middleware(extensions) -> DeerFlowSummarizationMiddleware:
model = MagicMock()
model.invoke.return_value = SimpleNamespace(text="compressed summary")
model.ainvoke = AsyncMock(return_value=SimpleNamespace(text="compressed summary"))
model.with_config.return_value = model
return DeerFlowSummarizationMiddleware(
model=model,
trigger=("messages", 4),
keep=("messages", 2),
token_counter=len,
extensions=extensions,
)
def _identity(event: CompactionEvent) -> tuple[str | None, str | None, str | None]:
return event.task_id, event.run_id, event.thread_id
# --- The contract stays additive -------------------------------------------
def test_an_event_built_the_old_way_still_constructs_without_identity():
keyword = CompactionEvent(
transform_kind="summarization",
transform_version="1",
source_content_hashes=("h1",),
output_content_hash="h2",
compacted_message_count=1,
kept_message_count=1,
)
positional = CompactionEvent("summarization", "1", ("h1",), "h2", 1, 1)
assert keyword == positional
assert _identity(keyword) == (None, None, None)
# --- Dispatch ----------------------------------------------------------------
class TestNotifyHandsOverTheLiveStore:
@pytest.mark.asyncio
async def test_a_live_store_reaches_the_observer_and_names_the_event(self, monkeypatch):
monkeypatch.setattr(notify, "_notify_loop", asyncio.get_running_loop())
observer = _CompactionRecorder()
store = ExtensionData("run-1")
store.set(TaskInfo(task_id="run-1", run_id="run-1", thread_id="thread-1", kind="lead"))
notify.notify_context_compacted(CompactionEvent("summarization", "1", (), "h", 0, 0), extensions=_extensions(observer), task_store=store)
[(received_store, event)] = await observer.received()
assert received_store is store
assert _identity(event) == ("run-1", "run-1", "thread-1")
@pytest.mark.asyncio
async def test_a_store_without_task_info_is_handed_over_without_identity(self, monkeypatch):
# A subagent with no parent run allocates a store but never seeds TaskInfo.
monkeypatch.setattr(notify, "_notify_loop", asyncio.get_running_loop())
observer = _CompactionRecorder()
store = ExtensionData("sub-1")
notify.notify_context_compacted(CompactionEvent("summarization", "1", (), "h", 0, 0), extensions=_extensions(observer), task_store=store)
[(received_store, event)] = await observer.received()
assert received_store is store
assert _identity(event) == (None, None, None)
@pytest.mark.asyncio
async def test_no_store_falls_back_to_detached(self, monkeypatch):
monkeypatch.setattr(notify, "_notify_loop", asyncio.get_running_loop())
observer = _CompactionRecorder()
notify.notify_context_compacted(CompactionEvent("summarization", "1", (), "h", 0, 0), extensions=_extensions(observer))
[(received_store, event)] = await observer.received()
assert received_store.scope_id == "detached"
assert _identity(event) == (None, None, None)
# --- End to end through the hosts that allocate the store -----------------
class _CompactingLeadAgent:
"""Stands in for the lead graph: runs the real summarization middleware once
against the runtime context ``run_agent`` hands the graph."""
def __init__(self, middleware: DeerFlowSummarizationMiddleware) -> None:
self.middleware = middleware
self.task_store: ExtensionData | None = None
async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=False):
context = (config or {}).get("context")
self.task_store = context.get(EXTENSION_TASK_STORE_KEY)
assert await self.middleware.abefore_model({"messages": _messages()}, SimpleNamespace(context=context)) is not None
yield {"messages": []}
def _bridge():
return SimpleNamespace(publish=AsyncMock(), publish_end=AsyncMock(), cleanup=AsyncMock())
async def _run_lead(extensions, agent):
manager = RunManager()
record = await manager.create("thread-identity", assistant_id="custom-agent")
await run_agent(
_bridge(),
manager,
record,
ctx=RunContext(checkpointer=InMemorySaver(), extensions=extensions),
agent_factory=lambda *, config: agent,
graph_input={},
config={},
)
assert record.status is RunStatus.success
return record
class TestLeadRunCompaction:
@pytest.mark.asyncio
async def test_async_compaction_names_the_run_it_happened_in(self, monkeypatch):
monkeypatch.setattr(notify, "_notify_loop", asyncio.get_running_loop())
observer = _CompactionRecorder()
lifecycle = _LifecycleRecorder()
extensions = _extensions(observer, lifecycle=lifecycle)
agent = _CompactingLeadAgent(_middleware(extensions))
record = await _run_lead(extensions, agent)
[(store, event)] = await observer.received()
[info] = lifecycle.infos
assert store is agent.task_store
assert store.scope_id == info.task_id == record.run_id
assert store.get(TaskInfo) == info
assert _identity(event) == (info.task_id, info.run_id, info.thread_id) == (record.run_id, record.run_id, "thread-identity")
@pytest.mark.asyncio
async def test_identity_does_not_depend_on_a_lifecycle_contributor(self, monkeypatch):
"""Registering a compaction observer alone allocates the store; the host
must not drop the identity just because no lifecycle contributor asked."""
monkeypatch.setattr(notify, "_notify_loop", asyncio.get_running_loop())
observer = _CompactionRecorder()
extensions = _extensions(observer)
assert extensions.needs_task_store and not extensions.has_task_lifecycle
agent = _CompactingLeadAgent(_middleware(extensions))
record = await _run_lead(extensions, agent)
[(store, event)] = await observer.received()
assert store is agent.task_store
assert store.scope_id == record.run_id
assert store.get(TaskInfo) == TaskInfo(
task_id=record.run_id,
run_id=record.run_id,
thread_id="thread-identity",
kind="lead",
agent_name="custom-agent",
)
assert _identity(event) == (record.run_id, record.run_id, "thread-identity")
class TestSyncCompaction:
@pytest.mark.asyncio
async def test_sync_compaction_carries_the_live_task_identity(self, monkeypatch):
monkeypatch.setattr(notify, "_notify_loop", asyncio.get_running_loop())
observer = _CompactionRecorder()
store = ExtensionData("run-sync")
info = TaskInfo(task_id="run-sync", run_id="run-sync", thread_id="thread-sync", kind="lead")
store.set(info)
runtime = SimpleNamespace(context={"thread_id": "thread-sync", EXTENSION_TASK_STORE_KEY: store})
middleware = _middleware(_extensions(observer))
# The synchronous graph path runs before_model off the serving loop.
result = await asyncio.to_thread(middleware.before_model, {"messages": _messages()}, runtime)
assert result is not None
[(received_store, event)] = await observer.received()
assert received_store is store
assert _identity(event) == (info.task_id, info.run_id, info.thread_id)
class _Accessor:
def __init__(self, values: dict) -> None:
self.snapshot = SimpleNamespace(
values=values,
config={"configurable": {"thread_id": "thread-manual", "checkpoint_id": "ckpt-old", "checkpoint_ns": ""}},
metadata={"step": 4},
)
async def aget(self, _config):
return self.snapshot
async def aget_tuple(self, _config):
return SimpleNamespace(checkpoint={"channel_values": dict(self.snapshot.values)})
async def aupdate(self, config, values, *, as_node=None):
return {"configurable": {"thread_id": "thread-manual", "checkpoint_ns": "", "checkpoint_id": "ckpt-compacted"}}
class TestManualCompaction:
@pytest.mark.asyncio
async def test_manual_compact_outside_any_run_falls_back_to_a_detached_store(self, monkeypatch):
monkeypatch.setattr(notify, "_notify_loop", asyncio.get_running_loop())
observer = _CompactionRecorder()
middleware = _middleware(_extensions(observer))
monkeypatch.setattr(context_compaction, "_create_compaction_middleware", lambda **_kwargs: middleware)
result = await context_compaction.compact_thread_context(_Accessor({"messages": _messages()}), "thread-manual", app_config=SimpleNamespace())
assert result.compacted
[(store, event)] = await observer.received()
assert store.scope_id == "detached"
assert store.get(TaskInfo) is None
assert _identity(event) == (None, None, None)