diff --git a/frontend-web/src/components/workspace/agent-info-sidebar.tsx b/frontend-web/src/components/workspace/agent-info-sidebar.tsx new file mode 100644 index 00000000..27bd7640 --- /dev/null +++ b/frontend-web/src/components/workspace/agent-info-sidebar.tsx @@ -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(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(); + 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([]); + + 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 ( +
+ + + +
+ ); + } + + return ( + + ); +} diff --git a/frontend-web/src/core/agents/types.ts b/frontend-web/src/core/agents/types.ts index 21b4ceb7..1709fddf 100644 --- a/frontend-web/src/core/agents/types.ts +++ b/frontend-web/src/core/agents/types.ts @@ -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; } diff --git a/frontend-web/src/core/messages/utils.ts b/frontend-web/src/core/messages/utils.ts index e030a213..2b6ea765 100644 --- a/frontend-web/src/core/messages/utils.ts +++ b/frontend-web/src/core/messages/utils.ts @@ -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; } /** diff --git a/frontend-web/src/pages/AgentChatPage.tsx b/frontend-web/src/pages/AgentChatPage.tsx index 3a834120..e26bc612 100644 --- a/frontend-web/src/pages/AgentChatPage.tsx +++ b/frontend-web/src/pages/AgentChatPage.tsx @@ -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() { + + handleSubmit({ text, files: [] })} + /> diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index e8944de1..cab36879 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -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///SKILL.md` path (per the `` 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 diff --git a/offline-backend-20260512/backend/app/gateway/routers/agents.py b/offline-backend-20260512/backend/app/gateway/routers/agents.py index 7848dc8c..2a9f8fc6 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/agents.py +++ b/offline-backend-20260512/backend/app/gateway/routers/agents.py @@ -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) diff --git a/offline-backend-20260512/backend/docs/summarization.md b/offline-backend-20260512/backend/docs/summarization.md index c33a6895..a3037a0f 100644 --- a/offline-backend-20260512/backend/docs/summarization.md +++ b/offline-backend-20260512/backend/docs/summarization.md @@ -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 diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py index f2443b3f..276f7adf 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py @@ -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, diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/agents_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/agents_config.py index 3fddcd22..b8be6e01 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/config/agents_config.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/agents_config.py @@ -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: diff --git a/offline-backend-20260512/backend/tests/test_agents_backend.py b/offline-backend-20260512/backend/tests/test_agents_backend.py index d835484a..e59e847e 100644 --- a/offline-backend-20260512/backend/tests/test_agents_backend.py +++ b/offline-backend-20260512/backend/tests/test_agents_backend.py @@ -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: