From 0d127a72610e798ce0d9a8bdc43f11d893f070c4 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Mon, 18 May 2026 14:10:59 +0800 Subject: [PATCH] =?UTF-8?q?core(=E5=85=A5=E5=8F=A3=E5=BD=92=E4=BD=8D):=20c?= =?UTF-8?q?li.py=E2=86=92main.py,=20=E5=8E=9F=20main.py=E2=86=92core/agent?= =?UTF-8?q?=5Fbuilder.py,=20=E5=88=A0=20REPL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 §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) --- DESIGN.md | 46 ++-- PROGRESS.md | 16 +- RUN.md | 94 ++----- cli.py | 586 ------------------------------------------ core/agent_builder.py | 307 ++++++++++++++++++++++ main.py | 435 ++++++++++--------------------- web/app.py | 10 +- 7 files changed, 504 insertions(+), 990 deletions(-) delete mode 100644 cli.py create mode 100644 core/agent_builder.py diff --git a/DESIGN.md b/DESIGN.md index 6a2511f..5e6b8ba 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -14,7 +14,7 @@ - 模型自由: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// │ ├── .memory/{core.md, extended/*.md} # 跨 task 共享记忆(user 级,dotfile 隔离) │ └── / # 工作目录,用户起名(同 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///`,所有 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/` 换 `/`,布局不变。 +**工作目录(working_dir) = `workspace/users///`,所有 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/` 换 `/`,布局不变。 -**启动**:读 `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。 `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)。 ### 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 复用)。 -**REPL 内 task 切换** —— `/new` / `/resume [last|]`(无参列最近 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)。 -CLI:`chat --name "<任务名>" [--working-dir <目录名>] [--skill coding] [--desc "..."] [--resume last|] [--remote ]`;`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`) @@ -185,21 +186,19 @@ memory 由人填(也允许 agent 用 `write` 写),系统不自动维护 —— ### 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 | |---|---|---| -| 入口 | `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) | -| task_dir 派生 | `workspace/users///`(`name` 必填,简单名) | `/users///`(`name` 必填,简单名) | +| working_dir | `workspace/users///`(dev SPA 默认填 SENTINEL) | `/users///`(JWT `sub`) | | Memory | `workspace/users//.memory/`(FS,dotfile) | `/users//.memory/`(仍是 FS,dotfile) | | Sandbox | subprocess + env 过滤 | per-task docker exec | -| Auth | 无(`user_id='local'`) | PLATFORM_KEY → JWT(过渡)→ OIDC | - -**CLI 长期双模式**:本地直跑(默认,in-process,直连 PG,适合调内部状态)/ `--remote https://...`(HTTP 走 `/v1`,等价真实用户路径)。两模式共用 `cli.py`,差别只在 transport 层。 +| Auth | PLATFORM_KEY → JWT(过渡)— dev SPA 填 sentinel + 本地 key | 同 — platform 端服务端持 key 签 JWT | `workspace/` 仅存 skill 产物,state / messages 全在 PG。本地 vs SaaS 共用 `users//` 子树布局,差别只在外层根目录(`workspace/` vs `/`),不在 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 天 | | 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 天 | -| 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 取舍) | 已落 | 代码量增量:**+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' 真 OIDC | 替换 /v1/auth/login 内部为 ID token 校验 + CORS allowlist 收紧 | 1 天 | 真发布给真实用户前补;路由层 Depends 不动,只换 login 内部 | | 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 | | 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 价值 | -| CLI 双模式分叉、本地直跑被忽略 | transport 层抽象统一接口;CI 跑两路径同一组用例 | +| 过早抽象违背 §5 | B 阶段单一 PG 无 adapter;各阶段独立 dogfood 价值;CLI REPL(原 §7.6 #8 双模式)2026-05-18 整套撤,无双 transport 维护税 | | `/v1` 冻死后演化慢 | minor 半年兼容,major 6 个月 deprecation;`/v1internal` 实验 | | Rename 误中前缀 / 漏改子 task | cascade SQL 用 `old/%` + 单测覆盖 | | Running task 被 rename / delete | 后端校验 + UI 禁按钮 | @@ -449,9 +447,17 @@ create index on messages using gin (payload jsonb_path_ops); - CORS `allow_origins=["*"]` 本地宽松,platform 部署时按 platform 域名收紧 - **沉淀**: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"事实由用户判断"。 diff --git a/PROGRESS.md b/PROGRESS.md index 1906664..b6d40b9 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `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 覆盖健康检查 | | 6 | 长任务工程化 | 🟡 | task + 恢复 ✅;双层记忆 ✅;context 压缩未做 | | 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 / 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 表单加 ``(常态 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(注释里记了)。 @@ -96,8 +97,8 @@ tools/fs.py 182 tools/shell.py 94 tools/run_python.py 84 tools/skill_tool.py 45 -main.py 285 ← user_root / task_dir_from_name / validate_task_name(删 auto-derive 三件套) -cli.py 558 ← §7 B Step 4 / Phase G G1: --task-dir / web 子命令 +main.py 164 ← 入口: web / db / probe 三 click 命令(05-18 改名归位) +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/versions/ 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/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 行。 @@ -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。**真发布给真实用户前必做**。 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 ≡ 用户路径。 -4. **Phase 6 context 三层压缩**(~1 天)—— 兜底,V4 长上下文一般用不到。 +3. **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 并存不冲突。 diff --git a/RUN.md b/RUN.md index e1b4781..9db524b 100644 --- a/RUN.md +++ b/RUN.md @@ -2,7 +2,7 @@ > 怎么把 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-... 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 浏览器持有,登录时校验> JWT_SECRET=<≥32 字符随机串,HS256 签 session token;泄漏 = 任意伪造,与 PLATFORM_KEY 同级保护> # 可选:覆盖默认 7d # 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` 里)。 - **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(见上) # 3) DB schema 上车 -.venv/Scripts/python.exe cli.py db upgrade head -.venv/Scripts/python.exe cli.py db current # 应输出 0004 (head) +.venv/Scripts/python.exe main.py db upgrade 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 -# 新建 task —— `--name` 必填(任务显示名),`--working-dir` 可选(目录名,留空 → 用 --name) -.venv/Scripts/python.exe cli.py chat --name "初稿大纲" --working-dir proposal_v3 +# 默认 127.0.0.1:8765 启;dev SPA 在 /,Swagger UI 在 /docs +.venv/Scripts/python.exe main.py web -# 只给 name → working_dir fallback 用 name -.venv/Scripts/python.exe cli.py chat --name proposal_v3 +# 自定义端口 / 监听 0.0.0.0(慎用,部署形态走反代不直暴) +.venv/Scripts/python.exe main.py web --port 9000 -# 带 skill + 描述(便于后续 list 识别) -.venv/Scripts/python.exe cli.py chat --name "修登录 401" --working-dir fix_login_bug --skill coding --desc "登录返回 401 排查" - -# 同 working_dir 多 task(共享 workspace/users//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 [] /resume [last|] /id /status /done /abandon /desc <文本> /export []`(`/new ` 用新任务名 + 沿用当前 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 +# dev:文件改动自动重启(uvicorn 工厂模式 reload) +.venv/Scripts/python.exe main.py web --reload ``` ### 能力探测 / DB 管理 ```bash # 实测对账模型 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 -.venv/Scripts/python.exe cli.py db upgrade head -.venv/Scripts/python.exe cli.py db downgrade -1 -.venv/Scripts/python.exe cli.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 +.venv/Scripts/python.exe main.py db upgrade head +.venv/Scripts/python.exe main.py db downgrade -1 +.venv/Scripts/python.exe main.py db current ``` **Auth**:所有 `/v1/tasks*` 需 `Authorization: Bearer `;先走 `/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)。 -> 原 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 ...` | | Windows 控制台 emoji 崩 | Python stdout 是 GBK,emoji 不能直 print。用 `[OK]` / `[ng]` 等 ASCII 标签(见 memory) | | `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 ` | | Export 报 "无可导出内容" | task 没 messages(只 system 不算);先在 REPL 发条消息再 export | | `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;` | | 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}/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 按钮 | -| `[startup] reaped N stale active run(s)` | 上次 `cli.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` 重起 | +| `[startup] reaped N stale active run(s)` | 上次 `main.py web` 进程未正常 finish 留下 N 个 `running` / `cancelling` Run 行,启动 lifespan 自动标 error。无需处理,info 级 | +| `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 `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 | @@ -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`(装配) -- **核心**:`core/loop.py`(ReAct)/ `core/session.py`(PG messages)/ `core/task.py`(PG tasks)/ `core/llm.py`(LiteLLM 封装) +- **入口**:`main.py`(`web / db / probe` 三子命令)→ `core/agent_builder.py::build_agent`(装配 lib) +- **核心**:`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` - **存储**:`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) diff --git a/cli.py b/cli.py deleted file mode 100644 index 3af8640..0000000 --- a/cli.py +++ /dev/null @@ -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 # 显式 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 (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 (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///,同名多 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|] 切到已有 task /id /status 查看 " - "/done /abandon 改状态 /desc <文本> 设描述 " - "/export [] 导出对话为 .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 ` → 新 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 路径,默认 /chat_.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() diff --git a/core/agent_builder.py b/core/agent_builder.py new file mode 100644 index 0000000..3612291 --- /dev/null +++ b/core/agent_builder.py @@ -0,0 +1,307 @@ +"""装配入口: 读 config → 加载 capabilities/skills → 构造 LLM/tools/session/loop。 + +存储布局(§7.0 / §7.4):本地 + SaaS 共用 `workspace/` 根,只差 user_id: + + PG tasks / messages ← 元数据 + 消息 + workspace/users/// ← 工作目录(用户起名,可多 task 共享) + workspace/users//.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 子树根:`/users//`。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: + """`/users//` 绝对路径。 + + 入参 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 + 校验过), + 工作目录 = `/users///`。 + 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 文档里出现的 `` 占位符,一律指上面这个绝对路径。" + 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, + ) diff --git a/main.py b/main.py index 1f66998..e896456 100644 --- a/main.py +++ b/main.py @@ -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 ← 元数据 + 消息 - workspace/users/// ← 工作目录(用户起名,可多 task 共享) - workspace/users//.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 行。 +装配 lib 在 `core/agent_builder.py`(原 main.py 内容,改名归位); +本文件仅做入口 + click 命令组。 """ from __future__ import annotations -from datetime import datetime -from pathlib import Path -from typing import Optional, Tuple -from uuid import UUID, uuid4 +import sys -import yaml -from rich.console import Console +import click -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 +from core.agent_builder import load_config +from core.paths import ROOT +from core.ui import make_console -def load_config() -> dict: - return yaml.safe_load((ROOT / "config" / "agent.yaml").read_text(encoding="utf-8")) or {} +@click.group() +def cli() -> None: + """zcbot - 个人任务 agent""" -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 +# ─────────────── DB migration(alembic 包装) ─────────────── + +@cli.group() +def db() -> None: + """数据库管理 (alembic upgrade/downgrade/current)。需先 export ZCBOT_DB_URL。""" -def user_root(workspace_dir: Path, user_id: UUID) -> Path: - """per-user 子树根:`/users//`。working_dir / `.memory/` 都在下面。""" - d = workspace_dir / "users" / str(user_id) - d.mkdir(parents=True, exist_ok=True) - return d +def _alembic_cfg(): + from alembic.config import Config + return Config(str(ROOT / "alembic.ini")) -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: - """`/users//` 绝对路径。 - - 入参 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 + 校验过), - 工作目录 = `/users///`。 - 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 表精确匹配一个。""" +def _run_alembic(fn, *args) -> None: + """统一包一层友好出错(ZCBOT_DB_URL 未设置 / 连不上 → 简洁报错,不打 traceback)。""" try: - return UUID(s) - except ValueError: - pass - from sqlalchemy import cast, String, select - from core.storage import session_scope - from core.storage.models import Task - - 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] + 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) -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 文档里出现的 `` 占位符,一律指上面这个绝对路径。" - 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 +@db.command("upgrade") +@click.argument("revision", default="head") +def db_upgrade(revision: str) -> None: + """alembic upgrade (default head).""" + from alembic import command + _run_alembic(command.upgrade, revision) -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)。 +@db.command("downgrade") +@click.argument("revision") +def db_downgrade(revision: str) -> None: + """alembic downgrade (use -1 for one step, base for all).""" + from alembic import command + _run_alembic(command.downgrade, revision) - 新建 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。 - """ +@db.command("current") +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() - model = model_name or cfg["default_model"] - uid = user_id or SENTINEL_USER_ID + name = model or cfg["default_model"] - # 本地 sentinel user 入库(idempotent);build_agent 是所有 task 操作的入口 - ensure_local_sentinel() + 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) - 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 + console.print( + f"[bold]probing[/bold] [accent]{caps.model_id}[/accent] (profile: {name}) " + f"[muted]long-context={long_context}[/muted]\n" ) - 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 "", - } + try: + llm = LLM(caps) + except Exception as e: + console.print(f"[err]LLM 构造失败:[/err] {type(e).__name__}: {e}") + sys.exit(1) - 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 "", + 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) - 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 + 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]") -def sync_task_tokens(task_state: TaskState, llm: LLM) -> None: - """每轮 agent.run 后调,把 LLM 累计 tokens UPDATE 到 PG tasks 表。 +# ─────────────── Web 服务 ─────────────── - 走 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, - ) +@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 服务(JSON API + dev SPA)。Auth 需 PLATFORM_KEY / JWT_SECRET env。""" + 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() diff --git a/web/app.py b/web/app.py index a4cac6b..15ea7bf 100644 --- a/web/app.py +++ b/web/app.py @@ -116,7 +116,7 @@ def _load_user_root(user_id: UUID) -> Path: """user_root = `/users//`,所有 files API 的边界。 若目录尚未存在自动 mkdir(空 user 首次访问也能拿到根)。 """ - from main import resolve_workspace, user_root + from core.agent_builder import resolve_workspace, user_root ws = resolve_workspace(None) 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 同步调用本身不可中断)。 `ok / cancelled` 收尾直接回 `idle`(不留持久标记);只有 error 是持久终态。 """ - from main import build_agent, sync_task_tokens + from core.agent_builder import build_agent, sync_task_tokens try: broker.emit(task_id, {"type": "run_start"}) agent, session, sid, task_state, task_dir = build_agent( @@ -358,7 +358,7 @@ def create_app() -> FastAPI: - name / working_dir 都过 validate_task_name(简单名,无 `/\\..`,非 `.` 起头,≤255) - 前缀嵌套(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: name = validate_task_name(body.name) except InvalidTaskName as e: @@ -496,7 +496,7 @@ def create_app() -> FastAPI: 但还无关联 task 的目录)。每项带 n_tasks(关联 task 数)+ last_used(最近使用 ISO)。 排序:有 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) root = user_root(ws, user_id) @@ -568,7 +568,7 @@ def create_app() -> FastAPI: if body.skill is not None: updates["skill"] = body.skill if body.name is not None: - from main import InvalidTaskName, validate_task_name + from core.agent_builder import InvalidTaskName, validate_task_name try: updates["name"] = validate_task_name(body.name) except InvalidTaskName as e: