zcbot/DESIGN.md

35 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)/ 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_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_password(邮箱密码)或 /v1/auth/login(platform_key)换 JWT → POST /v1/tasks/{id}/messages 起 BG 线程,内部 build_agentagent.yaml → 加载 ModelCapabilitiesLLM(caps) → 拼 system prompt(general_v1.md + skill discovery + cwd + working_dir 绝对路径)→ 装配工具 → AgentLoop.runZCBOT_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-task docker 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 + 本地文件系统

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 内 → 相对 ROOT posix 串;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-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。

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/shellExecutor.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 3 天
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 并发写同名 文件级悲观锁,冲突早失败
同 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 级)
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 主动动作,顶层目录走 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 两列入 tasksusage_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 看到的目录,无中间层翻译。

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