This commit is contained in:
吉鑫升 committed 2026-06-11 09:34:45 +08:00
commit 6efe1b80c5
10 files changed
+603 -67

No files matched your search

@@ -0,0 +1,299 @@
"use client";
import {
BotIcon,
CheckIcon,
InfoIcon,
MessageCircleQuestionIcon,
PanelRightCloseIcon,
PanelRightOpenIcon,
PencilIcon,
PlusIcon,
SparklesIcon,
XIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
import { Tooltip } from "@/components/workspace/tooltip";
import { type Agent } from "@/core/agents";
import { useUpdateAgent } from "@/core/agents/hooks";
import { getStoredAuth } from "@/core/auth";
import { useSkills } from "@/core/skills/hooks";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
/**
* Collapsible right-hand panel on the agent chat page that surfaces the agent's
* profile (name / description / skills) plus a list of frequently-asked
* questions ("常用问题"). Clicking a question sends it as a message. The agent's
* owner (or an admin) can edit the question list inline.
*/
export function AgentInfoSidebar({
agent,
agentName,
onAskQuestion,
disabled,
}: {
agent: Agent | null | undefined;
agentName: string;
onAskQuestion: (text: string) => void;
disabled?: boolean;
}) {
const [collapsed, setCollapsed] = useState(true);
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
const { skills } = useSkills();
const updateAgent = useUpdateAgent();
const displayName = desensitize(agent?.name ?? agentName);
const description = agent?.description
? desensitize(agent.description)
: undefined;
// Owner / admin gate for inline editing of the common-questions list.
const storedAuth = useMemo(() => getStoredAuth(), []);
const canEdit = useMemo(() => {
if (!agent) return false;
if (storedAuth?.system_role === "admin") return true;
return agent.user_id != null && agent.user_id === storedAuth?.user_id;
}, [agent, storedAuth]);
// Map a stored skill name to its Chinese display label (falls back to name).
const skillLabel = useMemo(() => {
const map = new Map<string, string>();
for (const skill of skills) {
map.set(skill.name, skill.name_zh?.trim() ? skill.name_zh : skill.name);
}
return (name: string) => map.get(name) ?? name;
}, [skills]);
const questions = useMemo(
() => (agent?.common_questions ?? []).filter((q) => q.trim().length > 0),
[agent?.common_questions],
);
// --- Inline editing state ---
const [editing, setEditing] = useState(false);
const [drafts, setDrafts] = useState<string[]>([]);
const startEditing = () => {
setDrafts(questions.length > 0 ? [...questions] : [""]);
setEditing(true);
};
const cancelEditing = () => {
setEditing(false);
setDrafts([]);
};
const handleSave = async () => {
if (!agent) return;
const cleaned = drafts.map((q) => q.trim()).filter((q) => q.length > 0);
try {
await updateAgent.mutateAsync({
id: agent.id,
request: { common_questions: cleaned },
});
toast.success("常用问题已保存");
setEditing(false);
setDrafts([]);
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
}
};
// Leaving edit mode whenever the panel collapses keeps the state tidy.
useEffect(() => {
if (collapsed && editing) cancelEditing();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [collapsed]);
if (collapsed) {
return (
<div className="flex shrink-0 flex-col items-center border-l pt-14">
<Tooltip content="智能体简介">
<Button
variant="ghost"
size="icon-sm"
onClick={() => setCollapsed(false)}
aria-label="展开智能体简介"
>
<PanelRightOpenIcon className="h-4 w-4" />
</Button>
</Tooltip>
</div>
);
}
return (
<aside className="bg-card/40 flex w-72 shrink-0 flex-col border-l pt-12">
<div className="flex items-center justify-between border-b px-3 py-2">
<span className="text-sm font-semibold">智能体简介</span>
<Tooltip content="收起">
<Button
variant="ghost"
size="icon-sm"
onClick={() => setCollapsed(true)}
aria-label="收起智能体简介"
>
<PanelRightCloseIcon className="h-4 w-4" />
</Button>
</Tooltip>
</div>
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto p-4">
{/* Identity */}
<div className="flex flex-col items-center gap-2 text-center">
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-full">
<BotIcon className="text-primary h-6 w-6" />
</div>
<div className="text-base font-bold" title={displayName}>
{displayName}
</div>
{description && (
<p className="text-muted-foreground text-xs leading-relaxed">
{description}
</p>
)}
</div>
{/* Skills */}
{agent?.skills && agent.skills.length > 0 && (
<div className="space-y-2">
<div className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium">
<SparklesIcon className="h-3.5 w-3.5" />
技能
</div>
<div className="flex flex-wrap gap-1.5">
{agent.skills.map((name) => (
<button
key={name}
type="button"
onClick={() => setDetailSkillName(name)}
title="查看技能详情"
className="border-border bg-background hover:bg-accent inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition-colors"
>
<span className="max-w-[150px] truncate">
{skillLabel(name)}
</span>
<InfoIcon className="text-muted-foreground h-3 w-3 shrink-0" />
</button>
))}
</div>
</div>
)}
{/* Common questions */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium">
<MessageCircleQuestionIcon className="h-3.5 w-3.5" />
常用问题
</div>
{canEdit && !editing && (
<Tooltip content="编辑常用问题">
<Button
variant="ghost"
size="icon-sm"
onClick={startEditing}
aria-label="编辑常用问题"
>
<PencilIcon className="h-3.5 w-3.5" />
</Button>
</Tooltip>
)}
</div>
{editing ? (
<div className="space-y-2">
{drafts.map((value, index) => (
<div key={index} className="flex items-center gap-1">
<Input
value={value}
placeholder={`问题 ${index + 1}`}
onChange={(e) => {
const next = [...drafts];
next[index] = e.currentTarget.value;
setDrafts(next);
}}
className="h-8 text-xs"
/>
<Button
variant="ghost"
size="icon-sm"
onClick={() =>
setDrafts(drafts.filter((_, i) => i !== index))
}
aria-label="删除"
>
<XIcon className="h-3.5 w-3.5" />
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => setDrafts([...drafts, ""])}
>
<PlusIcon className="h-3.5 w-3.5" /> 添加问题
</Button>
<div className="flex gap-2 pt-1">
<Button
size="sm"
className="flex-1"
onClick={() => void handleSave()}
disabled={updateAgent.isPending}
>
<CheckIcon className="h-3.5 w-3.5" /> 保存
</Button>
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={cancelEditing}
disabled={updateAgent.isPending}
>
取消
</Button>
</div>
</div>
) : questions.length > 0 ? (
<div className="flex flex-col gap-1.5">
{questions.map((question, index) => (
<button
key={index}
type="button"
disabled={disabled}
onClick={() => onAskQuestion(question)}
title={question}
className={cn(
"border-border bg-background hover:bg-accent w-full rounded-md border px-2.5 py-1.5 text-left text-xs transition-colors",
"disabled:cursor-not-allowed disabled:opacity-50",
)}
>
{question}
</button>
))}
</div>
) : (
<p className="text-muted-foreground text-xs">
{canEdit ? "还没有常用问题,点击上方编辑添加。" : "暂无常用问题。"}
</p>
)}
</div>
</div>
<SkillDetailDialog
skillName={detailSkillName}
open={detailSkillName !== null}
onOpenChange={(o) => {
if (!o) setDetailSkillName(null);
}}
/>
</aside>
);
}
+4
View File
@@ -16,6 +16,8 @@ export interface Agent {
model?: string | null;
tool_groups?: string[] | null;
skills?: string[] | null;
/** Frequently-asked questions (常用问题) shown in the agent chat sidebar. */
common_questions?: string[] | null;
tags?: Tag[];
featured_order?: number | null;
/** Whether the current user has favorited (收藏) this agent. */
@@ -46,6 +48,7 @@ export interface CreateAgentRequest {
skills?: string[] | null;
model?: string | null;
tool_groups?: string[] | null;
common_questions?: string[] | null;
}
export interface UpdateAgentRequest {
@@ -57,4 +60,5 @@ export interface UpdateAgentRequest {
skills?: string[] | null;
model?: string | null;
tool_groups?: string[] | null;
common_questions?: string[] | null;
}
+11 -1
View File
@@ -434,7 +434,17 @@ export function findToolCallResult(
}
export function isHiddenFromUIMessage(message: Message) {
return message.additional_kwargs?.hide_from_ui === true;
if (message.additional_kwargs?.hide_from_ui === true) {
return true;
}
// Conversation-summary messages are context for the model only — never shown
// to the user. New summaries are injected transiently (never persisted), but
// threads compacted by older builds still carry a persisted `name: "summary"`
// HumanMessage, so hide those too.
if (message.name === "summary") {
return true;
}
return false;
}
/**
+11
View File
@@ -7,6 +7,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button";
import { AgentInfoSidebar } from "@/components/workspace/agent-info-sidebar";
import { AgentWelcome } from "@/components/workspace/agent-welcome";
import { ArtifactTrigger } from "@/components/workspace/artifacts";
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
@@ -285,6 +286,16 @@ export default function AgentChatPage() {
</div>
</div>
</main>
<AgentInfoSidebar
agent={agent}
agentName={agentDisplayName}
disabled={
thread.isLoading ||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
}
onAskQuestion={(text) => handleSubmit({ text, files: [] })}
/>
</div>
</ChatBox>
</ThreadContext.Provider>
+2 -2
View File
@@ -163,7 +163,7 @@ Lead-agent middlewares are assembled in strict append order across `packages/har
6. **GuardrailMiddleware** - Pre-tool-call authorization via pluggable `GuardrailProvider` protocol (optional, if `guardrails.enabled` in config). Evaluates each tool call and returns error ToolMessage on deny. Three provider options: built-in `AllowlistProvider` (zero deps), OAP policy providers (e.g. `aport-agent-guardrails`), or custom providers. See [docs/GUARDRAILS.md](docs/GUARDRAILS.md) for setup, usage, and how to implement a provider.
7. **SandboxAuditMiddleware** - Audits sandboxed shell/file operations for security logging before tool execution continues
8. **ToolErrorHandlingMiddleware** - Converts tool exceptions into error `ToolMessage`s so the run can continue instead of aborting
9. **SummarizationMiddleware** - Context reduction when approaching token limits (optional, if enabled)
9. **SummarizationMiddleware** (`DeerFlowSummarizationMiddleware`) - Context reduction when approaching token limits (optional, if enabled). **Non-destructive**: overrides the stock `before_model` (which persists a `RemoveMessage(REMOVE_ALL_MESSAGES)`) to a no-op and instead compresses **transiently** in `wrap_model_call` — the model request is replaced with `[summary, *recent]` while the full original conversation stays in state (so the frontend keeps rendering the user's real Q&A). The `summary` message is `hide_from_ui` and never persisted; summaries are cached per-thread (bounded, sticky boundary) to avoid an LLM call on every model invocation. Tests: `tests/test_summarization_middleware.py`
10. **TodoListMiddleware** - Task tracking with `write_todos` tool (optional, if plan_mode)
11. **TokenUsageMiddleware** - Records token usage metrics when token tracking is enabled (optional)
12. **TitleMiddleware** - Two-phase auto-title (never shows "untitled"): `before_model` sets an instant question-derived *provisional* title the moment the first user message arrives (no LLM, zero latency), then `after_model` asynchronously generates the final LLM title and replaces it (tracked via the `title_provisional` state flag). Normalizes structured message content before prompting the title model
@@ -175,7 +175,7 @@ Lead-agent middlewares are assembled in strict append order across `packages/har
18. **ClarificationMiddleware** - Intercepts `ask_clarification` tool calls, interrupts via `Command(goto=END)` (must be last)
8a. **ToolMetricsMiddleware** - Appended immediately after `ToolErrorHandlingMiddleware` in `_build_runtime_middlewares`, so it sits *deepest* in the `wrap_tool_call` chain. Records one `tool_call_metrics` row per tool/skill/MCP invocation — timing duration and bucketing failures into a coarse `error_category` (rate_limited / ip_blocked / auth_error / timeout / network_error / not_found / empty_result / tool_error). It catches both raised exceptions (before #8 converts them) and *self-handled* errors returned as error `ToolMessage`s (search tools that swallow HTTP 429/403). It also captures the targeted Agent Skill into `tool_call_metrics.skill_name`: the lead agent invokes a skill by `read_file`-ing its `/mnt/skills/<category>/<name>/SKILL.md` path (per the `<skill_system>` prompt block — there is no dedicated "use skill" tool), so `skill_name` is recovered by scanning every tool call's string arguments for the `skills/{public,custom}/{name}/` path shape (`_SKILL_PATH_RE`), plus the explicit `name` argument of `skill_view`/`skill_manage`/`skill_archive`. `skill_view`-specific success detection handles its swallowed failures (`Skill 'x' not found.` returned as a plain string rather than raised). Regression coverage: `tests/test_tool_metrics_middleware.py`. Writes are best-effort fire-and-forget. **Per-run skill merging**: `SqlToolMetricsStore.record` upserts skill rows by `(run_id, skill_name)` instead of inserting one row per call — a skill touched by several tool calls / retries inside one run collapses to a single row. Any success in the run wins (a skill that failed then finally succeeded is recorded as one `success`); a skill that only ever failed yields one `error` row whose `error_message` accumulates every attempt's message; durations are summed. Non-skill tool calls, and skill calls with no `run_id`, keep one row each. The read→merge→write is guarded by a per-event-loop `asyncio.Lock`. Surfaced by the admin `GET /api/admin/tool-metrics` router (which has a `kind=skill` filter and a per-skill `/summary` breakdown).
9. **PromptPrefixMiddleware** - Injects the admin-configured prompt prefix into the model request only. The prefix is tagged onto the first human message's `additional_kwargs['prompt_prefix']` by the Gateway (`app.gateway.services`) and prepended to content here at call time, so the persisted/displayed message stays prefix-free
10. **SummarizationMiddleware** - Context reduction when approaching token limits (optional, if enabled)
10. **SummarizationMiddleware** (`DeerFlowSummarizationMiddleware`) - Context reduction when approaching token limits (optional, if enabled). **Non-destructive**: overrides the stock `before_model` (which persists a `RemoveMessage(REMOVE_ALL_MESSAGES)`) to a no-op and instead compresses **transiently** in `wrap_model_call` — the model request is replaced with `[summary, *recent]` while the full original conversation stays in state (so the frontend keeps rendering the user's real Q&A). The `summary` message is `hide_from_ui` and never persisted; summaries are cached per-thread (bounded, sticky boundary) to avoid an LLM call on every model invocation. Tests: `tests/test_summarization_middleware.py`
11. **TodoListMiddleware** - Task tracking with `write_todos` tool (optional, if plan_mode)
12. **TokenUsageMiddleware** - Records token usage metrics when token tracking is enabled (optional)
13. **TitleMiddleware** - Two-phase auto-title (never shows "untitled"): `before_model` sets an instant question-derived *provisional* title the moment the first user message arrives (no LLM, zero latency), then `after_model` asynchronously generates the final LLM title and replaces it (tracked via the `title_provisional` state flag). Normalizes structured message content before prompting the title model
@@ -53,6 +53,11 @@ class AgentResponse(BaseModel):
tool_groups: list[str] | None = None
skills: list[str] | None = None
common_questions: list[str] | None = Field(
default=None,
description="Frequently-asked questions shown in the agent chat sidebar (常用问题).",
)
tags: list[dict] = Field(default_factory=list, description="Tags assigned to this agent")
featured_order: int | None = Field(
@@ -101,6 +106,7 @@ class AgentCreateRequest(BaseModel):
skills: list[str] | None = Field(default=None, description="Skill whitelist persisted to the agent's config.yaml")
model: str | None = Field(default=None, description="Optional model override persisted to config.yaml")
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist persisted to config.yaml")
common_questions: list[str] | None = Field(default=None, description="Frequently-asked questions (常用问题) persisted to config.yaml")
class AgentUpdateRequest(BaseModel):
@@ -114,6 +120,7 @@ class AgentUpdateRequest(BaseModel):
skills: list[str] | None = Field(default=None, description="Updated skill whitelist (config.yaml)")
model: str | None = Field(default=None, description="Updated model override (config.yaml)")
tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist (config.yaml)")
common_questions: list[str] | None = Field(default=None, description="Updated frequently-asked questions (常用问题, config.yaml)")
class AgentSelectorResponse(BaseModel):
@@ -217,6 +224,7 @@ def _write_agent_config(
skills: list[str] | None = None,
model: str | None = None,
tool_groups: list[str] | None = None,
common_questions: list[str] | None = None,
) -> None:
agent_dir = get_paths().agent_dir(agent_id)
agent_dir.mkdir(parents=True, exist_ok=True)
@@ -232,19 +240,27 @@ def _write_agent_config(
config_data["model"] = model
if tool_groups is not None:
config_data["tool_groups"] = tool_groups
if common_questions is not None:
config_data["common_questions"] = common_questions
with open(config_file, "w", encoding="utf-8") as f:
yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True)
def _load_config_extras(agent_id: str) -> dict[str, Any]:
"""Read skills/model/tool_groups from the agent's config.yaml, tolerant of missing files."""
"""Read skills/model/tool_groups/common_questions from the agent's config.yaml, tolerant of missing files."""
empty = {"skills": None, "model": None, "tool_groups": None, "common_questions": None}
try:
cfg = load_agent_config(agent_id)
except (FileNotFoundError, ValueError):
return {"skills": None, "model": None, "tool_groups": None}
return dict(empty)
if cfg is None:
return {"skills": None, "model": None, "tool_groups": None}
return {"skills": cfg.skills, "model": cfg.model, "tool_groups": cfg.tool_groups}
return dict(empty)
return {
"skills": cfg.skills,
"model": cfg.model,
"tool_groups": cfg.tool_groups,
"common_questions": cfg.common_questions,
}
def _write_agent_soul(agent_id: str, soul: str) -> None:
@@ -404,7 +420,7 @@ def _record_to_response(
can happen lazily when the agent is actually opened.
"""
soul: str | None = None
extras: dict[str, Any] = {"skills": None, "model": None, "tool_groups": None}
extras: dict[str, Any] = {"skills": None, "model": None, "tool_groups": None, "common_questions": None}
if include_extras:
_ensure_agent_dir_from_record(record)
extras = _load_config_extras(record["id"])
@@ -425,6 +441,7 @@ def _record_to_response(
model=extras["model"],
tool_groups=extras["tool_groups"],
skills=extras["skills"],
common_questions=extras.get("common_questions"),
featured_order=record.get("featured_order"),
)
@@ -725,6 +742,7 @@ async def create_agent_endpoint(request: AgentCreateRequest, http_request: Reque
skills=request.skills,
model=request.model,
tool_groups=request.tool_groups,
common_questions=request.common_questions,
)
_write_agent_soul(agent_id, request.soul)
@@ -797,8 +815,9 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
next_skills = request.skills if "skills" in fields_set else existing_extras["skills"]
next_model = request.model if "model" in fields_set else existing_extras["model"]
next_tool_groups = request.tool_groups if "tool_groups" in fields_set else existing_extras["tool_groups"]
next_common_questions = request.common_questions if "common_questions" in fields_set else existing_extras["common_questions"]
config_dirty = bool(update_data) or any(key in fields_set for key in ("skills", "model", "tool_groups"))
config_dirty = bool(update_data) or any(key in fields_set for key in ("skills", "model", "tool_groups", "common_questions"))
if config_dirty:
_write_agent_config(
agent_id,
@@ -807,6 +826,7 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
skills=next_skills,
model=next_model,
tool_groups=next_tool_groups,
common_questions=next_common_questions,
)
# Keep the listing cache in lock-step with config.yaml on every write.
await store.replace_skills(agent_id, next_skills)
@@ -4,13 +4,26 @@ DeerFlow includes automatic conversation summarization to handle long conversati
## Overview
The summarization feature uses LangChain's `SummarizationMiddleware` to monitor conversation history and trigger summarization based on configurable thresholds. When activated, it:
The summarization feature uses DeerFlow's `DeerFlowSummarizationMiddleware` (a subclass of LangChain's `SummarizationMiddleware`) to monitor conversation history and trigger summarization based on configurable thresholds. When activated, it:
1. Monitors message token counts in real-time
2. Triggers summarization when thresholds are met
3. Keeps recent messages intact while summarizing older exchanges
4. Maintains AI/Tool message pairs together for context continuity
5. Injects the summary back into the conversation
5. Substitutes the summary for the older messages **in the model request only**
### Non-destructive (display-preserving) compression
> **Important:** DeerFlow compresses **transiently**, not destructively.
LangChain's stock `SummarizationMiddleware` runs in `before_model` and persists a `RemoveMessage(REMOVE_ALL_MESSAGES)` to the checkpoint — which permanently deletes the original Q&A from both the model context *and* the conversation the frontend renders. DeerFlow overrides this:
- `before_model` / `abefore_model` are **no-ops** (the destructive path is disabled).
- Compression runs in `wrap_model_call` / `awrap_model_call`: for a single model call, the message list is replaced with `[summary, *recent]` via `request.override(messages=...)`. **Nothing is written back to state.**
- The full original messages stay in the checkpoint, so the frontend keeps showing the user's real conversation. The summary message is named `summary` and flagged `hide_from_ui`, and because it only ever lives in the transient request it never reaches the frontend at all.
- The generated summary is cached **per-thread** so we don't pay an extra LLM call on every model invocation. The cached summary is reused (no LLM call) until enough *new* messages accumulate to push the compressed context back over the trigger threshold (a sticky boundary mirroring the stock middleware's post-compaction behavior). The cache is bounded (oldest threads evicted first).
> **Legacy threads:** conversations compacted by an older (destructive) build already lost their original messages and carry a persisted `summary` message. The frontend hides any `name: "summary"` message, so the stray summary bubble no longer shows — but those already-discarded messages cannot be recovered.
## Configuration
@@ -169,10 +182,7 @@ The default LangChain prompt instructs the model to:
- Messages to summarize (older messages beyond the `keep` threshold)
- Messages to preserve (recent messages within the `keep` threshold)
4. **Summary Generation**: The model generates a concise summary of the older messages
5. **Context Replacement**: The message history is updated:
- All old messages are removed
- A single summary message is added
- Recent messages are preserved
5. **Context Replacement (transient)**: The message list **handed to the model for this call** is rebuilt as `[summary, *recent]`. The persisted state is left untouched — old messages are **not** removed, so the frontend still renders the full original conversation.
6. **AI/Tool Pair Protection**: The system ensures AI messages and their corresponding tool messages stay together
7. **Skill Rescue**: Before the summary is generated, the most recently loaded skill files (tool results whose tool name is in `skill_file_read_tool_names` and whose target path is under `skills.container_path`) are lifted out of the summarization set and prepended to the preserved tail. Selection walks newest-first under three budgets: `preserve_recent_skill_count`, `preserve_recent_skill_tokens`, and `preserve_recent_skill_tokens_per_skill`. The triggering AIMessage and all of its paired ToolMessages move together so tool_call ↔ tool_result pairing stays intact.
@@ -298,8 +308,9 @@ The middleware intelligently preserves message context:
### Code Structure
- **Configuration**: `packages/harness/deerflow/config/summarization_config.py`
- **Integration**: `packages/harness/deerflow/agents/lead_agent/agent.py`
- **Middleware**: Uses `langchain.agents.middleware.SummarizationMiddleware`
- **Integration**: `packages/harness/deerflow/agents/lead_agent/agent.py` (`_create_summarization_middleware`)
- **Middleware**: `packages/harness/deerflow/agents/middlewares/summarization_middleware.py` — `DeerFlowSummarizationMiddleware`, subclassing `langchain.agents.middleware.SummarizationMiddleware` but overriding compaction to be transient (`wrap_model_call`) instead of destructive (`before_model`)
- **Tests**: `tests/test_summarization_middleware.py`
### Middleware Order
@@ -313,9 +324,9 @@ Summarization runs after ThreadData and Sandbox initialization but before Title
### State Management
- Summarization is stateless - configuration is loaded once at startup
- Summaries are added as regular messages in the conversation history
- The checkpointer persists the summarized history automatically
- Configuration is loaded once at startup
- Summarization is **non-destructive**: the summary replaces older messages only in the transient model request (`wrap_model_call`), never in persisted state. The checkpointer therefore keeps the full original conversation
- The per-thread summary cache lives in the middleware instance (in-memory, bounded). It is a cost optimization only — a cold/stale cache simply re-summarizes, which is always correct
## Example Configurations
@@ -1,21 +1,41 @@
"""Summarization middleware extensions for DeerFlow."""
"""Summarization middleware extensions for DeerFlow.
Unlike LangChain's stock ``SummarizationMiddleware`` (which runs in
``before_model`` and **persists** a destructive ``RemoveMessage(REMOVE_ALL_MESSAGES)``
to the checkpoint — wiping the original Q&A from both the model context *and*
the conversation the frontend renders), this middleware compresses the history
**transiently in ``wrap_model_call``**. The full original messages stay in state
(so the UI keeps showing the user's real conversation); only the message list
handed to the model for a single call is replaced with ``[summary, *recent]``.
The generated summary is cached per-thread so we don't pay an extra LLM call on
every model invocation: once a thread is summarized, the cached summary is reused
until enough *new* messages accumulate to push the compressed context back over
the trigger threshold (sticky boundary, mirroring the stock middleware's
post-compaction behavior).
"""
from __future__ import annotations
import logging
from collections.abc import Collection
from collections import OrderedDict
from collections.abc import Awaitable, Callable, Collection
from dataclasses import dataclass
from typing import Any, Protocol, override, runtime_checkable
from langchain.agents import AgentState
from langchain.agents.middleware import SummarizationMiddleware
from langchain_core.messages import AIMessage, AnyMessage, HumanMessage, RemoveMessage, ToolMessage
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse
from langchain_core.messages import AIMessage, AnyMessage, HumanMessage, ToolMessage
from langgraph.config import get_config
from langgraph.graph.message import REMOVE_ALL_MESSAGES
from langgraph.runtime import Runtime
logger = logging.getLogger(__name__)
# Upper bound on the per-thread summary cache so a long-lived process serving
# many threads can't grow it without limit. Oldest entries are evicted first.
_SUMMARY_CACHE_MAX_THREADS = 512
@dataclass(frozen=True)
class SummarizationEvent:
@@ -95,6 +115,22 @@ class _SkillBundle:
skill_key: str
@dataclass(frozen=True)
class _SummaryCacheEntry:
"""A cached, reusable summary for one thread.
``summarized_ids`` are the ids of the original messages folded into
``summary_text``. The cache is only reusable while every one of those ids is
still present in the live message list and no AIMessage/ToolMessage pair was
split when the boundary was drawn (``reusable``) — otherwise filtering by id
could orphan a tool result, so we fall back to re-summarizing.
"""
summarized_ids: frozenset[str]
summary_text: str
reusable: bool
class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
"""Summarization middleware with pre-compression hook dispatch and skill rescue."""
@@ -116,69 +152,181 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
self._preserve_recent_skill_count = max(0, preserve_recent_skill_count)
self._preserve_recent_skill_tokens = max(0, preserve_recent_skill_tokens)
self._preserve_recent_skill_tokens_per_skill = max(0, preserve_recent_skill_tokens_per_skill)
# Per-thread cache of the most recent summary: thread_id -> (summarized message ids, summary text).
# Lets us reuse a summary across model calls instead of re-running the LLM every invocation.
self._summary_cache: OrderedDict[str, _SummaryCacheEntry] = OrderedDict()
@override
def before_model(self, state: AgentState, runtime: Runtime) -> dict | None:
return self._maybe_summarize(state, runtime)
"""Disable the stock middleware's destructive ``before_model`` compaction.
Compression now happens transiently in :meth:`wrap_model_call`, so the
persisted conversation (and the frontend's view of it) is never mutated.
"""
return None
@override
async def abefore_model(self, state: AgentState, runtime: Runtime) -> dict | None:
return await self._amaybe_summarize(state, runtime)
"""Async counterpart of :meth:`before_model` — also a no-op."""
return None
def _maybe_summarize(self, state: AgentState, runtime: Runtime) -> dict | None:
messages = state["messages"]
self._ensure_message_ids(messages)
# ---- wrap_model_call: transient, display-preserving compression ----------
total_tokens = self.token_counter(messages)
if not self._should_summarize(messages, total_tokens):
@override
def wrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) -> ModelCallResult:
compressed = self._compressed_messages(request)
if compressed is not None:
request = request.override(messages=compressed)
return handler(request)
@override
async def awrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
) -> ModelCallResult:
compressed = await self._acompressed_messages(request)
if compressed is not None:
request = request.override(messages=compressed)
return await handler(request)
def _compressed_messages(self, request: ModelRequest) -> list[AnyMessage] | None:
"""Build the compressed message list for this call (sync summary path).
Returns ``None`` when no compression is needed (the request is passed
through untouched).
"""
plan = self._plan_compression(request)
if plan is None:
return None
cutoff_index = self._determine_cutoff_index(messages)
if cutoff_index <= 0:
return None
messages_to_summarize, preserved_messages = self._partition_with_skill_rescue(messages, cutoff_index)
self._fire_hooks(messages_to_summarize, preserved_messages, runtime)
action, payload = plan
if action == "reuse":
return payload # type: ignore[return-value]
# action == "summarize"
messages_to_summarize, preserved, thread_id = payload # type: ignore[misc]
self._fire_hooks(messages_to_summarize, preserved, getattr(request, "runtime", None))
summary = self._create_summary(messages_to_summarize)
new_messages = self._build_new_messages(summary)
return self._finish_summarize(messages_to_summarize, preserved, summary, thread_id)
return {
"messages": [
RemoveMessage(id=REMOVE_ALL_MESSAGES),
*new_messages,
*preserved_messages,
]
}
async def _acompressed_messages(self, request: ModelRequest) -> list[AnyMessage] | None:
"""Async counterpart of :meth:`_compressed_messages`."""
plan = self._plan_compression(request)
if plan is None:
return None
action, payload = plan
if action == "reuse":
return payload # type: ignore[return-value]
messages_to_summarize, preserved, thread_id = payload # type: ignore[misc]
self._fire_hooks(messages_to_summarize, preserved, getattr(request, "runtime", None))
summary = await self._acreate_summary(messages_to_summarize)
return self._finish_summarize(messages_to_summarize, preserved, summary, thread_id)
async def _amaybe_summarize(self, state: AgentState, runtime: Runtime) -> dict | None:
messages = state["messages"]
def _plan_compression(
self,
request: ModelRequest,
) -> tuple[str, Any] | None:
"""Decide what to do for this model call, doing **no** LLM work.
Returns one of:
- ``None`` — pass the request through unchanged (no compression needed).
- ``("reuse", compressed_messages)`` — reuse the cached summary.
- ``("summarize", (messages_to_summarize, preserved, thread_id))`` — the
caller must run the (a)sync summarizer, then call
:meth:`_finish_summarize`.
"""
messages = list(getattr(request, "messages", None) or [])
if not messages:
return None
self._ensure_message_ids(messages)
thread_id = self._resolve_request_thread_id(request)
total_tokens = self.token_counter(messages)
if not self._should_summarize(messages, total_tokens):
cached = self._summary_cache.get(thread_id) if thread_id else None
present_ids = {m.id for m in messages}
if cached is not None and cached.reusable and cached.summarized_ids <= present_ids:
preserved = [m for m in messages if m.id not in cached.summarized_ids]
effective = [self._summary_message(cached.summary_text), *preserved]
if not self._should_summarize(effective, self.token_counter(effective)):
# Refresh LRU recency and reuse without an LLM call.
self._summary_cache.move_to_end(thread_id)
return ("reuse", effective)
# New messages have pushed the compressed context back over the
# threshold — fall through and re-summarize a larger prefix.
if not self._should_summarize(messages, self.token_counter(messages)):
return None
cutoff_index = self._determine_cutoff_index(messages)
if cutoff_index <= 0:
return None
messages_to_summarize, preserved_messages = self._partition_with_skill_rescue(messages, cutoff_index)
self._fire_hooks(messages_to_summarize, preserved_messages, runtime)
summary = await self._acreate_summary(messages_to_summarize)
new_messages = self._build_new_messages(summary)
messages_to_summarize, preserved = self._partition_with_skill_rescue(messages, cutoff_index)
if not messages_to_summarize:
return None
return {
"messages": [
RemoveMessage(id=REMOVE_ALL_MESSAGES),
*new_messages,
*preserved_messages,
]
}
return ("summarize", (messages_to_summarize, preserved, thread_id))
def _finish_summarize(
self,
messages_to_summarize: list[AnyMessage],
preserved: list[AnyMessage],
summary: str,
thread_id: str | None,
) -> list[AnyMessage]:
"""Assemble the compressed message list and update the per-thread cache."""
compressed = [self._summary_message(summary), *preserved]
if thread_id:
summarized_ids = frozenset(m.id for m in messages_to_summarize if m.id is not None)
preserved_ids = {m.id for m in preserved if m.id is not None}
# A non-empty overlap means skill rescue split an AIMessage across the
# boundary; reconstructing ``preserved`` by id filtering later could
# orphan a tool result, so mark the entry non-reusable (re-summarize).
reusable = summarized_ids.isdisjoint(preserved_ids)
self._store_cache_entry(
thread_id,
_SummaryCacheEntry(summarized_ids=summarized_ids, summary_text=summary, reusable=reusable),
)
return compressed
def _store_cache_entry(self, thread_id: str, entry: _SummaryCacheEntry) -> None:
"""Insert/refresh a cache entry, evicting the oldest thread if over capacity."""
self._summary_cache[thread_id] = entry
self._summary_cache.move_to_end(thread_id)
while len(self._summary_cache) > _SUMMARY_CACHE_MAX_THREADS:
self._summary_cache.popitem(last=False)
@staticmethod
def _resolve_request_thread_id(request: ModelRequest) -> str | None:
"""Best-effort thread id from the model request's runtime."""
runtime = getattr(request, "runtime", None)
if runtime is None:
return None
return _resolve_thread_id(runtime)
def _summary_message(self, summary: str) -> HumanMessage:
"""Build the summary message handed to the model.
It is named ``summary`` and flagged ``hide_from_ui`` defensively. Because
it only ever lives in the transient model request (never in persisted
state), the frontend never receives it — the user keeps seeing the
original conversation.
"""
return HumanMessage(
content=f"Here is a summary of the conversation to date:\n\n{summary}",
name="summary",
additional_kwargs={"hide_from_ui": True, "lc_source": "summarization"},
)
@override
def _build_new_messages(self, summary: str) -> list[HumanMessage]:
"""Override the base implementation to let the human message with the special name 'summary'.
And this message will be ignored to display in the frontend, but still can be used as context for the model.
"""
return [HumanMessage(content=f"Here is a summary of the conversation to date:\n\n{summary}", name="summary")]
"""Kept for backwards compatibility; delegates to :meth:`_summary_message`."""
return [self._summary_message(summary)]
def _partition_with_skill_rescue(
self,
@@ -69,6 +69,9 @@ class AgentConfig(BaseModel):
# - [] (explicit empty list): no skills
# - ["skill1", "skill2"]: load only the specified skills
skills: list[str] | None = None
# Frequently-asked questions ("常用问题") shown in the agent chat sidebar.
# Display/UX metadata only — the runtime never reads these. None = none set.
common_questions: list[str] | None = None
def load_agent_config(agent_id: str | None) -> AgentConfig | None:
@@ -177,12 +177,42 @@ def test_load_config_extras_returns_values_written_to_yaml(isolated_paths) -> No
)
extras = _load_config_extras(agent_id)
assert extras == {"skills": ["s1"], "model": None, "tool_groups": None}
assert extras == {"skills": ["s1"], "model": None, "tool_groups": None, "common_questions": None}
def test_load_config_extras_missing_directory_returns_none_triple(isolated_paths) -> None:
extras = _load_config_extras("does-not-exist")
assert extras == {"skills": None, "model": None, "tool_groups": None}
assert extras == {"skills": None, "model": None, "tool_groups": None, "common_questions": None}
def test_write_agent_config_persists_common_questions(isolated_paths) -> None:
agent_id = "agent-cq-1"
questions = ["这个智能体能做什么?", "如何上传文件?"]
_write_agent_config(agent_id, name="助手", description="", common_questions=questions)
config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
assert raw["common_questions"] == questions
extras = _load_config_extras(agent_id)
assert extras["common_questions"] == questions
def test_write_agent_config_omits_common_questions_when_none(isolated_paths) -> None:
agent_id = "agent-cq-2"
_write_agent_config(agent_id, name="x", description="")
config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
assert "common_questions" not in raw
assert _load_config_extras(agent_id)["common_questions"] is None
def test_write_agent_config_explicit_empty_common_questions_is_persisted(isolated_paths) -> None:
agent_id = "agent-cq-3"
_write_agent_config(agent_id, name="x", description="", common_questions=[])
assert _load_config_extras(agent_id)["common_questions"] == []
def test_write_agent_config_explicit_empty_skills_is_persisted(isolated_paths) -> None: