mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
* fix(summarization): cover all selected history before compaction * fix(summarization): bound compaction work and recover oversized carries * docs: leave headroom in middleware guidance chain * fix(summarization): cool down repeated chunk-limit failures * test(runtime): complete callback cleanup graph fixture --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
360 lines
17 KiB
Python
360 lines
17 KiB
Python
"""Cost guards and recoverable carries must never truncate selected source."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import html
|
|
import re
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, Mock
|
|
|
|
import pytest
|
|
from langchain_core.language_models import BaseChatModel
|
|
from langchain_core.messages import AIMessage, HumanMessage, get_buffer_string
|
|
from langchain_core.outputs import ChatGeneration, ChatResult
|
|
from pydantic import Field
|
|
|
|
from deerflow.agents.middlewares.pii_redaction_middleware import redact_text
|
|
from deerflow.agents.middlewares.summarization_middleware import DeerFlowSummarizationMiddleware, SummaryGenerationError
|
|
from deerflow.config.pii_redaction_config import PiiRedactionConfig
|
|
from deerflow.config.task_continuity_config import TaskContinuityConfig
|
|
|
|
|
|
def _text(length):
|
|
return " ".join(f"evidence_{index:05d} remains relevant" for index in range(length // 20 + 2))[:length]
|
|
|
|
|
|
class FreeModel(BaseChatModel):
|
|
reply: str = "S"
|
|
hard_limit: int = 70
|
|
prompts: list[str] = Field(default_factory=list)
|
|
guard_hits: int = 0
|
|
|
|
@property
|
|
def _llm_type(self):
|
|
return "free-cost-recovery-regression"
|
|
|
|
def _generate(self, messages, stop=None, run_manager=None, **kwargs):
|
|
if len(self.prompts) >= self.hard_limit:
|
|
self.guard_hits += 1
|
|
raise RuntimeError("free regression model call limit reached")
|
|
self.prompts.append("\n".join(str(message.content) for message in messages))
|
|
return ChatResult(generations=[ChatGeneration(message=AIMessage(content=self.reply))])
|
|
|
|
async def _agenerate(self, messages, stop=None, run_manager=None, **kwargs):
|
|
return self._generate(messages, stop=stop, run_manager=run_manager, **kwargs)
|
|
|
|
|
|
class CountingChars:
|
|
def __init__(self):
|
|
self.input_characters = 0
|
|
|
|
def __call__(self, messages):
|
|
length = sum(len(message.content) for message in messages)
|
|
self.input_characters += length
|
|
return length
|
|
|
|
|
|
def _middleware(model, *, budget=512, default_counter=False, **kwargs):
|
|
options = {
|
|
"model": model,
|
|
"trigger": ("messages", 4),
|
|
"keep": ("messages", 2),
|
|
"trim_tokens_to_summarize": budget,
|
|
"summary_prompt": "{messages}",
|
|
"anchor_model_name": "offline",
|
|
"run_model_name": "offline",
|
|
}
|
|
if not default_counter:
|
|
options["token_counter"] = CountingChars()
|
|
options.update(kwargs)
|
|
return DeerFlowSummarizationMiddleware(**options)
|
|
|
|
|
|
def _state(text, previous=None):
|
|
selected = [HumanMessage(content=text, id="old-user"), AIMessage(content="old answer", id="old-ai")]
|
|
state = {
|
|
"messages": [*selected, HumanMessage(content="current request", id="current-user"), AIMessage(content="current answer", id="current-ai")],
|
|
"summary_text": previous,
|
|
}
|
|
return state, selected
|
|
|
|
|
|
def _sections(prompt):
|
|
return {name: html.unescape(body) for name, body in re.findall(r"<(existing_summary|new_messages)>\n(.*?)\n</\1>", prompt, re.DOTALL)}
|
|
|
|
|
|
def _assert_full_source_and_budget(model, selected, counter, budget, pii=None):
|
|
sections = [_sections(prompt) for prompt in model.prompts]
|
|
source = "".join(part["new_messages"] for part in sections)
|
|
assert source == redact_text(get_buffer_string(selected), pii)
|
|
for part in sections:
|
|
assert sum(counter([HumanMessage(content=text)]) for text in part.values()) <= budget
|
|
|
|
|
|
def _assert_shortened(text, start, end):
|
|
assert text.startswith(start) and text.endswith(end)
|
|
assert re.search(r"shorten|truncat|omitt|缩短|省略|截断", text, re.IGNORECASE), "summary shortening needs an explicit marker"
|
|
|
|
|
|
async def _compact(middleware, state, asynchronous):
|
|
runtime = SimpleNamespace(context={})
|
|
return await middleware.acompact_state(state, runtime, force=True) if asynchronous else middleware.compact_state(state, runtime, force=True)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"])
|
|
async def test_near_budget_carry_recovers_with_default_counter_and_complete_source(asynchronous):
|
|
model = FreeModel(hard_limit=8)
|
|
middleware = _middleware(model, budget=4000, default_counter=True)
|
|
# Default approximate counter, not a custom tiny-budget token model.
|
|
low, high, previous = 0, 32000, ""
|
|
while low <= high:
|
|
middle = (low + high) // 2
|
|
candidate = _text(middle)
|
|
if middleware.token_counter([HumanMessage(content=candidate)]) <= 3990:
|
|
previous, low = candidate, middle + 1
|
|
else:
|
|
high = middle - 1
|
|
assert middleware.token_counter([HumanMessage(content=previous)]) == 3990
|
|
model.reply = previous
|
|
state, selected = _state(_text(780), previous)
|
|
|
|
result = await _compact(middleware, state, asynchronous)
|
|
|
|
assert result is not None, "a verbose carry must not consume all useful source progress"
|
|
assert model.guard_hits == 0 and len(model.prompts) <= 4
|
|
assert middleware.token_counter([HumanMessage(content=result.summary_text)]) <= 2000
|
|
_assert_full_source_and_budget(model, selected, middleware.token_counter, 4000)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"])
|
|
@pytest.mark.parametrize("manual", [False, True], ids=["automatic", "manual"])
|
|
async def test_64_chunk_policy_aborts_without_committing_any_partial_result(monkeypatch, asynchronous, manual):
|
|
from deerflow.agents.middlewares import summarization_middleware as module
|
|
from deerflow.agents.task_continuity import archive
|
|
|
|
budget = 128
|
|
model = FreeModel(hard_limit=70)
|
|
hook = Mock()
|
|
observed = Mock()
|
|
capture, acapture = Mock(), AsyncMock()
|
|
monkeypatch.setattr(module, "notify_context_compacted", observed)
|
|
monkeypatch.setattr(archive, "capture", capture)
|
|
monkeypatch.setattr(archive, "acapture", acapture)
|
|
middleware = _middleware(model, budget=budget, before_summarization=[hook], task_continuity_config=TaskContinuityConfig(enabled=True))
|
|
state, selected = _state(_text(budget * 80), "S")
|
|
state["task_history"] = {"already": "captured"}
|
|
original = copy.deepcopy(state)
|
|
assert len(get_buffer_string(selected)) > 64 * budget
|
|
runtime = SimpleNamespace(context={})
|
|
|
|
if manual:
|
|
with pytest.raises(SummaryGenerationError):
|
|
if asynchronous:
|
|
await middleware.acompact_state(state, runtime, force=True, raise_on_failure=True)
|
|
else:
|
|
middleware.compact_state(state, runtime, force=True, raise_on_failure=True)
|
|
else:
|
|
result = await middleware.abefore_model(state, runtime) if asynchronous else middleware.before_model(state, runtime)
|
|
assert result is None
|
|
|
|
# The fake has its own independent 70-call stop to keep a broken product
|
|
# guard free and bounded; hitting that fake stop is NOT passing this test.
|
|
assert 0 < len(model.prompts) <= 64
|
|
assert model.guard_hits == 0
|
|
assert state == original
|
|
assert middleware.summary_call_count == middleware.summary_noop_count == middleware.summary_skip_count == 0
|
|
hook.assert_not_called()
|
|
observed.assert_not_called()
|
|
capture.assert_not_called()
|
|
acapture.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"])
|
|
async def test_final_generated_summary_is_shortened_and_next_turn_can_compact(asynchronous):
|
|
reply = "GENERATED_START " + _text(1400) + " GENERATED_END"
|
|
model = FreeModel(reply=reply)
|
|
middleware = _middleware(model)
|
|
state, selected = _state("a short older request")
|
|
|
|
first = await _compact(middleware, state, asynchronous)
|
|
|
|
assert first is not None
|
|
assert middleware.token_counter([HumanMessage(content=first.summary_text)]) <= 256
|
|
_assert_shortened(first.summary_text, "GENERATED_START", "GENERATED_END")
|
|
_assert_full_source_and_budget(model, selected, middleware.token_counter, 512)
|
|
followup = {
|
|
"messages": [*first.preserved_messages, HumanMessage(content="next request", id="next-user"), AIMessage(content="next answer", id="next-ai")],
|
|
"summary_text": first.summary_text,
|
|
}
|
|
model.reply = "S"
|
|
calls_before = len(model.prompts)
|
|
second = await _compact(middleware, followup, asynchronous)
|
|
assert second is not None and len(model.prompts) > calls_before
|
|
assert middleware.summary_call_count == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"])
|
|
async def test_oversized_legacy_summary_recovers_without_clipping_new_source(asynchronous):
|
|
previous = "LEGACY_START " + _text(1500) + " LEGACY_END"
|
|
model = FreeModel(reply="A compact carry")
|
|
middleware = _middleware(model)
|
|
state, selected = _state(_text(2400), previous)
|
|
|
|
result = await _compact(middleware, state, asynchronous)
|
|
|
|
assert result is not None and model.guard_hits == 0
|
|
first_carry = _sections(model.prompts[0])["existing_summary"]
|
|
assert middleware.token_counter([HumanMessage(content=first_carry)]) <= 256
|
|
_assert_shortened(first_carry, "LEGACY_START", "LEGACY_END")
|
|
_assert_full_source_and_budget(model, selected, middleware.token_counter, 512)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"])
|
|
async def test_shortening_preserves_unicode_ends_and_redacts_pii_before_slicing(asynchronous):
|
|
pii = PiiRedactionConfig(enabled=True, token_secret="carry-shortening-test-secret-0123456789")
|
|
previous = "LEGACY_START中🦉e\u0301 alice@example.com " + "中文<字段>& " * 180 + " bob@example.org LEGACY_END火🦊"
|
|
reply = "GENERATED_START中🦉e\u0301 " + "摘要<内容>& " * 180 + " GENERATED_END火🦊"
|
|
model = FreeModel(reply=reply)
|
|
middleware = _middleware(model, app_config=SimpleNamespace(pii_redaction=pii))
|
|
state, selected = _state("source contact source.person@example.net " + "完整事实🧭<项目>& " * 120, previous)
|
|
|
|
result = await _compact(middleware, state, asynchronous)
|
|
|
|
assert result is not None and model.guard_hits == 0
|
|
result.summary_text.encode("utf-8")
|
|
_assert_shortened(result.summary_text, "GENERATED_START中🦉e\u0301", "GENERATED_END火🦊")
|
|
_assert_shortened(_sections(model.prompts[0])["existing_summary"], "LEGACY_START中🦉e\u0301", "LEGACY_END火🦊")
|
|
assert all(email not in prompt for email in ("alice@example.com", "bob@example.org", "source.person@example.net") for prompt in model.prompts)
|
|
_assert_full_source_and_budget(model, selected, middleware.token_counter, 512, pii)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unbounded_budget_keeps_full_carry_and_uses_one_step():
|
|
previous = "LEGACY_START " + _text(1500) + " LEGACY_END"
|
|
reply = "GENERATED_START " + _text(2000) + " GENERATED_END"
|
|
model = FreeModel(reply=reply)
|
|
middleware = _middleware(model, budget=None)
|
|
state, selected = _state(_text(2400), previous)
|
|
|
|
result = await _compact(middleware, state, True)
|
|
|
|
assert result is not None and result.summary_text == reply
|
|
assert len(model.prompts) == 1
|
|
assert _sections(model.prompts[0])["existing_summary"] == previous
|
|
assert _sections(model.prompts[0])["new_messages"] == get_buffer_string(selected)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_counter_input_work_grows_with_source_without_full_suffix_rescans():
|
|
work = []
|
|
for length in (2048, 8192):
|
|
counter = CountingChars()
|
|
model = FreeModel(reply="short carry")
|
|
middleware = _middleware(model, token_counter=counter)
|
|
state, selected = _state(_text(length), "S")
|
|
result = await _compact(middleware, state, True)
|
|
assert result is not None and model.guard_hits == 0
|
|
input_characters = counter.input_characters
|
|
_assert_full_source_and_budget(model, selected, CountingChars(), 512)
|
|
work.append(input_characters)
|
|
# Four times the source may have fixed overhead and chunk-boundary effects;
|
|
# allow generous slack, while rejecting the old full-suffix quadratic term.
|
|
assert work[1] <= work[0] * 5.5, f"counter input work grew superlinearly: {work}"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"])
|
|
async def test_exactly_64_chunks_can_finish_with_an_extra_fallback_attempt(asynchronous):
|
|
class FailFirstModel(FreeModel):
|
|
failed: bool = False
|
|
|
|
def _generate(self, messages, stop=None, run_manager=None, **kwargs):
|
|
if not self.failed:
|
|
self.failed = True
|
|
self.prompts.append("\n".join(str(message.content) for message in messages))
|
|
raise RuntimeError("one primary attempt needs the configured fallback")
|
|
return super()._generate(messages, stop=stop, run_manager=run_manager, **kwargs)
|
|
|
|
primary, fallback = FailFirstModel(), FreeModel()
|
|
budget = 64
|
|
middleware = _middleware(primary, budget=budget, anchor_model_name="primary", configured_model_name="primary", run_model_name="fallback", prebuilt_models={"fallback": fallback})
|
|
# A full one-character carry leaves 63 source characters per bounded call.
|
|
# A single serialized line avoids optional newline boundary adjustments.
|
|
text = _text((budget - 1) * 64 - len("Human: "))
|
|
selected = [HumanMessage(content=text, id="old-user")]
|
|
state = {"messages": [*selected, HumanMessage(content="current", id="current-user"), AIMessage(content="recent", id="current-ai")], "summary_text": "S"}
|
|
assert len(get_buffer_string(selected)) == (budget - 1) * 64
|
|
|
|
result = await _compact(middleware, state, asynchronous)
|
|
|
|
assert result is not None, "the 64th completed chunk is permitted; the guard applies before a 65th chunk"
|
|
assert len(primary.prompts) == 64
|
|
assert len(fallback.prompts) == 1
|
|
assert primary.guard_hits == fallback.guard_hits == 0
|
|
assert fallback.prompts[0] == primary.prompts[0]
|
|
_assert_full_source_and_budget(primary, selected, middleware.token_counter, budget)
|
|
assert middleware.summary_call_count == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"])
|
|
async def test_clamped_noop_cache_skips_calls_without_claiming_original_summary_unchanged(asynchronous):
|
|
previous = "LEGACY_START " + _text(1400) + " LEGACY_END"
|
|
model = FreeModel(reply=previous)
|
|
middleware = _middleware(model)
|
|
state, _ = _state("selected source remains present until a successful commit", previous)
|
|
original = copy.deepcopy(state)
|
|
|
|
first = await _compact(middleware, state, asynchronous)
|
|
assert first is not None and first.summary_text != previous
|
|
assert len(model.prompts) == 1
|
|
assert middleware.summary_noop_count == middleware.summary_skip_count == 0
|
|
|
|
second = await _compact(middleware, state, asynchronous)
|
|
assert second is not None and second.summary_text == first.summary_text
|
|
assert len(model.prompts) == 1
|
|
assert middleware.summary_skip_count == 1
|
|
assert middleware.summary_noop_count == 0, "cache reuse did not preserve the original oversized summary byte-for-byte"
|
|
assert state == original
|
|
|
|
changed = copy.deepcopy(state)
|
|
changed["messages"][0] = changed["messages"][0].model_copy(update={"content": "a newly selected source"})
|
|
third = await _compact(middleware, changed, asynchronous)
|
|
assert third is not None
|
|
assert len(model.prompts) == 2
|
|
assert _sections(model.prompts[-1])["new_messages"] == get_buffer_string(changed["messages"][:2])
|
|
assert middleware.summary_call_count == 3
|
|
assert middleware.summary_skip_count == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"])
|
|
async def test_weighted_unicode_counter_can_exceed_character_count_without_losing_source(asynchronous):
|
|
def weighted_tokens(messages):
|
|
return sum(1 if character.isascii() else 5 for message in messages for character in message.content)
|
|
|
|
# Unique numeric labels ensure every source fragment has a distinct prompt,
|
|
# so no-op cache reuse cannot hide repeated source from the model recorder.
|
|
text = " ".join(f"{index:03d}中🦉e\u0301火<{index:03d}>&" for index in range(40))
|
|
state, selected = _state(text, "S")
|
|
serialized = get_buffer_string(selected)
|
|
assert weighted_tokens([HumanMessage(content=serialized)]) > len(serialized)
|
|
assert weighted_tokens([HumanMessage(content="中🦉e\u0301火")]) > len("中🦉e\u0301火")
|
|
model = FreeModel()
|
|
budget = 256
|
|
middleware = _middleware(model, budget=budget, token_counter=weighted_tokens)
|
|
|
|
result = await _compact(middleware, state, asynchronous)
|
|
|
|
assert result is not None and model.guard_hits == 0
|
|
assert len(model.prompts) > 1
|
|
assert len(set(model.prompts)) == len(model.prompts)
|
|
_assert_full_source_and_budget(model, selected, weighted_tokens, budget)
|
|
assert weighted_tokens([HumanMessage(content=result.summary_text)]) <= budget // 2
|