feat: V1记忆系统支持、管理员记忆编辑及对话记忆注入开关
- 新增 V1 记忆系统(memory.json)支持:提取结果强制中文、历史字段修复为增量累积日志 - 用户设置页面:V1 模式下新增 agent 选择器,可查看各 agent 的私有记忆 - 管理员记忆页面:区分 V1/V2 渲染,V1 支持完整 CRUD(用户画像/历史摘要/事实记录) - 后端新增 V1 记忆 CRUD API(sections/facts),前端同步添加对应 hooks 和 API 调用 - 新建专家页面:标记 bootstrap 线程类型,跳过记忆保存,侧边栏过滤该类线程 - 输入框工具栏新增「记忆」开关:关闭时 V1/V2 均不注入,开启时按 config.yaml 版本正常注入 - 修复 agent 目录不存在时的 FileNotFoundError,回退至默认配置 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
d59e0514b3
commit
b3a39cbdf7
27 files changed
+2725
-92
No files matched your search
@@ -19,8 +19,12 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
useAdminAddMemoryEntry,
|
||||
useAdminAddV1Fact,
|
||||
useAdminDeleteMemoryEntry,
|
||||
useAdminDeleteV1Fact,
|
||||
useAdminReplaceMemoryEntry,
|
||||
useAdminUpdateV1Fact,
|
||||
useAdminUpdateV1Section,
|
||||
useUserMemory,
|
||||
} from "@/core/admin/hooks";
|
||||
import { useAllUsers } from "@/core/auth/hooks";
|
||||
@@ -31,6 +35,288 @@ function getEntries(data: Record<string, unknown>): string[] {
|
||||
return raw.filter((e): e is string => typeof e === "string" && e.trim().length > 0);
|
||||
}
|
||||
|
||||
// ── V1 只读视图 ────────────────────────────────────────────────────────────
|
||||
|
||||
const CTX_LABELS: Record<string, string> = {
|
||||
workContext: "工作背景",
|
||||
personalContext: "个人信息",
|
||||
topOfMind: "当前重点",
|
||||
};
|
||||
const HISTORY_LABELS: Record<string, string> = {
|
||||
recentMonths: "近期",
|
||||
earlierContext: "早期背景",
|
||||
longTermBackground: "长期背景",
|
||||
};
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
preference: "bg-blue-100 text-blue-700",
|
||||
knowledge: "bg-green-100 text-green-700",
|
||||
context: "bg-yellow-100 text-yellow-700",
|
||||
behavior: "bg-purple-100 text-purple-700",
|
||||
goal: "bg-orange-100 text-orange-700",
|
||||
correction: "bg-red-100 text-red-700",
|
||||
};
|
||||
|
||||
const V1_CATEGORIES = ["preference", "knowledge", "context", "behavior", "goal", "correction"];
|
||||
|
||||
function extractSummary(val: unknown): string {
|
||||
if (typeof val === "string") return val;
|
||||
if (val && typeof val === "object" && "summary" in val)
|
||||
return String((val as Record<string, unknown>).summary ?? "");
|
||||
return "";
|
||||
}
|
||||
|
||||
// 单个摘要字段的行内编辑
|
||||
function V1SectionField({
|
||||
label,
|
||||
value,
|
||||
onSave,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onSave: (v: string) => Promise<void>;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(value);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
async function handleSave() {
|
||||
if (draft.trim() === value.trim()) { setEditing(false); return; }
|
||||
setSaving(true);
|
||||
try { await onSave(draft.trim()); setEditing(false); }
|
||||
catch (err) { toast.error(err instanceof Error ? err.message : "保存失败"); }
|
||||
finally { setSaving(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">{label}</span>
|
||||
{!editing && (
|
||||
<Button size="icon" variant="ghost" className="size-6" onClick={() => { setDraft(value); setEditing(true); }}>
|
||||
<Pencil className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{editing ? (
|
||||
<div className="space-y-1.5">
|
||||
<Textarea value={draft} onChange={(e) => setDraft(e.target.value)} className="min-h-[72px] text-sm" autoFocus />
|
||||
<div className="flex gap-1.5">
|
||||
<Button size="sm" onClick={handleSave} disabled={saving}>
|
||||
{saving ? <Loader2 className="size-3 animate-spin" /> : <Check className="size-3" />}保存
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditing(false)}><X className="size-3" />取消</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm whitespace-pre-wrap text-foreground/80 min-h-[1.5rem]">{value || <span className="text-muted-foreground italic">(空)</span>}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 单条事实的行内编辑
|
||||
function V1FactItem({
|
||||
fact,
|
||||
agent,
|
||||
userId,
|
||||
}: {
|
||||
fact: Record<string, unknown>;
|
||||
agent: string | null;
|
||||
userId: string;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draftContent, setDraftContent] = useState(String(fact.content ?? ""));
|
||||
const [draftCategory, setDraftCategory] = useState(String(fact.category ?? "context"));
|
||||
const [draftConfidence, setDraftConfidence] = useState(
|
||||
typeof fact.confidence === "number" ? Math.round(fact.confidence * 100) : 80,
|
||||
);
|
||||
const updateFact = useAdminUpdateV1Fact(userId);
|
||||
const deleteFact = useAdminDeleteV1Fact(userId);
|
||||
|
||||
const factId = String(fact.id ?? "");
|
||||
const confidence = typeof fact.confidence === "number" ? fact.confidence : 0;
|
||||
const category = String(fact.category ?? "context");
|
||||
|
||||
async function handleSave() {
|
||||
try {
|
||||
await updateFact.mutateAsync({
|
||||
factId,
|
||||
updates: { content: draftContent.trim(), category: draftCategory, confidence: draftConfidence / 100 },
|
||||
agent,
|
||||
});
|
||||
setEditing(false);
|
||||
} catch (err) { toast.error(err instanceof Error ? err.message : "更新失败"); }
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
try { await deleteFact.mutateAsync({ factId, agent }); }
|
||||
catch (err) { toast.error(err instanceof Error ? err.message : "删除失败"); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded border p-2 space-y-1 group">
|
||||
{editing ? (
|
||||
<div className="space-y-2">
|
||||
<Textarea value={draftContent} onChange={(e) => setDraftContent(e.target.value)} className="min-h-[64px] text-sm" autoFocus />
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Select value={draftCategory} onValueChange={setDraftCategory}>
|
||||
<SelectTrigger className="h-7 w-36 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{V1_CATEGORIES.map((c) => <SelectItem key={c} value={c} className="text-xs">{c}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-muted-foreground">置信度</span>
|
||||
<input
|
||||
type="number" min={0} max={100} value={draftConfidence}
|
||||
onChange={(e) => setDraftConfidence(Number(e.target.value))}
|
||||
className="w-16 h-7 rounded border px-2 text-xs"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Button size="sm" onClick={handleSave} disabled={updateFact.isPending}>
|
||||
{updateFact.isPending ? <Loader2 className="size-3 animate-spin" /> : <Check className="size-3" />}保存
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditing(false)}><X className="size-3" />取消</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${CATEGORY_COLORS[category] ?? "bg-gray-100 text-gray-700"}`}>{category}</span>
|
||||
<span className="text-xs text-muted-foreground">{Math.round(confidence * 100)}%</span>
|
||||
</div>
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button size="icon" variant="ghost" className="size-6" onClick={() => { setDraftContent(String(fact.content ?? "")); setDraftCategory(category); setDraftConfidence(Math.round(confidence * 100)); setEditing(true); }}>
|
||||
<Pencil className="size-3" />
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="size-6 hover:text-destructive" onClick={handleDelete} disabled={deleteFact.isPending}>
|
||||
{deleteFact.isPending ? <Loader2 className="size-3 animate-spin" /> : <Trash2 className="size-3" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm whitespace-pre-wrap">{String(fact.content ?? "")}</p>
|
||||
{fact.sourceError ? <p className="text-xs text-red-600 bg-red-50 rounded px-2 py-1">避免:{String(fact.sourceError)}</p> : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 新增事实表单
|
||||
function V1AddFactForm({ agent, userId }: { agent: string | null; userId: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [content, setContent] = useState("");
|
||||
const [category, setCategory] = useState("context");
|
||||
const [confidence, setConfidence] = useState(80);
|
||||
const addFact = useAdminAddV1Fact(userId);
|
||||
|
||||
async function handleAdd() {
|
||||
if (!content.trim()) return;
|
||||
try {
|
||||
await addFact.mutateAsync({ content: content.trim(), category, confidence: confidence / 100, agent });
|
||||
setContent(""); setCategory("context"); setConfidence(80); setOpen(false);
|
||||
} catch (err) { toast.error(err instanceof Error ? err.message : "新增失败"); }
|
||||
}
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<Button variant="outline" size="sm" className="mt-1 w-full" onClick={() => setOpen(true)}>
|
||||
<Plus className="size-3" />新增事实
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="rounded-md border p-3 space-y-2">
|
||||
<Textarea placeholder="事实内容…" value={content} onChange={(e) => setContent(e.target.value)} className="min-h-[64px] text-sm" autoFocus />
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Select value={category} onValueChange={setCategory}>
|
||||
<SelectTrigger className="h-7 w-36 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{V1_CATEGORIES.map((c) => <SelectItem key={c} value={c} className="text-xs">{c}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-muted-foreground">置信度</span>
|
||||
<input type="number" min={0} max={100} value={confidence} onChange={(e) => setConfidence(Number(e.target.value))} className="w-16 h-7 rounded border px-2 text-xs" />
|
||||
<span className="text-xs text-muted-foreground">%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Button size="sm" onClick={handleAdd} disabled={addFact.isPending || !content.trim()}>
|
||||
{addFact.isPending ? <Loader2 className="size-3 animate-spin" /> : <Check className="size-3" />}确认
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setOpen(false)}><X className="size-3" />取消</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function V1BucketView({
|
||||
data,
|
||||
agent,
|
||||
userId,
|
||||
}: {
|
||||
data: Record<string, unknown>;
|
||||
agent: string | null;
|
||||
userId: string;
|
||||
}) {
|
||||
const updateSection = useAdminUpdateV1Section(userId);
|
||||
|
||||
const ctxRaw = (data.user ?? data.userContext ?? {}) as Record<string, unknown>;
|
||||
const historyRaw = (data.history ?? {}) as Record<string, unknown>;
|
||||
const factsRaw = Array.isArray(data.facts) ? (data.facts as Record<string, unknown>[]) : [];
|
||||
|
||||
const allCtxFields = ["workContext", "personalContext", "topOfMind"];
|
||||
const allHistoryFields = ["recentMonths", "earlierContext", "longTermBackground"];
|
||||
|
||||
if (!Object.keys(ctxRaw).length && !Object.keys(historyRaw).length && !factsRaw.length) {
|
||||
return <p className="text-muted-foreground py-6 text-center text-sm">暂无 V1 记忆</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 用户画像字段 */}
|
||||
<div className="rounded-md border p-3 space-y-3">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground">用户画像</h4>
|
||||
{allCtxFields.map((field) => (
|
||||
<V1SectionField
|
||||
key={field}
|
||||
label={CTX_LABELS[field] ?? field}
|
||||
value={extractSummary(ctxRaw[field])}
|
||||
onSave={(summary) =>
|
||||
updateSection.mutateAsync({ sectionType: "user", field, summary, agent }).then(() => undefined)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 历史摘要字段 */}
|
||||
<div className="rounded-md border p-3 space-y-3">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground">历史摘要</h4>
|
||||
{allHistoryFields.map((field) => (
|
||||
<V1SectionField
|
||||
key={field}
|
||||
label={HISTORY_LABELS[field] ?? field}
|
||||
value={extractSummary(historyRaw[field])}
|
||||
onSave={(summary) =>
|
||||
updateSection.mutateAsync({ sectionType: "history", field, summary, agent }).then(() => undefined)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 事实记录 */}
|
||||
<div className="rounded-md border p-3 space-y-2">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground">事实记录({factsRaw.length} 条)</h4>
|
||||
{factsRaw.map((fact, i) => (
|
||||
<V1FactItem key={String(fact.id ?? i)} fact={fact} agent={agent} userId={userId} />
|
||||
))}
|
||||
<V1AddFactForm agent={agent} userId={userId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Memory entry list with inline editing ──────────────────────────────────
|
||||
|
||||
function MemoryEntryList({
|
||||
@@ -217,11 +503,76 @@ function UserMemoryPanel({ userId }: { userId: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
const isV1 = memory?.version === "v1";
|
||||
const globalBucket = memory?.buckets.find((b) => b.name === "global");
|
||||
const agentBuckets = (memory?.buckets ?? []).filter((b) => b.name !== "global");
|
||||
|
||||
const userEntries = globalBucket ? getEntries(globalBucket.data) : [];
|
||||
// ── V1 只读视图 ──────────────────────────────────────────────────────────
|
||||
if (isV1) {
|
||||
const currentAgent = selectedAgent ?? agentBuckets[0]?.name ?? null;
|
||||
const currentBucket = currentAgent
|
||||
? agentBuckets.find((b) => b.name === currentAgent)
|
||||
: globalBucket;
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="global" className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="border-b px-4 pt-4">
|
||||
<div className="mb-2 rounded-md bg-amber-50 border border-amber-200 px-3 py-1.5 text-xs text-amber-700">
|
||||
V1 模式:记忆由系统自动提取,不支持手动管理
|
||||
</div>
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="global">用户记忆</TabsTrigger>
|
||||
{agentBuckets.length > 0 && (
|
||||
<TabsTrigger value="agent">
|
||||
Agent 记忆
|
||||
<Badge variant="secondary" className="ml-1.5 text-xs">
|
||||
{agentBuckets.length}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="global" className="flex-1 overflow-hidden p-0">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-4">
|
||||
{globalBucket ? <V1BucketView data={globalBucket.data} agent={null} userId={userId} /> : (
|
||||
<p className="text-muted-foreground py-6 text-center text-sm">暂无数据</p>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
{agentBuckets.length > 0 && (
|
||||
<TabsContent value="agent" className="flex-1 overflow-hidden p-0">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">选择 Agent:</span>
|
||||
<Select value={currentAgent ?? ""} onValueChange={(v) => setSelectedAgent(v)}>
|
||||
<SelectTrigger className="h-7 w-48 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{agentBuckets.map((b) => (
|
||||
<SelectItem key={b.name} value={b.name} className="text-xs">
|
||||
{b.display_name || b.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{currentBucket && <V1BucketView data={currentBucket.data} agent={currentAgent} userId={userId} />}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
// ── V2 可编辑视图 ────────────────────────────────────────────────────────
|
||||
const userEntries = globalBucket ? getEntries(globalBucket.data) : [];
|
||||
const currentAgent = selectedAgent ?? agentBuckets[0]?.name ?? null;
|
||||
const agentBucket = agentBuckets.find((b) => b.name === currentAgent);
|
||||
const agentEntries = agentBucket ? getEntries(agentBucket.data) : [];
|
||||
@@ -284,16 +635,10 @@ function UserMemoryPanel({ userId }: { userId: string }) {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
— MEMORY.md
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">— MEMORY.md</span>
|
||||
</div>
|
||||
{currentAgent && (
|
||||
<MemoryEntryList
|
||||
entries={agentEntries}
|
||||
userId={userId}
|
||||
agent={currentAgent}
|
||||
/>
|
||||
<MemoryEntryList entries={agentEntries} userId={userId} agent={currentAgent} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { ChatStatus } from "ai";
|
||||
import {
|
||||
BrainCircuitIcon,
|
||||
CheckIcon,
|
||||
FileTextIcon,
|
||||
GraduationCapIcon,
|
||||
@@ -134,6 +135,7 @@ export function InputBox({
|
||||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
writing_mode?: boolean;
|
||||
memory_injection_enabled?: boolean;
|
||||
};
|
||||
extraHeader?: React.ReactNode;
|
||||
isNewThread?: boolean;
|
||||
@@ -147,6 +149,7 @@ export function InputBox({
|
||||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
writing_mode?: boolean;
|
||||
memory_injection_enabled?: boolean;
|
||||
},
|
||||
) => void;
|
||||
onFollowupsVisibilityChange?: (visible: boolean) => void;
|
||||
@@ -285,6 +288,14 @@ export function InputBox({
|
||||
});
|
||||
}, [context, onContextChange]);
|
||||
|
||||
const memoryInjectionEnabled = context.memory_injection_enabled ?? true;
|
||||
const handleMemoryInjectionToggle = useCallback(() => {
|
||||
onContextChange?.({
|
||||
...context,
|
||||
memory_injection_enabled: !memoryInjectionEnabled,
|
||||
});
|
||||
}, [context, memoryInjectionEnabled, onContextChange]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (message: PromptInputMessage) => {
|
||||
if (status === "streaming") {
|
||||
@@ -590,6 +601,26 @@ export function InputBox({
|
||||
<span className="text-xs font-normal">写作</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={
|
||||
memoryInjectionEnabled
|
||||
? "记忆注入已开启:对话前会注入历史记忆"
|
||||
: "记忆注入已关闭:本次对话不注入记忆"
|
||||
}
|
||||
>
|
||||
<PromptInputButton
|
||||
className={cn(
|
||||
"gap-1.5! rounded-full px-3! shadow-none",
|
||||
memoryInjectionEnabled
|
||||
? "border border-blue-300 bg-blue-50 text-blue-700 hover:bg-blue-100 dark:border-blue-700 dark:bg-blue-950/40 dark:text-blue-200"
|
||||
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||
)}
|
||||
onClick={handleMemoryInjectionToggle}
|
||||
>
|
||||
<BrainCircuitIcon className="size-3" />
|
||||
<span className="text-xs font-normal">记忆</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>
|
||||
<PromptInputActionMenu>
|
||||
<ModeHoverGuide
|
||||
mode={
|
||||
|
||||
@@ -150,7 +150,9 @@ export function RecentChatList() {
|
||||
);
|
||||
|
||||
const visibleThreads = threads.filter(
|
||||
(thread) => thread.metadata?.thread_type !== "scheduler",
|
||||
(thread) =>
|
||||
thread.metadata?.thread_type !== "scheduler" &&
|
||||
thread.metadata?.thread_type !== "bootstrap",
|
||||
);
|
||||
|
||||
if (visibleThreads.length === 0) {
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
useReplaceMemoryEntry,
|
||||
} from "@/core/memory/hooks";
|
||||
import type {
|
||||
MemoryFact,
|
||||
MemorySearchMode,
|
||||
MemoryTarget,
|
||||
MemoryV2Config,
|
||||
@@ -54,6 +55,10 @@ export function MemorySettingsPage() {
|
||||
const { t } = useI18n();
|
||||
const m = t.settings.memory;
|
||||
|
||||
// 先读配置拿到 version,再决定渲染哪套 UI
|
||||
const { configResponse } = useMemoryConfig();
|
||||
const memoryVersion = configResponse?.version ?? "v2";
|
||||
|
||||
// 工作笔记按专家隔离 —— selectedAgent 控制查看/编辑哪个专家的 MEMORY.md。
|
||||
// 用户画像 (USER.md) 是 per-user 的,不受 selectedAgent 影响。
|
||||
// 专家列表来自 agents 注册表 (非内建的才是用户的专家);default 是每个
|
||||
@@ -211,7 +216,33 @@ export function MemorySettingsPage() {
|
||||
</div>
|
||||
) : !memory ? (
|
||||
<div className="text-muted-foreground text-sm">{m.entryEmpty}</div>
|
||||
) : memoryVersion === "v1" ? (
|
||||
// V1 模式:只读展示 facts 卡片列表,支持按 agent 切换
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{m.workNotesAgentLabel}:
|
||||
</span>
|
||||
<Select value={selectedAgent} onValueChange={setSelectedAgent}>
|
||||
<SelectTrigger className="h-8 w-44" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">
|
||||
{m.workNotesDefaultAgent}
|
||||
</SelectItem>
|
||||
{experts.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<MemoryV1View memory={memory} />
|
||||
</div>
|
||||
) : (
|
||||
// V2 模式:USER.md / MEMORY.md 条目编辑
|
||||
<div className="space-y-4">
|
||||
{renderEntrySection(
|
||||
"user",
|
||||
@@ -251,13 +282,17 @@ export function MemorySettingsPage() {
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<div className="mt-8">
|
||||
<MemoryConfigPanel />
|
||||
</div>
|
||||
{memoryVersion !== "v1" && (
|
||||
<>
|
||||
<div className="mt-8">
|
||||
<MemoryConfigPanel />
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<MemorySearchPanel />
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<MemorySearchPanel />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 新增 / 编辑条目对话框 */}
|
||||
<Dialog
|
||||
@@ -415,6 +450,133 @@ function MemoryConfigPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
const CTX_LABELS: Record<string, string> = {
|
||||
workContext: "工作背景",
|
||||
personalContext: "个人信息",
|
||||
topOfMind: "当前重点",
|
||||
};
|
||||
|
||||
const HISTORY_LABELS: Record<string, string> = {
|
||||
recentMonths: "近期",
|
||||
earlierContext: "早期背景",
|
||||
longTermBackground: "长期背景",
|
||||
};
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
preference: "bg-blue-100 text-blue-700",
|
||||
knowledge: "bg-green-100 text-green-700",
|
||||
context: "bg-yellow-100 text-yellow-700",
|
||||
behavior: "bg-purple-100 text-purple-700",
|
||||
goal: "bg-orange-100 text-orange-700",
|
||||
correction: "bg-red-100 text-red-700",
|
||||
};
|
||||
|
||||
/** V1 记忆只读视图:展示 memory.json 的全部字段。 */
|
||||
function MemoryV1View({
|
||||
memory,
|
||||
}: {
|
||||
memory: {
|
||||
facts: MemoryFact[];
|
||||
user_context: Record<string, string>;
|
||||
history: Record<string, string>;
|
||||
memory_usage: string;
|
||||
};
|
||||
}) {
|
||||
const ctxEntries = Object.entries(memory.user_context ?? {}).filter(([, v]) => v);
|
||||
const historyEntries = Object.entries(memory.history ?? {}).filter(([, v]) => v);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 说明横幅 */}
|
||||
<div className="rounded-md border border-dashed px-4 py-3 text-sm text-muted-foreground">
|
||||
V1 模式:记忆由系统在对话结束后自动提取,无需手动管理。
|
||||
</div>
|
||||
|
||||
{/* 用户画像 (userContext) */}
|
||||
<div className="rounded-lg border p-4 space-y-2">
|
||||
<h3 className="text-base font-medium">用户画像</h3>
|
||||
{ctxEntries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">暂无画像信息,对话后系统会自动归纳。</p>
|
||||
) : (
|
||||
<dl className="space-y-2">
|
||||
{ctxEntries.map(([key, value]) => (
|
||||
<div key={key} className="text-sm">
|
||||
<dt className="font-medium text-muted-foreground mb-0.5">
|
||||
{CTX_LABELS[key] ?? key}
|
||||
</dt>
|
||||
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 历史摘要 (history) */}
|
||||
<div className="rounded-lg border p-4 space-y-2">
|
||||
<h3 className="text-base font-medium">历史摘要</h3>
|
||||
{historyEntries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">暂无历史摘要。</p>
|
||||
) : (
|
||||
<dl className="space-y-2">
|
||||
{historyEntries.map(([key, value]) => (
|
||||
<div key={key} className="text-sm">
|
||||
<dt className="font-medium text-muted-foreground mb-0.5">
|
||||
{HISTORY_LABELS[key] ?? key}
|
||||
</dt>
|
||||
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 事实记录 (facts) */}
|
||||
<div className="rounded-lg border p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-medium">事实记录</h3>
|
||||
<span className="text-xs text-muted-foreground">{memory.memory_usage}</span>
|
||||
</div>
|
||||
{memory.facts.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-3 text-sm text-muted-foreground">
|
||||
暂无事实记录,对话后系统会自动提取。
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{memory.facts.map((fact) => (
|
||||
<div key={fact.id} className="rounded-md border p-3 space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 text-xs font-medium ${
|
||||
CATEGORY_COLORS[fact.category] ?? "bg-gray-100 text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{fact.category}
|
||||
</span>
|
||||
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary"
|
||||
style={{ width: `${Math.round(fact.confidence * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{Math.round(fact.confidence * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm whitespace-pre-wrap [overflow-wrap:anywhere]">{fact.content}</p>
|
||||
{fact.sourceError && (
|
||||
<p className="text-xs text-red-600 bg-red-50 rounded px-2 py-1">
|
||||
避免:{fact.sourceError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 一行开关控件。 */
|
||||
function ToggleRow({
|
||||
label,
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface AdminMemoryBucket {
|
||||
|
||||
export interface AdminMemoryResponse {
|
||||
user_id: string;
|
||||
version: "v1" | "v2";
|
||||
buckets: AdminMemoryBucket[];
|
||||
}
|
||||
|
||||
@@ -118,3 +119,72 @@ export async function deleteAdminMemoryEntry(
|
||||
"Failed to delete memory entry",
|
||||
);
|
||||
}
|
||||
|
||||
// ── V1 Memory CRUD ──────────────────────────────────────────────────────────
|
||||
|
||||
export async function updateAdminV1Section(
|
||||
userId: string,
|
||||
sectionType: "user" | "history",
|
||||
field: string,
|
||||
summary: string,
|
||||
agent: string | null,
|
||||
): Promise<AdminMemoryBucket> {
|
||||
return parse(
|
||||
await apiFetch(`${base()}/${encodeURIComponent(userId)}/memory/v1/sections`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ section_type: sectionType, field, summary, agent }),
|
||||
}),
|
||||
"Failed to update V1 section",
|
||||
);
|
||||
}
|
||||
|
||||
export async function addAdminV1Fact(
|
||||
userId: string,
|
||||
content: string,
|
||||
category: string,
|
||||
confidence: number,
|
||||
agent: string | null,
|
||||
): Promise<AdminMemoryBucket> {
|
||||
return parse(
|
||||
await apiFetch(`${base()}/${encodeURIComponent(userId)}/memory/v1/facts`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content, category, confidence, agent }),
|
||||
}),
|
||||
"Failed to add V1 fact",
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateAdminV1Fact(
|
||||
userId: string,
|
||||
factId: string,
|
||||
updates: { content?: string; category?: string; confidence?: number },
|
||||
agent: string | null,
|
||||
): Promise<AdminMemoryBucket> {
|
||||
return parse(
|
||||
await apiFetch(`${base()}/${encodeURIComponent(userId)}/memory/v1/facts/${encodeURIComponent(factId)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...updates, agent }),
|
||||
}),
|
||||
"Failed to update V1 fact",
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteAdminV1Fact(
|
||||
userId: string,
|
||||
factId: string,
|
||||
agent: string | null,
|
||||
): Promise<AdminMemoryBucket> {
|
||||
const params = new URLSearchParams();
|
||||
if (agent) params.set("agent", agent);
|
||||
const qs = params.toString() ? `?${params}` : "";
|
||||
return parse(
|
||||
await apiFetch(
|
||||
`${base()}/${encodeURIComponent(userId)}/memory/v1/facts/${encodeURIComponent(factId)}${qs}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
"Failed to delete V1 fact",
|
||||
);
|
||||
}
|
||||
@@ -2,11 +2,15 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
addAdminMemoryEntry,
|
||||
addAdminV1Fact,
|
||||
deleteAdminMemoryEntry,
|
||||
deleteAdminV1Fact,
|
||||
getUserMemory,
|
||||
listUserThreadMessages,
|
||||
listUserThreads,
|
||||
replaceAdminMemoryEntry,
|
||||
updateAdminV1Fact,
|
||||
updateAdminV1Section,
|
||||
} from "./api";
|
||||
|
||||
/** Threads owned by a given user. Disabled until a user is picked. */
|
||||
@@ -70,3 +74,66 @@ export function useAdminDeleteMemoryEntry(userId: string) {
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
// ── V1 CRUD hooks ──────────────────────────────────────────────────────────
|
||||
|
||||
export function useAdminUpdateV1Section(userId: string) {
|
||||
const invalidate = useMemoryInvalidate(userId);
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
sectionType,
|
||||
field,
|
||||
summary,
|
||||
agent,
|
||||
}: {
|
||||
sectionType: "user" | "history";
|
||||
field: string;
|
||||
summary: string;
|
||||
agent: string | null;
|
||||
}) => updateAdminV1Section(userId, sectionType, field, summary, agent),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminAddV1Fact(userId: string) {
|
||||
const invalidate = useMemoryInvalidate(userId);
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
content,
|
||||
category,
|
||||
confidence,
|
||||
agent,
|
||||
}: {
|
||||
content: string;
|
||||
category: string;
|
||||
confidence: number;
|
||||
agent: string | null;
|
||||
}) => addAdminV1Fact(userId, content, category, confidence, agent),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminUpdateV1Fact(userId: string) {
|
||||
const invalidate = useMemoryInvalidate(userId);
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
factId,
|
||||
updates,
|
||||
agent,
|
||||
}: {
|
||||
factId: string;
|
||||
updates: { content?: string; category?: string; confidence?: number };
|
||||
agent: string | null;
|
||||
}) => updateAdminV1Fact(userId, factId, updates, agent),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminDeleteV1Fact(userId: string) {
|
||||
const invalidate = useMemoryInvalidate(userId);
|
||||
return useMutation({
|
||||
mutationFn: ({ factId, agent }: { factId: string; agent: string | null }) =>
|
||||
deleteAdminV1Fact(userId, factId, agent),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,23 @@
|
||||
// Memory V2 数据模型。对应后端 /api/memory/v2 系列端点。
|
||||
// Memory 数据模型。对应后端 /api/memory/v2 系列端点。支持 V1 / V2 两个版本。
|
||||
|
||||
/** builtin 本地记忆数据视图 (USER.md / MEMORY.md)。 */
|
||||
/** V1 记忆中的单条事实。 */
|
||||
export interface MemoryFact {
|
||||
id: string;
|
||||
content: string;
|
||||
category: string;
|
||||
confidence: number;
|
||||
createdAt: string;
|
||||
/** 纠错类事实:被纠正的原错误描述(category=correction 时可能有值)。 */
|
||||
sourceError?: string;
|
||||
}
|
||||
|
||||
/** 记忆数据视图 —— V1/V2 共用结构。
|
||||
*
|
||||
* - V2 模式: user_entries / memory_entries / user_usage / memory_usage 有值
|
||||
* - V1 模式: facts / user_context / memory_usage 有值
|
||||
*/
|
||||
export interface MemoryV2Data {
|
||||
// V2 字段
|
||||
/** USER.md 条目 —— 用户画像,跨 agent 共享。 */
|
||||
user_entries: string[];
|
||||
/** MEMORY.md 条目 —— 当前 agent 的工作笔记。 */
|
||||
@@ -10,17 +26,27 @@ export interface MemoryV2Data {
|
||||
user_usage: string;
|
||||
/** MEMORY.md 字符用量。 */
|
||||
memory_usage: string;
|
||||
|
||||
// V1 字段
|
||||
/** V1 facts 列表,按置信度降序。 */
|
||||
facts: MemoryFact[];
|
||||
/** V1 userContext 摘要。 */
|
||||
user_context: Record<string, string>;
|
||||
/** V1 history 历史摘要。 */
|
||||
history: Record<string, string>;
|
||||
}
|
||||
|
||||
/** 记忆条目所属类别。 */
|
||||
export type MemoryTarget = "user" | "memory";
|
||||
|
||||
/** 生效记忆配置 + 用户可改字段白名单。 */
|
||||
/** 生效记忆配置 + 用户可改字段白名单 + 版本号。 */
|
||||
export interface MemoryV2ConfigResponse {
|
||||
/** 按 per-user 覆盖合并后的生效配置 (嵌套 dict)。 */
|
||||
config: MemoryV2Config;
|
||||
/** 用户可在运行时覆盖的字段 (点号路径)。 */
|
||||
writable_fields: string[];
|
||||
/** 当前记忆版本:v1 或 v2。 */
|
||||
version: "v1" | "v2";
|
||||
}
|
||||
|
||||
/** 生效记忆配置的关注字段 (后端 dict 的子集)。 */
|
||||
|
||||
@@ -506,6 +506,7 @@ export function useThreadStream({
|
||||
: context.mode === "thinking"
|
||||
? "low"
|
||||
: undefined),
|
||||
memory_injection_enabled: context.memory_injection_enabled ?? true,
|
||||
thread_id: threadId,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -14,11 +14,12 @@ export interface AgentThreadContext extends Record<string, unknown> {
|
||||
model_name: string | undefined;
|
||||
thinking_enabled: boolean;
|
||||
is_plan_mode: boolean;
|
||||
subagent_enabled: boolean;
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
writing_mode?: boolean;
|
||||
writing_artifact_path?: string;
|
||||
agent_id?: string;
|
||||
subagent_enabled: boolean;
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
writing_mode?: boolean;
|
||||
writing_artifact_path?: string;
|
||||
memory_injection_enabled?: boolean;
|
||||
agent_id?: string;
|
||||
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */
|
||||
agent_name?: string;
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ export default function NewAgentPage() {
|
||||
if (threadPrepared) {
|
||||
return;
|
||||
}
|
||||
await getAPIClient().threads.create({ threadId });
|
||||
await getAPIClient().threads.create({ threadId, metadata: { thread_type: "bootstrap" } });
|
||||
setThreadPrepared(true);
|
||||
}, [threadId, threadPrepared]);
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ from app.gateway.deps import (
|
||||
from deerflow.agents.memory import BuiltinFileProvider
|
||||
from deerflow.config.paths import get_paths
|
||||
from deerflow.runtime import serialize_channel_values
|
||||
from app.gateway.routers.memory import _build_v1_provider, _is_v1
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/admin/users", tags=["admin-users"])
|
||||
@@ -145,36 +146,29 @@ class AdminMemoryEntryReplaceRequest(BaseModel):
|
||||
|
||||
class AdminMemoryResponse(BaseModel):
|
||||
user_id: str
|
||||
version: str = Field(default="v2", description="记忆版本:v1 或 v2")
|
||||
buckets: list[AdminMemoryBucket] = Field(default_factory=list)
|
||||
|
||||
|
||||
def _read_memory_bucket(user_id: str, agent_id: str | None) -> dict:
|
||||
"""Read one Memory V2 file into a JSON-able ``{"entries": [...]}`` dict.
|
||||
|
||||
``agent_id is None`` -> the user's ``USER.md`` (profile, cross-agent);
|
||||
otherwise that agent's ``MEMORY.md`` (per-agent working notes).
|
||||
"""
|
||||
"""Read one Memory V2 file into a JSON-able ``{"entries": [...]}`` dict."""
|
||||
provider = BuiltinFileProvider()
|
||||
provider.initialize(user_id=user_id, agent_id=agent_id)
|
||||
target = "user" if agent_id is None else "memory"
|
||||
return {"entries": provider.read(target)}
|
||||
|
||||
|
||||
def _read_v1_memory_bucket(user_id: str, agent_id: str | None) -> dict:
|
||||
"""Read one V1 memory.json into its raw dict (facts / user / history)."""
|
||||
provider = _build_v1_provider(user_id, agent_id)
|
||||
return provider.read_v1_data()
|
||||
|
||||
|
||||
@router.get("/{user_id}/memory", response_model=AdminMemoryResponse)
|
||||
async def get_user_memory(user_id: str, request: Request) -> AdminMemoryResponse:
|
||||
"""Return every memory bucket for one user.
|
||||
|
||||
Memory V2 layout: the ``global`` bucket is ``USER.md`` (user profile);
|
||||
each per-agent bucket is that agent's ``MEMORY.md`` working notes.
|
||||
Agent list is sourced from the database (all agents owned by this user)
|
||||
so agents without any existing MEMORY.md still appear.
|
||||
"""
|
||||
"""Return every memory bucket for one user (V1 or V2 depending on config)."""
|
||||
await _require_admin(request)
|
||||
|
||||
buckets: list[AdminMemoryBucket] = [
|
||||
AdminMemoryBucket(name="global", display_name="用户画像", data=_read_memory_bucket(user_id, None))
|
||||
]
|
||||
|
||||
# Source agents from the database: only agents owned by this specific user.
|
||||
agent_store = get_agent_store(request)
|
||||
all_agents = await agent_store.list_visible(user_id)
|
||||
@@ -183,16 +177,39 @@ async def get_user_memory(user_id: str, request: Request) -> AdminMemoryResponse
|
||||
key=lambda a: (a.get("name") or a["id"]).lower(),
|
||||
)
|
||||
|
||||
if _is_v1(user_id):
|
||||
# V1 模式:每个桶读 memory.json,内容为 {facts, user, history}
|
||||
buckets: list[AdminMemoryBucket] = []
|
||||
try:
|
||||
buckets.append(AdminMemoryBucket(name="global", display_name="用户记忆", data=_read_v1_memory_bucket(user_id, None)))
|
||||
except Exception:
|
||||
buckets.append(AdminMemoryBucket(name="global", display_name="用户记忆", data={}))
|
||||
|
||||
for agent in owned_agents:
|
||||
agent_id: str = agent["id"]
|
||||
display_name: str = agent.get("name") or agent_id
|
||||
try:
|
||||
data = _read_v1_memory_bucket(user_id, agent_id)
|
||||
except Exception:
|
||||
data = {}
|
||||
buckets.append(AdminMemoryBucket(name=agent_id, display_name=display_name, data=data))
|
||||
|
||||
return AdminMemoryResponse(user_id=user_id, version="v1", buckets=buckets)
|
||||
|
||||
# V2 模式:读 USER.md / MEMORY.md,内容为 {entries: [...]}
|
||||
buckets = [
|
||||
AdminMemoryBucket(name="global", display_name="用户画像", data=_read_memory_bucket(user_id, None))
|
||||
]
|
||||
for agent in owned_agents:
|
||||
agent_id: str = agent["id"]
|
||||
display_name: str = agent.get("name") or agent_id
|
||||
agent_id = agent["id"]
|
||||
display_name = agent.get("name") or agent_id
|
||||
try:
|
||||
data = _read_memory_bucket(user_id, agent_id)
|
||||
except Exception:
|
||||
data = {"entries": []}
|
||||
buckets.append(AdminMemoryBucket(name=agent_id, display_name=display_name, data=data))
|
||||
|
||||
return AdminMemoryResponse(user_id=user_id, buckets=buckets)
|
||||
return AdminMemoryResponse(user_id=user_id, version="v2", buckets=buckets)
|
||||
|
||||
|
||||
@router.post("/{user_id}/memory/entries", response_model=AdminMemoryBucket)
|
||||
@@ -266,3 +283,133 @@ async def admin_delete_memory_entry(
|
||||
raise HTTPException(status_code=500, detail="Failed to delete memory entry") from exc
|
||||
bucket_name = "global" if agent is None else agent
|
||||
return AdminMemoryBucket(name=bucket_name, data=_read_memory_bucket(user_id, agent))
|
||||
|
||||
|
||||
# ── V1 Memory CRUD (admin) ─────────────────────────────────────────────────
|
||||
|
||||
_V1_USER_FIELDS = {"workContext", "personalContext", "topOfMind"}
|
||||
_V1_HISTORY_FIELDS = {"recentMonths", "earlierContext", "longTermBackground"}
|
||||
|
||||
|
||||
class AdminV1SectionUpdateRequest(BaseModel):
|
||||
section_type: str = Field(..., description="'user' 或 'history'")
|
||||
field: str = Field(..., description="字段名,如 workContext / recentMonths")
|
||||
summary: str = Field(..., description="新的摘要文本")
|
||||
agent: str | None = Field(None, description="Agent id;None 表示用户级全局记忆")
|
||||
|
||||
|
||||
class AdminV1FactAddRequest(BaseModel):
|
||||
content: str = Field(..., min_length=1)
|
||||
category: str = Field(default="context")
|
||||
confidence: float = Field(default=0.8, ge=0.0, le=1.0)
|
||||
agent: str | None = Field(None)
|
||||
|
||||
|
||||
class AdminV1FactUpdateRequest(BaseModel):
|
||||
content: str | None = Field(None)
|
||||
category: str | None = Field(None)
|
||||
confidence: float | None = Field(None, ge=0.0, le=1.0)
|
||||
agent: str | None = Field(None)
|
||||
|
||||
|
||||
@router.put("/{user_id}/memory/v1/sections", response_model=AdminMemoryBucket)
|
||||
async def admin_update_v1_section(
|
||||
user_id: str,
|
||||
body: AdminV1SectionUpdateRequest,
|
||||
request: Request,
|
||||
) -> AdminMemoryBucket:
|
||||
"""更新 V1 记忆的某个摘要字段(用户画像 / 历史)。"""
|
||||
await _require_admin(request)
|
||||
if body.section_type == "user" and body.field not in _V1_USER_FIELDS:
|
||||
raise HTTPException(status_code=400, detail=f"无效 user 字段:{body.field}")
|
||||
if body.section_type == "history" and body.field not in _V1_HISTORY_FIELDS:
|
||||
raise HTTPException(status_code=400, detail=f"无效 history 字段:{body.field}")
|
||||
if body.section_type not in ("user", "history"):
|
||||
raise HTTPException(status_code=400, detail="section_type 须为 'user' 或 'history'")
|
||||
|
||||
from deerflow.agents.memory.v1.storage import get_memory_storage, utc_now_iso_z
|
||||
storage = get_memory_storage()
|
||||
data = storage.load(body.agent, user_id=user_id)
|
||||
data.setdefault(body.section_type, {})[body.field] = {
|
||||
"summary": body.summary,
|
||||
"updatedAt": utc_now_iso_z(),
|
||||
}
|
||||
if not storage.save(data, body.agent, user_id=user_id):
|
||||
raise HTTPException(status_code=500, detail="写入失败")
|
||||
|
||||
bucket_name = "global" if body.agent is None else body.agent
|
||||
return AdminMemoryBucket(name=bucket_name, data=_read_v1_memory_bucket(user_id, body.agent))
|
||||
|
||||
|
||||
@router.post("/{user_id}/memory/v1/facts", response_model=AdminMemoryBucket)
|
||||
async def admin_add_v1_fact(
|
||||
user_id: str,
|
||||
body: AdminV1FactAddRequest,
|
||||
request: Request,
|
||||
) -> AdminMemoryBucket:
|
||||
"""新增一条 V1 事实记录。"""
|
||||
await _require_admin(request)
|
||||
try:
|
||||
from deerflow.agents.memory.v1.updater import create_memory_fact
|
||||
create_memory_fact(
|
||||
body.content,
|
||||
category=body.category,
|
||||
confidence=body.confidence,
|
||||
agent_name=body.agent,
|
||||
user_id=user_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail="新增失败") from exc
|
||||
bucket_name = "global" if body.agent is None else body.agent
|
||||
return AdminMemoryBucket(name=bucket_name, data=_read_v1_memory_bucket(user_id, body.agent))
|
||||
|
||||
|
||||
@router.put("/{user_id}/memory/v1/facts/{fact_id}", response_model=AdminMemoryBucket)
|
||||
async def admin_update_v1_fact(
|
||||
user_id: str,
|
||||
fact_id: str,
|
||||
body: AdminV1FactUpdateRequest,
|
||||
request: Request,
|
||||
) -> AdminMemoryBucket:
|
||||
"""更新一条 V1 事实记录(内容 / 类别 / 置信度)。"""
|
||||
await _require_admin(request)
|
||||
try:
|
||||
from deerflow.agents.memory.v1.updater import update_memory_fact
|
||||
update_memory_fact(
|
||||
fact_id,
|
||||
content=body.content,
|
||||
category=body.category,
|
||||
confidence=body.confidence,
|
||||
agent_name=body.agent,
|
||||
user_id=user_id,
|
||||
)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail=f"事实 '{fact_id}' 不存在")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail="更新失败") from exc
|
||||
bucket_name = "global" if body.agent is None else body.agent
|
||||
return AdminMemoryBucket(name=bucket_name, data=_read_v1_memory_bucket(user_id, body.agent))
|
||||
|
||||
|
||||
@router.delete("/{user_id}/memory/v1/facts/{fact_id}", response_model=AdminMemoryBucket)
|
||||
async def admin_delete_v1_fact(
|
||||
user_id: str,
|
||||
fact_id: str,
|
||||
request: Request,
|
||||
agent: str | None = Query(None, description="Agent id;None 表示用户级全局记忆"),
|
||||
) -> AdminMemoryBucket:
|
||||
"""删除一条 V1 事实记录。"""
|
||||
await _require_admin(request)
|
||||
try:
|
||||
from deerflow.agents.memory.v1.updater import delete_memory_fact
|
||||
delete_memory_fact(fact_id, agent_name=agent, user_id=user_id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail=f"事实 '{fact_id}' 不存在")
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail="删除失败") from exc
|
||||
bucket_name = "global" if agent is None else agent
|
||||
return AdminMemoryBucket(name=bucket_name, data=_read_v1_memory_bucket(user_id, agent))
|
||||
@@ -73,20 +73,38 @@ _AgentQuery = Query(
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class MemoryV2DataResponse(BaseModel):
|
||||
"""Memory V2 builtin provider 的数据视图。"""
|
||||
class MemoryFact(BaseModel):
|
||||
"""V1 记忆中的单条事实。"""
|
||||
|
||||
user_entries: list[str] = Field(default_factory=list, description="USER.md 条目 (用户画像)")
|
||||
memory_entries: list[str] = Field(default_factory=list, description="MEMORY.md 条目 (agent 工作笔记)")
|
||||
user_usage: str = Field(default="", description="USER.md 字符用量,如 '12% — 165/1375 字符'")
|
||||
memory_usage: str = Field(default="", description="MEMORY.md 字符用量")
|
||||
id: str = Field(default="", description="事实 UUID")
|
||||
content: str = Field(default="", description="事实文本")
|
||||
category: str = Field(default="knowledge", description="事实类别")
|
||||
confidence: float = Field(default=0.0, description="置信度 0-1")
|
||||
createdAt: str = Field(default="", description="创建时间 ISO 格式")
|
||||
sourceError: str = Field(default="", description="纠错类事实:被纠正的原错误描述")
|
||||
|
||||
|
||||
class MemoryV2DataResponse(BaseModel):
|
||||
"""Memory 数据视图 —— V1/V2 共用,多余字段各版本置空。"""
|
||||
|
||||
# V2 字段
|
||||
user_entries: list[str] = Field(default_factory=list, description="V2: USER.md 条目 (用户画像)")
|
||||
memory_entries: list[str] = Field(default_factory=list, description="V2: MEMORY.md 条目 (agent 工作笔记)")
|
||||
user_usage: str = Field(default="", description="字符/计数用量描述")
|
||||
memory_usage: str = Field(default="", description="字符/计数用量描述")
|
||||
|
||||
# V1 字段
|
||||
facts: list[MemoryFact] = Field(default_factory=list, description="V1: facts 列表 (含置信度)")
|
||||
user_context: dict[str, str] = Field(default_factory=dict, description="V1: userContext 摘要")
|
||||
history: dict[str, str] = Field(default_factory=dict, description="V1: history 历史摘要")
|
||||
|
||||
|
||||
class MemoryV2ConfigResponse(BaseModel):
|
||||
"""Memory V2 生效配置 (含三层覆盖合并结果) + 用户可改字段白名单。"""
|
||||
"""Memory 生效配置 (含三层覆盖合并结果) + 用户可改字段白名单 + 版本号。"""
|
||||
|
||||
config: dict[str, Any] = Field(..., description="按 per-user 覆盖合并后的生效配置")
|
||||
writable_fields: list[str] = Field(..., description="用户可在运行时覆盖的字段 (点号路径)")
|
||||
version: str = Field(default="v2", description="当前记忆版本:v1 或 v2")
|
||||
|
||||
|
||||
class MemoryConfigPatchRequest(BaseModel):
|
||||
@@ -134,6 +152,33 @@ class MemorySearchResponse(BaseModel):
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def _is_v1(user_id: str) -> bool:
|
||||
"""判断当前用户是否处于 V1 记忆模式。"""
|
||||
config = get_effective_memory_config(user_id)
|
||||
return getattr(config, "version", "v2") == "v1"
|
||||
|
||||
|
||||
def _build_v1_provider(user_id: str, agent_name: str | None = None):
|
||||
"""构建并初始化 V1BuiltinProvider。"""
|
||||
from deerflow.agents.memory.providers.v1_builtin import V1BuiltinProvider
|
||||
|
||||
config = get_effective_memory_config(user_id)
|
||||
v1_cfg = config.v1
|
||||
provider = V1BuiltinProvider(
|
||||
max_facts=v1_cfg.max_facts,
|
||||
fact_confidence_threshold=v1_cfg.fact_confidence_threshold,
|
||||
debounce_seconds=v1_cfg.debounce_seconds,
|
||||
model_name=v1_cfg.model_name,
|
||||
token_budget=v1_cfg.token_budget,
|
||||
)
|
||||
provider.initialize(
|
||||
user_id=user_id,
|
||||
agent_id=agent_name,
|
||||
base_dir=str(get_paths().base_dir),
|
||||
)
|
||||
return provider
|
||||
|
||||
|
||||
def _build_builtin_provider(user_id: str, agent_name: str | None):
|
||||
"""按生效配置构建并初始化一个 V2 builtin provider。"""
|
||||
from deerflow.agents.memory.providers.builtin import BuiltinFileProvider
|
||||
@@ -161,7 +206,7 @@ def _validate_v2_target(target: str) -> str:
|
||||
|
||||
|
||||
def _v2_data_response(provider) -> MemoryV2DataResponse:
|
||||
"""从 builtin provider 读出当前数据,组装成 MemoryV2DataResponse。"""
|
||||
"""从 V2 builtin provider 读出当前数据。"""
|
||||
return MemoryV2DataResponse(
|
||||
user_entries=provider.read("user"),
|
||||
memory_entries=provider.read("memory"),
|
||||
@@ -170,6 +215,49 @@ def _v2_data_response(provider) -> MemoryV2DataResponse:
|
||||
)
|
||||
|
||||
|
||||
def _v1_data_response(provider) -> MemoryV2DataResponse:
|
||||
"""从 V1 provider 读出当前数据,转换为统一响应格式。
|
||||
|
||||
支持新格式 {user: {workContext: {summary, updatedAt}}} 和老格式 {userContext: {workContext: str}}。
|
||||
"""
|
||||
data = provider.read_v1_data()
|
||||
raw_facts = data.get("facts", [])
|
||||
facts = [
|
||||
MemoryFact(
|
||||
id=str(f.get("id", "")),
|
||||
content=str(f.get("content", "")),
|
||||
category=str(f.get("category", "knowledge")),
|
||||
confidence=float(f.get("confidence", 0)),
|
||||
createdAt=str(f.get("createdAt", "")),
|
||||
sourceError=str(f.get("sourceError", "")),
|
||||
)
|
||||
for f in raw_facts
|
||||
]
|
||||
|
||||
def _extract_section(section: dict) -> dict[str, str]:
|
||||
"""从 {key: {summary: str, updatedAt: str}} 或 {key: str} 中提取摘要字符串。"""
|
||||
result = {}
|
||||
for k, v in section.items():
|
||||
if isinstance(v, dict):
|
||||
summary = v.get("summary", "")
|
||||
else:
|
||||
summary = str(v)
|
||||
if summary:
|
||||
result[k] = summary
|
||||
return result
|
||||
|
||||
# 新格式用 "user" key,老格式用 "userContext" key(已由 storage 迁移,此处兜底)
|
||||
ctx_raw = data.get("user") or data.get("userContext") or {}
|
||||
history_raw = data.get("history") or {}
|
||||
|
||||
return MemoryV2DataResponse(
|
||||
facts=sorted(facts, key=lambda f: f.confidence, reverse=True),
|
||||
user_context=_extract_section(ctx_raw),
|
||||
history=_extract_section(history_raw),
|
||||
memory_usage=provider.usage_text(),
|
||||
)
|
||||
|
||||
|
||||
def _raise_if_write_failed(result: dict) -> None:
|
||||
"""builtin provider 写操作失败时抛 400。"""
|
||||
if not result.get("success"):
|
||||
@@ -184,33 +272,43 @@ def _raise_if_write_failed(result: dict) -> None:
|
||||
@router.get(
|
||||
"/memory/v2",
|
||||
response_model=MemoryV2DataResponse,
|
||||
summary="Get Memory V2 Data",
|
||||
description="读取 Memory V2 builtin provider 的 USER.md / MEMORY.md 条目。",
|
||||
summary="Get Memory Data",
|
||||
description="读取记忆数据。V1 模式返回 facts 列表;V2 模式返回 USER.md / MEMORY.md 条目。",
|
||||
)
|
||||
async def get_memory_v2(agent: str | None = _AgentQuery) -> MemoryV2DataResponse:
|
||||
"""读取当前用户的 Memory V2 本地记忆。"""
|
||||
"""读取当前用户的本地记忆。"""
|
||||
agent_name = _resolve_agent_name(agent)
|
||||
user_id = get_effective_user_id()
|
||||
try:
|
||||
if _is_v1(user_id):
|
||||
provider = _build_v1_provider(user_id, agent_name)
|
||||
return _v1_data_response(provider)
|
||||
provider = _build_builtin_provider(user_id, agent_name)
|
||||
return _v2_data_response(provider)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=500, detail="Failed to read memory V2 data.") from exc
|
||||
raise HTTPException(status_code=500, detail="Failed to read memory data.") from exc
|
||||
|
||||
|
||||
def _assert_not_v1(user_id: str) -> None:
|
||||
"""V1 模式下写入操作返回 400。"""
|
||||
if _is_v1(user_id):
|
||||
raise HTTPException(status_code=400, detail="V1 模式下记忆由系统自动提取,不支持手动修改。")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/memory/v2/entries",
|
||||
response_model=MemoryV2DataResponse,
|
||||
summary="Add Memory V2 Entry",
|
||||
description="手动往 USER.md / MEMORY.md 新增一条记忆条目。",
|
||||
description="手动往 USER.md / MEMORY.md 新增一条记忆条目 (仅 V2 模式)。",
|
||||
)
|
||||
async def add_memory_v2_entry(
|
||||
request: MemoryEntryCreateRequest,
|
||||
agent: str | None = _AgentQuery,
|
||||
) -> MemoryV2DataResponse:
|
||||
"""手动新增一条 V2 记忆条目。"""
|
||||
_validate_v2_target(request.target)
|
||||
user_id = get_effective_user_id()
|
||||
_assert_not_v1(user_id)
|
||||
_validate_v2_target(request.target)
|
||||
try:
|
||||
provider = _build_builtin_provider(user_id, _resolve_agent_name(agent))
|
||||
result = provider.add(request.target, request.content)
|
||||
@@ -226,15 +324,16 @@ async def add_memory_v2_entry(
|
||||
"/memory/v2/entries",
|
||||
response_model=MemoryV2DataResponse,
|
||||
summary="Replace Memory V2 Entry",
|
||||
description="用 old_text 定位一条记忆条目并替换为新内容。",
|
||||
description="用 old_text 定位一条记忆条目并替换为新内容 (仅 V2 模式)。",
|
||||
)
|
||||
async def replace_memory_v2_entry(
|
||||
request: MemoryEntryReplaceRequest,
|
||||
agent: str | None = _AgentQuery,
|
||||
) -> MemoryV2DataResponse:
|
||||
"""手动替换一条 V2 记忆条目。"""
|
||||
_validate_v2_target(request.target)
|
||||
user_id = get_effective_user_id()
|
||||
_assert_not_v1(user_id)
|
||||
_validate_v2_target(request.target)
|
||||
try:
|
||||
provider = _build_builtin_provider(user_id, _resolve_agent_name(agent))
|
||||
result = provider.replace(request.target, request.old_text, request.content)
|
||||
@@ -250,7 +349,7 @@ async def replace_memory_v2_entry(
|
||||
"/memory/v2/entries",
|
||||
response_model=MemoryV2DataResponse,
|
||||
summary="Delete Memory V2 Entry",
|
||||
description="用 old_text 定位并删除一条记忆条目。",
|
||||
description="用 old_text 定位并删除一条记忆条目 (仅 V2 模式)。",
|
||||
)
|
||||
async def delete_memory_v2_entry(
|
||||
target: str = Query(..., description="memory 或 user"),
|
||||
@@ -258,8 +357,9 @@ async def delete_memory_v2_entry(
|
||||
agent: str | None = _AgentQuery,
|
||||
) -> MemoryV2DataResponse:
|
||||
"""手动删除一条 V2 记忆条目。"""
|
||||
_validate_v2_target(target)
|
||||
user_id = get_effective_user_id()
|
||||
_assert_not_v1(user_id)
|
||||
_validate_v2_target(target)
|
||||
try:
|
||||
provider = _build_builtin_provider(user_id, _resolve_agent_name(agent))
|
||||
result = provider.remove(target, old_text)
|
||||
@@ -289,6 +389,7 @@ async def get_memory_v2_config() -> MemoryV2ConfigResponse:
|
||||
return MemoryV2ConfigResponse(
|
||||
config=config.model_dump(),
|
||||
writable_fields=sorted(USER_WRITABLE_FIELDS),
|
||||
version=getattr(config, "version", "v2"),
|
||||
)
|
||||
|
||||
|
||||
@@ -313,6 +414,7 @@ async def patch_memory_v2_config(request: MemoryConfigPatchRequest, req: Request
|
||||
return MemoryV2ConfigResponse(
|
||||
config=config.model_dump(),
|
||||
writable_fields=sorted(USER_WRITABLE_FIELDS),
|
||||
version=getattr(config, "version", "v2"),
|
||||
)
|
||||
|
||||
|
||||
@@ -341,6 +443,7 @@ async def delete_memory_v2_config(
|
||||
return MemoryV2ConfigResponse(
|
||||
config=config.model_dump(),
|
||||
writable_fields=sorted(USER_WRITABLE_FIELDS),
|
||||
version=getattr(config, "version", "v2"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -144,9 +144,19 @@ checkpointer:
|
||||
# users/{user_id}/memory_config.json > .deer-flow/memory_user_overrides.json > 此处
|
||||
memory:
|
||||
enabled: true
|
||||
provider:
|
||||
hindsight # 'builtin' | 'hindsight'(外部 provider 名)
|
||||
# builtin 始终启用,这里只决定 external 选哪个
|
||||
version: v2 # 记忆版本: 'v1'(原版 DeerFlow, memory.json + LLM 自动提取)
|
||||
# 'v2'(USER.md/MEMORY.md + 可选 Hindsight)
|
||||
provider: hindsight # 'builtin' | 'hindsight'(外部 provider 名)
|
||||
# builtin 始终启用,这里只决定 external 选哪个
|
||||
# (version=v1 时此字段忽略)
|
||||
|
||||
# V1 专属配置 —— version=v1 时生效
|
||||
v1:
|
||||
max_facts: 100 # 最多保留的事实条数,超出时驱逐最低置信度的事实
|
||||
fact_confidence_threshold: 0.7 # 事实置信度阈值,低于此值不保存
|
||||
debounce_seconds: 30 # 对话结束后延迟多少秒才触发 LLM 提取
|
||||
model_name: "" # 用于事实提取的 LLM 模型名;空则继承全局默认模型
|
||||
token_budget: 2000 # 注入系统提示词的 token 预算
|
||||
builtin:
|
||||
enabled: true
|
||||
memory_char_limit: 2200
|
||||
|
||||
+13
-3
@@ -268,6 +268,7 @@ def _build_middlewares(
|
||||
# Add TodoList middleware if plan mode is enabled
|
||||
cfg = _get_runtime_config(config)
|
||||
is_plan_mode = cfg.get("is_plan_mode", False)
|
||||
is_bootstrap = cfg.get("is_bootstrap", False)
|
||||
todo_list_middleware = _create_todo_list_middleware(is_plan_mode)
|
||||
if todo_list_middleware is not None:
|
||||
middlewares.append(todo_list_middleware)
|
||||
@@ -279,8 +280,10 @@ def _build_middlewares(
|
||||
# Add TitleMiddleware
|
||||
middlewares.append(TitleMiddleware(app_config=resolved_app_config))
|
||||
|
||||
# Add MemoryMiddleware (after TitleMiddleware)
|
||||
middlewares.append(MemoryMiddleware(agent_name=agent_name, memory_config=resolved_app_config.memory))
|
||||
# Add MemoryMiddleware (after TitleMiddleware) — skip in bootstrap mode (agent creation flow)
|
||||
memory_injection_enabled = cfg.get("memory_injection_enabled", True)
|
||||
if not is_bootstrap:
|
||||
middlewares.append(MemoryMiddleware(agent_name=agent_name, memory_config=resolved_app_config.memory, injection_enabled=memory_injection_enabled))
|
||||
|
||||
# Add ViewImageMiddleware only if the current model supports vision.
|
||||
# Use the resolved runtime model_name from make_lead_agent to avoid stale config values.
|
||||
@@ -336,13 +339,18 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
subagent_enabled = cfg.get("subagent_enabled", False)
|
||||
max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3)
|
||||
is_bootstrap = cfg.get("is_bootstrap", False)
|
||||
memory_injection_enabled = bool(cfg.get("memory_injection_enabled", True))
|
||||
writing_mode = bool(cfg.get("writing_mode", False))
|
||||
writing_artifact_path = cfg.get("writing_artifact_path") if writing_mode else None
|
||||
if not isinstance(writing_artifact_path, str) or not writing_artifact_path.strip():
|
||||
writing_artifact_path = None
|
||||
agent_id = validate_agent_id(cfg.get("agent_id") or cfg.get("agent_name"))
|
||||
|
||||
agent_config = load_agent_config(agent_id) if not is_bootstrap else None
|
||||
try:
|
||||
agent_config = load_agent_config(agent_id) if not is_bootstrap else None
|
||||
except FileNotFoundError:
|
||||
logger.warning("Agent directory not found for '%s'; running with default config", agent_id)
|
||||
agent_config = None
|
||||
agent_display_name = agent_config.name if agent_config else agent_id
|
||||
# Custom agent model from agent config (if any), or None to let _resolve_model_name pick the default
|
||||
agent_model_name = agent_config.model if agent_config and agent_config.model else None
|
||||
@@ -419,6 +427,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
available_skills=None,
|
||||
is_bootstrap=True,
|
||||
app_config=resolved_app_config,
|
||||
memory_injection_enabled=memory_injection_enabled,
|
||||
),
|
||||
state_schema=ThreadState,
|
||||
)
|
||||
@@ -443,6 +452,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
writing_artifact_path=writing_artifact_path,
|
||||
available_skills=(set(agent_config.skills or []) if agent_config else None),
|
||||
app_config=resolved_app_config,
|
||||
memory_injection_enabled=memory_injection_enabled,
|
||||
),
|
||||
state_schema=ThreadState,
|
||||
)
|
||||
+6
-2
@@ -604,7 +604,7 @@ combined with a FastAPI gateway for REST API access [citation:FastAPI](https://f
|
||||
"""
|
||||
|
||||
|
||||
def _get_memory_context(agent_name: str | None = None, *, app_config: AppConfig | None = None) -> str:
|
||||
def _get_memory_context(agent_name: str | None = None, *, app_config: AppConfig | None = None, injection_enabled: bool = True) -> str:
|
||||
"""组装注入系统提示词的记忆上下文 (Memory V2)。
|
||||
|
||||
通过 :class:`MemoryManager` 收集各 provider 的记忆块。P1 期只有 builtin
|
||||
@@ -614,10 +614,13 @@ def _get_memory_context(agent_name: str | None = None, *, app_config: AppConfig
|
||||
Args:
|
||||
agent_name: agent 标识;``None`` 时按 "default" 处理。
|
||||
app_config: 显式应用配置 (本函数实际按 per-user 覆盖解析生效配置)。
|
||||
injection_enabled: 运行时开关;``False`` 时跳过注入直接返回空串。
|
||||
|
||||
Returns:
|
||||
包在 ``<memory>`` 标签里的记忆上下文;未启用或为空时返回空串。
|
||||
"""
|
||||
if not injection_enabled:
|
||||
return ""
|
||||
try:
|
||||
from deerflow.agents.memory.manager import build_memory_manager
|
||||
from deerflow.config.memory_config import get_effective_memory_config
|
||||
@@ -853,11 +856,12 @@ def apply_prompt_template(
|
||||
available_skills: set[str] | None = None,
|
||||
is_bootstrap: bool = False,
|
||||
app_config: AppConfig | None = None,
|
||||
memory_injection_enabled: bool = True,
|
||||
) -> str:
|
||||
storage_agent_id = agent_id if agent_id is not None else agent_name
|
||||
|
||||
# Get memory context
|
||||
memory_context = _get_memory_context(storage_agent_id, app_config=app_config)
|
||||
memory_context = _get_memory_context(storage_agent_id, app_config=app_config, injection_enabled=memory_injection_enabled)
|
||||
|
||||
# Include subagent section only if enabled (from runtime parameter)
|
||||
n = max_concurrent_subagents
|
||||
|
||||
+41
-20
@@ -223,8 +223,11 @@ def build_memory_manager(
|
||||
) -> MemoryManager:
|
||||
"""按生效配置组装并初始化一个 MemoryManager。
|
||||
|
||||
这是构建 manager 的统一入口 —— 注册 builtin provider,以及在
|
||||
``config.provider == "hindsight"`` 且 Hindsight 可用时注册 HindsightProvider。
|
||||
这是构建 manager 的统一入口 —— 根据 ``config.version`` 选择 V1 或 V2 provider:
|
||||
|
||||
- ``version="v1"``: 仅注册 V1BuiltinProvider (memory.json + LLM 自动提取)
|
||||
- ``version="v2"``: 注册 BuiltinFileProvider + 可选 HindsightProvider (USER.md/MEMORY.md)
|
||||
|
||||
所有 provider 已 ``initialize`` 完毕,可直接使用。
|
||||
|
||||
Parameters
|
||||
@@ -234,7 +237,6 @@ def build_memory_manager(
|
||||
config:
|
||||
生效的 :class:`MemoryConfig`;为 ``None`` 时按 ``user_id`` 解析 per-user 配置。
|
||||
"""
|
||||
from deerflow.agents.memory.providers.builtin import BuiltinFileProvider
|
||||
from deerflow.config.memory_config import get_effective_memory_config
|
||||
from deerflow.config.paths import get_paths
|
||||
|
||||
@@ -242,34 +244,53 @@ def build_memory_manager(
|
||||
config = get_effective_memory_config(user_id)
|
||||
|
||||
manager = MemoryManager()
|
||||
base_dir = str(get_paths().base_dir)
|
||||
|
||||
if config.builtin.enabled:
|
||||
if getattr(config, "version", "v2") == "v1":
|
||||
# V1 模式:原版 DeerFlow memory.json + LLM 自动提取
|
||||
from deerflow.agents.memory.providers.v1_builtin import V1BuiltinProvider
|
||||
|
||||
v1_cfg = config.v1
|
||||
manager.add_provider(
|
||||
BuiltinFileProvider(
|
||||
memory_char_limit=config.builtin.memory_char_limit,
|
||||
user_char_limit=config.builtin.user_char_limit,
|
||||
deduplicate_on_load=config.builtin.deduplicate_on_load,
|
||||
security=config.security,
|
||||
V1BuiltinProvider(
|
||||
max_facts=v1_cfg.max_facts,
|
||||
fact_confidence_threshold=v1_cfg.fact_confidence_threshold,
|
||||
debounce_seconds=v1_cfg.debounce_seconds,
|
||||
model_name=v1_cfg.model_name,
|
||||
token_budget=v1_cfg.token_budget,
|
||||
)
|
||||
)
|
||||
else:
|
||||
# V2 模式:USER.md / MEMORY.md + 可选 Hindsight
|
||||
from deerflow.agents.memory.providers.builtin import BuiltinFileProvider
|
||||
|
||||
if config.provider == "hindsight":
|
||||
try:
|
||||
from deerflow.agents.memory.providers.hindsight import HindsightProvider
|
||||
if config.builtin.enabled:
|
||||
manager.add_provider(
|
||||
BuiltinFileProvider(
|
||||
memory_char_limit=config.builtin.memory_char_limit,
|
||||
user_char_limit=config.builtin.user_char_limit,
|
||||
deduplicate_on_load=config.builtin.deduplicate_on_load,
|
||||
security=config.security,
|
||||
)
|
||||
)
|
||||
|
||||
hindsight = HindsightProvider(config.hindsight)
|
||||
if hindsight.is_available():
|
||||
manager.add_provider(hindsight)
|
||||
else:
|
||||
logger.info("Hindsight provider 已配置但不可用 (包未装或缺 api_url),退回纯 builtin")
|
||||
except Exception as exc: # noqa: BLE001 - Hindsight 接入失败不能阻断记忆系统
|
||||
logger.warning("加载 Hindsight provider 失败,退回纯 builtin:%s", exc)
|
||||
if config.provider == "hindsight":
|
||||
try:
|
||||
from deerflow.agents.memory.providers.hindsight import HindsightProvider
|
||||
|
||||
hindsight = HindsightProvider(config.hindsight)
|
||||
if hindsight.is_available():
|
||||
manager.add_provider(hindsight)
|
||||
else:
|
||||
logger.info("Hindsight provider 已配置但不可用 (包未装或缺 api_url),退回纯 builtin")
|
||||
except Exception as exc: # noqa: BLE001 - Hindsight 接入失败不能阻断记忆系统
|
||||
logger.warning("加载 Hindsight provider 失败,退回纯 builtin:%s", exc)
|
||||
|
||||
manager.initialize_all(
|
||||
user_id=user_id,
|
||||
agent_id=agent_id or "default",
|
||||
thread_id=thread_id,
|
||||
base_dir=str(get_paths().base_dir),
|
||||
base_dir=base_dir,
|
||||
)
|
||||
return manager
|
||||
|
||||
|
||||
+39
@@ -69,3 +69,42 @@ def filter_messages_for_memory(messages: list[Any]) -> list[Any]:
|
||||
filtered.append(msg)
|
||||
|
||||
return filtered
|
||||
|
||||
|
||||
_CORRECTION_PATTERNS = re.compile(
|
||||
r"\b(?:no[,.]?\s+(?:that'?s?\s+)?(?:wrong|incorrect|not\s+right|not\s+what)|"
|
||||
r"actually[,.]?\s+(?:it\s+(?:is|should)|you\s+(?:should|need|must)|the\s+correct)|"
|
||||
r"that'?s?\s+(?:wrong|incorrect|not\s+right|not\s+what\s+i)|"
|
||||
r"you\s+(?:got\s+it\s+wrong|misunderstood|made\s+an?\s+error|made\s+a\s+mistake)|"
|
||||
r"incorrect[,.]|wrong[,.]|mistake[,.]|correction:|that\s+is\s+not\s+right)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_REINFORCEMENT_PATTERNS = re.compile(
|
||||
r"\b(?:perfect[,.]?|exactly[,.]?|correct[,.]?|that'?s?\s+(?:right|correct|perfect|exactly\s+right)|"
|
||||
r"you\s+(?:got\s+it|nailed\s+it)|great\s+(?:job|work)|well\s+done|"
|
||||
r"yes[,.]?\s+(?:that'?s?\s+(?:right|correct|exactly)|exactly)|"
|
||||
r"keep\s+(?:it\s+up|doing\s+that|going)|"
|
||||
r"(?:that'?s?\s+|this\s+is\s+)?(?:exactly|precisely)\s+(?:what\s+i|right))\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def detect_correction(messages: list[Any]) -> bool:
|
||||
"""Detect if the conversation contains explicit user correction signals."""
|
||||
for msg in messages:
|
||||
if getattr(msg, "type", None) == "human":
|
||||
text = extract_message_text(msg)
|
||||
if _CORRECTION_PATTERNS.search(text):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def detect_reinforcement(messages: list[Any]) -> bool:
|
||||
"""Detect if the conversation contains positive reinforcement signals."""
|
||||
for msg in messages:
|
||||
if getattr(msg, "type", None) == "human":
|
||||
text = extract_message_text(msg)
|
||||
if _REINFORCEMENT_PATTERNS.search(text):
|
||||
return True
|
||||
return False
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
"""Memory V1 — 原版 DeerFlow JSON-based MemoryProvider 实现。
|
||||
|
||||
存储格式:``{base_dir}/users/{user_id}/memory.json``(经 FileMemoryStorage 管理)
|
||||
JSON 结构使用嵌套 ``{summary, updatedAt}`` 对象,与原版 DeerFlow 完全一致。
|
||||
|
||||
LLM 在 V1 模式下不调用 memory 工具 —— 事实完全由异步 LLM 提取。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from deerflow.agents.memory.provider import MemoryProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _FakeMsg:
|
||||
"""轻量 message 对象,用于将文本传给原版 format_conversation_for_update。"""
|
||||
__slots__ = ("type", "content")
|
||||
|
||||
def __init__(self, type_: str, content: str) -> None:
|
||||
self.type = type_
|
||||
self.content = content
|
||||
|
||||
|
||||
class V1BuiltinProvider(MemoryProvider):
|
||||
"""原版 DeerFlow V1 记忆 provider。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
max_facts: int = 100,
|
||||
fact_confidence_threshold: float = 0.7,
|
||||
debounce_seconds: int = 30,
|
||||
model_name: str = "",
|
||||
token_budget: int = 2000,
|
||||
) -> None:
|
||||
self._max_facts = max_facts
|
||||
self._confidence_threshold = fact_confidence_threshold
|
||||
self._debounce_seconds = debounce_seconds
|
||||
self._model_name = model_name
|
||||
self._token_budget = token_budget
|
||||
|
||||
self._user_id: str = ""
|
||||
self._thread_id: str = ""
|
||||
self._agent_id: str | None = None
|
||||
self._frozen_block: str = ""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "v1_builtin"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return True
|
||||
|
||||
def initialize(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
agent_id: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
base_dir: str,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
_ = base_dir # FileMemoryStorage uses get_paths() internally
|
||||
self._user_id = user_id
|
||||
self._thread_id = thread_id or ""
|
||||
self._agent_id = agent_id
|
||||
self._frozen_block = self._build_frozen_block()
|
||||
|
||||
# ---- 系统提示词 -------------------------------------------------------
|
||||
|
||||
def _build_frozen_block(self) -> str:
|
||||
"""从 memory.json 读取数据,用 format_memory_for_injection 渲染注入块。
|
||||
|
||||
当 agent_id 已知时加载 per-agent 记忆;否则加载用户级记忆。
|
||||
"""
|
||||
try:
|
||||
from deerflow.agents.memory.v1.prompt import format_memory_for_injection
|
||||
from deerflow.agents.memory.v1.storage import get_memory_storage
|
||||
|
||||
data = get_memory_storage().load(
|
||||
agent_name=self._agent_id or None,
|
||||
user_id=self._user_id,
|
||||
)
|
||||
text = format_memory_for_injection(data, max_tokens=self._token_budget)
|
||||
if not text:
|
||||
return ""
|
||||
return f"<memory>\n{text}\n</memory>"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("V1 provider 加载 memory.json 失败: %s", exc)
|
||||
return ""
|
||||
|
||||
def system_prompt_block(self) -> str:
|
||||
return self._frozen_block
|
||||
|
||||
# ---- 召回 / 写入 ------------------------------------------------------
|
||||
|
||||
def prefetch(self, query: str, *, thread_id: str = "") -> str:
|
||||
# V1 无动态召回,静态 snapshot 已在 system_prompt_block 里
|
||||
return ""
|
||||
|
||||
def sync_turn(
|
||||
self,
|
||||
user_content: str,
|
||||
assistant_content: str,
|
||||
*,
|
||||
thread_id: str = "",
|
||||
turn_index: int | None = None,
|
||||
) -> None:
|
||||
"""把本轮对话加入防抖队列,等待 LLM 异步提取事实。"""
|
||||
if not user_content.strip() and not assistant_content.strip():
|
||||
return
|
||||
try:
|
||||
from deerflow.agents.memory.v1.queue import get_memory_queue
|
||||
|
||||
msgs = [
|
||||
_FakeMsg("human", user_content),
|
||||
_FakeMsg("ai", assistant_content),
|
||||
]
|
||||
queue = get_memory_queue()
|
||||
queue.add(
|
||||
thread_id=thread_id or self._thread_id,
|
||||
messages=msgs,
|
||||
user_id=self._user_id,
|
||||
agent_name=self._agent_id or None,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("V1 provider sync_turn 入队失败: %s", exc)
|
||||
|
||||
# ---- 工具 (V1 不暴露工具给 LLM) -------------------------------------
|
||||
|
||||
def get_tool_schemas(self) -> list[dict[str, Any]]:
|
||||
return []
|
||||
|
||||
# ---- API 读取接口 (供 Gateway router 用) -----------------------------
|
||||
|
||||
def read_v1_data(self) -> dict[str, Any]:
|
||||
"""读取 memory.json 的当前内容(实时读磁盘,非 frozen snapshot)。
|
||||
|
||||
当 agent_id 已知时读取 per-agent 记忆;否则读用户级记忆。
|
||||
"""
|
||||
from deerflow.agents.memory.v1.storage import get_memory_storage
|
||||
return get_memory_storage().load(
|
||||
agent_name=self._agent_id or None,
|
||||
user_id=self._user_id,
|
||||
)
|
||||
|
||||
def fact_count(self) -> int:
|
||||
try:
|
||||
return len(self.read_v1_data().get("facts", []))
|
||||
except Exception: # noqa: BLE001
|
||||
return 0
|
||||
|
||||
def usage_text(self) -> str:
|
||||
return f"{self.fact_count()}/{self._max_facts} 个事实"
|
||||
|
||||
|
||||
__all__ = ["V1BuiltinProvider"]
|
||||
+1
@@ -0,0 +1 @@
|
||||
"""Memory V1 — 原版 DeerFlow JSON-based 记忆子模块。"""
|
||||
+371
@@ -0,0 +1,371 @@
|
||||
"""Prompt templates for memory update and injection."""
|
||||
|
||||
import math
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
import tiktoken
|
||||
|
||||
TIKTOKEN_AVAILABLE = True
|
||||
except ImportError:
|
||||
TIKTOKEN_AVAILABLE = False
|
||||
|
||||
# Prompt template for updating memory based on conversation
|
||||
MEMORY_UPDATE_PROMPT = """You are a memory management system. Your task is to analyze a conversation and update the user's memory profile.
|
||||
|
||||
Current Memory State:
|
||||
<current_memory>
|
||||
{current_memory}
|
||||
</current_memory>
|
||||
|
||||
New Conversation to Process:
|
||||
<conversation>
|
||||
{conversation}
|
||||
</conversation>
|
||||
|
||||
Instructions:
|
||||
1. Analyze the conversation for important information about the user
|
||||
2. Extract relevant facts, preferences, and context with specific details (numbers, names, technologies)
|
||||
3. Update the memory sections as needed following the detailed length guidelines below
|
||||
|
||||
Before extracting facts, perform a structured reflection on the conversation:
|
||||
1. Error/Retry Detection: Did the agent encounter errors, require retries, or produce incorrect results?
|
||||
If yes, record the root cause and correct approach as a high-confidence fact with category "correction".
|
||||
2. User Correction Detection: Did the user correct the agent's direction, understanding, or output?
|
||||
If yes, record the correct interpretation or approach as a high-confidence fact with category "correction".
|
||||
Include what went wrong in "sourceError" only when category is "correction" and the mistake is explicit in the conversation.
|
||||
3. Project Constraint Discovery: Were any project-specific constraints discovered during the conversation?
|
||||
If yes, record them as facts with the most appropriate category and confidence.
|
||||
|
||||
{correction_hint}
|
||||
|
||||
Memory Section Guidelines:
|
||||
|
||||
**User Context** (Current state - concise summaries):
|
||||
- workContext: Professional role, company, key projects, main technologies (2-3 sentences)
|
||||
Example: Core contributor, project names with metrics (16k+ stars), technical stack
|
||||
- personalContext: Languages, communication preferences, key interests (1-2 sentences)
|
||||
Example: Bilingual capabilities, specific interest areas, expertise domains
|
||||
- topOfMind: Multiple ongoing focus areas and priorities (3-5 sentences, detailed paragraph)
|
||||
Example: Primary project work, parallel technical investigations, ongoing learning/tracking
|
||||
Include: Active implementation work, troubleshooting issues, market/research interests
|
||||
Note: This captures SEVERAL concurrent focus areas, not just one task
|
||||
|
||||
**History** (Temporal context - rich paragraphs; updated incrementally each conversation):
|
||||
- recentMonths: Running log of recent work and activities (4-6 sentences or 1-2 paragraphs)
|
||||
Scope: Accumulate from each conversation — any task the user worked on, problems solved, technologies touched
|
||||
Update policy: Set shouldUpdate=true whenever the conversation contains any task, discussion, or work activity; integrate new content into existing text rather than overwriting
|
||||
Include: Technologies explored, projects worked on, problems solved, interests demonstrated
|
||||
- earlierContext: Important patterns promoted from recentMonths over time (3-5 sentences or 1 paragraph)
|
||||
Scope: Recurring themes and established patterns across multiple conversations
|
||||
Update policy: Set shouldUpdate=true when a pattern repeats or an older item in recentMonths should be archived here
|
||||
Include: Past projects, learning journeys, established patterns
|
||||
- longTermBackground: Persistent foundational context that rarely changes (2-4 sentences)
|
||||
Scope: Core expertise, fundamental working style, long-standing interests
|
||||
Update policy: Set shouldUpdate=true only when a genuinely new foundational fact emerges
|
||||
Include: Core expertise, longstanding interests, fundamental working style
|
||||
|
||||
**Facts Extraction**:
|
||||
- Extract specific, quantifiable details (e.g., "16k+ GitHub stars", "200+ datasets")
|
||||
- Include proper nouns (company names, project names, technology names)
|
||||
- Preserve technical terminology and version numbers
|
||||
- Categories:
|
||||
* preference: Tools, styles, approaches user prefers/dislikes
|
||||
* knowledge: Specific expertise, technologies mastered, domain knowledge
|
||||
* context: Background facts (job title, projects, locations, languages)
|
||||
* behavior: Working patterns, communication habits, problem-solving approaches
|
||||
* goal: Stated objectives, learning targets, project ambitions
|
||||
* correction: Explicit agent mistakes or user corrections, including the correct approach
|
||||
- Confidence levels:
|
||||
* 0.9-1.0: Explicitly stated facts ("I work on X", "My role is Y")
|
||||
* 0.7-0.8: Strongly implied from actions/discussions
|
||||
* 0.5-0.6: Inferred patterns (use sparingly, only for clear patterns)
|
||||
|
||||
**What Goes Where**:
|
||||
- workContext: Current job, active projects, primary tech stack
|
||||
- personalContext: Languages, personality, interests outside direct work tasks
|
||||
- topOfMind: Multiple ongoing priorities and focus areas user cares about recently (gets updated most frequently)
|
||||
Should capture 3-5 concurrent themes: main work, side explorations, learning/tracking interests
|
||||
- recentMonths: Cumulative log of recent work — update from EVERY conversation with meaningful content
|
||||
- earlierContext: Recurring patterns promoted from recentMonths; update when patterns solidify
|
||||
- longTermBackground: Unchanging foundational facts about the user; update only for new foundational info
|
||||
|
||||
**Language Requirement**:
|
||||
- ALL summaries and fact content MUST be written in Chinese (Simplified)
|
||||
- Keep technical terms, proper nouns, product names, and company names in their original form (e.g., DeepSeek, LangGraph, GitHub)
|
||||
- Surrounding descriptive text must be in Chinese regardless of the conversation language
|
||||
|
||||
Output Format (JSON):
|
||||
{{
|
||||
"user": {{
|
||||
"workContext": {{ "summary": "...", "shouldUpdate": true/false }},
|
||||
"personalContext": {{ "summary": "...", "shouldUpdate": true/false }},
|
||||
"topOfMind": {{ "summary": "...", "shouldUpdate": true/false }}
|
||||
}},
|
||||
"history": {{
|
||||
"recentMonths": {{ "summary": "...", "shouldUpdate": true/false }},
|
||||
"earlierContext": {{ "summary": "...", "shouldUpdate": true/false }},
|
||||
"longTermBackground": {{ "summary": "...", "shouldUpdate": true/false }}
|
||||
}},
|
||||
"newFacts": [
|
||||
{{ "content": "...", "category": "preference|knowledge|context|behavior|goal|correction", "confidence": 0.0-1.0 }}
|
||||
],
|
||||
"factsToRemove": ["fact_id_1", "fact_id_2"]
|
||||
}}
|
||||
|
||||
Important Rules:
|
||||
- For recentMonths: set shouldUpdate=true whenever the conversation contains any task, work, or substantive discussion — this field accumulates like a log
|
||||
- For earlierContext / longTermBackground: set shouldUpdate=true only when a new pattern solidifies or a foundational fact changes
|
||||
- For user context fields: set shouldUpdate=true if there's meaningful new information
|
||||
- Follow length guidelines: workContext/personalContext are concise (1-3 sentences), topOfMind and history sections are detailed (paragraphs)
|
||||
- Include specific metrics, version numbers, and proper nouns in facts
|
||||
- Only add facts that are clearly stated (0.9+) or strongly implied (0.7+)
|
||||
- Use category "correction" for explicit agent mistakes or user corrections; assign confidence >= 0.95 when the correction is explicit
|
||||
- Include "sourceError" only for explicit correction facts when the prior mistake or wrong approach is clearly stated; omit it otherwise
|
||||
- Remove facts that are contradicted by new information
|
||||
- When updating topOfMind, integrate new focus areas while removing completed/abandoned ones
|
||||
Keep 3-5 concurrent focus themes that are still active and relevant
|
||||
- For history sections, integrate new information chronologically into appropriate time period
|
||||
- Preserve technical accuracy - keep exact names of technologies, companies, projects
|
||||
- Focus on information useful for future interactions and personalization
|
||||
- IMPORTANT: Do NOT record file upload events in memory. Uploaded files are
|
||||
session-specific and ephemeral — they will not be accessible in future sessions.
|
||||
Recording upload events causes confusion in subsequent conversations.
|
||||
- CRITICAL: Write ALL summary text and fact content in Chinese (Simplified). Technical terms and proper nouns (product names, company names, technology names) may remain in their original language, but all other text must be in Chinese.
|
||||
|
||||
Return ONLY valid JSON, no explanation or markdown."""
|
||||
|
||||
|
||||
# Prompt template for extracting facts from a single message
|
||||
FACT_EXTRACTION_PROMPT = """Extract factual information about the user from this message.
|
||||
|
||||
Message:
|
||||
{message}
|
||||
|
||||
Extract facts in this JSON format:
|
||||
{{
|
||||
"facts": [
|
||||
{{ "content": "...", "category": "preference|knowledge|context|behavior|goal|correction", "confidence": 0.0-1.0 }}
|
||||
]
|
||||
}}
|
||||
|
||||
Categories:
|
||||
- preference: User preferences (likes/dislikes, styles, tools)
|
||||
- knowledge: User's expertise or knowledge areas
|
||||
- context: Background context (location, job, projects)
|
||||
- behavior: Behavioral patterns
|
||||
- goal: User's goals or objectives
|
||||
- correction: Explicit corrections or mistakes to avoid repeating
|
||||
|
||||
Rules:
|
||||
- Only extract clear, specific facts
|
||||
- Confidence should reflect certainty (explicit statement = 0.9+, implied = 0.6-0.8)
|
||||
- Skip vague or temporary information
|
||||
- Write all fact content in Chinese (Simplified); technical terms and proper nouns may remain in their original language
|
||||
|
||||
Return ONLY valid JSON."""
|
||||
|
||||
|
||||
def _count_tokens(text: str, encoding_name: str = "cl100k_base") -> int:
|
||||
"""Count tokens in text using tiktoken.
|
||||
|
||||
Args:
|
||||
text: The text to count tokens for.
|
||||
encoding_name: The encoding to use (default: cl100k_base for GPT-4/3.5).
|
||||
|
||||
Returns:
|
||||
The number of tokens in the text.
|
||||
"""
|
||||
if not TIKTOKEN_AVAILABLE:
|
||||
return len(text) // 4
|
||||
|
||||
try:
|
||||
encoding = tiktoken.get_encoding(encoding_name)
|
||||
return len(encoding.encode(text))
|
||||
except Exception:
|
||||
return len(text) // 4
|
||||
|
||||
|
||||
def _coerce_confidence(value: Any, default: float = 0.0) -> float:
|
||||
"""Coerce a confidence-like value to a bounded float in [0, 1].
|
||||
|
||||
Non-finite values (NaN, inf, -inf) are treated as invalid and fall back
|
||||
to the default before clamping, preventing them from dominating ranking.
|
||||
The ``default`` parameter is assumed to be a finite value.
|
||||
"""
|
||||
try:
|
||||
confidence = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return max(0.0, min(1.0, default))
|
||||
if not math.isfinite(confidence):
|
||||
return max(0.0, min(1.0, default))
|
||||
return max(0.0, min(1.0, confidence))
|
||||
|
||||
|
||||
def format_memory_for_injection(memory_data: dict[str, Any], max_tokens: int = 2000) -> str:
|
||||
"""Format memory data for injection into system prompt.
|
||||
|
||||
Args:
|
||||
memory_data: The memory data dictionary.
|
||||
max_tokens: Maximum tokens to use (counted via tiktoken for accuracy).
|
||||
|
||||
Returns:
|
||||
Formatted memory string for system prompt injection.
|
||||
"""
|
||||
if not memory_data:
|
||||
return ""
|
||||
|
||||
sections = []
|
||||
|
||||
# Format user context
|
||||
user_data = memory_data.get("user", {})
|
||||
if user_data:
|
||||
user_sections = []
|
||||
|
||||
work_ctx = user_data.get("workContext", {})
|
||||
if work_ctx.get("summary"):
|
||||
user_sections.append(f"Work: {work_ctx['summary']}")
|
||||
|
||||
personal_ctx = user_data.get("personalContext", {})
|
||||
if personal_ctx.get("summary"):
|
||||
user_sections.append(f"Personal: {personal_ctx['summary']}")
|
||||
|
||||
top_of_mind = user_data.get("topOfMind", {})
|
||||
if top_of_mind.get("summary"):
|
||||
user_sections.append(f"Current Focus: {top_of_mind['summary']}")
|
||||
|
||||
if user_sections:
|
||||
sections.append("User Context:\n" + "\n".join(f"- {s}" for s in user_sections))
|
||||
|
||||
# Format history
|
||||
history_data = memory_data.get("history", {})
|
||||
if history_data:
|
||||
history_sections = []
|
||||
|
||||
recent = history_data.get("recentMonths", {})
|
||||
if recent.get("summary"):
|
||||
history_sections.append(f"Recent: {recent['summary']}")
|
||||
|
||||
earlier = history_data.get("earlierContext", {})
|
||||
if earlier.get("summary"):
|
||||
history_sections.append(f"Earlier: {earlier['summary']}")
|
||||
|
||||
background = history_data.get("longTermBackground", {})
|
||||
if background.get("summary"):
|
||||
history_sections.append(f"Background: {background['summary']}")
|
||||
|
||||
if history_sections:
|
||||
sections.append("History:\n" + "\n".join(f"- {s}" for s in history_sections))
|
||||
|
||||
# Format facts (sorted by confidence; include as many as token budget allows)
|
||||
facts_data = memory_data.get("facts", [])
|
||||
if isinstance(facts_data, list) and facts_data:
|
||||
ranked_facts = sorted(
|
||||
(f for f in facts_data if isinstance(f, dict) and isinstance(f.get("content"), str) and f.get("content").strip()),
|
||||
key=lambda fact: _coerce_confidence(fact.get("confidence"), default=0.0),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
base_text = "\n\n".join(sections)
|
||||
base_tokens = _count_tokens(base_text) if base_text else 0
|
||||
facts_header = "Facts:\n"
|
||||
separator_tokens = _count_tokens("\n\n" + facts_header) if base_text else _count_tokens(facts_header)
|
||||
running_tokens = base_tokens + separator_tokens
|
||||
|
||||
fact_lines: list[str] = []
|
||||
for fact in ranked_facts:
|
||||
content_value = fact.get("content")
|
||||
if not isinstance(content_value, str):
|
||||
continue
|
||||
content = content_value.strip()
|
||||
if not content:
|
||||
continue
|
||||
category = str(fact.get("category", "context")).strip() or "context"
|
||||
confidence = _coerce_confidence(fact.get("confidence"), default=0.0)
|
||||
source_error = fact.get("sourceError")
|
||||
if category == "correction" and isinstance(source_error, str) and source_error.strip():
|
||||
line = f"- [{category} | {confidence:.2f}] {content} (avoid: {source_error.strip()})"
|
||||
else:
|
||||
line = f"- [{category} | {confidence:.2f}] {content}"
|
||||
|
||||
line_text = ("\n" + line) if fact_lines else line
|
||||
line_tokens = _count_tokens(line_text)
|
||||
|
||||
if running_tokens + line_tokens <= max_tokens:
|
||||
fact_lines.append(line)
|
||||
running_tokens += line_tokens
|
||||
else:
|
||||
break
|
||||
|
||||
if fact_lines:
|
||||
sections.append("Facts:\n" + "\n".join(fact_lines))
|
||||
|
||||
if not sections:
|
||||
return ""
|
||||
|
||||
result = "\n\n".join(sections)
|
||||
|
||||
token_count = _count_tokens(result)
|
||||
if token_count > max_tokens:
|
||||
char_per_token = len(result) / token_count
|
||||
target_chars = int(max_tokens * char_per_token * 0.95)
|
||||
result = result[:target_chars] + "\n..."
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def format_conversation_for_update(messages: list[Any]) -> str:
|
||||
"""Format conversation messages for memory update prompt.
|
||||
|
||||
Args:
|
||||
messages: List of conversation messages.
|
||||
|
||||
Returns:
|
||||
Formatted conversation string.
|
||||
"""
|
||||
lines = []
|
||||
for msg in messages:
|
||||
role = getattr(msg, "type", "unknown")
|
||||
content = getattr(msg, "content", str(msg))
|
||||
|
||||
# Handle content that might be a list (multimodal)
|
||||
if isinstance(content, list):
|
||||
text_parts = []
|
||||
for p in content:
|
||||
if isinstance(p, str):
|
||||
text_parts.append(p)
|
||||
elif isinstance(p, dict):
|
||||
text_val = p.get("text")
|
||||
if isinstance(text_val, str):
|
||||
text_parts.append(text_val)
|
||||
content = " ".join(text_parts) if text_parts else str(content)
|
||||
|
||||
# Strip uploaded_files tags from human messages to avoid persisting
|
||||
# ephemeral file path info into long-term memory. Skip the turn entirely
|
||||
# when nothing remains after stripping (upload-only message).
|
||||
if role == "human":
|
||||
content = re.sub(r"<uploaded_files>[\s\S]*?</uploaded_files>\n*", "", str(content)).strip()
|
||||
if not content:
|
||||
continue
|
||||
|
||||
# Truncate very long messages
|
||||
if len(str(content)) > 1000:
|
||||
content = str(content)[:1000] + "..."
|
||||
|
||||
if role == "human":
|
||||
lines.append(f"User: {content}")
|
||||
elif role == "ai":
|
||||
lines.append(f"Assistant: {content}")
|
||||
|
||||
return "\n\n".join(lines)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MEMORY_UPDATE_PROMPT",
|
||||
"FACT_EXTRACTION_PROMPT",
|
||||
"format_memory_for_injection",
|
||||
"format_conversation_for_update",
|
||||
"_count_tokens",
|
||||
"_coerce_confidence",
|
||||
]
|
||||
@@ -0,0 +1,253 @@
|
||||
"""Memory V1 — 防抖异步更新队列(原版 DeerFlow MemoryUpdateQueue)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_v1_cfg():
|
||||
from deerflow.config.memory_config import get_memory_config
|
||||
return get_memory_config().v1
|
||||
|
||||
|
||||
def _memory_enabled() -> bool:
|
||||
from deerflow.config.memory_config import get_memory_config
|
||||
return get_memory_config().enabled
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConversationContext:
|
||||
"""Context for a conversation to be processed for memory update."""
|
||||
thread_id: str
|
||||
messages: list[Any]
|
||||
timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
|
||||
agent_name: str | None = None
|
||||
user_id: str | None = None
|
||||
correction_detected: bool = False
|
||||
reinforcement_detected: bool = False
|
||||
|
||||
|
||||
class MemoryUpdateQueue:
|
||||
"""Queue for memory updates with debounce mechanism.
|
||||
|
||||
Collects conversation contexts and processes them after a configurable
|
||||
debounce period. Multiple conversations received within the debounce
|
||||
window are deduplicated by (thread_id, user_id, agent_name).
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._queue: list[ConversationContext] = []
|
||||
self._lock = threading.Lock()
|
||||
self._timer: threading.Timer | None = None
|
||||
self._processing = False
|
||||
|
||||
@staticmethod
|
||||
def _queue_key(thread_id: str, user_id: str | None, agent_name: str | None) -> tuple[str, str | None, str | None]:
|
||||
return (thread_id, user_id, agent_name)
|
||||
|
||||
def add(
|
||||
self,
|
||||
thread_id: str,
|
||||
messages: list[Any],
|
||||
agent_name: str | None = None,
|
||||
user_id: str | None = None,
|
||||
correction_detected: bool = False,
|
||||
reinforcement_detected: bool = False,
|
||||
) -> None:
|
||||
"""Add a conversation to the update queue (with debounce)."""
|
||||
if not _memory_enabled():
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
self._enqueue_locked(
|
||||
thread_id=thread_id,
|
||||
messages=messages,
|
||||
agent_name=agent_name,
|
||||
user_id=user_id,
|
||||
correction_detected=correction_detected,
|
||||
reinforcement_detected=reinforcement_detected,
|
||||
)
|
||||
self._reset_timer()
|
||||
|
||||
logger.info("V1 memory update queued for thread %s, queue size: %d", thread_id, len(self._queue))
|
||||
|
||||
def add_nowait(
|
||||
self,
|
||||
thread_id: str,
|
||||
messages: list[Any],
|
||||
agent_name: str | None = None,
|
||||
user_id: str | None = None,
|
||||
correction_detected: bool = False,
|
||||
reinforcement_detected: bool = False,
|
||||
) -> None:
|
||||
"""Add a conversation and start processing immediately in the background."""
|
||||
if not _memory_enabled():
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
self._enqueue_locked(
|
||||
thread_id=thread_id,
|
||||
messages=messages,
|
||||
agent_name=agent_name,
|
||||
user_id=user_id,
|
||||
correction_detected=correction_detected,
|
||||
reinforcement_detected=reinforcement_detected,
|
||||
)
|
||||
self._schedule_timer(0)
|
||||
|
||||
logger.info("V1 memory update queued for immediate processing on thread %s, queue size: %d", thread_id, len(self._queue))
|
||||
|
||||
def _enqueue_locked(
|
||||
self,
|
||||
*,
|
||||
thread_id: str,
|
||||
messages: list[Any],
|
||||
agent_name: str | None,
|
||||
user_id: str | None,
|
||||
correction_detected: bool,
|
||||
reinforcement_detected: bool,
|
||||
) -> None:
|
||||
queue_key = self._queue_key(thread_id, user_id, agent_name)
|
||||
|
||||
existing = next(
|
||||
(ctx for ctx in self._queue if self._queue_key(ctx.thread_id, ctx.user_id, ctx.agent_name) == queue_key),
|
||||
None,
|
||||
)
|
||||
|
||||
merged_correction = correction_detected or (existing.correction_detected if existing is not None else False)
|
||||
merged_reinforcement = reinforcement_detected or (existing.reinforcement_detected if existing is not None else False)
|
||||
|
||||
context = ConversationContext(
|
||||
thread_id=thread_id,
|
||||
messages=messages,
|
||||
agent_name=agent_name,
|
||||
user_id=user_id,
|
||||
correction_detected=merged_correction,
|
||||
reinforcement_detected=merged_reinforcement,
|
||||
)
|
||||
|
||||
self._queue = [ctx for ctx in self._queue if self._queue_key(ctx.thread_id, ctx.user_id, ctx.agent_name) != queue_key]
|
||||
self._queue.append(context)
|
||||
|
||||
def _reset_timer(self) -> None:
|
||||
cfg = _get_v1_cfg()
|
||||
self._schedule_timer(cfg.debounce_seconds)
|
||||
logger.debug("V1 memory update timer set for %ss", cfg.debounce_seconds)
|
||||
|
||||
def _schedule_timer(self, delay_seconds: float) -> None:
|
||||
if self._timer is not None:
|
||||
self._timer.cancel()
|
||||
self._timer = threading.Timer(delay_seconds, self._process_queue)
|
||||
self._timer.daemon = True
|
||||
self._timer.start()
|
||||
|
||||
def _process_queue(self) -> None:
|
||||
from deerflow.agents.memory.v1.updater import MemoryUpdater
|
||||
|
||||
with self._lock:
|
||||
if self._processing:
|
||||
self._schedule_timer(0)
|
||||
return
|
||||
if not self._queue:
|
||||
return
|
||||
self._processing = True
|
||||
contexts_to_process = self._queue.copy()
|
||||
self._queue.clear()
|
||||
self._timer = None
|
||||
|
||||
logger.info("V1 memory: processing %d queued updates", len(contexts_to_process))
|
||||
|
||||
try:
|
||||
updater = MemoryUpdater()
|
||||
for context in contexts_to_process:
|
||||
try:
|
||||
logger.info("V1 memory: updating for thread %s", context.thread_id)
|
||||
success = updater.update_memory(
|
||||
messages=context.messages,
|
||||
thread_id=context.thread_id,
|
||||
agent_name=context.agent_name,
|
||||
correction_detected=context.correction_detected,
|
||||
reinforcement_detected=context.reinforcement_detected,
|
||||
user_id=context.user_id,
|
||||
)
|
||||
if success:
|
||||
logger.info("V1 memory: updated successfully for thread %s", context.thread_id)
|
||||
else:
|
||||
logger.warning("V1 memory: update skipped/failed for thread %s", context.thread_id)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error("V1 memory: error updating for thread %s: %s", context.thread_id, e)
|
||||
|
||||
if len(contexts_to_process) > 1:
|
||||
time.sleep(0.5)
|
||||
finally:
|
||||
with self._lock:
|
||||
self._processing = False
|
||||
|
||||
def flush(self) -> None:
|
||||
"""Force immediate processing of the queue."""
|
||||
with self._lock:
|
||||
if self._timer is not None:
|
||||
self._timer.cancel()
|
||||
self._timer = None
|
||||
self._process_queue()
|
||||
|
||||
def flush_nowait(self) -> None:
|
||||
"""Start queue processing immediately in a background thread."""
|
||||
with self._lock:
|
||||
self._schedule_timer(0)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear the queue without processing (for testing)."""
|
||||
with self._lock:
|
||||
if self._timer is not None:
|
||||
self._timer.cancel()
|
||||
self._timer = None
|
||||
self._queue.clear()
|
||||
self._processing = False
|
||||
|
||||
@property
|
||||
def pending_count(self) -> int:
|
||||
with self._lock:
|
||||
return len(self._queue)
|
||||
|
||||
@property
|
||||
def is_processing(self) -> bool:
|
||||
with self._lock:
|
||||
return self._processing
|
||||
|
||||
|
||||
_memory_queue: MemoryUpdateQueue | None = None
|
||||
_queue_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_memory_queue() -> MemoryUpdateQueue:
|
||||
"""Get the global memory update queue singleton."""
|
||||
global _memory_queue
|
||||
with _queue_lock:
|
||||
if _memory_queue is None:
|
||||
_memory_queue = MemoryUpdateQueue()
|
||||
return _memory_queue
|
||||
|
||||
|
||||
def reset_memory_queue() -> None:
|
||||
"""Reset the global memory queue (for testing)."""
|
||||
global _memory_queue
|
||||
with _queue_lock:
|
||||
if _memory_queue is not None:
|
||||
_memory_queue.clear()
|
||||
_memory_queue = None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ConversationContext",
|
||||
"MemoryUpdateQueue",
|
||||
"get_memory_queue",
|
||||
"reset_memory_queue",
|
||||
]
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
"""Memory V1 — 存储层(原版 DeerFlow 格式)。
|
||||
|
||||
JSON 结构:
|
||||
{
|
||||
"version": "1.0",
|
||||
"lastUpdated": "...",
|
||||
"user": {
|
||||
"workContext": {"summary": "", "updatedAt": ""},
|
||||
"personalContext": {"summary": "", "updatedAt": ""},
|
||||
"topOfMind": {"summary": "", "updatedAt": ""}
|
||||
},
|
||||
"history": {
|
||||
"recentMonths": {"summary": "", "updatedAt": ""},
|
||||
"earlierContext": {"summary": "", "updatedAt": ""},
|
||||
"longTermBackground":{"summary": "", "updatedAt": ""}
|
||||
},
|
||||
"facts": []
|
||||
}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from deerflow.config.agents_config import AGENT_NAME_PATTERN
|
||||
from deerflow.config.paths import get_paths
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def utc_now_iso_z() -> str:
|
||||
"""Current UTC time as ISO-8601 with ``Z`` suffix."""
|
||||
return datetime.now(UTC).isoformat().removesuffix("+00:00") + "Z"
|
||||
|
||||
|
||||
def create_empty_memory() -> dict[str, Any]:
|
||||
"""Create an empty memory structure (new format)."""
|
||||
return {
|
||||
"version": "1.0",
|
||||
"lastUpdated": utc_now_iso_z(),
|
||||
"user": {
|
||||
"workContext": {"summary": "", "updatedAt": ""},
|
||||
"personalContext": {"summary": "", "updatedAt": ""},
|
||||
"topOfMind": {"summary": "", "updatedAt": ""},
|
||||
},
|
||||
"history": {
|
||||
"recentMonths": {"summary": "", "updatedAt": ""},
|
||||
"earlierContext": {"summary": "", "updatedAt": ""},
|
||||
"longTermBackground": {"summary": "", "updatedAt": ""},
|
||||
},
|
||||
"facts": [],
|
||||
}
|
||||
|
||||
|
||||
def _migrate_legacy(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""把老格式 {userContext: {workContext: str}} 迁移到新格式 {user: {workContext: {summary, updatedAt}}}。"""
|
||||
if "userContext" not in data:
|
||||
return data
|
||||
|
||||
now = utc_now_iso_z()
|
||||
old_ctx = data.pop("userContext", {})
|
||||
data["user"] = {
|
||||
k: {"summary": v or "", "updatedAt": now} if isinstance(v, str) else v
|
||||
for k, v in old_ctx.items()
|
||||
}
|
||||
|
||||
# history 字段可能是纯字符串,需要包裹
|
||||
if "history" in data:
|
||||
hist = data["history"]
|
||||
data["history"] = {
|
||||
k: {"summary": v or "", "updatedAt": now} if isinstance(v, str) else v
|
||||
for k, v in hist.items()
|
||||
}
|
||||
|
||||
# 确保 user 包含所有必要字段
|
||||
base = create_empty_memory()
|
||||
for key in ("user", "history"):
|
||||
section = base[key]
|
||||
for field in section:
|
||||
if field not in data.get(key, {}):
|
||||
data.setdefault(key, {})[field] = section[field]
|
||||
|
||||
data["version"] = "1.0"
|
||||
data.pop("updatedAt", None)
|
||||
return data
|
||||
|
||||
|
||||
class FileMemoryStorage:
|
||||
"""File-based memory storage provider (原版 DeerFlow 实现)。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# Per-(user_id, agent_name) cache: value = (memory_data, file_mtime)
|
||||
self._memory_cache: dict[tuple[str | None, str | None], tuple[dict[str, Any], float | None]] = {}
|
||||
self._cache_lock = threading.Lock()
|
||||
|
||||
def _validate_agent_name(self, agent_name: str) -> None:
|
||||
if not agent_name:
|
||||
raise ValueError("Agent name must be a non-empty string.")
|
||||
if not AGENT_NAME_PATTERN.match(agent_name):
|
||||
raise ValueError(f"Invalid agent name {agent_name!r}: names must match {AGENT_NAME_PATTERN.pattern}")
|
||||
|
||||
def _get_memory_file_path(self, agent_name: str | None = None, *, user_id: str | None = None) -> Path:
|
||||
paths = get_paths()
|
||||
if user_id is not None:
|
||||
if agent_name is not None:
|
||||
self._validate_agent_name(agent_name)
|
||||
return paths.user_agent_memory_file(user_id, agent_name)
|
||||
return paths.user_memory_file(user_id)
|
||||
# Legacy: no user_id
|
||||
if agent_name is not None:
|
||||
self._validate_agent_name(agent_name)
|
||||
return paths.agent_memory_file(agent_name)
|
||||
return paths.memory_file
|
||||
|
||||
def _load_from_file(self, agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]:
|
||||
file_path = self._get_memory_file_path(agent_name, user_id=user_id)
|
||||
if not file_path.exists():
|
||||
return create_empty_memory()
|
||||
try:
|
||||
with open(file_path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if not isinstance(data, dict):
|
||||
return create_empty_memory()
|
||||
data = _migrate_legacy(data)
|
||||
return data
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.warning("Failed to load memory file %s: %s", file_path, e)
|
||||
return create_empty_memory()
|
||||
|
||||
@staticmethod
|
||||
def _cache_key(agent_name: str | None = None, *, user_id: str | None = None) -> tuple[str | None, str | None]:
|
||||
return (user_id, agent_name)
|
||||
|
||||
def load(self, agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]:
|
||||
"""Load memory data (cached with file modification time check)."""
|
||||
file_path = self._get_memory_file_path(agent_name, user_id=user_id)
|
||||
cache_key = self._cache_key(agent_name, user_id=user_id)
|
||||
|
||||
try:
|
||||
current_mtime = file_path.stat().st_mtime if file_path.exists() else None
|
||||
except OSError:
|
||||
current_mtime = None
|
||||
|
||||
with self._cache_lock:
|
||||
cached = self._memory_cache.get(cache_key)
|
||||
if cached is not None and cached[1] == current_mtime:
|
||||
return cached[0]
|
||||
|
||||
memory_data = self._load_from_file(agent_name, user_id=user_id)
|
||||
|
||||
with self._cache_lock:
|
||||
self._memory_cache[cache_key] = (memory_data, current_mtime)
|
||||
|
||||
return memory_data
|
||||
|
||||
def reload(self, agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]:
|
||||
"""Reload memory data from file, forcing cache invalidation."""
|
||||
file_path = self._get_memory_file_path(agent_name, user_id=user_id)
|
||||
memory_data = self._load_from_file(agent_name, user_id=user_id)
|
||||
cache_key = self._cache_key(agent_name, user_id=user_id)
|
||||
|
||||
try:
|
||||
mtime = file_path.stat().st_mtime if file_path.exists() else None
|
||||
except OSError:
|
||||
mtime = None
|
||||
|
||||
with self._cache_lock:
|
||||
self._memory_cache[cache_key] = (memory_data, mtime)
|
||||
return memory_data
|
||||
|
||||
def save(self, memory_data: dict[str, Any], agent_name: str | None = None, *, user_id: str | None = None) -> bool:
|
||||
"""Save memory data to file and update cache (atomic write)."""
|
||||
file_path = self._get_memory_file_path(agent_name, user_id=user_id)
|
||||
cache_key = self._cache_key(agent_name, user_id=user_id)
|
||||
|
||||
try:
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
memory_data = {**memory_data, "lastUpdated": utc_now_iso_z()}
|
||||
|
||||
temp_path = file_path.with_suffix(f".{uuid.uuid4().hex}.tmp")
|
||||
with open(temp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(memory_data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
temp_path.replace(file_path)
|
||||
|
||||
try:
|
||||
mtime = file_path.stat().st_mtime
|
||||
except OSError:
|
||||
mtime = None
|
||||
|
||||
with self._cache_lock:
|
||||
self._memory_cache[cache_key] = (memory_data, mtime)
|
||||
logger.info("Memory saved to %s", file_path)
|
||||
return True
|
||||
except OSError as e:
|
||||
logger.error("Failed to save memory file: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
_storage_instance: FileMemoryStorage | None = None
|
||||
_storage_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_memory_storage() -> FileMemoryStorage:
|
||||
"""Get the global FileMemoryStorage singleton."""
|
||||
global _storage_instance
|
||||
if _storage_instance is not None:
|
||||
return _storage_instance
|
||||
with _storage_lock:
|
||||
if _storage_instance is None:
|
||||
_storage_instance = FileMemoryStorage()
|
||||
return _storage_instance
|
||||
|
||||
|
||||
__all__ = [
|
||||
"create_empty_memory",
|
||||
"utc_now_iso_z",
|
||||
"FileMemoryStorage",
|
||||
"get_memory_storage",
|
||||
]
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
"""Hooks fired before summarization removes messages from state(原版 DeerFlow)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from deerflow.agents.memory.message_processing import detect_correction, detect_reinforcement, filter_messages_for_memory
|
||||
from deerflow.agents.memory.v1.queue import get_memory_queue
|
||||
from deerflow.config.memory_config import get_memory_config
|
||||
|
||||
|
||||
def memory_flush_hook(event: object) -> None:
|
||||
"""Flush messages about to be summarized into the V1 memory queue.
|
||||
|
||||
``event`` is expected to be a ``SummarizationEvent``-like object with:
|
||||
- ``thread_id: str``
|
||||
- ``messages_to_summarize: list``
|
||||
- ``agent_name: str | None``
|
||||
- ``runtime: RunnableConfig``
|
||||
"""
|
||||
if not get_memory_config().enabled:
|
||||
return
|
||||
|
||||
thread_id = getattr(event, "thread_id", None)
|
||||
if not thread_id:
|
||||
return
|
||||
|
||||
messages_to_summarize = getattr(event, "messages_to_summarize", [])
|
||||
filtered_messages = filter_messages_for_memory(list(messages_to_summarize))
|
||||
user_messages = [m for m in filtered_messages if getattr(m, "type", None) == "human"]
|
||||
assistant_messages = [m for m in filtered_messages if getattr(m, "type", None) == "ai"]
|
||||
if not user_messages or not assistant_messages:
|
||||
return
|
||||
|
||||
correction_detected = detect_correction(filtered_messages)
|
||||
reinforcement_detected = not correction_detected and detect_reinforcement(filtered_messages)
|
||||
|
||||
try:
|
||||
from deerflow.runtime.user_context import resolve_runtime_user_id
|
||||
runtime = getattr(event, "runtime", None)
|
||||
user_id = resolve_runtime_user_id(runtime) if runtime is not None else None
|
||||
except Exception: # noqa: BLE001
|
||||
user_id = None
|
||||
|
||||
agent_name = getattr(event, "agent_name", None)
|
||||
queue = get_memory_queue()
|
||||
queue.add_nowait(
|
||||
thread_id=thread_id,
|
||||
messages=filtered_messages,
|
||||
agent_name=agent_name,
|
||||
user_id=user_id,
|
||||
correction_detected=correction_detected,
|
||||
reinforcement_detected=reinforcement_detected,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["memory_flush_hook"]
|
||||
+495
@@ -0,0 +1,495 @@
|
||||
"""Memory V1 — updater(原版 DeerFlow MemoryUpdater)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import atexit
|
||||
import concurrent.futures
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from deerflow.agents.memory.v1.prompt import (
|
||||
MEMORY_UPDATE_PROMPT,
|
||||
format_conversation_for_update,
|
||||
)
|
||||
from deerflow.agents.memory.v1.storage import (
|
||||
create_empty_memory,
|
||||
get_memory_storage,
|
||||
utc_now_iso_z,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SYNC_MEMORY_UPDATER_EXECUTOR = concurrent.futures.ThreadPoolExecutor(
|
||||
max_workers=4,
|
||||
thread_name_prefix="v1-memory-updater-sync",
|
||||
)
|
||||
atexit.register(lambda: _SYNC_MEMORY_UPDATER_EXECUTOR.shutdown(wait=False))
|
||||
|
||||
|
||||
def _get_v1_cfg():
|
||||
"""返回 V1MemoryConfig(含 max_facts, fact_confidence_threshold, model_name)。"""
|
||||
from deerflow.config.memory_config import get_memory_config
|
||||
return get_memory_config().v1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 向后兼容的便捷函数(供 gateway router 等调用)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_memory_data(agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]:
|
||||
return get_memory_storage().load(agent_name, user_id=user_id)
|
||||
|
||||
|
||||
def reload_memory_data(agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]:
|
||||
return get_memory_storage().reload(agent_name, user_id=user_id)
|
||||
|
||||
|
||||
def import_memory_data(memory_data: dict[str, Any], agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]:
|
||||
storage = get_memory_storage()
|
||||
if not storage.save(memory_data, agent_name, user_id=user_id):
|
||||
raise OSError("Failed to save imported memory data")
|
||||
return storage.load(agent_name, user_id=user_id)
|
||||
|
||||
|
||||
def clear_memory_data(agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]:
|
||||
cleared = create_empty_memory()
|
||||
if not get_memory_storage().save(cleared, agent_name, user_id=user_id):
|
||||
raise OSError("Failed to save cleared memory data")
|
||||
return cleared
|
||||
|
||||
|
||||
def _validate_confidence(confidence: float) -> float:
|
||||
if not math.isfinite(confidence) or confidence < 0 or confidence > 1:
|
||||
raise ValueError("confidence")
|
||||
return confidence
|
||||
|
||||
|
||||
def create_memory_fact(
|
||||
content: str,
|
||||
category: str = "context",
|
||||
confidence: float = 0.5,
|
||||
agent_name: str | None = None,
|
||||
*,
|
||||
user_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
normalized_content = content.strip()
|
||||
if not normalized_content:
|
||||
raise ValueError("content")
|
||||
normalized_category = category.strip() or "context"
|
||||
validated_confidence = _validate_confidence(confidence)
|
||||
now = utc_now_iso_z()
|
||||
memory_data = get_memory_data(agent_name, user_id=user_id)
|
||||
updated_memory = dict(memory_data)
|
||||
facts = list(memory_data.get("facts", []))
|
||||
facts.append({
|
||||
"id": f"fact_{uuid.uuid4().hex[:8]}",
|
||||
"content": normalized_content,
|
||||
"category": normalized_category,
|
||||
"confidence": validated_confidence,
|
||||
"createdAt": now,
|
||||
"source": "manual",
|
||||
})
|
||||
updated_memory["facts"] = facts
|
||||
if not get_memory_storage().save(updated_memory, agent_name, user_id=user_id):
|
||||
raise OSError("Failed to save memory data after creating fact")
|
||||
return updated_memory
|
||||
|
||||
|
||||
def delete_memory_fact(fact_id: str, agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]:
|
||||
memory_data = get_memory_data(agent_name, user_id=user_id)
|
||||
facts = memory_data.get("facts", [])
|
||||
updated_facts = [f for f in facts if f.get("id") != fact_id]
|
||||
if len(updated_facts) == len(facts):
|
||||
raise KeyError(fact_id)
|
||||
updated_memory = dict(memory_data)
|
||||
updated_memory["facts"] = updated_facts
|
||||
if not get_memory_storage().save(updated_memory, agent_name, user_id=user_id):
|
||||
raise OSError(f"Failed to save memory data after deleting fact '{fact_id}'")
|
||||
return updated_memory
|
||||
|
||||
|
||||
def update_memory_fact(
|
||||
fact_id: str,
|
||||
content: str | None = None,
|
||||
category: str | None = None,
|
||||
confidence: float | None = None,
|
||||
agent_name: str | None = None,
|
||||
*,
|
||||
user_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
memory_data = get_memory_data(agent_name, user_id=user_id)
|
||||
updated_memory = dict(memory_data)
|
||||
updated_facts: list[dict[str, Any]] = []
|
||||
found = False
|
||||
for fact in memory_data.get("facts", []):
|
||||
if fact.get("id") == fact_id:
|
||||
found = True
|
||||
updated_fact = dict(fact)
|
||||
if content is not None:
|
||||
normalized = content.strip()
|
||||
if not normalized:
|
||||
raise ValueError("content")
|
||||
updated_fact["content"] = normalized
|
||||
if category is not None:
|
||||
updated_fact["category"] = category.strip() or "context"
|
||||
if confidence is not None:
|
||||
updated_fact["confidence"] = _validate_confidence(confidence)
|
||||
updated_facts.append(updated_fact)
|
||||
else:
|
||||
updated_facts.append(fact)
|
||||
if not found:
|
||||
raise KeyError(fact_id)
|
||||
updated_memory["facts"] = updated_facts
|
||||
if not get_memory_storage().save(updated_memory, agent_name, user_id=user_id):
|
||||
raise OSError(f"Failed to save memory data after updating fact '{fact_id}'")
|
||||
return updated_memory
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 内容提取辅助
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _extract_text(content: Any) -> str:
|
||||
"""从 LLM response content(str 或 list of blocks)提取纯文本。"""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
pieces: list[str] = []
|
||||
pending: list[str] = []
|
||||
|
||||
def flush():
|
||||
if pending:
|
||||
pieces.append("".join(pending))
|
||||
pending.clear()
|
||||
|
||||
for block in content:
|
||||
if isinstance(block, str):
|
||||
pending.append(block)
|
||||
elif isinstance(block, dict):
|
||||
flush()
|
||||
text_val = block.get("text")
|
||||
if isinstance(text_val, str):
|
||||
pieces.append(text_val)
|
||||
flush()
|
||||
return "\n".join(pieces)
|
||||
return str(content)
|
||||
|
||||
|
||||
_UPLOAD_SENTENCE_RE = re.compile(
|
||||
r"[^.!?]*\b(?:"
|
||||
r"upload(?:ed|ing)?(?:\s+\w+){0,3}\s+(?:file|files?|document|documents?|attachment|attachments?)"
|
||||
r"|file\s+upload"
|
||||
r"|/mnt/user-data/uploads/"
|
||||
r"|<uploaded_files>"
|
||||
r")[^.!?]*[.!?]?\s*",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _strip_upload_mentions_from_memory(memory_data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""移除记忆中关于文件上传事件的句子(上传文件是会话级临时的)。"""
|
||||
for section in ("user", "history"):
|
||||
section_data = memory_data.get(section, {})
|
||||
for _key, val in section_data.items():
|
||||
if isinstance(val, dict) and "summary" in val:
|
||||
cleaned = _UPLOAD_SENTENCE_RE.sub("", val["summary"]).strip()
|
||||
cleaned = re.sub(r" +", " ", cleaned)
|
||||
val["summary"] = cleaned
|
||||
|
||||
facts = memory_data.get("facts", [])
|
||||
if facts:
|
||||
memory_data["facts"] = [f for f in facts if not _UPLOAD_SENTENCE_RE.search(f.get("content", ""))]
|
||||
return memory_data
|
||||
|
||||
|
||||
def _fact_content_key(content: Any) -> str | None:
|
||||
if not isinstance(content, str):
|
||||
return None
|
||||
stripped = content.strip()
|
||||
if not stripped:
|
||||
return None
|
||||
return stripped.casefold()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MemoryUpdater(原版核心类)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class MemoryUpdater:
|
||||
"""Updates memory using LLM based on conversation context."""
|
||||
|
||||
def __init__(self, model_name: str | None = None) -> None:
|
||||
self._model_name = model_name
|
||||
|
||||
def _get_model(self):
|
||||
from deerflow.models import create_chat_model
|
||||
cfg = _get_v1_cfg()
|
||||
model_name = self._model_name or cfg.model_name
|
||||
return create_chat_model(name=model_name or None, thinking_enabled=False)
|
||||
|
||||
def _build_correction_hint(self, correction_detected: bool, reinforcement_detected: bool) -> str:
|
||||
correction_hint = ""
|
||||
if correction_detected:
|
||||
correction_hint = (
|
||||
"IMPORTANT: Explicit correction signals were detected in this conversation. "
|
||||
"Pay special attention to what the agent got wrong, what the user corrected, "
|
||||
"and record the correct approach as a fact with category "
|
||||
'"correction" and confidence >= 0.95 when appropriate.'
|
||||
)
|
||||
if reinforcement_detected:
|
||||
reinforcement_hint = (
|
||||
"IMPORTANT: Positive reinforcement signals were detected in this conversation. "
|
||||
"The user explicitly confirmed the agent's approach was correct or helpful. "
|
||||
"Record the confirmed approach, style, or preference as a fact with category "
|
||||
'"preference" or "behavior" and confidence >= 0.9 when appropriate.'
|
||||
)
|
||||
correction_hint = (correction_hint + "\n" + reinforcement_hint).strip() if correction_hint else reinforcement_hint
|
||||
return correction_hint
|
||||
|
||||
def _prepare_update_prompt(
|
||||
self,
|
||||
messages: list[Any],
|
||||
agent_name: str | None,
|
||||
correction_detected: bool,
|
||||
reinforcement_detected: bool,
|
||||
user_id: str | None = None,
|
||||
) -> tuple[dict[str, Any], str] | None:
|
||||
from deerflow.config.memory_config import get_memory_config
|
||||
config = get_memory_config()
|
||||
if not config.enabled or not messages:
|
||||
return None
|
||||
|
||||
current_memory = get_memory_data(agent_name, user_id=user_id)
|
||||
conversation_text = format_conversation_for_update(messages)
|
||||
if not conversation_text.strip():
|
||||
return None
|
||||
|
||||
correction_hint = self._build_correction_hint(correction_detected, reinforcement_detected)
|
||||
prompt = MEMORY_UPDATE_PROMPT.format(
|
||||
current_memory=json.dumps(current_memory, indent=2, ensure_ascii=False),
|
||||
conversation=conversation_text,
|
||||
correction_hint=correction_hint,
|
||||
)
|
||||
return current_memory, prompt
|
||||
|
||||
def _finalize_update(
|
||||
self,
|
||||
current_memory: dict[str, Any],
|
||||
response_content: Any,
|
||||
thread_id: str | None,
|
||||
agent_name: str | None,
|
||||
user_id: str | None = None,
|
||||
) -> bool:
|
||||
response_text = _extract_text(response_content).strip()
|
||||
if response_text.startswith("```"):
|
||||
lines = response_text.split("\n")
|
||||
response_text = "\n".join(lines[1:-1] if lines[-1] == "```" else lines[1:])
|
||||
|
||||
update_data = json.loads(response_text)
|
||||
updated_memory = self._apply_updates(copy.deepcopy(current_memory), update_data, thread_id)
|
||||
updated_memory = _strip_upload_mentions_from_memory(updated_memory)
|
||||
return get_memory_storage().save(updated_memory, agent_name, user_id=user_id)
|
||||
|
||||
async def aupdate_memory(
|
||||
self,
|
||||
messages: list[Any],
|
||||
thread_id: str | None = None,
|
||||
agent_name: str | None = None,
|
||||
correction_detected: bool = False,
|
||||
reinforcement_detected: bool = False,
|
||||
user_id: str | None = None,
|
||||
) -> bool:
|
||||
return await asyncio.to_thread(
|
||||
self._do_update_memory_sync,
|
||||
messages=messages,
|
||||
thread_id=thread_id,
|
||||
agent_name=agent_name,
|
||||
correction_detected=correction_detected,
|
||||
reinforcement_detected=reinforcement_detected,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
def _do_update_memory_sync(
|
||||
self,
|
||||
messages: list[Any],
|
||||
thread_id: str | None = None,
|
||||
agent_name: str | None = None,
|
||||
correction_detected: bool = False,
|
||||
reinforcement_detected: bool = False,
|
||||
user_id: str | None = None,
|
||||
) -> bool:
|
||||
try:
|
||||
prepared = self._prepare_update_prompt(
|
||||
messages=messages,
|
||||
agent_name=agent_name,
|
||||
correction_detected=correction_detected,
|
||||
reinforcement_detected=reinforcement_detected,
|
||||
user_id=user_id,
|
||||
)
|
||||
if prepared is None:
|
||||
return False
|
||||
|
||||
current_memory, prompt = prepared
|
||||
model = self._get_model()
|
||||
response = model.invoke(prompt, config={"run_name": "v1_memory_agent"})
|
||||
return self._finalize_update(
|
||||
current_memory=current_memory,
|
||||
response_content=response.content,
|
||||
thread_id=thread_id,
|
||||
agent_name=agent_name,
|
||||
user_id=user_id,
|
||||
)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning("V1 memory: failed to parse LLM response: %s", e)
|
||||
return False
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.exception("V1 memory update failed: %s", e)
|
||||
return False
|
||||
|
||||
def update_memory(
|
||||
self,
|
||||
messages: list[Any],
|
||||
thread_id: str | None = None,
|
||||
agent_name: str | None = None,
|
||||
correction_detected: bool = False,
|
||||
reinforcement_detected: bool = False,
|
||||
user_id: str | None = None,
|
||||
) -> bool:
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
loop = None
|
||||
|
||||
if loop is not None and loop.is_running():
|
||||
try:
|
||||
future = _SYNC_MEMORY_UPDATER_EXECUTOR.submit(
|
||||
self._do_update_memory_sync,
|
||||
messages=messages,
|
||||
thread_id=thread_id,
|
||||
agent_name=agent_name,
|
||||
correction_detected=correction_detected,
|
||||
reinforcement_detected=reinforcement_detected,
|
||||
user_id=user_id,
|
||||
)
|
||||
return future.result()
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("V1 memory: failed to offload update to executor")
|
||||
return False
|
||||
|
||||
return self._do_update_memory_sync(
|
||||
messages=messages,
|
||||
thread_id=thread_id,
|
||||
agent_name=agent_name,
|
||||
correction_detected=correction_detected,
|
||||
reinforcement_detected=reinforcement_detected,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
def _apply_updates(
|
||||
self,
|
||||
current_memory: dict[str, Any],
|
||||
update_data: dict[str, Any],
|
||||
thread_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
cfg = _get_v1_cfg()
|
||||
now = utc_now_iso_z()
|
||||
|
||||
# 更新 user context 段
|
||||
user_updates = update_data.get("user", {})
|
||||
for section in ("workContext", "personalContext", "topOfMind"):
|
||||
section_data = user_updates.get(section, {})
|
||||
if section_data.get("shouldUpdate") and section_data.get("summary"):
|
||||
current_memory.setdefault("user", {})[section] = {
|
||||
"summary": section_data["summary"],
|
||||
"updatedAt": now,
|
||||
}
|
||||
|
||||
# 更新 history 段
|
||||
history_updates = update_data.get("history", {})
|
||||
for section in ("recentMonths", "earlierContext", "longTermBackground"):
|
||||
section_data = history_updates.get(section, {})
|
||||
if section_data.get("shouldUpdate") and section_data.get("summary"):
|
||||
current_memory.setdefault("history", {})[section] = {
|
||||
"summary": section_data["summary"],
|
||||
"updatedAt": now,
|
||||
}
|
||||
|
||||
# 删除事实
|
||||
facts_to_remove = set(update_data.get("factsToRemove", []))
|
||||
if facts_to_remove:
|
||||
current_memory["facts"] = [f for f in current_memory.get("facts", []) if f.get("id") not in facts_to_remove]
|
||||
|
||||
# 新增事实(去重 + 置信度过滤)
|
||||
existing_keys = {
|
||||
key for key in (_fact_content_key(f.get("content")) for f in current_memory.get("facts", []))
|
||||
if key is not None
|
||||
}
|
||||
|
||||
for fact in update_data.get("newFacts", []):
|
||||
confidence = fact.get("confidence", 0.5)
|
||||
if confidence < cfg.fact_confidence_threshold:
|
||||
continue
|
||||
raw_content = fact.get("content", "")
|
||||
if not isinstance(raw_content, str):
|
||||
continue
|
||||
normalized_content = raw_content.strip()
|
||||
fact_key = _fact_content_key(normalized_content)
|
||||
if fact_key is not None and fact_key in existing_keys:
|
||||
continue
|
||||
|
||||
fact_entry: dict[str, Any] = {
|
||||
"id": f"fact_{uuid.uuid4().hex[:8]}",
|
||||
"content": normalized_content,
|
||||
"category": fact.get("category", "context"),
|
||||
"confidence": confidence,
|
||||
"createdAt": now,
|
||||
"source": thread_id or "unknown",
|
||||
}
|
||||
source_error = fact.get("sourceError")
|
||||
if isinstance(source_error, str) and source_error.strip():
|
||||
fact_entry["sourceError"] = source_error.strip()
|
||||
|
||||
current_memory["facts"].append(fact_entry)
|
||||
if fact_key is not None:
|
||||
existing_keys.add(fact_key)
|
||||
|
||||
# 超出上限时驱逐置信度最低的事实
|
||||
if len(current_memory["facts"]) > cfg.max_facts:
|
||||
current_memory["facts"] = sorted(
|
||||
current_memory["facts"],
|
||||
key=lambda f: f.get("confidence", 0),
|
||||
reverse=True,
|
||||
)[:cfg.max_facts]
|
||||
|
||||
return current_memory
|
||||
|
||||
|
||||
def update_memory_from_conversation(
|
||||
messages: list[Any],
|
||||
thread_id: str | None = None,
|
||||
agent_name: str | None = None,
|
||||
correction_detected: bool = False,
|
||||
reinforcement_detected: bool = False,
|
||||
user_id: str | None = None,
|
||||
) -> bool:
|
||||
updater = MemoryUpdater()
|
||||
return updater.update_memory(messages, thread_id, agent_name, correction_detected, reinforcement_detected, user_id=user_id)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MemoryUpdater",
|
||||
"update_memory_from_conversation",
|
||||
"get_memory_data",
|
||||
"reload_memory_data",
|
||||
"import_memory_data",
|
||||
"clear_memory_data",
|
||||
"create_memory_fact",
|
||||
"delete_memory_fact",
|
||||
"update_memory_fact",
|
||||
]
|
||||
+5
-1
@@ -90,16 +90,18 @@ class MemoryMiddleware(AgentMiddleware[MemoryMiddlewareState]):
|
||||
|
||||
state_schema = MemoryMiddlewareState
|
||||
|
||||
def __init__(self, agent_name: str | None = None, *, memory_config: "MemoryConfig | None" = None):
|
||||
def __init__(self, agent_name: str | None = None, *, memory_config: "MemoryConfig | None" = None, injection_enabled: bool = True):
|
||||
"""初始化 MemoryMiddleware。
|
||||
|
||||
Args:
|
||||
agent_name: 提供时记忆按 agent 隔离;None 用默认 agent。
|
||||
memory_config: 显式记忆配置;省略时用全局配置。
|
||||
injection_enabled: 运行时开关;``False`` 时跳过 Hindsight 动态召回注入。
|
||||
"""
|
||||
super().__init__()
|
||||
self._agent_name = agent_name
|
||||
self._memory_config = memory_config
|
||||
self._injection_enabled = injection_enabled
|
||||
|
||||
# ---- 运行时上下文解析 -------------------------------------------------
|
||||
|
||||
@@ -133,6 +135,8 @@ class MemoryMiddleware(AgentMiddleware[MemoryMiddlewareState]):
|
||||
召回结果包在 ``<memory-context>`` 标签里并带系统提示,告诉模型这是
|
||||
背景信息而非用户的新输入。
|
||||
"""
|
||||
if not self._injection_enabled:
|
||||
return None
|
||||
config = self._memory_config or get_memory_config()
|
||||
if not config.enabled:
|
||||
return None
|
||||
|
||||
@@ -113,15 +113,30 @@ class SecurityConfig(BaseModel):
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class V1MemoryConfig(BaseModel):
|
||||
"""原版 DeerFlow V1 记忆 provider 配置 (memory.json + LLM 自动提取)。"""
|
||||
|
||||
max_facts: int = Field(default=100, ge=10, le=500, description="最多保留的事实条数")
|
||||
fact_confidence_threshold: float = Field(default=0.7, ge=0.0, le=1.0, description="事实置信度阈值,低于此值不保存")
|
||||
debounce_seconds: int = Field(default=30, ge=5, le=300, description="对话结束后延迟多少秒才触发 LLM 提取")
|
||||
model_name: str = Field(default="", description="用于事实提取的 LLM 模型名;空则继承全局默认模型")
|
||||
token_budget: int = Field(default=2000, ge=200, le=8000, description="注入系统提示词的 token 预算")
|
||||
|
||||
|
||||
class MemoryConfig(BaseModel):
|
||||
"""记忆子系统的全局配置 (config.yaml 的 ``memory:`` 段)。"""
|
||||
|
||||
enabled: bool = Field(default=True, description="记忆子系统总开关")
|
||||
version: str = Field(
|
||||
default="v2",
|
||||
description="记忆系统版本:v1(原版 DeerFlow,memory.json + LLM 自动提取) 或 v2(USER.md/MEMORY.md)",
|
||||
)
|
||||
provider: str = Field(
|
||||
default="builtin",
|
||||
description="启用的外部 provider 名:builtin(无外部) 或 hindsight",
|
||||
)
|
||||
|
||||
v1: V1MemoryConfig = Field(default_factory=V1MemoryConfig)
|
||||
builtin: BuiltinMemoryConfig = Field(default_factory=BuiltinMemoryConfig)
|
||||
hindsight: HindsightMemoryConfig = Field(default_factory=HindsightMemoryConfig)
|
||||
injection: InjectionConfig = Field(default_factory=InjectionConfig)
|
||||
@@ -288,6 +303,7 @@ async def delete_user_memory_override_db(user_id: str, field: str | None, store:
|
||||
|
||||
|
||||
__all__ = [
|
||||
"V1MemoryConfig",
|
||||
"BuiltinMemoryConfig",
|
||||
"HindsightMemoryConfig",
|
||||
"InjectionConfig",
|
||||
|
||||
+13
-1
@@ -82,7 +82,19 @@ def memory_tool(
|
||||
user_id = get_effective_user_id()
|
||||
config = get_effective_memory_config(user_id)
|
||||
|
||||
if not config.enabled or not config.builtin.enabled:
|
||||
if not config.enabled:
|
||||
return json.dumps(
|
||||
{"success": False, "error": "记忆功能未启用,可能在配置中被关闭。"},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
if getattr(config, "version", "v2") == "v1":
|
||||
return json.dumps(
|
||||
{"success": False, "error": "当前使用 V1 记忆模式,记忆由系统自动提取,不支持手动写入。"},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
if not config.builtin.enabled:
|
||||
return json.dumps(
|
||||
{"success": False, "error": "记忆功能未启用,可能在配置中被关闭。"},
|
||||
ensure_ascii=False,
|
||||
|
||||
Reference in new issue
Block a user