mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +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>
143 lines
6.7 KiB
Python
143 lines
6.7 KiB
Python
"""A breached compaction budget must not be re-spent on every turn."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
from collections import OrderedDict
|
|
from types import SimpleNamespace
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
from langchain_core.messages import AIMessage, HumanMessage
|
|
from test_summarization_cost_recovery import FreeModel, _middleware, _state, _text
|
|
|
|
from deerflow.agents.middlewares import summarization_middleware as module
|
|
from deerflow.agents.middlewares.summarization_middleware import SummaryGenerationError
|
|
|
|
|
|
class ProgressModel(FreeModel):
|
|
"""Produce a new carry each time so the no-op cache cannot hide spend."""
|
|
|
|
def _generate(self, messages, stop=None, run_manager=None, **kwargs):
|
|
self.reply = f"summary-{len(self.prompts) + 1}"
|
|
return super()._generate(messages, stop=stop, run_manager=run_manager, **kwargs)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def isolated_cooldowns(monkeypatch):
|
|
monkeypatch.setattr(module, "_SUMMARY_CHUNK_RETRY_CACHE", OrderedDict(), raising=False)
|
|
|
|
|
|
async def compact(middleware, state, runtime, asynchronous, manual=False):
|
|
method = middleware.acompact_state if asynchronous else middleware.compact_state
|
|
if manual:
|
|
with pytest.raises(SummaryGenerationError):
|
|
if asynchronous:
|
|
await method(state, runtime, force=True, raise_on_failure=True)
|
|
else:
|
|
method(state, runtime, force=True, raise_on_failure=True)
|
|
return None
|
|
return await middleware.abefore_model(state, runtime) if asynchronous else middleware.before_model(state, runtime)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"])
|
|
@pytest.mark.parametrize("manual", [False, True], ids=["automatic", "manual"])
|
|
@pytest.mark.parametrize("rebuild", [False, True], ids=["same-instance", "new-instance"])
|
|
async def test_breach_cooldown_survives_new_turns_and_middleware_rebuilds(monkeypatch, caplog, asynchronous, manual, rebuild):
|
|
runtime = SimpleNamespace(context={"thread_id": "over-limit", "user_id": "alice"})
|
|
state, _ = _state(_text(128 * 80), "S")
|
|
original = copy.deepcopy(state)
|
|
model = ProgressModel(hard_limit=140)
|
|
hook, observed = Mock(), Mock()
|
|
monkeypatch.setattr(module, "notify_context_compacted", observed)
|
|
middleware = _middleware(model, budget=128, before_summarization=[hook])
|
|
assert await compact(middleware, state, runtime, asynchronous, manual) is None
|
|
assert len(model.prompts) == 64 and model.guard_hits == 0
|
|
assert state == original
|
|
# A full-source hash would miss the next trigger as history grows.
|
|
state["messages"].extend([HumanMessage(content="next request", id="next-user"), AIMessage(content="next answer", id="next-ai")])
|
|
original = copy.deepcopy(state)
|
|
if rebuild:
|
|
middleware = _middleware(model, budget=128, before_summarization=[hook])
|
|
assert await compact(middleware, state, runtime, asynchronous, manual) is None
|
|
assert len(model.prompts) == 64, "the blocked retry must make no additional model calls"
|
|
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()
|
|
assert "cooldown" in caplog.text
|
|
assert "trim_tokens_to_summarize" in caplog.text
|
|
assert "evidence_" not in caplog.text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"])
|
|
async def test_cooldown_expires_without_sleeping(monkeypatch, asynchronous):
|
|
clock = [1000.0]
|
|
monkeypatch.setattr(module, "monotonic", lambda: clock[0], raising=False)
|
|
runtime = SimpleNamespace(context={"thread_id": "expiry", "user_id": "alice"})
|
|
state, _ = _state(_text(128 * 80), "S")
|
|
model = ProgressModel(hard_limit=140)
|
|
middleware = _middleware(model, budget=128)
|
|
assert await compact(middleware, state, runtime, asynchronous) is None
|
|
assert len(model.prompts) == 64
|
|
clock[0] += 299
|
|
assert await compact(middleware, state, runtime, asynchronous) is None
|
|
assert len(model.prompts) == 64
|
|
clock[0] += 1
|
|
assert await compact(middleware, state, runtime, asynchronous) is None
|
|
assert len(model.prompts) == 128 and model.guard_hits == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"])
|
|
@pytest.mark.parametrize("remedy", ["larger-budget", "smaller-source"])
|
|
async def test_remediation_can_retry_immediately(asynchronous, remedy):
|
|
runtime = SimpleNamespace(context={"thread_id": "remedy", "user_id": "alice"})
|
|
state, _ = _state(_text(128 * 80), "S")
|
|
model = ProgressModel(hard_limit=70)
|
|
middleware = _middleware(model, budget=128)
|
|
assert await compact(middleware, state, runtime, asynchronous) is None
|
|
assert len(model.prompts) == 64
|
|
if remedy == "larger-budget":
|
|
middleware = _middleware(model, budget=20000)
|
|
else:
|
|
state["messages"][0] = state["messages"][0].model_copy(update={"content": "shortened input"})
|
|
assert await compact(middleware, state, runtime, asynchronous) is not None
|
|
assert 64 < len(model.prompts) <= 66 and model.guard_hits == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("scope", ["user", "thread", "agent", "window", "subagent"])
|
|
async def test_breach_does_not_suppress_another_execution_scope(scope):
|
|
runtime = SimpleNamespace(context={"thread_id": "shared-id", "user_id": "alice", "agent_name": "researcher"})
|
|
if scope == "subagent":
|
|
runtime.context.update(is_subagent=True, agent_id="child", sandbox_lease_owner_id="execution-1")
|
|
state, _ = _state(_text(128 * 80), "S")
|
|
first = ProgressModel()
|
|
assert await compact(_middleware(first, budget=128), state, runtime, True) is None
|
|
assert len(first.prompts) == 64
|
|
if scope == "subagent":
|
|
runtime.context["sandbox_lease_owner_id"] = "execution-2"
|
|
elif scope == "window":
|
|
state["messages"][0] = state["messages"][0].model_copy(update={"id": "different-window"})
|
|
else:
|
|
key = {"user": "user_id", "thread": "thread_id", "agent": "agent_name"}[scope]
|
|
runtime.context[key] = "another-value"
|
|
other = ProgressModel()
|
|
assert await compact(_middleware(other, budget=128), state, runtime, True) is None
|
|
assert len(other.prompts) == 64 and other.guard_hits == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ordinary_generation_failure_does_not_start_chunk_cooldown():
|
|
runtime = SimpleNamespace(context={"thread_id": "transient", "user_id": "alice"})
|
|
state, _ = _state("short source", "S")
|
|
model = FreeModel(reply="")
|
|
middleware = _middleware(model)
|
|
assert await compact(middleware, state, runtime, True) is None
|
|
model.reply = "Recovered summary"
|
|
assert await compact(middleware, state, runtime, True) is not None
|
|
assert len(model.prompts) == 2
|