core: Session/TaskState 原子写 + Phase 6 双层记忆
- 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) <noreply@anthropic.com>
This commit is contained in:
parent
375bb2999c
commit
e4a48fbb53
21
DESIGN.md
21
DESIGN.md
|
|
@ -54,6 +54,10 @@ zcbot/
|
||||||
│ └── models/
|
│ └── models/
|
||||||
│ └── deepseek_v4.yaml # flash + pro 两档
|
│ └── deepseek_v4.yaml # flash + pro 两档
|
||||||
├── workspace/
|
├── workspace/
|
||||||
|
│ ├── memory/ # 双层记忆 (workspace 级,跨 task 共享)
|
||||||
|
│ │ ├── core.md # 注 system prompt,常驻
|
||||||
|
│ │ └── extended/ # 索引(标题+绝对路径)注 prompt,内容靠 read 工具按需拉
|
||||||
|
│ │ └── *.md
|
||||||
│ └── tasks/<task_id>/
|
│ └── tasks/<task_id>/
|
||||||
│ ├── state.json # TaskState
|
│ ├── state.json # TaskState
|
||||||
│ ├── messages.json # Session
|
│ ├── 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 删掉"的窗口)。
|
**懒创建** —— `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|<id>]` 切到已有 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|<id>]` 切到已有 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|<id>]`;`tasks [--status active|completed|abandoned]` 列任务。
|
CLI:`chat --mode coding --desc "..." [--resume last|<id>]`;`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. 模型路由
|
## 4. 模型路由
|
||||||
|
|
|
||||||
48
PROGRESS.md
48
PROGRESS.md
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
> 配合 `DESIGN.md` 阅读。本文件只记录 phase 状态、决策偏差、文件量、下一步。
|
> 配合 `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 过滤 |
|
| 3 | Hybrid 范式 (run_python) | ✅ | subprocess + 敏感 env 过滤 |
|
||||||
| 4 | 演化性能力 | 🟡 | Model Profile + Capability Probing ✅;版本化 prompts 未做 |
|
| 4 | 演化性能力 | 🟡 | Model Profile + Capability Probing ✅;版本化 prompts 未做 |
|
||||||
| 5 | Eval Suite | ⏸ 不做 | 个人工具用 dogfooding 替代,probe 覆盖健康检查 |
|
| 5 | Eval Suite | ⏸ 不做 | 个人工具用 dogfooding 替代,probe 覆盖健康检查 |
|
||||||
| 6 | 长任务工程化 | 🟡 | task + state.json + 中断恢复 ✅;context 压缩、双层记忆未做 |
|
| 6 | 长任务工程化 | 🟡 | task + state.json + 中断恢复 ✅;双层记忆 ✅;context 压缩未做 |
|
||||||
| 7 | 打磨 | ❌ | Docker 沙盒 / 更多 skill / Web UI |
|
| 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 删"的窗口)
|
- **懒创建 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` 留下显式痕迹,要保)
|
- `_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 <task_id> [--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>` 占位符指向 task_dir |
|
| 工具基目录 | 用户当前 cwd(读)+ task_dir(写) | system prompt 同时给 cwd 与 task_dir 绝对路径,SKILL.md `<task_dir>` 占位符指向 task_dir |
|
||||||
| Workspace 用途 | `tasks/<id>/{state.json, messages.json}` | memory/ 待 Phase 6 双层记忆 |
|
| Workspace 用途 | `tasks/<id>/{state.json, messages.json}` + `memory/{core.md, extended/}` | memory 跨 task 共享 |
|
||||||
| Eval Suite | 不做 | 设计为团队场景;个人工具 dogfooding 替代 |
|
| Eval Suite | 不做 | 设计为团队场景;个人工具 dogfooding 替代 |
|
||||||
| 版本化 prompt | 直接 `general_v1.md`,无 active.md 软链接 | Windows 软链接麻烦,真要切版本时再做 |
|
| 版本化 prompt | 直接 `general_v1.md`,无 active.md 软链接 | Windows 软链接麻烦,真要切版本时再做 |
|
||||||
| run_python 沙盒 | subprocess + env 过滤 | 阶段 1 设计如此;Docker 待 Phase 7 |
|
| run_python 沙盒 | subprocess + env 过滤 | 阶段 1 设计如此;Docker 待 Phase 7 |
|
||||||
|
|
@ -64,31 +80,33 @@
|
||||||
```
|
```
|
||||||
core/capabilities.py 71
|
core/capabilities.py 71
|
||||||
core/llm.py 89
|
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/probe.py 243 ← Phase 4
|
||||||
core/session.py 77
|
core/session.py 93 ← +atomic_write_text
|
||||||
core/skills.py 81
|
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/base.py 34
|
||||||
tools/fs.py 182
|
tools/fs.py 182
|
||||||
tools/shell.py 94
|
tools/shell.py 94
|
||||||
tools/run_python.py 84
|
tools/run_python.py 84
|
||||||
tools/skill_tool.py 45
|
tools/skill_tool.py 45
|
||||||
main.py 185 ← Phase 6 task 装配 / +task_dir 注入 / -占位 save (懒创建)
|
main.py 210 ← +memory 注入 (_build_system_prompt)
|
||||||
cli.py 358 ← +probe / +tasks / +/resume / +空 task 清理
|
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/<topic>.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 长上下文一般用不到
|
2. **Phase 6 context 三层压缩**(~1 天)—— 兜底用,V4 长上下文一般用不到
|
||||||
3. **小修打磨**(~半小时)—— `Session.save()` 改原子写(tmp + rename),防 surrogate 等异常 truncate
|
3. **Phase 7 更多 skill / 模型档案**(持续)
|
||||||
4. **Phase 7 Docker 沙盒**(~1 天)—— 替换 subprocess,run_python 安全升级
|
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 透传明显不够用再做
|
||||||
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 透传明显不够用再做
|
|
||||||
|
|
|
||||||
7
cli.py
7
cli.py
|
|
@ -42,6 +42,7 @@ def _cleanup_if_empty(task_dir, session, console=None) -> bool:
|
||||||
1) session 没有 user 消息
|
1) session 没有 user 消息
|
||||||
2) task_dir 在磁盘上(懒创建后,没说话就没目录,直接 no-op)
|
2) task_dir 在磁盘上(懒创建后,没说话就没目录,直接 no-op)
|
||||||
3) 目录里只剩 messages.json(state.json 存在 = `/done /abandon /desc` 留下的显式痕迹,要保)
|
3) 目录里只剩 messages.json(state.json 存在 = `/done /abandon /desc` 留下的显式痕迹,要保)
|
||||||
|
原子写留下的 `*.tmp` 孤儿不算痕迹,放过。
|
||||||
"""
|
"""
|
||||||
if any(m.get("role") == "user" for m in session.messages):
|
if any(m.get("role") == "user" for m in session.messages):
|
||||||
return False
|
return False
|
||||||
|
|
@ -51,7 +52,11 @@ def _cleanup_if_empty(task_dir, session, console=None) -> bool:
|
||||||
return False
|
return False
|
||||||
if any(p.is_dir() for p in entries):
|
if any(p.is_dir() for p in entries):
|
||||||
return False
|
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
|
return False
|
||||||
shutil.rmtree(task_dir, ignore_errors=True)
|
shutil.rmtree(task_dir, ignore_errors=True)
|
||||||
if console is not None:
|
if console is not None:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
"""双层记忆: `workspace/memory/`。
|
||||||
|
|
||||||
|
core.md —— 注 system prompt,每次都看到。装稳定事实
|
||||||
|
(用户偏好 / 常用命令 / 项目约定 / 模型 quirk 备忘等)
|
||||||
|
extended/<x>.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)
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
|
@ -25,6 +26,22 @@ def _to_dict(msg: Any) -> Any:
|
||||||
return msg
|
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:
|
class Session:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|
@ -54,11 +71,10 @@ class Session:
|
||||||
def save(self) -> None:
|
def save(self) -> None:
|
||||||
if self.path is None:
|
if self.path is None:
|
||||||
return
|
return
|
||||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
payload = {"meta": self.meta, "messages": self.messages}
|
payload = {"meta": self.meta, "messages": self.messages}
|
||||||
self.path.write_text(
|
atomic_write_text(
|
||||||
|
self.path,
|
||||||
json.dumps(payload, ensure_ascii=False, indent=2),
|
json.dumps(payload, ensure_ascii=False, indent=2),
|
||||||
encoding="utf-8",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from .session import atomic_write_text
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class TaskState:
|
class TaskState:
|
||||||
|
|
@ -37,11 +39,10 @@ class TaskState:
|
||||||
return self.tokens_prompt + self.tokens_completion
|
return self.tokens_prompt + self.tokens_completion
|
||||||
|
|
||||||
def save(self, task_dir: Path) -> None:
|
def save(self, task_dir: Path) -> None:
|
||||||
task_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
self.updated_at = datetime.now().isoformat(timespec="seconds")
|
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),
|
json.dumps(asdict(self), ensure_ascii=False, indent=2),
|
||||||
encoding="utf-8",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
|
||||||
51
main.py
51
main.py
|
|
@ -16,6 +16,7 @@ from rich.console import Console
|
||||||
from core.capabilities import ModelCapabilities
|
from core.capabilities import ModelCapabilities
|
||||||
from core.llm import LLM
|
from core.llm import LLM
|
||||||
from core.loop import AgentLoop
|
from core.loop import AgentLoop
|
||||||
|
from core.memory import memory_block
|
||||||
from core.session import Session
|
from core.session import Session
|
||||||
from core.sinks import ConsoleEventSink
|
from core.sinks import ConsoleEventSink
|
||||||
from core.skills import SkillRegistry
|
from core.skills import SkillRegistry
|
||||||
|
|
@ -73,6 +74,35 @@ def resolve_task_messages_path(
|
||||||
return tdir / sid / "messages.json", sid
|
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 文档里出现的 `<task_dir>` 占位符,一律指上面这个绝对路径。"
|
||||||
|
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(
|
def build_agent(
|
||||||
model_name: Optional[str] = None,
|
model_name: Optional[str] = None,
|
||||||
workspace: Optional[str] = None,
|
workspace: Optional[str] = None,
|
||||||
|
|
@ -99,8 +129,15 @@ def build_agent(
|
||||||
|
|
||||||
task_dir = session_path.parent
|
task_dir = session_path.parent
|
||||||
|
|
||||||
|
system_prompt = _build_system_prompt(cfg, skills, workspace_dir, tool_base, task_dir)
|
||||||
|
|
||||||
if resume:
|
if resume:
|
||||||
session = Session.load(session_path)
|
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")
|
saved_cwd = session.meta.get("cwd")
|
||||||
if saved_cwd and console is not None and saved_cwd != str(tool_base):
|
if saved_cwd and console is not None and saved_cwd != str(tool_base):
|
||||||
console.print(
|
console.print(
|
||||||
|
|
@ -124,20 +161,6 @@ def build_agent(
|
||||||
)
|
)
|
||||||
task_state.save(task_dir)
|
task_state.save(task_dir)
|
||||||
else:
|
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 文档里出现的 `<task_dir>` 占位符,一律指上面这个绝对路径。"
|
|
||||||
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")
|
now_iso = datetime.now().isoformat(timespec="seconds")
|
||||||
meta = {
|
meta = {
|
||||||
"id": sid,
|
"id": sid,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue