From e4a48fbb5340a72ba95bf2b5235c714b079ac233 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Mon, 11 May 2026 15:13:56 +0800 Subject: [PATCH] =?UTF-8?q?core:=20Session/TaskState=20=E5=8E=9F=E5=AD=90?= =?UTF-8?q?=E5=86=99=20+=20Phase=206=20=E5=8F=8C=E5=B1=82=E8=AE=B0?= =?UTF-8?q?=E5=BF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - core.session.atomic_write_text (tmp + fsync + os.replace) 接管 Session/ TaskState 落盘, 中途异常不留 0 字节; _cleanup_if_empty 放过 *.tmp 孤儿 - core/memory.py: workspace/memory/{core.md, extended/} 双层记忆. core.md 注 system prompt, extended/*.md 索引(标题+绝对路径)注 prompt, 内容靠 read 工具按需拉 - _build_system_prompt 从 build_agent 里提出来, new 和 resume 都走同一段, resume 时覆盖 messages[0] -> memory 演化即时生效 - PROGRESS/DESIGN 同步: §7 platform track 行 + A 阶段完成 + 双层记忆/原子写 + 文件清单到 2429 行 Co-Authored-By: Claude Opus 4.7 (1M context) --- DESIGN.md | 21 +++++++++++++- PROGRESS.md | 48 +++++++++++++++++++++---------- cli.py | 7 ++++- core/memory.py | 76 +++++++++++++++++++++++++++++++++++++++++++++++++ core/session.py | 22 ++++++++++++-- core/task.py | 7 +++-- main.py | 51 ++++++++++++++++++++++++--------- 7 files changed, 195 insertions(+), 37 deletions(-) create mode 100644 core/memory.py diff --git a/DESIGN.md b/DESIGN.md index 7250e0c..b52ca8d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -54,6 +54,10 @@ zcbot/ │ └── models/ │ └── deepseek_v4.yaml # flash + pro 两档 ├── workspace/ +│ ├── memory/ # 双层记忆 (workspace 级,跨 task 共享) +│ │ ├── core.md # 注 system prompt,常驻 +│ │ └── extended/ # 索引(标题+绝对路径)注 prompt,内容靠 read 工具按需拉 +│ │ └── *.md │ └── tasks// │ ├── state.json # TaskState │ ├── messages.json # Session @@ -138,10 +142,25 @@ yaml 是手填的,可能错。`probe` 用真实 LLM 调用对账: **懒创建** —— `build_agent` 新建分支不立刻 save,task_dir 在第一条 user 消息触发 `Session.append → save()` 时才物化(`Session.save` / `TaskState.save` 都 `mkdir(parents=True)`)。启动 REPL 后立刻 `/exit` 磁盘无痕,跨进程也安全(没有"另一个 REPL 刚 build_agent 还没说话就被这个进程当空 task 删掉"的窗口)。 -**REPL 内 task 切换** —— `/new` 开新 task,`/resume [last|]` 切到已有 task(无参数列最近 10 个表格让用户选),`/done /abandon` 改状态,`/desc` 改描述。切走前 `_cleanup_if_empty` 守门:三条都满足才删 task_dir —— ① session 没 user 消息 ② 目录在磁盘上 ③ 目录里只剩 `messages.json`(state.json 存在 = `/done /abandon /desc` 留下的显式痕迹,要保)。 +**REPL 内 task 切换** —— `/new` 开新 task,`/resume [last|]` 切到已有 task(无参数列最近 10 个表格让用户选),`/done /abandon` 改状态,`/desc` 改描述。切走前 `_cleanup_if_empty` 守门:三条都满足才删 task_dir —— ① session 没 user 消息 ② 目录在磁盘上 ③ 目录里只剩 `messages.json`(state.json 存在 = `/done /abandon /desc` 留下的显式痕迹,要保;原子写的 `*.tmp` 孤儿不算)。 + +**原子落盘** —— `Session.save` 和 `TaskState.save` 都走 `core.session.atomic_write_text`:先写 `path.tmp` + `fsync`,再 `os.replace` 到目标。中途异常(磁盘满 / surrogate 编码错 / 进程被杀)不留 0 字节或半文件,老内容保留。 CLI:`chat --mode coding --desc "..." [--resume last|]`;`tasks [--status active|completed|abandoned]` 列任务。 +### 3.7 双层记忆(`core/memory.py`) + +跨 task 共享的事实(用户偏好 / 项目约定 / 模型 quirk 备忘)放 `workspace/memory/`,两层切法: + +| 层 | 文件 | 加载时机 | 适合内容 | +|---|------|---------|---------| +| Core | `workspace/memory/core.md` | 每次 build_agent 拼进 system prompt | 跨任务高频用的精炼事实(几百 token 内) | +| Extended | `workspace/memory/extended/*.md` | 索引(标题+绝对路径)进 prompt,内容靠 `read` 工具按需拉 | 大量低频专题(API 速查 / 历史事件) | + +**system prompt 每次 build_agent 重建**,resume 也走 `_build_system_prompt` 并覆盖 `messages[0]` —— memory 演化即时生效。代价:resume 时上下文里的 system 段可能和上一轮不一样,但跨轮强一致性不是个人 agent 的痛点,memory 时效性更重要。 + +memory 文件由人填(也允许 agent 用 `write` 写)。系统不自动维护 —— 这是和"auto memory"框架的关键差异:**事实由用户判断,不由 LLM 自动总结**(后者噪音和误判风险高)。 + --- ## 4. 模型路由 diff --git a/PROGRESS.md b/PROGRESS.md index c2f7d68..b03c08b 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md` 阅读。本文件只记录 phase 状态、决策偏差、文件量、下一步。 -最后更新:2026-05-08(REPL `/resume` + 懒创建 task_dir + 切换前空清理) +最后更新:2026-05-11(原子写 + Phase 6 双层记忆) --- @@ -15,8 +15,9 @@ | 3 | Hybrid 范式 (run_python) | ✅ | subprocess + 敏感 env 过滤 | | 4 | 演化性能力 | 🟡 | Model Profile + Capability Probing ✅;版本化 prompts 未做 | | 5 | Eval Suite | ⏸ 不做 | 个人工具用 dogfooding 替代,probe 覆盖健康检查 | -| 6 | 长任务工程化 | 🟡 | task + state.json + 中断恢复 ✅;context 压缩、双层记忆未做 | +| 6 | 长任务工程化 | 🟡 | task + state.json + 中断恢复 ✅;双层记忆 ✅;context 压缩未做 | | 7 | 打磨 | ❌ | Docker 沙盒 / 更多 skill / Web UI | +| §7 platform track | Core/Platform 切分(SaaS 化) | 🟡 | A 阶段 loop 事件流化 ✅;B-E 待平台 kickoff 后开 | --- @@ -45,6 +46,21 @@ - **懒创建 task_dir**:`build_agent` 新建分支不再 `session.save()` / `task_state.save()` 占位,推迟到首条 user 消息触发的 `Session.append → save()`。启动 REPL 立刻 `/exit` 磁盘无痕,跨进程安全(没有"另一个 REPL 刚 build_agent 没说话就被本进程当空 task 删"的窗口) - `_cleanup_if_empty` 在切走前(`/exit /quit /new /resume` + Ctrl-C/EOF)守门。三条都满足才删 task_dir:① 无 user 消息 ② 目录在磁盘上 ③ 文件集 ⊆ `{messages.json}`(state.json 存在 = 用户跑过 `/done /abandon /desc` 留下显式痕迹,要保) +**§7 草案 + 对话导出**(2026-05-09 → 05-10): +- DESIGN §7 加 Core/Platform 切分草案(SaaS 化方向):资源模型 `/v1/*` + SSE 事件流、Postgres + 本地盘存储、Per-task Docker 容器 + per-run exec、多租户鉴权与隔离、A-E 五段落地。**§1-§6 personal-tool 路线照走,不阻塞 dogfood** +- `cli.py export [--out path]` + `core/export_docx.py` —— 把 task 对话(user/assistant/tool)倒成 `.docx`,assistant 走 markdown→docx 转换;方便归档/外发 + +**§7 A 阶段:loop 事件流化**(2026-05-11): +- `core/loop.py` 去掉所有 `console.print`,改 `sink.emit({type, ...})`。事件:`llm_start / llm_end / text / tool_call / tool_result / done` +- 新增 `core/sinks.py` + `ConsoleEventSink` 接管渲染:spinner / `[in N out N t Xs]` / assistant Markdown / `tool>(args)` / 结果预览。CLI 行为**零回归** +- 给后续 SSE 铺路:接 HTTP 时换 sink 实现(把 emit 转 yield)即可,loop 一行不用动 + +**原子写 + 双层记忆**(2026-05-11): +- `core.session.atomic_write_text`(tmp + fsync + `os.replace`)接管 `Session.save` / `TaskState.save`,中途异常不留 0 字节;`_cleanup_if_empty` 放过 `*.tmp` 孤儿 +- 新增 `core/memory.py`:`workspace/memory/core.md` 注 system prompt,`extended/*.md` 索引(标题+绝对路径)注 prompt,内容靠 `read` 工具按需拉 +- `_build_system_prompt` 提出来,**new 和 resume 都走同一段**,memory 演化即时生效(resume 时覆盖 `messages[0]`,代价是上一轮的 system 段不再同形,memory 时效性更重要) +- DESIGN §3.7 记法,目录树补 `workspace/memory/` + --- ## 关键决策与偏差 @@ -52,7 +68,7 @@ | 项 | 决策 | 与设计差异 | |---|------|-----------| | 工具基目录 | 用户当前 cwd(读)+ task_dir(写) | system prompt 同时给 cwd 与 task_dir 绝对路径,SKILL.md `` 占位符指向 task_dir | -| Workspace 用途 | `tasks//{state.json, messages.json}` | memory/ 待 Phase 6 双层记忆 | +| Workspace 用途 | `tasks//{state.json, messages.json}` + `memory/{core.md, extended/}` | memory 跨 task 共享 | | Eval Suite | 不做 | 设计为团队场景;个人工具 dogfooding 替代 | | 版本化 prompt | 直接 `general_v1.md`,无 active.md 软链接 | Windows 软链接麻烦,真要切版本时再做 | | run_python 沙盒 | subprocess + env 过滤 | 阶段 1 设计如此;Docker 待 Phase 7 | @@ -64,31 +80,33 @@ ``` core/capabilities.py 71 core/llm.py 89 -core/loop.py 158 ← +markdown 渲染 / spinner 显时长+token +core/loop.py 152 ← §7 A: 去 console.print,改 sink.emit +core/sinks.py 101 ← §7 A 新增: ConsoleEventSink +core/ui.py 38 ← 语义化 console 主题 core/probe.py 243 ← Phase 4 -core/session.py 77 +core/session.py 93 ← +atomic_write_text core/skills.py 81 -core/task.py 63 ← Phase 6 +core/task.py 64 ← Phase 6 +core/memory.py 76 ← Phase 6 双层记忆 +core/export_docx.py 372 ← task 对话导出 .docx tools/base.py 34 tools/fs.py 182 tools/shell.py 94 tools/run_python.py 84 tools/skill_tool.py 45 -main.py 185 ← Phase 6 task 装配 / +task_dir 注入 / -占位 save (懒创建) -cli.py 358 ← +probe / +tasks / +/resume / +空 task 清理 +main.py 210 ← +memory 注入 (_build_system_prompt) +cli.py 439 ← +export 命令 / cleanup 放过 .tmp ───────────────────────────────── -Python 合计 ~1764 行 +Python 合计 ~2429 行 ``` -加上 skills/ppt 下的脚本(~600 行)、SKILL.md / references / config / prompts,总仓库约 2500 行可读源码。 +加上 skills/ppt 下的脚本(~600 行)、SKILL.md / references / config / prompts,总仓库约 3000 行可读源码。 --- ## 下一步候选(性价比排序) -1. **Phase 6 双层记忆**(~半天)—— `workspace/memory/core.md` 注 prompt,`extended/.md` 按需读 +1. **§7 B 阶段:Storage / Executor / DI / KeyProvider**(~1 周)—— Session/TaskState 落 Postgres、Executor 抽象走 docker exec、(tenant_id, user_id) 上下文透传、API key 改 KeyProvider。**等平台 kickoff 时间锁定后开** 2. **Phase 6 context 三层压缩**(~1 天)—— 兜底用,V4 长上下文一般用不到 -3. **小修打磨**(~半小时)—— `Session.save()` 改原子写(tmp + rename),防 surrogate 等异常 truncate -4. **Phase 7 Docker 沙盒**(~1 天)—— 替换 subprocess,run_python 安全升级 -5. **Phase 7 更多 skill / 模型档案**(持续) -6. **Proposal mermaid 流程图预渲染**(~半天,看到第二张图再做)—— 现状是 ASCII 框图走 fenced code 透传 (新宋体 + Consolas + xml:space=preserve),中文与 box drawing 字符宽度对不齐时还是有错位。增强方案: ` ```mermaid ` 块在 `render_docx.py` 里调 `mmdc` (mermaid-cli) → PNG → `add_picture` 嵌入。依赖 Node.js + `npm i -g @mermaid-js/mermaid-cli`,首次配置略麻烦,所以等 ASCII 透传明显不够用再做 +3. **Phase 7 更多 skill / 模型档案**(持续) +4. **Proposal mermaid 流程图预渲染**(~半天,看到第二张图再做)—— 现状是 ASCII 框图走 fenced code 透传 (新宋体 + Consolas + xml:space=preserve),中文与 box drawing 字符宽度对不齐时还是有错位。增强方案: ` ```mermaid ` 块在 `render_docx.py` 里调 `mmdc` (mermaid-cli) → PNG → `add_picture` 嵌入。依赖 Node.js + `npm i -g @mermaid-js/mermaid-cli`,首次配置略麻烦,所以等 ASCII 透传明显不够用再做 diff --git a/cli.py b/cli.py index 30ce2c3..3047f84 100644 --- a/cli.py +++ b/cli.py @@ -42,6 +42,7 @@ def _cleanup_if_empty(task_dir, session, console=None) -> bool: 1) session 没有 user 消息 2) task_dir 在磁盘上(懒创建后,没说话就没目录,直接 no-op) 3) 目录里只剩 messages.json(state.json 存在 = `/done /abandon /desc` 留下的显式痕迹,要保) + 原子写留下的 `*.tmp` 孤儿不算痕迹,放过。 """ if any(m.get("role") == "user" for m in session.messages): return False @@ -51,7 +52,11 @@ def _cleanup_if_empty(task_dir, session, console=None) -> bool: return False if any(p.is_dir() for p in entries): return False - if {p.name for p in entries if p.is_file()} - {"messages.json"}: + meaningful = { + p.name for p in entries + if p.is_file() and not p.name.endswith(".tmp") + } + if meaningful - {"messages.json"}: return False shutil.rmtree(task_dir, ignore_errors=True) if console is not None: diff --git a/core/memory.py b/core/memory.py new file mode 100644 index 0000000..53f7d79 --- /dev/null +++ b/core/memory.py @@ -0,0 +1,76 @@ +"""双层记忆: `workspace/memory/`。 + + core.md —— 注 system prompt,每次都看到。装稳定事实 + (用户偏好 / 常用命令 / 项目约定 / 模型 quirk 备忘等) + extended/.md —— 索引(标题+路径)注 prompt,内容 agent 用 `read` 按需拉。 + 装少数任务才用的专题资料(某 API 速查 / 某历史事件等) + +为什么这样切: + core 一直挂在上下文里,token 成本固定 ⇒ 只放跨任务高频用的精炼内容 + extended 索引只占几行,内容按需付费 ⇒ 适合大量低频专题 + +memory 是 workspace 级别(不是 task 级别)。同一 workspace 的所有 task 共享。 +SaaS 化(§7)后会按 tenant 隔离 —— 接口不变,只换 storage backend。 +""" +from __future__ import annotations + +from pathlib import Path +from typing import List, Tuple + + +def _memory_dir(workspace_dir: Path) -> Path: + return workspace_dir / "memory" + + +def _read_first_title(p: Path) -> str: + """取文件第一个非空 h1/h2 行作为标题;没有就用文件名 stem。""" + try: + for raw in p.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if line.startswith("#"): + return line.lstrip("#").strip() + if line: + return line[:60] + except (OSError, UnicodeDecodeError): + pass + return p.stem + + +def _load_core(workspace_dir: Path) -> str: + p = _memory_dir(workspace_dir) / "core.md" + if not p.is_file(): + return "" + try: + return p.read_text(encoding="utf-8").strip() + except (OSError, UnicodeDecodeError): + return "" + + +def _extended_index(workspace_dir: Path) -> List[Tuple[str, Path]]: + """返回 [(title, abs_path), ...],按文件名排序。""" + ext_dir = _memory_dir(workspace_dir) / "extended" + if not ext_dir.is_dir(): + return [] + items: List[Tuple[str, Path]] = [] + for p in sorted(ext_dir.glob("*.md")): + if p.is_file(): + items.append((_read_first_title(p), p.resolve())) + return items + + +def memory_block(workspace_dir: Path) -> str: + """构造注入 system prompt 的记忆段;两块都空就返回空串。""" + core = _load_core(workspace_dir) + ext = _extended_index(workspace_dir) + if not core and not ext: + return "" + + parts = ["\n\n## 记忆 (workspace 级,跨 task 共享)"] + if core: + parts.append("\n### Core (常驻 prompt)\n") + parts.append(core) + if ext: + parts.append("\n\n### Extended (按需用 `read` 加载)\n") + for title, path in ext: + parts.append(f"- `{path}` — {title}\n") + return "".join(parts) diff --git a/core/session.py b/core/session.py index 47a6a4a..d5ed433 100644 --- a/core/session.py +++ b/core/session.py @@ -11,6 +11,7 @@ from __future__ import annotations import json +import os from pathlib import Path from typing import Any, Dict, List, Optional @@ -25,6 +26,22 @@ def _to_dict(msg: Any) -> Any: return msg +def atomic_write_text(path: Path, text: str, encoding: str = "utf-8") -> None: + """原子写: 先写到 path.tmp 再 os.replace 到 path。 + + 防止写中途异常(磁盘满 / surrogate 编码错 / 进程被杀)留下 0 字节或半文件。 + 单 REPL 单 task 假设下 .tmp 名固定;若上次写崩留下孤儿,本次写会覆盖它。 + `_cleanup_if_empty` 已配合放过 `*.tmp` 文件。 + """ + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + with open(tmp, "w", encoding=encoding, newline="\n") as f: + f.write(text) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, path) + + class Session: def __init__( self, @@ -54,11 +71,10 @@ class Session: def save(self) -> None: if self.path is None: return - self.path.parent.mkdir(parents=True, exist_ok=True) payload = {"meta": self.meta, "messages": self.messages} - self.path.write_text( + atomic_write_text( + self.path, json.dumps(payload, ensure_ascii=False, indent=2), - encoding="utf-8", ) @classmethod diff --git a/core/task.py b/core/task.py index aed061e..5383a30 100644 --- a/core/task.py +++ b/core/task.py @@ -15,6 +15,8 @@ from datetime import datetime from pathlib import Path from typing import Optional +from .session import atomic_write_text + @dataclass class TaskState: @@ -37,11 +39,10 @@ class TaskState: return self.tokens_prompt + self.tokens_completion def save(self, task_dir: Path) -> None: - task_dir.mkdir(parents=True, exist_ok=True) self.updated_at = datetime.now().isoformat(timespec="seconds") - (task_dir / "state.json").write_text( + atomic_write_text( + task_dir / "state.json", json.dumps(asdict(self), ensure_ascii=False, indent=2), - encoding="utf-8", ) @classmethod diff --git a/main.py b/main.py index d8a0556..345af18 100644 --- a/main.py +++ b/main.py @@ -16,6 +16,7 @@ from rich.console import Console from core.capabilities import ModelCapabilities from core.llm import LLM from core.loop import AgentLoop +from core.memory import memory_block from core.session import Session from core.sinks import ConsoleEventSink from core.skills import SkillRegistry @@ -73,6 +74,35 @@ def resolve_task_messages_path( return tdir / sid / "messages.json", sid +def _build_system_prompt( + cfg: dict, + skills: SkillRegistry, + workspace_dir: Path, + tool_base: Path, + task_dir: Path, +) -> str: + """拼 system prompt: 模板 + skill 列表 + memory + 工作目录段。 + + new task 和 resume task 都走这里,memory 演化即时生效。 + """ + prompt = (ROOT / cfg["system_prompt"]).read_text(encoding="utf-8") + if skills.skills: + prompt += f"\n\n## 可用 skill (用 load_skill 加载完整指引)\n{skills.discovery_block()}" + prompt += memory_block(workspace_dir) + task_dir_abs = task_dir.resolve() + prompt += ( + f"\n\n## 工作目录\n" + f"- cwd(用户启动时所在目录,只读用): `{tool_base}`\n" + f"- **task_dir(所有产物写到这里)**: `{task_dir_abs}`\n\n" + f"SKILL 文档里出现的 `` 占位符,一律指上面这个绝对路径。" + f"产物示例: `{task_dir_abs}/spec_lock.md`、" + f"`{task_dir_abs}/sections/01_summary.md`、" + f"`{task_dir_abs}/slides/`、最终 .docx/.pptx。\n" + f"⛔ 不要把产物写到 cwd / `skills/` / repo 根 —— 只写到 task_dir。" + ) + return prompt + + def build_agent( model_name: Optional[str] = None, workspace: Optional[str] = None, @@ -99,8 +129,15 @@ def build_agent( task_dir = session_path.parent + system_prompt = _build_system_prompt(cfg, skills, workspace_dir, tool_base, task_dir) + if resume: session = Session.load(session_path) + # 用最新 memory + skill 列表刷新 system prompt(messages[0]),memory 演化即时生效 + if session.messages and session.messages[0].get("role") == "system": + session.messages[0]["content"] = system_prompt + else: + session.messages.insert(0, {"role": "system", "content": system_prompt}) saved_cwd = session.meta.get("cwd") if saved_cwd and console is not None and saved_cwd != str(tool_base): console.print( @@ -124,20 +161,6 @@ def build_agent( ) task_state.save(task_dir) else: - system_prompt = (ROOT / cfg["system_prompt"]).read_text(encoding="utf-8") - if skills.skills: - system_prompt += f"\n\n## 可用 skill (用 load_skill 加载完整指引)\n{skills.discovery_block()}" - task_dir_abs = task_dir.resolve() - system_prompt += ( - f"\n\n## 工作目录\n" - f"- cwd(用户启动时所在目录,只读用): `{tool_base}`\n" - f"- **task_dir(所有产物写到这里)**: `{task_dir_abs}`\n\n" - f"SKILL 文档里出现的 `` 占位符,一律指上面这个绝对路径。" - f"产物示例: `{task_dir_abs}/spec_lock.md`、" - f"`{task_dir_abs}/sections/01_summary.md`、" - f"`{task_dir_abs}/slides/`、最终 .docx/.pptx。\n" - f"⛔ 不要把产物写到 cwd / `skills/` / repo 根 —— 只写到 task_dir。" - ) now_iso = datetime.now().isoformat(timespec="seconds") meta = { "id": sid,