Compare commits
5 Commits
263cdb974a
...
b4df60062e
| Author | SHA1 | Date |
|---|---|---|
|
|
b4df60062e | |
|
|
4560a95fac | |
|
|
e4a48fbb53 | |
|
|
375bb2999c | |
|
|
f6c3492514 |
|
|
@ -0,0 +1,7 @@
|
|||
# zcbot 开发笔记 (给 Claude Code)
|
||||
|
||||
## 环境
|
||||
|
||||
- **Python 虚拟环境**: `.venv/`(项目根目录下),所有依赖装在里面
|
||||
- 跑脚本 / 测试一律用 `.venv/Scripts/python.exe ...`,**不要用全局 `python`**(没装 litellm/python-pptx 等会报 ModuleNotFoundError)
|
||||
- requirements 见 `requirements.txt`
|
||||
320
DESIGN.md
320
DESIGN.md
|
|
@ -13,13 +13,14 @@
|
|||
- **编码**:文件编辑、shell 执行、迭代验证
|
||||
|
||||
### 不做什么
|
||||
- 子 agent / IM 渠道 / 多用户 / Web UI(初期 CLI 即可)/ 自定义 RAG / 锁定 Anthropic
|
||||
- 子 agent / IM 渠道 / 自定义 RAG / 锁定 Anthropic(注:多用户 / Web UI 是 §7 SaaS 化路线,personal-tool 阶段不做)
|
||||
- **Eval Suite**:个人工具用 dogfooding 判断模型升级,造作 case 没区分度
|
||||
|
||||
### 关键约束
|
||||
- 模型自由:LiteLLM 接 OpenAI-compatible 任意 provider(默认 DeepSeek V4)
|
||||
- 任务持久化:任意时刻关机,下次能恢复
|
||||
- 演化性:模型升级时 agent 跟着升级,不需要大改架构
|
||||
- **形态兼容**:本地 CLI 与 SaaS 共享同一份 core 和同一种 storage(PG,无 SQLite / JSON 分支);CLI 长期保留(本地直跑 + `--remote` API client 双模式),不会被 HTTP API 取代(详 §7.0)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -54,9 +55,11 @@ zcbot/
|
|||
│ └── models/
|
||||
│ └── deepseek_v4.yaml # flash + pro 两档
|
||||
├── workspace/
|
||||
│ └── tasks/<task_id>/
|
||||
│ ├── state.json # TaskState
|
||||
│ ├── messages.json # Session
|
||||
│ ├── memory/ # 双层记忆 (workspace 级,跨 task 共享)
|
||||
│ │ ├── core.md # 注 system prompt,常驻
|
||||
│ │ └── extended/ # 索引(标题+绝对路径)注 prompt,内容靠 read 工具按需拉
|
||||
│ │ └── *.md
|
||||
│ └── tasks/<task_id>/ # task_dir:仅 skill 产物,state/messages 在 PG
|
||||
│ ├── spec_lock.md # skill 阶段一产物 (proposal/ppt)
|
||||
│ ├── source/ # proposal 用户素材 (PDF / 团队介绍)
|
||||
│ ├── source.md # ppt 转过的素材
|
||||
|
|
@ -70,7 +73,7 @@ zcbot/
|
|||
**task_dir = `workspace/tasks/<task_id>/`,所有 skill 产物都写到这里**。task_dir 绝对路径在 system prompt 里显式给 agent,SKILL.md 的 `<task_dir>` 占位符指向它。如果 agent 写错位置(写到 cwd / `skills/` / repo 根),git status 会立刻报红 —— `.gitignore` 不再用无锚通配规则盖住污染。
|
||||
|
||||
### 启动时拼装顺序
|
||||
1. 读 `config/agent.yaml` 拿 default_model
|
||||
1. 读 `config/agent.yaml` 拿 default_model;`ZCBOT_DB_URL` 环境变量指向 PG(本地 dev 连远端测试 PG 或 docker compose 起的本地 PG;两形态同一种 schema)
|
||||
2. `ModelCapabilities.load("deepseek_v4.flash", config/models/)` 拿能力档案
|
||||
3. `LLM(caps)` 构造,从 env 读 API key
|
||||
4. 解析 task_dir(新建 or resume)
|
||||
|
|
@ -130,17 +133,38 @@ yaml 是手填的,可能错。`probe` 用真实 LLM 调用对账:
|
|||
|
||||
### 3.6 Session 与 Task
|
||||
|
||||
**Session**(`core/session.py`)= 消息列表 + meta + 落 `messages.json`。
|
||||
**Session**(`core/session.py`)= 消息列表 + meta,**直接 ORM 写 PG `messages` 表**(append-only,`jsonb` 存 LiteLLM 原样 payload)。
|
||||
|
||||
**Task**(`core/task.py`)= Session 的上层概念,含 mode / description / status (active/completed/abandoned) / model / reasoning_effort / cwd / created_at / updated_at / tokens_prompt / tokens_completion。落 `state.json`。
|
||||
**Task**(`core/task.py`)= Session 的上层概念,含 mode / description / status (active/completed/abandoned) / model / reasoning_effort / task_dir / created_at / updated_at / tokens_prompt / tokens_completion。**直接 ORM 写 PG `tasks` 表**。
|
||||
|
||||
存储:`workspace/tasks/<task_id>/{state.json, messages.json}`。每轮 `agent.run` 后调 `sync_task_tokens` 把 LLM 累计 tokens 写回。
|
||||
存储:Session / Task → PG;task_dir FS 目录只存 skill 产物(spec_lock / sections / *.docx / *.pptx 等),不再有 `state.json` / `messages.json`。每轮 `agent.run` 后 `sync_task_tokens` UPDATE 累计 tokens。**本地 + SaaS 同一份 schema 和 ORM 实现,无 adapter 抽象层**,差别只在 `ZCBOT_DB_URL`(本地连 docker compose 起的 PG / 远端 dev PG,SaaS 连生产 PG)。
|
||||
|
||||
**懒创建** —— `build_agent` 新建分支不立刻 save,task_dir 在第一条 user 消息触发 `Session.append → save()` 时才物化(`Session.save` / `TaskState.save` 都 `mkdir(parents=True)`)。启动 REPL 后立刻 `/exit` 磁盘无痕,跨进程也安全(没有"另一个 REPL 刚 build_agent 还没说话就被这个进程当空 task 删掉"的窗口)。
|
||||
**懒创建** —— `build_agent` 新建分支不立刻 INSERT,Task / Session 在第一条 user 消息触发 `Session.append` 时才 INSERT;task_dir FS 目录在 skill 第一次落产物时 `mkdir(parents=True)`。启动 REPL 后立刻 `/exit` 不留 DB 行 + 不留 FS 目录,跨进程安全。
|
||||
|
||||
**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` 守门:DB 里该 task 没 messages 行 **且** FS task_dir 没产物 → DELETE tasks 行 + rmdir task_dir;任一痕迹存在则保留。
|
||||
|
||||
CLI:`chat --mode coding --desc "..." [--resume last|<id>]`;`tasks [--status active|completed|abandoned]` 列任务。
|
||||
**原子性** —— PG INSERT 天然原子,messages / tasks 写入无 0 字节风险。skill 产物(spec_lock.md / sections/*.md 等)仍走 `core.session.atomic_write_text`(tmp + fsync + replace),避免大文件写一半留半文件。
|
||||
|
||||
CLI:`chat --mode coding --desc "..." [--resume last|<id>] [--remote <url>]`;`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 自动总结**(后者噪音和误判风险高)。
|
||||
|
||||
**形态兼容** —— memory **永远在 FS,不入 DB**:
|
||||
- 本地形态:`workspace/memory/{core.md, extended/}`
|
||||
- SaaS 形态:`<storage_root>/users/<user_id>/memory/{core.md, extended/}`(bind mount 进容器)
|
||||
|
||||
理由:① memory 本质是"用户笔记",FS 读写 + 编辑器手编是产品语义的一部分,DB 化反而要造一层 UI 让用户改 md;② 跨 task 共享靠"同一 user 看同一份目录"语义自动达成,不需要 schema 设计;③ 不参与 §7.4 表结构,task 删/folder 删都不连带 memory。memory 不分 folder,是 per-user 单一命名空间。
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -200,11 +224,11 @@ default_model: deepseek_v4.flash
|
|||
### 已知风险
|
||||
| 风险 | 缓解 |
|
||||
|-----|------|
|
||||
| run_python subprocess 沙盒不够强 | 限制工作目录 + 敏感 env 过滤;后续可升级 Docker |
|
||||
| run_python subprocess 沙盒不够强(本地形态非真隔离) | 限制工作目录 + 敏感 env 过滤;SaaS 形态走 docker exec(§7.6 #6),本地依赖用户对模型生成代码的最终审阅 |
|
||||
| V4 在某些复杂任务不如 Claude | dogfooding 判断,fallback 手动切 |
|
||||
| Skill description 不够好 → 触发不准 | 用 Pro 优化 description,实战观察 |
|
||||
| Long context 退化 | `probe --long-context` 探测可靠 ceiling,不依赖宣称值 |
|
||||
| `Session.save()` 不原子,异常会留 0 字节文件 | 后续改 tmp + rename(已记 PROGRESS) |
|
||||
| 本地 PG 连接不稳定 / 离线 dogfood | `docker compose up -d` 一行起本地 PG 兜底;也可连远端 dev / staging PG;CI 用 ephemeral PG container |
|
||||
|
||||
### 取舍说明
|
||||
**为什么用 Hybrid 范式而不是纯 CodeAgent**:V4 JSON tool call 已稳定;沙盒成本只在需要时付;兼容 thinking 模式。
|
||||
|
|
@ -217,6 +241,276 @@ default_model: deepseek_v4.flash
|
|||
|
||||
---
|
||||
|
||||
## 7. SaaS 化(草案,status=design,2026-05-12)
|
||||
|
||||
> §1-§6 是 **本地 dogfood 形态**;本节是 **SaaS 形态**,把 core 包成多用户在线服务。
|
||||
> 不引入 platform/core 切分 —— core 就是后端,直接对用户做 auth(原"平台签 JWT、core 验签"多租户方案废弃)。两条形态共享同一份 core,差别只在 CLI 入口 vs HTTP 入口。本节落地前 §1-§6 路线照走,不阻塞 dogfood。
|
||||
|
||||
### 7.0 与本地形态的兼容性
|
||||
|
||||
SaaS 化不是"重写"也不是"取代 CLI",而是**给同一份 core 加一个 HTTP 入口**。落地过程中本地 CLI 必须始终可用。
|
||||
|
||||
**两条形态共享**:
|
||||
- 同一份 `core/`(loop / capabilities / skills / memory / storage 接口)
|
||||
- 同一份 `tools/`(底层 executor 从 subprocess 换 docker exec,接口不变)
|
||||
- 同一份 SKILL.md 和 prompts
|
||||
|
||||
**两条形态差别**:
|
||||
|
||||
| 维度 | 本地形态 | SaaS 形态 |
|
||||
|---|---|---|
|
||||
| 入口 | `cli.py chat ...` 直调 core | HTTP `/v1/...` + SSE |
|
||||
| Storage | **PG**(`ZCBOT_DB_URL` 指 docker compose / 远端 dev PG) | **PG**(`ZCBOT_DB_URL` 指生产 PG) |
|
||||
| task_dir 根 | `workspace/tasks/<task_id>/`(派生,task 私有) | `<storage_root>/users/<user_id>/<task_dir>/`(用户给,可共享) |
|
||||
| Memory | `workspace/memory/`(FS) | `<storage_root>/users/<user_id>/memory/`(仍是 FS) |
|
||||
| Sandbox | subprocess + env 过滤(非真隔离) | per-task docker exec |
|
||||
| Auth | 无(单用户 `user_id='local'`) | OIDC + JWT(user_id) |
|
||||
|
||||
**CLI 长期双模式**:
|
||||
- **本地直跑**:`cli.py chat`(默认),直接调 core in-process,直连 PG。适合 dogfood / 调 core 内部状态
|
||||
- **API client**:`cli.py chat --remote https://...`,走 HTTP /v1,跟前端用户路径一致
|
||||
|
||||
两模式共用 `cli.py` 入口,差别只在 transport 层(in-process call vs HTTP)。dogfood ≡ 真实用户路径只在 `--remote` 模式下成立;**本地直跑模式永久保留**(调试 core 内部状态比 HTTP roundtrip 顺手)。
|
||||
|
||||
**本地 PG 连接** —— `ZCBOT_DB_URL` 指向 docker compose 起的本地 PG(`docker compose up -d` 一行起,repo 自带 `docker-compose.yml`)或远端 dev / staging PG。**离线场景靠本地 docker compose 兜底**,不靠"零依赖"幻觉。
|
||||
|
||||
`workspace/` 目录:仅存 skill 产物(spec_lock / sections / *.docx / *.pptx),state / messages 全在 PG。本地 vs SaaS 差别只在 task_dir 根路径,不在 storage 形态。
|
||||
|
||||
### 7.1 心智模型:Folder-centric,task-as-DB-record
|
||||
|
||||
参考 Claude Code(cwd 是 anchor,状态存别处)+ OpenAI Assistants(stateful agent service)。
|
||||
|
||||
- **Folder** = 用户的"硬盘",路径 `users/<user_id>/<user-defined>/...`。能浏览、新建、改名、上传、下载,**和本地文件管理器体感一致**。folder 没 ID,**path 就是标识**;改名走 prefix cascade。
|
||||
- **Task** = DB 一行,带 `task_dir` 指向 folder(相对 user root)。同 folder 允许多 task,但 task 之间**不允许嵌套**(no-subtask)。
|
||||
- **Messages** = DB 表,append-only,`jsonb` 存 LiteLLM 原样 payload。
|
||||
- **Skill 运行产物** 全落 cwd,不引入 artifacts 表;终稿后 SKILL.md 指示 agent 清中间件。
|
||||
- **Skill 定义** 是项目代码,跟部署走,所有用户共享,不入用户 folder。
|
||||
|
||||
**task_dir 在两形态的对应**(§7.0 总览的展开):
|
||||
- 本地形态:`task_dir = workspace/tasks/<task_id>/`(派生,task 私有,无并发写冲突)
|
||||
- SaaS 形态:`task_dir = <storage_root>/users/<user_id>/<user-given-path>/`(用户给,可被同 user 多 task 共享)
|
||||
|
||||
state / messages **两形态都在 PG**,FS 只承担 skill 产物(sections / *.docx / 中间件)。多 task 共享同 folder 时由 §7.8 文件级悲观锁兜底(并发写同名文件冲突早失败,推到模型自纠)。
|
||||
|
||||
### 7.2 资源模型与接口(/v1)
|
||||
|
||||
```
|
||||
POST /v1/folders 创建
|
||||
GET /v1/folders 列树
|
||||
GET /v1/folders/{path} 详情(task 列表 + 文件列表)
|
||||
PATCH /v1/folders/{path} 改名/移动(prefix cascade)
|
||||
DELETE /v1/folders/{path} hard cascade(连带 task+messages,前端二确认)
|
||||
|
||||
POST /v1/folders/{path}/files 上传(multipart)
|
||||
GET /v1/folders/{path}/files[/{name}] 列 / 下载
|
||||
DELETE /v1/folders/{path}/files/{name}
|
||||
|
||||
POST /v1/tasks 创建({task_dir, mode, desc, model})
|
||||
GET /v1/tasks 列(?task_dir= ?status= 过滤)
|
||||
GET /v1/tasks/{id} 详情
|
||||
PATCH /v1/tasks/{id} 改 mode/desc/status
|
||||
DELETE /v1/tasks/{id} 删 task(messages 一起删,不动 cwd 文件)
|
||||
|
||||
POST /v1/tasks/{id}/messages 发消息,返回 {run_id}
|
||||
GET /v1/tasks/{id}/messages 历史(?search= 走 jsonb GIN / tsvector)
|
||||
GET /v1/tasks/{id}/runs/{run_id}/events SSE 事件流
|
||||
POST /v1/tasks/{id}/runs/{run_id}/cancel
|
||||
|
||||
GET /v1/skills | /v1/models | /v1/usage
|
||||
POST /v1/probe (admin) 跑 capability probe
|
||||
```
|
||||
|
||||
**SSE 事件**:`tool_call` / `tool_result` / `text` (delta) / `usage` / `done`,带 `run_id`。
|
||||
|
||||
**版本化**:`/v1` minor 半年内向后兼容,major 6 个月 deprecation。
|
||||
|
||||
### 7.3 认证模型
|
||||
|
||||
OIDC / Clerk / 自建邮箱登录,JWT 只带 `user_id` claim:
|
||||
|
||||
```
|
||||
Authorization: Bearer <user_jwt>
|
||||
X-Request-Id: <uuid>
|
||||
```
|
||||
|
||||
所有 storage/executor 调用 scoped by `user_id`。**无 tenant 层** —— 个人 SaaS 用不上,日后做企业版加 `org_id` claim 等价隔离。
|
||||
|
||||
### 7.4 存储:Postgres + 本地文件系统
|
||||
|
||||
```sql
|
||||
users(user_id uuid pk, email null, password_hash | oidc_subject null, plan null, created_at)
|
||||
-- 本地形态固定 INSERT 一行 sentinel: user_id = '00000000-0000-0000-0000-000000000000',
|
||||
-- email / auth / plan 全 NULL;CLI 启动时若不存在则建,tasks 全部 FK 到它
|
||||
|
||||
tasks(
|
||||
task_id uuid pk,
|
||||
user_id uuid fk,
|
||||
task_dir text not null, -- 相对 user root,如 "project_a/sub"
|
||||
mode text, -- coding / proposal / ppt / chat
|
||||
description text,
|
||||
status text, -- pending / running / paused / done
|
||||
model_profile text,
|
||||
tokens_prompt int default 0,
|
||||
tokens_completion int default 0,
|
||||
cost_usd numeric default 0,
|
||||
created_at timestamptz,
|
||||
updated_at timestamptz
|
||||
);
|
||||
create index on tasks (user_id, task_dir);
|
||||
|
||||
messages(
|
||||
message_id uuid pk,
|
||||
task_id uuid fk,
|
||||
idx int not null,
|
||||
payload jsonb not null, -- LiteLLM dict 原样
|
||||
tokens_in int, tokens_out int,
|
||||
created_at timestamptz,
|
||||
unique (task_id, idx)
|
||||
);
|
||||
create index on messages using gin (payload jsonb_path_ops);
|
||||
-- 对话全文搜按需加 tsvector + GIN(中文起步 simple + pg_trgm)
|
||||
|
||||
runs(run_id uuid pk, task_id fk, status, started_at, finished_at, error, tokens_p, tokens_c)
|
||||
usage_events(id, user_id, task_id uuid, run_id uuid, kind, value, ts)
|
||||
-- append-only。task_id/run_id 不 FK,task 硬删后审计记录仍存活
|
||||
```
|
||||
|
||||
**No-subtask 校验**(`create_task` 入口):
|
||||
|
||||
```sql
|
||||
SELECT 1 FROM tasks
|
||||
WHERE user_id = ?
|
||||
AND ( ? LIKE task_dir || '/%' -- new 在已有之下 → 拒
|
||||
OR task_dir LIKE ? || '/%' ); -- 已有在 new 之下 → 拒
|
||||
-- 同 task_dir 允许(同 folder 多 task)
|
||||
```
|
||||
|
||||
**Folder rename**(改名 `old → new`,FS rename 成功后跑):
|
||||
|
||||
```sql
|
||||
UPDATE tasks
|
||||
SET task_dir = ? || substring(task_dir from char_length(?) + 1) -- new, old
|
||||
WHERE user_id = ? AND (task_dir = ? OR task_dir LIKE ? || '/%'); -- old, old
|
||||
```
|
||||
|
||||
LIKE 用 `old/%` 而非 `old%`,避免 `project_a` 误中 `project_a_other`。**running task 引用该 folder 时禁 rename / delete**(后端校验 + UI 禁按钮)。
|
||||
|
||||
**Folder delete**:hard cascade,前端 modal 列影响面("将删 N 个对话、M 条消息、K 个文件")+ 输入 folder 名二确认。
|
||||
|
||||
```sql
|
||||
-- 先 DB 后 FS;DB 失败 FS 不动一致;DB 成功 FS 失败由后台 GC 兜底清孤儿目录
|
||||
DELETE FROM messages
|
||||
WHERE task_id IN (SELECT task_id FROM tasks
|
||||
WHERE user_id=? AND (task_dir=? OR task_dir LIKE ?||'/%'));
|
||||
DELETE FROM tasks
|
||||
WHERE user_id=? AND (task_dir=? OR task_dir LIKE ?||'/%');
|
||||
-- 然后 FS 递归删 folder
|
||||
```
|
||||
|
||||
`usage_events` 不参与 cascade(审计 append-only)。
|
||||
|
||||
**文件系统**:
|
||||
|
||||
```
|
||||
<storage_root>/users/<user_id>/
|
||||
memory/{core.md, extended/} # 跨 task 的 per-user 记忆,不入 DB
|
||||
project_a/source/ sections/ proposal.docx
|
||||
project_b/...
|
||||
```
|
||||
|
||||
本地优先 S3(简化部署 / 低延迟),storage 抽象层留好后续可换 backend。
|
||||
|
||||
**Storage 实现:单一 PG ORM**(本地 + SaaS 共用):
|
||||
- 一份 schema、一份 ORM(SQLAlchemy)、一份查询代码,无 adapter 抽象层,无 SQL 方言适配,无契约测试
|
||||
- 本地 dev 连接:`ZCBOT_DB_URL=postgresql://...` 环境变量;repo 自带 `docker-compose.yml` 起本地 PG(零配置)或连远端 dev / staging PG
|
||||
- Schema 演化:alembic 管理 migration,`db/migrations/*.py` 与代码一同版本化;CLI 启动校验当前 schema 版本,落后报错让用户跑 `cli db upgrade`(本地)或部署管线自动 `alembic upgrade head`(SaaS)
|
||||
- 旧 workspace JSON 一次性迁移:`cli migrate-from-fs --workspace ./workspace` 把 `state.json` / `messages.json` 导入 PG,完成后 workspace 进只读 archive 模式
|
||||
- 本地单用户 sentinel:DB init 时若 users 表无 sentinel 行则 INSERT;本地 CLI 所有 tasks 全 FK 到这一行,无 auth 流程,但 schema 与 SaaS 完全一致
|
||||
- memory 不参与:per-user FS,两形态都不入 DB
|
||||
|
||||
### 7.5 沙盒:Per-task 容器 + Per-run exec
|
||||
|
||||
| 选择 | 理由 |
|
||||
|---|---|
|
||||
| 每 task 长驻容器 | 起容器 ~300ms 太慢;多轮 tool call 共享划算 |
|
||||
| 每 run 一次 `docker exec` | exec 级 timeout/资源限制 |
|
||||
| 空闲 N 分钟回收 | 不浪费,resume 时拉起 |
|
||||
| **bind mount = user root** | `<storage_root>/users/<user_id>/` → 容器 `/workspace`;同用户多 task 不互隔(协作方便),跨用户由独立容器实例隔离 |
|
||||
|
||||
**资源限制**:cgroup CPU/mem、磁盘配额、egress allowlist(只放 LLM + PyPI 镜像)、root fs read-only、no-new-privileges、drop ALL caps。
|
||||
|
||||
**选型**:起步 Docker(运维门槛低);流量起来后视情况换 gVisor / Firecracker / e2b。Executor Protocol 抽象后切换成本低。
|
||||
|
||||
### 7.6 Core 代码改造(按依赖顺序)
|
||||
|
||||
| # | 项 | 影响文件 | 估时 |
|
||||
|---|---|---|---|
|
||||
| 1 | ~~事件流化 `loop.py`~~ | 已完成(commit `375bb29`) | — |
|
||||
| 2 | **Storage 落 PG**:`Session` / `TaskState` 改 SQLAlchemy ORM 写 PG `messages` / `tasks` 表(单一实现,无 adapter 抽象);alembic 管 schema migration;`cli migrate-from-fs` 一次性把现有 workspace JSON 导入;repo 加 `docker-compose.yml` 起本地 PG 用于 dev | `core/session.py` `core/task.py` 新增 `core/storage/` `db/migrations/` `cli.py::migrate_from_fs` `cli.py::db_upgrade` `docker-compose.yml` `requirements.txt` | 3 天 |
|
||||
| 3 | **task_dir 双形态共存**:`TaskState.task_dir` 可显式指定(本地默认 `workspace/tasks/<task_id>/`,SaaS = 用户给路径);`tools/fs.py::_resolve` 接受 task_dir 注入;system prompt 注入逻辑两形态共用 | `core/task.py` `tools/fs.py` `main.py` `prompts/system/general_v1.md` | 1 天 |
|
||||
| 4 | **Folder API**:list / create / rename(cascade + 锁 running task) / delete(hard cascade,前端二确认强校验) / upload / download | 新增 `core/folders/` | 2 天 |
|
||||
| 5 | **No-subtask 校验**:`create_task` 入口跑 §7.4 的 SQL | `core/task.py` | 0.5 天 |
|
||||
| 6 | **Executor + 沙箱**:`run_python`/`shell` → `Executor.run(...)`,`docker exec` 到 per-user/per-task 容器;`api_key_env` → `KeyProvider`(运行时注入);**本地形态保留 subprocess executor**,SaaS 形态走 docker executor | `tools/run_python.py` `tools/shell.py` `core/capabilities.py` `core/llm.py` 新增 `core/executor/` | 2-3 天 |
|
||||
| 7 | **HTTP /v1**:FastAPI + SSE + OIDC | 新增 `core/api/` `core/auth/` | 4 天 |
|
||||
| 8 | **CLI 双模式**:加 transport 层抽象 —— 无 `--remote` 时走 in-process 直调 core(本地形态);`--remote <url>` 走 HTTP API client(dogfood ≡ 真实用户路径);**不删除本地直跑** | `cli.py` 加 `core/transport/` | 1.5 天 |
|
||||
|
||||
代码量增量:**+1000~1500 行**(单一 PG 实现比双 adapter 方案省 500-800 行;无契约测试集 / 无方言适配层)。
|
||||
|
||||
### 7.7 分阶段落地
|
||||
|
||||
| 阶段 | 范围 | 工作量 | 验收 |
|
||||
|---|---|---|---|
|
||||
| A | §7.6 #1 | done | ✅ |
|
||||
| B | §7.6 #2 #3 #4 #5(Storage 落 PG + task_dir 双形态 + Folder API + no-subtask) | ~1 周 | 本地 CLI 走 PG,messages 进 DB 可全文搜;多 task + folder rename 单测过;`migrate-from-fs` 跑通 |
|
||||
| C | §7.6 #6(Executor + sandbox) | 3 天 | 两本地账号互不可见对方 folder,本地 subprocess executor 仍可用 |
|
||||
| D | §7.6 #7(HTTP /v1 + auth) | 4 天 | curl/Postman 跑通主流程 |
|
||||
| E | §7.6 #8(CLI transport 双模式) | 1.5 天 | CLI 默认本地直跑保留,`--remote` 走 HTTP 也跑通 |
|
||||
| F | 上线打磨(限流 / 监控 / 告警 / HA) | 持续 | SLO 99.5% |
|
||||
|
||||
**B 阶段一次性切换** —— 切到 PG 后本地与 SaaS 走相同代码路径,无回退、无双轨。**dogfood 即生效**(messages 进 DB → 全文搜、jsonb 查询立刻可用)。前置:repo 提供 `docker-compose.yml`,作者本机 `docker compose up -d postgres` 一行准备好 dev DB。
|
||||
|
||||
### 7.8 已知风险
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|---|---|
|
||||
| 过早抽象违背 §5 哲学 | B 阶段单一 PG 实现无 adapter 抽象层;C-E 各阶段独立 dogfood 价值,"先有场景再加" |
|
||||
| 本地 PG 连接 / 离线 dogfood | `docker compose up -d` 本地起 PG 兜底;也支持连远端 dev / staging PG;CI 用 ephemeral PG container |
|
||||
| CLI 双模式分叉、本地直跑被忽略 | transport 层抽象统一接口;CI 跑 in-process 和 HTTP 两路径同一组用例 |
|
||||
| `/v1` 冻死后演化慢 | minor 半年兼容,major 6 个月 deprecation;`/v1internal` 实验 |
|
||||
| Rename 误命中前缀 / 漏改子 task | cascade SQL + 单测覆盖 `project_a` 不中 `project_a_other` |
|
||||
| 运行中 task 被 rename / delete | 后端校验 + UI 禁按钮 |
|
||||
| 误删 folder 丢对话 | 前端二确认 + 输入 folder 名;真要再加 trash bin(延迟 cascade) |
|
||||
| DB-then-FS 中断留孤儿目录 | 后台 GC 周期扫 "FS 有但 DB 无引用" 的目录 |
|
||||
| 同 folder 多 task 并发写同名文件 | 文件级悲观锁,冲突早失败 |
|
||||
| Sandbox 出站越权 | egress allowlist 起步只放 LLM + PyPI 镜像 |
|
||||
| 资源滥用(LLM / 存储) | BYO key 默认;月度 token & 存储配额;cold task LRU 清 |
|
||||
|
||||
### 7.9 取舍说明
|
||||
|
||||
**path-as-identity 而非 folder_id**:folder 真实存在于 FS,folder_id 等于造两份 source of truth(易不一致)。rename 是 UI 主动动作,cascade 单事务搞定。
|
||||
|
||||
**user auth 而非 tenant 层**:个人 SaaS 用不上。日后做企业版加 `org_id` claim,数据隔离规则等价。提前抽象 MVP 多 NULL 一层。
|
||||
|
||||
**skill 中间件全落 cwd 不引入 artifacts 表**:中间件是用户花 token 生成的资产,可下载可替换;artifacts 表 + 分类是为不确定的 UX 收益预付架构成本。真嫌乱 UI 加折叠视图。
|
||||
|
||||
**hard cascade 而非 soft orphan**:`orphaned` 让 list/resume/UI 都多一种特殊 case,代码长尾;"删 folder = 删项目" 比 "留对话残骸" 自然。`usage_events` append-only 不 FK,task 硬删后月账仍存活。
|
||||
|
||||
**Docker + Postgres 起步**:运维门槛最低,Executor 抽象层留好,切 microVM / S3 都是 backend 替换不动接口。
|
||||
|
||||
**本地也用 PG,不用 SQLite / JSON**:
|
||||
1. **dogfood ≡ 真实用户路径** —— 本地与 SaaS 走相同 SQL 方言、相同事务语义、相同 ORM,bug 在 dogfood 阶段就能复现,不会等到生产
|
||||
2. **Docker 已经是必然依赖** —— §7.6 #6 沙盒走 docker exec;装 Docker 是前提,顺手 `docker compose up postgres` 是零增量门槛
|
||||
3. **双 adapter 维护税远高于 PG 一次性配置成本** —— 一份 schema、一份 ORM、一份查询;SaaS 起步即终态,切换成本归零
|
||||
4. **本地 dev 也能连测试服** —— 不强迫本机起 PG,作者可直接连远端 dev / staging PG 跑 dogfood,体感跟连 SaaS 几乎一致
|
||||
|
||||
**CLI 不被 API 取代,而是双模式共存**:本地直跑模式调 core 内部状态比 HTTP roundtrip 顺手;前端用户路径靠 `--remote` 模式打通。transport 层抽象代价小、长期价值高 —— 删本地直跑省不下多少代码,反而失去最便利的调试入口。**离线**靠本地 docker compose PG 兜底,不靠"全栈零依赖"幻觉。
|
||||
|
||||
**Memory 不入 DB**:跨 task 共享靠"同一 user 看同一份 FS 目录"的语义自动达成,不需要 schema。md 文件用户直接编辑器改,DB 化反而要造 UI、违反 §3.7 "事实由用户判断" 原则。两形态 memory 行为一致(只是根目录不同),迁移零成本。
|
||||
|
||||
**为什么 Tasks/Messages 在 PG 但 skill 产物在 FS**:tasks / messages 是元数据 + 对话流,需要查询、过滤、全文搜、跨 task 统计 —— 都是 DB 强项,jsonb GIN / pg_trgm 让查询代码不爆炸。skill 产物(`*.pptx` / `*.docx` / `sections/*.md`)是终用户拿走的文件,期望直接在文件管理器看到、用 Office 打开、邮件附件发出去 —— 进 DB 就要做"导出"这一步多余操作,且二进制 BLOB 在 PG 里没 GIN 索引价值。**FS 是产物的天然存储,DB 是元数据 / 状态 / 查询索引的天然存储,各司其职**。同理 §7.5 沙盒 bind mount = user root,容器里看到的就是用户在 Web UI 里看到的目录,无中间层翻译。
|
||||
|
||||
---
|
||||
|
||||
## 附录:DeepSeek V4 关键事实(2026-04-24)
|
||||
|
||||
- **V4-Pro**:1.6T 总 / 49B 激活,1M context,SWE-Bench 80.6 / Terminal-Bench 67.9 / MCPAtlas 73.6
|
||||
|
|
|
|||
88
PROGRESS.md
88
PROGRESS.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
> 配合 `DESIGN.md` 阅读。本文件只记录 phase 状态、决策偏差、文件量、下一步。
|
||||
|
||||
最后更新:2026-05-08(REPL `/resume` + 懒创建 task_dir + 切换前空清理)
|
||||
最后更新:2026-05-12(§7 改写为 user-direct SaaS 草案)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -10,52 +10,42 @@
|
|||
|
||||
| Phase | 标题 | 状态 | 备注 |
|
||||
|------|-----|-----|------|
|
||||
| 1 | 最小可用骨架 | ✅ | 全部验收点过 |
|
||||
| 2 | Skill 系统 + 三个 skill | ✅ | Anthropic 格式;coding/ppt/proposal |
|
||||
| 3 | Hybrid 范式 (run_python) | ✅ | subprocess + 敏感 env 过滤 |
|
||||
| 4 | 演化性能力 | 🟡 | Model Profile + Capability Probing ✅;版本化 prompts 未做 |
|
||||
| 5 | Eval Suite | ⏸ 不做 | 个人工具用 dogfooding 替代,probe 覆盖健康检查 |
|
||||
| 6 | 长任务工程化 | 🟡 | task + state.json + 中断恢复 ✅;context 压缩、双层记忆未做 |
|
||||
| 7 | 打磨 | ❌ | Docker 沙盒 / 更多 skill / Web UI |
|
||||
| 1-3 | 骨架 + Skill + run_python | ✅ | 三个 skill;CoreCoder 唯一匹配 edit;敏感 env 过滤 |
|
||||
| 4 | 演化性能力 | 🟡 | Model Profile + Probing ✅;版本化 prompt 未做 |
|
||||
| 5 | Eval Suite | ⏸ 不做 | dogfooding 替代,probe 覆盖健康检查 |
|
||||
| 6 | 长任务工程化 | 🟡 | task + state.json + 恢复 ✅;双层记忆 ✅;context 压缩未做 |
|
||||
| 7 | 打磨 | ❌ | Docker 沙盒 / 更多 skill |
|
||||
| §7 SaaS 化 | DESIGN §7 路线 | 🟡 | A 事件流化 ✅;B (Storage 落 PG + task_dir 双形态 + Folder API + no-subtask) 可立刻开,本地与 SaaS 共用同一种 storage |
|
||||
|
||||
---
|
||||
|
||||
## 已完成关键能力
|
||||
|
||||
**Phase 1-3**(2026 早期):骨架 + skill 系统 + run_python。所有工具基目录是用户当前 cwd(不是 zcbot 仓库本身),agent 操作的是用户项目。`tools/fs.py` 的 `edit` 用 CoreCoder 风格唯一匹配。`tools/run_python.py` 过滤 `*API_KEY *TOKEN *SECRET *PASSWORD *PRIVATE_KEY` 环境变量。三个 skill 中 `ppt/` 最完整(v3:商务红硬约束 + apply_brand 品牌条 + Iconify 图标库 + scripts:fetch_icon / quality_check / render_icon;素材摄取改用 markitdown CLI)。
|
||||
**2026-Q1 ~ 05-06:Phase 1-4** —— 骨架 / 三个 skill(coding/ppt/proposal)/ run_python 范式 / Model Profile + Capability Probing。`ppt` v3:商务红约束 + apply_brand + Iconify + render_icon/quality_check;素材摄取改 markitdown CLI。
|
||||
|
||||
**Phase 4**(2026-05-06):
|
||||
- `core/probe.py` + `cli.py probe` —— basic_chat / parallel_tools / thinking_mode / long_context 四项探测
|
||||
- 真实 probe 跑通,**flash mismatch 发现**:yaml `parallel_tools: false` 但实测能并发(暂不自动改 yaml,需更多场景观察)
|
||||
- pro 全 ok
|
||||
**2026-05-06:Phase 6 部分** —— task + state.json + tokens 累计;CLI `tasks` + REPL `/status /done /abandon /desc`;移除 legacy `workspace/sessions/`。
|
||||
|
||||
**Phase 6 部分**(2026-05-06):
|
||||
- `core/task.py` + `workspace/tasks/<id>/{state.json, messages.json}` —— TaskState 跟 mode/desc/status/tokens/timestamps;`build_agent` 返 5 元组;`sync_task_tokens` 每轮后写回
|
||||
- CLI 新增 `tasks` 子命令 + REPL `/status /done /abandon /desc`;`chat` 加 `--mode --desc` 选项
|
||||
- 移除 legacy `workspace/sessions/` 兼容(单一布局)
|
||||
**2026-05-07:TUI 打磨 + task_dir 落地** —— rich Markdown 渲染;thinking spinner 显实时耗时+累计 token;system prompt 注入 task_dir 绝对路径,skill 产物全收敛 `workspace/tasks/<id>/`;`.gitignore` 删 bandaid 行。
|
||||
|
||||
**TUI 打磨 + task_dir 落地**(2026-05-07):
|
||||
- assistant 文字走 `rich.markdown.Markdown`,粗体/列表/表格/代码块正常渲染(非流式)
|
||||
- thinking spinner 由 daemon 线程每 100ms 刷文案,显示实时耗时 + 累计 token;每轮 LLM 返回追加 dim 一行 `[in N out N t Xs]` 留痕
|
||||
- system prompt 显式注入 `task_dir` 绝对路径,SKILL.md 里 `<task_dir>` 占位符**真正落地**;`spec_lock.md` / `sections/` / `slides/` / 最终 docx/pptx 全收敛到 `workspace/tasks/<id>/`
|
||||
- `.gitignore` 删 `sections/` `slides/` `spec_lock.md` 三条无锚 bandaid —— 现在写错位置 git status 立刻报红,不再靠 ignore 兜底
|
||||
**2026-05-08:REPL task 切换 + 懒创建** —— `/resume [last|<id>]`;`build_agent` 不预占文件,首条 user 消息触发 save;`_cleanup_if_empty` 三条件守门防误删。
|
||||
|
||||
**REPL 内 task 切换 + 懒创建**(2026-05-08):
|
||||
- `/resume [last|<id>]` REPL 命令,无参数列最近 10 个 task 表格让用户挑序号或 task_id;和 `/new` 对称,都在 REPL 内重建 (agent, session, sid, task_state, task_dir) 五元组。`tasks` 命令和 `/resume` 共用 `_list_task_rows` helper
|
||||
- **懒创建 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` 留下显式痕迹,要保)
|
||||
**2026-05-09 → 05-10:§7 草案 + 对话导出** —— DESIGN §7 初版 SaaS 草案(后于 05-12 重写);`cli.py export <task_id>` + `core/export_docx.py` 导对话成 docx。
|
||||
|
||||
**2026-05-11:原子写 + 双层记忆 + §7 A** —— `atomic_write_text` 接管 save;`core/memory.py` 双层记忆(core.md 入 prompt,extended/* 走索引);loop 事件流化(`sink.emit`)铺 SSE 路。
|
||||
|
||||
**2026-05-12:§7 改写** —— 原 platform/core 多租户方案废弃,改 user-direct(folder-centric,task/messages 入 PG,no-subtask 约束,hard cascade delete)。
|
||||
|
||||
---
|
||||
|
||||
## 关键决策与偏差
|
||||
|
||||
| 项 | 决策 | 与设计差异 |
|
||||
|---|------|-----------|
|
||||
| 工具基目录 | 用户当前 cwd(读)+ task_dir(写) | system prompt 同时给 cwd 与 task_dir 绝对路径,SKILL.md `<task_dir>` 占位符指向 task_dir |
|
||||
| Workspace 用途 | `tasks/<id>/{state.json, messages.json}` | memory/ 待 Phase 6 双层记忆 |
|
||||
| Eval Suite | 不做 | 设计为团队场景;个人工具 dogfooding 替代 |
|
||||
| 版本化 prompt | 直接 `general_v1.md`,无 active.md 软链接 | Windows 软链接麻烦,真要切版本时再做 |
|
||||
| run_python 沙盒 | subprocess + env 过滤 | 阶段 1 设计如此;Docker 待 Phase 7 |
|
||||
| 项 | 决策 | 备注 |
|
||||
|---|------|------|
|
||||
| 工具基目录 | cwd(读)+ task_dir(写) | system prompt 同时注入两者绝对路径 |
|
||||
| Workspace 布局 | `tasks/<id>/` + `memory/{core.md, extended/}` | memory 跨 task 共享 |
|
||||
| Eval Suite | 不做 | 个人工具用 dogfooding |
|
||||
| 版本化 prompt | 直接 `general_v1.md` | Windows 软链接麻烦,真要切再做 |
|
||||
| run_python 沙盒 | subprocess + env 过滤 | Docker 在 §7 C 阶段 |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -64,31 +54,35 @@
|
|||
```
|
||||
core/capabilities.py 71
|
||||
core/llm.py 89
|
||||
core/loop.py 158 ← +markdown 渲染 / spinner 显时长+token
|
||||
core/probe.py 243 ← Phase 4
|
||||
core/session.py 77
|
||||
core/loop.py 152 ← §7 A: sink.emit
|
||||
core/sinks.py 101 ← §7 A
|
||||
core/ui.py 38
|
||||
core/probe.py 243
|
||||
core/session.py 93 ← +atomic_write_text
|
||||
core/skills.py 81
|
||||
core/task.py 63 ← Phase 6
|
||||
core/task.py 64
|
||||
core/memory.py 76
|
||||
core/export_docx.py 372
|
||||
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
|
||||
cli.py 439
|
||||
─────────────────────────────────
|
||||
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` 按需读
|
||||
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 透传明显不够用再做
|
||||
1. **§7 B 阶段**(~1 周)—— Storage 落 PG(单一实现,无 adapter 抽象)+ task_dir 双形态 + Folder API + No-subtask。**dogfood 即生效**(messages 进 DB → 全文搜立刻可用)。
|
||||
- 前置:repo 加 `docker-compose.yml`(`docker compose up -d postgres` 起本地 dev PG)或 `ZCBOT_DB_URL` 指向远端测试 PG
|
||||
- 里程碑:① schema + alembic 初版迁移 ② SQLAlchemy ORM 接入 `Session` / `TaskState` ③ CLI 适配(去 `.json` 读写,加 `_cleanup_if_empty` 新逻辑)④ `cli migrate-from-fs` 工具(把现有 `workspace/tasks/*/` 导入 PG)⑤ Folder API + no-subtask SQL 校验 ⑥ 本地单用户 sentinel(`user_id='00000000-...'`)init 流程
|
||||
2. **Phase 6 context 三层压缩**(~1 天)—— 兜底,V4 长上下文一般用不到
|
||||
3. **Phase 7 更多 skill / 模型档案**(持续)
|
||||
4. **Proposal mermaid 流程图预渲染**(~半天)—— ASCII 透传不够用时再上 `mmdc`(Node.js 依赖)
|
||||
|
|
|
|||
7
cli.py
7
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:
|
||||
|
|
|
|||
120
core/loop.py
120
core/loop.py
|
|
@ -1,19 +1,17 @@
|
|||
"""主 agent loop: ReAct 风格,LLM ↔ Tool 反复直到无 tool_call。"""
|
||||
"""主 agent loop: ReAct 风格,LLM ↔ Tool 反复直到无 tool_call。
|
||||
|
||||
loop 不直接 print —— 进度通过 sink.emit(event) 上抛。Sink 决定怎么呈现
|
||||
(本地 console / SSE / 日志)。事件类型见 core/sinks.py 头部说明。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from rich.console import Console
|
||||
from rich.markdown import Markdown
|
||||
|
||||
from .capabilities import ModelCapabilities
|
||||
from .llm import LLM
|
||||
from .session import Session
|
||||
from .ui import make_console
|
||||
|
||||
|
||||
def _extract_usage(usage: Any) -> Tuple[int, int]:
|
||||
|
|
@ -36,7 +34,7 @@ class AgentLoop:
|
|||
tools: Dict[str, Any],
|
||||
session: Session,
|
||||
capabilities: ModelCapabilities,
|
||||
console: Optional[Console] = None,
|
||||
sink: Optional[Any] = None,
|
||||
max_iterations: Optional[int] = None,
|
||||
) -> None:
|
||||
self.llm = llm
|
||||
|
|
@ -44,68 +42,42 @@ class AgentLoop:
|
|||
self.session = session
|
||||
self.caps = capabilities
|
||||
self.max_iterations = max_iterations or capabilities.max_iterations
|
||||
self.console = console or make_console()
|
||||
self.sink = sink
|
||||
|
||||
@contextmanager
|
||||
def _thinking(self):
|
||||
"""spinner 实时刷耗时 + 上下文 token 数。yield 出的 ctx 退出后填 elapsed。"""
|
||||
start = time.monotonic()
|
||||
stop = threading.Event()
|
||||
|
||||
def fmt() -> str:
|
||||
elapsed = time.monotonic() - start
|
||||
total = self.llm.token_counter.total
|
||||
tail = f" ctx {total:,} tok" if total else ""
|
||||
return f"[muted]thinking... {elapsed:.1f}s{tail}[/muted]"
|
||||
|
||||
class Ctx:
|
||||
elapsed: float = 0.0
|
||||
|
||||
ctx = Ctx()
|
||||
status = self.console.status(fmt(), spinner="dots")
|
||||
|
||||
def tick() -> None:
|
||||
while not stop.wait(0.1):
|
||||
try:
|
||||
status.update(fmt())
|
||||
except Exception:
|
||||
return
|
||||
|
||||
with status:
|
||||
th = threading.Thread(target=tick, daemon=True)
|
||||
th.start()
|
||||
try:
|
||||
yield ctx
|
||||
finally:
|
||||
stop.set()
|
||||
th.join(timeout=0.5)
|
||||
ctx.elapsed = time.monotonic() - start
|
||||
def _emit(self, event: dict) -> None:
|
||||
if self.sink is not None:
|
||||
self.sink.emit(event)
|
||||
|
||||
def run(self, user_message: str) -> str:
|
||||
self.session.append({"role": "user", "content": user_message})
|
||||
|
||||
for _ in range(self.max_iterations):
|
||||
with self._thinking() as t:
|
||||
response = self.llm.chat(
|
||||
messages=self.session.messages,
|
||||
tools=[t.schema for t in self.tools.values()],
|
||||
reasoning_effort=self.caps.default_reasoning_effort or None,
|
||||
)
|
||||
self._emit({"type": "llm_start"})
|
||||
start = time.monotonic()
|
||||
response = self.llm.chat(
|
||||
messages=self.session.messages,
|
||||
tools=[t.schema for t in self.tools.values()],
|
||||
reasoning_effort=self.caps.default_reasoning_effort or None,
|
||||
)
|
||||
elapsed = time.monotonic() - start
|
||||
msg = response.choices[0].message
|
||||
self.session.append(msg)
|
||||
|
||||
pt, ct = _extract_usage(getattr(response, "usage", None))
|
||||
self.console.print(
|
||||
f"[info][in {pt:,} out {ct:,} t {t.elapsed:.1f}s][/info]"
|
||||
)
|
||||
self._emit({
|
||||
"type": "llm_end",
|
||||
"prompt_tokens": pt,
|
||||
"completion_tokens": ct,
|
||||
"elapsed": elapsed,
|
||||
})
|
||||
|
||||
tool_calls = getattr(msg, "tool_calls", None) or []
|
||||
content = getattr(msg, "content", None)
|
||||
if content:
|
||||
self.console.print("[assistant]assistant>[/assistant]")
|
||||
self.console.print(Markdown(content))
|
||||
self._emit({"type": "text", "content": content})
|
||||
|
||||
if not tool_calls:
|
||||
self._emit({"type": "done"})
|
||||
return content or ""
|
||||
|
||||
for tc in tool_calls:
|
||||
|
|
@ -118,6 +90,7 @@ class AgentLoop:
|
|||
}
|
||||
)
|
||||
|
||||
self._emit({"type": "done"})
|
||||
return "[reached max iterations]"
|
||||
|
||||
def _execute_tool_call(self, tc: Any) -> str:
|
||||
|
|
@ -128,31 +101,52 @@ class AgentLoop:
|
|||
except json.JSONDecodeError as e:
|
||||
return f"[Error] invalid JSON arguments for {name}: {e}"
|
||||
|
||||
preview = json.dumps(args, ensure_ascii=False)
|
||||
if len(preview) > 200:
|
||||
preview = preview[:200] + "..."
|
||||
self.console.print(f"[tool]tool>[/tool] {name}({preview})")
|
||||
args_preview = json.dumps(args, ensure_ascii=False)
|
||||
if len(args_preview) > 200:
|
||||
args_preview = args_preview[:200] + "..."
|
||||
self._emit({
|
||||
"type": "tool_call",
|
||||
"name": name,
|
||||
"args": args,
|
||||
"args_preview": args_preview,
|
||||
})
|
||||
|
||||
tool = self.tools.get(name)
|
||||
if tool is None:
|
||||
return f"[Error] unknown tool: {name}"
|
||||
err = f"[Error] unknown tool: {name}"
|
||||
self._emit({"type": "tool_result", "name": name, "result": err,
|
||||
"preview": err, "truncated": False})
|
||||
return err
|
||||
|
||||
try:
|
||||
result = tool.execute(**args)
|
||||
except TypeError as e:
|
||||
return f"[Error] bad arguments to {name}: {e}"
|
||||
err = f"[Error] bad arguments to {name}: {e}"
|
||||
self._emit({"type": "tool_result", "name": name, "result": err,
|
||||
"preview": err, "truncated": False})
|
||||
return err
|
||||
except Exception as e:
|
||||
return f"[Error executing {name}] {type(e).__name__}: {e}"
|
||||
err = f"[Error executing {name}] {type(e).__name__}: {e}"
|
||||
self._emit({"type": "tool_result", "name": name, "result": err,
|
||||
"preview": err, "truncated": False})
|
||||
return err
|
||||
|
||||
if not isinstance(result, str):
|
||||
result = str(result)
|
||||
|
||||
# 控制返回给模型的 tool 结果体量,避免炸 context
|
||||
MAX_LEN = 16_000
|
||||
truncated = False
|
||||
if len(result) > MAX_LEN:
|
||||
result = result[:MAX_LEN] + f"\n[... truncated, {len(result) - MAX_LEN} chars ...]"
|
||||
truncated = True
|
||||
|
||||
# 给用户预览(截短)
|
||||
preview = result if len(result) < 400 else result[:400] + "..."
|
||||
self.console.print(f"[muted]{preview}[/muted]")
|
||||
self._emit({
|
||||
"type": "tool_result",
|
||||
"name": name,
|
||||
"result": result,
|
||||
"preview": preview,
|
||||
"truncated": truncated,
|
||||
})
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,101 @@
|
|||
"""EventSink: 把 loop 产生的事件画到目标(本地 console / SSE / 日志)。
|
||||
|
||||
Loop 不直接 print,改 emit({type, ...})。Sink 决定怎么呈现。
|
||||
|
||||
事件类型(loop 当前会发的):
|
||||
llm_start {type} —— 一轮 LLM 调用开始
|
||||
llm_end {type, prompt_tokens, completion_tokens, elapsed}
|
||||
text {type, content} —— assistant 文字段(整段,非流式)
|
||||
tool_call {type, name, args, args_preview}
|
||||
tool_result {type, name, result, preview, truncated}
|
||||
done {type} —— 一次 run 全部结束
|
||||
|
||||
后续接 SSE 时,sink 实现里把 emit 转 yield 即可,loop 一行不用改。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable, Optional
|
||||
|
||||
from rich.console import Console
|
||||
from rich.markdown import Markdown
|
||||
|
||||
|
||||
class ConsoleEventSink:
|
||||
"""把事件画到 rich console。spinner 在 llm_start..llm_end 之间显示,
|
||||
后台 daemon 线程每 100ms 刷耗时 + 累计 token。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
console: Console,
|
||||
token_counter: Optional[Callable[[], int]] = None,
|
||||
) -> None:
|
||||
self.console = console
|
||||
# 把 LLM 累计 token 数取出来(spinner 文案要用),可选;无则不显示 ctx
|
||||
self._tokens = token_counter or (lambda: 0)
|
||||
self._status = None
|
||||
self._stop: Optional[threading.Event] = None
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._start = 0.0
|
||||
|
||||
def emit(self, event: dict) -> None:
|
||||
t = event.get("type")
|
||||
if t == "llm_start":
|
||||
self._spinner_start()
|
||||
elif t == "llm_end":
|
||||
self._spinner_stop()
|
||||
pt = event.get("prompt_tokens", 0)
|
||||
ct = event.get("completion_tokens", 0)
|
||||
el = event.get("elapsed", 0.0)
|
||||
self.console.print(f"[info][in {pt:,} out {ct:,} t {el:.1f}s][/info]")
|
||||
elif t == "text":
|
||||
content = event.get("content") or ""
|
||||
if content:
|
||||
self.console.print("[assistant]assistant>[/assistant]")
|
||||
self.console.print(Markdown(content))
|
||||
elif t == "tool_call":
|
||||
name = event.get("name", "")
|
||||
preview = event.get("args_preview", "")
|
||||
self.console.print(f"[tool]tool>[/tool] {name}({preview})")
|
||||
elif t == "tool_result":
|
||||
preview = event.get("preview", "")
|
||||
self.console.print(f"[muted]{preview}[/muted]")
|
||||
# done: 无需输出
|
||||
|
||||
def _spinner_start(self) -> None:
|
||||
self._start = time.monotonic()
|
||||
self._stop = threading.Event()
|
||||
|
||||
def fmt() -> str:
|
||||
elapsed = time.monotonic() - self._start
|
||||
total = self._tokens()
|
||||
tail = f" ctx {total:,} tok" if total else ""
|
||||
return f"[muted]thinking... {elapsed:.1f}s{tail}[/muted]"
|
||||
|
||||
self._status = self.console.status(fmt(), spinner="dots")
|
||||
self._status.__enter__()
|
||||
|
||||
def tick() -> None:
|
||||
while not self._stop.wait(0.1):
|
||||
try:
|
||||
self._status.update(fmt())
|
||||
except Exception:
|
||||
return
|
||||
|
||||
self._thread = threading.Thread(target=tick, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def _spinner_stop(self) -> None:
|
||||
if self._stop is not None:
|
||||
self._stop.set()
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=0.5)
|
||||
if self._status is not None:
|
||||
try:
|
||||
self._status.__exit__(None, None, None)
|
||||
except Exception:
|
||||
pass
|
||||
self._status = None
|
||||
self._stop = None
|
||||
self._thread = None
|
||||
|
|
@ -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
|
||||
|
|
|
|||
55
main.py
55
main.py
|
|
@ -16,7 +16,9 @@ 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
|
||||
from core.task import TaskState
|
||||
from tools.fs import EditTool, GlobTool, GrepTool, ReadTool, WriteTool
|
||||
|
|
@ -72,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 文档里出现的 `<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(
|
||||
model_name: Optional[str] = None,
|
||||
workspace: Optional[str] = None,
|
||||
|
|
@ -98,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(
|
||||
|
|
@ -123,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 文档里出现的 `<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")
|
||||
meta = {
|
||||
"id": sid,
|
||||
|
|
@ -173,7 +197,8 @@ def build_agent(
|
|||
rp = RunPythonTool(base_dir=tool_base)
|
||||
tools[rp.name] = rp
|
||||
|
||||
agent = AgentLoop(llm, tools, session, caps, console=console)
|
||||
sink = ConsoleEventSink(console, token_counter=lambda: llm.token_counter.total) if console else None
|
||||
agent = AgentLoop(llm, tools, session, caps, sink=sink)
|
||||
return agent, session, sid, task_state, task_dir
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue