zcbot/DESIGN.md

38 KiB
Raw Blame History

设计文档

本地运行的个人任务 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,无 SQLite / JSON 分支)/ web /v1 API。本地形态 = python main.py web 起 FastAPI + dev SPA + 邀请码登录(invites 表,name → uuid5 推导 user_id),跟 SaaS 走完全一致的路径,无 CLI REPL / 本地 in-process 分叉(2026-05-18 撤,详 §7.9)

2. 架构

zcbot/
├── core/
│   ├── capabilities.py     # ModelCapabilities,从 yaml 加载
│   ├── llm.py              # LiteLLM 封装,按 capabilities 自动启 features
│   ├── loop.py             # ReAct 主循环
│   ├── probe.py            # 真实探测对账 yaml 声称的能力
│   ├── session.py          # 消息列表 + meta + 落盘
│   ├── skills.py           # SkillRegistry (Anthropic 渐进披露)
│   └── task.py             # TaskState
├── 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/deepseek_v4.yaml}
├── workspace/
│   └── users/<user_id>/
│       ├── .memory/{core.md, extended/*.md}  # 跨 task 共享记忆(user 级,dotfile 隔离)
│       └── <working_dir>/                      # 工作目录,用户起名(同 working_dir 多 task 共享),仅 skill 产物
├── core/agent_builder.py                       # 装配 lib: build_agent / system prompt / validate_task_name
└── main.py                                     # 入口: web / db / probe 三子命令

工作目录(working_dir) = workspace/users/<user_id>/<working_dir>/,所有 skill 产物写到这里,绝对路径在 system prompt 显式给 agent(prompt 里仍叫 task_dir 占位符,跟 SKILL.md DSL 一致)。写错位置(cwd / skills/ / repo 根)git status 立刻报红。user_id 走 JWT sub:邀请码登录(invites 表)由 name 经 uuid5(固定 namespace, name) 推导,platform 登录则由 platform 直传;无 SENTINEL fallback,所有路径必须显式有 user_idname(任务显示名)必填,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_invite(dev SPA / 同事试用)或 /v1/auth/login(platform 机器对机器)换 JWT → POST /v1/tasks/{id}/messages 起 BG 线程,内部 build_agent(core/agent_builder.py)读 agent.yaml → 加载 ModelCapabilitiesLLM(caps) → 解析 working_dir → 拼 system prompt(general_v1.md + skill discovery + cwd + working_dir 绝对路径)→ 装配工具 → AgentLoop.runZCBOT_DB_URL 指 PG(本地 docker compose / 远端 dev / 生产)。


3. 核心组件

3.1 主循环(core/loop.py)

ReAct:LLM → 若有 tool_calls 就执行 → 结果塞回消息 → 再调 LLM。无 tool_call 即返回。

  • 工具结果对模型截 16K 字符,用户预览 400 字符
  • 后台 daemon 线程每 100ms 刷 spinner:thinking... 1.3s ctx 12,345 tok
  • 每轮 LLM 返回追加 dim 一行 [in N out N t Xs]
  • assistant 文本走 rich.markdown.Markdown 整段渲染(非流式)
  • 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 /v1/tasks POST 时即写;CLI 内仍走 Session.append 首条 user 消息触发的占位 INSERT(ensure_local_task_row idempotent,name 透传给 NOT NULL 列)—— REPL 启动后立刻 /exit 不留 DB 行(目录留着无害,跨 task 复用)。

Task 切换 / 软删 —— dev SPA 顶 bar 新建 modal + 左侧列表点切 / done / abandon 按钮 / 硬删按钮。无 user message 的 task DB 行可经 DELETE /v1/tasks/{id} 清掉(FS 一律不动 — 同 name 跨 task 共享,绝不 rmtree)。

原子性 —— PG INSERT 天然原子;skill 产物走 core.session.atomic_write_text(tmp + fsync + replace)。

入口:python main.py web 起服务后,所有交互(新建 / resume / 改 status / 改 description / 改 skill / 改 name / 导出 docx / 看消息)走 web /v1/*(dev SPA 或 platform 端 / curl)。原 CLI REPL(chat / tasks / export 子命令)2026-05-18 撤,详 §7.9。

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 也走 _build_system_prompt 并覆盖 messages[0] —— memory 演化即时生效。

memory 由人填(也允许 agent 用 write 写),系统不自动维护 —— 关键差异:事实由用户判断,不由 LLM 自动总结

memory 永远在 FS,不入 DB:统一 <workspace_or_storage_root>/users/<user_id>/.memory/(本地直接是 workspace/,SaaS 是 <storage_root>/,bind mount 进容器)。user_id 全程从 JWT sub 透传(邀请码登录走 uuid5、platform 登录直传)。dotfile .memory/ 命名:跟用户起的项目目录(同样落 <uid>/ 下)区分,避免项目名取 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,差别只在 CLI 入口 vs HTTP 入口。本节落地前 §1-§6 路线照走,不阻塞 dogfood。

7.0 与本地形态的兼容性

SaaS 化不是"重写",而是把同一份 web /v1 服务部署到云端。本地形态(python main.py web)与 SaaS 形态走完全一致的代码路径,无 CLI / in-process 分叉(2026-05-18 撤,详 §7.9)。

共享:同一份 core/ / tools/ / SKILL.md / prompts / web /v1 路由 / dev SPA。 差别:

维度 本地 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>/(user_id 从 JWT 透传) <storage_root>/users/<user_id>/<name>/(JWT sub)
Memory workspace/users/<user_id>/.memory/(FS,dotfile) <storage_root>/users/<user_id>/.memory/(仍是 FS,dotfile)
Sandbox subprocess + env 过滤 per-task docker exec
Auth 邀请码(invites 表,name→uuid5)→ JWT;platform_key → JWT(机器对机器) OIDC → JWT(D' 替换 platform_key 路径;邀请码同步下线)

workspace/ 仅存 skill 产物,state / messages 全在 PG。本地 vs SaaS 共用 users/<user_id>/ 子树布局,差别只在外层根目录(workspace/ vs <storage_root>/),不在 storage 形态。

7.1 心智模型:Task 一等公民 + Dir 文件副视图

两个并列入口,正交不嵌套:

视图 入口语义 适用场景 API
Task list(主) "我的对话历史" 任务驱动:"继续昨天那个 bug fix" GET /v1/tasks?status=&task_dir=
Dir tree(辅) "我的文件资产" 项目驱动:"看汇报项目里所有素材 + 关联对话" GET /v1/folders

类比:macOS Finder + 最近使用 / Apple Notes 文件夹视图 + 全部备忘录。两个视图查同一份数据的不同切面,dir 不是 task 的父容器

  • Task = DB 一行,一等公民,自带 task_dir text 字段:
    • 新建必给 name(简单名),task_dir = workspace/users/<user_id>/<name>/。同 name 多 task 共享 → "同一项目多对话"语义;不再支持空 task_dir / 自动 UUID 派生(原 ChatGPT thread 模式取消,纯对话也得起个项目名)
    • 指定 → 项目化 task,同 task_dir 多 task 自动共享 source/ / sections/ / 终稿(无需建"项目"实体)
  • Dir = FS 路径,无 DB 实体,path 即标识;无父子结构,改名走 prefix cascade(§7.4)
  • No-subtask:同 task_dir 允许(同项目多对话),前缀嵌套拒
  • Messages = DB 表,append-only,jsonb 存 LiteLLM 原样 payload
  • Skill 产物全落 task_dir,不引入 artifacts 表;SKILL.md 指示 agent 清中间件
  • Skill 定义是项目代码,跟部署走,所有用户共享

空 dir(用户上传素材但还没开 task)在 dir tree 视图正常展示 —— 上传本身是有效产品行为;UI 上跟"有 task 的 dir"做轻量区分(如 task 数 badge)。

state / messages 两形态都在 PG,FS 只承担 skill 产物。多 task 共享同 task_dir 时由 §7.8 文件级悲观锁兜底。

7.2 资源模型(/v1)

Task 一等公民(/v1/tasks*);files 与 task 正交(§7.1 双视图心智),走 user-rooted /v1/files*,以 workspace/users/<uid>/ 为边界(不强制选 task)。所有路由统一 /v1 前缀,返 JSON;前端 / UI 由 platform 端实现,本仓库不维护(§7.9 取舍)。本地开发用 FastAPI 自带 /docs Swagger UI 自查;GET / 302 跳 /docs

Tasks
  POST   /v1/tasks                          创建 {name(必填), working_dir?, description?, skill?};
                                            留空 working_dir → 用 name 作目录名;
                                            working_dir 派生 workspace/users/<user_id>/<working_dir>/;
                                            name/working_dir 不合法 → 400
  GET    /v1/tasks?page=&page_size=&status=&skill=&working_dir=&q=&ordering=
                                            列表,返 `{page, page_size, count, results}`
                                            分页 1-based;page_size 1100 clamp;status active/completed/abandoned;
                                            skill 精确;working_dir 末段名(后端拼前缀比对);q 在 name+description ILIKE;
                                            ordering DRF 风格逗号分隔,`-field` 倒序;allowlist
                                            created_at/updated_at/name/status;**默认 `-created_at`**
  GET    /v1/tasks/{id}                     单 task meta + 完整 messages
  PATCH  /v1/tasks/{id}                     {status?,description?,name?,skill?};status 从 web 不让切回 active(走 CLI)
  DELETE /v1/tasks/{id}                     硬删:DB 行 + messages(CASCADE);**FS working_dir 保留**
                                            (同 working_dir 多 task 共享,文件由用户经 /files/delete 单独清)
  GET    /v1/folders                        列当前 user 的 working_dir(FS 是 source of truth + 关联 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;'error' 起新 run
                                            时清(跟 ok 一样视为可重启)。`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 在工具调用之间 poll 看见即退,
                                            给未执行 tool_call 补 `[cancelled by user]`
                                            tool result(保 LiteLLM 协议),emit `cancelled`
                                            事件;finally 写终态 — 正常 / cancel 都回 `idle`
                                            (不留持久标记),异常才写 `error`。
                                            run_status != `running` → 409。
                                            LLM 同步 call 本身不可中断 — 最坏等当前一轮跑完

Files(user-rooted,不绑 task — `workspace/users/<uid>/` 为根)
  GET    /v1/files?path=                    列子目录 {entries, crumbs, exists, root, current};留空 → user_root;
                                            dotfile(`.memory/` 等)一律隐藏(同 /v1/folders 约定)
  POST   /v1/files/upload                   multipart;path 通过 form;严格拒含 / \\ .. 的 filename
  GET    /v1/files/download?path=           下载单文件;`..` / 绝对 / symlink 越界 400
  POST   /v1/files/delete                   {path} 文件或空目录;非空目录 400;user_root 拒

Export
  GET    /v1/tasks/{id}/export              docx 临时文件下载,BackgroundTask 删 tmp

Misc
  GET    /healthz                           {"status":"ok"}
  GET    /                                  302 → /docs (Swagger UI 自查,本地形态便利)

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 只送预览给 UI
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。/v1internal 实验位(未启)。

CORS:本地 dev allow_origins=["*"];部署 platform 时收紧到 platform 域名 allowlist。

Auth:PLATFORM_KEY → JWT 兑换(过渡形态,见 §7.3);Authorization: Bearer <jwt> 走所有 /v1/tasks*;/healthz/docs/openapi.json//v1/auth/login/static/* 豁免。

7.3 认证

当前形态(D' 过渡,2026-05-15 落地):platform 服务端(或 dev 浏览器)持有 PLATFORM_KEY 共享密钥,调 POST /v1/auth/login {user_id, platform_key} → 后端校验 key 匹配 → 签 HS256 JWT(sub=user_id,默 7d TTL,JWT_SECRET env 签)→ 返 {token, expires_at, user_id, ttl_seconds}。后续 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 是单点可信中间层(持 KEY = 可为任意 user_id 签 token,等同 user 身份由 platform 注入);风险与"platform 服务端泄漏 = 用户身份泄漏"同级,可接受。

未来形态(真 OIDC,D 阶段后期):OIDC / Clerk / 自建邮箱登录,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 + 本地文件系统

users(user_id uuid pk, email null, password_hash | oidc_subject null, plan null, created_at)
-- 行由 web auth 入口按需 INSERT(邀请码登录走 uuid5、platform 登录直传);email/auth/plan 全 NULL 直到接 OIDC

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,                       -- error 状态的错误文本,其他状态 NULL
      created_at, updated_at);
create index on tasks (user_id, working_dir);
-- working_dir 存储约定:本地 ROOT 内 → 相对 ROOT 的 posix 串
-- (`workspace/users/<user_id>/<name>`,name 是简单名,无 /\..);
-- 新建强制 `name` 必填,空串只可能在 legacy 数据(开发期已 wipe)。
-- SaaS 阶段同理(基础是 <storage_root>/users/<uid>/)。
-- 读写边界统一过 core/paths.py::{to_db_path,from_db_path}。
-- 入口校验 main.py::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, created_at,
         unique (task_id, idx));
create index on messages using gin (payload jsonb_path_ops);
-- 全文搜按需加 tsvector + GIN(中文 simple + pg_trgm 起步)

invites(token text pk, name text not null unique, created_at)
-- 0005 加;dev SPA 邀请码登录后端。user_id 由 uuid5(固定 NS, name) 推导,不入表。
-- 管理:直接 INSERT/DELETE/UPDATE(后续若需要可加 `python main.py invite ...` 薄包装);
-- 撤销 = DELETE row;换 token 不换身份 = UPDATE token;换 name = 换身份(旧 task 留在旧 user 下)。

0004 简化:删 runs / usage_events(从未真用过 — 详 §7.9 取舍)。原 runs 表 角色等价于"task 当前 in-flight 状态",合并到 tasks.run_status + tasks.run_error 两列; usage_events 是为未来计费预付的架构成本,真要计费再加,DB schema 改动便宜。run_id 取消 —— 单活 run 形态下它对客户端 / broker / cancel 全是冗余字段。

No-subtask 校验(create_task):查同 user 下是否存在 new LIKE existing/%existing LIKE new/%,中一则拒;同 task_dir 允许。两侧先用 from_db_path 归一到 absolute posix 再比前缀(混合存储形态 [相对+绝对] 不会漏判),数量小直接 Python 端比对,不在 SQL 里拼分隔符。

Folder rename(old → new,FS rename 成功后):UPDATE tasks SET task_dir = new || substring(task_dir from len(old)+1) WHERE user_id=? AND (task_dir = old OR task_dir LIKE old||'/%')old/% 而非 old%,避免 project_a 误中 project_a_other。running task 引用时禁 rename / delete。

Folder delete:hard cascade,前端 modal 列影响面 + 输入 folder 名二确认。先 DELETE messages → DELETE tasks → FS 递归删;DB 成功 FS 失败由后台 GC 兜底清孤儿目录。usage_events 不参与 cascade。

文件系统(本地 <storage_root> = workspace/,SaaS 替换为部署根,布局不变):

<storage_root>/users/<user_id>/
  .memory/{core.md, extended/}     # per-user 记忆,dotfile 隔离,不入 DB
  <name>/                          # 项目目录,name 用户起(必填),task_dir 直接落这
  <name>/...                       # 同 name 多 task 共享同目录(§7.1)

本地优先 S3(部署简化 / 低延迟),storage 抽象层留好后续可换。

Storage 实现:单一 PG ORM(本地 + SaaS 共用):一份 schema、一份 SQLAlchemy、一份查询,无 adapter,无 SQL 方言适配,无契约测试。alembic 管 migration;CLI 启动校验 schema 版本,落后报错让用户跑 cli db upgrade(本地)或部署管线自动 alembic upgrade head(SaaS)。cli migrate-from-fs --workspace ./workspace 一次性导旧 JSON。

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;同 user 多 task 不互隔(协作方便),跨 user 由独立实例隔离

资源限制: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) done
2 Storage 落 PG:Session / TaskState 改 SQLAlchemy 写 PG;alembic;cli migrate-from-fs;docker-compose.yml 起本地 PG 3 天
3 task_dir 字段语义:新建必给 name(简单名),task_dir 派生为 <storage_root>/users/<user_id>/<name>/(本地 <storage_root> = workspace/,user_id 走 JWT);同 name 多 task 共享同目录;tools/fs.py::_resolve 接 task_dir 注入;system prompt 注入 1 天
4 Folder API:list / create / rename(cascade + 锁 running) / delete(hard cascade) / upload / download 2 天
5 No-subtask 校验:create_task 入口跑 §7.4 SQL 0.5 天
6 Executor + sandbox:run_python/shellExecutor.run(...);本地保留 subprocess executor,SaaS 走 docker;api_key_envKeyProvider 运行时注入 2-3 天
7 HTTP /v1:FastAPI + SSE + OIDC 4 天
8 CLI 双模式 —— (2026-05-18):dev SPA 起后浏览器一直开着,CLI REPL chat/tasks/export 三命令已撤;main.py 入口只剩 web / db / probe,无双 transport(§7.9) 已撤
9 Web UI 简洁版(Jinja2+HTMX) → 改为 API surface 完工:Phase G 落地的模板 / HTMX / 服务端 markdown 渲染删除,所有路由切纯 JSON;UI 由 platform 端实现(§7.9 取舍) 已落

代码量增量:+1000~1500 行(单一 PG 比双 adapter 省 500-800 行;UI 不计入,本仓库只维护 API)。

7.7 分阶段落地

阶段 范围 工作量 验收
A #1 事件流化 done sink 协议铺 SSE 路
B #2 #3 #4 #5(Storage 落 PG + task_dir 双形态 + no-subtask) done 本地走 PG,messages 进 DB,任务/消息/状态全在 PG;task_dir 改相对存储(§7.4 注释)
D #7 HTTP /v1 surface(无 auth) done /v1/tasks/* + SSE JSON + files 4 路由 + export + Swagger;本地形态走 JWT 跑通
D' 过渡 PLATFORM_KEY → JWT 兑换 + user_id 数据隔离 + dev SPA done POST /v1/auth/login 拿 token,Authorization: Bearer 走全部 /v1/tasks*;web/static/dev.html 单文件 3 栏 SPA 给开发自验。详 §7.3
D' 真 OIDC 替换 /v1/auth/login 内部为 ID token 校验 + CORS allowlist 收紧 1 天 真发布给真实用户前补;路由层 Depends 不动,只换 login 内部
C #6 Executor + sandbox 3 天 两本地账号互不可见对方 folder,本地 subprocess executor 仍可用
E CLI transport 双模式(2026-05-18,§7.9):dev SPA 已是本地 dogfood 主路径,CLI REPL 整套删,main.py 入口只剩 web/db/probe
G Web UI 简洁版 —— 删除,前端由 platform 端实现 本仓库不维护 UI
F 上线打磨(限流 / 监控 / 告警 / HA) 持续 SLO 99.5%

B 阶段一次性切换 —— 切到 PG 后本地与 SaaS 走相同代码路径,无回退、无双轨。dogfood 即生效(messages 进 DB → 全文搜、jsonb 查询立刻可用)。

D 落在 G 前面 —— 原排期 D 在 G 后(以为 dogfood 用 UI 跑),实际转向"platform 端联调"后,API surface 反而成阻塞;G 的 Jinja2+HTMX 投入(G1-G6 ~3 天)沉淀 = 删除前的 dogfood 价值,留下的 sink 协议 / broker / no-subtask / files 路径安全归一 / task_dir 相对存储仍被 D 复用。

7.8 已知风险

风险 缓解
过早抽象违背 §5 B 阶段单一 PG 无 adapter;各阶段独立 dogfood 价值;CLI REPL(原 §7.6 #8 双模式)2026-05-18 整套撤,无双 transport 维护税
/v1 冻死后演化慢 minor 半年兼容,major 6 个月 deprecation;/v1internal 实验
Rename 误中前缀 / 漏改子 task cascade SQL 用 old/% + 单测覆盖
Running task 被 rename / delete 后端校验 + UI 禁按钮
误删 folder 二确认 + 输入 folder 名;真要再加 trash bin
DB-then-FS 中断留孤儿目录 后台 GC 周期扫"FS 有但 DB 无引用"
同 folder 多 task 并发写同名 文件级悲观锁,冲突早失败
同 task 并发 POST messages 撞 messages.idx UniqueConstraint POST /v1/tasks/{id}/messages 单活 run 检查:SELECT … FOR UPDATE 锁 task 行 + 查 tasks.run_status in ('running','cancelling'),有 → 409;同事务标 running 避 TOCTOU。配启动 lifespan reaper 把孤儿 running/cancelling 全标 error(进程 crash 残留)。未来真生产 multi-worker 换 heartbeat / lease
Run 跑太久 / 用户想中断 POST /v1/tasks/{id}/cancel 协作式 cancel:标 cancelling + broker 信号;AgentLoop.cancel_check 回调在每轮 LLM 前、tool_calls 之间 poll;命中给未执行 tool_call 补 [cancelled by user] tool result 保 LiteLLM 协议,emit cancelled 事件,BG finally run_statusidle(不留持久标记)。LLM 同步 call 本身不可中断 — 接受最坏等当前一轮跑完(几十秒内)
Sandbox 出站越权 egress allowlist 起步只放 LLM + PyPI
资源滥用 BYO key 默认;月度配额;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 等价。

skill 产物全落 cwd 不引入 artifacts 表:中间件是用户花 token 生成的资产,可下载可替换;artifacts 表是为不确定 UX 收益预付架构成本。真嫌乱 UI 加折叠视图。

hard cascade 而非 soft orphan:orphaned 让 list / resume / UI 都多一种特殊 case,"删 folder = 删项目"比"留对话残骸"自然。(原 usage_events 表 0004 删 — 见下条)

0004 删 runs + usage_events(2026-05-18 决策):

  • runs:实质是"task 当前 in-flight 状态"的影子表 —— tokens_p/c 写但从未被读(tokens 累计走 tasks.tokens_prompt/_completion),started_at/finished_at/error 也只写不读,run_id 唯二实用是 broker pub/sub 频道键 + cancel 参数。但 §7.1 选定单活 run 形态下 run_id 是冗余 —— 同 task 同时最多 1 个活 run,客户端只需要 task_id(永远有)就够。合并 run_status + run_error 两列入 tasks,删表;broker 改 task_id 索引;/v1/tasks/{id}/runs/{rid}/{events,cancel}/v1/tasks/{id}/{events,cancel}
  • usage_events:从未真写入(代码库零引用),纯死代码,为"未来计费"预付的架构成本。真要计费时改 DB schema 便宜,不预付。
  • 取舍代价:失"历史 run 元数据"(每次 LLM 调用的独立时间戳 / token 切片)—— messages 表已记下每次对话产物,token 累计在 tasks,真要细粒度审计再补回 usage_events(届时是新需求,不是技术债)。
  • run_status 终态语义:ok / cancelled 收尾直接回 idle(用户视角"跑完了 / 停了"等价),只有 error 是持久终态(让用户能看到),起新 run 时由 post_message 清掉。

本地也用 PG,不用 SQLite / JSON:① dogfood ≡ 真实用户路径,bug 在 dogfood 就能复现;② Docker 已是必然依赖(§7.5),docker compose up postgres 零增量门槛;③ 双 adapter 维护税远高于 PG 一次性配置成本;④ 本地 dev 也能连远端测试服。

API-only,UI 由 platform 实现(2026-05-15 决策):

  • 原计划:Phase G 用 Jinja2 + HTMX 在本仓库做"简洁 Web UI",dogfood 用,真上线再做正经前端。已落地 G1-G6:task list / chat 流式 / files 浏览 / new / done/abandon/export/toast,共 ~600 行 HTML+CSS+SSE-HTML-片段。
  • 触发:用户决定与已有 platform 联调,前端用 platform 的框架,本仓库再维护 HTML / CSS / HTMX 就是双套 UI 浪费。
  • 取舍:
    • web/templates/* web/static/* + jinja2/markdown-it-py/pygments/mdit-py-plugins 依赖
    • SSE 事件 payload 从 HTML 片段切 JSON({"type":"text","content":"..."} 等);前端自渲染 markdown / tool_call 折叠
    • 路由统一 /v1 前缀,响应全 JSON,FastAPI 自带 /docs Swagger UI 接替"对内调试"角色(本地形态 GET / 302→ /docs)
    • auth 走 D' 过渡形态:邀请码(invites 表,dev SPA / 同事试用)+ PLATFORM_KEY(platform 机器对机器),两条都签同款 JWT(见 §7.3);真 OIDC 留到联调约定 token 形态后接
    • CORS allow_origins=["*"] 本地宽松,platform 部署时按 platform 域名收紧
  • 沉淀:G 阶段的 sink 协议(§7 A)/ RunBroker fan-out / no-subtask 校验 / files 路径安全归一 / task_dir 相对存储 全部保留,不在 UI 层不被牵连

dev SPA 留一份 + 升级为本地 dogfood 主路径(2026-05-15 决策,2026-05-18 强化):web/static/dev.html 单文件 vanilla JS,3 栏布局(task list + chat + files),~1100 行无构建链。与"UI 由 platform 实现"不冲突:platform UI 是给真用户的生产形态;dev.html 是给本仓库开发者 dogfood + 自验 /v1 API + SSE 流的开发期工具。platform 未上线 / 网络断 / 凌晨随手验时不需要拉 platform。理由:① SSE 调试在 curl 里看不到 UI 反应,需要可视端;② Swagger 不发 SSE 流也没流式视图;③ 一个静态文件维护成本可忽略,删了再补不如留着;④ CLI REPL 撤(见下条)后 dev SPA 成为唯一本地交互通道,功能要齐(新建 / resume / done/abandon / 硬删 / 改 status/desc/name/skill / 文件浏览 + 上传 + 删 / chat 流式 + stop / 导出 docx)。形态:登录页填邀请码 → /v1/auth/login_invite 拿 JWT → localStorage 存 → fetch+Bearer。

CLI REPL 撤,入口统一 main.py {web,db,probe}(2026-05-18 决策,推翻原"CLI 双模式共存"):

  • 原计划:cli.py chat REPL 本地直跑 + --remote https://... 走 HTTP,两套覆盖"本地调内部状态"+ "dogfood ≡ 真用户路径"。
  • 触发:dev SPA 落地后浏览器一直开着,REPL 命令(/new /resume /done /abandon /desc /export)与 web /v1 接口完全等价;维护双套 task 切换语义只是"对称美",每个 REPL 命令的 bug fix 要在 web 端再 fix 一次。--remote 那套从未实现,也再不需要(platform 联调 + dev SPA + curl Swagger 已覆盖真用户路径)。
  • 取舍:
    • cli.py 改名 main.py(入口);原 main.py(装配 lib)挪到 core/agent_builder.py(单一职责,SoC)。
    • chat / tasks / export 三命令(浏览器 dev SPA + web /v1 全覆盖);保留 web / db / probe(uvicorn / alembic / 模型探测,各有不可替代逻辑)。
    • 净减 ~400 行 REPL 逻辑(_cleanup_if_empty / _list_task_rows / _resolve_uuid_or_prefix / build_agent.console 等 CLI-only 代码),main.py ~180 行,core/agent_builder.py ~320 行。
  • :CLI "无 auth 直跑调 core 内部状态"通道。但 dev SPA 邀请码登录走同一条 web 路径,看内部状态可以临时写几行 ad-hoc script(from core.agent_builder import build_agent; ...),不需要常驻 CLI 命令。
  • 离线:本地 python main.py web 起服务 + 浏览器 dev SPA;ZCBOT_DB_URL 指 docker compose / 远端 dev PG,跟之前一样,不靠"全栈零依赖"幻觉。

Memory 不入 DB:跨 task 共享靠"同一 user 同一 FS 目录"自动达成。md 用户直接编辑器改,DB 化反而要造 UI、违反 §3.7"事实由用户判断"。

Web UI 走 server-render + HTMX 不上 SPA:① 与 §5 "Less Scaffolding" 一致,不引入 React/Vue 构建链 / node_modules / 双语言双 lint;② chat 主交互是 SSE 流式追加 + 表单提交,HTMX hx-swap / sse-swap 原生覆盖,无需客户端状态管理;③ FastAPI 单进程既出 /v1 JSON 也出 HTML 模板,部署单容器;④ 上限低(协作 / 实时多光标 / 复杂表单态做不动),真要做重前端再换栈,届时 /v1 已稳定可直接对接 SPA。

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 看到的目录,无中间层翻译。


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