deer-flow/backend/tests/test_context_compaction_observation.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

216 lines
8.3 KiB
Python

"""Compaction destroys the mapping it is observed by.
Summarization replaces N messages with one summary. After the fact, only the
summary survives, so 'which messages became this summary' is not reconstructible
from state — it has to be emitted at the moment of the transform.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from deerflow_extension_api import CompactionEvent, canonical_hash
def test_event_records_both_ends_of_the_transform():
event = CompactionEvent(
transform_kind="summarization",
transform_version="1",
source_content_hashes=("h1", "h2"),
output_content_hash="h3",
compacted_message_count=2,
kept_message_count=4,
)
assert event.source_content_hashes == ("h1", "h2")
assert event.output_content_hash == "h3"
def test_source_hashes_are_a_tuple_so_the_event_cannot_be_mutated_after_emission():
event = CompactionEvent(
transform_kind="summarization",
transform_version="1",
source_content_hashes=("h1",),
output_content_hash="h3",
compacted_message_count=1,
kept_message_count=1,
)
with pytest.raises(AttributeError):
event.output_content_hash = "other"
_UNOBSERVED = object()
def _observed_extensions(observer=None):
"""A real ``LoadedExtensions`` carrying one compaction observer.
``replace`` on the ambient set rather than a hand-built stub: the
middleware reads other fields off ``_extensions`` too (the system-model
call path), so a namespace carrying only the observer tuple would pass
these tests while diverging from what the middleware is handed in
production.
"""
from dataclasses import replace
from deerflow.extensions import get_agent_build_extensions
return replace(get_agent_build_extensions(), context_compaction_observers=(("test-source", observer or (lambda event, context=None: None)),))
def test_source_hashes_are_computed_on_content_directly_not_a_stringified_copy():
"""Regression: hashing ``str(message.content)`` would defeat canonical_hash's
key-order normalization for multimodal (``list[dict]``) content, which
``view_image_middleware`` and other producers routinely inject. Two
logically identical messages whose dict content differs only in key
insertion order must hash the same.
"""
from langchain_core.messages import HumanMessage
from deerflow.agents.middlewares.summarization_middleware import DeerFlowSummarizationMiddleware
a = HumanMessage(content=[{"type": "text", "text": "hi"}, {"b": 1, "a": 2}])
b = HumanMessage(content=[{"type": "text", "text": "hi"}, {"a": 2, "b": 1}])
middleware = DeerFlowSummarizationMiddleware(model=MagicMock(), extensions=_observed_extensions())
hashes = middleware._freeze_compaction_sources([a, b])
assert hashes[0] == hashes[1]
assert hashes[0] == canonical_hash(a.content)
# str() on a dict renders insertion order, so the pre-stringified form
# this guards against would not have matched.
assert str(a.content) != str(b.content)
# --- Driving a real compaction --------------------------------------------
#
# Mirrors tests/test_summarization_middleware.py's `_messages` / `_middleware` /
# `_runtime` fixture helpers rather than inventing a second way to drive the
# middleware: a static model, `token_counter=len`, and a runtime carrying a
# plain `context` mapping.
def _messages() -> list:
from langchain_core.messages import AIMessage, HumanMessage
return [
HumanMessage(content="user-1"),
AIMessage(content="assistant-1"),
HumanMessage(content="user-2"),
AIMessage(content="assistant-2"),
]
def _runtime(thread_id: str | None = "thread-1") -> SimpleNamespace:
context = {}
if thread_id is not None:
context["thread_id"] = thread_id
return SimpleNamespace(context=context)
def _middleware(*, trigger=("messages", 4), keep=("messages", 2), extensions=_UNOBSERVED):
from deerflow.agents.middlewares.summarization_middleware import 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=trigger,
keep=keep,
token_counter=len,
extensions=_observed_extensions() if extensions is _UNOBSERVED else extensions,
)
class TestSummarizationEmitsTheEvent:
@pytest.mark.asyncio
async def test_a_compaction_notifies_observers_once(self, monkeypatch):
from deerflow.agents.middlewares import summarization_middleware
events = []
monkeypatch.setattr(
summarization_middleware,
"notify_context_compacted",
lambda event, extensions=None, task_store=None: events.append(event),
)
middleware = _middleware()
result = await middleware.abefore_model({"messages": _messages()}, _runtime())
assert result is not None
assert len(events) == 1
event = events[0]
assert event.transform_kind == "summarization"
assert event.compacted_message_count == 2
assert event.kept_message_count == 2
assert event.source_content_hashes == (
canonical_hash("user-1"),
canonical_hash("assistant-1"),
)
assert event.output_content_hash == canonical_hash("compressed summary")
@pytest.mark.asyncio
async def test_no_event_is_emitted_when_the_trigger_does_not_fire(self, monkeypatch):
from deerflow.agents.middlewares import summarization_middleware
events = []
monkeypatch.setattr(
summarization_middleware,
"notify_context_compacted",
lambda event, extensions=None, task_store=None: events.append(event),
)
# A trigger threshold far above the message count never fires, so
# compaction never runs and the record half is never reached.
middleware = _middleware(trigger=("messages", 100))
result = await middleware.abefore_model({"messages": _messages()}, _runtime())
assert result is None
assert events == []
class TestAnInstallWithNoObserverPaysNothing:
"""Hashing the sources is an O(context-size) canonical-JSON pass.
Every install runs this middleware; almost none of them register a
compaction observer. The check cannot live in ``notify_context_compacted``
— by the time it is called the hashing has already happened — so the freeze
site has to make it itself.
"""
def test_the_sources_are_not_hashed_when_nothing_observes(self):
from dataclasses import replace
from deerflow.extensions import get_agent_build_extensions
unobserved = replace(get_agent_build_extensions(), context_compaction_observers=())
middleware = _middleware(extensions=unobserved)
assert middleware._freeze_compaction_sources(_messages()) == ()
def test_the_sources_are_hashed_when_an_observer_is_registered(self):
middleware = _middleware(extensions=_observed_extensions())
assert middleware._freeze_compaction_sources(_messages()) == tuple(canonical_hash(m.content) for m in _messages())
@pytest.mark.asyncio
async def test_the_compaction_itself_still_happens_unobserved(self, monkeypatch):
"""The skip must cost the run nothing but the hashes."""
from dataclasses import replace
from deerflow.agents.middlewares import summarization_middleware
from deerflow.extensions import get_agent_build_extensions
events = []
monkeypatch.setattr(summarization_middleware, "notify_context_compacted", lambda event, extensions=None, task_store=None: events.append(event))
unobserved = replace(get_agent_build_extensions(), context_compaction_observers=())
result = await _middleware(extensions=unobserved).abefore_model({"messages": _messages()}, _runtime())
assert result is not None, "compaction must still run; only the observation bookkeeping is skipped"
# The middleware still calls notify (which would itself no-op on the
# empty observer tuple); what it must not do is compute the hashes.
assert [e.source_content_hashes for e in events] == [()]