diff --git a/DESIGN.md b/DESIGN.md index 62064ba..6a65ddd 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,20 +1,16 @@ # 设计文档 > 本地运行的个人任务 agent,覆盖三类工作:汇报 PPT、科研申报书、代码。 -> 模型自由(LiteLLM 接 OpenAI-compatible),代码可控(目标 1500-2000 行 Python)。 +> 模型自由(LiteLLM 接 OpenAI-compatible),代码可控。本文只记**架构与取舍(为什么)**;进度/历史见 PROGRESS,怎么跑见 RUN。 --- ## 1. 边界 -**做**:PPT(`python-pptx`)/ 申报书(`python-docx`)/ 编码(读写文件 + shell + 迭代验证)。 -**不做**:子 agent / IM 渠道 / 自定义 RAG / 锁定 Anthropic / Eval Suite(个人工具 dogfooding 替代)。多用户 / Web UI 归 §7。 +**做**:PPT / 申报书 / 编码(读写文件 + shell + 迭代验证)。 +**不做**:子 agent / 自定义 RAG / 锁定 Anthropic / Eval Suite(dogfooding 替代)。多用户 / Web 归 §7。 -**关键约束**: -- 模型自由:LiteLLM + OpenAI-compatible(默认 DeepSeek V4) -- 任务持久化:任意时刻关机,下次能恢复 -- 演化性:模型升级不需要大改架构 -- **形态兼容**:本地与 SaaS 共享同一份 core / storage(PG)/ web `/v1` API,无 CLI REPL / 本地 in-process 分叉(2026-05-18 撤,详 §7.9) +**关键约束**:模型自由(LiteLLM,默认 DeepSeek V4);任务持久化(任意时刻关机可恢复);演化性(模型升级不大改架构);**形态兼容**——本地与 SaaS 共享同一份 core / PG / web `/v1` API,无 CLI REPL 分叉(§7.9)。 --- @@ -25,150 +21,85 @@ zcbot/ ├── core/ │ ├── capabilities.py # ModelCapabilities,从 yaml 加载 │ ├── llm.py # LiteLLM 封装,按 capabilities 自动启 features -│ ├── loop.py # ReAct 主循环 + cancel_check 协作式 cancel +│ ├── loop.py # ReAct 主循环 + 协作式 cancel │ ├── probe.py # 真实探测对账 yaml 声称的能力 -│ ├── session.py # 消息列表 + meta + 落 PG -│ ├── skills.py # SkillRegistry(Anthropic 渐进披露) +│ ├── session.py # 消息列表 + meta,落 PG +│ ├── skills.py # SkillRegistry(渐进披露,多来源) │ ├── task.py # TaskState │ ├── memory.py # per-user .memory/ 双层记忆 -│ ├── shortcuts.py # 快捷指令(触发词→完整指令,入口层确定性展开;.memory/shortcuts.md) -│ ├── 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 -│ └── skill_authoring.py # save_skill / fork_skill(host-side 写用户 .skills) -├── skills/{coding,ppt,proposal}/ # SKILL.md + references / scripts / assets +│ ├── shortcuts.py # 快捷指令(入口层确定性展开) +│ ├── paths.py # task_dir db form 归一 +│ ├── storage/ # SQLAlchemy 2.x ORM +│ ├── scheduler.py # 定时任务(§8.5) +│ ├── wechat/ # 渠道:ilink / wecom / service / inbound(§8.7) +│ ├── sandbox/ + executor*.py # Executor ABC + Docker per-user 容器池(§7.5) +│ └── agent_builder.py # 装配 lib:build_agent / system prompt +├── tools/ # fs / shell / run_python / skill / 媒体 / 检索 / host-side 域工具 +├── skills// # SKILL.md + references / scripts / assets +├── rendering/ # 平台渲染层 md→docx/pdf(§8.6) ├── prompts/system/general_v1.md -├── config/{agent.yaml, models/*.yaml} -├── workspace/users// -│ ├── .memory/{core.md, extended/*.md} # 跨 task 共享记忆,dotfile 隔离 -│ └── / # 工作目录,用户起名(同 working_dir 多 task 共享) -├── web/{app.py, auth.py, broker.py, sinks.py, static/dev.html} -├── db/migrations/ # alembic -└── main.py # 入口:web / db / probe / user 四子命令 +├── config/{agent.yaml, models/*.yaml, media/*.yaml} +├── workspace/users//{.memory/, .skills/, /} +├── web/{app.py, auth.py, admin.py, broker.py, sinks.py, static/} +├── db/migrations/ # alembic +└── main.py # 入口:web / db / probe / user ``` -**工作目录(working_dir) = `workspace/users///`,所有 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/` 换 `/`,布局不变。 +**工作目录** = `workspace/users///`,所有 skill 产物写这里,绝对路径注入 system prompt。`user_id` 走 JWT `sub`,**无 SENTINEL fallback**。`name`(显示名)必填、`working_dir` 可选(留空用 name);都是简单名(拒 `/\..`、`.` 起头);同 working_dir 多 task 共享同目录(§7.1)。SaaS 化只换外层根目录,布局不变。 -**启动**:`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。 +**启动**:`main.py web` → FastAPI + lifespan(reaper / scheduler / 渠道入站)→ 登录换 JWT → `POST /v1/tasks/{id}/messages` 起 BG 线程 → `build_agent`(capabilities → LLM → system prompt → 工具)→ `AgentLoop.run`。 --- ## 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 协议 -- **停机判据 = 解耦「跑了几步」与「是否在推进」**(2026-06-10):用户感知的"轮"是来回对话次数,一个 run 内模型自主连调 N 次 tool **概念上仍是 1 轮**,该放它跑完;真正要掐的是"空转"。故 `max_iterations`(从 capabilities/yaml 读,flash 120 / pro 150)降级为**纯安全 backstop**,不再当"轮预算"砍正经长任务;主防护是两道**进展信号**:① `_RepeatGuard` 逐指纹"同名同参+无产出(`[Error]`/结果一字不差重复)"累计,SOFT=2 注提示、HARD=4 拦截;② run 级全局 `_stall`——整步所有 tool 都无净产出则 +1、任一净产出清零,连续 `_STALL_LIMIT=8` 步主动停(`[stopped: no progress]`),比撞 backstop 早得多掐死循环。撞 backstop / 空转停都 emit 明确"回复『继续』可续跑"提示,不静默停。**取舍**:step-count 是"不收敛"的粗糙代理,正经任务 80 步和死循环 5 步被一刀切同等对待是错的;进展信号才对症。新增成本=run loop 一个计数器,死循环兜底反而更早(8 步 vs 120 步)。 +ReAct:LLM → tool_calls 执行 → 结果塞回 → 再调;无 tool_call 即返回。工具结果对模型截 16K、用户预览 400 字符;事件走 `sink.emit`(SSE 桥),content delta 即时 emit。 +- **LLM 走 streaming**(`chat_stream` + `stream_chunk_builder` 拼回);`cancel_check` 在每轮 LLM 前 + **chunk 间** + tool 间 poll → cancel 延迟 ~100ms;中途 cancel 已收 chunk 丢弃不入库,未执行 tool_call 补 `[cancelled by user]` 保协议。 +- **停机判据 = 解耦"跑了几步"与"是否在推进"**(2026-06-10):`max_iterations` 降为纯安全 backstop(step-count 是"不收敛"的粗糙代理,正经 80 步任务和死循环 5 步不该一刀切);主防护是进展信号——① `_RepeatGuard` 逐指纹累计"同名同参+无产出"(SOFT2 提示 / HARD4 拦截);② run 级 `_stall` 连续 8 步全 tool 无净产出主动停。停下都 emit"回复『继续』可续跑",不静默。 ### 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。 +每模型一份 yaml(context/输出/parallel_tools/thinking/计费/max_iterations 等),新模型 5 分钟接入不改代码;`LLM.chat` 按档案自动启 features。 -### 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.3 Capability Probing(`core/probe.py`) +yaml 是手填的,probe 用真实调用对账(basic_chat/parallel_tools/thinking/long_context)。显式触发,不进启动路径。 ### 3.4 工具系统(Hybrid 范式) -**JSON tool call**(`tools/`):read / write / edit / glob / grep / shell / run_python / load_skill / save_skill / fork_skill — 离散操作。 -**Code execution**(`run_python`):tmp `.py` + subprocess + 工作目录限制 + 敏感 env 过滤(`*API_KEY *TOKEN *SECRET *PASSWORD *PRIVATE_KEY`)— 批处理 / 算数据 / 生成文档。 -关键设计:`edit` **唯一匹配**(CoreCoder 风格,old_str 重复即报错);工具按**原子操作**切分,不做 `make_pptx()` 这种高级封装。 +**JSON tool call** 管离散操作;**run_python**(tmp .py + subprocess + 敏感 env 过滤)管批处理/生成文档。`edit` **唯一匹配**(old_str 重复即报错);工具按**原子操作**切分,不做 `make_pptx()` 式高级封装。持 key 的能力一律 host-side tool、仅对应 env 存在才注册(§7.5 #7)。 ### 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 决定模型能否触发。 +三层加载:Discovery(name+description,几百 token)→ Activation(`load_skill` 完整 SKILL.md)→ Execution(references 按需拉)。写 WHY+WHAT 不写 Step 1/2/3;description 决定触发。 -**用户私有 skill(多来源 registry,2026-06-11)**:`SkillRegistry` 收**有序来源列表**——内置 `ROOT/skills`(只读)+ 用户 `user_root/.skills`(可写,per-user)。用户来源排后,**同名覆盖内置(user wins)**;覆盖在 discovery 显式标注,不静默。取舍:① **user wins** 而非 namespace 隔离——核心用例是"copy 内置 skill 再改",同名覆盖才符合"我的覆盖全局"直觉,且 skill 是纯指引、覆盖只作用于该用户自己会话,blast radius 锁死;② **创作走 host-side typed tool**(`save_skill`/`fork_skill`)而非 fs/shell——fs 的 base_dir 锚 cwd(host)/ 容器 wd(docker),够不到 `user_root/.skills`,跨 backend 不可靠;host-side 工具知 user_root,一个落点两模式通吃(与 seedream/document_* 持 key host-side 同范式),且 `fork_skill` copytree 整目录解决"带脚本 skill 的 fork";③ 用户来源加载失败(YAML 坏 / 缺 description)收进 `load_errors` 注入 prompt 提示用户修,不静默丢、不崩整次扫描。 +**用户私有 skill**(2026-06-11):registry 收有序来源列表——内置 `ROOT/skills`(只读)+ 用户 `user_root/.skills`(可写)。取舍:① **user wins 同名覆盖**(核心用例是"copy 内置再改",覆盖只作用于本人会话,blast radius 锁死),覆盖显式标注不静默;② **创作走 host-side `save_skill`/`fork_skill`**——fs 工具的 base_dir 跨 backend 够不到 `user_root/.skills`,host 工具一个落点两模式通吃;③ 用户 skill 加载失败收进 `load_errors` 注入 prompt 提示修,不崩整次扫描。 -**Skill 定向模型(frontmatter `model:`,2026-07-06)**:内置 skill 可在 frontmatter 声明 `model: `(如 ppt → `glm.pro52`),表示"该工作流用这个模型效果最好"。架构 = 声明 → 执行 → 兜底 → 呈现四层,**单一执行点**:① 执行 —— 对话中 `load_skill` 命中即 **run 内热切**(agent_builder 注入 switcher 闭包,loop 替换 `self.llm/self.caps`,下一轮 LLM 调用即生效;记账/压缩阈值/reasoning 都读 `self.caps` 自动跟上),并持久化 `tasks.model/model_profile`(resume / 下条 send 沿用);切失败(yaml 配错/缺 key)降级 warn、原模型继续。② 兜底 —— send 侧"档外模型静默降级"对定向模型豁免(判定:落到档外只有"管理员降档"与"skill 定向"两条路,`profile ∈ 内置 skill 定向集合`即放行)。③ 呈现 —— loop emit `model_switch` 事件,前端插模型分隔线 + 顶栏下拉同步。取舍:**定向模型是产品决策,跳过档位门控**(任何档位用户触发该 skill 都可用);**只信内置 skill**(用户 skill 的 `model` 字段忽略,否则自写 frontmatter 即绕门控);**切换不自动切回**("skill 结束"不可判定,且同 task 后续多半仍在该 skill 语境);**不设独立开关、不做建 task 时预切** —— registry 每 run 现扫 SKILL.md,frontmatter 那一行本身就是热配置(删行下个 run 停用,per-skill 粒度),全局 DB 开关是同一行为的第二事实源、建 task 预切是第二执行点,都砍(评估过 `app_settings` 开关表方案,放弃)。 +**Skill 定向模型**(frontmatter `model:`,2026-07-06):内置 skill 声明"该工作流用这个模型最好"(如 ppt→glm.pro52)。**单一执行点**:对话中 `load_skill` 命中 → run 内热切(loop 换 `self.llm/self.caps`,下一轮生效)+ 持久化 task 模型;切失败降级原模型。取舍:**跳档位门控**(产品决策,任何档位可用);**只信内置 skill**(用户 skill 的 model 忽略,防自写 frontmatter 绕门控);**不自动切回**("skill 结束"不可判定);**不设 DB 开关、不做建 task 预切**——frontmatter 那一行本身就是热配置(删行即停,per-skill 粒度),全局开关是第二事实源、预切是第二执行点,都砍。 ### 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)。 +Session = 消息列表,ORM 直写 PG `messages`(append-only,jsonb 存 LiteLLM 原样 payload);Task = 上层元数据,写 `tasks`。working_dir FS 只存 skill 产物,无 state.json。本地 + SaaS 同一份 schema/ORM,差别只在 `ZCBOT_DB_URL`。字段:`name`=显示名(独立于目录)、`working_dir`=相对 ROOT posix 串(多 task 共享)、`skill`=类型标签。working_dir 在创建入口 eager mkdir;DELETE 走软删(§7.9),FS 一律不动。原子性:PG INSERT 天然;产物走 `atomic_write_text`。 ### 3.7 双层记忆(`core/memory.py`) +跨 task 事实放 `user_root/.memory/`:Core(`core.md`,每次 build_agent 进 prompt)+ Extended(`extended/*.md`,索引进 prompt、内容按需 `read`;索引优先 frontmatter description,legacy 退首行)。system prompt 每次 build_agent 重建,memory 演化即时生效。 -跨 task 共享的事实(用户偏好 / 项目约定 / 模型 quirk)放 `workspace/users//.memory/`(per-user,dotfile 隔离): - -| 层 | 文件 | 加载 | 适合 | -|---|---|---|---| -| Core | `core.md` | 每次 build_agent 进 system prompt | 跨任务高频精炼事实(几百 token) | -| Extended | `extended/*.md` | 索引(frontmatter `description`,缺则退回首行标题 — legacy 兼容)+ 可写绝对路径进 prompt,内容靠 `read` 工具按需拉 | 大量低频专题 | - -**system prompt 每次 build_agent 重建**(resume 也是),memory 演化即时生效。 - -**写入路径 = agent 自管(prompt 契约,非后台蒸馏)**:`memory_block` 把 `.memory/` 的**可写绝对路径锚点** + 一段「记忆维护契约」一起注进 prompt(契约 + 锚点常驻,即使记忆为空,否则新用户冷启动不知道自己能记)。契约规定:学到跨 task 复用的稳定事实就当场用已有 `write`/`edit` 存,写前 `grep`/`read` 查重(更新而非堆重复),extended 一事一文件 + frontmatter `description`(这行进索引决定召回)。**不引专用 `remember` 工具**(复用 fs 工具,改动最小);**不做后台自动蒸馏**(不烧额外 token,人仍可审核/手编)。路径锚点按 backend 给 host 绝对路径 / docker `/workspace/.memory`(同 working_dir 的容器路径转译)。 - -**memory 永远在 FS,不入 DB**:本地 `workspace/users//.memory/`,SaaS `/users//.memory/`(bind mount 进容器)。**dotfile `.memory/` 命名**避免项目名取 `memory` 时撞;`validate_task_name` 拒 `.` 起头双向防呆。理由:用户笔记语义,FS 读写 + 编辑器手编是产品的一部分;跨 task 共享靠"同一 user 同一目录"自动达成,无需 schema。 - -**前端记忆面板 = 只读窗口,"改"全走对话(取舍)**:web 左栏「记忆」按钮开只读 modal,直接读 FS 渲染全貌(`GET /v1/memory` 全貌 + `GET /v1/memory/extended/{filename}` 单篇),**故意不提供写/删 API**。理由:① "看全貌"是读、不是 operation —— 走 LLM 反而又贵又只能拿到转述,看地面真相必须直读 FS;② "改"走对话(agent 自管,上文契约)= 单一写入口、自然语言、能合并改写,且用户不会写坏 frontmatter。对照业界:Claude(同为文件式记忆)给全套 view+edit;ChatGPT/Gemini 黑箱式只给看/删、长期不支持内联编辑。我们取"GUI 当眼睛、模型当手":既守住文件式记忆的透明卖点,又不引第二套写代码。后续若"删一条 / prune 臃肿 core.md"这类确定性精确操作摩擦明显,再单加直接的 delete(delete 是唯一廉价且确定性强、值得直连的 mutation,同 ChatGPT 做法)。路径穿越校验收口在 `core/memory.py`(只许 `.memory/extended/` 下扁平 `.md` + resolve 子树兜底)。 - -**快捷指令 ≠ memory(两种机制,别混)**(`core/shortcuts.py`):触发词 → 完整指令的映射,存 `.memory/shortcuts.md`(`| 触发词 | 指令 |` 两列 md 表)。**关键区别**:memory 是注上下文、给模型**概率召回**的软上下文;快捷指令是入口层、模型跑之前的**确定性替换** —— 每条入站消息先经 `shortcuts.expand(ws, uid, text)` 整条 `strip()+casefold()` 精确匹配,命中即把文本换成完整指令再跑 agent(与「新话题」魔法命令同风格,"帮我出个简报"不误伤)。取舍:① **性能** —— shortcuts.md **内容永不注上下文**(触发靠入口层查表,不靠模型),存再多条平时上下文也是 0,触发时进上下文的就是那条完整指令本身(= 用户本来要打的字),无额外 token;若反过来把它塞进 core.md 让模型概率召回,则既不确定、又每轮烧 token,正是本设计要绕开的坑。② **渠道无关** —— `expand` 在渠道核心 `_run_channel_conversation`(微信/企业微信)与网页 `post_message` 两处共用,任意入口打同一触发词行为一致。③ **维护复用 memory 心智** —— 存储蹭 `.memory/` per-user 壳(agent 已有写权限),`memory_block` 加一行契约让模型在用户说"记个快捷词 X→Y"时写 shortcuts.md;但这行契约只讲"能维护 + 格式",不注文件内容。故:**存储借 memory 的壳,触发逻辑独立且确定**。 +- **写入 = agent 自管**(prompt 契约,非后台蒸馏):`memory_block` 注可写路径锚点 + 维护契约(常驻,即使记忆为空——解冷启动);agent 用已有 fs 工具维护、写前查重。不引专用 `remember` 工具、不做后台蒸馏(不烧额外 token,人可审核手编)。 +- **memory 永远在 FS 不入 DB**:用户笔记语义,编辑器手编是产品一部分;跨 task 共享靠同一目录自动达成。dotfile 命名防项目名撞车,`validate_task_name` 拒 `.` 起头双向防呆。 +- **前端记忆面板只读,"改"全走对话**:看全貌是读、直读 FS 才是地面真相;改走 agent 自管 = 单一写入口、不写坏 frontmatter。故意零写/删 API;将来若"删一条"摩擦大再单加 delete(唯一廉价确定性 mutation)。路径穿越校验收口在 `core/memory.py`。 +- **快捷指令 ≠ memory**(`core/shortcuts.py`):触发词→完整指令,存 `.memory/shortcuts.md` 但**内容永不注上下文**——入口层(渠道核心 + web post_message 共用)整条精确匹配确定性替换,0 额外 token、渠道无关;maintenance 蹭 memory 心智(对话让模型写)。若反过来塞 core.md 靠模型概率召回:既不确定又每轮烧 token,正是要绕开的坑。 --- ## 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。 +默认 `deepseek_v4.flash`;复杂 bug / 终稿升 pro + reasoning_effort=max;fallback 手动切 Claude。成本量级:修 bug flash ~$0.01 / 完整申报书 flash ~$0.30(pro-max ~$1.5,Opus ~$10+)。99% 任务 flash 够用。 --- ## 5. 设计哲学 -### 核心原则:Less Scaffolding, More Trust +**Less Scaffolding, More Trust**:把 LLM 当会持续变强的同事,告诉它目标不告诉步骤;脚手架在模型升级后会变枷锁。 -老 agent 框架失败的核心:给 LLM 太多脚手架,模型升级后这些脚手架成枷锁。**正确做法**:把 LLM 当一个**会持续变强的同事**,告诉它目标,不告诉它步骤。 +七条:① prompt 用 WHY+WHAT 不用 HOW;② skill 渐进披露;③ 工具原子切分留组合空间;④ Model Profile 化不硬编码;⑤ probing 对账;⑥ 版本化 prompt(真要切再做);⑦ ~~eval~~(dogfooding 更有效)。 -### 七条具体原则 -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 | +借鉴:CoreCoder(主循环 + edit 唯一匹配)/ Anthropic Skills(渐进披露)/ nanobot(workspace 隔离)/ smolagents(LiteLLM + CodeAct)。 --- @@ -176,621 +107,203 @@ yaml 是手填的,probe 用真实调用对账:`basic_chat` / `parallel_tools` / | 风险 | 缓解 | |---|---| -| 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 | +| 本地 run_python 非真隔离 | 工作目录限制 + env 过滤;SaaS 走 docker(§7.5);本地靠用户审阅 | +| V4 复杂任务不如 Claude | dogfooding 判断,fallback 手动切 | +| skill description 触发不准 | 实战观察迭代 | +| long context 退化 | probe 探测可靠 ceiling | +| 本地 PG 离线 | docker compose 起本地 PG / 连远端 | -**Hybrid 范式而非纯 CodeAgent**:V4 JSON tool call 已稳定;sandbox 成本只在需要时付;兼容 thinking。 -**Anthropic Skill 标准**:行业标准已成,跨 SDK 兼容。 -**不做 subagent**:状态管理爆炸;单 agent + skill 已覆盖 95% 场景。 -**不做 Eval Suite**:个人单用户场景,dogfooding 信号比造作 case 强,probe 覆盖健康检查。 +**Hybrid 而非纯 CodeAgent**:V4 JSON tool call 已稳,sandbox 成本按需付。**不做 subagent**:状态管理爆炸,单 agent + skill 覆盖 95%。**不做 Eval Suite**:单用户 dogfooding 信号更强。 --- -## 7. SaaS 化(草案,status=design,2026-05-12) +## 7. SaaS 化 -> §1-§6 是**本地 dogfood 形态**;本节是**SaaS 形态**,把 core 包成多用户在线服务。 -> 不引入 platform/core 切分 — core 就是后端,直接对用户做 auth。两条形态共享同一份 core,差别只在反代部署。本节落地前 §1-§6 路线照走,不阻塞 dogfood。 +> §1-§6 是本地 dogfood 形态;本节把同一份 core 包成多用户在线服务。不引 platform/core 切分——core 就是后端,直接对用户 auth。 ### 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///` | `/users///` | -| Memory | `workspace/users//.memory/` (FS, dotfile) | `/users//.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 路径);**邮箱密码长期保留**,与 OIDC 并存 | - -`workspace/` 仅存 skill 产物,state / messages 全在 PG。本地 vs SaaS 共用 `users//` 子树布局,差别只在外层根目录,不在 storage 形态。 +同一份 web `/v1` 服务换部署位置:Storage 都是 PG(换 `ZCBOT_DB_URL`);working_dir/memory 换外层根目录;Sandbox 本地 subprocess → SaaS per-user 容器;Auth 邮箱密码 + platform_key→JWT → 未来 OIDC(邮箱密码长期并存)。`workspace/` 仅存产物,state/messages 全在 PG。 ### 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///`(留空 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 文件级悲观锁兜底。 +两个并列入口,正交不嵌套:**Task list**(主,"我的对话历史")+ **Dir tree**(辅,"我的文件资产")。类比 Finder + 最近使用。dir **不是 task 的父容器**、无 DB 实体、path 即标识。同 working_dir 多 task 共享 = "同一项目多对话",无需"项目"实体;前缀嵌套拒(no-subtask)。skill 产物全落 working_dir,不引 artifacts 表。空 dir 正常展示(上传本身是有效行为)。多 task 并发写由软警告兜底(§7.9)。 ### 7.2 资源模型(/v1) -Task 一等公民;files 与 task 正交(§7.1),走 user-rooted `/v1/files*`,以 `workspace/users//` 为边界(不强制选 task)。所有路由统一 `/v1` 前缀,**返 JSON**;前端由 platform 端实现(§7.9 取舍),本地开发用 FastAPI `/docs` Swagger UI 自查。 +统一 `/v1` 前缀返 JSON;UI 由 platform 实现(§7.9),本地 dev SPA dogfood。要点(细节见 `web/app.py`): ``` -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// 为根) - 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) +Tasks POST/GET/PATCH/DELETE /v1/tasks*(分页+筛选+ordering allowlist;DELETE=软删,FS 不动) + GET /v1/folders(working_dir + task 计数) + GET/POST /v1/tasks/{id}/messages(POST 起 run;单活 run:running/cancelling→409, + SELECT FOR UPDATE 锁 task 行防 idx race) + GET /v1/tasks/{id}/events(SSE) POST /v1/tasks/{id}/cancel(协作式,202) +Auth POST /v1/auth/login(platform_key)/ login_password / change_password;GET /v1/me +Files GET /v1/files?path= / upload / download / delete / rename + (user-rooted;dotfile 隐藏;越界 400;顶层目录 DB-aware,见 §7.4) +Admin GET /v1/admin/*(require_admin;overview + usage/models|users + storage/users) +Export GET /v1/tasks/{id}/export(docx) ``` -**SSE 事件**(`Content-Type: text/event-stream`,响应头带 `X-Accel-Buffering: no` 给 nginx 反代友好;每事件 `event: ` + `data: `): - -``` -run_start {} -llm_start {} -text {"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":": "} -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/*` 豁免。 +**SSE 事件**:`run_start / llm_start / text{delta} / tool_call / tool_result(预览,完整走 DB)/ llm_end / model_switch / cancelled / error / done`。fan-out:每订阅独立 queue;迟到订阅立收 done。事件不持久化(messages 走 PG)。 +**版本化**:`/v1` minor 半年兼容,major 6 个月 deprecation。**CORS**:本地 `*`,部署收紧。 ### 7.3 认证 -**当前形态(D' 过渡)**:两条 login 路径签**同款 JWT**(HS256,`JWT_SECRET` env 签,默 7d TTL): -- `POST /v1/auth/login {user_id, platform_key, name?, user_name?}` — platform 服务端机器对机器入口,持 `PLATFORM_KEY` 共享密钥可为任意 user_id 签 token(等同 user 身份由 platform 注入)。body 可选带 `name`(显示名)/ `user_name`(平台账号名),`ensure_user_row` upsert 落 `users.name/user_name`(`COALESCE(EXCLUDED, 旧值)`:平台传非空就刷新、同步平台侧改名,传 null 不覆盖);响应回带 `{name, user_name, role}`。缺省即旧行为(只填 user_id),向后兼容老调用方。与未来 OIDC 的 `name/preferred_username` claim 注入同构 -- `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) -- `GET /v1/me` — 返 `{user_id, role, name, user_name, email}`(走 DB 查),dev SPA 据 role 决定显不显"管理"入口,据 name/user_name/email 渲顶栏用户名(默认 name,hover 显账号 / 邮箱)。两条 login 响应同样回带 name/user_name(平滑展示,登录即有名,/v1/me 再校准) -- `GET /v1/admin/*` — 管理后台,`Depends(require_admin)`(验 JWT + `users.role=='admin'`,否则 403)。`/v1/admin/overview` 返固定指标(runtime/tasks/users/usage 总用量+近7d趋势,供轮询);`/v1/admin/usage/models?range=&sort=`、`/v1/admin/usage/users?range=&sort=&page=&page_size=`、`/v1/admin/storage/users?page=&page_size=` 是带时间筛选(all/7d/30d)/ 排序(cost/tokens)/ 分页的独立表端点。独立页 `/static/admin.html`(目录导航 + 客户端打印导出 PDF)。后续续挂建用户/改角色/配置等管理动作 - -后续 `Authorization: Bearer ` 走所有 /v1/tasks*,FastAPI `Depends(require_user)` 验签 → 提取 user_id → SELECT/UPDATE 全带 `Task.user_id == user_id` 条件做隔离。`/v1/admin/*` 在 `require_user` 基础上再叠一层 `users.role=='admin'` 检查(`make_require_admin`)。`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 契约不变。**邮箱密码路径长期保留**,与 OIDC 并存(自有账号体系 + 同事试用不依赖外部 IdP);OIDC 只接管 platform 机器对机器那条路径。所有 storage/executor scoped by `user_id`,**无 tenant 层** — 个人 SaaS 用不上,做企业版再加 `org_id` 等价隔离。 +两条 login 签同款 JWT(HS256,7d):`login`(platform 机器对机器,持 PLATFORM_KEY 可为任意 user_id 签;body 可带 name/user_name,upsert COALESCE 落 users——与未来 OIDC claim 注入同构)+ `login_password`(邮箱 bcrypt;错误统一 403 防探测)。`require_user` 提取 user_id,所有查询带 `Task.user_id==` 隔离;`require_admin` 再查 `users.role=='admin'`(**role 走 DB 不进 JWT**,改完即时生效)。信任模型:platform 是单点可信中间层,泄漏风险与 platform 自身同级,可接受。**未来 OIDC** 只换 login 内部校验,路由层不动;**无 tenant 层**(企业版再加 org_id)。 ### 7.4 存储:Postgres + 本地文件系统 ```sql -users(user_id uuid pk, email text null unique, password_hash text null, oidc_subject null, plan null, - -- plan:模型档位名(0001 起就有列,0.31 起启用;之前休眠)。值是 config/agent.yaml - -- model_tiers 的 key(如 'pro');NULL/未知 → 落 'default' 档。控制该用户能用哪些模型, - -- 详见 core/model_access.py。role=admin 始终全开,不受档位限制。无需 migration。 - name text null, user_name text null, -- 0016:平台登录注入的档案(显示名 / 平台账号名); - -- platform_key 入口 ensure_user_row upsert 写, - -- 邮箱密码 / 历史行留空。未来 OIDC claim 注入同构 - role text not null default 'user', -- 0009:user/admin;admin 才能访问 /v1/admin/* 管理后台 +users(user_id pk, email unique null, password_hash, oidc_subject, plan, -- plan=模型档位(0.31 启用) + name, user_name, -- 0016 平台注入档案 + role default 'user', -- 0009 admin 门控 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 注入) --- role:make_require_admin 每请求查(不进 JWT,改完即时生效、老 token 不重签); --- 提管理员 main.py user role --email X --role admin。与 ZCBOT_ADMIN_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, - channel text not null default 'web', -- web/wechat 渠道来源(0013);仅 INSERT 写定, - -- upsert/save 不传不覆盖。前端据此打徽章 + 列表强制置顶 - 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//);写入入口 --- 只接 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); +tasks(task_id pk, user_id fk, name NOT NULL, working_dir NOT NULL, skill, description, status, + model_profile, tokens_*, cost_usd, + channel default 'web', -- 0013 渠道来源,仅 INSERT 写定 + run_status default 'idle', run_error, -- 0004 合并 runs 表 + scheduled_job_id, -- 0017 定时任务归属,普通列表过滤 + context_base_idx, -- 0019 §8.8 软重置窗口起点 + deleted_at, -- 0010 软删 + created_at, updated_at) +messages(pk, task_id fk, idx, payload jsonb, tokens_in/out, model_profile, kind, -- kind=push 等 + unique(task_id, idx); gin(payload)) +usage_events(pk, user_id, task_id, message_id, kind, -- chat/image/video/vision/... 自由文本 + model_profile, units jsonb, cost numeric, created_at) -- 多态用量,加媒体不动 schema +scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB) ``` -**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。 - -**文件系统**(本地 `` = `workspace/`,SaaS 替换为部署根): -``` -/users// - .memory/{core.md, extended/} # per-user 记忆,dotfile 隔离,不入 DB - / # 项目目录,name 用户起(必填),working_dir 直接落这 - # 同 name 多 task 共享同目录(§7.1) -``` - -**Storage 实现:单一 PG ORM**(本地 + SaaS 共用):一份 schema、一份 SQLAlchemy、一份查询,无 adapter,无 SQL 方言适配,无契约测试。alembic 管 migration。 +- working_dir 存相对 ROOT posix 串,读写统一过 `core/paths.py`;入口 `validate_task_name` 拒空/`/\NUL`/`.` 起头。 +- **0004 简化**:runs 表只写不读、run_id 单活 run 下全冗余 → 合并 `run_status/run_error` 入 tasks。**0006**:`tasks.model_profile` 为 source-of-truth(PATCH 切、下条 send 生效);usage_events 重建 v2 多态形态,统计 source-of-truth;tasks 三列保留作粗概览。run_status 终态:ok/cancelled 收回 idle,只有 error 持久。 +- **No-subtask**:同 user 下前缀互含即拒(归一 posix 后 Python 端比对);同 working_dir 允许。 +- **files API 是目录树唯一 mutation 入口,DB-FS 一致性服务端内化**:顶层目录 rename/delete 走 DB-aware 分支(事务锁关联 task、running→409、DB UPDATE 先于 FS、delete 被引用→409)。 +- **单一 PG ORM**(本地 + SaaS 共用):一份 schema 一份查询,无 adapter,alembic 管 migration。 ### 7.5 沙盒:Per-user 容器 + Per-tool exec -| 选择 | 理由 | -|---|---| -| 每 user 长驻 sandbox container | 文件模型本来以 `/users//` 为安全边界;同 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 | `/users//` → `/workspace`;每次 exec 显式 `cwd=/workspace/`;同 user 内不做运行时隔离,跨 user 由独立容器 + 独立 mount 隔离 | +选型:**每 user 长驻容器**(文件模型本就以 user root 为安全边界,per-task 会切碎共享工作区)+ **每 tool 一次 docker exec**(exec 级 timeout/cwd/统计)+ 空闲 5 分钟回收 + bind mount user root→`/workspace`。 -**边界划分**:Control plane 留在宿主后端,Execution plane 进容器。宿主后端负责 auth / JWT / DB 事务 / task-message 状态机 / `/v1/files` 路径校验与上传下载 / SSE broker / LLM 调用 / 受控 `web_search` 与 `web_fetch` / 配额审计。容器只跑不可信执行: `shell`、`run_python`、用户或模型生成脚本、编译器 / 解释器 / 包管理器 / 渲染命令。目标不是"所有操作都进容器",而是"所有不可信代码执行都不能在宿主执行";否则 DB 凭据、JWT secret、对象存储凭据反而更容易被带进执行面。 +**边界划分**:Control plane 留宿主(auth/DB/files 校验/SSE/LLM/受控 web 工具/配额审计),Execution plane 进容器(shell/run_python/任意生成代码)。目标不是"所有操作进容器",是"所有不可信执行不能在宿主"——否则凭据反被带进执行面。 -**硬限制**: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//` 或 working_dir 受控临时目录;exec 结束后按进程组清理,避免后台进程常驻。 +**硬限制**:cgroup CPU/mem、pids-limit、exec timeout、并发数、read-only rootfs、tmpfs /tmp、no-new-privileges、drop ALL caps、非 root、`--shm-size`。**软配额**:按 user 计 DB(磁盘/LLM cost/wall time/流量/并发),超额 429。**网络**:默认 deny outbound,搜索抓取走宿主受控工具。 -**软配额**:按 user 计入 DB,超额返回 429 / 402 / 明确错误。配额项包括 workspace 磁盘总量、月度 LLM cost / tokens、tool wall time、文件上传下载流量、网络下载量、running task / exec 并发数。磁盘配额起步用应用层统计(上传 / write / tool 执行前后检查 + 周期扫 user root),后续需要更硬边界再上 filesystem project quota / volume driver。 +**落地清单(Stage C 硬协议,实施按此对账)**: +1. **网络 blocklist 硬编码段**(任一缺失=未完成):`169.254/16`(metadata)、loopback、内网三段、CGNAT `100.64/10`;**PG 实际 IP 单独再 block**(belt-and-suspenders)。 +2. **egress 模型**:容器 `HTTP(S)_PROXY` 走宿主 proxy + iptables DROP 其余 outbound(防 SDK 绕 env);proxy 做域名 allowlist(pypi/github/npm + 镜像)+ IP block + per-user 计量 + 审计。 +3. **进程组清理**:exec 套 `setsid`,timeout/cancel/正常三路径都 `kill -- -PGID`——防 `nohup` 派生 daemon 跨 exec 持久化成"跨对话后门"。 +4. **磁盘配额硬化时点**:首版应用层统计;**外部用户开放前必须升 xfs/ext4 project quota**(扫描间隙打满共享盘会拖死同节点)。 +5. **Executor 接口 + runtime 注入**:不 hard-code docker exec,走 `Executor.call_tool` 抽象 + `ZCBOT_SANDBOX_RUNTIME` config——未来切 gVisor/Firecracker 应用层零改动。 +6. **工具按信任域二分,Executor 内部 dispatch**:container backend = shell/run_python/fs 全套(fs 以前 host 跑无 user_root 校验能读任意文件,进容器 `/workspace` 是物理边界;`tool_runner.py` stdin 喂 JSON);host backend = load_skill/skill_authoring/web_*/媒体/documents 等持 key 工具。AgentLoop 零感知。代价每 fs call ~200ms,LLM 推理下是噪声。 +7. **Secret-bearing 域工具不进 sandbox、不做 key 下发**:容器内任意代码可 `print(os.environ)`,短期 token 只缩窗口不改根因;正确形态 = host-side JSON tool(LLM 传业务参数 → host 持 key 调远端 → 裁剪计量审计 → 返结果/落盘路径)。仅 env 存在才注册。 -**网络**:容器默认 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 SSRF)、`127.0.0.0/8`+`::1`(loopback)、内网三段 `10/8`+`172.16/12`+`192.168/16`、`100.64.0.0/10`(CGNAT);**PG 实际 IP 单独再 block 一遍**(belt-and-suspenders,自定义网络/VPC peering 会让段级 block 看似覆盖实际能直连)。 - -2. **网络 egress 模型**:容器内 `HTTP(S)_PROXY` 走宿主侧 proxy + iptables `DROP outbound except `(防 SDK 不读 env 绕过)。宿主 proxy 负责:① 域名 allowlist;② 红线段 IP block(再做一次);③ per-user 出网字节计量入软配额(超额 429);④ 审计日志 `network_audit`。**Allowlist 初始集**:`*.pypi.org`/`*.pythonhosted.org`/`github.com`/`raw|codeload|objects.githubusercontent.com`/`*.npmjs.org` + 部署的 PyPI 镜像域名。 - -3. **进程组清理协议**:`docker exec` 通过 `setsid` 包一层,timeout/cancel/正常结束三路径都 `kill -- -PGID` 杀整组。**目的**:防 `nohup &`/`disown`/派生 daemon 跨 exec 持久化——同 user 不做内隔离,stale 进程能看到后续 exec in-memory 状态,守不住这条残留风险就放大成"跨对话持久后门"。 - -4. **磁盘配额硬化时点**:首版应用层统计 + 周期扫描(=软配额);**外部用户开放前必须升级到 xfs/ext4 project quota 或 zfs dataset quota**。否则扫描间隙打满共享 fs 会拖死同节点其他 user(写满远快于扫描周期),且不算配额超额、排查痛苦。 - -5. **Executor 接口 + runtime config 注入**:不在工具层 hard-code `docker exec`,走 backend driver 抽象(`Executor.call_tool(tool, args, ctx)`);container runtime 走 config `ZCBOT_SANDBOX_RUNTIME=runc|runsc`(`docker run --runtime=`)。**理由**:未来切 gVisor/Firecracker/Kata/e2b 应用层零改动,避免接口泄漏 Docker 假设(`docker exec/cp/stats`)致后期重写。 - -6. **工具按信任域二分,Executor 内部 dispatch**(2026-05-26 修正:原"host 工具走 `resolve_user_path` 校验"是假命题无此函数;dogfood 发现 glob 仍列 host repo,改物理边界替代代码护栏): - - **Container exec backend**:`shell`/`run_python`/`read`/`write`/`edit`/`glob`/`grep` 全走 docker exec。shell/run_python 是任意代码;fs 工具以前 host 跑 `base_dir=Path.cwd()` 无 user_root 校验能读 `/etc/passwd`/源码/`~/.ssh`,进容器后 `user_root=/workspace` 是物理边界。调用形态:`docker exec --user zcbot --workdir /workspace/ -i python /sandbox/tool_runner.py ` + stdin 喂 JSON args(CJK/引号透明传);`tool_runner.py` 复用 `tools/fs.py`,skill references 走 `skills:/sandbox/skills:ro` mount。 - - **Host in-process backend**:`load_skill`/`save_skill`/`fork_skill`/`web_*`/`seedream`/`seedance`/`document_*`/`mp_*` — 持 key 不能进容器 env;`load_skill` 是内存查找无越界;`save_skill`/`fork_skill` host-side 写 `user_root/.skills`(沙箱 fs 的 base_dir 够不到)。 - - Dispatcher(`DockerExecutor`)内部分流,`AgentLoop` 零感知;接口形状按"未来全进容器 + tool-runner unix socket RPC"留好(升级信号见下表)。**代价**:每 fs tool call 多 ~200ms,对话级 N≤15 → 1-3s,LLM 推理 5-30s 下噪声。 - -7. **Secret-bearing domain tools 不进 sandbox,不做 key 下发**(2026-06-01):凡需 `*_API_KEY`/OAuth/DB credential 的能力**不能**让容器读 env,也不做"credential broker 发短期 key"(sandbox 内任意代码可 `print(os.environ)`/monkeypatch SDK,短期 token 只缩有效期不改根因)。正确形态=**host-side JSON tool**:LLM 传非敏感业务参数 → host tool 取 key 调远端 API → 裁剪/限大小/计量/审计 → 只返业务结果或落盘文件路径,容器最多读到落盘产物。已落地:`documents`/Materials Project 改 host tool(详 PROGRESS 06-01)。注册规则:仅对应 env 存在时注册,否则 schema 不暴露 + skill 文档提示降级。 - -**升级触发信号(反向兜底:无信号不升级)**: - -| 升级方向 | 触发信号 | 不升级的理由 | +| 方向 | 触发信号 | 不升级理由 | |---|---|---| -| Docker → **gVisor**(`runsc`) | 开放陌生注册 / 逃逸 CVE 未及时打补丁窗口 / 可疑 syscall 告警 | Docker + 完整 hardening 已挡主流逃逸,kernel 0day 在 dogfood 阶段非 #1 风险;gVisor syscall -30~50% 是真代价 | -| gVisor → **Firecracker / e2b** | 合规客户(PCI/HIPAA) / 单机 100+ user / gVisor 兼容墙撞死 | Firecracker 每 VM 100MB+ 起步不划算;e2b 数据出去执行与 storage_root 自持模型冲突 | -| `docker exec` → **容器内 tool-runner**(socket RPC) | `docker_exec_overhead/total > 30%` 持续两周 / 模型起长驻 web 服务 / 单轮工具调用 >20 次 | 自管进程组清理 + cgroup + 状态污染面 + 失去 Docker 工具链观测,代价 >> 200ms×N;**美学统一性 ≠ 升级理由** | +| Docker → gVisor | 陌生注册开放 / 逃逸 CVE 窗口 / 可疑 syscall | 完整 hardening 已挡主流逃逸;gVisor syscall -30~50% 真代价 | +| gVisor → Firecracker/e2b | 合规客户 / 单机 100+ user / 兼容墙 | 每 VM 100MB+ 不划算;e2b 与 storage_root 自持冲突 | +| docker exec → 容器内 tool-runner RPC | exec 开销 >30% 持续两周 / 长驻服务 / 单轮 >20 次调用 | 自管清理+观测损失 >> 200ms×N;美学统一 ≠ 理由 | -**Image 体积 / 多 user 资源 / 加包策略**(2026-05-28):sandbox image ~1.5G(python+chromium+node+mermaid),后续 domain 包还会推大。三点认知分开: +**Image 体积 / 多 user 资源 / 加包**(2026-05-28):① image 大 ≠ 运行时吃资源(layer 共享、不 exec 只是磁盘字节);② 瓶颈在并发 exec 不在 idle 容器,杠杆全在运行时限制;③ 新增依赖 = base 收敛 + **per-user venv**(`/.venv/`,bind mount 回收不丢;不放共享 volume——install 脚本是任意代码,破坏隔离)+ 使用频次沉淀进 base。 -1. **Image 大 ≠ 运行时吃更多资源**:空载 `sleep infinity` RSS 个位数 MB,image 里的库不 exec 只是磁盘字节;layer 共享让 N 个 user 容器磁盘乘数=1。真吃 RAM 的是 active exec(chromium 渲 mermaid 瞬时 200-500MB),跟 image 大小解耦。 -2. **多 user 瓶颈在并发 exec 不在 idle 容器**:100 idle 容器几百 MB 可接受;10 user 同渲 mermaid 瞬时 2-5GB 才是瓶颈。**杠杆全在运行时**(单容器 `--memory/--cpus/--pids-limit` + 同 user exec semaphore + 整机 active cap + idle 5min 回收),减 image 体积对这条曲线无影响。 -3. **新增依赖:base 收敛 + per-user 持久化 venv + 使用频次沉淀**:重包(torch/texlive)或长尾 domain 包不进 base,中高频+轻量的留 base;**采用 per-user venv** 落 `/.venv/`(bind mount 进容器 idle 回收不丢,`pip install --target` + `PYTHONPATH` 注入)。不放共享 named volume(破坏跨 user 隔离,install 脚本是任意代码);不依赖 pip cache(只省网络、回收照丢)。**沉淀机制**:audit 统计 >30% user 装过 ≥3 次的包 → 下次 build 合并进 `requirements.txt`,base 跟真实使用收敛。 +### 7.6 / 7.7 改造项与阶段 -落地对应 Stage C `DockerExecutor`(cgroup limits / 并发 semaphore / idle 回收 / per-user venv);audit 沉淀可延后。 - -### 7.6 Core 代码改造(按依赖顺序) - -> 哪步做完见 PROGRESS `## 状态`;此处只记改造项与依赖顺序。 - -| # | 项 | -|---|---| -| 1 | 事件流化 `loop.py` | -| 2 | Storage 落 PG(Session/TaskState 改 SQLAlchemy + alembic + docker-compose) | -| 3 | working_dir 字段语义(name 必填,派生 `users///`,同 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 | — | -| CORS 收紧 | `allow_origins` 从 `*` 改 platform 域名 allowlist | 已接入真实用户,**应尽快做**(与 OIDC 解耦) | -| D' 真 OIDC | 替换 /v1/auth/login 内部为 ID token 校验(邮箱密码并存保留) | 选做,platform_key 信任模型可接受则可延后;真要弃 PLATFORM_KEY 共享密钥时再做 | -| 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 复用。 +进度见 PROGRESS `## 状态`。依赖顺序:事件流化 → PG(一次性切换无双轨)→ working_dir 语义 → Files API → no-subtask → Executor+sandbox → /v1 → ~~CLI 双模式~~(撤)→ ~~Web UI~~(撤,API-only)。阶段:A/B/D/D' 完;CORS 收紧应尽快;真 OIDC 选做;**C 是外部用户开放 hard prereq**;F(限流/监控/HA)持续。 ### 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 活跃邻居),不挡发送;宪法文件已由 `-` 命名隔离(§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 分钟回收 | +| `/v1` 冻死演化慢 | minor 半年兼容 + deprecation 窗口 | +| running task 被 rename/delete | 后端校验 + UI 禁按钮 | +| DB-then-FS 中断孤儿 | rename DB 先行可回滚;delete 后台 GC 扫"FS 有 DB 无" | +| 同 wd 多 task 并发写同名 | known limitation,频率近 0;软警告 banner;宪法文件已按 short_id 命名隔离 | +| 并发 POST 撞 messages.idx | 单活 run gate(FOR UPDATE + 409)+ lifespan reaper;multi-worker 再换 lease | +| shell/run_python 无沙箱开放外部 = 主机沦陷 | **Stage C 是 hard prereq**;`BLOCKED_PATTERNS` 是 trivial-bypass 装饰品,不再加规则(黑名单 fundamentally broken),防线在 OS 层 | +| sandbox 出站越权 / 资源滥用 | default-deny + 受控 proxy;硬限制 + 软配额 + idle 回收 | ### 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~~ → task 改软删除(2026-06-17 推翻)**:原决策为避免 `orphaned` 特殊 case 选硬删(`DELETE tasks` CASCADE 连带 messages/usage_events)。公测后目标变为**沉淀用户对话轨迹做训练/研究语料**,硬删 = 语料永久丢失,故推翻:`DELETE /v1/tasks/{id}` 改为置 `tasks.deleted_at`(0010 migration),从 `list_tasks` / `list_folders` 计数中过滤,messages/usage_events(CASCADE 不再触发)与工作目录文件全部保留;新增 `POST /v1/tasks/{id}/restore` 恢复。原"特殊 case"成本被一处 `WHERE deleted_at IS NULL` 收口(列表是唯一用户可见入口,按 id 取单 task 的端点不过滤,恢复/直链仍可达)。心智改为:**平台对数据 append-only,用户"删除" = 可见性状态,永不销毁字节**。物理清理留给将来的管理员工具。`delete_file` 顶层目录 409 引用检查同步排除软删 task(否则"任务都删了文件夹却删不掉"死结)。 - -**文件留存(归档)—— 设计已定,实现待办**(2026-06-17):任务对话靠软删除即留在 DB;但**用户文件在 FS 上,删除/覆盖即字节丢失**,需单独留存以供训练/研究。已对齐的方案(尚未实现,优先级靠后):**① 基础设施层定时增量备份做持久化地基**(restic/borg → 只进不删、内容寻址去重,定时跑;与应用代码完全解耦 → 新端点/新工具自动覆盖不会漏,且捕获删除+覆盖+最终成品,这是"删除前归档"钩子拿不到的)+ **② 应用层轻量事件日志**(删除/覆盖时只追加 user/task/path/time/reason 一条,补 ① 缺的用户意图/出处语义;放 DB 表 `data_events` 而非 jsonl,避并发追加竞争)。**起步同盘**(防误删+留语料够;不防整盘损坏 —— 已知边界,将来换备份 target 到第二块盘/异地即可,纯配置改动)。**不选**"每个删除端点内联 copytree-再删":横切关注点手写 N 处 → 易漏(删文件/夹/skill、rename/upload/i2i 覆盖入口持续增加)、只看得见删除一瞬、跨卷拷脆。覆盖(如 seedream i2i 改图)若 ① 颗粒度不够,将来在该具体工具内定点补"覆盖前快照",不铺全局钩子。 - -**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):dogfood 经验同 wd 多 task 主要是"项目对话历史轨迹",并发频率近 0。走 Claude Code 同款"信任 + 软警告 + 承认 limitation",dev SPA 在 selectTask + SSE 收尾拉同 wd 活跃邻居挂 banner,不挡发送,真高频再升级。**不选 γ(同 wd 单活 gate)**:硬挡破坏扁平共享中间产物的切换流畅性;**不选 short_id 全产物隔离**:破坏 §7.1 共享语义 + SKILL 改造成本;**不选 clone task**:对零频"真要并行"场景工程量过重。 - -**`shell` / `run_python` 不在工具层加强黑名单,§7.5 sandbox 是 SaaS hard prereq**(2026-05-21,05-25):`BLOCKED_PATTERNS` 只挡几个明显失误,稍有意识就绕过(双空格 / `bash -c` / `python -c` / `curl|sh` / `cd /` 全过),`cwd` 非 chroot。**不继续加规则**:命令注入图灵完备(`shell=True` + 任何脚本语言),黑名单枚举不完、越复杂越虚假安全感、误伤合法用法。**正确防线在 OS 层**:§7.5 per-user 容器 + drop ALL caps + read-only rootfs + bind mount own root + default-deny network + cgroup,Stage C 前仅 dogfood + 信任白名单。**per-user 非 per-task**:文件模型 user-rooted,安全目标是跨 user 隔离非同 user task 互隔。**非所有操作进容器**:auth/DB/files/SSE/LLM/受控 web 工具属 control plane 留宿主做权限审计,只有不可信代码进 execution plane。**本地 dogfood 接受风险**:自己机器 + 自己 prompt,blast radius 限自身(§5);外部场景 blast radius 是主机 + 他人数据 + cloud IAM,信任模型不同必须 §7.5。 - -**task 级「宪法」文件靠文件名隔离,不 cascade / 不入 DB / 不开物理子目录**(2026-05-20):同 wd 多 task 共享中间产物(source/sections/figures)是价值,但 spec 这种 1:1 宪法文件必须隔离。文件名 `--..md`:`short_id`(`task_id.hex[:8]` 永不变)主锚,glob 字典序最大=current;日期让"重定调"写新文件成历史快照;`task_name` 仅可读说明,改 name 不 cascade(short_id 兜底)。**不选**:① cascade rename(in-flight 丢文件 + 复杂);② DB 化(最干净但工作量 5-10× 且失"直接编辑 markdown"、spec 字段还在演化);③ 物理 task 子目录(破坏扁平共享)。**升级 DB 化信号**:想做结构化编辑视图 / 跨 task 查 spec 字段 / 版本文件堆积乱。约定由 `_build_system_prompt` 单点注入,所有 skill SKILL.md 引用同一份。 +- **path-as-identity 而非 folder_id**:folder 真实存在于 FS,folder_id 是第二份 source of truth;rename 走 DB-aware 同事务 cascade。 +- **files API 单一 mutation 入口**(2026-05-18):"顶层目录分支"从数据状态派生而非客户端意图,放服务端才有强制力;双命名空间(/folders vs /files)把分支搬给 client,失强制力且端点翻倍。 +- **task 软删除(2026-06-17 推翻 hard cascade)**:公测后对话轨迹是训练/研究语料,硬删=永久丢失。`deleted_at` 置位 + restore;心智=**平台对数据 append-only,"删除"是可见性状态**。物理清理留管理员工具。 +- **文件留存(设计已定,实现待办)**:用户文件在 FS,删除/覆盖即字节丢。方案=① restic/borg 定时增量备份做地基(与应用解耦,新端点自动覆盖,捕获删除+覆盖+成品)+ ② 应用层 `data_events` 事件日志(补用户意图语义)。**不选**每个删除端点内联 copytree:横切关注点手写 N 处必漏。起步同盘(不防整盘损坏,已知边界)。 +- **0004 删 runs/usage_events 旧表**:只写不读的死代码;代价是失历史 run 元数据,真要细粒度审计再补(届时是新需求非技术债)。 +- **本地也用 PG 不用 SQLite**:dogfood ≡ 真实路径;Docker 已是必然依赖;双 adapter 维护税 > 一次性配置。 +- **API-only,UI 由 platform 实现**(2026-05-15):本仓库再维护一套 UI 是双套浪费;SSE payload 从 HTML 切 JSON;沉淀的 sink/broker/路径安全全保留。**dev SPA 留一份**作 dogfood 主路径(SSE 调试 curl/Swagger 都覆盖不了)。 +- **CLI REPL 撤**(2026-05-18):dev SPA 落地后 REPL 与 web 完全等价,双套 task 语义只是"对称美",每个 bug 修两次;看内部状态临时写 ad-hoc script 即可。 +- **Memory 不入 DB**:见 §3.7。**Tasks/messages 在 PG、产物在 FS**:查询/统计是 DB 强项;产物终用户要文件管理器看到、Office 打开——**FS 是产物天然存储,DB 是元数据天然存储**;bind mount = user root 让容器视角 ≡ 用户视角,无翻译层。 +- **同 wd 并发只做软警告**(2026-05-21):dogfood 中同 wd 多 task 是"项目对话历史",并发近 0;硬 gate 破坏切换流畅、short_id 全隔离破坏共享语义、clone task 工程过重——都不选,真高频再升级。 +- **shell 黑名单不加强**(2026-05-21):命令注入图灵完备,枚举不完、越复杂越虚假安全;正确防线 §7.5 OS 层。本地 dogfood blast radius 限自身可接受,外部开放信任模型不同必须 Stage C。 +- **task 级「宪法」文件靠文件名隔离**(2026-05-20):`--..md`,short_id 主锚 + glob 字典序最大=current;不 cascade rename(in-flight 丢文件)、不 DB 化(工作量 5-10× 且失直接编辑)、不开物理子目录(破坏扁平共享)。升级 DB 化信号:结构化编辑视图 / 跨 task 查 spec 字段。 --- ## 8. 未来步骤 / 已落地设计 -> 实施细节(步骤清单 / 验收项)进 PROGRESS + git;此处只留缺口、选型与取舍。 +> 实施细节进 PROGRESS + git;此处只留缺口、选型与取舍。 -### 8.1 图像理解 + Seedream i2i(2026-05-29 设计;✅ 2026-06-16 i2i + look_at_image 双双落地) +### 8.1 图像理解 + Seedream i2i(✅ 2026-06-16) -**缺口**:DeepSeek V4 主模型纯文本无视觉;`seedream` 只 t2i;"基于已生成图二次修改" / "上传外部参考图让 agent 据此干活"两条路径未覆盖。 +缺口:主模型纯文本;t2i 无法"改已生成图"或"读上传图"。**选 E+C 组合**:seedream 加 `reference_images` 走 i2i + 新增 `look_at_image`(Doubao Seed 2.0 Lite,一次读图 <¥0.01)让 DeepSeek 自决何时"借眼睛"。**不选 A**(主模型换多模态:V4 code/tool-calling 是核心,换=降能力+改 loop 引 multimodal,工程 5×);**不选 B**(每条消息隐式 vision 路由:烧 token+失 agentic 控制权)。关键实测:ARK 接受 base64 data URL → 内网无需对象存储。**升级到 A 的信号**:用户要"贴图直接对话读图"成高频——当前假设"图是工具调用对象"而非"对话内容"。 -**选 E + C 组合**:`seedream` 加 `reference_images` 走 i2i(改已生成图,像素级)+ 新增 `look_at_image` 走豆包视觉单图理解(读外部图,DeepSeek 自决何时调)。改动面=2 tool + 1 prompt 段 + 1 yaml 段,不动 loop / llm / capabilities / DB / 前端。 -> **模型选型更新(2026-06-16)**:设计时写的 Seed 1.6 vision 已过时,落地用 **Doubao Seed 2.0 Lite**(`doubao-seed-2-0-lite-260428`,2026-02 发布、05 升级为全模态理解 SOTA 细粒度感知)。Seed 2.0 全系文本模型已原生支持图片输入 → A 路(主模型换多模态)门槛降低,但主模型 DeepSeek V4 的 code/tool-calling 仍是核心,**维持 C 路(vision 当工具)不变**。token 计费(输入 ¥0.6 / 输出 ¥3.6 / Mtok),一次读图 < ¥0.01。 -- **不选 A(主模型换多模态)**:V4 的 code / tool calling 是主路径核心,换豆包当主 chat 降能力 + 要改 loop/memory 引 multimodal,工程 5× 且破坏架构。 -- **不选 B(后台 vision 路由)**:每条消息隐式 vision 描述 = 多烧 token + 1 跳延迟 + 失去 agentic 控制权 + debug 难。 +### 8.2 Token 优化与上下文治理(✅ 2026-06-04 起) -**关键实测**:Seedream 5.0 `/images/generations` 接受 `image_urls` base64 data URL,200 返新图 → **内网无需对象存储中介**(排除最大工程不确定性)。约束:输出 ≥~1920²、单张参考 ≤10MB、最多 14 张。 +根因:全量历史每轮重发。**质量边界(设计约束,后续改动都守)**:不改模型输入的优化(caching/计费)零风险;改可见上下文的必须**保留可追溯原文**(长结果落文件留路径,确认过的需求/规格/结论不删);**禁止"只保留最近 N 条"当主策略**。落地:`core/context.py` 发送前压缩旧 tool/load_skill(保协议字段,不改持久化历史)+ **压力门槛**(未逼近上限完全跳过,护 DeepSeek 前缀缓存,实测命中 92-94%)。教训(2026-06-12):**不压 assistant tool_call 参数**——压缩 marker 会被模型仿写成真实参数(投毒),范本必须永远真实可执行。task summary 并入 §8.8 Phase 2。 -**风险 / 边界**:v1 只支持单张参考(multi-ref 角色定义靠 prompt,留 v2);base64 ARK 未承诺长期稳定(收紧则降级走 TOS 上传换 URL)。 +### 8.3 PPTX 前端在线预览(✅ Stage 1) -**落地实况(2026-06-16,详 PROGRESS)**: -- **E 路(改图)**:`seedream` 加 `reference_images`(v1 单图,传 >1 报错);路径解析强制落 user_root 内防越界;前端 `chat.js` 补 paste 路径注入(把粘贴图路径作 `[用户上传的参考图]` 行进正文,修了"粘贴路径到不了模型"的既有缺口)。 -- **C 路(看图)**:`look_at_image` tool(`tools/look_at_image.py`)走 Seed 2.0 Lite `/chat/completions`,base64 单图 + 问题 → 文本解读;`doubao.yaml` 加 `vision:` 段;`usage.py` 加 `record_vision_usage`(kind="vision",token 计费);agent_builder 注册 + media prompt 段。图片解析与 i2i 共用 `tools/image_ref.py`。真机 smoke(`scripts/smoke_look_at_image.py`)OCR 验证通过。 -- 两路均不动 loop / llm / DB schema(`usage_events.kind` 自由文本,vision 无需 migration)。 -**升级到 A 的信号**:用户要"贴图同时说话模型直接读图回话",或多轮带图成高频 —— 当前假设"图是工具调用对象"而非"对话内容"。 +关键洞察:前端已有 PDF iframe 路径 → 后端 soffice 转 PDF 即可,前端几乎不动。选 LibreOffice(像素级保真、任意 pptx)不选轻量 HTML(复杂失真)/PDF→PNG(失矢量)。转换在 web host 不进沙盒;宏安全 high + 禁网 + 仅本人 user_root。与 `pptx_preview.py`(agent 生成期自检)分工。Stage 2 未做:常驻 listener / eager 预转。 -### 8.2 Token 优化与上下文治理(2026-06-04,✅ 已落地,详 PROGRESS) +### 8.4 运维监控 / 无感更新(监控 ✅ / 换版 design) -**根因**:`Session.load()` 把全量历史装回每轮 LLM 调用,旧 tool 结果 / `load_skill` 正文 / 检索结果 / 长 stdout 反复携带;LiteLLM cost map 未覆盖 V4 致 `cost_cny=0` 不可用。 +**优雅 drain 已是单实例上限**(SIGTERM 拒新 run + 等收尾);先撞的瓶颈是线程池(每活跃 run 占 1 线程)。落地排序:① 轻量监控(显式 executor + 60s `[stats]` 周期日志——要历史峰值不是快照);② 按数据决策扩容;③ `--reload` 缩 503 窗。**不做监控界面**:运维健康是少数标量,日志够;业务分析走 DB SQL。界面阶梯:日志 → /v1/stats → Grafana → 只读 dashboard,现停第一级。gunicorn 无感换版 / broker 外置 Redis 搁置到真实投诉。 -**质量边界(设计约束,后续改动都守)**: -- 不改模型输入的优化(prompt caching、固定前缀、计费修复、cache hit/miss 记录)不影响输出质量。 -- 改模型可见上下文的优化(裁剪 / 摘要 / 按需读取)必须**保留可追溯原文**:长结果写文件留路径,summary 只替代陈旧噪声,**用户确认过的需求 / 规格 / 大纲 / 关键结论不删**。 -- **禁止把"只保留最近 N 条"当主策略** —— 省 token 但最易丢已确认约束。 +### 8.5 定时任务(✅ 2026-06-18) -**选型**:Context Editing + Memory/File State + Cache Observability 混合。稳定 system/tools 前缀利于 provider cache;旧 tool result 移除或压缩;关键发现写 task summary / FS,需要时 `read` 重新拉。长上下文保留作少数全局推理的临时能力,非默认每轮成本。 +**核心洞察**:job 本体 = cron+tz + 一句 prompt + 会话模式;守护循环只负责到点把带标记的 prompt 喂进**现成 agent 主管线**,不造第二套执行路径。**"发邮件"不是字段是 agent 动作**——加任何投递能力不改 schema。业界四源(OpenClaw/Autobot/Claude Code/geta)模式收敛佐证。 -**落地形态**:`core/context.py` 发送前压缩旧 tool / `load_skill` / assistant tool_call arguments(保 `role/tool_call_id/name` 协议完整),不改持久化历史;**上下文压力门槛**(2026-06-10):总 chars 未逼近上限则完全跳过压缩、原样发,护 DeepSeek 前缀缓存(短任务字节逐轮一致、命中 92-94%)。task summary(旧消息压成一条、区分硬约束/计划/文件路径/关键事实)为第二步,未做 —— 已并入 §8.8 Phase 2(对齐 Hermes 结构化摘要)统一推进。channel 常驻会话的无限累积另由 §8.8 软重置分段治理(本节压缩挡不住跨时段累积)。 +- **三层投递**:baseline(结果必进 task 线程)→ opt-in 推送(prompt 里说,agent 调工具)→ 可靠兜底(job.notify 结构化,run 完确定性补发)。 +- **会话模式**:isolated 默认(每次新 task,省 token)/ persistent(绑定 task 续上下文,token 逐日涨——仅用户明确要连续性)。mode 只管对话延续;**文件夹两模式都按 job 复用**。定时 task 标 `scheduled_job_id` 不混普通列表。 +- **可靠性**:退避重试(transient/permanent 区分,v1 简化为下个 cron 点)、per-job 超时(默 1800s,复用协作 cancel;超时按 error 记不吞)、**无补跑**、定时 run 内禁 schedule_create(防自我繁殖)、连续失败 N 次自停。 +- **选型**:croniter 只当 next_run 计算器(vixie dom/dow OR 语义 + 时区,手搓必踩坑);**不引 APScheduler/Celery**(单机低并发,过度工程);**不用 JSON 文件持久化**(已有 PG)。persistent 绑定 task 正忙 → 跳过本次不排队。 +- **前端取舍**:对话端完整 CRUD(schedule_* 工具),前端只读看板 + 停用/删除——cron 构建器 UX 难题直接消失(用户对 bot 说"每天早九点");工具与 REST 共用 `core.scheduler` 服务层不漂移。v1 纯工具不配 skill(schema 够;skill 值钱处是教写好 job.prompt,v2 按需)。 -### 8.3 PPTX 前端在线预览(2026-06-09,✅ 已落地 Stage 1) +### 8.6 平台渲染层 rendering/(✅ 2026-06-23) -**动机**:文件区点 `.pptx` 原只能下载;要在浏览器直接翻看,且覆盖任意 pptx(含上传)。 -**关键洞察(定方案极简)**:前端已有 `