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:
parent
2e519ab8a6
commit
0d127a7261
46
DESIGN.md
46
DESIGN.md
|
|
@ -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"事实由用户判断"。
|
||||||
|
|
||||||
|
|
|
||||||
16
PROGRESS.md
16
PROGRESS.md
|
|
@ -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
94
RUN.md
|
|
@ -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
586
cli.py
|
|
@ -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()
|
|
||||||
|
|
@ -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
435
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 ← 元数据 + 消息
|
装配 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()
|
||||||
|
|
|
||||||
10
web/app.py
10
web/app.py
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue