mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-08 03:30:34 +00:00
compact_thread_context built the summarization middleware inline on the
Gateway event loop, and with it the run-profile model and any separately
configured summary model. When the provider reads local credential files
(the Codex provider reads the Codex CLI auth.json while it is constructed),
each POST /api/threads/{thread_id}/compact did synchronous file IO on the
shared loop, once for every model it built, so other requests and streams
waited for it. The route passes no app_config, so the call also read the
live config on the loop first, which stats and hashes config.yaml on every
call. #7767 listed this site in the utils guidance as not covered.
Build the middleware through run_assembly, as #7484 and #7767 do for the
one-shot helpers, skill moderation and the goal evaluator, and read the live
config in that pool too when no app_config is passed. The lead agent already
builds the same middleware there through run_agent's agent factory call.
Model resolution, the memory-flush policy, the disabled, failed and
pending-work outcomes and the written checkpoint are unchanged, and
cancellation during construction neither starts the summary call nor writes
a checkpoint.
tests/blocking_io/test_compaction_model_offloop.py builds real
CodexChatModels under the strict Blockbuster gate. On main, compaction
reported itself disabled because every model build hit BlockingError
(os.stat), and the /compact handler answered 500 because the gate rejected
its config read first.
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
245 lines
10 KiB
Python
245 lines
10 KiB
Python
"""Manual compaction must not build its summarization models on the loop.
|
|
|
|
``POST /api/threads/{thread_id}/compact`` resolves the live config and builds
|
|
the summarization middleware right before the summary call. That build
|
|
constructs the run-profile model and any separately configured summary model.
|
|
Provider construction can read local credentials (Codex CLI ``auth.json``); the
|
|
real Codex provider is built here so that IO stays inside the strict gate,
|
|
while inference stays offline.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import threading
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
from langchain_core.messages import AIMessage, HumanMessage
|
|
from langgraph.checkpoint.memory import InMemorySaver
|
|
from langgraph.types import Overwrite
|
|
|
|
from app.gateway.routers import threads
|
|
from app.gateway.services import build_checkpoint_state_mutation_accessor
|
|
from deerflow.agents.middlewares import summarization_middleware
|
|
from deerflow.config.app_config import AppConfig, get_app_config, reset_app_config
|
|
from deerflow.config.extensions_config import reset_extensions_config
|
|
from deerflow.models.openai_codex_provider import CodexChatModel
|
|
from deerflow.runtime import RunManager
|
|
from deerflow.runtime.context_compaction import ContextCompactionDisabled, compact_thread_context
|
|
from deerflow.runtime.context_keys import CHECKPOINT_AGENT_NAME_METADATA_KEY, DEFAULT_AGENT_NAME_METADATA_VALUE
|
|
from deerflow.runtime.user_context import get_effective_user_id, reset_current_user, set_current_user
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
THREAD_ID = "compaction-thread"
|
|
_SUMMARY = "The user asked for a trip plan and got one."
|
|
_KEEP = ("messages", 1)
|
|
# Memory stays off, so no pre-compaction flush runs; test_summarization_memory_flush.py covers that hook.
|
|
_CONFIG_YAML = """\
|
|
sandbox:
|
|
use: deerflow.sandbox.local:LocalSandboxProvider
|
|
models:
|
|
- name: fixture-codex
|
|
model: fixture-model
|
|
use: deerflow.models.openai_codex_provider:CodexChatModel
|
|
summarization:
|
|
enabled: true
|
|
memory:
|
|
enabled: false
|
|
"""
|
|
|
|
|
|
def _codex_config(**summarization) -> AppConfig:
|
|
return AppConfig.model_validate(
|
|
{
|
|
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"},
|
|
"models": [
|
|
{"name": "fixture-codex", "model": "fixture-model", "use": "deerflow.models.openai_codex_provider:CodexChatModel"},
|
|
{"name": "fixture-summary", "model": "fixture-summary-model", "use": "deerflow.models.openai_codex_provider:CodexChatModel"},
|
|
],
|
|
"summarization": {"enabled": True, **summarization},
|
|
"memory": {"enabled": False},
|
|
}
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def codex(tmp_path, monkeypatch):
|
|
"""Real Codex construction (credential file IO included); offline inference."""
|
|
credentials = tmp_path / "auth.json"
|
|
credentials.write_text(json.dumps({"access_token": "fixture-token", "account_id": "fixture-account"}), encoding="utf-8")
|
|
monkeypatch.setenv("CODEX_AUTH_PATH", str(credentials))
|
|
calls = []
|
|
|
|
async def invoke(model, prompt, config=None, **kwargs):
|
|
calls.append((model, threading.get_ident()))
|
|
return AIMessage(content=_SUMMARY)
|
|
|
|
monkeypatch.setattr(CodexChatModel, "ainvoke", invoke)
|
|
return calls
|
|
|
|
|
|
class _SummaryModel:
|
|
"""A built provider; only its summary call is observed."""
|
|
|
|
_llm_type = "fixture"
|
|
|
|
def __init__(self) -> None:
|
|
self.ainvoke = AsyncMock(return_value=AIMessage(content=_SUMMARY))
|
|
|
|
def with_config(self, **kwargs):
|
|
return self
|
|
|
|
|
|
def _gateway_request():
|
|
state = SimpleNamespace(checkpointer=InMemorySaver(), checkpoint_channel_mode="full", store=None, run_manager=RunManager())
|
|
return SimpleNamespace(app=SimpleNamespace(state=state))
|
|
|
|
|
|
async def _seeded_thread():
|
|
"""A finished thread bound to the default agent, plus its compaction accessor."""
|
|
request = _gateway_request()
|
|
accessor, config = build_checkpoint_state_mutation_accessor(request, thread_id=THREAD_ID, as_node="seed")
|
|
config["metadata"] = {CHECKPOINT_AGENT_NAME_METADATA_KEY: DEFAULT_AGENT_NAME_METADATA_VALUE}
|
|
messages = [HumanMessage(id="h1", content="Plan a trip."), AIMessage(id="a1", content="Here is a plan."), HumanMessage(id="h2", content="Book the hotel.")]
|
|
await accessor.aupdate(config, {"messages": Overwrite(messages)}, as_node="seed")
|
|
accessor, config = build_checkpoint_state_mutation_accessor(request, thread_id=THREAD_ID, as_node="manual_compaction")
|
|
return request, accessor, config
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("summarization", "summary_model"),
|
|
[({}, "fixture-model"), ({"model_name": "fixture-summary"}, "fixture-summary-model")],
|
|
ids=["run-model", "configured-summary-model"],
|
|
)
|
|
async def test_manual_compaction_builds_codex_models_off_loop(codex, summarization, summary_model):
|
|
_, accessor, config = await _seeded_thread()
|
|
|
|
result = await compact_thread_context(accessor, THREAD_ID, keep=_KEEP, app_config=_codex_config(**summarization))
|
|
|
|
# On the loop, the gate rejects every credential read, no model can be
|
|
# built, and compaction reports itself disabled (the route's 409).
|
|
assert (result.compacted, result.removed_message_count, result.preserved_message_count) == (True, 2, 1)
|
|
snapshot = await accessor.aget(config)
|
|
assert [message.id for message in snapshot.values["messages"]] == ["h2"]
|
|
assert snapshot.values["summary_text"] == _SUMMARY
|
|
assert [(model.model, thread) for model, thread in codex] == [(summary_model, threading.get_ident())]
|
|
|
|
|
|
@pytest.mark.parametrize("warm_cache", [False, True], ids=["cold-config", "cached-config"])
|
|
async def test_compact_route_reads_live_config_off_loop(tmp_path, monkeypatch, codex, warm_cache):
|
|
config_path = tmp_path / "config.yaml"
|
|
extensions_path = tmp_path / "extensions_config.json"
|
|
|
|
def seed_files() -> None:
|
|
config_path.write_text(_CONFIG_YAML, encoding="utf-8")
|
|
extensions_path.write_text('{"mcpServers": {}, "skills": {}}', encoding="utf-8")
|
|
|
|
await asyncio.to_thread(seed_files)
|
|
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
|
|
monkeypatch.setattr("deerflow.config.paths._paths", None)
|
|
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path))
|
|
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
|
|
reset_app_config()
|
|
reset_extensions_config()
|
|
request, _, _ = await _seeded_thread()
|
|
try:
|
|
if warm_cache:
|
|
await asyncio.to_thread(get_app_config)
|
|
# The route passes no app_config, so compaction resolves the live
|
|
# config, which stats and hashes config.yaml even on a cache hit.
|
|
response = await threads.compact_thread.__wrapped__(THREAD_ID, threads.ThreadCompactRequest(keep={"type": "messages", "value": 1}), request)
|
|
finally:
|
|
reset_app_config()
|
|
reset_extensions_config()
|
|
|
|
# On the loop, the gate rejects the config read and the route answers 500.
|
|
assert (response.compacted, response.removed_message_count, response.preserved_message_count) == (True, 2, 1)
|
|
assert [(model.model, thread) for model, thread in codex] == [("fixture-model", threading.get_ident())]
|
|
|
|
|
|
async def test_construction_preserves_context_and_keeps_default_pool_available(monkeypatch):
|
|
loop = asyncio.get_running_loop()
|
|
loop.set_default_executor(ThreadPoolExecutor(max_workers=1))
|
|
started = asyncio.Event()
|
|
release = threading.Event()
|
|
observed = []
|
|
model = _SummaryModel()
|
|
|
|
def construct(**kwargs):
|
|
observed.append((get_effective_user_id(), threading.get_ident()))
|
|
loop.call_soon_threadsafe(started.set)
|
|
if not release.wait(timeout=10):
|
|
raise TimeoutError("test construction was not released")
|
|
return model
|
|
|
|
monkeypatch.setattr(summarization_middleware, "create_chat_model", construct)
|
|
_, accessor, _ = await _seeded_thread()
|
|
token = set_current_user(SimpleNamespace(id="request-owner"))
|
|
task = asyncio.create_task(compact_thread_context(accessor, THREAD_ID, keep=_KEEP, app_config=_codex_config()))
|
|
try:
|
|
await asyncio.wait_for(started.wait(), timeout=5)
|
|
assert not task.done()
|
|
assert await asyncio.wait_for(asyncio.to_thread(lambda: "unrelated work"), timeout=5) == "unrelated work"
|
|
finally:
|
|
release.set()
|
|
try:
|
|
await asyncio.wait_for(task, timeout=5)
|
|
finally:
|
|
reset_current_user(token)
|
|
|
|
assert task.result().compacted is True
|
|
assert observed == [("request-owner", observed[0][1])]
|
|
assert observed[0][1] != threading.get_ident()
|
|
model.ainvoke.assert_awaited_once()
|
|
|
|
|
|
async def test_cancelled_construction_does_not_start_inference_or_write(monkeypatch):
|
|
loop = asyncio.get_running_loop()
|
|
started = asyncio.Event()
|
|
finished = asyncio.Event()
|
|
release = threading.Event()
|
|
model = _SummaryModel()
|
|
|
|
def construct(**kwargs):
|
|
loop.call_soon_threadsafe(started.set)
|
|
try:
|
|
if not release.wait(timeout=10):
|
|
raise TimeoutError("test construction was not released")
|
|
return model
|
|
finally:
|
|
loop.call_soon_threadsafe(finished.set)
|
|
|
|
monkeypatch.setattr(summarization_middleware, "create_chat_model", construct)
|
|
_, accessor, config = await _seeded_thread()
|
|
before = await accessor.aget(config)
|
|
task = asyncio.create_task(compact_thread_context(accessor, THREAD_ID, keep=_KEEP, app_config=_codex_config()))
|
|
try:
|
|
await asyncio.wait_for(started.wait(), timeout=5)
|
|
task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
finally:
|
|
release.set()
|
|
await asyncio.wait_for(finished.wait(), timeout=5)
|
|
|
|
model.ainvoke.assert_not_awaited()
|
|
after = await accessor.aget(config)
|
|
assert after.config["configurable"]["checkpoint_id"] == before.config["configurable"]["checkpoint_id"]
|
|
assert [message.id for message in after.values["messages"]] == ["h1", "a1", "h2"]
|
|
|
|
|
|
async def test_construction_failure_still_reports_compaction_disabled(monkeypatch):
|
|
def construct(**kwargs):
|
|
raise ValueError("fixture credential unavailable")
|
|
|
|
monkeypatch.setattr(summarization_middleware, "create_chat_model", construct)
|
|
_, accessor, config = await _seeded_thread()
|
|
|
|
with pytest.raises(ContextCompactionDisabled):
|
|
await compact_thread_context(accessor, THREAD_ID, keep=_KEEP, app_config=_codex_config())
|
|
snapshot = await accessor.aget(config)
|
|
assert [message.id for message in snapshot.values["messages"]] == ["h1", "a1", "h2"]
|