feat: 数据结构向df的memory.json对齐
This commit is contained in:
parent
7db468aa6f
commit
31daed1887
@ -407,8 +407,8 @@ def _get_memory_context(agent_name: str | None = None) -> str:
|
|||||||
if thread_memory is not None:
|
if thread_memory is not None:
|
||||||
thread_content = format_thread_memory_for_injection(
|
thread_content = format_thread_memory_for_injection(
|
||||||
{
|
{
|
||||||
"profile": thread_memory.get("profile", {}),
|
"user": thread_memory.get("user", {}),
|
||||||
"preferences": thread_memory.get("preferences", {}),
|
"history": thread_memory.get("history", {}),
|
||||||
"facts": thread_memory.get("facts", []),
|
"facts": thread_memory.get("facts", []),
|
||||||
},
|
},
|
||||||
max_tokens=thread_config.max_injection_tokens,
|
max_tokens=thread_config.max_injection_tokens,
|
||||||
|
|||||||
@ -21,18 +21,15 @@ Conversation:
|
|||||||
|
|
||||||
Return JSON only with this schema:
|
Return JSON only with this schema:
|
||||||
{{
|
{{
|
||||||
"profile": {{
|
"user": {{
|
||||||
"name": string|null,
|
"workContext": {{"summary": string, "updatedAt": string}},
|
||||||
"role": string|null,
|
"personalContext": {{"summary": string, "updatedAt": string}},
|
||||||
"expertise": string[],
|
"topOfMind": {{"summary": string, "updatedAt": string}}
|
||||||
"language": "zh-CN"|"en-US"|null,
|
|
||||||
"context": string|null
|
|
||||||
}},
|
}},
|
||||||
"preferences": {{
|
"history": {{
|
||||||
"tone": "casual"|"formal"|"technical"|"friendly"|null,
|
"recentMonths": {{"summary": string, "updatedAt": string}},
|
||||||
"verbosity": "concise"|"detailed"|null,
|
"earlierContext": {{"summary": string, "updatedAt": string}},
|
||||||
"codeStyle": string|null,
|
"longTermBackground": {{"summary": string, "updatedAt": string}}
|
||||||
"other": string|null
|
|
||||||
}},
|
}},
|
||||||
"facts": [
|
"facts": [
|
||||||
{{
|
{{
|
||||||
@ -53,8 +50,16 @@ Rules:
|
|||||||
|
|
||||||
def create_empty_thread_memory() -> dict[str, Any]:
|
def create_empty_thread_memory() -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"profile": {"name": None, "role": None, "expertise": [], "language": None, "context": None},
|
"user": {
|
||||||
"preferences": {"tone": None, "verbosity": None, "codeStyle": None, "other": None},
|
"workContext": {"summary": "", "updatedAt": ""},
|
||||||
|
"personalContext": {"summary": "", "updatedAt": ""},
|
||||||
|
"topOfMind": {"summary": "", "updatedAt": ""},
|
||||||
|
},
|
||||||
|
"history": {
|
||||||
|
"recentMonths": {"summary": "", "updatedAt": ""},
|
||||||
|
"earlierContext": {"summary": "", "updatedAt": ""},
|
||||||
|
"longTermBackground": {"summary": "", "updatedAt": ""},
|
||||||
|
},
|
||||||
"facts": [],
|
"facts": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,32 +68,31 @@ def format_thread_memory_for_injection(memory_data: dict[str, Any], max_tokens:
|
|||||||
if not memory_data:
|
if not memory_data:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
profile = memory_data.get("profile") or {}
|
user = memory_data.get("user") or {}
|
||||||
preferences = memory_data.get("preferences") or {}
|
history = memory_data.get("history") or {}
|
||||||
facts = memory_data.get("facts") or []
|
facts = memory_data.get("facts") or []
|
||||||
|
|
||||||
profile_lines: list[str] = []
|
user_lines: list[str] = []
|
||||||
for key, label in (("name", "Name"), ("role", "Role"), ("language", "Language"), ("context", "Context")):
|
for key, label in (("workContext", "Work Context"), ("personalContext", "Personal Context"), ("topOfMind", "Top Of Mind")):
|
||||||
value = profile.get(key)
|
section = user.get(key) if isinstance(user, dict) else None
|
||||||
if isinstance(value, str) and value.strip():
|
if isinstance(section, dict):
|
||||||
profile_lines.append(f"- {label}: {value.strip()}")
|
summary = section.get("summary")
|
||||||
expertise = profile.get("expertise")
|
if isinstance(summary, str) and summary.strip():
|
||||||
if isinstance(expertise, list):
|
user_lines.append(f"- {label}: {summary.strip()}")
|
||||||
cleaned = [str(item).strip() for item in expertise if str(item).strip()]
|
|
||||||
if cleaned:
|
|
||||||
profile_lines.append(f"- Expertise: {', '.join(cleaned)}")
|
|
||||||
|
|
||||||
pref_lines: list[str] = []
|
history_lines: list[str] = []
|
||||||
for key, label in (("tone", "Tone"), ("verbosity", "Verbosity"), ("codeStyle", "Code Style"), ("other", "Other")):
|
for key, label in (("recentMonths", "Recent Months"), ("earlierContext", "Earlier Context"), ("longTermBackground", "Long-Term Background")):
|
||||||
value = preferences.get(key)
|
section = history.get(key) if isinstance(history, dict) else None
|
||||||
if isinstance(value, str) and value.strip():
|
if isinstance(section, dict):
|
||||||
pref_lines.append(f"- {label}: {value.strip()}")
|
summary = section.get("summary")
|
||||||
|
if isinstance(summary, str) and summary.strip():
|
||||||
|
history_lines.append(f"- {label}: {summary.strip()}")
|
||||||
|
|
||||||
sections: list[str] = []
|
sections: list[str] = []
|
||||||
if profile_lines:
|
if user_lines:
|
||||||
sections.append("Profile:\n" + "\n".join(profile_lines))
|
sections.append("User:\n" + "\n".join(user_lines))
|
||||||
if pref_lines:
|
if history_lines:
|
||||||
sections.append("Preferences:\n" + "\n".join(pref_lines))
|
sections.append("History:\n" + "\n".join(history_lines))
|
||||||
|
|
||||||
# Facts are lowest priority: include by confidence/recency and trim by token budget.
|
# Facts are lowest priority: include by confidence/recency and trim by token budget.
|
||||||
ranked_facts = sorted(
|
ranked_facts = sorted(
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import logging
|
|||||||
import re
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import threading
|
import threading
|
||||||
from datetime import datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@ -40,13 +40,13 @@ def _memory_to_markdown(data: dict[str, Any]) -> str:
|
|||||||
return (
|
return (
|
||||||
"# Thread Memory\n\n"
|
"# Thread Memory\n\n"
|
||||||
f"Owner ID: {owner_text}\n\n"
|
f"Owner ID: {owner_text}\n\n"
|
||||||
"## Profile\n"
|
"## User\n"
|
||||||
"```json\n"
|
"```json\n"
|
||||||
f"{json.dumps(data.get('profile', {}), ensure_ascii=False, indent=2)}\n"
|
f"{json.dumps(data.get('user', {}), ensure_ascii=False, indent=2)}\n"
|
||||||
"```\n\n"
|
"```\n\n"
|
||||||
"## Preferences\n"
|
"## History\n"
|
||||||
"```json\n"
|
"```json\n"
|
||||||
f"{json.dumps(data.get('preferences', {}), ensure_ascii=False, indent=2)}\n"
|
f"{json.dumps(data.get('history', {}), ensure_ascii=False, indent=2)}\n"
|
||||||
"```\n\n"
|
"```\n\n"
|
||||||
"## Facts\n"
|
"## Facts\n"
|
||||||
"```json\n"
|
"```json\n"
|
||||||
@ -66,16 +66,16 @@ def _memory_from_markdown(markdown: str) -> dict[str, Any]:
|
|||||||
blocks = _JSON_FENCE_RE.findall(markdown)
|
blocks = _JSON_FENCE_RE.findall(markdown)
|
||||||
if len(blocks) >= 1:
|
if len(blocks) >= 1:
|
||||||
try:
|
try:
|
||||||
profile = json.loads(blocks[0])
|
user = json.loads(blocks[0])
|
||||||
if isinstance(profile, dict):
|
if isinstance(user, dict):
|
||||||
parsed["profile"] = profile
|
parsed["user"] = user
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if len(blocks) >= 2:
|
if len(blocks) >= 2:
|
||||||
try:
|
try:
|
||||||
preferences = json.loads(blocks[1])
|
history = json.loads(blocks[1])
|
||||||
if isinstance(preferences, dict):
|
if isinstance(history, dict):
|
||||||
parsed["preferences"] = preferences
|
parsed["history"] = history
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if len(blocks) >= 3:
|
if len(blocks) >= 3:
|
||||||
@ -90,26 +90,19 @@ def _memory_from_markdown(markdown: str) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def _row_to_memory(row: tuple[Any, ...]) -> dict[str, Any]:
|
def _row_to_memory(row: tuple[Any, ...]) -> dict[str, Any]:
|
||||||
memory_md = row[2]
|
decoded = _memory_from_markdown(row[2] if isinstance(row[2], str) else "")
|
||||||
if isinstance(memory_md, str) and memory_md.strip():
|
user = decoded.get("user", create_empty_thread_memory()["user"])
|
||||||
decoded = _memory_from_markdown(memory_md)
|
history = decoded.get("history", create_empty_thread_memory()["history"])
|
||||||
profile = decoded.get("profile", {})
|
|
||||||
preferences = decoded.get("preferences", {})
|
|
||||||
facts = decoded.get("facts", [])
|
facts = decoded.get("facts", [])
|
||||||
owner_id = decoded.get("ownerId")
|
owner_id = decoded.get("ownerId")
|
||||||
else:
|
|
||||||
owner_id = row[1]
|
|
||||||
profile = json.loads(row[3])
|
|
||||||
preferences = json.loads(row[4])
|
|
||||||
facts = json.loads(row[5])
|
|
||||||
return {
|
return {
|
||||||
"threadId": row[0],
|
"threadId": row[0],
|
||||||
"ownerId": owner_id,
|
"ownerId": row[1] if owner_id is None else owner_id,
|
||||||
"profile": profile,
|
"user": user,
|
||||||
"preferences": preferences,
|
"history": history,
|
||||||
"facts": facts,
|
"facts": facts,
|
||||||
"memoryVersion": int(row[6]),
|
"memoryVersion": int(row[3]),
|
||||||
"lastUpdated": str(row[7]),
|
"lastUpdated": str(row[4]),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -128,71 +121,41 @@ class SqliteThreadMemoryStorage(ThreadMemoryStorage):
|
|||||||
thread_id TEXT PRIMARY KEY,
|
thread_id TEXT PRIMARY KEY,
|
||||||
owner_id TEXT NULL,
|
owner_id TEXT NULL,
|
||||||
memory_md TEXT NOT NULL DEFAULT '',
|
memory_md TEXT NOT NULL DEFAULT '',
|
||||||
profile TEXT NOT NULL DEFAULT '{}',
|
|
||||||
preferences TEXT NOT NULL DEFAULT '{}',
|
|
||||||
facts TEXT NOT NULL DEFAULT '[]',
|
|
||||||
memory_version INTEGER NOT NULL DEFAULT 0,
|
memory_version INTEGER NOT NULL DEFAULT 0,
|
||||||
last_updated TEXT NOT NULL DEFAULT (datetime('now'))
|
last_updated TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
columns = {r[1] for r in self._conn.execute("PRAGMA table_info(thread_memory)").fetchall()}
|
|
||||||
if "memory_md" not in columns:
|
|
||||||
self._conn.execute("ALTER TABLE thread_memory ADD COLUMN memory_md TEXT NOT NULL DEFAULT ''")
|
|
||||||
self._conn.execute("CREATE INDEX IF NOT EXISTS idx_thread_memory_owner_id ON thread_memory(owner_id)")
|
self._conn.execute("CREATE INDEX IF NOT EXISTS idx_thread_memory_owner_id ON thread_memory(owner_id)")
|
||||||
self._conn.commit()
|
self._conn.commit()
|
||||||
|
|
||||||
def load(self, thread_id: str) -> dict[str, Any] | None:
|
def load(self, thread_id: str) -> dict[str, Any] | None:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
row = self._conn.execute(
|
row = self._conn.execute(
|
||||||
"SELECT thread_id, owner_id, profile, preferences, facts, memory_version, last_updated "
|
"SELECT thread_id, owner_id, memory_md, memory_version, last_updated "
|
||||||
"FROM thread_memory WHERE thread_id = ?",
|
"FROM thread_memory WHERE thread_id = ?",
|
||||||
(thread_id,),
|
(thread_id,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
return None
|
return None
|
||||||
row = (
|
|
||||||
row[0],
|
|
||||||
row[1],
|
|
||||||
"",
|
|
||||||
row[2],
|
|
||||||
row[3],
|
|
||||||
row[4],
|
|
||||||
row[5],
|
|
||||||
row[6],
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
row2 = self._conn.execute(
|
|
||||||
"SELECT thread_id, owner_id, memory_md, profile, preferences, facts, memory_version, last_updated "
|
|
||||||
"FROM thread_memory WHERE thread_id = ?",
|
|
||||||
(thread_id,),
|
|
||||||
).fetchone()
|
|
||||||
if row2 is not None:
|
|
||||||
row = row2
|
|
||||||
except sqlite3.OperationalError:
|
|
||||||
# Backward compatibility when running before migration.
|
|
||||||
pass
|
|
||||||
return _row_to_memory(row)
|
return _row_to_memory(row)
|
||||||
|
|
||||||
def save(self, thread_id: str, data: dict[str, Any], expected_version: int | None = None) -> bool:
|
def save(self, thread_id: str, data: dict[str, Any], expected_version: int | None = None) -> bool:
|
||||||
now = datetime.utcnow().isoformat() + "Z"
|
now = datetime.now(UTC).isoformat().replace("+00:00", "Z")
|
||||||
owner_id = data.get("ownerId")
|
owner_id = data.get("ownerId")
|
||||||
if expected_version is None:
|
if expected_version is None:
|
||||||
expected_version = 0
|
expected_version = 0
|
||||||
with self._lock:
|
with self._lock:
|
||||||
cur = self._conn.execute(
|
cur = self._conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO thread_memory (thread_id, owner_id, memory_md, profile, preferences, facts, memory_version, last_updated)
|
INSERT INTO thread_memory (thread_id, owner_id, memory_md, memory_version, last_updated)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, 0, ?)
|
VALUES (?, ?, ?, 0, ?)
|
||||||
ON CONFLICT(thread_id) DO NOTHING
|
ON CONFLICT(thread_id) DO NOTHING
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
thread_id,
|
thread_id,
|
||||||
owner_id,
|
owner_id,
|
||||||
_memory_to_markdown(data),
|
_memory_to_markdown(data),
|
||||||
json.dumps(data.get("profile", {}), ensure_ascii=False),
|
|
||||||
json.dumps(data.get("preferences", {}), ensure_ascii=False),
|
|
||||||
json.dumps(data.get("facts", []), ensure_ascii=False),
|
|
||||||
now,
|
now,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@ -203,15 +166,12 @@ class SqliteThreadMemoryStorage(ThreadMemoryStorage):
|
|||||||
cur = self._conn.execute(
|
cur = self._conn.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE thread_memory
|
UPDATE thread_memory
|
||||||
SET owner_id = ?, memory_md = ?, profile = ?, preferences = ?, facts = ?, memory_version = memory_version + 1, last_updated = ?
|
SET owner_id = ?, memory_md = ?, memory_version = memory_version + 1, last_updated = ?
|
||||||
WHERE thread_id = ? AND memory_version = ?
|
WHERE thread_id = ? AND memory_version = ?
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
owner_id,
|
owner_id,
|
||||||
_memory_to_markdown(data),
|
_memory_to_markdown(data),
|
||||||
json.dumps(data.get("profile", {}), ensure_ascii=False),
|
|
||||||
json.dumps(data.get("preferences", {}), ensure_ascii=False),
|
|
||||||
json.dumps(data.get("facts", []), ensure_ascii=False),
|
|
||||||
now,
|
now,
|
||||||
thread_id,
|
thread_id,
|
||||||
expected_version,
|
expected_version,
|
||||||
@ -239,31 +199,18 @@ class MysqlThreadMemoryStorage(ThreadMemoryStorage):
|
|||||||
thread_id VARCHAR(64) PRIMARY KEY,
|
thread_id VARCHAR(64) PRIMARY KEY,
|
||||||
owner_id VARCHAR(64) NULL,
|
owner_id VARCHAR(64) NULL,
|
||||||
memory_md LONGTEXT NOT NULL,
|
memory_md LONGTEXT NOT NULL,
|
||||||
profile JSON NOT NULL,
|
|
||||||
preferences JSON NOT NULL,
|
|
||||||
facts JSON NOT NULL,
|
|
||||||
memory_version INT NOT NULL DEFAULT 0,
|
memory_version INT NOT NULL DEFAULT 0,
|
||||||
last_updated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
last_updated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
INDEX idx_owner_id (owner_id)
|
INDEX idx_owner_id (owner_id)
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT COUNT(*)
|
|
||||||
FROM information_schema.COLUMNS
|
|
||||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'thread_memory' AND COLUMN_NAME = 'memory_md'
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
has_memory_md = cur.fetchone()[0] > 0
|
|
||||||
if not has_memory_md:
|
|
||||||
cur.execute("ALTER TABLE thread_memory ADD COLUMN memory_md LONGTEXT NOT NULL")
|
|
||||||
self._conn.commit()
|
self._conn.commit()
|
||||||
|
|
||||||
def load(self, thread_id: str) -> dict[str, Any] | None:
|
def load(self, thread_id: str) -> dict[str, Any] | None:
|
||||||
with self._conn.cursor() as cur:
|
with self._conn.cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT thread_id, owner_id, memory_md, profile, preferences, facts, memory_version, last_updated FROM thread_memory WHERE thread_id = %s",
|
"SELECT thread_id, owner_id, memory_md, memory_version, last_updated FROM thread_memory WHERE thread_id = %s",
|
||||||
(thread_id,),
|
(thread_id,),
|
||||||
)
|
)
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
@ -276,17 +223,14 @@ class MysqlThreadMemoryStorage(ThreadMemoryStorage):
|
|||||||
with self._conn.cursor() as cur:
|
with self._conn.cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO thread_memory (thread_id, owner_id, memory_md, profile, preferences, facts, memory_version)
|
INSERT INTO thread_memory (thread_id, owner_id, memory_md, memory_version)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, 0)
|
VALUES (%s, %s, %s, 0)
|
||||||
ON DUPLICATE KEY UPDATE thread_id = thread_id
|
ON DUPLICATE KEY UPDATE thread_id = thread_id
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
thread_id,
|
thread_id,
|
||||||
owner_id,
|
owner_id,
|
||||||
_memory_to_markdown(data),
|
_memory_to_markdown(data),
|
||||||
json.dumps(data.get("profile", {}), ensure_ascii=False),
|
|
||||||
json.dumps(data.get("preferences", {}), ensure_ascii=False),
|
|
||||||
json.dumps(data.get("facts", []), ensure_ascii=False),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if cur.rowcount == 1:
|
if cur.rowcount == 1:
|
||||||
@ -295,15 +239,12 @@ class MysqlThreadMemoryStorage(ThreadMemoryStorage):
|
|||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE thread_memory
|
UPDATE thread_memory
|
||||||
SET owner_id = %s, memory_md = %s, profile = %s, preferences = %s, facts = %s, memory_version = memory_version + 1
|
SET owner_id = %s, memory_md = %s, memory_version = memory_version + 1
|
||||||
WHERE thread_id = %s AND memory_version = %s
|
WHERE thread_id = %s AND memory_version = %s
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
owner_id,
|
owner_id,
|
||||||
_memory_to_markdown(data),
|
_memory_to_markdown(data),
|
||||||
json.dumps(data.get("profile", {}), ensure_ascii=False),
|
|
||||||
json.dumps(data.get("preferences", {}), ensure_ascii=False),
|
|
||||||
json.dumps(data.get("facts", []), ensure_ascii=False),
|
|
||||||
thread_id,
|
thread_id,
|
||||||
expected_version,
|
expected_version,
|
||||||
),
|
),
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from deerflow.agents.memory.updater import _extract_text
|
from deerflow.agents.memory.updater import _extract_text
|
||||||
@ -51,23 +51,32 @@ class ThreadMemoryUpdater:
|
|||||||
return None
|
return None
|
||||||
return text
|
return text
|
||||||
|
|
||||||
profile = data.get("profile", {})
|
user = data.get("user", {})
|
||||||
preferences = data.get("preferences", {})
|
history = data.get("history", {})
|
||||||
facts = data.get("facts", [])
|
facts = data.get("facts", [])
|
||||||
cleaned = create_empty_thread_memory()
|
cleaned = create_empty_thread_memory()
|
||||||
|
|
||||||
cleaned["profile"]["name"] = safe_text(profile.get("name"))
|
def copy_summary_section(target_parent: dict[str, Any], target_key: str, source_parent: Any):
|
||||||
cleaned["profile"]["role"] = safe_text(profile.get("role"))
|
if not isinstance(source_parent, dict):
|
||||||
cleaned["profile"]["language"] = safe_text(profile.get("language"))
|
return
|
||||||
cleaned["profile"]["context"] = safe_text(profile.get("context"))
|
source_section = source_parent.get(target_key)
|
||||||
expertise = profile.get("expertise")
|
if not isinstance(source_section, dict):
|
||||||
if isinstance(expertise, list):
|
return
|
||||||
cleaned["profile"]["expertise"] = [x for x in (safe_text(item) for item in expertise) if x]
|
summary = safe_text(source_section.get("summary"))
|
||||||
|
updated_at = safe_text(source_section.get("updatedAt"))
|
||||||
|
if summary:
|
||||||
|
target_parent[target_key]["summary"] = summary
|
||||||
|
if updated_at:
|
||||||
|
target_parent[target_key]["updatedAt"] = updated_at
|
||||||
|
elif summary:
|
||||||
|
target_parent[target_key]["updatedAt"] = datetime.now(UTC).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
cleaned["preferences"]["tone"] = safe_text(preferences.get("tone"))
|
copy_summary_section(cleaned["user"], "workContext", user)
|
||||||
cleaned["preferences"]["verbosity"] = safe_text(preferences.get("verbosity"))
|
copy_summary_section(cleaned["user"], "personalContext", user)
|
||||||
cleaned["preferences"]["codeStyle"] = safe_text(preferences.get("codeStyle"))
|
copy_summary_section(cleaned["user"], "topOfMind", user)
|
||||||
cleaned["preferences"]["other"] = safe_text(preferences.get("other"))
|
copy_summary_section(cleaned["history"], "recentMonths", history)
|
||||||
|
copy_summary_section(cleaned["history"], "earlierContext", history)
|
||||||
|
copy_summary_section(cleaned["history"], "longTermBackground", history)
|
||||||
|
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
for fact in facts if isinstance(facts, list) else []:
|
for fact in facts if isinstance(facts, list) else []:
|
||||||
@ -87,7 +96,7 @@ class ThreadMemoryUpdater:
|
|||||||
"content": content,
|
"content": content,
|
||||||
"category": str(fact.get("category", "context")).strip() or "context",
|
"category": str(fact.get("category", "context")).strip() or "context",
|
||||||
"confidence": max(0.0, min(1.0, confidence)),
|
"confidence": max(0.0, min(1.0, confidence)),
|
||||||
"createdAt": datetime.utcnow().isoformat() + "Z",
|
"createdAt": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
|
||||||
"source": thread_id,
|
"source": thread_id,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -101,8 +110,8 @@ class ThreadMemoryUpdater:
|
|||||||
storage = get_thread_memory_storage()
|
storage = get_thread_memory_storage()
|
||||||
current = storage.load(thread_id)
|
current = storage.load(thread_id)
|
||||||
base_memory = create_empty_thread_memory() if current is None else {
|
base_memory = create_empty_thread_memory() if current is None else {
|
||||||
"profile": current.get("profile", {}),
|
"user": current.get("user", {}),
|
||||||
"preferences": current.get("preferences", {}),
|
"history": current.get("history", {}),
|
||||||
"facts": current.get("facts", []),
|
"facts": current.get("facts", []),
|
||||||
}
|
}
|
||||||
prompt = build_thread_memory_prompt(base_memory, messages)
|
prompt = build_thread_memory_prompt(base_memory, messages)
|
||||||
|
|||||||
@ -6,8 +6,16 @@ from deerflow.agents.memory.thread_prompt import build_thread_memory_prompt, for
|
|||||||
def test_thread_memory_injection_keeps_profile_and_preferences_under_small_budget(monkeypatch):
|
def test_thread_memory_injection_keeps_profile_and_preferences_under_small_budget(monkeypatch):
|
||||||
monkeypatch.setattr("deerflow.agents.memory.thread_prompt._count_tokens", lambda text, encoding_name="cl100k_base": len(text))
|
monkeypatch.setattr("deerflow.agents.memory.thread_prompt._count_tokens", lambda text, encoding_name="cl100k_base": len(text))
|
||||||
memory = {
|
memory = {
|
||||||
"profile": {"name": "Alice", "role": "Engineer", "expertise": ["Python", "React"], "language": "en-US", "context": "Building APIs"},
|
"user": {
|
||||||
"preferences": {"tone": "technical", "verbosity": "concise", "codeStyle": "typed-first", "other": "tests first"},
|
"workContext": {"summary": "Building APIs", "updatedAt": "2026-05-08T00:00:00Z"},
|
||||||
|
"personalContext": {"summary": "Engineer using Python and React", "updatedAt": "2026-05-08T00:00:00Z"},
|
||||||
|
"topOfMind": {"summary": "Improving thread memory", "updatedAt": "2026-05-08T00:00:00Z"},
|
||||||
|
},
|
||||||
|
"history": {
|
||||||
|
"recentMonths": {"summary": "Shipped memory features", "updatedAt": "2026-05-08T00:00:00Z"},
|
||||||
|
"earlierContext": {"summary": "Started from TS projects", "updatedAt": "2026-05-08T00:00:00Z"},
|
||||||
|
"longTermBackground": {"summary": "Frontend developer", "updatedAt": "2026-05-08T00:00:00Z"},
|
||||||
|
},
|
||||||
"facts": [
|
"facts": [
|
||||||
{"content": "Fact one that might be trimmed", "category": "context", "confidence": 0.9},
|
{"content": "Fact one that might be trimmed", "category": "context", "confidence": 0.9},
|
||||||
{"content": "Fact two that might be trimmed", "category": "context", "confidence": 0.8},
|
{"content": "Fact two that might be trimmed", "category": "context", "confidence": 0.8},
|
||||||
@ -15,14 +23,14 @@ def test_thread_memory_injection_keeps_profile_and_preferences_under_small_budge
|
|||||||
}
|
}
|
||||||
|
|
||||||
result = format_thread_memory_for_injection(memory, max_tokens=140)
|
result = format_thread_memory_for_injection(memory, max_tokens=140)
|
||||||
assert "Profile:" in result
|
assert "User:" in result
|
||||||
assert "Preferences:" in result
|
assert "History:" in result
|
||||||
|
|
||||||
|
|
||||||
def test_build_thread_memory_prompt_does_not_raise_format_key_error():
|
def test_build_thread_memory_prompt_does_not_raise_format_key_error():
|
||||||
prompt = build_thread_memory_prompt(
|
prompt = build_thread_memory_prompt(
|
||||||
{"profile": {}, "preferences": {}, "facts": []},
|
{"user": {}, "history": {}, "facts": []},
|
||||||
[HumanMessage(content="My name is Alice.")],
|
[HumanMessage(content="My name is Alice.")],
|
||||||
)
|
)
|
||||||
assert "Current per-thread memory" in prompt
|
assert "Current per-thread memory" in prompt
|
||||||
assert '"profile"' in prompt
|
assert '"user"' in prompt
|
||||||
|
|||||||
@ -1,11 +1,21 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
from deerflow.agents.memory.thread_storage import SqliteThreadMemoryStorage
|
from deerflow.agents.memory.thread_storage import SqliteThreadMemoryStorage
|
||||||
|
|
||||||
|
|
||||||
def _payload():
|
def _payload():
|
||||||
return {
|
return {
|
||||||
"ownerId": None,
|
"ownerId": None,
|
||||||
"profile": {"name": "A", "role": None, "expertise": [], "language": None, "context": None},
|
"user": {
|
||||||
"preferences": {"tone": None, "verbosity": None, "codeStyle": None, "other": None},
|
"workContext": {"summary": "Frontend engineer", "updatedAt": "2026-05-08T00:00:00Z"},
|
||||||
|
"personalContext": {"summary": "Prefers Chinese", "updatedAt": "2026-05-08T00:00:00Z"},
|
||||||
|
"topOfMind": {"summary": "Thread memory migration", "updatedAt": "2026-05-08T00:00:00Z"},
|
||||||
|
},
|
||||||
|
"history": {
|
||||||
|
"recentMonths": {"summary": "Worked on memory features", "updatedAt": "2026-05-08T00:00:00Z"},
|
||||||
|
"earlierContext": {"summary": "", "updatedAt": ""},
|
||||||
|
"longTermBackground": {"summary": "Builds web products", "updatedAt": "2026-05-08T00:00:00Z"},
|
||||||
|
},
|
||||||
"facts": [],
|
"facts": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -39,28 +49,35 @@ def test_sqlite_thread_memory_saves_markdown_payload(tmp_path):
|
|||||||
row = storage._conn.execute("SELECT memory_md FROM thread_memory WHERE thread_id = ?", (thread_id,)).fetchone()
|
row = storage._conn.execute("SELECT memory_md FROM thread_memory WHERE thread_id = ?", (thread_id,)).fetchone()
|
||||||
assert row is not None
|
assert row is not None
|
||||||
assert isinstance(row[0], str)
|
assert isinstance(row[0], str)
|
||||||
assert "## Profile" in row[0]
|
assert "## User" in row[0]
|
||||||
assert "## Preferences" in row[0]
|
assert "## History" in row[0]
|
||||||
assert "## Facts" in row[0]
|
assert "## Facts" in row[0]
|
||||||
|
|
||||||
|
|
||||||
def test_sqlite_thread_memory_loads_legacy_json_row(tmp_path):
|
def test_sqlite_thread_memory_loads_markdown_row(tmp_path):
|
||||||
db_path = tmp_path / "legacy-thread-memory.db"
|
db_path = tmp_path / "thread-memory.db"
|
||||||
storage = SqliteThreadMemoryStorage(str(db_path))
|
storage = SqliteThreadMemoryStorage(str(db_path))
|
||||||
thread_id = "thread-legacy"
|
thread_id = "thread-load"
|
||||||
|
payload = _payload()
|
||||||
|
|
||||||
with storage._lock:
|
with storage._lock:
|
||||||
storage._conn.execute(
|
storage._conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO thread_memory (thread_id, owner_id, memory_md, profile, preferences, facts, memory_version, last_updated)
|
INSERT INTO thread_memory (thread_id, owner_id, memory_md, memory_version, last_updated)
|
||||||
VALUES (?, ?, '', ?, ?, ?, 0, datetime('now'))
|
VALUES (?, ?, ?, 0, datetime('now'))
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
thread_id,
|
thread_id,
|
||||||
"owner-1",
|
"owner-1",
|
||||||
'{"name":"Alice","role":null,"expertise":[],"language":null,"context":null}',
|
(
|
||||||
'{"tone":null,"verbosity":null,"codeStyle":null,"other":null}',
|
"# Thread Memory\n\n"
|
||||||
"[]",
|
"Owner ID: owner-1\n\n"
|
||||||
|
"## User\n```json\n"
|
||||||
|
+ json.dumps(payload["user"], ensure_ascii=False, indent=2)
|
||||||
|
+ "\n```\n\n## History\n```json\n"
|
||||||
|
+ json.dumps(payload["history"], ensure_ascii=False, indent=2)
|
||||||
|
+ "\n```\n\n## Facts\n```json\n[]\n```"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
storage._conn.commit()
|
storage._conn.commit()
|
||||||
@ -68,4 +85,4 @@ def test_sqlite_thread_memory_loads_legacy_json_row(tmp_path):
|
|||||||
loaded = storage.load(thread_id)
|
loaded = storage.load(thread_id)
|
||||||
assert loaded is not None
|
assert loaded is not None
|
||||||
assert loaded["ownerId"] == "owner-1"
|
assert loaded["ownerId"] == "owner-1"
|
||||||
assert loaded["profile"]["name"] == "Alice"
|
assert loaded["user"]["workContext"]["summary"] == "Frontend engineer"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user