mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-21 10:15:47 +00:00
feat: add manual context compaction (#3969)
* feat: add manual context compaction * fix: harden manual context compaction
This commit is contained in:
@@ -64,6 +64,7 @@ DeerFlow has newly integrated the intelligent search and crawling toolset indepe
|
||||
- [Skills \& Tools](#skills--tools)
|
||||
- [Claude Code Integration](#claude-code-integration)
|
||||
- [Session Goals](#session-goals)
|
||||
- [Manual Context Compaction](#manual-context-compaction)
|
||||
- [Sub-Agents](#sub-agents)
|
||||
- [Sandbox \& File System](#sandbox--file-system)
|
||||
- [Context Engineering](#context-engineering)
|
||||
@@ -690,6 +691,10 @@ After each Gateway-backed run, DeerFlow evaluates the visible conversation again
|
||||
|
||||
The Web UI shows the active goal above the composer. The same command is available from the TUI and supported IM channels. In the Web UI and supported IM channels, setting `/goal <completion condition>` also starts a run with the condition as the task; status and clear commands only manage goal state.
|
||||
|
||||
### Manual Context Compaction
|
||||
|
||||
Use `/compact` in the Web UI composer to summarize older context for the current thread. DeerFlow keeps the full chat visible, but future model calls use the compacted summary plus recent messages. The command is ignored when there is not enough history to compact, and it is blocked while the thread has a run in flight.
|
||||
|
||||
### Sub-Agents
|
||||
|
||||
Complex tasks rarely fit in a single pass. DeerFlow decomposes them.
|
||||
|
||||
@@ -60,6 +60,7 @@ DeerFlow 新近集成了 BytePlus 自研的智能搜索与抓取工具集——[
|
||||
- [Skills 与 Tools](#skills-与-tools)
|
||||
- [Claude Code 集成](#claude-code-集成)
|
||||
- [Session Goals](#session-goals)
|
||||
- [手动上下文压缩](#手动上下文压缩)
|
||||
- [Sub-Agents](#sub-agents)
|
||||
- [Sandbox 与文件系统](#sandbox-与文件系统)
|
||||
- [Context Engineering](#context-engineering)
|
||||
@@ -574,6 +575,10 @@ DEERFLOW_LANGGRAPH_URL=http://localhost:2026/api/langgraph # LangGraph API
|
||||
|
||||
Web UI 会在输入框上方展示当前激活的 goal。同样的命令在 TUI 和受支持的 IM 渠道里也可用。在 Web UI 和受支持的 IM 渠道里,设置 `/goal <完成条件>` 还会以该条件作为任务启动一次 run;状态查询和清除命令则只管理 goal 状态本身。
|
||||
|
||||
### 手动上下文压缩
|
||||
|
||||
在 Web UI 输入框中使用 `/compact`,可以把当前 thread 的早期上下文压缩成摘要。完整聊天记录仍会保留在界面上,但后续模型调用会基于压缩摘要和最近消息继续。当前历史不足时不会压缩;thread 正在运行任务时会阻止压缩。
|
||||
|
||||
### Sub-Agents
|
||||
|
||||
复杂任务通常不可能一次完成,DeerFlow 会先拆解,再执行。
|
||||
|
||||
+7
-1
@@ -312,7 +312,7 @@ CORS is same-origin by default when requests enter through nginx on port 2026. S
|
||||
| **Skills** (`/api/skills`) | `GET /` - list skills; `GET /{name}` - details; `PUT /{name}` - update enabled; `POST /install` - install from .skill archive (accepts standard optional frontmatter like `version`, `author`, `compatibility`) |
|
||||
| **Memory** (`/api/memory`) | `GET /` - memory data; `POST /reload` - force reload; `GET /config` - config; `GET /status` - config + data |
|
||||
| **Uploads** (`/api/threads/{id}/uploads`) | `POST /` - upload files (auto-converts PDF/PPT/Excel/Word); `GET /list` - list; `DELETE /{filename}` - delete |
|
||||
| **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; `POST /branches` - create a new main-thread branch from a completed assistant turn checkpoint. Workspace files are not checkpointed, so the branch only best-effort copies the current workspace when branching from the **latest** turn (`workspace_clone_mode="current_thread_best_effort"`); branching from an older/historical turn skips the copy (`workspace_clone_mode="skipped_historical_turn"`) so the branch never inherits files that only exist in a later timeline; `GET /goal`, `PUT /goal`, `DELETE /goal` - read, set, and clear the active thread goal; unexpected failures are logged server-side and return a generic 500 detail |
|
||||
| **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; `POST /branches` - create a new main-thread branch from a completed assistant turn checkpoint. Workspace files are not checkpointed, so the branch only best-effort copies the current workspace when branching from the **latest** turn (`workspace_clone_mode="current_thread_best_effort"`); branching from an older/historical turn skips the copy (`workspace_clone_mode="skipped_historical_turn"`) so the branch never inherits files that only exist in a later timeline; `GET /goal`, `PUT /goal`, `DELETE /goal` - read, set, and clear the active thread goal; `POST /compact` - manually summarize older active context into `summary_text` and retain the recent message window, blocked while a run is in flight; unexpected failures are logged server-side and return a generic 500 detail |
|
||||
| **Artifacts** (`/api/threads/{id}/artifacts`) | `GET /{path}` - serve artifacts; active content types (`text/html`, `application/xhtml+xml`, `image/svg+xml`) are always forced as download attachments to reduce XSS risk; `?download=true` still forces download for other file types |
|
||||
| **Suggestions** (`/api/suggestions`) | `GET /config` - returns global suggestions config boolean; `POST /threads/{id}/suggestions` - generate follow-up questions; rich list/block model content is normalized and inline reasoning (`<think>...</think>`, including unclosed/truncated blocks from reasoning models like MiniMax-M3) is stripped before JSON parsing |
|
||||
| **Thread Runs** (`/api/threads/{id}/runs`) | `POST /` - create background run; `POST /stream` - create + SSE stream; `POST /wait` - create + block; `POST /regenerate/prepare` - prepare clean input + checkpoint metadata for regenerating the latest assistant answer; `GET /` - list runs; `GET /{rid}` - run details; `POST /{rid}/cancel` - cancel; `GET /{rid}/join` - join SSE; `GET /{rid}/messages` - paginated messages `{data, has_more}`; `GET /{rid}/events` - full event stream; `GET /{rid}/workspace-changes` - workspace/output file change summary and optional diffs; `GET /../messages` - thread messages with feedback; `GET /../token-usage` - aggregate tokens |
|
||||
@@ -843,6 +843,12 @@ Automatic conversation summarization when approaching token limits:
|
||||
- Configured in `config.yaml` under `summarization` key
|
||||
- Trigger types: tokens, messages, or fraction of max input
|
||||
- Keeps recent messages while summarizing older ones
|
||||
- Manual compaction uses `POST /api/threads/{id}/compact`, reuses the same
|
||||
`DeerFlowSummarizationMiddleware`, writes a new checkpoint with updated
|
||||
`messages` and `summary_text`, and bumps only those channel versions.
|
||||
The route shares the per-thread serialization gate used by `/goal` writes
|
||||
and run admission so compaction cannot race with goal updates or runs that
|
||||
read/write checkpoints.
|
||||
|
||||
See [docs/summarization.md](docs/summarization.md) for details.
|
||||
|
||||
|
||||
@@ -24,12 +24,26 @@ from langgraph.checkpoint.base import empty_checkpoint, uuid6
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from app.gateway.authz import require_permission
|
||||
from app.gateway.deps import get_checkpointer
|
||||
from app.gateway.deps import get_checkpointer, get_run_manager
|
||||
from app.gateway.internal_auth import get_trusted_internal_owner_user_id
|
||||
from app.gateway.utils import sanitize_log_param
|
||||
from deerflow.config.paths import Paths, get_paths
|
||||
from deerflow.config.summarization_config import ContextSize
|
||||
from deerflow.runtime import serialize_channel_values_for_api
|
||||
from deerflow.runtime.goal import DEFAULT_MAX_GOAL_CONTINUATIONS, build_goal_state, ensure_thread_checkpoint, goal_thread_lock, read_thread_goal, write_thread_goal
|
||||
from deerflow.runtime.context_compaction import (
|
||||
ContextCompactionDisabled,
|
||||
ContextCompactionFailed,
|
||||
ThreadCompactionResult,
|
||||
compact_thread_context,
|
||||
)
|
||||
from deerflow.runtime.goal import (
|
||||
DEFAULT_MAX_GOAL_CONTINUATIONS,
|
||||
build_goal_state,
|
||||
ensure_thread_checkpoint,
|
||||
goal_thread_lock,
|
||||
read_thread_goal,
|
||||
write_thread_goal,
|
||||
)
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
from deerflow.utils.file_io import run_file_io
|
||||
from deerflow.utils.time import coerce_iso, now_iso
|
||||
@@ -319,6 +333,27 @@ class ThreadGoalResponse(BaseModel):
|
||||
goal: dict[str, Any] | None = Field(default=None, description="Current goal state, or null when no goal is active")
|
||||
|
||||
|
||||
class ThreadCompactRequest(BaseModel):
|
||||
"""Request body for manually compacting a thread's active context."""
|
||||
|
||||
force: bool = Field(default=True, description="Run compaction even if automatic summarization thresholds are not met")
|
||||
keep: ContextSize | None = Field(default=None, description="Optional retention policy for this compaction only")
|
||||
agent_name: str | None = Field(default=None, max_length=128, description="Optional custom agent name for memory attribution")
|
||||
|
||||
|
||||
class ThreadCompactResponse(BaseModel):
|
||||
"""Response model for manual thread-context compaction."""
|
||||
|
||||
thread_id: str
|
||||
compacted: bool
|
||||
reason: str | None = None
|
||||
removed_message_count: int = 0
|
||||
preserved_message_count: int = 0
|
||||
summary_updated: bool = False
|
||||
checkpoint_id: str | None = None
|
||||
total_tokens: int = 0
|
||||
|
||||
|
||||
class HistoryEntry(BaseModel):
|
||||
"""Single checkpoint history entry."""
|
||||
|
||||
@@ -807,6 +842,52 @@ async def clear_thread_goal(thread_id: str, request: Request) -> ThreadGoalRespo
|
||||
return ThreadGoalResponse(goal=None)
|
||||
|
||||
|
||||
def _thread_compact_response(result: ThreadCompactionResult) -> ThreadCompactResponse:
|
||||
return ThreadCompactResponse(
|
||||
thread_id=result.thread_id,
|
||||
compacted=result.compacted,
|
||||
reason=result.reason,
|
||||
removed_message_count=result.removed_message_count,
|
||||
preserved_message_count=result.preserved_message_count,
|
||||
summary_updated=result.summary_updated,
|
||||
checkpoint_id=result.checkpoint_id,
|
||||
total_tokens=result.total_tokens,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{thread_id}/compact", response_model=ThreadCompactResponse)
|
||||
@require_permission("threads", "write", owner_check=True, require_existing=True)
|
||||
async def compact_thread(thread_id: str, body: ThreadCompactRequest, request: Request) -> ThreadCompactResponse:
|
||||
"""Manually summarize old thread context while preserving the visible history."""
|
||||
run_manager = get_run_manager(request)
|
||||
checkpointer = get_checkpointer(request)
|
||||
keep = body.keep.to_tuple() if body.keep is not None else None
|
||||
try:
|
||||
async with goal_thread_lock(thread_id):
|
||||
if await run_manager.has_inflight(thread_id):
|
||||
raise HTTPException(status_code=409, detail="Thread has a run in flight. Compact after the run finishes.")
|
||||
result = await compact_thread_context(
|
||||
checkpointer,
|
||||
thread_id,
|
||||
keep=keep,
|
||||
force=body.force,
|
||||
user_id=get_effective_user_id(),
|
||||
agent_name=body.agent_name,
|
||||
)
|
||||
except ContextCompactionDisabled:
|
||||
raise HTTPException(status_code=409, detail="Context compaction is disabled.") from None
|
||||
except ContextCompactionFailed:
|
||||
raise HTTPException(status_code=500, detail="Failed to compact thread context.") from None
|
||||
except LookupError:
|
||||
raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found") from None
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Failed to compact thread %s", sanitize_log_param(thread_id))
|
||||
raise HTTPException(status_code=500, detail="Failed to compact thread context.") from None
|
||||
return _thread_compact_response(result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.get("/{thread_id}/state", response_model=ThreadStateResponse)
|
||||
@require_permission("threads", "read", owner_check=True)
|
||||
|
||||
@@ -42,6 +42,7 @@ from deerflow.runtime import (
|
||||
UnsupportedStrategyError,
|
||||
run_agent,
|
||||
)
|
||||
from deerflow.runtime.goal import goal_thread_lock
|
||||
from deerflow.runtime.runs.naming import resolve_root_run_name
|
||||
from deerflow.runtime.secret_context import redact_config_secrets
|
||||
from deerflow.runtime.user_context import reset_current_user, set_current_user
|
||||
@@ -612,20 +613,21 @@ async def start_run(
|
||||
owner_context_token = set_current_user(SimpleNamespace(id=owner_user_id)) if owner_user_id else None
|
||||
try:
|
||||
try:
|
||||
record = await run_mgr.create_or_reject(
|
||||
thread_id,
|
||||
body.assistant_id,
|
||||
on_disconnect=disconnect,
|
||||
metadata=body.metadata or {},
|
||||
# Persist a secret-redacted copy of the config: the run record is
|
||||
# written to runs.kwargs_json and echoed by the run API, so a
|
||||
# request-scoped secret (#3861) must not ride along. The live
|
||||
# config built below keeps the secrets for the actual run.
|
||||
kwargs={"input": body.input, "config": redact_config_secrets(body.config)},
|
||||
multitask_strategy=body.multitask_strategy,
|
||||
model_name=model_name,
|
||||
user_id=owner_user_id,
|
||||
)
|
||||
async with goal_thread_lock(thread_id):
|
||||
record = await run_mgr.create_or_reject(
|
||||
thread_id,
|
||||
body.assistant_id,
|
||||
on_disconnect=disconnect,
|
||||
metadata=body.metadata or {},
|
||||
# Persist a secret-redacted copy of the config: the run record is
|
||||
# written to runs.kwargs_json and echoed by the run API, so a
|
||||
# request-scoped secret (#3861) must not ride along. The live
|
||||
# config built below keeps the secrets for the actual run.
|
||||
kwargs={"input": body.input, "config": redact_config_secrets(body.config)},
|
||||
multitask_strategy=body.multitask_strategy,
|
||||
model_name=model_name,
|
||||
user_id=owner_user_id,
|
||||
)
|
||||
except ConflictError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except UnsupportedStrategyError as exc:
|
||||
|
||||
@@ -27,13 +27,12 @@ from langchain.agents.middleware import AgentMiddleware
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
|
||||
from deerflow.agents.lead_agent.prompt import apply_prompt_template
|
||||
from deerflow.agents.memory.summarization_hook import memory_flush_hook
|
||||
from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware
|
||||
from deerflow.agents.middlewares.loop_detection_middleware import LoopDetectionMiddleware
|
||||
from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
|
||||
from deerflow.agents.middlewares.safety_finish_reason_middleware import SafetyFinishReasonMiddleware
|
||||
from deerflow.agents.middlewares.subagent_limit_middleware import SubagentLimitMiddleware
|
||||
from deerflow.agents.middlewares.summarization_middleware import BeforeSummarizationHook, DeerFlowSummarizationMiddleware
|
||||
from deerflow.agents.middlewares.summarization_middleware import DeerFlowSummarizationMiddleware, create_summarization_middleware
|
||||
from deerflow.agents.middlewares.title_middleware import TitleMiddleware
|
||||
from deerflow.agents.middlewares.todo_middleware import TodoMiddleware
|
||||
from deerflow.agents.middlewares.token_usage_middleware import TokenUsageMiddleware
|
||||
@@ -87,58 +86,7 @@ def _resolve_model_name(requested_model_name: str | None = None, *, app_config:
|
||||
|
||||
def _create_summarization_middleware(*, app_config: AppConfig | None = None) -> DeerFlowSummarizationMiddleware | None:
|
||||
"""Create and configure the summarization middleware from config."""
|
||||
resolved_app_config = app_config or get_app_config()
|
||||
config = resolved_app_config.summarization
|
||||
|
||||
if not config.enabled:
|
||||
return None
|
||||
|
||||
# Prepare trigger parameter
|
||||
trigger = None
|
||||
if config.trigger is not None:
|
||||
if isinstance(config.trigger, list):
|
||||
trigger = [t.to_tuple() for t in config.trigger]
|
||||
else:
|
||||
trigger = config.trigger.to_tuple()
|
||||
|
||||
# Prepare keep parameter
|
||||
keep = config.keep.to_tuple()
|
||||
|
||||
# Prepare model parameter.
|
||||
# Bind "middleware:summarize" tag so RunJournal identifies these LLM calls
|
||||
# as middleware rather than lead_agent (SummarizationMiddleware is a
|
||||
# LangChain built-in, so we tag the model at creation time).
|
||||
# attach_tracing=False because the graph-level RunnableConfig (set in
|
||||
# ``_make_lead_agent``) already carries tracing callbacks; binding them
|
||||
# again at the model level would emit duplicate spans and break
|
||||
# ``session_id`` / ``user_id`` propagation.
|
||||
if config.model_name:
|
||||
model = create_chat_model(name=config.model_name, thinking_enabled=False, app_config=resolved_app_config, attach_tracing=False)
|
||||
else:
|
||||
model = create_chat_model(thinking_enabled=False, app_config=resolved_app_config, attach_tracing=False)
|
||||
model = model.with_config(tags=["middleware:summarize"])
|
||||
|
||||
# Prepare kwargs
|
||||
kwargs = {
|
||||
"model": model,
|
||||
"trigger": trigger,
|
||||
"keep": keep,
|
||||
}
|
||||
|
||||
if config.trim_tokens_to_summarize is not None:
|
||||
kwargs["trim_tokens_to_summarize"] = config.trim_tokens_to_summarize
|
||||
|
||||
if config.summary_prompt is not None:
|
||||
kwargs["summary_prompt"] = config.summary_prompt
|
||||
|
||||
hooks: list[BeforeSummarizationHook] = []
|
||||
if resolved_app_config.memory.enabled:
|
||||
hooks.append(memory_flush_hook)
|
||||
|
||||
return DeerFlowSummarizationMiddleware(
|
||||
**kwargs,
|
||||
before_summarization=hooks,
|
||||
)
|
||||
return create_summarization_middleware(app_config=app_config)
|
||||
|
||||
|
||||
def _create_todo_list_middleware(is_plan_mode: bool) -> TodoMiddleware | None:
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol, override, runtime_checkable
|
||||
from typing import Any, Protocol, override, runtime_checkable
|
||||
|
||||
from langchain.agents import AgentState
|
||||
from langchain.agents.middleware import SummarizationMiddleware
|
||||
@@ -15,6 +15,8 @@ from langgraph.graph.message import REMOVE_ALL_MESSAGES
|
||||
from langgraph.runtime import Runtime
|
||||
|
||||
from deerflow.agents.middlewares.dynamic_context_middleware import is_dynamic_context_reminder
|
||||
from deerflow.config.app_config import get_app_config
|
||||
from deerflow.models import create_chat_model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_SUMMARY_TRIGGER_MESSAGE_NAME = "summary"
|
||||
@@ -31,6 +33,16 @@ class SummarizationEvent:
|
||||
runtime: Runtime
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ContextCompactionResult:
|
||||
"""Result of summarizing old context and retaining the active tail."""
|
||||
|
||||
summary_text: str
|
||||
messages_to_summarize: tuple[AnyMessage, ...]
|
||||
preserved_messages: tuple[AnyMessage, ...]
|
||||
total_tokens: int
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class BeforeSummarizationHook(Protocol):
|
||||
"""Hook invoked before summarization removes messages from state."""
|
||||
@@ -249,14 +261,19 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
|
||||
async def abefore_model(self, state: AgentState, runtime: Runtime) -> dict | None:
|
||||
return await self._amaybe_summarize(state, runtime)
|
||||
|
||||
def _maybe_summarize(self, state: AgentState, runtime: Runtime) -> dict | None:
|
||||
def _prepare_compaction(
|
||||
self,
|
||||
state: AgentState,
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> tuple[list[AnyMessage], list[AnyMessage], str | None, int] | None:
|
||||
messages = state["messages"]
|
||||
self._ensure_message_ids(messages)
|
||||
|
||||
previous_summary = state.get("summary_text") if isinstance(state.get("summary_text"), str) else None
|
||||
trigger_messages = self._messages_for_trigger_count(messages, previous_summary)
|
||||
total_tokens = self.token_counter(trigger_messages)
|
||||
if not self._should_summarize(trigger_messages, total_tokens):
|
||||
if not force and not self._should_summarize(trigger_messages, total_tokens):
|
||||
return None
|
||||
|
||||
cutoff_index = self._determine_cutoff_index(messages)
|
||||
@@ -267,46 +284,74 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
|
||||
messages_to_summarize, preserved_messages = self._preserve_dynamic_context_reminders(messages_to_summarize, preserved_messages)
|
||||
if not messages_to_summarize:
|
||||
return None
|
||||
return messages_to_summarize, preserved_messages, previous_summary, total_tokens
|
||||
|
||||
def compact_state(
|
||||
self,
|
||||
state: AgentState,
|
||||
runtime: Runtime,
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> ContextCompactionResult | None:
|
||||
prepared = self._prepare_compaction(state, force=force)
|
||||
if prepared is None:
|
||||
return None
|
||||
messages_to_summarize, preserved_messages, previous_summary, total_tokens = prepared
|
||||
self._fire_hooks(messages_to_summarize, preserved_messages, runtime)
|
||||
summary = self._summarize_with(messages_to_summarize, previous_summary=previous_summary)
|
||||
if summary is None:
|
||||
return None
|
||||
return {
|
||||
"messages": [
|
||||
RemoveMessage(id=REMOVE_ALL_MESSAGES),
|
||||
*preserved_messages,
|
||||
],
|
||||
"summary_text": summary,
|
||||
}
|
||||
return ContextCompactionResult(
|
||||
summary_text=summary,
|
||||
messages_to_summarize=tuple(messages_to_summarize),
|
||||
preserved_messages=tuple(preserved_messages),
|
||||
total_tokens=total_tokens,
|
||||
)
|
||||
|
||||
async def _amaybe_summarize(self, state: AgentState, runtime: Runtime) -> dict | None:
|
||||
messages = state["messages"]
|
||||
self._ensure_message_ids(messages)
|
||||
|
||||
previous_summary = state.get("summary_text") if isinstance(state.get("summary_text"), str) else None
|
||||
trigger_messages = self._messages_for_trigger_count(messages, previous_summary)
|
||||
total_tokens = self.token_counter(trigger_messages)
|
||||
if not self._should_summarize(trigger_messages, total_tokens):
|
||||
return None
|
||||
|
||||
cutoff_index = self._determine_cutoff_index(messages)
|
||||
if cutoff_index <= 0:
|
||||
return None
|
||||
|
||||
messages_to_summarize, preserved_messages = self._partition_messages(messages, cutoff_index)
|
||||
messages_to_summarize, preserved_messages = self._preserve_dynamic_context_reminders(messages_to_summarize, preserved_messages)
|
||||
if not messages_to_summarize:
|
||||
async def acompact_state(
|
||||
self,
|
||||
state: AgentState,
|
||||
runtime: Runtime,
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> ContextCompactionResult | None:
|
||||
prepared = self._prepare_compaction(state, force=force)
|
||||
if prepared is None:
|
||||
return None
|
||||
messages_to_summarize, preserved_messages, previous_summary, total_tokens = prepared
|
||||
self._fire_hooks(messages_to_summarize, preserved_messages, runtime)
|
||||
summary = await self._asummarize_with(messages_to_summarize, previous_summary=previous_summary)
|
||||
if summary is None:
|
||||
return None
|
||||
return ContextCompactionResult(
|
||||
summary_text=summary,
|
||||
messages_to_summarize=tuple(messages_to_summarize),
|
||||
preserved_messages=tuple(preserved_messages),
|
||||
total_tokens=total_tokens,
|
||||
)
|
||||
|
||||
def _maybe_summarize(self, state: AgentState, runtime: Runtime) -> dict | None:
|
||||
result = self.compact_state(state, runtime, force=False)
|
||||
if result is None:
|
||||
return None
|
||||
return {
|
||||
"messages": [
|
||||
RemoveMessage(id=REMOVE_ALL_MESSAGES),
|
||||
*preserved_messages,
|
||||
*result.preserved_messages,
|
||||
],
|
||||
"summary_text": summary,
|
||||
"summary_text": result.summary_text,
|
||||
}
|
||||
|
||||
async def _amaybe_summarize(self, state: AgentState, runtime: Runtime) -> dict | None:
|
||||
result = await self.acompact_state(state, runtime, force=False)
|
||||
if result is None:
|
||||
return None
|
||||
return {
|
||||
"messages": [
|
||||
RemoveMessage(id=REMOVE_ALL_MESSAGES),
|
||||
*result.preserved_messages,
|
||||
],
|
||||
"summary_text": result.summary_text,
|
||||
}
|
||||
|
||||
def _preserve_dynamic_context_reminders(
|
||||
@@ -386,3 +431,64 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
|
||||
except Exception:
|
||||
hook_name = getattr(hook, "__name__", None) or type(hook).__name__
|
||||
logger.exception("before_summarization hook %s failed", hook_name)
|
||||
|
||||
|
||||
def create_summarization_middleware(
|
||||
*,
|
||||
app_config: Any | None = None,
|
||||
keep: tuple[str, int | float] | None = None,
|
||||
) -> DeerFlowSummarizationMiddleware | None:
|
||||
"""Create the configured summarization middleware.
|
||||
|
||||
Both the lead-agent automatic path and the manual context-compaction path
|
||||
use this factory so model resolution, hooks, prompt config, and retention
|
||||
defaults cannot drift.
|
||||
"""
|
||||
from deerflow.agents.memory.summarization_hook import memory_flush_hook
|
||||
|
||||
resolved_app_config = app_config or get_app_config()
|
||||
config = resolved_app_config.summarization
|
||||
|
||||
if not config.enabled:
|
||||
return None
|
||||
|
||||
trigger = None
|
||||
if config.trigger is not None:
|
||||
if isinstance(config.trigger, list):
|
||||
trigger = [item.to_tuple() for item in config.trigger]
|
||||
else:
|
||||
trigger = config.trigger.to_tuple()
|
||||
|
||||
if config.model_name:
|
||||
model = create_chat_model(
|
||||
name=config.model_name,
|
||||
thinking_enabled=False,
|
||||
app_config=resolved_app_config,
|
||||
attach_tracing=False,
|
||||
)
|
||||
else:
|
||||
model = create_chat_model(
|
||||
thinking_enabled=False,
|
||||
app_config=resolved_app_config,
|
||||
attach_tracing=False,
|
||||
)
|
||||
model = model.with_config(tags=["middleware:summarize"])
|
||||
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": model,
|
||||
"trigger": trigger,
|
||||
"keep": keep or config.keep.to_tuple(),
|
||||
}
|
||||
if config.trim_tokens_to_summarize is not None:
|
||||
kwargs["trim_tokens_to_summarize"] = config.trim_tokens_to_summarize
|
||||
if config.summary_prompt is not None:
|
||||
kwargs["summary_prompt"] = config.summary_prompt
|
||||
|
||||
hooks: list[BeforeSummarizationHook] = []
|
||||
if resolved_app_config.memory.enabled:
|
||||
hooks.append(memory_flush_hook)
|
||||
|
||||
return DeerFlowSummarizationMiddleware(
|
||||
**kwargs,
|
||||
before_summarization=hooks,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Manual thread-context compaction helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from dataclasses import dataclass
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from langgraph.checkpoint.base import uuid6
|
||||
|
||||
from deerflow.agents.middlewares.summarization_middleware import DeerFlowSummarizationMiddleware, create_summarization_middleware
|
||||
from deerflow.config.app_config import AppConfig, get_app_config
|
||||
from deerflow.runtime.goal import _call_checkpointer_method, _next_channel_version
|
||||
from deerflow.utils.time import now_iso
|
||||
|
||||
|
||||
class ContextCompactionDisabled(RuntimeError):
|
||||
"""Raised when manual compaction is requested while summarization is disabled."""
|
||||
|
||||
|
||||
class ContextCompactionFailed(RuntimeError):
|
||||
"""Raised when a compressible thread cannot be summarized."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ThreadCompactionResult:
|
||||
"""Result returned after a manual context-compaction attempt."""
|
||||
|
||||
thread_id: str
|
||||
compacted: bool
|
||||
reason: str | None = None
|
||||
removed_message_count: int = 0
|
||||
preserved_message_count: int = 0
|
||||
summary_updated: bool = False
|
||||
checkpoint_id: str | None = None
|
||||
total_tokens: int = 0
|
||||
|
||||
|
||||
def _create_compaction_middleware(
|
||||
*,
|
||||
app_config: AppConfig,
|
||||
keep: tuple[str, int | float] | None,
|
||||
) -> DeerFlowSummarizationMiddleware:
|
||||
middleware = create_summarization_middleware(app_config=app_config, keep=keep)
|
||||
if middleware is None:
|
||||
raise ContextCompactionDisabled("Context compaction is disabled.")
|
||||
return middleware
|
||||
|
||||
|
||||
def _checkpoint_namespace(checkpoint_tuple: Any) -> str:
|
||||
config = getattr(checkpoint_tuple, "config", {}) or {}
|
||||
configurable = config.get("configurable", {}) if isinstance(config, dict) else {}
|
||||
checkpoint_ns = configurable.get("checkpoint_ns", "") if isinstance(configurable, dict) else ""
|
||||
return checkpoint_ns if isinstance(checkpoint_ns, str) else ""
|
||||
|
||||
|
||||
async def compact_thread_context(
|
||||
checkpointer: Any,
|
||||
thread_id: str,
|
||||
*,
|
||||
keep: tuple[str, int | float] | None = None,
|
||||
force: bool = True,
|
||||
user_id: str | None = None,
|
||||
agent_name: str | None = None,
|
||||
app_config: AppConfig | None = None,
|
||||
) -> ThreadCompactionResult:
|
||||
"""Summarize old messages in a thread and write a compacted checkpoint."""
|
||||
resolved_app_config = app_config or get_app_config()
|
||||
middleware = _create_compaction_middleware(app_config=resolved_app_config, keep=keep)
|
||||
|
||||
read_config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
|
||||
checkpoint_tuple = await _call_checkpointer_method(checkpointer, "aget_tuple", "get_tuple", read_config)
|
||||
if checkpoint_tuple is None:
|
||||
raise LookupError(f"Thread {thread_id} checkpoint not found")
|
||||
|
||||
checkpoint: dict[str, Any] = dict(getattr(checkpoint_tuple, "checkpoint", {}) or {})
|
||||
metadata: dict[str, Any] = dict(getattr(checkpoint_tuple, "metadata", {}) or {})
|
||||
channel_values: dict[str, Any] = dict(checkpoint.get("channel_values", {}) or {})
|
||||
messages = channel_values.get("messages")
|
||||
if not isinstance(messages, list) or not messages:
|
||||
return ThreadCompactionResult(thread_id=thread_id, compacted=False, reason="not_enough_messages")
|
||||
|
||||
state = {
|
||||
"messages": list(messages),
|
||||
"summary_text": channel_values.get("summary_text"),
|
||||
}
|
||||
|
||||
runtime_context = {"thread_id": thread_id, "user_id": user_id}
|
||||
if agent_name:
|
||||
runtime_context["agent_name"] = agent_name
|
||||
runtime = SimpleNamespace(context=runtime_context)
|
||||
result = await middleware.acompact_state(state, runtime, force=force) # type: ignore[arg-type]
|
||||
if result is None:
|
||||
return ThreadCompactionResult(thread_id=thread_id, compacted=False, reason="not_enough_messages")
|
||||
|
||||
channel_values["messages"] = list(result.preserved_messages)
|
||||
channel_values["summary_text"] = result.summary_text
|
||||
checkpoint["channel_values"] = channel_values
|
||||
|
||||
channel_versions = dict(checkpoint.get("channel_versions", {}) or {})
|
||||
new_versions: dict[str, Any] = {}
|
||||
for channel in ("messages", "summary_text"):
|
||||
next_version = _next_channel_version(checkpointer, channel_versions.get(channel))
|
||||
channel_versions[channel] = next_version
|
||||
new_versions[channel] = next_version
|
||||
checkpoint["channel_versions"] = channel_versions
|
||||
checkpoint["id"] = str(uuid6())
|
||||
checkpoint["ts"] = now_iso()
|
||||
|
||||
metadata["source"] = "update"
|
||||
metadata["updated_at"] = now_iso()
|
||||
prev_step = metadata.get("step")
|
||||
metadata["step"] = (prev_step + 1) if isinstance(prev_step, int) else 1
|
||||
metadata["writes"] = {
|
||||
"manual_compaction": {
|
||||
"messages": {
|
||||
"removed": len(result.messages_to_summarize),
|
||||
"preserved": len(result.preserved_messages),
|
||||
},
|
||||
"summary_text": {
|
||||
"sha256": hashlib.sha256(result.summary_text.encode("utf-8")).hexdigest(),
|
||||
"chars": len(result.summary_text),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
write_config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": _checkpoint_namespace(checkpoint_tuple)}}
|
||||
new_config = await _call_checkpointer_method(checkpointer, "aput", "put", write_config, checkpoint, metadata, new_versions)
|
||||
new_checkpoint_id = None
|
||||
if isinstance(new_config, dict):
|
||||
new_checkpoint_id = new_config.get("configurable", {}).get("checkpoint_id")
|
||||
|
||||
return ThreadCompactionResult(
|
||||
thread_id=thread_id,
|
||||
compacted=True,
|
||||
removed_message_count=len(result.messages_to_summarize),
|
||||
preserved_message_count=len(result.preserved_messages),
|
||||
summary_updated=True,
|
||||
checkpoint_id=new_checkpoint_id,
|
||||
total_tokens=result.total_tokens,
|
||||
)
|
||||
@@ -385,6 +385,15 @@ async def _call_checkpointer_method(checkpointer: Any, async_name: str, sync_nam
|
||||
return await result if inspect.isawaitable(result) else result
|
||||
|
||||
|
||||
def _next_channel_version(checkpointer: Any, current_version: Any) -> Any:
|
||||
get_next_version = getattr(checkpointer, "get_next_version", None)
|
||||
if callable(get_next_version):
|
||||
return get_next_version(current_version, None)
|
||||
if isinstance(current_version, int):
|
||||
return current_version + 1
|
||||
return 1
|
||||
|
||||
|
||||
async def ensure_thread_checkpoint(checkpointer: Any, thread_id: str) -> None:
|
||||
"""Create an empty root checkpoint for *thread_id* when none exists."""
|
||||
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
|
||||
@@ -464,13 +473,7 @@ async def write_thread_goal(
|
||||
|
||||
channel_versions = dict(checkpoint.get("channel_versions", {}) or {})
|
||||
current_version = channel_versions.get("goal")
|
||||
get_next_version = getattr(checkpointer, "get_next_version", None)
|
||||
if callable(get_next_version):
|
||||
next_version = get_next_version(current_version, None)
|
||||
elif isinstance(current_version, int):
|
||||
next_version = current_version + 1
|
||||
else:
|
||||
next_version = 1
|
||||
next_version = _next_channel_version(checkpointer, current_version)
|
||||
channel_versions["goal"] = next_version
|
||||
|
||||
checkpoint["channel_values"] = channel_values
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
|
||||
from deerflow.runtime import context_compaction
|
||||
from deerflow.runtime.context_compaction import compact_thread_context
|
||||
|
||||
|
||||
class _FakeCheckpointer:
|
||||
def __init__(self, checkpoint: dict, metadata: dict | None = None) -> None:
|
||||
self.checkpoint = checkpoint
|
||||
self.metadata = metadata or {"step": 4, "created_at": "2026-07-06T00:00:00+00:00"}
|
||||
self.put_args = None
|
||||
|
||||
async def aget_tuple(self, config):
|
||||
return SimpleNamespace(
|
||||
checkpoint=self.checkpoint,
|
||||
metadata=self.metadata,
|
||||
config={"configurable": {"thread_id": config["configurable"]["thread_id"], "checkpoint_id": "ckpt-old", "checkpoint_ns": ""}},
|
||||
)
|
||||
|
||||
def get_next_version(self, current_version, _channel):
|
||||
if current_version is None:
|
||||
return 1
|
||||
return current_version + 1
|
||||
|
||||
async def aput(self, config, checkpoint, metadata, new_versions):
|
||||
self.put_args = (config, checkpoint, metadata, new_versions)
|
||||
return {"configurable": {"checkpoint_id": checkpoint["id"]}}
|
||||
|
||||
|
||||
class _FakeCompactionMiddleware:
|
||||
def __init__(self, *, should_compact: bool = True) -> None:
|
||||
self.should_compact = should_compact
|
||||
self.prepare_calls = 0
|
||||
self.runtime_contexts: list[dict] = []
|
||||
|
||||
def _prepare_compaction(self, state, *, force=False):
|
||||
self.prepare_calls += 1
|
||||
if not self.should_compact:
|
||||
return None
|
||||
return (state["messages"][:-1], state["messages"][-1:], state.get("summary_text"), 123)
|
||||
|
||||
async def acompact_state(self, state, runtime, *, force=False):
|
||||
self.runtime_contexts.append(dict(runtime.context))
|
||||
prepared = self._prepare_compaction(state, force=force)
|
||||
if prepared is None:
|
||||
return None
|
||||
messages_to_summarize, preserved_messages, _previous_summary, total_tokens = prepared
|
||||
return SimpleNamespace(
|
||||
summary_text="COMPRESSED SUMMARY",
|
||||
messages_to_summarize=tuple(messages_to_summarize),
|
||||
preserved_messages=tuple(preserved_messages),
|
||||
total_tokens=total_tokens,
|
||||
)
|
||||
|
||||
|
||||
class _SyncCheckpointer:
|
||||
def __init__(self, checkpoint: dict, metadata: dict | None = None) -> None:
|
||||
self.checkpoint = checkpoint
|
||||
self.metadata = metadata or {"step": 4, "created_at": "2026-07-06T00:00:00+00:00"}
|
||||
self.put_args = None
|
||||
|
||||
def get_tuple(self, config):
|
||||
return SimpleNamespace(
|
||||
checkpoint=self.checkpoint,
|
||||
metadata=self.metadata,
|
||||
config={"configurable": {"thread_id": config["configurable"]["thread_id"], "checkpoint_id": "ckpt-old", "checkpoint_ns": ""}},
|
||||
)
|
||||
|
||||
def get_next_version(self, current_version, _channel):
|
||||
if current_version is None:
|
||||
return 1
|
||||
return current_version + 1
|
||||
|
||||
def put(self, config, checkpoint, metadata, new_versions):
|
||||
self.put_args = (config, checkpoint, metadata, new_versions)
|
||||
return {"configurable": {"checkpoint_id": checkpoint["id"]}}
|
||||
|
||||
|
||||
class _RejectDeepcopy:
|
||||
def __deepcopy__(self, _memo):
|
||||
raise AssertionError("compact_thread_context must not deepcopy unrelated channel values")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compact_thread_context_writes_summary_and_bumps_changed_channels(monkeypatch):
|
||||
messages = [
|
||||
HumanMessage(content="old question"),
|
||||
AIMessage(content="old answer"),
|
||||
HumanMessage(content="latest question"),
|
||||
]
|
||||
checkpointer = _FakeCheckpointer(
|
||||
{
|
||||
"id": "ckpt-old",
|
||||
"channel_values": {"messages": messages, "summary_text": "OLD SUMMARY", "sandbox": _RejectDeepcopy()},
|
||||
"channel_versions": {"messages": 7, "summary_text": 3, "title": 2},
|
||||
}
|
||||
)
|
||||
middleware = _FakeCompactionMiddleware()
|
||||
monkeypatch.setattr(
|
||||
context_compaction,
|
||||
"_create_compaction_middleware",
|
||||
lambda **_kwargs: middleware,
|
||||
)
|
||||
|
||||
result = await compact_thread_context(
|
||||
checkpointer,
|
||||
"thread-1",
|
||||
app_config=SimpleNamespace(),
|
||||
user_id="user-1",
|
||||
agent_name="research-agent",
|
||||
)
|
||||
|
||||
assert result.compacted is True
|
||||
assert result.removed_message_count == 2
|
||||
assert result.preserved_message_count == 1
|
||||
assert result.summary_updated is True
|
||||
assert result.total_tokens == 123
|
||||
|
||||
assert checkpointer.put_args is not None
|
||||
_config, written_checkpoint, written_metadata, new_versions = checkpointer.put_args
|
||||
assert written_checkpoint["channel_values"]["messages"] == [messages[-1]]
|
||||
assert written_checkpoint["channel_values"]["summary_text"] == "COMPRESSED SUMMARY"
|
||||
assert isinstance(written_checkpoint["channel_values"]["sandbox"], _RejectDeepcopy)
|
||||
assert written_checkpoint["channel_versions"]["messages"] == 8
|
||||
assert written_checkpoint["channel_versions"]["summary_text"] == 4
|
||||
assert written_checkpoint["channel_versions"]["title"] == 2
|
||||
assert new_versions == {"messages": 8, "summary_text": 4}
|
||||
assert written_metadata["writes"]["manual_compaction"]["messages"] == {
|
||||
"removed": 2,
|
||||
"preserved": 1,
|
||||
}
|
||||
assert "COMPRESSED SUMMARY" not in str(written_metadata["writes"])
|
||||
assert middleware.prepare_calls == 1
|
||||
assert middleware.runtime_contexts == [
|
||||
{"thread_id": "thread-1", "user_id": "user-1", "agent_name": "research-agent"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compact_thread_context_returns_noop_without_writing(monkeypatch):
|
||||
checkpointer = _FakeCheckpointer(
|
||||
{
|
||||
"id": "ckpt-old",
|
||||
"channel_values": {"messages": [HumanMessage(content="latest only")]},
|
||||
"channel_versions": {"messages": 1},
|
||||
}
|
||||
)
|
||||
middleware = _FakeCompactionMiddleware(should_compact=False)
|
||||
monkeypatch.setattr(
|
||||
context_compaction,
|
||||
"_create_compaction_middleware",
|
||||
lambda **_kwargs: middleware,
|
||||
)
|
||||
|
||||
result = await compact_thread_context(checkpointer, "thread-1", app_config=SimpleNamespace())
|
||||
|
||||
assert result.compacted is False
|
||||
assert result.reason == "not_enough_messages"
|
||||
assert checkpointer.put_args is None
|
||||
assert middleware.prepare_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compact_thread_context_supports_sync_checkpointer_methods(monkeypatch):
|
||||
messages = [
|
||||
HumanMessage(content="old question"),
|
||||
AIMessage(content="old answer"),
|
||||
HumanMessage(content="latest question"),
|
||||
]
|
||||
checkpointer = _SyncCheckpointer(
|
||||
{
|
||||
"id": "ckpt-old",
|
||||
"channel_values": {"messages": messages, "summary_text": "OLD SUMMARY"},
|
||||
"channel_versions": {"messages": 7, "summary_text": 3},
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
context_compaction,
|
||||
"_create_compaction_middleware",
|
||||
lambda **_kwargs: _FakeCompactionMiddleware(),
|
||||
)
|
||||
|
||||
result = await compact_thread_context(checkpointer, "thread-1", app_config=SimpleNamespace())
|
||||
|
||||
assert result.compacted is True
|
||||
assert checkpointer.put_args is not None
|
||||
@@ -8,6 +8,7 @@ from unittest.mock import MagicMock
|
||||
import pytest
|
||||
|
||||
from deerflow.agents.lead_agent import agent as lead_agent_module
|
||||
from deerflow.agents.middlewares import summarization_middleware as summarization_middleware_module
|
||||
from deerflow.agents.middlewares.loop_detection_middleware import LoopDetectionMiddleware
|
||||
from deerflow.config.app_config import AppConfig
|
||||
from deerflow.config.loop_detection_config import LoopDetectionConfig
|
||||
@@ -497,9 +498,9 @@ def test_create_summarization_middleware_uses_configured_model_alias(monkeypatch
|
||||
def _raise_get_app_config():
|
||||
raise AssertionError("ambient get_app_config() must not be used when app_config is explicit")
|
||||
|
||||
monkeypatch.setattr(lead_agent_module, "get_app_config", _raise_get_app_config)
|
||||
monkeypatch.setattr(lead_agent_module, "create_chat_model", _fake_create_chat_model)
|
||||
monkeypatch.setattr(lead_agent_module, "DeerFlowSummarizationMiddleware", lambda **kwargs: kwargs)
|
||||
monkeypatch.setattr(summarization_middleware_module, "get_app_config", _raise_get_app_config)
|
||||
monkeypatch.setattr(summarization_middleware_module, "create_chat_model", _fake_create_chat_model)
|
||||
monkeypatch.setattr(summarization_middleware_module, "DeerFlowSummarizationMiddleware", lambda **kwargs: kwargs)
|
||||
|
||||
middleware = lead_agent_module._create_summarization_middleware(app_config=app_config)
|
||||
|
||||
@@ -510,6 +511,30 @@ def test_create_summarization_middleware_uses_configured_model_alias(monkeypatch
|
||||
fake_model.with_config.assert_called_once_with(tags=["middleware:summarize"])
|
||||
|
||||
|
||||
def test_create_summarization_middleware_omits_model_name_when_unconfigured(monkeypatch):
|
||||
app_config = _make_app_config([_make_model("default-model", supports_thinking=False)])
|
||||
app_config.summarization = SummarizationConfig(enabled=True, model_name=None)
|
||||
app_config.memory = MemoryConfig(enabled=False)
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
fake_model = MagicMock()
|
||||
fake_model.with_config.return_value = fake_model
|
||||
|
||||
def _fake_create_chat_model(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return fake_model
|
||||
|
||||
monkeypatch.setattr(summarization_middleware_module, "create_chat_model", _fake_create_chat_model)
|
||||
monkeypatch.setattr(summarization_middleware_module, "DeerFlowSummarizationMiddleware", lambda **kwargs: kwargs)
|
||||
|
||||
middleware = lead_agent_module._create_summarization_middleware(app_config=app_config)
|
||||
|
||||
assert "name" not in captured
|
||||
assert captured["thinking_enabled"] is False
|
||||
assert captured["app_config"] is app_config
|
||||
assert middleware["model"] is fake_model
|
||||
|
||||
|
||||
def test_create_summarization_middleware_uses_frontend_supported_update_key(monkeypatch):
|
||||
"""LangGraph update keys use the middleware class name plus hook name."""
|
||||
|
||||
@@ -519,7 +544,7 @@ def test_create_summarization_middleware_uses_frontend_supported_update_key(monk
|
||||
|
||||
fake_model = MagicMock()
|
||||
fake_model.with_config.return_value = fake_model
|
||||
monkeypatch.setattr(lead_agent_module, "create_chat_model", lambda **kwargs: fake_model)
|
||||
monkeypatch.setattr(summarization_middleware_module, "create_chat_model", lambda **kwargs: fake_model)
|
||||
|
||||
middleware = lead_agent_module._create_summarization_middleware(app_config=app_config)
|
||||
|
||||
@@ -543,9 +568,9 @@ def test_create_summarization_middleware_threads_resolved_app_config_to_model(mo
|
||||
captured["app_config"] = app_config
|
||||
return fake_model
|
||||
|
||||
monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: fallback_app_config)
|
||||
monkeypatch.setattr(lead_agent_module, "create_chat_model", _fake_create_chat_model)
|
||||
monkeypatch.setattr(lead_agent_module, "DeerFlowSummarizationMiddleware", lambda **kwargs: kwargs)
|
||||
monkeypatch.setattr(summarization_middleware_module, "get_app_config", lambda: fallback_app_config)
|
||||
monkeypatch.setattr(summarization_middleware_module, "create_chat_model", _fake_create_chat_model)
|
||||
monkeypatch.setattr(summarization_middleware_module, "DeerFlowSummarizationMiddleware", lambda **kwargs: kwargs)
|
||||
|
||||
lead_agent_module._create_summarization_middleware()
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from langchain_core.language_models import BaseChatModel
|
||||
from langchain_core.messages import AIMessage, HumanMessage, RemoveMessage, SystemMessage
|
||||
from langchain_core.outputs import ChatGeneration, ChatResult
|
||||
@@ -166,6 +168,21 @@ class TestSummaryWritesChannel:
|
||||
assert out is not None
|
||||
assert out["summary_text"] == "UPDATED_SUMMARY"
|
||||
|
||||
def test_compact_state_force_ignores_trigger_threshold(self):
|
||||
middleware = DeerFlowSummarizationMiddleware(
|
||||
model=_StaticChatModel(text="FORCED_SUMMARY"),
|
||||
trigger=("messages", 100),
|
||||
keep=("messages", 2),
|
||||
token_counter=len,
|
||||
)
|
||||
|
||||
result = middleware.compact_state({"messages": _big_history(3)}, SimpleNamespace(context={}), force=True)
|
||||
|
||||
assert result is not None
|
||||
assert result.summary_text == "FORCED_SUMMARY"
|
||||
assert len(result.preserved_messages) == 2
|
||||
assert len(result.messages_to_summarize) > 0
|
||||
|
||||
def test_previous_summary_is_trimmed_with_summary_prompt_input(self):
|
||||
middleware = DeerFlowSummarizationMiddleware(
|
||||
model=_StaticChatModel(text="UPDATED_SUMMARY"),
|
||||
|
||||
+1
-1
@@ -69,7 +69,7 @@ The frontend is a stateful chat application. Users create **threads** (conversat
|
||||
4. TanStack Query manages server state; localStorage stores user settings
|
||||
5. Components subscribe to thread state and render updates
|
||||
|
||||
`/goal` is a built-in composer command, not a skill activation. `src/components/workspace/input-box.tsx` intercepts `/goal`, `/goal clear`, and `/goal <condition>` before normal chat submission, calling Gateway `GET/PUT/DELETE /api/threads/{thread_id}/goal`. Setting `/goal <condition>` also submits the condition text as the next user task so the agent starts running immediately; status and clear do not start a run. Goal requests are tied to the current `threadId` with an `AbortController`, so switching threads or unmounting the composer aborts in-flight goal requests and stale responses cannot update the new thread's goal state. The chat pages render `GoalStatus` above the composer from `AgentThreadState.goal`, with local optimistic state until the next stream `values` update arrives.
|
||||
`/goal` and `/compact` are built-in composer commands, not skill activations. `src/components/workspace/input-box.tsx` intercepts `/goal`, `/goal clear`, and `/goal <condition>` before normal chat submission, calling Gateway `GET/PUT/DELETE /api/threads/{thread_id}/goal`. Setting `/goal <condition>` also submits the condition text as the next user task so the agent starts running immediately; status and clear do not start a run. Goal and compact requests are tied to the current `threadId` with an `AbortController`, so switching threads or unmounting the composer aborts in-flight requests and stale responses cannot update the new thread's composer state. The chat pages render `GoalStatus` above the composer from `AgentThreadState.goal`, with local optimistic state until the next stream `values` update arrives. `/compact` calls `POST /api/threads/{thread_id}/compact` to summarize older active context while leaving the full visible chat history intact; it is skipped on new/empty threads and blocked server-side while a run is in flight.
|
||||
|
||||
Human input requests are a structured message protocol layered on normal chat history. The backend writes request payloads to `ToolMessage.artifact.human_input`, `src/core/messages/human-input.ts` owns the runtime validators/types, and `src/components/workspace/messages/human-input-card.tsx` renders the reusable card. `MessageList` owns answered/latest/pending state for visible cards, but derives answered responses from raw `thread.messages` because replies are hidden; pending cards clear when the hidden reply appears, when dispatch is dropped, or when a new `thread.error` reports an async stream failure. Page-level submit callbacks must send a normal human message and put `hide_from_ui: true` plus the response payload in the fourth `sendMessage(..., options)` argument as `options.additionalKwargs`; the third argument remains run context such as `{ agent_name }`. Composer entry points should disable normal bottom input while `hasOpenHumanInputRequest(...)` is true so users answer through the card and preserve response metadata.
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ export type GoalCommand =
|
||||
|
||||
export type InputSubmitAction =
|
||||
| { kind: "goal"; command: GoalCommand }
|
||||
| { kind: "compact" }
|
||||
| { kind: "stop" }
|
||||
| { kind: "empty" }
|
||||
| { kind: "message" };
|
||||
@@ -184,6 +185,10 @@ export function parseGoalCommand(value: string): GoalCommand | null {
|
||||
return { kind: "set", objective: args };
|
||||
}
|
||||
|
||||
export function parseCompactCommand(value: string): boolean {
|
||||
return /^\/(?:compact|context\s+compact)\s*$/i.test(value.trim());
|
||||
}
|
||||
|
||||
export function getInputSubmitAction({
|
||||
text,
|
||||
fileCount,
|
||||
@@ -197,6 +202,9 @@ export function getInputSubmitAction({
|
||||
if (goalCommand && fileCount === 0) {
|
||||
return { kind: "goal", command: goalCommand };
|
||||
}
|
||||
if (parseCompactCommand(text) && fileCount === 0) {
|
||||
return { kind: "compact" };
|
||||
}
|
||||
if (status === "streaming") {
|
||||
return { kind: "stop" };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { ChatStatus } from "ai";
|
||||
import {
|
||||
CheckIcon,
|
||||
@@ -72,6 +73,8 @@ import {
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import { useSuggestionsConfig } from "@/core/suggestions/hooks";
|
||||
import type { AgentThreadContext, GoalState } from "@/core/threads";
|
||||
import { compactThreadContext } from "@/core/threads/api";
|
||||
import { threadTokenUsageQueryKey } from "@/core/threads/token-usage";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import {
|
||||
formatUploadSize,
|
||||
@@ -251,6 +254,7 @@ export function InputBox({
|
||||
onStop?: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const queryClient = useQueryClient();
|
||||
const searchParams = useSearchParams();
|
||||
const [modelDialogOpen, setModelDialogOpen] = useState(false);
|
||||
const { models } = useModels();
|
||||
@@ -264,6 +268,7 @@ export function InputBox({
|
||||
const promptRootRef = useRef<HTMLDivElement | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const goalRequestStateRef = useRef(createGoalRequestState());
|
||||
const compactRequestStateRef = useRef(createGoalRequestState());
|
||||
const promptHistoryIndexRef = useRef<number | null>(null);
|
||||
const promptHistoryDraftRef = useRef("");
|
||||
|
||||
@@ -292,8 +297,13 @@ export function InputBox({
|
||||
description: t.inputBox.goalCommandDescription,
|
||||
kind: "builtin",
|
||||
},
|
||||
{
|
||||
name: "compact",
|
||||
description: t.inputBox.compactCommandDescription,
|
||||
kind: "builtin",
|
||||
},
|
||||
],
|
||||
[t.inputBox.goalCommandDescription],
|
||||
[t.inputBox.compactCommandDescription, t.inputBox.goalCommandDescription],
|
||||
);
|
||||
|
||||
const reportUploadLimitViolations = useCallback(
|
||||
@@ -428,7 +438,11 @@ export function InputBox({
|
||||
|
||||
useEffect(() => {
|
||||
const goalRequestState = goalRequestStateRef.current;
|
||||
return () => abortGoalRequest(goalRequestState);
|
||||
const compactRequestState = compactRequestStateRef.current;
|
||||
return () => {
|
||||
abortGoalRequest(goalRequestState);
|
||||
abortGoalRequest(compactRequestState);
|
||||
};
|
||||
}, [threadId]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -601,6 +615,66 @@ export function InputBox({
|
||||
],
|
||||
);
|
||||
|
||||
const handleCompactCommand = useCallback(async (): Promise<void> => {
|
||||
if (isWelcomeMode) {
|
||||
textInput.setInput("");
|
||||
toast.info(t.inputBox.compactSkipped);
|
||||
return;
|
||||
}
|
||||
const request = beginGoalRequest(compactRequestStateRef.current, threadId);
|
||||
const signal = request.controller.signal;
|
||||
try {
|
||||
const result = await compactThreadContext(threadId, {
|
||||
signal,
|
||||
agentName:
|
||||
typeof context.agent_name === "string" ? context.agent_name : null,
|
||||
});
|
||||
if (
|
||||
!isCurrentGoalRequest(compactRequestStateRef.current, request, threadId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
textInput.setInput("");
|
||||
promptHistoryIndexRef.current = null;
|
||||
promptHistoryDraftRef.current = "";
|
||||
setFollowups([]);
|
||||
setFollowupsHidden(false);
|
||||
setFollowupsLoading(false);
|
||||
|
||||
void queryClient.invalidateQueries({ queryKey: ["thread", threadId] });
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: threadTokenUsageQueryKey(threadId),
|
||||
});
|
||||
|
||||
if (result.compacted) {
|
||||
toast.success(t.inputBox.compactSuccess);
|
||||
} else {
|
||||
toast.info(t.inputBox.compactSkipped);
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
isAbortError(error) ||
|
||||
!isCurrentGoalRequest(compactRequestStateRef.current, request, threadId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t.inputBox.compactFailed,
|
||||
);
|
||||
} finally {
|
||||
finishGoalRequest(compactRequestStateRef.current, request);
|
||||
}
|
||||
}, [
|
||||
context.agent_name,
|
||||
queryClient,
|
||||
t.inputBox.compactFailed,
|
||||
t.inputBox.compactSkipped,
|
||||
t.inputBox.compactSuccess,
|
||||
isWelcomeMode,
|
||||
textInput,
|
||||
threadId,
|
||||
]);
|
||||
|
||||
const submitThreadMessage = useCallback(
|
||||
(message: PromptInputMessage) => {
|
||||
const files = message.files.flatMap((file) =>
|
||||
@@ -713,6 +787,9 @@ export function InputBox({
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (submitAction.kind === "compact") {
|
||||
return handleCompactCommand();
|
||||
}
|
||||
if (submitAction.kind === "stop") {
|
||||
onStop?.();
|
||||
return;
|
||||
@@ -723,6 +800,7 @@ export function InputBox({
|
||||
return submitThreadMessage(message);
|
||||
},
|
||||
[
|
||||
handleCompactCommand,
|
||||
handleGoalCommand,
|
||||
onStop,
|
||||
status,
|
||||
|
||||
@@ -151,6 +151,8 @@ export const enUS: Translations = {
|
||||
suggestionPlaceholderRequired:
|
||||
"Replace the suggestion placeholder before sending.",
|
||||
goalCommandDescription: "Set, show, or clear an active goal",
|
||||
compactCommandDescription:
|
||||
"Compact earlier context while keeping the full chat visible",
|
||||
goalLabel: "Goal",
|
||||
goalContinuing: "Continuing {count}/{max}",
|
||||
goalContinuationTooltip:
|
||||
@@ -160,6 +162,10 @@ export const enUS: Translations = {
|
||||
goalNone: "No active goal.",
|
||||
goalActive: "Active goal: {goal}",
|
||||
goalFailed: "Goal command failed.",
|
||||
compactSuccess:
|
||||
"Earlier context compacted. The full chat remains visible; future model calls will use the summary and recent messages.",
|
||||
compactSkipped: "The current context does not need compaction yet.",
|
||||
compactFailed: "Context compaction failed.",
|
||||
suggestions: [
|
||||
{
|
||||
suggestion: "Write",
|
||||
|
||||
@@ -126,6 +126,7 @@ export interface Translations {
|
||||
followupConfirmReplace: string;
|
||||
suggestionPlaceholderRequired: string;
|
||||
goalCommandDescription: string;
|
||||
compactCommandDescription: string;
|
||||
goalLabel: string;
|
||||
goalContinuing: string;
|
||||
goalContinuationTooltip: string;
|
||||
@@ -134,6 +135,9 @@ export interface Translations {
|
||||
goalNone: string;
|
||||
goalActive: string;
|
||||
goalFailed: string;
|
||||
compactSuccess: string;
|
||||
compactSkipped: string;
|
||||
compactFailed: string;
|
||||
suggestions: {
|
||||
suggestion: string;
|
||||
prompt: string;
|
||||
|
||||
@@ -144,6 +144,7 @@ export const zhCN: Translations = {
|
||||
followupConfirmReplace: "替换并发送",
|
||||
suggestionPlaceholderRequired: "发送前请先填写建议模板中的占位内容。",
|
||||
goalCommandDescription: "设置、查看或清除当前目标",
|
||||
compactCommandDescription: "压缩早期上下文,保留完整聊天记录",
|
||||
goalLabel: "目标",
|
||||
goalContinuing: "续跑中 {count}/{max}",
|
||||
goalContinuationTooltip:
|
||||
@@ -153,6 +154,10 @@ export const zhCN: Translations = {
|
||||
goalNone: "当前没有目标。",
|
||||
goalActive: "当前目标:{goal}",
|
||||
goalFailed: "目标命令执行失败。",
|
||||
compactSuccess:
|
||||
"已压缩早期上下文。完整聊天记录仍保留,后续模型将基于摘要和最近消息继续。",
|
||||
compactSkipped: "当前上下文还不需要压缩。",
|
||||
compactFailed: "上下文压缩失败。",
|
||||
suggestions: [
|
||||
{
|
||||
suggestion: "写作",
|
||||
|
||||
@@ -3,6 +3,22 @@ import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type { ThreadTokenUsageResponse } from "./types";
|
||||
|
||||
export type ThreadCompactResponse = {
|
||||
thread_id: string;
|
||||
compacted: boolean;
|
||||
reason?: string | null;
|
||||
removed_message_count: number;
|
||||
preserved_message_count: number;
|
||||
summary_updated: boolean;
|
||||
checkpoint_id?: string | null;
|
||||
total_tokens: number;
|
||||
};
|
||||
|
||||
export type CompactThreadContextOptions = {
|
||||
signal?: AbortSignal;
|
||||
agentName?: string | null;
|
||||
};
|
||||
|
||||
export type ThreadBranchResponse = {
|
||||
thread_id: string;
|
||||
parent_thread_id: string;
|
||||
@@ -79,3 +95,31 @@ export async function branchThreadFromTurn(
|
||||
|
||||
return (await response.json()) as ThreadBranchResponse;
|
||||
}
|
||||
|
||||
export async function compactThreadContext(
|
||||
threadId: string,
|
||||
options: CompactThreadContextOptions = {},
|
||||
): Promise<ThreadCompactResponse> {
|
||||
const response = await fetchWithAuth(
|
||||
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/compact`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
force: true,
|
||||
...(options.agentName ? { agent_name: options.agentName } : {}),
|
||||
}),
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await readThreadAPIError(response, "Failed to compact context."),
|
||||
);
|
||||
}
|
||||
|
||||
return (await response.json()) as ThreadCompactResponse;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getMatchingSkillSuggestions,
|
||||
isAbortError,
|
||||
isCurrentGoalRequest,
|
||||
parseCompactCommand,
|
||||
parseGoalCommand,
|
||||
readGoalResponseError,
|
||||
type SlashSuggestion,
|
||||
@@ -62,6 +63,20 @@ describe("parseGoalCommand", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseCompactCommand", () => {
|
||||
it("matches compact commands", () => {
|
||||
expect(parseCompactCommand("/compact")).toBe(true);
|
||||
expect(parseCompactCommand(" /context compact ")).toBe(true);
|
||||
expect(parseCompactCommand("/CONTEXT COMPACT")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-compact commands", () => {
|
||||
expect(parseCompactCommand("/compact now")).toBe(false);
|
||||
expect(parseCompactCommand("/context")).toBe(false);
|
||||
expect(parseCompactCommand("compact")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getInputSubmitAction", () => {
|
||||
it("handles /goal commands before the streaming stop shortcut", () => {
|
||||
expect(
|
||||
@@ -106,6 +121,33 @@ describe("getInputSubmitAction", () => {
|
||||
).toEqual({ kind: "message" });
|
||||
});
|
||||
|
||||
it("handles compact commands", () => {
|
||||
expect(
|
||||
getInputSubmitAction({
|
||||
text: "/compact",
|
||||
fileCount: 0,
|
||||
status: "ready",
|
||||
}),
|
||||
).toEqual({ kind: "compact" });
|
||||
expect(
|
||||
getInputSubmitAction({
|
||||
text: "/context compact",
|
||||
fileCount: 0,
|
||||
status: "ready",
|
||||
}),
|
||||
).toEqual({ kind: "compact" });
|
||||
});
|
||||
|
||||
it("does not treat compact commands with attachments as compact", () => {
|
||||
expect(
|
||||
getInputSubmitAction({
|
||||
text: "/compact",
|
||||
fileCount: 1,
|
||||
status: "ready",
|
||||
}),
|
||||
).toEqual({ kind: "message" });
|
||||
});
|
||||
|
||||
it("ignores empty ready submits", () => {
|
||||
expect(
|
||||
getInputSubmitAction({
|
||||
@@ -243,6 +285,21 @@ describe("goal request lifecycle", () => {
|
||||
).toBe(true);
|
||||
expect(isAbortError(new Error("other"))).toBe(false);
|
||||
});
|
||||
|
||||
it("supports compact request staleness guards with the same lifecycle", () => {
|
||||
const state = createGoalRequestState();
|
||||
const compact = beginGoalRequest(state, "thread-1");
|
||||
|
||||
const replacement = beginGoalRequest(state, "thread-1");
|
||||
|
||||
expect(compact.controller.signal.aborted).toBe(true);
|
||||
expect(isCurrentGoalRequest(state, compact, "thread-1")).toBe(false);
|
||||
expect(isCurrentGoalRequest(state, replacement, "thread-1")).toBe(true);
|
||||
|
||||
finishGoalRequest(state, replacement);
|
||||
|
||||
expect(isCurrentGoalRequest(state, replacement, "thread-1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findSuggestionTemplatePlaceholder", () => {
|
||||
|
||||
@@ -112,3 +112,46 @@ test("branchThreadFromTurn surfaces gateway detail on failure", async () => {
|
||||
}),
|
||||
).rejects.toThrow("This turn can no longer be branched from.");
|
||||
});
|
||||
|
||||
test("compactThreadContext posts agent attribution and abort signal", async () => {
|
||||
const controller = new AbortController();
|
||||
fetchWithAuth.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
thread_id: "thread-1",
|
||||
compacted: true,
|
||||
removed_message_count: 4,
|
||||
preserved_message_count: 2,
|
||||
summary_updated: true,
|
||||
checkpoint_id: "checkpoint-3",
|
||||
total_tokens: 123,
|
||||
}),
|
||||
});
|
||||
|
||||
const { compactThreadContext } = await import("@/core/threads/api");
|
||||
|
||||
await expect(
|
||||
compactThreadContext("thread-1", {
|
||||
agentName: "research-agent",
|
||||
signal: controller.signal,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
compacted: true,
|
||||
checkpoint_id: "checkpoint-3",
|
||||
});
|
||||
|
||||
expect(fetchWithAuth).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/api/threads/thread-1/compact"),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
force: true,
|
||||
agent_name: "research-agent",
|
||||
}),
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user