Merge branch 'ji' of http://192.168.0.200/knowledge-base-bj/deerflow-server into ji
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+193
-45
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user