mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-10-08 03:10:24 +00:00
refactor(evolve): consolidate auto memory planner and writer into single step (#267)
* refactor(evolve): consolidate auto memory planner and writer into single step - Removed separate AutoMemoryPlannerStep and AutoMemoryWriterStep classes - Combined functionality into new AutoMemoryStep class in auto_memory.py - Migrated prompt templates from separate YAML files to unified auto_memory.yaml - Updated module imports to reference new consolidated step - Simplified memory recording process using single ReAct agent instead of two-stage planning/writing - Maintained same input/output contract with messages, session_id, and memory_hint parameters - Preserved all original functionality for creating/updating daily notes with conversation facts * fix(daily): update empty session_id handling to create day-level file - Changed test to verify empty session_id creates day-level file daily/<date>.md - Updated assertion to check response success instead of rejection - Modified metadata verification to include path, session_id and created status - Added file existence check for the generated daily markdown file - Updated test name and print statement to reflect new behavior - Fixed test registration to use updated function name
This commit is contained in:
parent
9ee2f0f7ab
commit
c4ca617992
12 changed files with 483 additions and 675 deletions
|
|
@ -101,12 +101,12 @@ litellm = [
|
|||
]
|
||||
|
||||
light = [
|
||||
"agentscope==1.0.19",
|
||||
"agentscope==1.0.20",
|
||||
"flowllm[reme]>=0.2.0.10",
|
||||
]
|
||||
|
||||
core = [
|
||||
"agentscope==1.0.19",
|
||||
"agentscope==1.0.20",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
|
|
|||
|
|
@ -109,19 +109,18 @@ jobs:
|
|||
|
||||
daily_create:
|
||||
backend: base
|
||||
description: "Provision a session note under a daily folder: daily/<date>/<session_id>.md"
|
||||
description: "Provision a session note under a daily folder: daily/<date>/<session_id>.md or daily/<date>.md"
|
||||
parameters:
|
||||
type: object
|
||||
properties:
|
||||
session_id:
|
||||
type: string
|
||||
description: "the session identifier (also the file stem)"
|
||||
description: "the session identifier (also the file stem); empty = day-level file"
|
||||
default: ""
|
||||
date:
|
||||
type: string
|
||||
description: "YYYY-MM-DD; empty = today"
|
||||
default: ""
|
||||
required:
|
||||
- session_id
|
||||
steps:
|
||||
- backend: daily_create_step
|
||||
|
||||
|
|
@ -371,7 +370,7 @@ jobs:
|
|||
|
||||
auto_memory:
|
||||
backend: base
|
||||
description: "Auto-memory orchestrator"
|
||||
description: "Auto-memory: record conversation facts into a daily note"
|
||||
parameters:
|
||||
type: object
|
||||
properties:
|
||||
|
|
@ -380,6 +379,10 @@ jobs:
|
|||
description: "messages"
|
||||
items:
|
||||
type: object
|
||||
session_id:
|
||||
type: string
|
||||
description: "session identifier passed to daily_create"
|
||||
default: ""
|
||||
memory_hint:
|
||||
type: string
|
||||
description: "optional hint"
|
||||
|
|
@ -390,8 +393,7 @@ jobs:
|
|||
required:
|
||||
- messages
|
||||
steps:
|
||||
- backend: auto_memory_planner_step
|
||||
- backend: auto_memory_writer_step
|
||||
- backend: auto_memory_step
|
||||
|
||||
components:
|
||||
tokenizer:
|
||||
|
|
|
|||
|
|
@ -7,8 +7,7 @@ from .common.help import HelpStep
|
|||
from .common.llm_demo import LLMDemoStep
|
||||
from .common.stream_demo import StreamDemoStep1, StreamDemoStep2
|
||||
from .common.version import VersionStep
|
||||
from .evolve.auto_memory_planner import AutoMemoryPlannerStep
|
||||
from .evolve.auto_memory_writer import AutoMemoryWriterStep
|
||||
from .evolve.auto_memory import AutoMemoryStep
|
||||
from .file_io.daily_create import DailyCreateStep
|
||||
from .file_io.daily_list import DailyListStep
|
||||
from .file_io.daily_reindex import DailyReindexStep
|
||||
|
|
@ -46,8 +45,7 @@ __all__ = [
|
|||
"StreamDemoStep2",
|
||||
"VersionStep",
|
||||
# evolve
|
||||
"AutoMemoryPlannerStep",
|
||||
"AutoMemoryWriterStep",
|
||||
"AutoMemoryStep",
|
||||
# file_io
|
||||
"DeleteStep",
|
||||
"EditStep",
|
||||
|
|
|
|||
88
reme4/steps/evolve/auto_memory.py
Normal file
88
reme4/steps/evolve/auto_memory.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
"""``auto_memory`` — record conversation facts into a daily note.
|
||||
|
||||
Calls ``daily_create`` as a system call to provision the note path,
|
||||
then hands off to a ReAct agent that reads existing content (if any),
|
||||
decides what to preserve, and writes the note via ``read`` / ``edit``
|
||||
/ ``frontmatter_update`` / ``write`` tools.
|
||||
|
||||
Inputs (from RuntimeContext):
|
||||
messages (list[Msg], required): conversation slice to inspect.
|
||||
session_id (str, optional): passed to daily_create to determine
|
||||
the note path.
|
||||
memory_hint (str, optional): caller-supplied hint for the agent.
|
||||
timezone (str, optional): IANA timezone for date resolution.
|
||||
|
||||
Output (written to context.response):
|
||||
answer: one-line summary from the agent.
|
||||
metadata: {path, created}.
|
||||
"""
|
||||
|
||||
from agentscope.agent import ReActAgent
|
||||
from agentscope.message import Msg
|
||||
from agentscope.tool import Toolkit
|
||||
|
||||
from ._evolve import format_history, now
|
||||
from ..base_step import BaseStep
|
||||
from ...components import R
|
||||
|
||||
|
||||
@R.register("auto_memory_step")
|
||||
class AutoMemoryStep(BaseStep):
|
||||
"""Record conversation facts into a daily note via a ReAct agent."""
|
||||
|
||||
def __init__(self, console_enabled: bool = False, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.console_enabled = console_enabled
|
||||
self.agent_tools: list[str] = ["read", "edit", "frontmatter_update", "write"]
|
||||
|
||||
async def execute(self):
|
||||
assert self.context is not None
|
||||
messages: list[Msg] = [
|
||||
item if isinstance(item, Msg) else Msg.from_dict(item) for item in self.context.get("messages", [])
|
||||
]
|
||||
session_id: str = self.context.get("session_id", "")
|
||||
memory_hint: str = self.context.get("memory_hint", "")
|
||||
current = now(self.context.get("timezone"))
|
||||
|
||||
if not messages:
|
||||
self.context.response.success = True
|
||||
self.context.response.answer = "Skipped: no messages supplied"
|
||||
return
|
||||
|
||||
create_response = await self.run_job("daily_create", session_id=session_id)
|
||||
if not create_response.success:
|
||||
self.context.response.success = False
|
||||
self.context.response.answer = f"daily_create failed: {create_response.answer}"
|
||||
return
|
||||
|
||||
note_path: str = create_response.metadata["path"]
|
||||
created: bool = create_response.metadata["created"]
|
||||
|
||||
toolkit = Toolkit()
|
||||
for job_name in self.agent_tools:
|
||||
self.add_as_tool(toolkit, job_name)
|
||||
|
||||
agent = ReActAgent(
|
||||
name="auto_memory",
|
||||
model=self.as_llm,
|
||||
sys_prompt=self.prompt_format("system_prompt"),
|
||||
formatter=self.as_llm_formatter,
|
||||
toolkit=toolkit,
|
||||
)
|
||||
agent.set_console_output_enabled(self.console_enabled)
|
||||
|
||||
template_key = "user_message_create" if created else "user_message_update"
|
||||
user_message: str = self.prompt_format(
|
||||
template_key,
|
||||
today=current.strftime("%Y-%m-%d"),
|
||||
vault_dir=str(self.file_store.vault_path),
|
||||
note=memory_hint or "(none)",
|
||||
note_path=note_path,
|
||||
history=format_history(messages),
|
||||
)
|
||||
|
||||
final_msg: Msg = await agent.reply(Msg(name="reme", role="user", content=user_message))
|
||||
|
||||
self.context.response.success = True
|
||||
self.context.response.answer = (final_msg.get_text_content() or "").strip()
|
||||
self.context.response.metadata.update({"path": note_path, "created": created})
|
||||
230
reme4/steps/evolve/auto_memory.yaml
Normal file
230
reme4/steps/evolve/auto_memory.yaml
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
system_prompt: |
|
||||
You are an automatic memory system. Your job is to record key information from recent conversations into a daily note at the specified path. Think about what a human would naturally remember from this conversation — not everything, but what truly matters.
|
||||
|
||||
## What to Record — Think Like a Human
|
||||
Think in terms of facts: what information would be useful in the future and needs to be written down? Capture things that are hard to re-obtain:
|
||||
|
||||
- Persistent facts about the user — who they are, how they work, what they want
|
||||
- What happened, what decisions were made, and why — narrative threads
|
||||
- Current state — progress, blockers, next steps — stale by tomorrow but critical today
|
||||
- Actionable procedures or solutions that can be directly reused
|
||||
- Anything you consider important that doesn't fit the above — if you think it'll be useful later, write it down
|
||||
|
||||
Be comprehensive — every fact worth keeping should appear. Quote original wording or numbers verbatim at key points.
|
||||
|
||||
## Body Format
|
||||
|
||||
Free-form — use whatever structure best fits the content (headings, lists, etc.). The only hard rule is **completeness**.
|
||||
|
||||
## Frontmatter Rules
|
||||
|
||||
- `name` = the filename stem, copied verbatim. Do not Title-Case or rewrite it.
|
||||
- `description` = a thorough summary; vague descriptions like "notes" / "misc" are unacceptable.
|
||||
- **Never set `status`** — it is a field reserved for downstream processing.
|
||||
system_prompt_zh: |
|
||||
你是自动记忆系统。你的职责是将最近对话中的核心信息记录到指定路径的日记中。思考人类会从这段对话中自然地记住什么——不是所有内容,而是真正重要的信息。
|
||||
|
||||
## 记录什么——像人类一样思考
|
||||
从事实的角度想:哪些信息未来会有用,需要记下来?捕捉那些难以重新获取的信息:
|
||||
|
||||
- 关于用户的持久事实——他们是谁、怎么工作、想要什么
|
||||
- 发生了什么事、做了什么决策、为什么——叙事线索
|
||||
- 当前状态——进度、卡点、下一步——明天就会过时但今天很重要
|
||||
- 可以直接复用的操作步骤或方案
|
||||
- 你认为重要但不属于以上类别的信息——如果你觉得以后会用到,就记下来
|
||||
|
||||
要全面——每一条值得保留的事实都应出现。关键处逐字引用原始措辞或数字。
|
||||
|
||||
## 正文格式
|
||||
|
||||
自由格式——用最适合内容的结构(标题、列表等)。唯一的硬性规则是**完整性**。
|
||||
|
||||
## Frontmatter 规则
|
||||
|
||||
- `name` = 文件名 stem,逐字照抄。不要 Title-Case 化,不要改写。
|
||||
- `description` = 详细总结;模糊的描述如 "notes" / "misc" 不可接受。
|
||||
- **永远不要设置 `status`**——它是下游处理保留的字段。
|
||||
|
||||
user_message_create: |
|
||||
Today: {today}
|
||||
Vault directory: {vault_dir}
|
||||
Extra hint: {note}
|
||||
Target path: {note_path}
|
||||
|
||||
# Recent Conversation
|
||||
|
||||
{history}
|
||||
|
||||
# Your Task
|
||||
|
||||
Record the key information from the conversation above into the daily note at the target path.
|
||||
|
||||
## Step 1 — Skip Check
|
||||
|
||||
Did the conversation produce substantive information worth long-term memory? Pure greetings or small talk → reply with a brief skip message and stop (do not call any tools).
|
||||
|
||||
When truly ambiguous, default to writing — losing a memory is worse than writing one extra note.
|
||||
|
||||
## Step 2 — Write
|
||||
|
||||
The target file is a newly created empty file. Write the full content in one shot:
|
||||
`write path={note_path} name=<name> description=<description> content=<body>`
|
||||
|
||||
- `name` must equal the filename stem of the target path (the part between the last `/` and `.md`), copied verbatim.
|
||||
- `description` must be a thorough summary of the body — specific enough that the description alone conveys all key information.
|
||||
|
||||
## Step 3 — Summary
|
||||
|
||||
State in one sentence what you did (which file was created). This is your final text output.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Only operate on one target path: `{note_path}`. Do not touch other notes.
|
||||
user_message_create_zh: |
|
||||
今天:{today}
|
||||
Vault 目录:{vault_dir}
|
||||
额外提示:{note}
|
||||
目标路径:{note_path}
|
||||
|
||||
# 最近的对话
|
||||
|
||||
{history}
|
||||
|
||||
# 你的任务
|
||||
|
||||
将上述对话中的核心信息记录到目标路径的日记中。
|
||||
|
||||
## 步骤 1 — 跳过检查
|
||||
|
||||
对话是否产生了值得长期记忆的实质性信息?纯粹的寒暄或闲聊 → 回复一条简短的跳过消息并停止(不调用任何工具)。
|
||||
|
||||
当真正模棱两可时,默认写入——丢失记忆比多写一条笔记更糟。
|
||||
|
||||
## 步骤 2 — 写入
|
||||
|
||||
目标文件是新建的空文件。一次性写入完整内容:
|
||||
`write path={note_path} name=<name> description=<description> content=<正文>`
|
||||
|
||||
- `name` 必须等于目标路径的文件名 stem(最后一个 `/` 与 `.md` 之间的部分),逐字照抄。
|
||||
- `description` 必须是正文的详尽总结——具体到仅凭 description 就能传达全部核心信息。
|
||||
|
||||
## 步骤 3 — 总结
|
||||
|
||||
用一句话说明你做了什么(创建了哪个文件)。这是你最后一次文本输出。
|
||||
|
||||
## 边界
|
||||
|
||||
- 只针对一个目标路径:`{note_path}`。不要碰其他笔记。
|
||||
|
||||
user_message_update: |
|
||||
Today: {today}
|
||||
Vault directory: {vault_dir}
|
||||
Extra hint: {note}
|
||||
Target path: {note_path}
|
||||
|
||||
# Recent Conversation
|
||||
|
||||
{history}
|
||||
|
||||
# Your Task
|
||||
|
||||
Merge key information from the conversation above into the existing daily note at the target path.
|
||||
|
||||
## Step 1 — Skip Check
|
||||
|
||||
Did the conversation produce substantive information worth long-term memory? Pure greetings or small talk → reply with a brief skip message and stop (do not call any tools).
|
||||
|
||||
When truly ambiguous, default to writing — losing a memory is worse than writing one extra note.
|
||||
|
||||
## Step 2 — Read Existing Content
|
||||
|
||||
Call `read path={note_path}` to inspect the current note content.
|
||||
- If the body is empty (only frontmatter, no actual content) → treat as new, jump to **Step 3b**.
|
||||
- If there is body content → go to **Step 3a** to merge.
|
||||
|
||||
## Step 3a — Merge Update
|
||||
|
||||
The note already has content. Your task is to merge new information into it.
|
||||
|
||||
Merge rules:
|
||||
- **Timeline / history entries**: append only, never delete existing entries.
|
||||
- **Current-state entries** (progress, blockers, next steps, open questions): rewrite the entire section to reflect the latest snapshot.
|
||||
- **Everything else**: merge and deduplicate — keep all old facts, add new facts, remove exact duplicates.
|
||||
|
||||
Execution:
|
||||
1. Use `edit path={note_path} old=<original fragment> new=<replacement fragment>` for each section that needs updating. You may call `edit` multiple times.
|
||||
2. After body changes, refresh the frontmatter description: `frontmatter_update path={note_path} metadata={{"description": "<updated summary>"}}`.
|
||||
3. If `edit` fails repeatedly (e.g., cannot find the original text due to formatting mismatch), fall back to `write path={note_path} name=<name> description=<description> content=<full body>` for a complete rewrite.
|
||||
|
||||
## Step 3b — Full Write (Empty File Fallback)
|
||||
|
||||
The file exists but its body is empty. Write the full content in one shot:
|
||||
`write path={note_path} name=<name> description=<description> content=<body>`
|
||||
|
||||
- `name` must equal the filename stem of the target path (the part between the last `/` and `.md`), copied verbatim.
|
||||
- `description` must be a thorough summary of the body — specific enough that the description alone conveys all key information.
|
||||
|
||||
## Step 4 — Summary
|
||||
|
||||
State in one sentence what you did (what content was updated). This is your final text output.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Only operate on one target path: `{note_path}`. Do not touch other notes.
|
||||
- `write` unconditionally overwrites body and frontmatter — use with caution.
|
||||
user_message_update_zh: |
|
||||
今天:{today}
|
||||
Vault 目录:{vault_dir}
|
||||
额外提示:{note}
|
||||
目标路径:{note_path}
|
||||
|
||||
# 最近的对话
|
||||
|
||||
{history}
|
||||
|
||||
# 你的任务
|
||||
|
||||
将上述对话中的核心信息合并到目标路径的已有日记中。
|
||||
|
||||
## 步骤 1 — 跳过检查
|
||||
|
||||
对话是否产生了值得长期记忆的实质性信息?纯粹的寒暄或闲聊 → 回复一条简短的跳过消息并停止(不调用任何工具)。
|
||||
|
||||
当真正模棱两可时,默认写入——丢失记忆比多写一条笔记更糟。
|
||||
|
||||
## 步骤 2 — 读取现有内容
|
||||
|
||||
调用 `read path={note_path}` 查看当前笔记内容。
|
||||
- 如果正文为空(只有 frontmatter 无实际内容)→ 按新建处理,跳到 **步骤 3b**。
|
||||
- 如果有正文内容 → 转到 **步骤 3a** 进行合并。
|
||||
|
||||
## 步骤 3a — 合并更新
|
||||
|
||||
笔记已有内容。你的任务是将新信息合并进去。
|
||||
|
||||
合并规则:
|
||||
- **时间线 / 历史条目**:仅追加,永远不删除已有条目。
|
||||
- **当下状态类条目**(进度、卡点、下一步、未决问题):整段重写,反映最新快照。
|
||||
- **其余内容**:合并去重——保留全部旧事实,添加新事实,去除完全重复项。
|
||||
|
||||
执行:
|
||||
1. 对需要更新的每个部分使用 `edit path={note_path} old=<原文片段> new=<替换片段>`。可以多次调用 `edit`。
|
||||
2. 正文变更后,刷新 frontmatter 的 description:`frontmatter_update path={note_path} metadata={{"description": "<更新后的总结>"}}`。
|
||||
3. 如果 `edit` 多次失败(如因格式不匹配找不到原文),退回 `write path={note_path} name=<name> description=<description> content=<完整正文>` 全量重写。
|
||||
|
||||
## 步骤 3b — 全量写入(空文件 fallback)
|
||||
|
||||
文件存在但正文为空。一次性写入完整内容:
|
||||
`write path={note_path} name=<name> description=<description> content=<正文>`
|
||||
|
||||
- `name` 必须等于目标路径的文件名 stem(最后一个 `/` 与 `.md` 之间的部分),逐字照抄。
|
||||
- `description` 必须是正文的详尽总结——具体到仅凭 description 就能传达全部核心信息。
|
||||
|
||||
## 步骤 4 — 总结
|
||||
|
||||
用一句话说明你做了什么(更新了哪些内容)。这是你最后一次文本输出。
|
||||
|
||||
## 边界
|
||||
|
||||
- 只针对一个目标路径:`{note_path}`。不要碰其他笔记。
|
||||
- `write` 会无条件覆盖正文和 frontmatter,请谨慎使用。
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
"""``auto_memory_planner`` — planner for the auto-memory system.
|
||||
|
||||
Inspects the recent conversation, surveys today's existing daily notes
|
||||
via ``daily_list``, reads any candidate that already covers the topic
|
||||
via ``read``, and emits a list of daily-note upsert tasks
|
||||
``(path, description)`` through structured output. The result is
|
||||
exposed under ``response.metadata['memory_updates']`` for the
|
||||
orchestrator (``auto_memory``) to feed into ``auto_memory_writer``
|
||||
one task at a time.
|
||||
|
||||
The planner never writes notes itself — planning only.
|
||||
|
||||
Inputs (from RuntimeContext):
|
||||
messages (list[Msg], required): conversation slice to inspect.
|
||||
memory_hint (str, optional): caller-supplied hint to bias filename
|
||||
stem selection or disambiguate same-day tasks.
|
||||
|
||||
Output (written to context.response):
|
||||
answer: one-line human summary of what was planned.
|
||||
metadata['memory_updates']: list of ``{path, description}`` dicts.
|
||||
"""
|
||||
|
||||
from agentscope.message import Msg
|
||||
from agentscope.tool import Toolkit
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ._evolve import FlexReActAgent, format_history, now
|
||||
from ..base_step import BaseStep
|
||||
from ...components import R
|
||||
|
||||
|
||||
class MemoryUpdateTask(BaseModel):
|
||||
"""One daily-note upsert task emitted by the planner."""
|
||||
|
||||
path: str = Field(
|
||||
description="Vault-relative note path, form `daily/<YYYY-MM-DD>/<kebab-case-stem>.md`. "
|
||||
"Reuse an existing path to upsert.",
|
||||
)
|
||||
description: str = Field(description="Flat fact checklist — what to preserve, not how to categorize or format.")
|
||||
|
||||
|
||||
class MemoryUpdatesPlan(BaseModel):
|
||||
"""Structured output emitted by the planner's finish-tool."""
|
||||
|
||||
memory_updates: list[MemoryUpdateTask] = Field(
|
||||
default_factory=list,
|
||||
description="List of daily-note upsert tasks; empty means nothing worth persisting.",
|
||||
)
|
||||
|
||||
|
||||
@R.register("auto_memory_planner_step")
|
||||
class AutoMemoryPlannerStep(BaseStep):
|
||||
"""Plan daily-note upsert tasks via a ReAct agent with structured output."""
|
||||
|
||||
def __init__(self, console_enabled: bool = False, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.console_enabled = console_enabled
|
||||
self.planner_tools: list[str] = ["daily_list", "read"]
|
||||
|
||||
async def execute(self):
|
||||
assert self.context is not None
|
||||
messages: list[Msg] = [
|
||||
item if isinstance(item, Msg) else Msg.from_dict(item) for item in self.context.get("messages", [])
|
||||
]
|
||||
memory_hint: str = self.context.get("memory_hint", "")
|
||||
current = now(self.context.get("timezone"))
|
||||
|
||||
if not messages:
|
||||
self.context.response.success = True
|
||||
self.context.response.answer = "Skipped: no messages supplied"
|
||||
self.context.response.metadata.update({"memory_updates": []})
|
||||
return
|
||||
|
||||
toolkit = Toolkit()
|
||||
for job_name in self.planner_tools:
|
||||
self.add_as_tool(toolkit, job_name)
|
||||
|
||||
agent = FlexReActAgent(
|
||||
name="auto_memory_planner",
|
||||
model=self.as_llm,
|
||||
sys_prompt=self.prompt_format("system_prompt"),
|
||||
formatter=self.as_llm_formatter,
|
||||
toolkit=toolkit,
|
||||
)
|
||||
agent.set_console_output_enabled(self.console_enabled)
|
||||
|
||||
user_message: str = self.prompt_format(
|
||||
"user_message",
|
||||
today=current.strftime("%Y-%m-%d"),
|
||||
vault_dir=str(self.file_store.vault_path),
|
||||
note=memory_hint or "(none)",
|
||||
history=format_history(messages),
|
||||
)
|
||||
|
||||
final_msg: Msg = await agent.reply(
|
||||
Msg(name="reme", role="user", content=user_message),
|
||||
structured_model=MemoryUpdatesPlan,
|
||||
)
|
||||
|
||||
meta: dict = final_msg.metadata if isinstance(final_msg.metadata, dict) else {}
|
||||
raw_tasks = meta.get("memory_updates") or []
|
||||
cleaned: list[dict] = []
|
||||
for item in raw_tasks:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
path = str(item.get("path") or "").strip()
|
||||
description = str(item.get("description") or "").strip()
|
||||
if path and description and path.endswith(".md") and ".." not in path.split("/"):
|
||||
cleaned.append({"path": path, "description": description})
|
||||
|
||||
self.context.response.success = True
|
||||
self.context.response.metadata.update({"memory_updates": cleaned, "count": len(cleaned)})
|
||||
if not cleaned:
|
||||
self.context.response.answer = "[SKIP] No memory updates planned"
|
||||
return
|
||||
|
||||
lines = [f"Planned {len(cleaned)} memory update(s):"]
|
||||
lines += [f"- {t['path']}: {t['description']}" for t in cleaned]
|
||||
self.context.response.answer = "\n".join(lines)
|
||||
|
|
@ -1,142 +0,0 @@
|
|||
system_prompt: |
|
||||
You are the planner of an auto-memory system. Each invocation's task: inspect the recent conversation, then emit a list of daily-note upsert tasks via the `generate_response` finish tool. You never write notes yourself — a separate writer agent handles that.
|
||||
|
||||
user_message: |
|
||||
Today: {today}
|
||||
Vault dir: {vault_dir}
|
||||
Extra hint: {note}
|
||||
|
||||
# Recent conversation
|
||||
|
||||
{history}
|
||||
|
||||
# Your task
|
||||
|
||||
Inspect the conversation above and emit a list of daily-note upsert tasks. Follow the five-step workflow below, then submit your planned `memory_updates` by calling `generate_response`.
|
||||
|
||||
## Five steps per invocation
|
||||
|
||||
### Step 1 — Skip check
|
||||
Is the conversation a substantive exchange that produced information worth long-term memory — such as user preferences, project decisions, technical facts, workflow knowledge, or status updates? Pure greetings or small talk with no such information → emit an empty `memory_updates` list (the orchestrator treats this as a skip).
|
||||
When truly ambiguous, default to writing — losing memory is worse than an extra note.
|
||||
|
||||
### Step 2 — Survey today
|
||||
Call `daily_list` to see all existing `daily/<today>/<stem>.md` files along with their `name`, `description`, and other metadata.
|
||||
|
||||
### Step 3 — Read candidates
|
||||
For any existing note whose `name` / `description` looks relevant to this conversation, call `read path=daily/<today>/<stem>.md` to see its body. Skip clearly unrelated notes.
|
||||
|
||||
### Step 4 — Plan paths
|
||||
|
||||
Decide one or more `(path, description)` pairs. Each `path` is the vault-relative note path of form `daily/<today>/<stem>.md`, where `<stem>` is the filename without the `.md` suffix.
|
||||
|
||||
#### 4a. Filename stem naming conventions
|
||||
|
||||
The stem is the note's filename without `.md` — its job is to **uniquely identify** this event/topic, not to cram in all context. Constraints:
|
||||
|
||||
- **Format**: English kebab-case, composed of nouns / noun phrases, stable as a filename.
|
||||
- **Length**: Usually 2-5 words, roughly 15-50 characters. **Too short and uninformative** (e.g. `bug`, `chat`, `misc`, `notes-1`) → unusable; **too long with progress/blockers/dates packed in** → unusable, those belong in the body and frontmatter.
|
||||
- **Information density**: Reading the stem alone should identify which event/topic it refers to; progress, status, and context do not go into the stem.
|
||||
- **Event type** (specific event / task / outage / debug / release): the stem identifies "which event". E.g.: `auth-middleware-rewrite`, `ingest-pipeline-oom`.
|
||||
- **Topic type** (ongoing concept / knowledge domain / preference / tool recipe): the stem identifies "which topic". E.g.: `pytorch-distributed-training`, `pr-summary-style`.
|
||||
|
||||
#### 4b. Reuse vs. create
|
||||
|
||||
- When the conversation continues a thread you found in Step 3 (same logical event, same topic), **reuse the same path** (same stem under today's folder). Fragmenting one thread across multiple paths is the worst failure mode.
|
||||
- When no existing note covers the topic, **create a new path** of form `daily/<today>/<stem>.md` with the stem following 4a conventions.
|
||||
- A single conversation may span multiple unrelated topics — emit a separate task for each distinct event/topic rather than merging into one giant task.
|
||||
|
||||
#### 4c. What goes in the description
|
||||
|
||||
The description only answers "what facts to preserve" — how to format the body, structure sections, or merge updates is the writer's responsibility, not yours.
|
||||
|
||||
The description is a flat fact checklist listing everything from the conversation worth preserving. Quote key original wording or numbers verbatim. Do not categorize the facts — categorization into note sections is the writer's job.
|
||||
|
||||
Coverage should be comprehensive, including but not limited to:
|
||||
- Durable facts about the user (role, project, responsibilities, tools, preferences, constraints, goals)
|
||||
- Domain knowledge (concepts, decision conclusions and rationale, dependency versions, design constraints, system topology)
|
||||
- Replayable operations (command sequences, script recipes, workflows, debugging steps)
|
||||
- Current status (progress, blockers, next steps, open questions)
|
||||
- Timeline events (events that occurred, decision moments)
|
||||
|
||||
### Step 5 — Submit
|
||||
Call `generate_response` with `memory_updates=[{{path, description}}, ...]`. If Step 1 determined a skip, emit `[]`. This is your only way to finish — do not produce free text after Step 4; the structured payload from the finish tool is your entire deliverable.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- You never write notes — do not call `write`, `edit`, or `frontmatter_update`. Those tools belong to the writer.
|
||||
- When surveying, stay in today's `daily/` folder — do not traverse the entire vault.
|
||||
- Every `path` you emit must be of form `daily/<today>/<stem>.md` — never write outside today's daily folder.
|
||||
- Emit exactly one `memory_updates` list per invocation. Do not call `generate_response` more than once.
|
||||
|
||||
|
||||
system_prompt_zh: |
|
||||
你是自动记忆系统的规划者。每次调用的任务:研究最近的对话,然后通过 `generate_response` 完成工具发出一份日记 upsert 任务列表。你自己不写笔记——由独立的写入代理负责。
|
||||
|
||||
user_message_zh: |
|
||||
今天:{today}
|
||||
Vault 目录:{vault_dir}
|
||||
额外提示:{note}
|
||||
|
||||
# 最近的对话
|
||||
|
||||
{history}
|
||||
|
||||
# 你的任务
|
||||
|
||||
研究上面的对话,发出一份日记 upsert 任务列表。按下面的五步流程执行,最后通过调用 `generate_response` 提交计划好的 `memory_updates`。
|
||||
|
||||
## 每次调用的五个步骤
|
||||
|
||||
### 步骤 1 — 跳过检查
|
||||
对话是否产生了值得长期记忆的信息——如用户偏好、项目决策、技术事实、工作流知识或状态更新?纯粹的寒暄或闲聊,且未透露任何此类信息 → 发出空的 `memory_updates` 列表(编排器会视为跳过)。
|
||||
当真正模棱两可时,默认写入——丢失记忆比多写一条笔记更糟。
|
||||
|
||||
### 步骤 2 — 概览今天
|
||||
调用 `daily_list` 查看所有现存的 `daily/<today>/<stem>.md` 及其 `name`、`description` 和其他 metadata 信息。
|
||||
|
||||
### 步骤 3 — 阅读候选
|
||||
对任何 `name` / `description` 看起来与本次对话相关的现有笔记,调用 `read path=daily/<today>/<stem>.md` 直接阅读正文。明显无关的笔记跳过。
|
||||
|
||||
### 步骤 4 — 规划 path
|
||||
|
||||
决定一个或多个 `(path, description)` 对。每个 `path` 是 vault 相对路径,形如 `daily/<today>/<stem>.md`;其中 `<stem>` 段就是笔记文件名去掉 `.md` 后缀的部分。
|
||||
|
||||
#### 4a. 文件名 stem 命名规范
|
||||
|
||||
stem 就是笔记文件名去掉 `.md` 的部分——它的职责是**唯一标识**这条 event/topic,不是塞进所有上下文。约束:
|
||||
|
||||
- **格式**:英文 kebab-case,由名词 / 名词短语组成,稳定可作文件名。
|
||||
- **长度**:通常 2-5 个词,约 15-50 字符。**太短无信息**(如 `bug`、`chat`、`misc`、`notes-1`)→ 不可用;**太长把进度/卡点/日期都塞进去** → 不可用,那些属于正文与 frontmatter。
|
||||
- **信息含量**:读 stem 即可识别该 event/topic 是什么;进度、状态、上下文不进 stem。
|
||||
- **event 类型**(具体事件 / 任务 / 故障 / 调试 / 发布):stem 标识"是哪件事"。例:`auth-middleware-rewrite`、`ingest-pipeline-oom`。
|
||||
- **topic 类型**(持续性的概念 / 知识领域 / 偏好 / 工具配方):stem 标识"是哪类主题"。例:`pytorch-distributed-training`、`pr-summary-style`。
|
||||
|
||||
#### 4b. 复用 vs 新建
|
||||
|
||||
- 当对话延续一条你在步骤 3 中发现的现有线索时(同一逻辑事件、同一主题),**复用相同的 path**(即今天目录下相同的 stem)。把一条线索碎片化到多个 path 是最严重的失败模式。
|
||||
- 当没有现存笔记覆盖该主题时,**新建一个形如 `daily/<today>/<stem>.md` 的 path**,stem 段遵循 4a 规范。
|
||||
- 一次对话可能跨越多个无关主题——为每个不同的 event/topic 各发一个任务,而不是合成一个巨型任务。
|
||||
|
||||
#### 4c. description 的内容
|
||||
|
||||
description 只负责回答"要保留什么事实"——正文如何分 section、frontmatter 怎么写、UPDATE 如何合并都由写入者处理,不在你的职责内。
|
||||
|
||||
description 是一份扁平的事实清单,列出对话中全部值得保留的内容——关键处逐字引用原始措辞或数字。不要对事实做分类——分到笔记 section 是写入者的工作。
|
||||
|
||||
覆盖范围要全面,包括但不限于:
|
||||
- 用户身份相关的持久事实(角色、项目、职责、工具、偏好、约束、目标)
|
||||
- 领域知识(概念、决策结论与理由、依赖版本、设计约束、系统拓扑)
|
||||
- 可重放的操作(命令序列、脚本配方、工作流、调试步骤)
|
||||
- 当下状态(进度、卡点、下一步、未决问题)
|
||||
- 时间线事件(发生的事件、决策时刻)
|
||||
|
||||
### 步骤 5 — 提交
|
||||
调用 `generate_response`,传入 `memory_updates=[{{path, description}}, ...]`。如果步骤 1 判定跳过,就发出 `[]`。这是你唯一的收尾方式——步骤 4 之后不要再产出自由文本;完成工具的结构化负载就是全部交付物。
|
||||
|
||||
## 边界
|
||||
|
||||
- 你从不写笔记——不调用 `write`、`edit`、`frontmatter_update`。那些工具属于写入者。
|
||||
- 概览时只待在今天的 `daily/` 文件夹——不要走遍整个 vault。
|
||||
- 发出的每个 `path` 必须形如 `daily/<today>/<stem>.md`——绝不写到今天 daily 目录之外。
|
||||
- 每次调用只产出一份 `memory_updates` 列表。不要多次调用 `generate_response`。
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
"""``auto_memory_writer`` — execute daily-note upserts.
|
||||
|
||||
Reads the ``memory_updates`` list produced by ``auto_memory_planner``
|
||||
from ``context.response.metadata['memory_updates']``, then iterates
|
||||
over each ``{path, description}`` task: decides UPDATE vs CREATE by
|
||||
probing the vault, and writes the note via ``frontmatter_read`` /
|
||||
``frontmatter_update`` / ``read`` / ``edit`` / ``write``.
|
||||
|
||||
A fresh ReAct agent is created per task to keep conversations isolated.
|
||||
|
||||
Inputs (from RuntimeContext):
|
||||
messages (list[Msg], required): conversation slice (context).
|
||||
memory_hint (str, optional): caller-supplied note hint.
|
||||
response.metadata['memory_updates'] (list[dict]): planner output.
|
||||
|
||||
Output (written to context.response):
|
||||
answer: one line per task — ``<action> <path>``.
|
||||
metadata['written_count']: number of tasks executed.
|
||||
"""
|
||||
|
||||
from agentscope.agent import ReActAgent
|
||||
from agentscope.message import Msg
|
||||
from agentscope.tool import Toolkit
|
||||
|
||||
from ._evolve import format_history, now
|
||||
from ..base_step import BaseStep
|
||||
from ...components import R
|
||||
|
||||
|
||||
@R.register("auto_memory_writer_step")
|
||||
class AutoMemoryWriterStep(BaseStep):
|
||||
"""Execute note upserts from the planner's task list."""
|
||||
|
||||
def __init__(self, console_enabled: bool = False, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.console_enabled = console_enabled
|
||||
self.writer_tools: list[str] = ["frontmatter_read", "frontmatter_update", "read", "edit", "write"]
|
||||
|
||||
async def execute(self):
|
||||
assert self.context is not None
|
||||
memory_updates: list[dict] = self.context.response.metadata.get("memory_updates") or []
|
||||
if not memory_updates:
|
||||
self.context.response.success = True
|
||||
self.context.response.answer = "[SKIP] No memory updates to write"
|
||||
return
|
||||
|
||||
current = now(self.context.get("timezone"))
|
||||
messages: list[Msg] = [
|
||||
item if isinstance(item, Msg) else Msg.from_dict(item) for item in self.context.get("messages", [])
|
||||
]
|
||||
memory_hint: str = self.context.get("memory_hint", "")
|
||||
|
||||
toolkit = Toolkit()
|
||||
for job_name in self.writer_tools:
|
||||
self.add_as_tool(toolkit, job_name)
|
||||
|
||||
results: list[str] = []
|
||||
for task in memory_updates:
|
||||
note_path = task.get("path", "")
|
||||
description = task.get("description", "")
|
||||
if not note_path or not description:
|
||||
continue
|
||||
|
||||
agent = ReActAgent(
|
||||
name="auto_memory_writer",
|
||||
model=self.as_llm,
|
||||
sys_prompt=self.prompt_format("system_prompt"),
|
||||
formatter=self.as_llm_formatter,
|
||||
toolkit=toolkit,
|
||||
)
|
||||
agent.set_console_output_enabled(self.console_enabled)
|
||||
|
||||
user_message: str = self.prompt_format(
|
||||
"user_message",
|
||||
today=current.strftime("%Y-%m-%d"),
|
||||
vault_dir=str(self.file_store.vault_path),
|
||||
note=memory_hint or "(none)",
|
||||
note_path=note_path,
|
||||
writing_hint=description,
|
||||
history=format_history(messages),
|
||||
)
|
||||
|
||||
final_msg: Msg = await agent.reply(Msg(name="reme", role="user", content=user_message))
|
||||
result_line = (final_msg.get_text_content() or "").strip()
|
||||
results.append(result_line)
|
||||
|
||||
self.context.response.success = True
|
||||
self.context.response.answer = "\n".join(results) if results else "[SKIP] No valid tasks"
|
||||
self.context.response.metadata.update({"written_count": len(results)})
|
||||
|
|
@ -1,167 +0,0 @@
|
|||
system_prompt: |
|
||||
You are the writer of the auto-memory system. The planner hands you exactly one target path + description per call; you create or update the corresponding daily note at that path. You never plan new paths or filenames yourself.
|
||||
|
||||
user_message: |
|
||||
Today: {today}
|
||||
Vault directory: {vault_dir}
|
||||
Extra hint: {note}
|
||||
Target path: {note_path}
|
||||
|
||||
# Writing hint to reference
|
||||
|
||||
{writing_hint}
|
||||
|
||||
# Recent conversation
|
||||
|
||||
{history}
|
||||
|
||||
# Your task
|
||||
|
||||
Referring to the hint above, create or update the note at the target path. Follow the four-step process below.
|
||||
|
||||
## Output format: body
|
||||
|
||||
Your job is to capture the facts from the recent conversation into the body — not a single one may be dropped, nor watered down by paraphrase.
|
||||
|
||||
Coverage must be comprehensive, including but not limited to:
|
||||
- Persistent facts about the user's identity (role, project, responsibilities, tools, preferences, constraints, goals)
|
||||
- Domain knowledge (concepts, decisions and their rationale, dependency versions, design constraints, system topology)
|
||||
- Replayable operations (command sequences, script recipes, workflows, debugging steps)
|
||||
- Current state (progress, blockers, next steps, open questions)
|
||||
- Timeline events (things that happened, decision moments)
|
||||
|
||||
The body format is free-form — use whatever structure best fits the content. The only hard rule is **completeness**: every fact in writing_hint must appear in the body. Quote the original wording or numbers verbatim at critical points.
|
||||
|
||||
Merge rules for UPDATE:
|
||||
- Timeline / history entries: append only — never delete existing entries.
|
||||
- Current-state entries (progress, blockers, next steps): rewrite the whole section to reflect the latest snapshot.
|
||||
- Everything else: merge and dedupe (keep all old facts, add new facts, drop exact duplicates).
|
||||
|
||||
## Output format: frontmatter
|
||||
|
||||
Only two required fields:
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: <must equal the filename stem of the target path, copied verbatim>
|
||||
description: <a detailed summary of the body — specific enough that the description alone conveys all the core information in the note>
|
||||
---
|
||||
```
|
||||
|
||||
Rules:
|
||||
- `name` must be the filename stem of the target path (the part between the last `/` and `.md`), copied verbatim. Do not Title-Case it; do not rewrite it.
|
||||
- `description` must be an exhaustive summary: mention every key fact, decision, and state point in the body, so the description itself works as a reliable index entry. Vague descriptions like "notes" / "misc" / "various topics" are unacceptable.
|
||||
- **Never set `status`** — it is a field reserved for the downstream distill stage; touching it will cause the note to be skipped on the next distill run.
|
||||
- On UPDATE, refresh `description` to reflect the updated body.
|
||||
|
||||
## The four steps for each call
|
||||
|
||||
### Step 1 — Probe
|
||||
Call `frontmatter_read path=<target path>`:
|
||||
- Returns frontmatter dict → **UPDATE branch** (note already exists)
|
||||
- Returns error / not-found → **CREATE branch** (does not yet exist)
|
||||
|
||||
### Step 2 — UPDATE branch
|
||||
1. `read path=<target path>` to view the current body.
|
||||
2. Plan the merge per the rules above (timeline appends, facts merge-and-dedupe, current-state rewritten in full).
|
||||
3. Execute the write — **prefer `edit` over `write`**; only fall back to `write` when the change is genuinely too sweeping for `edit` to express cleanly:
|
||||
- **Default: `edit`** — `edit path=<target path> old=<original snippet> new=<replacement snippet>` for each changed section (frontmatter is unaffected). Use multiple `edit` calls if needed to cover several sections.
|
||||
- After any body change, you must also refresh frontmatter → `frontmatter_update path=<target path> metadata={{"description": "<updated summary>"}}`.
|
||||
- **Fallback: `write`** — only when the body has changed so extensively that multiple edits would be harder to get right than a full rewrite → `write path=<target path> name=<name> description=<description> content=<full body>`, resetting body and frontmatter in one shot.
|
||||
|
||||
### Step 2' — CREATE branch
|
||||
1. Write the full body, capturing every fact from writing_hint.
|
||||
2. Write the frontmatter with `name` and `description`.
|
||||
3. `write path=<target path> name=<name> description=<description> content=<body content>` — done in one call.
|
||||
|
||||
### Step 3 — Summarize what changed
|
||||
State in one sentence what you did (which file you created / what you updated). This sentence is your final text output and must be strictly a single line.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Each call targets exactly one path: the one assigned. Even if the conversation mentions other notes, do not touch them.
|
||||
- `write` unconditionally overwrites both body and frontmatter — use it carefully.
|
||||
|
||||
system_prompt_zh: |
|
||||
你是自动记忆系统的写入者。规划者每次调用会交给你恰好一个目标路径 + description;你在该路径创建或更新对应的日记。你从不规划新的路径或文件名。
|
||||
|
||||
user_message_zh: |
|
||||
今天:{today}
|
||||
Vault 目录:{vault_dir}
|
||||
额外提示:{note}
|
||||
目标路径:{note_path}
|
||||
|
||||
# 可以参考的提示
|
||||
|
||||
{writing_hint}
|
||||
|
||||
# 最近的对话
|
||||
|
||||
{history}
|
||||
|
||||
# 你的任务
|
||||
|
||||
参考上面的提示,在目标路径创建或更新日记。按下面的四步流程执行。
|
||||
|
||||
## 输出格式要求:正文
|
||||
|
||||
你的职责是捕捉最近的对话中的事实写入正文——一条都不能漏,也不能被改写淡化。
|
||||
|
||||
覆盖范围要全面,包括但不限于:
|
||||
- 用户身份相关的持久事实(角色、项目、职责、工具、偏好、约束、目标)
|
||||
- 领域知识(概念、决策结论与理由、依赖版本、设计约束、系统拓扑)
|
||||
- 可重放的操作(命令序列、脚本配方、工作流、调试步骤)
|
||||
- 当下状态(进度、卡点、下一步、未决问题)
|
||||
- 时间线事件(发生的事件、决策时刻)
|
||||
|
||||
正文格式自由——用最适合内容的结构。唯一的硬性规则是**完整性**:writing_hint 中的每一条事实都必须出现在正文中。关键处逐字引用原始措辞或数字。
|
||||
|
||||
UPDATE 时的合并规则:
|
||||
- 时间线 / 历史条目:仅追加,永远不删除已有条目。
|
||||
- 当下状态类条目(进度、卡点、下一步):整段重写,反映最新快照。
|
||||
- 其余内容:合并去重(保留全部旧事实,添加新事实,去除完全重复项)。
|
||||
|
||||
## 输出格式要求:frontmatter
|
||||
|
||||
只有两个必填字段:
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: <必须等于目标路径的文件名 stem,逐字照抄>
|
||||
description: <正文的详细总结——具体到仅凭 description 就能传达笔记中的全部核心信息>
|
||||
---
|
||||
```
|
||||
|
||||
规则:
|
||||
- `name` 必须是目标路径的文件名 stem(最后一个 `/` 与 `.md` 之间的部分),逐字照抄。不要 Title-Case 化,不要改写。
|
||||
- `description` 必须是详尽的总结:提及正文中的每一个关键事实、决策和状态要点,使 description 本身就能作为可靠的索引条目。模糊的描述如 "notes" / "misc" / "各种主题" 不可接受。
|
||||
- **永远不要设置 `status`**——它是下游蒸馏阶段保留的字段;动了它会让笔记在下一次 distill 运行中被忽略。
|
||||
- UPDATE 时,刷新 `description` 以反映更新后的正文内容。
|
||||
|
||||
## 每次调用的四个步骤
|
||||
|
||||
### 步骤 1 — 探测
|
||||
调用 `frontmatter_read path=<目标路径>`:
|
||||
- 返回 frontmatter 字典 → **UPDATE 分支**(笔记已存在)
|
||||
- 返回错误 / not-found → **CREATE 分支**(尚不存在)
|
||||
|
||||
### 步骤 2 — UPDATE 分支
|
||||
1. `read path=<目标路径>` 查看当前正文。
|
||||
2. 按上述规则规划合并(时间线追加,事实合并去重,当下状态整段重写)。
|
||||
3. 执行写入——**优先使用 `edit`,而非 `write`**;仅当变更范围确实过大、edit 难以清晰表达时才退回到 `write`:
|
||||
- **默认:`edit`** — `edit path=<目标路径> old=<原文片段> new=<替换片段>`,对每个变更区域分别调用(frontmatter 不受影响)。需要时可多次调用 `edit` 覆盖多个区域。
|
||||
- 正文变更后,必须同时刷新 frontmatter → `frontmatter_update path=<目标路径> metadata={{"description": "<更新后的总结>"}}`。
|
||||
- **退路:`write`** — 仅当正文改动极大、多次 edit 反而更难准确操作时 → `write path=<目标路径> name=<name> description=<description> content=<完整正文>`,一次性重置正文和 frontmatter。
|
||||
|
||||
### 步骤 2' — CREATE 分支
|
||||
1. 编写完整正文,捕捉 writing_hint 中的全部事实。
|
||||
2. 编写 frontmatter,包含 `name` 和 `description`。
|
||||
3. `write path=<目标路径> name=<name> description=<description> content=<正文内容>`——一次性完成。
|
||||
|
||||
### 步骤 3 — 总结改动内容
|
||||
用一句话说明你做了什么(创建了哪个文件 / 更新了哪些内容)。这句话是你最后一次文本输出,必须严格只有一行。
|
||||
|
||||
## 边界
|
||||
|
||||
- 每次调用只针对一个目标路径:被分配的那个。即使对话里提到其他笔记,也不要碰。
|
||||
- `write` 会无条件覆盖正文和frontmatter,请谨慎使用。
|
||||
|
|
@ -1,18 +1,22 @@
|
|||
"""``daily_create`` — provision a session note under a daily folder: ``daily/<date>/<session_id>.md``.
|
||||
"""``daily_create`` — provision a session note under a daily folder.
|
||||
|
||||
Validates the session_id, mkdirs the day folder, writes an empty-body
|
||||
note with frontmatter ``{name: session_id}`` if (and only if) the file
|
||||
does not already exist, refreshes the day index, and returns the
|
||||
When ``session_id`` is provided: ``daily/<date>/<session_id>.md``
|
||||
When ``session_id`` is empty: ``daily/<date>.md`` (the day-level file)
|
||||
|
||||
Validates the session_id (when non-empty), mkdirs the day folder,
|
||||
writes an empty-body note with frontmatter ``{name, description}``
|
||||
if (and only if) the file does not already exist, refreshes the day
|
||||
index (only when session_id is non-empty), and returns the
|
||||
vault-relative path.
|
||||
|
||||
Idempotent: when the note already exists this is a no-op write (the
|
||||
day index still refreshes — siblings may have changed; cheap
|
||||
self-healing). The caller fills the body via ``file_write`` /
|
||||
``file_edit`` / ``file_append`` (or a native editor); ``daily_create``
|
||||
deliberately does not accept a body.
|
||||
Idempotent: when the note already exists this is a no-op write.
|
||||
The caller fills the body via ``file_write`` / ``file_edit`` /
|
||||
``file_append`` (or a native editor); ``daily_create`` deliberately
|
||||
does not accept a body.
|
||||
|
||||
Inputs:
|
||||
session_id (required, validated) — the note's session identifier (also the file stem)
|
||||
session_id (optional) — the note's session identifier (also the file stem);
|
||||
empty string → day-level file
|
||||
date (optional, ``YYYY-MM-DD``; empty = today)
|
||||
|
||||
Outputs:
|
||||
|
|
@ -32,7 +36,7 @@ from ...components import R
|
|||
|
||||
@R.register("daily_create_step")
|
||||
class DailyCreateStep(BaseStep):
|
||||
"""Provision ``daily/<date>/<session_id>.md`` (idempotent); refresh day index."""
|
||||
"""Provision a daily note (idempotent); refresh day index when applicable."""
|
||||
|
||||
def _fail(self, message: str, **meta) -> None:
|
||||
"""Mark response failed; copy ``meta`` into ``response.metadata``."""
|
||||
|
|
@ -51,16 +55,16 @@ class DailyCreateStep(BaseStep):
|
|||
return session_id, day, daily_dir
|
||||
|
||||
@staticmethod
|
||||
def _empty_note_text(session_id: str) -> str:
|
||||
def _empty_note_text(name: str) -> str:
|
||||
"""Serialize an empty-body markdown note with frontmatter ``{name, description}``; trailing newline."""
|
||||
text = frontmatter.dumps(frontmatter.Post("", name=session_id, description=""))
|
||||
text = frontmatter.dumps(frontmatter.Post("", name=name, description=""))
|
||||
return text if text.endswith("\n") else text + "\n"
|
||||
|
||||
async def _create_if_missing(self, path_abs: Path, session_id: str) -> bool:
|
||||
async def _create_if_missing(self, path_abs: Path, name: str) -> bool:
|
||||
"""Write the empty note only when the file is absent. Returns ``True`` iff a new file was created."""
|
||||
if path_abs.is_file():
|
||||
return False
|
||||
await write_file_safe(path_abs, self._empty_note_text(session_id), encoding="utf-8")
|
||||
await write_file_safe(path_abs, self._empty_note_text(name), encoding="utf-8")
|
||||
return True
|
||||
|
||||
def _set_success(self, payload: dict, created: bool) -> None:
|
||||
|
|
@ -71,27 +75,32 @@ class DailyCreateStep(BaseStep):
|
|||
self.context.response.metadata.update(payload)
|
||||
|
||||
async def execute(self):
|
||||
"""Validate the session_id, provision the note file, refresh the day index, stamp the response."""
|
||||
"""Provision the note file, optionally refresh the day index, stamp the response."""
|
||||
assert self.context is not None
|
||||
session_id, day, daily_dir = self._collect_params()
|
||||
|
||||
err = validate_session_id(session_id)
|
||||
if err:
|
||||
self._fail(err)
|
||||
return None
|
||||
if session_id:
|
||||
err = validate_session_id(session_id)
|
||||
if err:
|
||||
self._fail(err)
|
||||
return None
|
||||
path_rel = f"{daily_dir}/{day}/{session_id}.md"
|
||||
name = session_id
|
||||
else:
|
||||
path_rel = f"{daily_dir}/{day}.md"
|
||||
name = day
|
||||
|
||||
path_rel = f"{daily_dir}/{day}/{session_id}.md"
|
||||
path_abs = (self.vault_path / path_rel).resolve()
|
||||
try:
|
||||
created = await self._create_if_missing(path_abs, session_id)
|
||||
created = await self._create_if_missing(path_abs, name)
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
self._fail(f"create failed: {e}", date=day, session_id=session_id, path=path_rel)
|
||||
return None
|
||||
|
||||
index = await refresh_day_index(self.file_store, day, daily_dir)
|
||||
self._set_success(
|
||||
{"date": day, "session_id": session_id, "path": path_rel, "created": created, "index": index},
|
||||
created,
|
||||
)
|
||||
payload: dict = {"date": day, "session_id": session_id, "path": path_rel, "created": created}
|
||||
if session_id:
|
||||
payload["index"] = await refresh_day_index(self.file_store, day, daily_dir)
|
||||
|
||||
self._set_success(payload, created)
|
||||
self.logger.info(f"[{self.name}] {'created' if created else 'reused'} path={path_rel}")
|
||||
return self.context.response
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
"""Integration test for the auto_memory job (planner + writer end-to-end).
|
||||
"""Integration test for the auto_memory job (single-step).
|
||||
|
||||
Drives the full ``auto_memory`` orchestrator against a real LLM. The scenario
|
||||
seeds one existing daily note covering an ongoing event, then feeds a
|
||||
10-message conversation that:
|
||||
Drives the ``auto_memory`` step against a real LLM. Two scenarios:
|
||||
|
||||
1. continues the existing event with new status / facts (expects an UPDATE
|
||||
that preserves the old facts and appends the new ones), and
|
||||
2. introduces a brand-new topic (expects a CREATE under today's daily folder
|
||||
with a stem distinct from the seeded one).
|
||||
1. **CREATE**: calls ``auto_memory`` with a fresh ``session_id`` and
|
||||
conversation messages. Expects a new note with the key facts.
|
||||
|
||||
2. **UPDATE**: seeds an existing daily note, calls ``auto_memory`` with
|
||||
the same ``session_id`` and new conversation messages. Expects the
|
||||
old facts to survive and new facts to land.
|
||||
|
||||
Requires LLM_API_KEY (and optionally LLM_BASE_URL / LLM_MODEL_NAME) in the
|
||||
environment or a .env file at the repo root. Hits the real Anthropic API.
|
||||
environment or a .env file at the repo root. Hits the real LLM API.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
|
@ -28,10 +28,8 @@ from reme4.utils import load_env
|
|||
|
||||
load_env()
|
||||
|
||||
# Where agent.memory jsonl dumps land — same directory as this test file.
|
||||
DUMP_DIR = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
SEED_STEM = "auth-middleware-rewrite"
|
||||
SEED_BODY = """---
|
||||
name: auth-middleware-rewrite
|
||||
|
|
@ -92,8 +90,8 @@ def _seed_note(vault_root: Path, today: str) -> Path:
|
|||
return path
|
||||
|
||||
|
||||
def _make_messages() -> list[dict]:
|
||||
"""A 10-turn conversation: first half continues the auth event, second half opens a new topic."""
|
||||
def _auth_messages() -> list[dict]:
|
||||
"""Messages continuing the auth middleware thread."""
|
||||
return [
|
||||
{
|
||||
"name": "user",
|
||||
|
|
@ -125,12 +123,16 @@ def _make_messages() -> list[dict]:
|
|||
"对,下一步:周五 2026-05-29 前把 redis 配置改成 volatile-ttl 并重测," "blocked 在 SRE @lihua 的排期。"
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _pytorch_messages() -> list[dict]:
|
||||
"""Messages about a brand-new pytorch topic."""
|
||||
return [
|
||||
{
|
||||
"name": "user",
|
||||
"role": "user",
|
||||
"content": (
|
||||
"切个话题,最近在调 pytorch 分布式训练。结论:DDP 启动推荐用 torchrun," "比 mp.spawn 稳很多。"
|
||||
),
|
||||
"content": ("最近在调 pytorch 分布式训练。结论:DDP 启动推荐用 torchrun," "比 mp.spawn 稳很多。"),
|
||||
},
|
||||
{
|
||||
"name": "assistant",
|
||||
|
|
@ -156,7 +158,7 @@ def _make_messages() -> list[dict]:
|
|||
{
|
||||
"name": "user",
|
||||
"role": "user",
|
||||
"content": "这两件事都先记一下。",
|
||||
"content": "先记一下。",
|
||||
},
|
||||
]
|
||||
|
||||
|
|
@ -168,8 +170,7 @@ def _read_text(p: Path) -> str:
|
|||
class _AgentMemoryRecorder:
|
||||
"""Monkey-patches ReActAgent.__init__ to capture every agent created inside
|
||||
the ``with`` block, then dumps each agent's memory to a jsonl file in
|
||||
DUMP_DIR on exit. One file per agent: ``agent_memory_<idx>_<name>.jsonl``,
|
||||
one message per line as ``Msg.to_dict()``.
|
||||
DUMP_DIR on exit.
|
||||
"""
|
||||
|
||||
def __init__(self, dump_dir: Path, prefix: str = "agent_memory"):
|
||||
|
|
@ -194,12 +195,11 @@ class _AgentMemoryRecorder:
|
|||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
"""Restore the original __init__ and dump all agent memories."""
|
||||
"""Restore the original __init__."""
|
||||
ReActAgent.__init__ = self._orig_init
|
||||
|
||||
async def dump(self) -> list[Path]:
|
||||
"""Dump all agent memories."""
|
||||
# Wipe any prior dumps from this prefix so reruns don't accumulate stale files.
|
||||
for stale in self.dump_dir.glob(f"{self.prefix}_*.jsonl"):
|
||||
stale.unlink()
|
||||
|
||||
|
|
@ -214,8 +214,8 @@ class _AgentMemoryRecorder:
|
|||
return self.dumped_paths
|
||||
|
||||
|
||||
def test_auto_memory_updates_existing_and_creates_new():
|
||||
"""End-to-end: planner survey + UPDATE one seeded note + CREATE one new note."""
|
||||
def test_auto_memory_create():
|
||||
"""CREATE a new note from scratch with a fresh session_id."""
|
||||
|
||||
async def run():
|
||||
with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
|
||||
|
|
@ -223,103 +223,33 @@ def test_auto_memory_updates_existing_and_creates_new():
|
|||
try:
|
||||
vault_root = Path(app.config.vault_dir).absolute()
|
||||
today = _today()
|
||||
seed_path = _seed_note(vault_root, today)
|
||||
seed_before = _read_text(seed_path)
|
||||
assert "legal/compliance" in seed_before
|
||||
|
||||
day_dir = vault_root / "daily" / today
|
||||
files_before = {p.name for p in day_dir.glob("*.md")}
|
||||
assert files_before == {f"{SEED_STEM}.md"}
|
||||
|
||||
messages = _make_messages()
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("[setup] vault_root =", vault_root)
|
||||
print("[setup] today =", today)
|
||||
print("[setup] seed_path =", seed_path)
|
||||
print(f"[setup] seed body ({len(seed_before)} bytes):\n{seed_before}")
|
||||
print(f"[setup] feeding {len(messages)} messages to auto_memory:")
|
||||
for i, m in enumerate(messages, 1):
|
||||
print(f" {i:2d}. [{m['role']}] {m['content']}")
|
||||
print("=" * 70)
|
||||
|
||||
with _AgentMemoryRecorder(DUMP_DIR) as recorder:
|
||||
response = await app.run_job("auto_memory", messages=messages)
|
||||
pytorch_session_id = "pytorch-distributed-training"
|
||||
with _AgentMemoryRecorder(DUMP_DIR, prefix="agent_create") as recorder:
|
||||
response = await app.run_job(
|
||||
"auto_memory",
|
||||
messages=_pytorch_messages(),
|
||||
session_id=pytorch_session_id,
|
||||
)
|
||||
dumped = await recorder.dump()
|
||||
print(f"\n[dump] captured {len(recorder.agents)} agent(s); wrote {len(dumped)} jsonl file(s):")
|
||||
for p in dumped:
|
||||
print(f" - {p}")
|
||||
print(f"[CREATE] agent memory dumped: {p}")
|
||||
|
||||
# --- response-level assertions -------------------------------
|
||||
assert response.success is True, f"job failed: {response.answer!r}"
|
||||
assert response.success is True, f"CREATE job failed: {response.answer!r}"
|
||||
meta = response.metadata or {}
|
||||
memory_updates = meta.get("memory_updates") or []
|
||||
assert meta.get("created") is True, f"Expected created=True, got {meta!r}"
|
||||
assert meta.get("path") == f"daily/{today}/{pytorch_session_id}.md"
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print(f"[planner] planned {len(memory_updates)} update(s):")
|
||||
for u in memory_updates:
|
||||
print(f" - path: {u.get('path')}")
|
||||
print(f" description: {u.get('description')}")
|
||||
print(f"\n[writer] written_count = {meta.get('written_count')}")
|
||||
print(f"[writer] answer:\n{response.answer}")
|
||||
print("=" * 70)
|
||||
pytorch_path = vault_root / meta["path"]
|
||||
assert pytorch_path.is_file(), f"created note not found at {pytorch_path}"
|
||||
|
||||
assert (
|
||||
len(memory_updates) >= 2
|
||||
), f"expected at least 2 planned updates (1 UPDATE + 1 CREATE), got {memory_updates}"
|
||||
|
||||
# Every emitted path must live under today's daily folder.
|
||||
paths = [u["path"] for u in memory_updates]
|
||||
for p in paths:
|
||||
assert p.startswith(f"daily/{today}/") and p.endswith(
|
||||
".md",
|
||||
), f"path {p!r} violates daily/<today>/<stem>.md shape"
|
||||
|
||||
# --- UPDATE branch: seeded note ------------------------------
|
||||
update_path_str = f"daily/{today}/{SEED_STEM}.md"
|
||||
assert (
|
||||
update_path_str in paths
|
||||
), f"planner did not reuse the seeded path {update_path_str!r}; got {paths}"
|
||||
seed_after = _read_text(seed_path)
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print(f"[UPDATE] {seed_path} ({len(seed_before)} → {len(seed_after)} bytes)")
|
||||
print(f"[UPDATE] body after:\n{seed_after}")
|
||||
print("=" * 70)
|
||||
|
||||
# Old facts must survive (timeline append + facts merge-and-dedupe).
|
||||
for old_fact in ("legal/compliance", "RS256", "Alice"):
|
||||
assert (
|
||||
old_fact in seed_after
|
||||
), f"UPDATE dropped pre-existing fact {old_fact!r}\n--- AFTER ---\n{seed_after}"
|
||||
# At least some of the new facts from the conversation must land.
|
||||
new_hits = [
|
||||
needle
|
||||
for needle in ("PR #432", "432", "volatile-ttl", "maxmemory-policy", "2026-05-29")
|
||||
if needle in seed_after
|
||||
]
|
||||
print("[UPDATE] preserved old facts: ['legal/compliance', 'RS256', 'Alice']")
|
||||
print(f"[UPDATE] landed new facts: {new_hits}")
|
||||
assert (
|
||||
len(new_hits) >= 2
|
||||
), f"UPDATE only landed {new_hits!r} of expected new facts\n--- AFTER ---\n{seed_after}"
|
||||
|
||||
# --- CREATE branch: new topic --------------------------------
|
||||
files_after = {p.name for p in day_dir.glob("*.md")}
|
||||
new_files = files_after - files_before
|
||||
print(f"\n[CREATE] new files under daily/{today}/: {sorted(new_files)}")
|
||||
assert new_files, f"no new note created under daily/{today}/; planner paths: {paths}"
|
||||
# Find the file that actually covers the pytorch topic.
|
||||
pytorch_path: Path | None = None
|
||||
for fname in new_files:
|
||||
text = _read_text(day_dir / fname)
|
||||
if any(kw in text for kw in ("torchrun", "NCCL", "pytorch", "mp.spawn")):
|
||||
pytorch_path = day_dir / fname
|
||||
break
|
||||
assert pytorch_path is not None, f"no created note covers the pytorch topic; new files: {new_files}"
|
||||
pytorch_text = _read_text(pytorch_path)
|
||||
|
||||
print("=" * 70)
|
||||
print("\n" + "=" * 70)
|
||||
print(f"[CREATE] {pytorch_path} ({len(pytorch_text)} bytes)")
|
||||
print(f"[CREATE] body:\n{pytorch_text}")
|
||||
print("=" * 70)
|
||||
|
|
@ -332,16 +262,79 @@ def test_auto_memory_updates_existing_and_creates_new():
|
|||
print(f"[CREATE] landed topic facts: {topic_hits}")
|
||||
assert (
|
||||
len(topic_hits) >= 3
|
||||
), f"CREATE only captured {topic_hits!r} of expected new-topic facts\n--- CREATE ---\n{pytorch_text}"
|
||||
# frontmatter sanity: name should equal the file stem, description non-empty.
|
||||
), f"CREATE only captured {topic_hits!r} of expected facts\n--- CREATE ---\n{pytorch_text}"
|
||||
|
||||
stem = pytorch_path.stem
|
||||
assert (
|
||||
f"name: {stem}" in pytorch_text
|
||||
), f"frontmatter name does not match stem {stem!r}\n{pytorch_text[:400]}"
|
||||
print(f"[CREATE] frontmatter name matches stem {stem!r}")
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("✓ test_auto_memory_updates_existing_and_creates_new passed")
|
||||
print("test_auto_memory_create passed")
|
||||
print("=" * 70)
|
||||
finally:
|
||||
await app.close()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_auto_memory_update():
|
||||
"""UPDATE an existing note — old facts must survive, new facts must land."""
|
||||
|
||||
async def run():
|
||||
with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
|
||||
app = await _make_app()
|
||||
try:
|
||||
vault_root = Path(app.config.vault_dir).absolute()
|
||||
today = _today()
|
||||
seed_path = _seed_note(vault_root, today)
|
||||
seed_before = _read_text(seed_path)
|
||||
assert "legal/compliance" in seed_before
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("[setup] vault_root =", vault_root)
|
||||
print("[setup] today =", today)
|
||||
print("[setup] seed_path =", seed_path)
|
||||
print("=" * 70)
|
||||
|
||||
with _AgentMemoryRecorder(DUMP_DIR, prefix="agent_update") as recorder:
|
||||
response = await app.run_job(
|
||||
"auto_memory",
|
||||
messages=_auth_messages(),
|
||||
session_id=SEED_STEM,
|
||||
)
|
||||
dumped = await recorder.dump()
|
||||
for p in dumped:
|
||||
print(f"[UPDATE] agent memory dumped: {p}")
|
||||
|
||||
assert response.success is True, f"UPDATE job failed: {response.answer!r}"
|
||||
meta = response.metadata or {}
|
||||
assert meta.get("created") is False, f"Expected created=False, got {meta!r}"
|
||||
assert meta.get("path") == f"daily/{today}/{SEED_STEM}.md"
|
||||
|
||||
seed_after = _read_text(seed_path)
|
||||
print("\n" + "=" * 70)
|
||||
print(f"[UPDATE] {seed_path} ({len(seed_before)} -> {len(seed_after)} bytes)")
|
||||
print(f"[UPDATE] body after:\n{seed_after}")
|
||||
print("=" * 70)
|
||||
|
||||
for old_fact in ("legal/compliance", "RS256", "Alice"):
|
||||
assert (
|
||||
old_fact in seed_after
|
||||
), f"UPDATE dropped pre-existing fact {old_fact!r}\n--- AFTER ---\n{seed_after}"
|
||||
|
||||
new_hits = [
|
||||
needle
|
||||
for needle in ("PR #432", "432", "volatile-ttl", "maxmemory-policy", "2026-05-29")
|
||||
if needle in seed_after
|
||||
]
|
||||
print(f"[UPDATE] preserved old facts, landed new facts: {new_hits}")
|
||||
assert (
|
||||
len(new_hits) >= 2
|
||||
), f"UPDATE only landed {new_hits!r} of expected new facts\n--- AFTER ---\n{seed_after}"
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("test_auto_memory_update passed")
|
||||
print("=" * 70)
|
||||
finally:
|
||||
await app.close()
|
||||
|
|
@ -351,5 +344,6 @@ def test_auto_memory_updates_existing_and_creates_new():
|
|||
|
||||
if __name__ == "__main__":
|
||||
print("=== auto_memory integration test ===")
|
||||
test_auto_memory_updates_existing_and_creates_new()
|
||||
test_auto_memory_create()
|
||||
test_auto_memory_update()
|
||||
print("\nAll integration tests passed!")
|
||||
|
|
|
|||
|
|
@ -382,18 +382,22 @@ def test_daily_create_rejects_invalid_session_id():
|
|||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_daily_create_rejects_empty_session_id():
|
||||
"""Empty / missing session_id is rejected with a clear message."""
|
||||
def test_daily_create_empty_session_id_creates_day_level_file():
|
||||
"""Empty session_id creates day-level file ``daily/<date>.md``."""
|
||||
|
||||
async def run():
|
||||
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
|
||||
store = await _make_store_with_dailies([])
|
||||
step = daily_create_step.DailyCreateStep(file_store=store)
|
||||
await step(session_id="", date="2026-05-18")
|
||||
assert step.context.response.success is False
|
||||
assert "session_id" in (step.context.response.answer or "").lower()
|
||||
assert step.context.response.success is True
|
||||
meta = step.context.response.metadata
|
||||
assert meta["path"] == "daily/2026-05-18.md"
|
||||
assert meta["session_id"] == ""
|
||||
assert meta["created"] is True
|
||||
assert Path(tmp, "daily", "2026-05-18.md").is_file()
|
||||
await store.close()
|
||||
print("✓ test_daily_create_rejects_empty_session_id passed")
|
||||
print("✓ test_daily_create_empty_session_id_creates_day_level_file passed")
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
|
@ -610,7 +614,7 @@ if __name__ == "__main__":
|
|||
test_daily_create_default_date_is_today()
|
||||
test_daily_create_default_frontmatter_uses_session_id_as_name()
|
||||
test_daily_create_rejects_invalid_session_id()
|
||||
test_daily_create_rejects_empty_session_id()
|
||||
test_daily_create_empty_session_id_creates_day_level_file()
|
||||
test_daily_create_then_skip_round_trip()
|
||||
test_day_index_lists_each_note()
|
||||
test_day_index_includes_note_descriptions()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue