core(入口归位): cli.py→main.py, 原 main.py→core/agent_builder.py, 删 REPL

按 §5 Less Scaffolding + SoC 把混三角色的 main.py 拆开:入口归位到 main.py,
装配 lib 归位到 core/agent_builder.py。dev SPA 落地后 CLI REPL(chat/tasks/
export)与 web /v1 等价,维护双套 task 切换语义只是"对称美",一并撤(§7 E
CLI 双模式路线同样撤)。

- cli.py → main.py(入口,只剩 web/db/probe 三 click 命令组)
- 原 main.py → core/agent_builder.py(build_agent / system prompt /
  validate_task_name 装配 lib;顺手删死代码 _resolve_uuid_or_prefix +
  resume "last" 分支)
- 删 chat/tasks/export 三 REPL 命令 + _cleanup_if_empty / _list_task_rows
  等 CLI-only helpers ~400 行
- web/app.py 5 处 from main import → from core.agent_builder import
- DESIGN §1/§2/§3.3/§3.6/§7.0/§7.6/§7.7/§7.8/§7.9 + RUN + PROGRESS 全套同步
- Smoke 6 case 全绿(in-process TestClient + 子进程 python main.py db current)
- 净减 486 行

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-05-18 14:10:59 +08:00
parent 2e519ab8a6
commit 0d127a7261
7 changed files with 504 additions and 990 deletions

View File

@ -14,7 +14,7 @@
- 模型自由:LiteLLM + OpenAI-compatible(默认 DeepSeek V4) - 模型自由:LiteLLM + OpenAI-compatible(默认 DeepSeek V4)
- 任务持久化:任意时刻关机,下次能恢复 - 任务持久化:任意时刻关机,下次能恢复
- 演化性:模型升级不需要大改架构 - 演化性:模型升级不需要大改架构
- **形态兼容**:本地 CLI 与 SaaS 共享同一份 core 和 storage(PG,无 SQLite / JSON 分支);CLI 长期保留(本地直跑 + `--remote` API client 双模式) - **形态兼容**:本地与 SaaS 共享同一份 core / storage(PG,无 SQLite / JSON 分支)/ web `/v1` API。本地形态 = `python main.py web` 起 FastAPI + dev SPA + sentinel JWT,跟 SaaS 走完全一致的路径(只是 user_id 一直填 sentinel),无 CLI REPL / 本地 in-process 分叉(2026-05-18 撤,详 §7.9)
--- ---
@ -43,12 +43,13 @@ zcbot/
│ └── users/<user_id>/ │ └── users/<user_id>/
│ ├── .memory/{core.md, extended/*.md} # 跨 task 共享记忆(user 级,dotfile 隔离) │ ├── .memory/{core.md, extended/*.md} # 跨 task 共享记忆(user 级,dotfile 隔离)
│ └── <working_dir>/ # 工作目录,用户起名(同 working_dir 多 task 共享),仅 skill 产物 │ └── <working_dir>/ # 工作目录,用户起名(同 working_dir 多 task 共享),仅 skill 产物
└── {main.py, cli.py} ├── 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 立刻报红。本地 CLI user_id 固定为 SENTINEL(`00000000-...`);web/JWT 路径用 `sub`。**`name`(任务显示名)必填**,**`working_dir` 可选**(留空 → 用 name 作目录名);两者都是简单名(不含 `/\..`、不以 `.` 起头,挡 `.memory`);同 `working_dir` 多 task 自动共享同目录(§7.1)。SaaS 化只是把 `workspace/``<storage_root>/`,布局不变。 **工作目录(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`;dev SPA 默认填 SENTINEL(`00000000-...`)走同一条路径,无本地 / 远程分叉。**`name`(任务显示名)必填**,**`working_dir` 可选**(留空 → 用 name 作目录名);两者都是简单名(不含 `/\..`、不以 `.` 起头,挡 `.memory`);同 `working_dir` 多 task 自动共享同目录(§7.1)。SaaS 化只是把 `workspace/``<storage_root>/`,布局不变。
**启动**:读 `agent.yaml` → 加载 `ModelCapabilities``LLM(caps)` → 解析 task_dir → 拼 system prompt(general_v1.md + skill discovery + cwd + task_dir 绝对路径)→ 装配工具 → REPL。新建路径**懒创建**,不预占文件(§3.6)。`ZCBOT_DB_URL` 指 PG(本地 docker compose / 远端 dev / 生产)。 **启动**:`python main.py web` → uvicorn 起 FastAPI → lifespan 跑 stale-run reaper → 客户端走 `/v1/auth/login` 换 JWT → `POST /v1/tasks/{id}/messages` 起 BG 线程,内部 `build_agent`(`core/agent_builder.py`)`agent.yaml` → 加载 `ModelCapabilities``LLM(caps)` → 解析 working_dir → 拼 system prompt(general_v1.md + skill discovery + cwd + working_dir 绝对路径)→ 装配工具 → `AgentLoop.run`。`ZCBOT_DB_URL` 指 PG(本地 docker compose / 远端 dev / 生产)。
--- ---
@ -67,7 +68,7 @@ ReAct:LLM → 若有 tool_calls 就执行 → 结果塞回消息 → 再调 LLM
字段: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。 字段: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。 `LLM.chat` 按 capabilities 自动启 `parallel_tool_calls` / `reasoning_effort` / Anthropic prompt-caching header。
### 3.3 Capability Probing(`core/probe.py` + `cli.py probe`) ### 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)。 yaml 是手填的,probe 用真实调用对账:`basic_chat` / `parallel_tools` / `thinking_mode` / `long_context`(opt-in)。不改 yaml,只出 rich Table 报告。**显式触发,不进启动路径**(避免烧 API)。
### 3.4 工具系统(Hybrid 范式) ### 3.4 工具系统(Hybrid 范式)
@ -91,11 +92,11 @@ yaml 是手填的,probe 用真实调用对账:`basic_chat` / `parallel_tools` /
**创建语义** —— 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 复用)。 **创建语义** —— 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 复用)。
**REPL 内 task 切换** —— `/new` / `/resume [last|<id>]`(无参列最近 10 个)/ `/done /abandon` / `/desc`。切走前 `_cleanup_if_empty` 守门:无 user message → DELETE DB 行;**FS 一律不动**(同 name 跨 task 共享,绝不 rmtree)。 **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)。 **原子性** —— PG INSERT 天然原子;skill 产物走 `core.session.atomic_write_text`(tmp + fsync + replace)。
CLI:`chat --name "<任务名>" [--working-dir <目录名>] [--skill coding] [--desc "..."] [--resume last|<id>] [--remote <url>]`;`tasks [--status ...]` **入口**:`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`) ### 3.7 双层记忆(`core/memory.py`)
@ -185,21 +186,19 @@ memory 由人填(也允许 agent 用 `write` 写),系统不自动维护 ——
### 7.0 与本地形态的兼容性 ### 7.0 与本地形态的兼容性
SaaS 化不是"重写"也不是"取代 CLI",而是**给同一份 core 加一个 HTTP 入口**。落地过程中本地 CLI 始终可用 SaaS 化不是"重写",而是把同一份 web `/v1` 服务部署到云端。本地形态(`python main.py web`)与 SaaS 形态走完全一致的代码路径,无 CLI / in-process 分叉(2026-05-18 撤,详 §7.9)
**共享**:同一份 `core/` / `tools/` / SKILL.md / prompts。 **共享**:同一份 `core/` / `tools/` / SKILL.md / prompts / web `/v1` 路由 / dev SPA
**差别**: **差别**:
| 维度 | 本地 | SaaS | | 维度 | 本地 | SaaS |
|---|---|---| |---|---|---|
| 入口 | `cli.py chat` 直调 core | HTTP `/v1/...` + SSE | | 入口 | `python main.py web` 起 FastAPI + dev SPA | uvicorn 部署形态,反代到 platform UI |
| Storage | **PG**(`ZCBOT_DB_URL` 指 docker compose / 远端 dev PG) | **PG**(指生产 PG) | | Storage | **PG**(`ZCBOT_DB_URL` 指 docker compose / 远端 dev PG) | **PG**(指生产 PG) |
| task_dir 派生 | `workspace/users/<sentinel>/<name>/`(`name` 必填,简单名) | `<storage_root>/users/<user_id>/<name>/`(`name` 必填,简单名) | | working_dir | `workspace/users/<sentinel>/<name>/`(dev SPA 默认填 SENTINEL) | `<storage_root>/users/<user_id>/<name>/`(JWT `sub`) |
| Memory | `workspace/users/<sentinel>/.memory/`(FS,dotfile) | `<storage_root>/users/<user_id>/.memory/`(仍是 FS,dotfile) | | Memory | `workspace/users/<sentinel>/.memory/`(FS,dotfile) | `<storage_root>/users/<user_id>/.memory/`(仍是 FS,dotfile) |
| Sandbox | subprocess + env 过滤 | per-task docker exec | | Sandbox | subprocess + env 过滤 | per-task docker exec |
| Auth | 无(`user_id='local'`) | PLATFORM_KEY → JWT(过渡)→ OIDC | | Auth | PLATFORM_KEY → JWT(过渡)— dev SPA 填 sentinel + 本地 key | 同 — platform 端服务端持 key 签 JWT |
**CLI 长期双模式**:本地直跑(默认,in-process,直连 PG,适合调内部状态)/ `--remote https://...`(HTTP 走 `/v1`,等价真实用户路径)。两模式共用 `cli.py`,差别只在 transport 层。
`workspace/` 仅存 skill 产物,state / messages 全在 PG。本地 vs SaaS 共用 `users/<user_id>/` 子树布局,差别只在外层根目录(`workspace/` vs `<storage_root>/`),不在 storage 形态。 `workspace/` 仅存 skill 产物,state / messages 全在 PG。本地 vs SaaS 共用 `users/<user_id>/` 子树布局,差别只在外层根目录(`workspace/` vs `<storage_root>/`),不在 storage 形态。
@ -380,7 +379,7 @@ create index on messages using gin (payload jsonb_path_ops);
| 5 | **No-subtask 校验**:`create_task` 入口跑 §7.4 SQL | 0.5 天 | | 5 | **No-subtask 校验**:`create_task` 入口跑 §7.4 SQL | 0.5 天 |
| 6 | **Executor + sandbox**:`run_python`/`shell` → `Executor.run(...)`;本地保留 subprocess executor,SaaS 走 docker;`api_key_env` → `KeyProvider` 运行时注入 | 2-3 天 | | 6 | **Executor + sandbox**:`run_python`/`shell` → `Executor.run(...)`;本地保留 subprocess executor,SaaS 走 docker;`api_key_env` → `KeyProvider` 运行时注入 | 2-3 天 |
| 7 | **HTTP /v1**:FastAPI + SSE + OIDC | 4 天 | | 7 | **HTTP /v1**:FastAPI + SSE + OIDC | 4 天 |
| 8 | **CLI 双模式**:transport 层抽象,默认 in-process;`--remote` 走 HTTP;**本地直跑不删** | 1.5 天 | | 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 取舍) | 已落 | | 9 | ~~Web UI 简洁版(Jinja2+HTMX)~~ → 改为 **API surface 完工**:Phase G 落地的模板 / HTMX / 服务端 markdown 渲染删除,所有路由切纯 JSON;UI 由 platform 端实现(§7.9 取舍) | 已落 |
代码量增量:**+1000~1500 行**(单一 PG 比双 adapter 省 500-800 行;UI 不计入,本仓库只维护 API)。 代码量增量:**+1000~1500 行**(单一 PG 比双 adapter 省 500-800 行;UI 不计入,本仓库只维护 API)。
@ -395,7 +394,7 @@ create index on messages using gin (payload jsonb_path_ops);
| 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' 过渡 | 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 内部 | | D' 真 OIDC | 替换 /v1/auth/login 内部为 ID token 校验 + CORS allowlist 收紧 | 1 天 | 真发布给真实用户前补;路由层 Depends 不动,只换 login 内部 |
| C | #6 Executor + sandbox | 3 天 | 两本地账号互不可见对方 folder,本地 subprocess executor 仍可用 | | C | #6 Executor + sandbox | 3 天 | 两本地账号互不可见对方 folder,本地 subprocess executor 仍可用 |
| E | #8 CLI transport 双模式 | 1.5 天 | 默认本地直跑保留,`--remote` 走 HTTP 跑通 | | ~~E~~ | ~~CLI transport 双模式~~**撤**(2026-05-18,§7.9):dev SPA 已是本地 dogfood 主路径,CLI REPL 整套删,`main.py` 入口只剩 web/db/probe | — |
| ~~G~~ | ~~Web UI 简洁版~~ —— **删除**,前端由 platform 端实现 | — | 本仓库不维护 UI | | ~~G~~ | ~~Web UI 简洁版~~ —— **删除**,前端由 platform 端实现 | — | 本仓库不维护 UI |
| F | 上线打磨(限流 / 监控 / 告警 / HA)| 持续 | SLO 99.5% | | F | 上线打磨(限流 / 监控 / 告警 / HA)| 持续 | SLO 99.5% |
@ -407,8 +406,7 @@ create index on messages using gin (payload jsonb_path_ops);
| 风险 | 缓解 | | 风险 | 缓解 |
|---|---| |---|---|
| 过早抽象违背 §5 | B 阶段单一 PG 无 adapter;C-E 各阶段独立 dogfood 价值 | | 过早抽象违背 §5 | B 阶段单一 PG 无 adapter;各阶段独立 dogfood 价值;CLI REPL(原 §7.6 #8 双模式)2026-05-18 整套撤,无双 transport 维护税 |
| CLI 双模式分叉、本地直跑被忽略 | transport 层抽象统一接口;CI 跑两路径同一组用例 |
| `/v1` 冻死后演化慢 | minor 半年兼容,major 6 个月 deprecation;`/v1internal` 实验 | | `/v1` 冻死后演化慢 | minor 半年兼容,major 6 个月 deprecation;`/v1internal` 实验 |
| Rename 误中前缀 / 漏改子 task | cascade SQL 用 `old/%` + 单测覆盖 | | Rename 误中前缀 / 漏改子 task | cascade SQL 用 `old/%` + 单测覆盖 |
| Running task 被 rename / delete | 后端校验 + UI 禁按钮 | | Running task 被 rename / delete | 后端校验 + UI 禁按钮 |
@ -449,9 +447,17 @@ create index on messages using gin (payload jsonb_path_ops);
- CORS `allow_origins=["*"]` 本地宽松,platform 部署时按 platform 域名收紧 - CORS `allow_origins=["*"]` 本地宽松,platform 部署时按 platform 域名收紧
- **沉淀**:G 阶段的 sink 协议(§7 A)/ RunBroker fan-out / no-subtask 校验 / files 路径安全归一 / task_dir 相对存储 全部保留,不在 UI 层不被牵连 - **沉淀**:G 阶段的 sink 协议(§7 A)/ RunBroker fan-out / no-subtask 校验 / files 路径安全归一 / task_dir 相对存储 全部保留,不在 UI 层不被牵连
**dev SPA 留一份**(2026-05-15 决策):`web/static/dev.html` 单文件 vanilla JS,3 栏布局(task list + chat + files),~600 行无构建链。**与"UI 由 platform 实现"不冲突**:platform UI 是给真用户的生产形态;dev.html 是给本仓库开发者自验 /v1 API + SSE 流的开发期工具。platform 未上线 / 网络断 / 凌晨随手验时不需要拉 platform。理由:① SSE 调试在 curl 里看不到 UI 反应,需要可视端;② Swagger 不发 SSE 流也没流式视图;③ 一个静态文件维护成本可忽略,删了再补不如留着。形态:登录页填 user_id(默 sentinel)+ platform_key → localStorage 存 JWT → fetch+Bearer。 **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)。形态:登录页填 user_id(默 sentinel)+ platform_key → localStorage 存 JWT → fetch+Bearer。
**CLI 不被 API 取代,而是双模式共存**:本地直跑调 core 内部状态比 HTTP roundtrip 顺手;前端用户路径靠 `--remote` 打通。离线靠本地 docker compose PG 兜底,不靠"全栈零依赖"幻觉。 **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 默认填 SENTINEL 走同一条 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"事实由用户判断"。 **Memory 不入 DB**:跨 task 共享靠"同一 user 同一 FS 目录"自动达成。md 用户直接编辑器改,DB 化反而要造 UI、违反 §3.7"事实由用户判断"。

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。 > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。
最后更新:2026-05-18(0004 大瘦身:删 runs / usage_events 表,run_status / run_error 合入 tasks;cancel / SSE 路由从 run_id 维度改 task 维度;broker 全 task_id 索引) 最后更新:2026-05-18(`cli.py` 改名 `main.py`(入口);原 `main.py` 挪到 `core/agent_builder.py`(装配 lib);CLI REPL `chat / tasks / export` 删,入口只剩 `web / db / probe`;§7 E CLI 双模式路线撤)
--- ---
@ -15,12 +15,13 @@
| 5 | Eval Suite | ⏸ 不做 | dogfooding 替代,probe 覆盖健康检查 | | 5 | Eval Suite | ⏸ 不做 | dogfooding 替代,probe 覆盖健康检查 |
| 6 | 长任务工程化 | 🟡 | task + 恢复 ✅;双层记忆 ✅;context 压缩未做 | | 6 | 长任务工程化 | 🟡 | task + 恢复 ✅;双层记忆 ✅;context 压缩未做 |
| 7 | 打磨 | ❌ | Docker 沙盒 / 更多 skill | | 7 | 打磨 | ❌ | Docker 沙盒 / 更多 skill |
| §7 SaaS | DESIGN §7 路线 | 🟡 | A 事件流化 ✅;B 完工;**D `/v1` JSON API 完工 ✅**(原 Phase G Jinja2/HTMX UI 撤,改 platform 端实现);**D' 过渡 auth(PLATFORM_KEY → JWT)+ dev SPA ✅**;**同 task 单活 run 锁 ✅**;**task-level cancel + dev SPA stop 按钮 ✅**;**0004 schema 瘦身 ✅**(删 runs/usage_events);真 OIDC 待;C(Executor)待;E(CLI 双模式)待。 | | §7 SaaS | DESIGN §7 路线 | 🟡 | A 事件流化 ✅;B 完工;**D `/v1` JSON API 完工 ✅**(原 Phase G Jinja2/HTMX UI 撤,改 platform 端实现);**D' 过渡 auth(PLATFORM_KEY → JWT)+ dev SPA ✅**;**同 task 单活 run 锁 ✅**;**task-level cancel + dev SPA stop 按钮 ✅**;**0004 schema 瘦身 ✅**(删 runs/usage_events);**入口归位 ✅**(`cli.py`→`main.py`,装配 lib 挪 `core/agent_builder.py`,CLI REPL 删,§7 E 撤);真 OIDC 待;C(Executor)待。 |
--- ---
## 已完成关键能力 ## 已完成关键能力
- **05-18 / 入口归位:`cli.py` 改名 `main.py`、原 `main.py` 挪 `core/agent_builder.py`,删 CLI REPL `chat/tasks/export`,§7 E 双模式路线撤**:接 0004 schema 大瘦身后又一轮架构清理。用户复盘"§7 E `--remote` 是不是可以移除""有 dev SPA 后 CLI REPL 还需要吗""统一到 main.py 是否合理"——一连串问题指向同一个底层:`cli.py`(CLI 入口)+ `main.py`(装配 lib)+ `chat / tasks / export` REPL 子命令是历史多形态共存遗留,在"UI 由 platform 实现 + dev SPA 是开发主路径"的新形态下都是冗余。**架构判断**:`main.py` 此前混三角色(装配 lib + 路径/验证 utility + 被 cli+web 共同 import 的事实入口),按 §5 Less Scaffolding + SoC 应该拆;直接答案是 `cli.py` 改名 `main.py`(入口),原 `main.py``core/agent_builder.py`(装配 lib),单一职责对齐 Python 社区惯例(入口叫 main.py,lib 在子模块)。**改动**:① `git mv main.py core/agent_builder.py`;② `git mv cli.py main.py`(覆盖);③ 5 处 `web/app.py::from main import xxx``from core.agent_builder import xxx`(`build_agent / sync_task_tokens / working_dir_from_name / resolve_workspace / user_root / InvalidTaskName / validate_task_name`);④ 新 `main.py` 自指 `from main import``from core.agent_builder import`;⑤ 删 `chat / tasks / export` 三个 click 命令 + REPL 内部 helpers(`_cleanup_if_empty / _delete_task_db_row / _task_has_messages / _list_task_rows` 共 ~110 行)+ REPL 主循环(`/exit /reset /new /resume /id /status /done /abandon /desc /export` 共 ~200 行)+ `--name --working-dir --skill --desc --resume --model` CLI 选项 + `tasks` 列表渲染 + `export` 命令 — 共 ~400 行;新 `main.py` ~180 行(`db {upgrade,downgrade,current}` + `probe` + `web` 三命令组);⑥ `core/agent_builder.py` 顺手清:删 `_resolve_uuid_or_prefix` 函数(web 端只传完整 UUID,前缀匹配无 caller)+ `resolve_task_id``task_id_arg in (None, "", "last")` 分支(web 不传 "last"),resume 直接 `UUID(task_id_arg)`;模块 docstring "本地 CLI user_id = SENTINEL" → "所有入口走 web `/v1` + JWT;dev SPA 默认填 SENTINEL 走同一条路径"。**Smoke 6 case 全绿**(in-process TestClient + 子进程跑 `python main.py db current`):① `/healthz` 200 ② POST /v1/tasks → GET → POST messages(返 `events_url` 无 run_id)→ cancel → DELETE 全链路 ③ `/v1/folders``core.agent_builder.user_root` 路径 ④ `/v1/files``_load_user_root``resolve_task_id` 完整 UUID resume(去前缀匹配后用 `UUID(...)` 直接解析;非 UUID 字符串 ValueError;ghost UUID `empty working_dir` ValueError)⑥ `subprocess.run([sys.executable, "main.py", "db", "current"])` 子进程跑通 + stdout 含 `0004 (head)`(验证 click 入口、alembic config 路径、ROOT 解析都没坏)。**文档同步**:DESIGN §1 形态兼容(删 `--remote`,讲"无 CLI / in-process 分叉")/ §2 目录树(`{main.py, cli.py}` → `core/agent_builder.py + main.py`)/ §3.3 `cli.py probe``main.py probe` / §3.6 "REPL 内 task 切换"段改"Task 切换 / 软删 走 dev SPA + /v1" + "入口"段讲 `python main.py web` / §7.0 共享差别表入口列改 `python main.py web` + auth 行讲"dev SPA 填 sentinel + 本地 key" / §7.6 #8 标"已撤" / §7.7 E 阶段标"撤" / §7.8 风险表"CLI 双模式分叉"行融合进"过早抽象" / §7.9 新增"CLI REPL 撤,入口统一 main.py"取舍说明 + 删原"CLI 双模式共存"段;RUN 顶 / 一次性初始化 / 日常命令 / 故障兜底 / 关键路径全部 `cli.py``main.py`,且日常命令段重写"只剩 `web / db / probe` + 所有 task 交互走 main.py web 后浏览器或 /v1`";PROGRESS 文件清单 / 状态表 / 下一步候选同步(去掉 E 路线)。**净效果**:总代码 -360 行(`cli.py` 558 行删 → `main.py` 180 行 + `core/agent_builder.py` ~320 行 = ~500;原 `main.py` 337 + `cli.py` 558 = 895;净减 -395);入口文件数 2 → 1;维护面 -1 套 task 切换语义(REPL `/new /resume /done /abandon` 全归到 `/v1/tasks*`);测试面 -1 套(原 cli build_agent 调用链 smoke 全归到 web TestClient)。
- **05-18 / 0004 schema 大瘦身:删 runs / usage_events 表,run_status / run_error 合入 tasks;路由从 run_id 维度改 task 维度**:用户复盘"为什么 cancel 接口要带 run_id?现在不是一个 task 一个 run 吗",顺手把 runs / usage_events 表也重新审视 — `usage_events` 全代码库零引用、零写入、零读取,纯死代码(为未来计费预付的架构成本);`runs` 表 `tokens_p/c` 写但从未被读(tokens 累计走 tasks 列),`started_at / finished_at / error` 也只写不读,`run_id` 唯二实用是 broker 频道键 + cancel 参数 — 但 §7.1 已选定**单活 run** 形态,同 task 同时最多 1 个活 run,客户端只需要 task_id(永远有)就够,run_id 完全冗余。按"开发期不写兼容层"心智一把切干净。**alembic 0004**:`DROP TABLE usage_events / runs`,`tasks` 加 `run_status text not null default 'idle'`(idle / running / cancelling / error)+ `run_error text null`。**ORM** `models.py``Run` / `UsageEvent` 两 class + 删 `BigInteger` import;`Task` 加两列;`storage/__init__.py` 文档示例同步;`Task.run_status` 终态语义:`ok / cancelled` 收尾都回 `idle`(用户视角"跑完 / 停了"等价不留持久标记),只有 `error` 是持久终态,起新 run 时清。**Broker**(`web/broker.py`)全面 task_id 索引:`_subs / _done / _cancel_flags` 三个 dict key 从 run_id 换 task_id;加 `start(task_id)` 入口在新 run 起来前清 `_done` 标记(避免上一轮 done 让新订阅者立刻断流)。**Sink**(`web/sinks.py`)绑 task_id 替代 run_id。**`web/app.py`**:① `_run_agent_bg(task_id, user_id, content)` 去掉 run_id 参数;装 `agent.cancel_check = lambda tid=task_id: broker.is_cancelled(tid)`;终态写 `tasks.run_status = "idle"`(原 `Run.status = "ok"/"cancelled"`)或 `"error"`(`run_error = err`);finally `broker.clear_cancel(tid) + broker.close(tid)`。② `POST /v1/tasks/{tid}/messages` 改:`SELECT Task.run_status … FOR UPDATE` 替代 `select(Run.run_id) … running/cancelling`;同事务 `UPDATE Task SET run_status='running', run_error=NULL`(error 也算可重启视为清);commit 后 `broker.start(tid)` 清 done;返 `{"events_url": "/v1/tasks/{tid}/events"}` 去掉 `run_id`。③ `POST /v1/tasks/{tid}/cancel` 取代 `POST /v1/tasks/{tid}/runs/{rid}/cancel`,只校验 task 归属 user;`run_status != 'running'` → 409。④ `GET /v1/tasks/{tid}/events` 取代 `/runs/{rid}/events`,broker.subscribe(tid)。⑤ lifespan reaper `UPDATE Task SET run_status='error' WHERE run_status IN ('running','cancelling')`,文案不变。⑥ `_task_dict` 暴露 `run_status` / `run_error` 字段给前端。**dev SPA**(`web/static/dev.html`):`state.currentRunId` 改 `state.streaming` bool;`POST /messages` 拿到 `events_url` 直接订阅,不再保存 run_id;cancel 按钮 click → `POST /v1/tasks/{tid}/cancel`(去掉 `/runs/{rid}/`)。**Migration 跑通**:本地 PG `db upgrade 0003 → 0004 (head)` 一把过(用户授权清旧数据,无 backfill)。**Smoke 18 case 全绿**(in-process TestClient + BG mock):POST /messages 返 `events_url` 无 run_id / tasks.run_status='running' / gate when running 409 / POST /cancel 202 + run_status='cancelling' + broker flag set / double cancel 409(状态非 running)/ gate during cancelling 也 409 / cancel idle 409 / cancel error 409 / error 状态可发新消息(error 不挂 gate + 清 run_error) / ghost task 404 / invalid UUID 404 / cross-user 404 / no auth 401 / GET /events 路由注册(SSE 流式跑会挂 30s 心跳,smoke 只验路径 + headers) / GET /tasks 返回 run_status / run_error 字段 / stale reaper 扫 running+cancelling 标 error / broker.start 清 _done / broker.subscribe + emit + close + late subscriber 立刻收 done / broker.request_cancel + is_cancelled + clear_cancel。**净增量**:核心代码 -200 行(删表 ORM + 两路由层简化),broker 加 21 行 start/cancel API,dev.html 几行字段重命名;DB 表 5 → 3,路由 `/runs/{rid}/{events,cancel}``/{events,cancel}`,前端 SPA 不再需要先拿 run_id 才能 cancel / 订阅 — 客户端只需 task_id。**文档同步**:DESIGN §7.2 路由表 messages 路由返 `events_url`(去 `run_id`)+ cancel / events 改 task-level + lead-in 注 0004 简化 + SSE schema text event 字段 `delta`(实际就是 delta,文档原 `content` 笔误);§7.4 schema 块 tasks 加两列 + 注 0004 合并;§7.9 hard cascade 行注 "原 usage_events 0004 删" + 加专项取舍说明"0004 删 runs + usage_events 表";§7.7 风险表两行同步 / 改 task-level 路由名;RUN 路由表三路由全改 + 故障兜底 cancel 409 文案改 + db upgrade head 改 0004;PROGRESS 已完成 + 状态表 + 文件清单。 - **05-18 / 0004 schema 大瘦身:删 runs / usage_events 表,run_status / run_error 合入 tasks;路由从 run_id 维度改 task 维度**:用户复盘"为什么 cancel 接口要带 run_id?现在不是一个 task 一个 run 吗",顺手把 runs / usage_events 表也重新审视 — `usage_events` 全代码库零引用、零写入、零读取,纯死代码(为未来计费预付的架构成本);`runs` 表 `tokens_p/c` 写但从未被读(tokens 累计走 tasks 列),`started_at / finished_at / error` 也只写不读,`run_id` 唯二实用是 broker 频道键 + cancel 参数 — 但 §7.1 已选定**单活 run** 形态,同 task 同时最多 1 个活 run,客户端只需要 task_id(永远有)就够,run_id 完全冗余。按"开发期不写兼容层"心智一把切干净。**alembic 0004**:`DROP TABLE usage_events / runs`,`tasks` 加 `run_status text not null default 'idle'`(idle / running / cancelling / error)+ `run_error text null`。**ORM** `models.py``Run` / `UsageEvent` 两 class + 删 `BigInteger` import;`Task` 加两列;`storage/__init__.py` 文档示例同步;`Task.run_status` 终态语义:`ok / cancelled` 收尾都回 `idle`(用户视角"跑完 / 停了"等价不留持久标记),只有 `error` 是持久终态,起新 run 时清。**Broker**(`web/broker.py`)全面 task_id 索引:`_subs / _done / _cancel_flags` 三个 dict key 从 run_id 换 task_id;加 `start(task_id)` 入口在新 run 起来前清 `_done` 标记(避免上一轮 done 让新订阅者立刻断流)。**Sink**(`web/sinks.py`)绑 task_id 替代 run_id。**`web/app.py`**:① `_run_agent_bg(task_id, user_id, content)` 去掉 run_id 参数;装 `agent.cancel_check = lambda tid=task_id: broker.is_cancelled(tid)`;终态写 `tasks.run_status = "idle"`(原 `Run.status = "ok"/"cancelled"`)或 `"error"`(`run_error = err`);finally `broker.clear_cancel(tid) + broker.close(tid)`。② `POST /v1/tasks/{tid}/messages` 改:`SELECT Task.run_status … FOR UPDATE` 替代 `select(Run.run_id) … running/cancelling`;同事务 `UPDATE Task SET run_status='running', run_error=NULL`(error 也算可重启视为清);commit 后 `broker.start(tid)` 清 done;返 `{"events_url": "/v1/tasks/{tid}/events"}` 去掉 `run_id`。③ `POST /v1/tasks/{tid}/cancel` 取代 `POST /v1/tasks/{tid}/runs/{rid}/cancel`,只校验 task 归属 user;`run_status != 'running'` → 409。④ `GET /v1/tasks/{tid}/events` 取代 `/runs/{rid}/events`,broker.subscribe(tid)。⑤ lifespan reaper `UPDATE Task SET run_status='error' WHERE run_status IN ('running','cancelling')`,文案不变。⑥ `_task_dict` 暴露 `run_status` / `run_error` 字段给前端。**dev SPA**(`web/static/dev.html`):`state.currentRunId` 改 `state.streaming` bool;`POST /messages` 拿到 `events_url` 直接订阅,不再保存 run_id;cancel 按钮 click → `POST /v1/tasks/{tid}/cancel`(去掉 `/runs/{rid}/`)。**Migration 跑通**:本地 PG `db upgrade 0003 → 0004 (head)` 一把过(用户授权清旧数据,无 backfill)。**Smoke 18 case 全绿**(in-process TestClient + BG mock):POST /messages 返 `events_url` 无 run_id / tasks.run_status='running' / gate when running 409 / POST /cancel 202 + run_status='cancelling' + broker flag set / double cancel 409(状态非 running)/ gate during cancelling 也 409 / cancel idle 409 / cancel error 409 / error 状态可发新消息(error 不挂 gate + 清 run_error) / ghost task 404 / invalid UUID 404 / cross-user 404 / no auth 401 / GET /events 路由注册(SSE 流式跑会挂 30s 心跳,smoke 只验路径 + headers) / GET /tasks 返回 run_status / run_error 字段 / stale reaper 扫 running+cancelling 标 error / broker.start 清 _done / broker.subscribe + emit + close + late subscriber 立刻收 done / broker.request_cancel + is_cancelled + clear_cancel。**净增量**:核心代码 -200 行(删表 ORM + 两路由层简化),broker 加 21 行 start/cancel API,dev.html 几行字段重命名;DB 表 5 → 3,路由 `/runs/{rid}/{events,cancel}``/{events,cancel}`,前端 SPA 不再需要先拿 run_id 才能 cancel / 订阅 — 客户端只需 task_id。**文档同步**:DESIGN §7.2 路由表 messages 路由返 `events_url`(去 `run_id`)+ cancel / events 改 task-level + lead-in 注 0004 简化 + SSE schema text event 字段 `delta`(实际就是 delta,文档原 `content` 笔误);§7.4 schema 块 tasks 加两列 + 注 0004 合并;§7.9 hard cascade 行注 "原 usage_events 0004 删" + 加专项取舍说明"0004 删 runs + usage_events 表";§7.7 风险表两行同步 / 改 task-level 路由名;RUN 路由表三路由全改 + 故障兜底 cancel 409 文案改 + db upgrade head 改 0004;PROGRESS 已完成 + 状态表 + 文件清单。
- **05-18 / cancel run endpoint + AgentLoop 协作式 cancel + dev SPA stop 按钮**:用户反馈"等待回复或 LLM 操作时没有停止接口"。落地 DESIGN §7.2 原标"待"的 `POST /v1/tasks/{id}/runs/{rid}/cancel`。**Broker**(`web/broker.py`):加 `request_cancel(rid)` / `is_cancelled(rid)` / `clear_cancel(rid)` 三方法,内部 `dict[UUID, threading.Event]` per-run;`setdefault` 保证 BG 还没 register 也能 set。**Loop**(`core/loop.py`):`AgentLoop` 加 `cancel_check: Optional[Callable[[], bool]]` 字段(CLI 路径不传 = None 永不 cancel),`_is_cancelled()` helper + `_fill_cancelled_tool_results(remaining)` 给未执行的 tool_call 全部 append `[cancelled by user]` tool message —— LiteLLM 协议要求每个 assistant tool_call 必须有匹配 tool result,否则 resume 时 LLM 报错。Check 点:每轮 LLM 前 + tool_calls 之间。命中 emit `cancelled` event + return `[cancelled]`。**LLM 同步 call 本身不可中断**(litellm 同步阻塞,无原生 cancel)—— 接受最坏等当前一轮跑完(通常几十秒),注释里讲清楚。**Endpoint**(`web/app.py::cancel_run`):校验 task 归属 user + run 归属 task(else 404),`run.status` 必须是 `running`(else 409 含具体 status);标 `cancelling`(过渡态)+ `broker.request_cancel(rid)`;202。`_run_agent_bg` 装配时 `agent.cancel_check = lambda rid=run_id: broker.is_cancelled(rid)`,run 完时判 `broker.is_cancelled` 写终态 `cancelled` vs `ok`;finally `broker.clear_cancel + broker.close`。**Gate 同步扩**:`post_message` 单活 run 检查从 `status == 'running'``status in ('running', 'cancelling')`,确保 cancel 后旧 BG 还没退出时新 POST 仍 409(避免新旧 run 撞 messages.idx)。**Reaper 同步扩**:lifespan 启动也扫 `cancelling`(进程 crash 时 BG 来不及写终态 cancelled,反正没线程在跑就清掉)。**dev SPA**(`web/static/dev.html`):chat 表单加 `<button id="chat-cancel" class="small danger">stop</button>`(常态 hidden);state 加 `currentRunId`;sendMessage 拿到 run_id 后 show stop,fetchSse `try/finally` 收尾时一并 hide stop / 清 currentRunId / 复原 send button(确保 SSE 失败路径 UI 也 reset 不卡死)。cancel 按钮 click → `POST /runs/{rid}/cancel`;409 静默忽略(并发 done 不算错)。`handleSseEvent` 加 `cancelled` case → 在当前 assistant 卡贴一个虚线红框 "已停止(stopped by user)" badge。CSS 加 `.cancelled-badge`。**Smoke 15 case 全绿**:HTTP 层 11 case(cancel happy + 双 cancel 409 + cancelling 期间 POST messages 409 + ghost run 404 + invalid UUID 404 + cross-task 404 + cross-user 404 + cancel 已 ok 409 + cancel 已 error 409 + no auth 401 + stale reaper 扫 cancelling);Loop 层 4 case(cancel before first iter 不调 LLM / cancel between tool_calls 补 cancelled placeholder 3 个 + 保协议 + emit cancelled / 正常 done 不 emit cancelled / CLI 路径 cancel_check=None 默永不 cancel)。**没动 SSE handler 的 break list**(`("done", "error")`):cancelled 在 SSE 里走流给前端看,broker.close 之后立即跟 done 收流。**文档同步**:DESIGN §7.2 路由表 cancel 行从"待"扩成完整描述 + SSE 事件加 `cancelled{}` 行 + §7.7 风险表加"Run 跑太久 / 用户想中断"行;RUN 路由表加 cancel 行 + POST /messages 409 文案改 "running / cancelling" + 故障兜底加三行(cancel 409 / 点 stop 没立刻停 / reaper 扫 cancelling);PROGRESS 已完成 + 下一步重排(去掉 cancel,留 OIDC / C Executor / E CLI 双模式)。 - **05-18 / cancel run endpoint + AgentLoop 协作式 cancel + dev SPA stop 按钮**:用户反馈"等待回复或 LLM 操作时没有停止接口"。落地 DESIGN §7.2 原标"待"的 `POST /v1/tasks/{id}/runs/{rid}/cancel`。**Broker**(`web/broker.py`):加 `request_cancel(rid)` / `is_cancelled(rid)` / `clear_cancel(rid)` 三方法,内部 `dict[UUID, threading.Event]` per-run;`setdefault` 保证 BG 还没 register 也能 set。**Loop**(`core/loop.py`):`AgentLoop` 加 `cancel_check: Optional[Callable[[], bool]]` 字段(CLI 路径不传 = None 永不 cancel),`_is_cancelled()` helper + `_fill_cancelled_tool_results(remaining)` 给未执行的 tool_call 全部 append `[cancelled by user]` tool message —— LiteLLM 协议要求每个 assistant tool_call 必须有匹配 tool result,否则 resume 时 LLM 报错。Check 点:每轮 LLM 前 + tool_calls 之间。命中 emit `cancelled` event + return `[cancelled]`。**LLM 同步 call 本身不可中断**(litellm 同步阻塞,无原生 cancel)—— 接受最坏等当前一轮跑完(通常几十秒),注释里讲清楚。**Endpoint**(`web/app.py::cancel_run`):校验 task 归属 user + run 归属 task(else 404),`run.status` 必须是 `running`(else 409 含具体 status);标 `cancelling`(过渡态)+ `broker.request_cancel(rid)`;202。`_run_agent_bg` 装配时 `agent.cancel_check = lambda rid=run_id: broker.is_cancelled(rid)`,run 完时判 `broker.is_cancelled` 写终态 `cancelled` vs `ok`;finally `broker.clear_cancel + broker.close`。**Gate 同步扩**:`post_message` 单活 run 检查从 `status == 'running'``status in ('running', 'cancelling')`,确保 cancel 后旧 BG 还没退出时新 POST 仍 409(避免新旧 run 撞 messages.idx)。**Reaper 同步扩**:lifespan 启动也扫 `cancelling`(进程 crash 时 BG 来不及写终态 cancelled,反正没线程在跑就清掉)。**dev SPA**(`web/static/dev.html`):chat 表单加 `<button id="chat-cancel" class="small danger">stop</button>`(常态 hidden);state 加 `currentRunId`;sendMessage 拿到 run_id 后 show stop,fetchSse `try/finally` 收尾时一并 hide stop / 清 currentRunId / 复原 send button(确保 SSE 失败路径 UI 也 reset 不卡死)。cancel 按钮 click → `POST /runs/{rid}/cancel`;409 静默忽略(并发 done 不算错)。`handleSseEvent` 加 `cancelled` case → 在当前 assistant 卡贴一个虚线红框 "已停止(stopped by user)" badge。CSS 加 `.cancelled-badge`。**Smoke 15 case 全绿**:HTTP 层 11 case(cancel happy + 双 cancel 409 + cancelling 期间 POST messages 409 + ghost run 404 + invalid UUID 404 + cross-task 404 + cross-user 404 + cancel 已 ok 409 + cancel 已 error 409 + no auth 401 + stale reaper 扫 cancelling);Loop 层 4 case(cancel before first iter 不调 LLM / cancel between tool_calls 补 cancelled placeholder 3 个 + 保协议 + emit cancelled / 正常 done 不 emit cancelled / CLI 路径 cancel_check=None 默永不 cancel)。**没动 SSE handler 的 break list**(`("done", "error")`):cancelled 在 SSE 里走流给前端看,broker.close 之后立即跟 done 收流。**文档同步**:DESIGN §7.2 路由表 cancel 行从"待"扩成完整描述 + SSE 事件加 `cancelled{}` 行 + §7.7 风险表加"Run 跑太久 / 用户想中断"行;RUN 路由表加 cancel 行 + POST /messages 409 文案改 "running / cancelling" + 故障兜底加三行(cancel 409 / 点 stop 没立刻停 / reaper 扫 cancelling);PROGRESS 已完成 + 下一步重排(去掉 cancel,留 OIDC / C Executor / E CLI 双模式)。
- **05-18 / `POST /v1/tasks/{id}/messages` 单活 run 锁 + 孤儿 reaper**:用户连点 send / 多 tab 同时发消息 → 两个 BG 线程争 `messages.idx`(UniqueConstraint 会 race-crash 第二个 INSERT)的旧 TODO 落地。**实现**:`web/app.py::post_message` 把所有权 + 活跃 Run 检查 + 新 Run INSERT 收进一个 `session_scope()` 事务,首行用 `select(Task.task_id).where(...).with_for_update()` 锁 task 行序列化并发 POST;事务内查 `Run.status='running'` 命中即 raise `HTTPException(409, "task already has a running run ({rid}); wait for it to finish")`;无活跃则同事务 `s.add(Run(...status="running"))` —— 三步原子完成,避免 TOCTOU。lifespan 加 **stale-run reaper**:启动时 `UPDATE runs SET status='error', error='server restarted before run finished' WHERE status='running'`,把进程 crash 留下的孤儿 running 全清掉(否则对应 task 永挂 409)。结果 rowcount > 0 时 print info 行 `[startup] reaped N stale running run(s)`。Cancel 路由(DESIGN §7.2 标 "待")没改:有了它 409 时用户可主动 cancel,不必等流式结束。**没动 `Session.append`**:gate 已在 HTTP 层挡住了,单写者前提下 idx 自递增不会冲;在 ORM 里再加锁是过度。**Smoke 10 case 全绿**(in-process TestClient + `_run_agent_bg` mock 不真起 LLM):happy(202 + Run INSERT running)/ gate(同 task 第二 POST 409 + detail 含 "running run" + "wait for it to finish")/ clear after Run.status=ok 解锁(202)/ clear after Run.status=error 同(202)/ ghost task 跨用户路径 404(锁前所有权检查)/ invalid UUID 404 / empty content 400 早于 lock / no auth 401 早于 lock / stale reaper 测试(强行 SET 全部 Run=running → 开新 TestClient 触发 lifespan → 所有 running 变 error + 之后 POST 还能 202)/ cross-user(other UID token 访 sentinel task → 404 不暴露存在性)。**采坑**:`@case` 每个用 `make_client()` 起新 app 会重复触发 reaper,把 case 1 留下的 running 清掉 → case 2 的 409 测不出来;改成全部 case 共享一个 SHARED_CLIENT 跑,仅 stale-reaper case 用 `fresh=True` 开第二个。**文档同步**:DESIGN §7.2 POST /messages 行注 409 行为 + cancel "待" 后注"做出来后 409 可主动 cancel" / §7.7 风险表加"同 task 并发 POST messages.idx race"行;RUN 路由表 POST /messages 注 409;故障兜底替过期 TODO 行 → 加 "POST 返 409" 处置 + "[startup] reaped N stale running" 解释。**未来 TODO**:multi-worker 部署形态下 reaper 不能简单全表清(会误清其他 worker 的真在跑 run),换 heartbeat + lease(注释里记了)。 - **05-18 / `POST /v1/tasks/{id}/messages` 单活 run 锁 + 孤儿 reaper**:用户连点 send / 多 tab 同时发消息 → 两个 BG 线程争 `messages.idx`(UniqueConstraint 会 race-crash 第二个 INSERT)的旧 TODO 落地。**实现**:`web/app.py::post_message` 把所有权 + 活跃 Run 检查 + 新 Run INSERT 收进一个 `session_scope()` 事务,首行用 `select(Task.task_id).where(...).with_for_update()` 锁 task 行序列化并发 POST;事务内查 `Run.status='running'` 命中即 raise `HTTPException(409, "task already has a running run ({rid}); wait for it to finish")`;无活跃则同事务 `s.add(Run(...status="running"))` —— 三步原子完成,避免 TOCTOU。lifespan 加 **stale-run reaper**:启动时 `UPDATE runs SET status='error', error='server restarted before run finished' WHERE status='running'`,把进程 crash 留下的孤儿 running 全清掉(否则对应 task 永挂 409)。结果 rowcount > 0 时 print info 行 `[startup] reaped N stale running run(s)`。Cancel 路由(DESIGN §7.2 标 "待")没改:有了它 409 时用户可主动 cancel,不必等流式结束。**没动 `Session.append`**:gate 已在 HTTP 层挡住了,单写者前提下 idx 自递增不会冲;在 ORM 里再加锁是过度。**Smoke 10 case 全绿**(in-process TestClient + `_run_agent_bg` mock 不真起 LLM):happy(202 + Run INSERT running)/ gate(同 task 第二 POST 409 + detail 含 "running run" + "wait for it to finish")/ clear after Run.status=ok 解锁(202)/ clear after Run.status=error 同(202)/ ghost task 跨用户路径 404(锁前所有权检查)/ invalid UUID 404 / empty content 400 早于 lock / no auth 401 早于 lock / stale reaper 测试(强行 SET 全部 Run=running → 开新 TestClient 触发 lifespan → 所有 running 变 error + 之后 POST 还能 202)/ cross-user(other UID token 访 sentinel task → 404 不暴露存在性)。**采坑**:`@case` 每个用 `make_client()` 起新 app 会重复触发 reaper,把 case 1 留下的 running 清掉 → case 2 的 409 测不出来;改成全部 case 共享一个 SHARED_CLIENT 跑,仅 stale-reaper case 用 `fresh=True` 开第二个。**文档同步**:DESIGN §7.2 POST /messages 行注 409 行为 + cancel "待" 后注"做出来后 409 可主动 cancel" / §7.7 风险表加"同 task 并发 POST messages.idx race"行;RUN 路由表 POST /messages 注 409;故障兜底替过期 TODO 行 → 加 "POST 返 409" 处置 + "[startup] reaped N stale running" 解释。**未来 TODO**:multi-worker 部署形态下 reaper 不能简单全表清(会误清其他 worker 的真在跑 run),换 heartbeat + lease(注释里记了)。
@ -96,8 +97,8 @@ tools/fs.py 182
tools/shell.py 94 tools/shell.py 94
tools/run_python.py 84 tools/run_python.py 84
tools/skill_tool.py 45 tools/skill_tool.py 45
main.py 285 ← user_root / task_dir_from_name / validate_task_name(删 auto-derive 三件套) main.py 164 ← 入口: web / db / probe 三 click 命令(05-18 改名归位)
cli.py 558 ← §7 B Step 4 / Phase G G1: --task-dir / web 子命令 core/agent_builder.py 307 ← 装配 lib: build_agent / system prompt / validate_task_name(原 main.py 内容)
db/migrations/env.py 61 ← §7 B Step 1 db/migrations/env.py 61 ← §7 B Step 1
db/migrations/versions/ db/migrations/versions/
0001_initial_schema.py 125 ← §7 B Step 1 0001_initial_schema.py 125 ← §7 B Step 1
@ -113,7 +114,7 @@ web/broker.py 121 ← in-process pub/sub + cancel signal,全 task
web/sinks.py 21 ← WebEventSink 绑 task_id(0004) web/sinks.py 21 ← WebEventSink 绑 task_id(0004)
web/static/dev.html 1133 ← D' dev SPA + stop 按钮 + cancelled badge web/static/dev.html 1133 ← D' dev SPA + stop 按钮 + cancelled badge
───────────────────────────────── ─────────────────────────────────
Python 合计 ~3800 行(+ dev.html 1133 静态) Python 合计 ~3400 行(+ dev.html 1133 静态)— 05-18 入口归位净减 ~400 行 REPL/CLI
``` ```
加 skills/ppt 脚本 ~600 行 + SKILL.md / references / config / prompts + alembic.ini,总仓库约 3500 行。 加 skills/ppt 脚本 ~600 行 + SKILL.md / references / config / prompts + alembic.ini,总仓库约 3500 行。
@ -124,7 +125,6 @@ Python 合计 ~3800 行(+ dev.html 1133 静态)
1. **真 OIDC 接入 + CORS 收紧**(~1 天)—— 把 `/v1/auth/login` 内部从 platform_key 校验换成 OIDC ID token 校验(路由层 Depends 不动);CORS 改成 platform 域名 allowlist。**真发布给真实用户前必做**。 1. **真 OIDC 接入 + CORS 收紧**(~1 天)—— 把 `/v1/auth/login` 内部从 platform_key 校验换成 OIDC ID token 校验(路由层 Depends 不动);CORS 改成 platform 域名 allowlist。**真发布给真实用户前必做**。
2. **§7 C Executor + sandbox**(~2-3 天)—— `run_python`/`shell` → `Executor.run(...)`,本地保留 subprocess、SaaS 走 docker;`api_key_env` → `KeyProvider` 运行时注入。多用户在线跑代码前置。 2. **§7 C Executor + sandbox**(~2-3 天)—— `run_python`/`shell` → `Executor.run(...)`,本地保留 subprocess、SaaS 走 docker;`api_key_env` → `KeyProvider` 运行时注入。多用户在线跑代码前置。
3. **§7 E CLI transport 双模式**(~1.5 天)—— `cli.py chat --remote https://...` 走 HTTP 替代 in-process。dogfood ≡ 用户路径。 3. **Phase 6 context 三层压缩**(~1 天)—— 兜底,V4 长上下文一般用不到。
4. **Phase 6 context 三层压缩**(~1 天)—— 兜底,V4 长上下文一般用不到。
> §7 B + D + D'(过渡 auth)+ 单活 run 锁 + cancel 主体已完工。剩余路线:真 OIDC → C(Executor)→ E(CLI 双模式)→ F(deploy / billing)。原 Phase G Web UI 路线撤(DESIGN §7.9),UI 改 platform 端实现;`web/static/dev.html` 是开发期单文件 SPA,跟 platform UI 并存不冲突。 > §7 B + D + D'(过渡 auth)+ 单活 run 锁 + cancel + 0004 schema 瘦身 + 入口归位 主体已完工。剩余路线:真 OIDC → C(Executor)→ F(deploy / billing)。**§7 E CLI 双模式撤**(2026-05-18 §7.9):dev SPA 已是本地 dogfood 主路径,CLI REPL 删,无 `--remote` 双 transport 维护税。原 Phase G Web UI 路线撤(§7.9),UI 改 platform 端实现;`web/static/dev.html` 是开发期单文件 SPA,跟 platform UI 并存不冲突。

94
RUN.md
View File

@ -2,7 +2,7 @@
> 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md` > 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md`
最后更新:2026-05-18(0004 schema 简化:删 runs / usage_events 表;cancel 改 task-level `POST /v1/tasks/{id}/cancel`;SSE 改 `GET /v1/tasks/{id}/events`;run_status / run_error 合并入 tasks) 最后更新:2026-05-18(`cli.py` 改名 `main.py`(入口),原 `main.py` 挪到 `core/agent_builder.py`(装配 lib);CLI 子命令 `chat / tasks / export` 删 — 全走 web `/v1` + dev SPA;`main.py` 只剩 `web / db / probe` 三命令)
--- ---
@ -13,16 +13,16 @@
``` ```
DEEPSEEK_API_KEY=sk-... DEEPSEEK_API_KEY=sk-...
ZCBOT_DB_URL=postgresql://user:pass@host:5432/zcbot ZCBOT_DB_URL=postgresql://user:pass@host:5432/zcbot
# cli.py web 必填(纯 CLI 用不到,只在起 web 时校验) # main.py web 必填(probe/db 用不到,只在起 web 时校验)
PLATFORM_KEY=<至少 16 字符的随机串,platform 服务端 / dev 浏览器持有,登录时校验> PLATFORM_KEY=<至少 16 字符的随机串,platform 服务端 / dev 浏览器持有,登录时校验>
JWT_SECRET=<≥32 字符随机串,HS256 签 session token;泄漏 = 任意伪造,与 PLATFORM_KEY 同级保护> JWT_SECRET=<≥32 字符随机串,HS256 签 session token;泄漏 = 任意伪造,与 PLATFORM_KEY 同级保护>
# 可选:覆盖默认 7d # 可选:覆盖默认 7d
# ZCBOT_JWT_TTL_SECONDS=604800 # ZCBOT_JWT_TTL_SECONDS=604800
``` ```
> litellm 在 import 时副作用加载 .env;CLI 入口直接走 `cli.py`,`.env` 会自动生效。直跑 `python -c "from core.storage import ..."` 不经 litellm 链路时记得自己 `import litellm` 触发,或手动 `export ZCBOT_DB_URL=...` > litellm 在 import 时副作用加载 .env;入口走 `main.py`,`.env` 会自动生效。直跑 `python -c "from core.storage import ..."` 不经 litellm 链路时记得自己 `import litellm` 触发,或手动 `export ZCBOT_DB_URL=...`
- **依赖**:`pip install -r requirements.txt`(已在 `.venv` 里)。 - **依赖**:`pip install -r requirements.txt`(已在 `.venv` 里)。
- **PG**:`ZCBOT_DB_URL` 必填。本地 docker compose 起 / 远端 dev / 生产任选。未设置时启动会清晰报错,不引导 docker(§7.4)。 - **PG**:`ZCBOT_DB_URL` 必填。本地 docker compose 起 / 远端 dev / 生产任选。未设置时启动会清晰报错,不引导 docker(§7.4)。
- **Auth env**(`cli.py web` 必填):`PLATFORM_KEY` + `JWT_SECRET`,任一缺失 web 启动会 fail-fast。生成随机串可用 `python -c "import secrets; print(secrets.token_urlsafe(48))"`CLI(`chat / tasks / probe / db`)不验,不要这两个 env 也能跑。 - **Auth env**(`main.py web` 必填):`PLATFORM_KEY` + `JWT_SECRET`,任一缺失 web 启动会 fail-fast。生成随机串可用 `python -c "import secrets; print(secrets.token_urlsafe(48))"``main.py db / probe` 不验,不要这两个 env 也能跑。
--- ---
@ -36,80 +36,40 @@ python -m venv .venv
# 2) 准备 .env(见上) # 2) 准备 .env(见上)
# 3) DB schema 上车 # 3) DB schema 上车
.venv/Scripts/python.exe cli.py db upgrade head .venv/Scripts/python.exe main.py db upgrade head
.venv/Scripts/python.exe cli.py db current # 应输出 0004 (head) .venv/Scripts/python.exe main.py db current # 应输出 0004 (head)
``` ```
--- ---
## 日常命令 ## 日常命令
### 聊天 / 任务 > 入口统一在 `main.py`,只剩三个子命令:`web / db / probe`。所有 task / 消息 / 文件交互
> 走 `main.py web` 起服务后浏览器(dev SPA)或 platform 端 / curl 调 `/v1/*`(下方 Web API 段)。
### Web 服务(§7 D + D' 过渡 auth)
```bash ```bash
# 新建 task —— `--name` 必填(任务显示名),`--working-dir` 可选(目录名,留空 → 用 --name) # 默认 127.0.0.1:8765 启;dev SPA 在 /,Swagger UI 在 /docs
.venv/Scripts/python.exe cli.py chat --name "初稿大纲" --working-dir proposal_v3 .venv/Scripts/python.exe main.py web
# 只给 name → working_dir fallback 用 name # 自定义端口 / 监听 0.0.0.0(慎用,部署形态走反代不直暴)
.venv/Scripts/python.exe cli.py chat --name proposal_v3 .venv/Scripts/python.exe main.py web --port 9000
# 带 skill + 描述(便于后续 list 识别) # dev:文件改动自动重启(uvicorn 工厂模式 reload)
.venv/Scripts/python.exe cli.py chat --name "修登录 401" --working-dir fix_login_bug --skill coding --desc "登录返回 401 排查" .venv/Scripts/python.exe main.py web --reload
# 同 working_dir 多 task(共享 workspace/users/<sentinel>/proposal_v3/ 目录,name 各不同)
.venv/Scripts/python.exe cli.py chat --name "补充资料" --working-dir proposal_v3
# 恢复最近一个 task(resume 时 --name / --working-dir 都忽略)
.venv/Scripts/python.exe cli.py chat --resume last
# 恢复指定 task(UUID 完整或 ≥8 字符前缀)
.venv/Scripts/python.exe cli.py chat --resume 76c6bd25
# 切模型
.venv/Scripts/python.exe cli.py chat --name x --model deepseek_v4.pro
```
REPL 内命令:`/exit /reset /new [<name>] /resume [last|<id>] /id /status /done /abandon /desc <文本> /export [<id>]`(`/new <name>` 用新任务名 + 沿用当前 working_dir;`/new` 无参 → 自动 gen `新任务_HH-MM-SS`)
### 列表 / 导出
```bash
# 看最近 20 个 task
.venv/Scripts/python.exe cli.py tasks
# 只看 active
.venv/Scripts/python.exe cli.py tasks --status active --limit 50
# 导出某 task 的对话为 .docx(自动从 PG 找 task_dir 作为输出目录)
.venv/Scripts/python.exe cli.py export 76c6bd25
# 导出最近的
.venv/Scripts/python.exe cli.py export last -o /tmp/chat.docx
``` ```
### 能力探测 / DB 管理 ### 能力探测 / DB 管理
```bash ```bash
# 实测对账模型 yaml 声称的能力(费 token,有 API 开销) # 实测对账模型 yaml 声称的能力(费 token,有 API 开销)
.venv/Scripts/python.exe cli.py probe --model deepseek_v4.flash .venv/Scripts/python.exe main.py probe --model deepseek_v4.flash
# DB migration # DB migration
.venv/Scripts/python.exe cli.py db upgrade head .venv/Scripts/python.exe main.py db upgrade head
.venv/Scripts/python.exe cli.py db downgrade -1 .venv/Scripts/python.exe main.py db downgrade -1
.venv/Scripts/python.exe cli.py db current .venv/Scripts/python.exe main.py db current
```
### Web API(§7 D + D' 过渡 auth)
```bash
# 默认 127.0.0.1:8765 启;dev SPA 在 /,Swagger UI 在 /docs
.venv/Scripts/python.exe cli.py web
# 自定义端口 / 监听 0.0.0.0(慎用,部署形态走反代不直暴)
.venv/Scripts/python.exe cli.py web --port 9000
# dev:文件改动自动重启(uvicorn 工厂模式 reload)
.venv/Scripts/python.exe cli.py web --reload
``` ```
**Auth**:所有 `/v1/tasks*``Authorization: Bearer <jwt>`;先走 `/v1/auth/login` 拿 token: **Auth**:所有 `/v1/tasks*``Authorization: Bearer <jwt>`;先走 `/v1/auth/login` 拿 token:
@ -157,7 +117,7 @@ curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/ta
**SSE 客户端注意**:浏览器原生 `EventSource` 不支持自定义 header,无法塞 Bearer token。要么走 `fetch + ReadableStream` 自解 SSE 帧(dev.html 走的就是这条),要么后端日后加 `?token=...` query 路径(目前不支持,避免 token 进 access log)。 **SSE 客户端注意**:浏览器原生 `EventSource` 不支持自定义 header,无法塞 Bearer token。要么走 `fetch + ReadableStream` 自解 SSE 帧(dev.html 走的就是这条),要么后端日后加 `?token=...` query 路径(目前不支持,避免 token 进 access log)。
> 原 Phase G Jinja2 + HTMX Web UI 路线撤(DESIGN §7.9 取舍说明)—— UI 改 platform 端实现,本仓库只维护 API + 一个 dev SPA。`cli.py web` 跑的是 API + Swagger + dev.html。 > 原 Phase G Jinja2 + HTMX Web UI 路线撤(DESIGN §7.9 取舍说明)—— UI 改 platform 端实现,本仓库只维护 API + 一个 dev SPA。`main.py web` 跑的是 API + Swagger + dev.html。
--- ---
@ -169,18 +129,18 @@ curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/ta
| `ModuleNotFoundError: litellm` | 用了全局 `python`,改 `.venv/Scripts/python.exe ...` | | `ModuleNotFoundError: litellm` | 用了全局 `python`,改 `.venv/Scripts/python.exe ...` |
| Windows 控制台 emoji 崩 | Python stdout 是 GBK,emoji 不能直 print。用 `[OK]` / `[ng]` 等 ASCII 标签(见 memory) | | Windows 控制台 emoji 崩 | Python stdout 是 GBK,emoji 不能直 print。用 `[OK]` / `[ng]` 等 ASCII 标签(见 memory) |
| `db upgrade``column already exists` | DB 已被改过,先 `db current` 确认 revision,必要时手 ALTER 或 `db downgrade base` 重来 | | `db upgrade``column already exists` | DB 已被改过,先 `db current` 确认 revision,必要时手 ALTER 或 `db downgrade base` 重来 |
| Resume 找不到 task | `cli.py tasks` 看 task_id 是否在;前缀冲突报 ambiguous 时给完整 UUID | | Resume 找不到 task | dev SPA 左侧 task 列表看 task_id 是否在;或 `curl /v1/tasks` |
| `--working-dir` 指定后 `/exit` 没清目录 | 设计如此 —— 工作目录绝不 rmtree(同 working_dir 多 task 共享);DB 行该删还是删。要彻底删手动 `rm -rf <dir>` | | `--working-dir` 指定后 `/exit` 没清目录 | 设计如此 —— 工作目录绝不 rmtree(同 working_dir 多 task 共享);DB 行该删还是删。要彻底删手动 `rm -rf <dir>` |
| Export 报 "无可导出内容" | task 没 messages(只 system 不算);先在 REPL 发条消息再 export | | Export 报 "无可导出内容" | task 没 messages(只 system 不算);先在 REPL 发条消息再 export |
| `NoSubtaskError: working_dir ... 与已有 task ... 前缀嵌套` | §7.4 no-subtask:同 user 不允许 working_dir 嵌套(child 或 parent)。**同项目多对话**请传**完全相同**的 `--working-dir`;否则改路径成 sibling(平级) | | `NoSubtaskError: working_dir ... 与已有 task ... 前缀嵌套` | §7.4 no-subtask:同 user 不允许 working_dir 嵌套(child 或 parent)。**同项目多对话**请传**完全相同**的 `--working-dir`;否则改路径成 sibling(平级) |
| `cli.py web` 启动后 curl 连不上 | 检查 proxy(`HTTP_PROXY` / `HTTPS_PROXY`):本地服务在 127.0.0.1,系统 proxy 拦截会 502。临时 `unset HTTP_PROXY HTTPS_PROXY` 或加 `curl --noproxy '*'`。验通:`curl --noproxy '*' http://127.0.0.1:8765/healthz` → `{"status":"ok"}` | | `main.py web` 启动后 curl 连不上 | 检查 proxy(`HTTP_PROXY` / `HTTPS_PROXY`):本地服务在 127.0.0.1,系统 proxy 拦截会 502。临时 `unset HTTP_PROXY HTTPS_PROXY` 或加 `curl --noproxy '*'`。验通:`curl --noproxy '*' http://127.0.0.1:8765/healthz` → `{"status":"ok"}` |
| SSE 卡住不流(经 nginx) | 反代要关 buffering — 后端响应头已带 `X-Accel-Buffering: no`,nginx ≥ 1.5.6 默认认。仍卡看 nginx 配 `proxy_buffering off; proxy_read_timeout 3600s;` | | SSE 卡住不流(经 nginx) | 反代要关 buffering — 后端响应头已带 `X-Accel-Buffering: no`,nginx ≥ 1.5.6 默认认。仍卡看 nginx 配 `proxy_buffering off; proxy_read_timeout 3600s;` |
| platform 端 CORS preflight 失败 | 本地 dev `allow_origins=["*"]` 应该没事;部署后看是否按 platform 域名收紧过头(`access-control-allow-origin` 响应头要含 platform 域名 或 `*`)| | platform 端 CORS preflight 失败 | 本地 dev `allow_origins=["*"]` 应该没事;部署后看是否按 platform 域名收紧过头(`access-control-allow-origin` 响应头要含 platform 域名 或 `*`)|
| `POST /v1/tasks/{id}/messages` 返 409 `task already has an active run` | 上一条消息的 BG run 还没跑完(SSE 没 `done`)。等流式跑完;或点 dev SPA 的 stop / `POST /v1/tasks/{id}/cancel`;服务异常下 `tasks.run_status``running`/`cancelling`,启动 reaper 会清 | | `POST /v1/tasks/{id}/messages` 返 409 `task already has an active run` | 上一条消息的 BG run 还没跑完(SSE 没 `done`)。等流式跑完;或点 dev SPA 的 stop / `POST /v1/tasks/{id}/cancel`;服务异常下 `tasks.run_status``running`/`cancelling`,启动 reaper 会清 |
| `POST /v1/tasks/{id}/cancel` 返 409 `task not running` | `run_status` 不是 `running`(idle / cancelling / error 都不能 cancel,error 只能起新 run 顶掉);dev SPA 自动忽略不报错 | | `POST /v1/tasks/{id}/cancel` 返 409 `task not running` | `run_status` 不是 `running`(idle / cancelling / error 都不能 cancel,error 只能起新 run 顶掉);dev SPA 自动忽略不报错 |
| 点 stop 后流式没立刻停 | LLM 同步调用本身不可中断,最坏等当前一轮跑完(通常几十秒)。loop 进入下个 check 点(每轮 LLM 前 / 每个 tool_call 前)就退,emit `cancelled` → SSE `done` → UI 收回 stop 按钮 | | 点 stop 后流式没立刻停 | LLM 同步调用本身不可中断,最坏等当前一轮跑完(通常几十秒)。loop 进入下个 check 点(每轮 LLM 前 / 每个 tool_call 前)就退,emit `cancelled` → SSE `done` → UI 收回 stop 按钮 |
| `[startup] reaped N stale active run(s)` | 上次 `cli.py web` 进程未正常 finish 留下 N 个 `running` / `cancelling` Run 行,启动 lifespan 自动标 error。无需处理,info 级 | | `[startup] reaped N stale active run(s)` | 上次 `main.py web` 进程未正常 finish 留下 N 个 `running` / `cancelling` Run 行,启动 lifespan 自动标 error。无需处理,info 级 |
| `cli.py web` 启动报 `PLATFORM_KEY env not set` / `JWT_SECRET env not set` | D' 过渡 auth 强制双 env 必填。生成 `python -c "import secrets;print(secrets.token_urlsafe(48))"` 各填一,写进 `.env` 重起 | | `main.py web` 启动报 `PLATFORM_KEY env not set` / `JWT_SECRET env not set` | D' 过渡 auth 强制双 env 必填。生成 `python -c "import secrets;print(secrets.token_urlsafe(48))"` 各填一,写进 `.env` 重起 |
| `/v1/*` 全返 401 `missing Authorization: Bearer` | 没拿 token 或没带 header。先 `POST /v1/auth/login` 拿 token,curl 加 `-H "Authorization: Bearer $TOKEN"` | | `/v1/*` 全返 401 `missing Authorization: Bearer` | 没拿 token 或没带 header。先 `POST /v1/auth/login` 拿 token,curl 加 `-H "Authorization: Bearer $TOKEN"` |
| `/v1/*` 返 401 `token expired` | JWT 默 7d TTL 到期,重 login。要更长改 `ZCBOT_JWT_TTL_SECONDS` env | | `/v1/*` 返 401 `token expired` | JWT 默 7d TTL 到期,重 login。要更长改 `ZCBOT_JWT_TTL_SECONDS` env |
| dev.html SSE 收不到流(消息发出去但 UI 没动) | EventSource 不支持 header,dev.html 走 `fetch + ReadableStream`。看浏览器 devtools Network,POST /messages 是否 202 + Network 看 events_url GET 是否 200 + Content-Type 是 text/event-stream;若 401,token 过期了 — logout 重 login | | dev.html SSE 收不到流(消息发出去但 UI 没动) | EventSource 不支持 header,dev.html 走 `fetch + ReadableStream`。看浏览器 devtools Network,POST /messages 是否 202 + Network 看 events_url GET 是否 200 + Content-Type 是 text/event-stream;若 401,token 过期了 — logout 重 login |
@ -190,8 +150,8 @@ curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/ta
## 关键路径与文件 ## 关键路径与文件
- **入口**:`cli.py`(REPL + `chat / tasks / probe / db / web` 子命令)→ `main.py::build_agent`(装配) - **入口**:`main.py`(`web / db / probe` 三子命令)→ `core/agent_builder.py::build_agent`(装配 lib)
- **核心**:`core/loop.py`(ReAct)/ `core/session.py`(PG messages)/ `core/task.py`(PG tasks)/ `core/llm.py`(LiteLLM 封装) - **核心**:`core/agent_builder.py`(build_agent / system prompt / validate_task_name 等装配 lib)/ `core/loop.py`(ReAct)/ `core/session.py`(PG messages)/ `core/task.py`(PG tasks)/ `core/llm.py`(LiteLLM 封装)
- **工具**:`tools/{fs,shell,run_python,skill_tool}.py` - **工具**:`tools/{fs,shell,run_python,skill_tool}.py`
- **存储**:`core/storage/{engine,models,utils}.py`(SQLAlchemy 2.x ORM)+ `db/migrations/`(alembic) - **存储**:`core/storage/{engine,models,utils}.py`(SQLAlchemy 2.x ORM)+ `db/migrations/`(alembic)
- **Web**:`web/{app.py, auth.py, broker.py, sinks.py}`(FastAPI + /v1 JSON API + SSE + PLATFORM_KEY→JWT)+ `web/static/dev.html`(dev SPA,单文件 vanilla JS) - **Web**:`web/{app.py, auth.py, broker.py, sinks.py}`(FastAPI + /v1 JSON API + SSE + PLATFORM_KEY→JWT)+ `web/static/dev.html`(dev SPA,单文件 vanilla JS)

586
cli.py
View File

@ -1,586 +0,0 @@
"""CLI 入口: 简单 REPL。
用法:
python cli.py chat # 新建一个 task
python cli.py chat --mode coding --desc "修一处 bug" # 带元数据建任务
python cli.py chat --resume last # 恢复最近一个 task
python cli.py chat --resume <uuid-or-prefix> # 显式 task_id(前缀 ≥8 字符)
python cli.py chat --model deepseek_v4.pro
python cli.py tasks # 列出 task
python cli.py probe # 实测对账 yaml 声称的能力
"""
from __future__ import annotations
import sys
from datetime import datetime
from pathlib import Path
import click
from rich.prompt import Prompt
from rich.table import Table
from core.storage import SENTINEL_USER_ID
from core.ui import make_console
from main import (
ROOT,
InvalidTaskName,
_resolve_uuid_or_prefix,
build_agent,
load_config,
resolve_workspace,
sync_task_tokens,
user_root,
validate_task_name,
)
@click.group()
def cli() -> None:
"""zcbot - 个人任务 agent"""
@cli.group()
def db() -> None:
"""数据库管理 (alembic upgrade/downgrade/current)。需先 export ZCBOT_DB_URL。"""
def _alembic_cfg():
from alembic.config import Config
return Config(str(ROOT / "alembic.ini"))
def _run_alembic(fn, *args) -> None:
"""统一包一层友好出错(ZCBOT_DB_URL 未设置 / 连不上 → 简洁报错,不打 traceback)。"""
try:
fn(_alembic_cfg(), *args)
except RuntimeError as e:
click.echo(f"[err] {e}", err=True)
sys.exit(2)
except Exception as e:
click.echo(f"[err] {type(e).__name__}: {e}", err=True)
sys.exit(3)
@db.command("upgrade")
@click.argument("revision", default="head")
def db_upgrade(revision: str) -> None:
"""alembic upgrade <revision> (default head)."""
from alembic import command
_run_alembic(command.upgrade, revision)
@db.command("downgrade")
@click.argument("revision")
def db_downgrade(revision: str) -> None:
"""alembic downgrade <revision> (use -1 for one step, base for all)."""
from alembic import command
_run_alembic(command.downgrade, revision)
@db.command("current")
def db_current() -> None:
"""alembic current -- show currently applied revision."""
from alembic import command
_run_alembic(command.current)
def _cleanup_if_empty(working_dir, session, workspace_dir, console=None) -> bool:
"""切走前清理空 task。
DB 行无条件删除(若存在且 session 内存无 user 消息)
FS **绝不 rmtree** working_dir 是用户起的项目目录名, working_dir task 复用,
可能里面已有别的产物; task 只清 DB
"""
_ = workspace_dir # 不再用,签名保留向后兼容
_ = working_dir # FS 不动,只清 DB
if session.n_user_msgs() > 0:
return False
_delete_task_db_row(session.task_id)
if console is not None:
console.print(
f"[muted]cleaned empty task {str(session.task_id)[:8]} (kept FS dir)[/muted]"
)
return True
def _delete_task_db_row(task_id) -> None:
"""删 PG tasks 行(messages 走 CASCADE)。task_id 可能从未入库,DELETE 0 行无副作用。"""
from sqlalchemy import delete
from core.storage import session_scope
from core.storage.models import Task
with session_scope() as s:
s.execute(delete(Task).where(Task.task_id == task_id))
def _task_has_messages(task_id_str: str) -> bool:
"""PG 里该 task_id 有至少一条 message。task_id 字符串(UUID 完整形式)。"""
from uuid import UUID
from sqlalchemy import select
from core.storage import session_scope
from core.storage.models import Message
try:
tid = UUID(task_id_str)
except ValueError:
return False
with session_scope() as s:
row = s.execute(
select(Message.message_id).where(Message.task_id == tid).limit(1)
).scalar_one_or_none()
return row is not None
def _list_task_rows(workspace_dir, limit=20, status=None):
"""返回 [(updated_at, task_id_str, status, name, skill, model, tokens, n_msgs, desc), ...] 时间降序。
Step 3 :全字段从 PG tasks 表读,messages 数从 PG ;workspace_dir 仅用于
保持签名向后兼容(不再读 state.json)status 过滤走 SQL WHERE
"""
from sqlalchemy import func, select
from core.storage import session_scope
from core.storage.models import Message, Task
_ = workspace_dir # 签名占位,Step 3 后已不需要
with session_scope() as s:
q = select(
Task.task_id, Task.updated_at, Task.status, Task.name, Task.skill,
Task.model, Task.model_profile, Task.tokens_prompt,
Task.tokens_completion, Task.description,
).order_by(Task.updated_at.desc())
if status:
q = q.where(Task.status == status)
rows_db = s.execute(q.limit(limit)).all()
msg_counts = dict(s.execute(
select(Message.task_id, func.count()).group_by(Message.task_id)
).all())
rows = []
for tid, updated_at, st_, nm, sk, mdl, prof, tp, tc, desc in rows_db:
n = msg_counts.get(tid, 0)
rows.append((
updated_at, str(tid), st_, nm, sk,
prof or mdl, (tp or 0) + (tc or 0), n, desc,
))
return rows
@cli.command()
@click.option("--model", default=None, help="模型档案,如 deepseek_v4.flash 或 deepseek_v4.pro")
@click.option("--workspace", default=None, help="工作目录根(默认 ./workspace)")
@click.option("--resume", default=None, help="恢复 task: 'last' 或 task_id")
@click.option("--skill", default="", help="智能体类型标签(coding/ppt/proposal/...自由形式,对齐 skills/)")
@click.option("--desc", default="", help="一句话任务描述,便于 tasks 列表识别")
@click.option("--name", default=None,
help="任务名(必填,DB 存,UI 显示用)。resume 时忽略。")
@click.option("--working-dir", default=None,
help="工作目录名(简单名,不含 / \\ .. 也不能以 . 起头);留空 → 用 --name。"
"工作目录落 workspace/users/<sentinel>/<working_dir>/,同名多 task 共享。"
"resume 时忽略。")
def chat(model: str, workspace: str, resume: str, skill: str, desc: str,
name: str, working_dir: str) -> None:
"""启动交互式 REPL。新建必填 `--name`,可选 `--working-dir`;用 --resume 接老的。"""
console = make_console()
ws_dir = resolve_workspace(workspace)
if not resume:
if not name:
console.print("[err]新建 task 需要 --name <任务名>[/err]")
sys.exit(1)
try:
name = validate_task_name(name)
except InvalidTaskName as e:
console.print(f"[err]name 不合法:[/err] {e}")
sys.exit(1)
if working_dir:
try:
working_dir = validate_task_name(working_dir)
except InvalidTaskName as e:
console.print(f"[err]working_dir 不合法:[/err] {e}")
sys.exit(1)
try:
agent, session, sid, task_state, task_dir = build_agent(
model_name=model,
workspace=workspace,
console=console,
session_id=resume,
resume=bool(resume),
skill=skill,
description=desc,
name=name if not resume else None,
working_dir=working_dir if not resume else None,
)
except Exception as e:
console.print(f"[err]启动失败:[/err] {type(e).__name__}: {e}")
sys.exit(1)
if resume:
console.print(
f"[ok]恢复 task[/ok] [bold]{sid[:8]}[/bold] ({len(session.messages)} 条消息) "
f"name: [accent]{task_state.name}[/accent] "
f"model: [accent]{agent.caps.model_id}[/accent]"
)
else:
meta_tail = ""
if task_state.skill or task_state.description:
meta_tail = f" skill={task_state.skill!r} desc={task_state.description!r}"
console.print(
f"[ok]新 task[/ok] [bold]{sid[:8]}[/bold] name=[accent]{task_state.name}[/accent] "
f"model: [accent]{agent.caps.model_id}[/accent]{meta_tail}"
)
console.print(
"[info]/exit 退出 /reset 清空对话(保留 task) /new 开新 task "
"/resume [last|<id>] 切到已有 task /id /status 查看 "
"/done /abandon 改状态 /desc <文本> 设描述 "
"/export [<id>] 导出对话为 .docx[/info]\n"
)
while True:
try:
user_input = Prompt.ask("[user]you[/user]", console=console)
except (EOFError, KeyboardInterrupt):
console.print("\n[muted]bye[/muted]")
_cleanup_if_empty(task_dir, session, ws_dir, console)
break
cmd = user_input.strip()
if cmd in ("/exit", "/quit"):
_cleanup_if_empty(task_dir, session, ws_dir, console)
break
if cmd == "/reset":
session.reset(keep_system=True)
console.print("[info]当前 task 对话已重置(保留 system 和 state)[/info]")
continue
if cmd.startswith("/new"):
_cleanup_if_empty(task_dir, session, ws_dir, console)
# `/new <name>` → 新 task,name = 参数;`/new` 无参 → 自动生成名(时间戳)
arg = cmd[len("/new"):].strip()
new_name = arg or f"新任务_{datetime.now().strftime('%H-%M-%S')}"
try:
new_name = validate_task_name(new_name)
except InvalidTaskName as e:
console.print(f"[err]name 不合法:[/err] {e}")
continue
# 沿用当前 task 的 working_dir(同项目多对话);取上层 task_dir 末段作为 dir name
current_wd = task_dir.name # 例如 `水泥申报` 或 `proposal_v3`
try:
agent, session, sid, task_state, task_dir = build_agent(
model_name=model, workspace=workspace, console=console,
skill=skill, description=desc,
name=new_name, working_dir=current_wd,
)
except Exception as e:
console.print(f"[err]新建失败:[/err] {type(e).__name__}: {e}")
continue
name = new_name # 更新当前 name
console.print(
f"[ok]新 task[/ok] [bold]{sid[:8]}[/bold] name=[accent]{name}[/accent] "
f"working_dir=[accent]{current_wd}[/accent]"
)
continue
if cmd.startswith("/resume"):
arg = cmd[len("/resume"):].strip()
target_id = None
if arg == "last":
rs = _list_task_rows(ws_dir, limit=1)
if not rs:
console.print("[warn]没有可恢复的 task[/warn]")
continue
target_id = rs[0][1]
elif arg:
target_id = arg
else:
rs = _list_task_rows(ws_dir, limit=10)
if not rs:
console.print("[warn]没有可恢复的 task[/warn]")
continue
tbl = Table(show_lines=False)
tbl.add_column("#", style="bold")
tbl.add_column("task id")
tbl.add_column("status")
tbl.add_column("name")
tbl.add_column("skill")
tbl.add_column("msgs", justify="right")
tbl.add_column("desc")
sc = {"active": "status.active", "completed": "status.completed", "abandoned": "status.abandoned"}
for i, (_, tid, st, nm, sk, _mdl, _tok, n, dsc) in enumerate(rs, 1):
c = sc.get(st, "info")
d_show = dsc if len(dsc) <= 50 else dsc[:47] + "..."
tbl.add_row(str(i), tid[:8], f"[{c}]{st}[/{c}]", nm, sk, str(n), d_show)
console.print(tbl)
try:
sel = Prompt.ask("[user]选编号或输入 task_id (回车取消)[/user]", console=console, default="")
except (EOFError, KeyboardInterrupt):
continue
sel = sel.strip()
if not sel:
continue
if sel.isdigit():
idx = int(sel) - 1
if 0 <= idx < len(rs):
target_id = rs[idx][1]
else:
console.print(f"[err]编号超界: {sel}[/err]")
continue
else:
target_id = sel
if target_id == sid:
console.print(f"[info]已是当前 task: {sid}[/info]")
continue
_cleanup_if_empty(task_dir, session, ws_dir, console)
try:
agent, session, sid, task_state, task_dir = build_agent(
model_name=model, workspace=workspace, console=console,
session_id=target_id, resume=True,
)
except Exception as e:
console.print(f"[err]恢复失败:[/err] {type(e).__name__}: {e}")
continue
console.print(
f"[ok]切到 task[/ok] [bold]{sid[:8]}[/bold] ({len(session.messages)} 条消息) "
f"model: [accent]{agent.caps.model_id}[/accent]"
)
continue
if cmd == "/id":
cwd_disp = session.meta.get("cwd", "?")
model_disp = session.meta.get("model", agent.caps.model_id)
console.print(f"[info]task: {sid} model: {model_disp} cwd: {cwd_disp}[/info]")
continue
if cmd == "/status":
console.print(
f"[info]task {task_state.task_id} name={task_state.name!r} "
f"status={task_state.status} skill={task_state.skill!r} "
f"desc={task_state.description!r}\n"
f" working_dir={task_state.working_dir}\n"
f" model={task_state.model} tokens={task_state.tokens_total} "
f"(p={task_state.tokens_prompt}/c={task_state.tokens_completion}) "
f"created={task_state.created_at} updated={task_state.updated_at}[/info]"
)
continue
if cmd == "/done":
task_state.status = "completed"
task_state.save()
console.print(f"[ok]task {sid} marked completed[/ok]")
break
if cmd == "/abandon":
task_state.status = "abandoned"
task_state.save()
console.print(f"[warn]task {sid} marked abandoned[/warn]")
break
if cmd.startswith("/desc"):
new_desc = cmd[len("/desc"):].strip()
task_state.description = new_desc
task_state.save()
console.print(f"[info]description set: {new_desc!r}[/info]")
continue
if cmd.startswith("/export"):
arg = cmd[len("/export"):].strip()
from uuid import UUID
if arg:
if arg == "last":
rs = _list_task_rows(ws_dir, limit=1)
if not rs:
console.print("[warn]没有 task 可导出[/warn]")
continue
arg = rs[0][1]
try:
target_tid = _resolve_uuid_or_prefix(arg)
except Exception as e:
console.print(f"[err]task_id 解析失败:[/err] {type(e).__name__}: {e}")
continue
target_dir = None # 让 export_chat_to_docx 从 PG 读 task_dir
else:
target_tid = UUID(sid)
target_dir = task_dir
if not _task_has_messages(str(target_tid)):
console.print(
f"[warn]无可导出内容: {str(target_tid)[:8]} 还没有消息[/warn]"
)
continue
try:
from core.export_docx import export_chat_to_docx
out = export_chat_to_docx(target_tid, target_dir)
except Exception as e:
console.print(f"[err]导出失败:[/err] {type(e).__name__}: {e}")
continue
console.print(f"[ok]已导出[/ok] -> {out}")
continue
if not cmd:
continue
try:
agent.run(user_input)
except KeyboardInterrupt:
console.print("\n[warn]已中断本轮。下一条输入会继续这个 task。[/warn]")
except Exception as e:
console.print(f"[err]运行错误:[/err] {type(e).__name__}: {e}")
finally:
sync_task_tokens(task_state, agent.llm)
@cli.command()
@click.option("--workspace", default=None, help="工作目录")
@click.option("--limit", default=20, help="显示最近 N 个")
@click.option("--status", default=None, help="只看某状态: active / completed / abandoned")
def tasks(workspace: str, limit: int, status: str) -> None:
"""列出已有 task(从 PG tasks 表读,按 updated_at 降序)。"""
cfg = load_config()
ws = resolve_workspace(workspace, cfg)
rows = _list_task_rows(ws, limit=limit, status=status)
if not rows:
click.echo(f"(no tasks under {user_root(ws, SENTINEL_USER_ID)})")
return
tbl = Table(show_lines=False)
tbl.add_column("task id", style="bold")
tbl.add_column("status")
tbl.add_column("name")
tbl.add_column("skill")
tbl.add_column("model")
tbl.add_column("msgs", justify="right")
tbl.add_column("tokens", justify="right")
tbl.add_column("desc")
sc = {"active": "status.active", "completed": "status.completed", "abandoned": "status.abandoned"}
for _, tid, st, nm, sk, model, tok, n, desc in rows:
c = sc.get(st, "info")
d_show = desc if len(desc) <= 50 else desc[:47] + "..."
tbl.add_row(tid[:8], f"[{c}]{st}[/{c}]", nm, sk, model, str(n), str(tok), d_show)
make_console().print(tbl)
@cli.command()
@click.argument("task_id")
@click.option("--workspace", default=None, help="工作目录")
@click.option("-o", "--output", default=None,
help="输出 .docx 路径,默认 <task_dir>/chat_<task_id>.docx")
@click.option("--include-system", is_flag=True,
help="包含 system prompt(默认跳过,信息密度低)")
@click.option("--no-reasoning", is_flag=True,
help="不包含 reasoning_content(默认带)")
@click.option("--tool-head", default=1000, type=int,
help="tool 结果保留前 N 字符(默认 1000)")
@click.option("--tool-tail", default=500, type=int,
help="tool 结果保留后 N 字符(默认 500)")
def export(task_id: str, workspace: str, output: str, include_system: bool,
no_reasoning: bool, tool_head: int, tool_tail: int) -> None:
"""把指定 task 的对话导出为 .docx。task_id 用 'last' 取最近一个。"""
from core.export_docx import export_chat_to_docx
console = make_console()
cfg = load_config()
ws = resolve_workspace(workspace, cfg)
if task_id == "last":
rs = _list_task_rows(ws, limit=1)
if not rs:
console.print("[err]没有 task 可导出[/err]")
sys.exit(1)
task_id = rs[0][1]
try:
tid = _resolve_uuid_or_prefix(task_id)
except Exception as e:
console.print(f"[err]task_id 解析失败:[/err] {type(e).__name__}: {e}")
sys.exit(1)
if not _task_has_messages(str(tid)):
console.print(f"[err]task 不存在或无 messages:[/err] {tid}")
sys.exit(1)
out = Path(output).resolve() if output else None
try:
path = export_chat_to_docx(
tid, None, out,
include_system=include_system,
include_reasoning=not no_reasoning,
tool_head=tool_head,
tool_tail=tool_tail,
)
except Exception as e:
console.print(f"[err]导出失败:[/err] {type(e).__name__}: {e}")
sys.exit(1)
console.print(f"[ok]导出完成[/ok] -> {path}")
@cli.command()
@click.option("--model", default=None, help="模型档案,如 deepseek_v4.flash 或 deepseek_v4.pro")
@click.option("--long-context", is_flag=True, help="加跑 needle-in-haystack(费 token,默认关)")
def probe(model: str, long_context: bool) -> None:
"""实测对账模型 yaml 声称的能力。会调用 LLM,有 API 开销。"""
from core.capabilities import ModelCapabilities
from core.llm import LLM
from core.probe import probe_capabilities
cfg = load_config()
name = model or cfg["default_model"]
console = make_console()
try:
caps = ModelCapabilities.load(name, ROOT / cfg["models_dir"])
except Exception as e:
console.print(f"[err]档案加载失败:[/err] {type(e).__name__}: {e}")
sys.exit(1)
console.print(
f"[bold]probing[/bold] [accent]{caps.model_id}[/accent] (profile: {name}) "
f"[muted]long-context={long_context}[/muted]\n"
)
try:
llm = LLM(caps)
except Exception as e:
console.print(f"[err]LLM 构造失败:[/err] {type(e).__name__}: {e}")
sys.exit(1)
with console.status("[muted]running probes...[/muted]", spinner="dots"):
report = probe_capabilities(caps, llm, include_long_context=long_context)
tbl = Table(show_lines=False)
tbl.add_column("capability", style="bold")
tbl.add_column("declared")
tbl.add_column("observed")
tbl.add_column("status")
tbl.add_column("detail")
color = {"ok": "ok", "mismatch": "warn", "error": "err", "skip": "muted"}
for r in report.results:
c = color.get(r.status, "info")
tbl.add_row(
r.name,
str(r.declared),
str(r.observed),
f"[{c}]{r.status}[/{c}]",
r.detail,
)
console.print(tbl)
if report.has_mismatch:
console.print(
"\n[warn]存在能力对账差异 —— 看 detail,必要时改 "
f"config/models/{caps.family}.yaml[/warn]"
)
sys.exit(2)
if any(r.status == "error" for r in report.results):
console.print("\n[err]部分探测出错(见 detail)[/err]")
sys.exit(3)
console.print("\n[ok]全部能力声明与实测一致。[/ok]")
@cli.command()
@click.option("--host", default="127.0.0.1", show_default=True,
help="监听地址。本地形态默认 127.0.0.1,不对外暴露")
@click.option("--port", default=8765, show_default=True, type=int,
help="监听端口")
@click.option("--reload/--no-reload", default=False,
help="dev:文件改动自动重启(uvicorn 工厂模式)")
def web(host: str, port: int, reload: bool) -> None:
"""启动 Web UI(§7 Phase G,本地形态 sentinel user 无 auth)。"""
import uvicorn
if reload:
# reload 模式需要 import string + factory,uvicorn 才能监听文件
uvicorn.run("web.app:create_app", host=host, port=port,
reload=True, factory=True, log_level="info")
else:
from web.app import create_app
uvicorn.run(create_app(), host=host, port=port, log_level="info")
if __name__ == "__main__":
cli()

307
core/agent_builder.py Normal file
View File

@ -0,0 +1,307 @@
"""装配入口: 读 config → 加载 capabilities/skills → 构造 LLM/tools/session/loop。
存储布局(§7.0 / §7.4):本地 + SaaS 共用 `workspace/` ,只差 user_id:
PG tasks / messages 元数据 + 消息
workspace/users/<user_id>/<working_dir>/ 工作目录(用户起名,可多 task 共享)
workspace/users/<user_id>/.memory/{core.md, extended/} per-user 记忆(dotfile 隔离)
所有入口都走 web `/v1` + JWT(user_id = sub);本地 dev SPA 默认填 SENTINEL
(`00000000-...`)走同一条路径task_id / user_id UUID;state.json 已删除
(元数据全在 PG)
**新建 task 必须给 `name`**(任务显示名,DB NOT NULL);**`working_dir` 可选**
(留空 name 作目录名; working_dir task 自动共享 §7.1)name working_dir
都过同一份 `validate_task_name` 校验(简单名,不含 `/\\..`不以 `.` 起头)
`_cleanup_if_empty` rmtree FS working_dir task 复用, task 只删 DB
"""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from typing import Optional, Tuple
from uuid import UUID, uuid4
import yaml
from rich.console import Console
from core.capabilities import ModelCapabilities
from core.llm import LLM
from core.loop import AgentLoop
from core.memory import memory_block
from core.paths import ROOT, from_db_path, to_db_path
from core.session import Session
from core.sinks import ConsoleEventSink
from core.skills import SkillRegistry
from core.storage import SENTINEL_USER_ID, check_no_subtask, ensure_local_sentinel
from core.task import TaskState
from tools.fs import EditTool, GlobTool, GrepTool, ReadTool, WriteTool
from tools.run_python import RunPythonTool
from tools.shell import ShellTool
from tools.skill_tool import LoadSkillTool
def load_config() -> dict:
return yaml.safe_load((ROOT / "config" / "agent.yaml").read_text(encoding="utf-8")) or {}
def resolve_workspace(workspace: Optional[str], cfg: Optional[dict] = None) -> Path:
cfg = cfg or load_config()
p = Path(workspace) if workspace else ROOT / cfg.get("workspace_dir", "workspace")
p.mkdir(parents=True, exist_ok=True)
return p
def user_root(workspace_dir: Path, user_id: UUID) -> Path:
"""per-user 子树根:`<workspace>/users/<user_id>/`。working_dir / `.memory/` 都在下面。"""
d = workspace_dir / "users" / str(user_id)
d.mkdir(parents=True, exist_ok=True)
return d
class InvalidTaskName(ValueError):
"""task name / working_dir 不合法(空 / 含分隔符 / dotfile 起头 / 超长)。"""
def validate_task_name(name: str) -> str:
"""返回 stripped name;非法抛 InvalidTaskName。
name working_dir 共用一份规则:非空 / 不含 `/\\` NUL / 不以 `.` 起头
( `.memory` 等系统区)/ 255 字符允许 CJK 与其他 Unicode 字符
"""
n = (name or "").strip()
if not n:
raise InvalidTaskName("name 不能为空")
if len(n) > 255:
raise InvalidTaskName(f"name 超长(>255 字符): {n[:40]!r}...")
if any(c in n for c in ("/", "\\", "\x00")):
raise InvalidTaskName(f"name 不能含 `/` `\\` 或 NUL: {n!r}")
if n.startswith("."):
raise InvalidTaskName(
f"name 不能以 `.` 起头(保留给 .memory 等系统区): {n!r}"
)
return n
def working_dir_from_name(workspace_dir: Path, user_id: UUID, dir_name: str) -> Path:
"""`<workspace>/users/<user_id>/<dir_name>` 绝对路径。
入参 dir_name `validate_task_name` 在入口校验过;本函数只拼路径, mkdir
(目录创建放在 task 创建入口 build_agent / web `/v1/tasks`,函数保持纯)
"""
return user_root(workspace_dir, user_id) / dir_name
def resolve_task_id(
workspace_dir: Path,
task_id_arg: Optional[str],
resume: bool,
user_id: UUID,
working_dir_name: Optional[str] = None,
) -> Tuple[UUID, Path]:
"""返回 (task_id, working_dir 绝对路径)。
新建:`working_dir_name` 必填(调用方应已 fallback name + 校验过),
工作目录 = `<workspace>/users/<uid>/<working_dir_name>/`
Resume:`task_id_arg` 是完整 UUID 字符串(web 路由进来的总是 UUID),
working_dir PG `tasks.working_dir` 读还原;`working_dir_name` resume 时被忽略
"""
if resume:
from sqlalchemy import select
from core.storage import session_scope
from core.storage.models import Task
tid = UUID(task_id_arg) if task_id_arg else None
if tid is None:
raise ValueError("resume 必须指定 task_id")
with session_scope() as s:
db_dir = s.execute(
select(Task.working_dir).where(Task.task_id == tid)
).scalar_one_or_none() or ""
if not db_dir:
raise ValueError(
f"task {tid} has empty working_dir in DB — should not happen "
"(new tasks require name + working_dir; legacy empty data was wiped)"
)
# DB 存的是 db 形态(相对 ROOT 或绝对),走 from_db_path 还原绝对
return tid, from_db_path(db_dir)
if not working_dir_name:
raise InvalidTaskName("new task 必须指定 working_dir(或留空 fallback 用 name)")
safe = validate_task_name(working_dir_name)
return uuid4(), working_dir_from_name(workspace_dir, user_id, safe)
def _build_system_prompt(
cfg: dict,
skills: SkillRegistry,
workspace_dir: Path,
tool_base: Path,
working_dir: Path,
user_id: UUID,
) -> str:
"""拼 system prompt: 模板 + skill 列表 + memory + 工作目录段。
new task resume task 都走这里,memory 演化即时生效memory user_id 隔离
"""
prompt = (ROOT / cfg["system_prompt"]).read_text(encoding="utf-8")
if skills.skills:
prompt += f"\n\n## 可用 skill (用 load_skill 加载完整指引)\n{skills.discovery_block()}"
prompt += memory_block(workspace_dir, user_id)
wd_abs = working_dir.resolve()
prompt += (
f"\n\n## 工作目录\n"
f"- cwd(用户启动时所在目录,只读用): `{tool_base}`\n"
f"- **task_dir(所有产物写到这里)**: `{wd_abs}`\n\n"
f"SKILL 文档里出现的 `<task_dir>` 占位符,一律指上面这个绝对路径。"
f"产物示例: `{wd_abs}/spec_lock.md`、"
f"`{wd_abs}/sections/01_summary.md`、"
f"`{wd_abs}/slides/`、最终 .docx/.pptx。\n"
f"⛔ 不要把产物写到 cwd / `skills/` / repo 根 —— 只写到 task_dir。"
)
return prompt
def build_agent(
model_name: Optional[str] = None,
workspace: Optional[str] = None,
console: Optional[Console] = None,
session_id: Optional[str] = None,
resume: bool = False,
tool_base: Optional[Path] = None,
skill: str = "",
description: str = "",
name: Optional[str] = None,
working_dir: Optional[str] = None,
user_id: Optional[UUID] = None,
) -> Tuple[AgentLoop, Session, str, TaskState, Path]:
"""返回 (agent, session, task_id_str, task_state, working_dir_path)。
新建 task:
- `name` 必填(任务显示名,DB NOT NULL, validate_task_name)
- `working_dir` 可选(留空 fallback name 作目录名;非空也走 validate_task_name)
Resume:name / working_dir 都忽略( DB )
`user_id` 决定 working_dir memory 子树no-subtask 校验作用域
None SENTINEL(本地 CLI)web 入口必须显式传入 JWT user_id
"""
cfg = load_config()
model = model_name or cfg["default_model"]
uid = user_id or SENTINEL_USER_ID
# 本地 sentinel user 入库(idempotent);build_agent 是所有 task 操作的入口
ensure_local_sentinel()
caps = ModelCapabilities.load(model, ROOT / cfg["models_dir"])
llm = LLM(caps)
workspace_dir = resolve_workspace(workspace, cfg)
# 新建时校验 name + 解析 working_dir(留空 fallback 用 name);resume 跳过
task_name_safe = ""
wd_name_for_resolve: Optional[str] = None
if not resume:
if not name:
raise InvalidTaskName("new task 必须指定 name(任务显示名)")
task_name_safe = validate_task_name(name)
wd_raw = (working_dir or "").strip()
wd_name = wd_raw if wd_raw else task_name_safe
wd_name_for_resolve = validate_task_name(wd_name)
task_id, working_dir_path = resolve_task_id(
workspace_dir, session_id, resume, uid, wd_name_for_resolve
)
sid = str(task_id)
# §7.4 no-subtask:新建 task 时校验 working_dir 不与同 user 已有 task 形成前缀嵌套
# (resume 跳过 —— 该 task 已落库,改名走 Folder API 的 cascade)
if not resume:
check_no_subtask(str(working_dir_path), user_id=uid)
# 新建 task 立刻建工作目录 —— 用户已声明项目,目录就该存在
# (同 working_dir 多 task 共享,exist_ok=True 不冲突)
working_dir_path.mkdir(parents=True, exist_ok=True)
tool_base = Path(tool_base) if tool_base else Path.cwd()
skills = SkillRegistry(ROOT / cfg.get("skills_dir", "skills"))
system_prompt = _build_system_prompt(
cfg, skills, workspace_dir, tool_base, working_dir_path, uid
)
now_iso = datetime.now().isoformat(timespec="seconds")
# meta["working_dir"] 是 db 形态(相对 ROOT 或绝对);Session.append → ensure_local_task_row
# 把它直接落 PG tasks.working_dir,所以这里就转好。文件系统操作仍用 working_dir_path(absolute)。
wd_db = to_db_path(working_dir_path)
meta = {
"id": sid,
"created_at": now_iso,
"cwd": str(tool_base),
"name": task_name_safe, # resume 时空字符串(Session.load 会从 DB 拿不到 -- 不要紧,ensure 走 ON CONFLICT DO NOTHING)
"working_dir": wd_db,
"model": caps.model_id,
"model_profile": model,
"skill": skill,
"description": description,
"reasoning_effort": caps.default_reasoning_effort or "",
}
if resume:
session = Session.load(task_id, system_prompt=system_prompt, meta=meta)
task_state = TaskState.load(task_id)
if task_state is None:
# tasks 行不存在 —— 理论上 resolve_task_id 已经定位到 DB 行了,走到这里
# 说明被并发删了,兜底构造空 state(不主动 save,等下条 append / 命令)
task_state = TaskState(
task_id=sid, name="", working_dir=wd_db,
skill=skill, description=description, status="active",
model=caps.model_id, model_profile=model,
)
# resume 时 meta name 用 DB 里读出来的真值(给 Session.append → ensure 用,避免落空串)
meta["name"] = task_state.name
else:
session = Session(task_id=task_id, system_prompt=system_prompt, meta=meta)
# 懒创建:TaskState 仅内存。tasks 行在首条 user 消息 append 时由
# ensure_local_task_row 占位 INSERT(name 已就位);首次 sync_task_tokens
# 或 /done /desc 走 upsert 覆盖完整字段。
task_state = TaskState(
task_id=sid, name=task_name_safe, working_dir=wd_db,
skill=skill, description=description, status="active",
model=caps.model_id, model_profile=model,
reasoning_effort=caps.default_reasoning_effort or "",
)
tools = {}
for cls in (ReadTool, WriteTool, EditTool, GlobTool, GrepTool, ShellTool):
t = cls(base_dir=tool_base)
tools[t.name] = t
if skills.skills:
ls = LoadSkillTool(registry=skills, base_dir=tool_base)
tools[ls.name] = ls
if caps.enable_run_python:
rp = RunPythonTool(base_dir=tool_base)
tools[rp.name] = rp
sink = ConsoleEventSink(console, token_counter=lambda: llm.token_counter.total) if console else None
agent = AgentLoop(llm, tools, session, caps, sink=sink)
return agent, session, sid, task_state, working_dir_path
def sync_task_tokens(task_state: TaskState, llm: LLM) -> None:
"""每轮 agent.run 后调,把 LLM 累计 tokens UPDATE 到 PG tasks 表。
update_task 而非 task_state.save() 只更 tokens 两列,避免无谓全字段 UPSERT
ORM-level update 自动刷 updated_at
"""
from uuid import UUID
from core.storage import update_task
tc = llm.token_counter
task_state.tokens_prompt = tc.prompt_tokens
task_state.tokens_completion = tc.completion_tokens
update_task(
UUID(task_state.task_id),
tokens_prompt=tc.prompt_tokens,
tokens_completion=tc.completion_tokens,
)

435
main.py
View File

@ -1,337 +1,164 @@
"""装配入口: 读 config → 加载 capabilities/skills → 构造 LLM/tools/session/loop """zcbot 命令入口: web 服务 + db migration + 模型能力探测
存储布局(§7.0 / §7.4):本地 + SaaS 共用 `workspace/` ,只差 user_id: REPL 形态( `chat / tasks / export`)已撤(2026-05-18) 浏览器 dev SPA
(`/static/dev.html`)+ web `/v1/*` 路由全覆盖,本地命令行 REPL 维护双套
task 创建 / resume / 切换语义已经是冗余所有交互走 `python main.py web`
起服务后浏览器操作
PG tasks / messages 元数据 + 消息 装配 lib `core/agent_builder.py`( main.py 内容,改名归位);
workspace/users/<user_id>/<working_dir>/ 工作目录(用户起名,可多 task 共享) 本文件仅做入口 + click 命令组
workspace/users/<user_id>/.memory/{core.md, extended/} per-user 记忆(dotfile 隔离)
本地 CLI user_id = SENTINEL(`00000000-...`),web/JWT user_id = sub
task_id / user_id UUID;state.json 已删除(元数据全在 PG)
**新建 task 必须给 `name`**(任务显示名,DB NOT NULL);**`working_dir` 可选**
(留空 name 作目录名; working_dir task 自动共享 §7.1)name working_dir
都过同一份 `validate_task_name` 校验(简单名,不含 `/\\..`不以 `.` 起头)
`_cleanup_if_empty` rmtree FS working_dir task 复用, task 只删 DB
""" """
from __future__ import annotations from __future__ import annotations
from datetime import datetime import sys
from pathlib import Path
from typing import Optional, Tuple
from uuid import UUID, uuid4
import yaml import click
from rich.console import Console
from core.capabilities import ModelCapabilities from core.agent_builder import load_config
from core.llm import LLM from core.paths import ROOT
from core.loop import AgentLoop from core.ui import make_console
from core.memory import memory_block
from core.paths import ROOT, from_db_path, to_db_path
from core.session import Session
from core.sinks import ConsoleEventSink
from core.skills import SkillRegistry
from core.storage import SENTINEL_USER_ID, check_no_subtask, ensure_local_sentinel
from core.task import TaskState
from tools.fs import EditTool, GlobTool, GrepTool, ReadTool, WriteTool
from tools.run_python import RunPythonTool
from tools.shell import ShellTool
from tools.skill_tool import LoadSkillTool
def load_config() -> dict: @click.group()
return yaml.safe_load((ROOT / "config" / "agent.yaml").read_text(encoding="utf-8")) or {} def cli() -> None:
"""zcbot - 个人任务 agent"""
def resolve_workspace(workspace: Optional[str], cfg: Optional[dict] = None) -> Path: # ─────────────── DB migration(alembic 包装) ───────────────
cfg = cfg or load_config()
p = Path(workspace) if workspace else ROOT / cfg.get("workspace_dir", "workspace") @cli.group()
p.mkdir(parents=True, exist_ok=True) def db() -> None:
return p """数据库管理 (alembic upgrade/downgrade/current)。需先 export ZCBOT_DB_URL。"""
def user_root(workspace_dir: Path, user_id: UUID) -> Path: def _alembic_cfg():
"""per-user 子树根:`<workspace>/users/<user_id>/`。working_dir / `.memory/` 都在下面。""" from alembic.config import Config
d = workspace_dir / "users" / str(user_id) return Config(str(ROOT / "alembic.ini"))
d.mkdir(parents=True, exist_ok=True)
return d
class InvalidTaskName(ValueError): def _run_alembic(fn, *args) -> None:
"""task name / working_dir 不合法(空 / 含分隔符 / dotfile 起头 / 超长)。""" """统一包一层友好出错(ZCBOT_DB_URL 未设置 / 连不上 → 简洁报错,不打 traceback)。"""
def validate_task_name(name: str) -> str:
"""返回 stripped name;非法抛 InvalidTaskName。
name working_dir 共用一份规则:非空 / 不含 `/\\` NUL / 不以 `.` 起头
( `.memory` 等系统区)/ 255 字符允许 CJK 与其他 Unicode 字符
"""
n = (name or "").strip()
if not n:
raise InvalidTaskName("name 不能为空")
if len(n) > 255:
raise InvalidTaskName(f"name 超长(>255 字符): {n[:40]!r}...")
if any(c in n for c in ("/", "\\", "\x00")):
raise InvalidTaskName(f"name 不能含 `/` `\\` 或 NUL: {n!r}")
if n.startswith("."):
raise InvalidTaskName(
f"name 不能以 `.` 起头(保留给 .memory 等系统区): {n!r}"
)
return n
def working_dir_from_name(workspace_dir: Path, user_id: UUID, dir_name: str) -> Path:
"""`<workspace>/users/<user_id>/<dir_name>` 绝对路径。
入参 dir_name `validate_task_name` 在入口校验过;本函数只拼路径, mkdir
(目录创建放在 task 创建入口 build_agent / web `/v1/tasks`,函数保持纯)
"""
return user_root(workspace_dir, user_id) / dir_name
def resolve_task_id(
workspace_dir: Path,
task_id_arg: Optional[str],
resume: bool,
user_id: UUID,
working_dir_name: Optional[str] = None,
) -> Tuple[UUID, Path]:
"""返回 (task_id, working_dir 绝对路径)。
新建:`working_dir_name` 必填(调用方应已 fallback name + 校验过),
工作目录 = `<workspace>/users/<uid>/<working_dir_name>/`
Resume:`task_id` 从前缀/UUID/'last' 解析,working_dir PG `tasks.working_dir`
读还原;`working_dir_name` resume 时被忽略
"""
if resume:
from sqlalchemy import select
from core.storage import session_scope
from core.storage.models import Task
if task_id_arg in (None, "", "last"):
with session_scope() as s:
row = s.execute(
select(Task.task_id, Task.working_dir)
.order_by(Task.updated_at.desc()).limit(1)
).first()
if row is None:
raise FileNotFoundError("no recoverable task: PG tasks 表为空")
tid, db_dir = row
else:
tid = _resolve_uuid_or_prefix(task_id_arg)
with session_scope() as s:
db_dir = s.execute(
select(Task.working_dir).where(Task.task_id == tid)
).scalar_one_or_none() or ""
if not db_dir:
raise ValueError(
f"task {tid} has empty working_dir in DB — should not happen "
"(new tasks require name + working_dir; legacy empty data was wiped)"
)
# DB 存的是 db 形态(相对 ROOT 或绝对),走 from_db_path 还原绝对
fs_dir = from_db_path(db_dir)
return tid, fs_dir
if not working_dir_name:
raise InvalidTaskName("new task 必须指定 working_dir(或留空 fallback 用 name)")
safe = validate_task_name(working_dir_name)
return uuid4(), working_dir_from_name(workspace_dir, user_id, safe)
def _resolve_uuid_or_prefix(s: str) -> UUID:
"""完整 UUID 字符串直接解析;否则当前缀,从 tasks 表精确匹配一个。"""
try: try:
return UUID(s) fn(_alembic_cfg(), *args)
except ValueError: except RuntimeError as e:
pass click.echo(f"[err] {e}", err=True)
from sqlalchemy import cast, String, select sys.exit(2)
from core.storage import session_scope except Exception as e:
from core.storage.models import Task click.echo(f"[err] {type(e).__name__}: {e}", err=True)
sys.exit(3)
with session_scope() as sess:
matches = sess.execute(
select(Task.task_id).where(cast(Task.task_id, String).like(f"{s}%"))
).scalars().all()
if not matches:
raise FileNotFoundError(f"no task matching prefix: {s}")
if len(matches) > 1:
raise ValueError(f"ambiguous prefix {s!r}, matched {len(matches)} tasks")
return matches[0]
def _build_system_prompt( @db.command("upgrade")
cfg: dict, @click.argument("revision", default="head")
skills: SkillRegistry, def db_upgrade(revision: str) -> None:
workspace_dir: Path, """alembic upgrade <revision> (default head)."""
tool_base: Path, from alembic import command
working_dir: Path, _run_alembic(command.upgrade, revision)
user_id: UUID,
) -> str:
"""拼 system prompt: 模板 + skill 列表 + memory + 工作目录段。
new task resume task 都走这里,memory 演化即时生效memory user_id 隔离
"""
prompt = (ROOT / cfg["system_prompt"]).read_text(encoding="utf-8")
if skills.skills:
prompt += f"\n\n## 可用 skill (用 load_skill 加载完整指引)\n{skills.discovery_block()}"
prompt += memory_block(workspace_dir, user_id)
wd_abs = working_dir.resolve()
prompt += (
f"\n\n## 工作目录\n"
f"- cwd(用户启动时所在目录,只读用): `{tool_base}`\n"
f"- **task_dir(所有产物写到这里)**: `{wd_abs}`\n\n"
f"SKILL 文档里出现的 `<task_dir>` 占位符,一律指上面这个绝对路径。"
f"产物示例: `{wd_abs}/spec_lock.md`、"
f"`{wd_abs}/sections/01_summary.md`、"
f"`{wd_abs}/slides/`、最终 .docx/.pptx。\n"
f"⛔ 不要把产物写到 cwd / `skills/` / repo 根 —— 只写到 task_dir。"
)
return prompt
def build_agent( @db.command("downgrade")
model_name: Optional[str] = None, @click.argument("revision")
workspace: Optional[str] = None, def db_downgrade(revision: str) -> None:
console: Optional[Console] = None, """alembic downgrade <revision> (use -1 for one step, base for all)."""
session_id: Optional[str] = None, from alembic import command
resume: bool = False, _run_alembic(command.downgrade, revision)
tool_base: Optional[Path] = None,
skill: str = "",
description: str = "",
name: Optional[str] = None,
working_dir: Optional[str] = None,
user_id: Optional[UUID] = None,
) -> Tuple[AgentLoop, Session, str, TaskState, Path]:
"""返回 (agent, session, task_id_str, task_state, working_dir_path)。
新建 task:
- `name` 必填(任务显示名,DB NOT NULL, validate_task_name)
- `working_dir` 可选(留空 fallback name 作目录名;非空也走 validate_task_name)
Resume:name / working_dir 都忽略( DB )
`user_id` 决定 working_dir memory 子树no-subtask 校验作用域 @db.command("current")
None SENTINEL(本地 CLI)web 入口必须显式传入 JWT user_id def db_current() -> None:
""" """alembic current -- show currently applied revision."""
from alembic import command
_run_alembic(command.current)
# ─────────────── Capability probing ───────────────
@cli.command()
@click.option("--model", default=None, help="模型档案,如 deepseek_v4.flash 或 deepseek_v4.pro")
@click.option("--long-context", is_flag=True, help="加跑 needle-in-haystack(费 token,默认关)")
def probe(model: str, long_context: bool) -> None:
"""实测对账模型 yaml 声称的能力。会调用 LLM,有 API 开销。"""
from rich.table import Table
from core.capabilities import ModelCapabilities
from core.llm import LLM
from core.probe import probe_capabilities
cfg = load_config() cfg = load_config()
model = model_name or cfg["default_model"] name = model or cfg["default_model"]
uid = user_id or SENTINEL_USER_ID
# 本地 sentinel user 入库(idempotent);build_agent 是所有 task 操作的入口 console = make_console()
ensure_local_sentinel() try:
caps = ModelCapabilities.load(name, ROOT / cfg["models_dir"])
except Exception as e:
console.print(f"[err]档案加载失败:[/err] {type(e).__name__}: {e}")
sys.exit(1)
caps = ModelCapabilities.load(model, ROOT / cfg["models_dir"]) console.print(
llm = LLM(caps) f"[bold]probing[/bold] [accent]{caps.model_id}[/accent] (profile: {name}) "
f"[muted]long-context={long_context}[/muted]\n"
workspace_dir = resolve_workspace(workspace, cfg)
# 新建时校验 name + 解析 working_dir(留空 fallback 用 name);resume 跳过
task_name_safe = ""
wd_name_for_resolve: Optional[str] = None
if not resume:
if not name:
raise InvalidTaskName("new task 必须指定 name(任务显示名)")
task_name_safe = validate_task_name(name)
wd_raw = (working_dir or "").strip()
wd_name = wd_raw if wd_raw else task_name_safe
wd_name_for_resolve = validate_task_name(wd_name)
task_id, working_dir_path = resolve_task_id(
workspace_dir, session_id, resume, uid, wd_name_for_resolve
)
sid = str(task_id)
# §7.4 no-subtask:新建 task 时校验 working_dir 不与同 user 已有 task 形成前缀嵌套
# (resume 跳过 —— 该 task 已落库,改名走 Folder API 的 cascade)
if not resume:
check_no_subtask(str(working_dir_path), user_id=uid)
# 新建 task 立刻建工作目录 —— 用户已声明项目,目录就该存在
# (同 working_dir 多 task 共享,exist_ok=True 不冲突)
working_dir_path.mkdir(parents=True, exist_ok=True)
tool_base = Path(tool_base) if tool_base else Path.cwd()
skills = SkillRegistry(ROOT / cfg.get("skills_dir", "skills"))
system_prompt = _build_system_prompt(
cfg, skills, workspace_dir, tool_base, working_dir_path, uid
) )
now_iso = datetime.now().isoformat(timespec="seconds") try:
# meta["working_dir"] 是 db 形态(相对 ROOT 或绝对);Session.append → ensure_local_task_row llm = LLM(caps)
# 把它直接落 PG tasks.working_dir,所以这里就转好。文件系统操作仍用 working_dir_path(absolute)。 except Exception as e:
wd_db = to_db_path(working_dir_path) console.print(f"[err]LLM 构造失败:[/err] {type(e).__name__}: {e}")
meta = { sys.exit(1)
"id": sid,
"created_at": now_iso,
"cwd": str(tool_base),
"name": task_name_safe, # resume 时空字符串(Session.load 会从 DB 拿不到 -- 不要紧,ensure 走 ON CONFLICT DO NOTHING)
"working_dir": wd_db,
"model": caps.model_id,
"model_profile": model,
"skill": skill,
"description": description,
"reasoning_effort": caps.default_reasoning_effort or "",
}
if resume: with console.status("[muted]running probes...[/muted]", spinner="dots"):
session = Session.load(task_id, system_prompt=system_prompt, meta=meta) report = probe_capabilities(caps, llm, include_long_context=long_context)
task_state = TaskState.load(task_id)
if task_state is None: tbl = Table(show_lines=False)
# tasks 行不存在 —— 理论上 resolve_task_id 已经定位到 DB 行了,走到这里 tbl.add_column("capability", style="bold")
# 说明被并发删了,兜底构造空 state(不主动 save,等下条 append / 命令) tbl.add_column("declared")
task_state = TaskState( tbl.add_column("observed")
task_id=sid, name="", working_dir=wd_db, tbl.add_column("status")
skill=skill, description=description, status="active", tbl.add_column("detail")
model=caps.model_id, model_profile=model, color = {"ok": "ok", "mismatch": "warn", "error": "err", "skip": "muted"}
) for r in report.results:
# resume 时 meta name 用 DB 里读出来的真值(给 Session.append → ensure 用,避免落空串) c = color.get(r.status, "info")
meta["name"] = task_state.name tbl.add_row(
else: r.name,
session = Session(task_id=task_id, system_prompt=system_prompt, meta=meta) str(r.declared),
# 懒创建:TaskState 仅内存。tasks 行在首条 user 消息 append 时由 str(r.observed),
# ensure_local_task_row 占位 INSERT(name 已就位);首次 sync_task_tokens f"[{c}]{r.status}[/{c}]",
# 或 /done /desc 走 upsert 覆盖完整字段。 r.detail,
task_state = TaskState(
task_id=sid, name=task_name_safe, working_dir=wd_db,
skill=skill, description=description, status="active",
model=caps.model_id, model_profile=model,
reasoning_effort=caps.default_reasoning_effort or "",
) )
console.print(tbl)
tools = {} if report.has_mismatch:
for cls in (ReadTool, WriteTool, EditTool, GlobTool, GrepTool, ShellTool): console.print(
t = cls(base_dir=tool_base) "\n[warn]存在能力对账差异 —— 看 detail,必要时改 "
tools[t.name] = t f"config/models/{caps.family}.yaml[/warn]"
)
if skills.skills: sys.exit(2)
ls = LoadSkillTool(registry=skills, base_dir=tool_base) if any(r.status == "error" for r in report.results):
tools[ls.name] = ls console.print("\n[err]部分探测出错(见 detail)[/err]")
sys.exit(3)
if caps.enable_run_python: console.print("\n[ok]全部能力声明与实测一致。[/ok]")
rp = RunPythonTool(base_dir=tool_base)
tools[rp.name] = rp
sink = ConsoleEventSink(console, token_counter=lambda: llm.token_counter.total) if console else None
agent = AgentLoop(llm, tools, session, caps, sink=sink)
return agent, session, sid, task_state, working_dir_path
def sync_task_tokens(task_state: TaskState, llm: LLM) -> None: # ─────────────── Web 服务 ───────────────
"""每轮 agent.run 后调,把 LLM 累计 tokens UPDATE 到 PG tasks 表。
update_task 而非 task_state.save() 只更 tokens 两列,避免无谓全字段 UPSERT @cli.command()
ORM-level update 自动刷 updated_at @click.option("--host", default="127.0.0.1", show_default=True,
""" help="监听地址。本地形态默认 127.0.0.1,不对外暴露")
from uuid import UUID @click.option("--port", default=8765, show_default=True, type=int,
from core.storage import update_task help="监听端口")
tc = llm.token_counter @click.option("--reload/--no-reload", default=False,
task_state.tokens_prompt = tc.prompt_tokens help="dev:文件改动自动重启(uvicorn 工厂模式)")
task_state.tokens_completion = tc.completion_tokens def web(host: str, port: int, reload: bool) -> None:
update_task( """启动 Web 服务(JSON API + dev SPA)。Auth 需 PLATFORM_KEY / JWT_SECRET env。"""
UUID(task_state.task_id), import uvicorn
tokens_prompt=tc.prompt_tokens,
tokens_completion=tc.completion_tokens, if reload:
) # reload 模式需要 import string + factory,uvicorn 才能监听文件
uvicorn.run("web.app:create_app", host=host, port=port,
reload=True, factory=True, log_level="info")
else:
from web.app import create_app
uvicorn.run(create_app(), host=host, port=port, log_level="info")
if __name__ == "__main__":
cli()

View File

@ -116,7 +116,7 @@ def _load_user_root(user_id: UUID) -> Path:
"""user_root = `<workspace>/users/<user_id>/`,所有 files API 的边界。 """user_root = `<workspace>/users/<user_id>/`,所有 files API 的边界。
若目录尚未存在自动 mkdir( user 首次访问也能拿到根) 若目录尚未存在自动 mkdir( user 首次访问也能拿到根)
""" """
from main import resolve_workspace, user_root from core.agent_builder import resolve_workspace, user_root
ws = resolve_workspace(None) ws = resolve_workspace(None)
return user_root(ws, user_id) return user_root(ws, user_id)
@ -193,7 +193,7 @@ def _run_agent_bg(task_id: UUID, user_id: UUID, user_message: str) -> None:
cancel_check broker.is_cancelled,loop 在工具调用之间 poll(LLM 同步调用本身不可中断) cancel_check broker.is_cancelled,loop 在工具调用之间 poll(LLM 同步调用本身不可中断)
`ok / cancelled` 收尾直接回 `idle`(不留持久标记);只有 error 是持久终态 `ok / cancelled` 收尾直接回 `idle`(不留持久标记);只有 error 是持久终态
""" """
from main import build_agent, sync_task_tokens from core.agent_builder import build_agent, sync_task_tokens
try: try:
broker.emit(task_id, {"type": "run_start"}) broker.emit(task_id, {"type": "run_start"})
agent, session, sid, task_state, task_dir = build_agent( agent, session, sid, task_state, task_dir = build_agent(
@ -358,7 +358,7 @@ def create_app() -> FastAPI:
- name / working_dir 都过 validate_task_name(简单名, `/\\..`, `.` 起头,255) - name / working_dir 都过 validate_task_name(简单名, `/\\..`, `.` 起头,255)
- 前缀嵌套(no-subtask, user ) 409 - 前缀嵌套(no-subtask, user ) 409
""" """
from main import InvalidTaskName, resolve_workspace, validate_task_name, working_dir_from_name from core.agent_builder import InvalidTaskName, resolve_workspace, validate_task_name, working_dir_from_name
try: try:
name = validate_task_name(body.name) name = validate_task_name(body.name)
except InvalidTaskName as e: except InvalidTaskName as e:
@ -496,7 +496,7 @@ def create_app() -> FastAPI:
但还无关联 task 的目录)每项带 n_tasks(关联 task )+ last_used(最近使用 ISO) 但还无关联 task 的目录)每项带 n_tasks(关联 task )+ last_used(最近使用 ISO)
排序: last_used 的按降序, last_used 的排最后,同列 by name asc 排序: last_used 的按降序, last_used 的排最后,同列 by name asc
""" """
from main import resolve_workspace, user_root from core.agent_builder import resolve_workspace, user_root
ws = resolve_workspace(None) ws = resolve_workspace(None)
root = user_root(ws, user_id) root = user_root(ws, user_id)
@ -568,7 +568,7 @@ def create_app() -> FastAPI:
if body.skill is not None: if body.skill is not None:
updates["skill"] = body.skill updates["skill"] = body.skill
if body.name is not None: if body.name is not None:
from main import InvalidTaskName, validate_task_name from core.agent_builder import InvalidTaskName, validate_task_name
try: try:
updates["name"] = validate_task_name(body.name) updates["name"] = validate_task_name(body.name)
except InvalidTaskName as e: except InvalidTaskName as e: