feat: add manual context compaction (#3969)

* feat: add manual context compaction

* fix: harden manual context compaction
This commit is contained in:
Ryker_Feng
2026-07-07 19:55:33 +08:00
committed by GitHub
parent 8fbcdf821b
commit 26d7a5970d
21 changed files with 893 additions and 117 deletions
+5
View File
@@ -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.
+5
View File
@@ -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
View File
@@ -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.
+83 -2
View File
@@ -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)
+16 -14
View File
@@ -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
+191
View File
@@ -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
View File
@@ -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,
+6
View File
@@ -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",
+4
View File
@@ -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;
+5
View File
@@ -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: "写作",
+44
View File
@@ -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,
},
);
});