From 9d6cde2d235439ead033c954eb200e4f1e891069 Mon Sep 17 00:00:00 2001 From: steven_li Date: Wed, 20 May 2026 18:01:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=B0=86=E9=A1=B9=E7=9B=AE=E4=BB=8Enan?= =?UTF-8?q?o=E9=87=8D=E5=91=BD=E5=90=8D=E4=B8=BAbeaver=E5=B9=B6=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E7=9B=B8=E5=85=B3=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将所有环境变量前缀从NANO_改为BEAVER_ - 更新README.md文档内容,包括项目介绍、组件说明和快速开始指南 - 修改.gitignore文件,添加auth-portal运行时路径排除规则 - 更新app-instance镜像标签从nano/app-instance改为beaver/app-instance - 增强技能安全检查器,支持工具前缀白名单功能 - 添加技能草稿重新检查安全性API端点 - 扩展证据选择器,收集工具调用名称用于技能学习 - 改进技能合成器,基于实际调用的工具生成工具提示 - 优化路由超时处理机制,增加重试逻辑 - 更新后端架构文档,添加可视化入口和基础概念说明 - 实现在WebSocket消息中传递工具迭代次数信息 --- .env.example | 28 +- .gitignore | 2 + 前端改造风格/DESIGN.md => DESIGN.md | 0 README.md | 460 ++---- app-instance/README.md | 4 +- app-instance/backend/beaver/engine/loader.py | 7 +- .../backend/beaver/interfaces/web/app.py | 21 + .../beaver/skills/learning/evidence.py | 45 +- .../backend/beaver/skills/learning/safety.py | 15 +- .../beaver/skills/learning/synthesizer.py | 46 +- app-instance/backend/beaver/tasks/router.py | 68 +- .../docs/architecture/backend-overview.md | 38 +- .../architecture/backend-visualization.html | 1388 +++++++++++++++++ .../docs/architecture/project-comparison.html | 1071 +++++++++++++ .../backend/tests/unit/test_config_loader.py | 4 +- .../tests/unit/test_main_agent_router.py | 68 + .../tests/unit/test_skill_learning_safety.py | 62 +- .../tests/unit/test_skill_learning_worker.py | 73 + .../backend/tests/unit/test_websocket_chat.py | 2 + app-instance/create-instance.sh | 61 +- .../frontend/app/(app)/skills/page.tsx | 12 + .../app/(app)/tasks/[taskId]/page.tsx | 240 ++- app-instance/frontend/lib/api.ts | 7 + auth-portal/src/.env.example | 4 +- auth-portal/src/README.md | 2 +- .../api/runtime/provider-onboarding/route.ts | 120 ++ auth-portal/src/app/globals.css | 643 ++++++-- auth-portal/src/app/layout.tsx | 4 +- auth-portal/src/app/login/page.tsx | 117 +- auth-portal/src/app/register/page.tsx | 355 +++-- .../src/components/LanguageSwitcher.tsx | 10 +- auth-portal/src/lib/auth-client.ts | 17 + auth-portal/src/lib/runtime-control.ts | 4 +- auth-portal/src/public/login-background.png | Bin 0 -> 1320383 bytes authz-service/.env.example | 4 +- authz-service/README.md | 4 +- authz-service/src/app/main.py | 4 - authz-service/src/app/models.py | 4 - authz-service/start-authz.sh | 4 +- deploy-control/.env.example | 6 +- deploy-control/README.md | 31 +- deploy-control/server.py | 155 +- router-proxy/.env.example | 4 +- router-proxy/README.md | 10 +- router-proxy/reload-proxy.sh | 2 +- router-proxy/start-proxy.sh | 4 +- .../0771910121417d983ca9fc61a48e13f7.png | Bin 393659 -> 0 bytes .../145e4ac39c635a3520edc41a5977db05.png | Bin 281982 -> 0 bytes .../308a0bb342bf60287d0ff250eb7899d5.png | Bin 215539 -> 0 bytes .../30e8036792577193657519d3f671e7d9.png | Bin 430619 -> 0 bytes .../3503b561cf9e1874915178dc24f2572b.png | Bin 139006 -> 0 bytes .../35e7c6e818c2ce11eac2c296e0bcbf9d.png | Bin 157358 -> 0 bytes .../4193508999431017053ab885384d3754.png | Bin 324740 -> 0 bytes .../5501cfdc70081b1c9a8723347bdf6122.png | Bin 351388 -> 0 bytes .../64a29bddd827dce7b196104f92ea5621.png | Bin 135548 -> 0 bytes .../8d98af7d0c96df7c7b4cded57efbba78.png | Bin 218747 -> 0 bytes .../a900c98901245c5796e01f0c150e5ec3.png | Bin 113219 -> 0 bytes .../abe6200a6ee2459da3e3d0dc69c19bfa.png | Bin 1145500 -> 0 bytes .../c32570e98dbb7e8a6f19f951811cd129.png | Bin 139148 -> 0 bytes .../c5062c18c83c338aa7af50c96f9a3b59.png | Bin 308453 -> 0 bytes .../cf74720c590e7747562eaf8f79c73b21.png | Bin 372118 -> 0 bytes 域名配置指引.md | 574 +++---- 部署指南.md | 686 +++----- 63 files changed, 4894 insertions(+), 1596 deletions(-) rename 前端改造风格/DESIGN.md => DESIGN.md (100%) create mode 100644 app-instance/backend/docs/architecture/backend-visualization.html create mode 100644 app-instance/backend/docs/architecture/project-comparison.html create mode 100644 auth-portal/src/app/api/runtime/provider-onboarding/route.ts create mode 100644 auth-portal/src/public/login-background.png delete mode 100644 前端改造风格/0771910121417d983ca9fc61a48e13f7.png delete mode 100644 前端改造风格/145e4ac39c635a3520edc41a5977db05.png delete mode 100644 前端改造风格/308a0bb342bf60287d0ff250eb7899d5.png delete mode 100644 前端改造风格/30e8036792577193657519d3f671e7d9.png delete mode 100644 前端改造风格/3503b561cf9e1874915178dc24f2572b.png delete mode 100644 前端改造风格/35e7c6e818c2ce11eac2c296e0bcbf9d.png delete mode 100644 前端改造风格/4193508999431017053ab885384d3754.png delete mode 100644 前端改造风格/5501cfdc70081b1c9a8723347bdf6122.png delete mode 100644 前端改造风格/64a29bddd827dce7b196104f92ea5621.png delete mode 100644 前端改造风格/8d98af7d0c96df7c7b4cded57efbba78.png delete mode 100644 前端改造风格/a900c98901245c5796e01f0c150e5ec3.png delete mode 100644 前端改造风格/abe6200a6ee2459da3e3d0dc69c19bfa.png delete mode 100644 前端改造风格/c32570e98dbb7e8a6f19f951811cd129.png delete mode 100644 前端改造风格/c5062c18c83c338aa7af50c96f9a3b59.png delete mode 100644 前端改造风格/cf74720c590e7747562eaf8f79c73b21.png diff --git a/.env.example b/.env.example index 2df2fff..1f222c5 100644 --- a/.env.example +++ b/.env.example @@ -1,18 +1,18 @@ # Shared values used by the root deployment flow in README.md -PROJECT_ROOT=/home/ivan/xuan/nano_project -NANO_NET=nano-instance-edge +PROJECT_ROOT=/home/ivan/xuan/beaver_project +BEAVER_NET=beaver-instance-edge -NANO_DEPLOY_TOKEN=change-me -NANO_AUTHZ_INTERNAL_TOKEN=change-me +BEAVER_DEPLOY_TOKEN=change-me +BEAVER_AUTHZ_INTERNAL_TOKEN=change-me -NANO_SERVER_IP=203.0.113.10 -NANO_BASE_DOMAIN=203.0.113.10.nip.io +BEAVER_SERVER_IP=203.0.113.10 +BEAVER_BASE_DOMAIN=203.0.113.10.nip.io -NANO_PROVIDER=openai -NANO_MODEL=openai/gpt-5 -NANO_API_KEY=sk-xxxxxxxx -NANO_API_BASE= +BEAVER_PROVIDER=openai +BEAVER_MODEL=openai/gpt-5 +BEAVER_API_KEY=sk-xxxxxxxx +BEAVER_API_BASE= # Per-instance Beaver backend config. In Docker app-instance this should point # to the mounted single-user sandbox config, not to frontend env. @@ -21,9 +21,9 @@ BEAVER_CONFIG_PATH=/root/.beaver/config.json BEAVER_WORKSPACE=/root/.beaver/workspace # Must be reachable from app-instance containers. -NANO_AUTHZ_URL=http://nano-authz-service:19090 -NANO_OUTLOOK_MCP_URL= -NANO_OUTLOOK_MCP_SERVER_ID=outlook_mcp +BEAVER_AUTHZ_URL=http://beaver-authz-service:19090 +BEAVER_OUTLOOK_MCP_URL= +BEAVER_OUTLOOK_MCP_SERVER_ID=outlook_mcp # Must be reachable from auth-portal and authz-service containers. -NANO_DEPLOY_URL=http://nano-deploy-control:8090 +BEAVER_DEPLOY_URL=http://beaver-deploy-control:8090 diff --git a/.gitignore b/.gitignore index 76a33e2..93541f0 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ app-instance/runtime/instances/ app-instance/runtime/registry/ router-proxy/runtime/conf.d/ runtime/ +!auth-portal/src/app/api/runtime/ +!auth-portal/src/app/api/runtime/** sessions/ **/sessions/state.db **/runtime/**/*.lock diff --git a/前端改造风格/DESIGN.md b/DESIGN.md similarity index 100% rename from 前端改造风格/DESIGN.md rename to DESIGN.md diff --git a/README.md b/README.md index 7c5c634..cf5c580 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,30 @@ -https://d3qpg7p2n3hazf.cloudfront.net/api/v1/client/subscribe?token=2185761c5925a800c2d2c1ec44449b65 -# nano_project +# Beaver Project -单机部署版运行结构: +`Beaver Project` 是一套单机 Docker 部署的多实例运行环境: -- `auth-portal` - - 用户入口页,提供登录、注册、跳转。 -- `authz-service` - - AuthZ 服务。 - - 现在负责注册编排:`auth-portal -> authz-service -> deploy-control -> app-instance -> authz-service` -- `deploy-control` - - 部署机控制面。 - - 负责创建实例、解析实例、刷新反向代理。 -- `router-proxy` - - 统一入口代理。 - - 按 Host 把 `.` 转发到对应实例容器。 -- `app-instance` - - 真正的单用户实例。 - - 一个容器里同时包含前端、后端和 Nginx。 +- 用户先进入独立的 `auth-portal` 完成注册或登录。 +- 注册会触发 `authz-service` 调用 `deploy-control`。 +- `deploy-control` 在同一台机器上创建一个独立的 `app-instance` 容器。 +- `router-proxy` 按实例域名把流量转发到对应容器。 + +当前推荐的最小部署方式是一台 Linux / WSL2 Ubuntu 机器加 Docker。生产域名和 HTTPS 可以放在项目外层的 Nginx、Caddy、Traefik 或云负载均衡上。 + +## 组件 + +| 目录 | 职责 | 默认端口 | +| --- | --- | --- | +| `auth-portal/` | 用户登录、注册、模型配置引导入口 | `3081` | +| `authz-service/` | AuthZ 服务,负责账号和 backend 身份编排 | `19090` | +| `deploy-control/` | 部署控制面,调用 Docker 创建和管理实例 | `8090` | +| `router-proxy/` | 统一实例入口代理,按 Host 分发到实例容器 | `8088` | +| `app-instance/` | 单用户运行实例,容器内包含前端、后端和 Nginx | 容器内 `8080` | + +公网环境通常只暴露: + +- `auth-portal`: `3081`,或外层代理后的 `https://portal.example.com` +- `router-proxy`: `8088`,或外层代理后的 `https://.apps.example.com` + +不要直接把 `deploy-control:8090` 和 `authz-service:19090` 暴露到公网。 ## 请求链路 @@ -27,9 +35,11 @@ Browser -> auth-portal -> authz-service POST /portal/register -> deploy-control POST /api/instances/register - -> create-instance.sh + -> app-instance/create-instance.sh -> app-instance POST /api/auth/register -> authz-service /oauth/register or /backends/register + -> auth-portal provider onboarding + -> deploy-control POST /api/instances/configure-provider ``` 登录: @@ -39,378 +49,134 @@ Browser -> auth-portal -> deploy-control POST /api/instances/resolve -> app-instance POST /api/auth/login + -> app-instance frontend URL ``` -## 这份部署指南的前提 +## 快速开始 -这份 README 只覆盖一套基准方案: +本机完整流程见: -- 一台 Linux 服务器 -- 用 Docker 运行 `auth-portal`、`authz-service`、`deploy-control`、`router-proxy` -- `deploy-control` 通过 Docker socket 在同一台机器上创建 `app-instance` -- 所有容器共用一个 Docker network:`nano-instance-edge` -- 测试域名先用 `nip.io` +- [部署指南.md](./部署指南.md) -如果你后面要接 HTTPS、外部 LB、Kubernetes、真实 DNS,这份流程仍然适合作为最小可运行基线。 +域名、HTTPS、公网反向代理说明见: -可直接参考这些模板文件: +- [域名配置指引.md](./域名配置指引.md) -- [`.env.example`](/home/ivan/xuan/nano_project/.env.example) -- [`auth-portal/src/.env.example`](/home/ivan/xuan/nano_project/auth-portal/src/.env.example) -- [`authz-service/.env.example`](/home/ivan/xuan/nano_project/authz-service/.env.example) -- [`deploy-control/.env.example`](/home/ivan/xuan/nano_project/deploy-control/.env.example) -- [`router-proxy/.env.example`](/home/ivan/xuan/nano_project/router-proxy/.env.example) - -注意: - -- 这些文件是模板,不会被现有脚本自动加载 -- 你可以手动 `export`,或者在 `docker run` 时使用 `--env-file` - -## 部署前必须先定好的值 - -先准备这些值: - -- `PROJECT_ROOT` - - 仓库根目录。 -- `NANO_NET` - - Docker network 名。 - - 推荐固定成 `nano-instance-edge`。 -- `NANO_DEPLOY_TOKEN` - - `auth-portal` / `authz-service` 调 `deploy-control` 时用的 Bearer token。 -- `NANO_AUTHZ_INTERNAL_TOKEN` - - AuthZ 内部接口 token。 -- `NANO_SERVER_IP` - - 服务器公网 IP,供 `nip.io` 测试使用。 -- `NANO_BASE_DOMAIN` - - 实例基域名。 - - 测试环境推荐 `.nip.io` -- `NANO_PROVIDER` - - 默认 provider,例如 `openai` -- `NANO_MODEL` - - 默认模型,例如 `openai/gpt-5` -- `NANO_API_KEY` - - 默认分发给新实例的 provider API key -- `NANO_API_BASE` - - 可空,自定义 provider base URL -- `NANO_AUTHZ_URL` - - 这个值必须是 `app-instance` 容器能访问到的 AuthZ 地址 -- `NANO_OUTLOOK_MCP_URL` - - 可空。 - - 如果配置了,所有新创建的 `app-instance` 都会默认带一个 Outlook MCP HTTP 工具配置。 -- `NANO_OUTLOOK_MCP_SERVER_ID` - - Outlook MCP 默认 server id。 - - 推荐固定成 `outlook_mcp`。 -- `NANO_DEPLOY_URL` - - `auth-portal` 和 `authz-service` 在容器网络里访问 deploy-control 的地址 - -直接导出一套最小配置: +最小配置变量: ```bash -export PROJECT_ROOT=/home/ivan/xuan/nano_project -export NANO_NET=nano-instance-edge +export PROJECT_ROOT=/home/ivan/xuan/beaver_project +export BEAVER_NET=beaver-instance-edge -export NANO_DEPLOY_TOKEN="$(openssl rand -hex 32)" -export NANO_AUTHZ_INTERNAL_TOKEN="$(openssl rand -hex 32)" +export BEAVER_DEPLOY_TOKEN="$(openssl rand -hex 32)" +export BEAVER_AUTHZ_INTERNAL_TOKEN="$(openssl rand -hex 32)" -export NANO_SERVER_IP=203.0.113.10 -export NANO_BASE_DOMAIN="${NANO_SERVER_IP}.nip.io" +export BEAVER_BASE_DOMAIN=127.0.0.1.nip.io +export BEAVER_AUTHZ_URL='http://beaver-authz-service:19090' +export BEAVER_DEPLOY_URL='http://beaver-deploy-control:8090' -export NANO_PROVIDER=openai -export NANO_MODEL=openai/gpt-5 -export NANO_API_KEY='sk-xxxxxxxx' -export NANO_API_BASE='' - -export NANO_AUTHZ_URL='http://nano-authz-service:19090' -export NANO_OUTLOOK_MCP_URL='' -export NANO_OUTLOOK_MCP_SERVER_ID='outlook_mcp' -export NANO_DEPLOY_URL='http://nano-deploy-control:8090' +export BEAVER_OUTLOOK_MCP_URL='' +export BEAVER_OUTLOOK_MCP_SERVER_ID='outlook_mcp' ``` -## 变量到底是什么 +启动顺序: -最容易混淆的是下面这几组: +1. 创建运行目录。 +2. 构建四个镜像。 +3. 创建共享 Docker network。 +4. 启动 `router-proxy`。 +5. 启动 `authz-service`。 +6. 启动 `deploy-control`。 +7. 启动 `auth-portal`。 +8. 打开 `http://127.0.0.1:3081/register` 测试注册。 -- `DEPLOY_API_TOKEN` - - 调用方带出去的 token。 - - `auth-portal` 和 `authz-service` 会用它请求 `deploy-control`。 -- `DEPLOY_CONTROL_API_TOKEN` - - `deploy-control` 服务端校验的 token。 - - 它必须和 `DEPLOY_API_TOKEN` 相等。 -- `AUTHZ_ISSUER` - - 当前实现里它既是 AuthZ 的 issuer,也是新实例要访问的 AuthZ base URL。 - - 所以不要乱写 `127.0.0.1`,要写成实例容器能访问到的地址。 -- `APP_INSTANCE_PROVIDER` - - 新实例默认 provider。 -- `APP_INSTANCE_MODEL` - - 新实例默认模型。 -- `APP_INSTANCE_API_KEY` - - 新实例默认 API key。 -- `APP_INSTANCE_API_BASE` - - 新实例默认 API base。 -- `DEFAULT_AUTHZ_BASE_URL` - - `deploy-control` 在没收到显式 `authz_base_url` 时,给新实例写入的兜底 AuthZ 地址。 +## 关键配置关系 -当前版本里,provider 配置不是从 AuthZ setting 下发的。 -它是在创建实例时由 `deploy-control` 写入 `app-instance` 的 `config.json`。 +`DEPLOY_API_TOKEN` 和 `DEPLOY_CONTROL_API_TOKEN` 必须相等: -## 目录持久化 +- `auth-portal` / `authz-service` 用 `DEPLOY_API_TOKEN` 请求 `deploy-control`。 +- `deploy-control` 用 `DEPLOY_CONTROL_API_TOKEN` 校验请求。 -至少保留这几个目录: - -- `authz-service/runtime/data` -- `app-instance/runtime` -- `router-proxy/runtime` - -建议先创建: - -```bash -mkdir -p \ - "$PROJECT_ROOT/authz-service/runtime/data" \ - "$PROJECT_ROOT/app-instance/runtime/instances" \ - "$PROJECT_ROOT/app-instance/runtime/registry" \ - "$PROJECT_ROOT/router-proxy/runtime/conf.d" -``` - -## 1. 构建镜像 - -先把四个镜像都构建好。虽然 `deploy-control` 在镜像缺失时也能触发构建,但上线前先显式构建更容易排错。 - -```bash -cd "$PROJECT_ROOT" - -docker build -t nano/app-instance:latest app-instance -docker build -t nano/authz-service:latest authz-service -docker build -t nano/deploy-control:latest deploy-control -docker build -t nano/auth-portal:latest auth-portal/src -``` - -## 2. 创建共享网络 - -```bash -docker network inspect "$NANO_NET" >/dev/null 2>&1 || docker network create "$NANO_NET" -``` - -## 3. 启动 router-proxy - -```bash -cd "$PROJECT_ROOT" - -PROXY_NETWORK_NAME="$NANO_NET" \ -PROXY_HTTP_PORT=8088 \ -./router-proxy/start-proxy.sh --replace -``` - -默认对外入口: - -- `router-proxy`: `http://.:8088` - -## 4. 启动 authz-service - -```bash -docker rm -f nano-authz-service >/dev/null 2>&1 || true - -docker run -d \ - --name nano-authz-service \ - --restart unless-stopped \ - --network "$NANO_NET" \ - -p 19090:19090 \ - -v "$PROJECT_ROOT/authz-service/runtime/data:/var/lib/authz-service/data" \ - -e AUTHZ_ISSUER="$NANO_AUTHZ_URL" \ - -e AUTHZ_INTERNAL_TOKEN="$NANO_AUTHZ_INTERNAL_TOKEN" \ - -e DEPLOY_API_BASE_URL="$NANO_DEPLOY_URL" \ - -e DEPLOY_API_TOKEN="$NANO_DEPLOY_TOKEN" \ - nano/authz-service:latest -``` - -这里最重要的是: - -- `AUTHZ_ISSUER` 现在不能只按“外部访问地址”理解 -- 它必须是后续 `app-instance` 容器也能访问到的地址 -- 这套单机 Docker 方案里直接用 `http://nano-authz-service:19090` - -## 5. 启动 deploy-control - -`deploy-control` 需要高权限: - -- 读写 Docker socket -- 访问 `app-instance/` -- 访问 `router-proxy/` - -```bash -docker rm -f nano-deploy-control >/dev/null 2>&1 || true - -docker run -d \ - --name nano-deploy-control \ - --restart unless-stopped \ - --network "$NANO_NET" \ - -p 8090:8090 \ - -v /var/run/docker.sock:/var/run/docker.sock \ - -v "$PROJECT_ROOT/app-instance:$PROJECT_ROOT/app-instance" \ - -v "$PROJECT_ROOT/router-proxy:$PROJECT_ROOT/router-proxy" \ - -e APP_INSTANCE_DIR="$PROJECT_ROOT/app-instance" \ - -e ROUTER_PROXY_DIR="$PROJECT_ROOT/router-proxy" \ - -e DEPLOY_CONTROL_API_TOKEN="$NANO_DEPLOY_TOKEN" \ - -e APP_INSTANCE_IMAGE="nano/app-instance:latest" \ - -e APP_INSTANCE_NETWORK_NAME="$NANO_NET" \ - -e APP_INSTANCE_PROVIDER="$NANO_PROVIDER" \ - -e APP_INSTANCE_MODEL="$NANO_MODEL" \ - -e APP_INSTANCE_API_KEY="$NANO_API_KEY" \ - -e APP_INSTANCE_API_BASE="$NANO_API_BASE" \ - -e DEFAULT_AUTHZ_BASE_URL="$NANO_AUTHZ_URL" \ - -e DEFAULT_AUTHZ_OUTLOOK_MCP_URL="$NANO_OUTLOOK_MCP_URL" \ - -e DEFAULT_OUTLOOK_MCP_SERVER_ID="$NANO_OUTLOOK_MCP_SERVER_ID" \ - -e DEPLOY_PUBLIC_SCHEME="http" \ - -e DEPLOY_PUBLIC_BASE_DOMAIN="$NANO_BASE_DOMAIN" \ - -e DEPLOY_PUBLIC_PORT="8088" \ - -e DEPLOY_AUTO_START_PROXY="1" \ - nano/deploy-control:latest -``` - -这里不要把宿主机目录挂到容器内的另一个短路径,比如 `/app-instance`。 - -原因是 `deploy-control` 会通过挂载进来的 Docker socket 再去创建 `app-instance` 容器;这时传给 Docker 的 bind mount 源路径必须是宿主机真实路径。如果你把宿主机目录映射成容器内短路径,`create-instance.sh` 生成的挂载源就会变成错误路径,最终表现为: - -- 注册接口超时 -- `app-instance` 容器反复重启 -- 日志里出现 `Missing Boardware Genius config: /root/.beaver/config.json` - -当前版本里,新实例的默认大模型配置就是从这里分发的: - -- `APP_INSTANCE_PROVIDER` -- `APP_INSTANCE_MODEL` -- `APP_INSTANCE_API_KEY` -- `APP_INSTANCE_API_BASE` - -如果 `APP_INSTANCE_API_KEY` 没配,新用户注册时创建实例会直接失败。 - -## 6. 启动 auth-portal - -```bash -docker rm -f nano-auth-portal >/dev/null 2>&1 || true - -docker run -d \ - --name nano-auth-portal \ - --restart unless-stopped \ - --network "$NANO_NET" \ - -p 3081:3081 \ - -e AUTHZ_API_BASE_URL="$NANO_AUTHZ_URL" \ - -e DEPLOY_API_BASE_URL="$NANO_DEPLOY_URL" \ - -e DEPLOY_API_TOKEN="$NANO_DEPLOY_TOKEN" \ - nano/auth-portal:latest -``` - -当前页面入口: - -- `http://:3081` - -## 7. 上线前健康检查 - -先确认四个基础组件都起来了: - -```bash -curl http://127.0.0.1:19090/healthz -curl http://127.0.0.1:8090/healthz -curl -I http://127.0.0.1:3081 -docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' -``` - -再确认 `router-proxy` 在跑: - -```bash -docker logs --tail=50 nano-router-proxy -``` - -## 8. 首次注册验收 - -打开: +`AUTHZ_ISSUER` 在这套单机部署里要写容器网络地址: ```text -http://:3081/register +http://beaver-authz-service:19090 ``` -注册一个新用户后,预期结果是: +不要写成 `http://127.0.0.1:19090`,因为新创建的 `app-instance` 容器里的 `127.0.0.1` 指向它自己,不是 AuthZ 容器。 -1. `auth-portal` 调 `authz-service /portal/register` -2. `authz-service` 调 `deploy-control /api/instances/register` -3. `deploy-control` 创建一个新的 `app-instance` -4. `app-instance` 回调 AuthZ 完成 backend 身份初始化 -5. 浏览器被跳转到该实例自己的 URL - -同时你应该能看到: +`DEPLOY_PUBLIC_*` 决定新实例展示给用户的 URL: ```bash -cd "$PROJECT_ROOT" -./app-instance/list-instances.sh --json +DEPLOY_PUBLIC_SCHEME=http +DEPLOY_PUBLIC_BASE_DOMAIN=127.0.0.1.nip.io +DEPLOY_PUBLIC_PORT=8088 ``` -新实例 URL 形如: +本机测试时实例 URL 形如: ```text -http://.:8088 +http://alice.127.0.0.1.nip.io:8088 ``` -如果你前面用的是: +正式 HTTPS 域名通常改成: + +```bash +DEPLOY_PUBLIC_SCHEME=https +DEPLOY_PUBLIC_BASE_DOMAIN=apps.example.com +DEPLOY_PUBLIC_PORT=443 +``` + +实例 URL 形如: ```text -NANO_BASE_DOMAIN=.nip.io +https://alice.apps.example.com ``` -那么实例地址会像: +前提是你已经在项目外层把 `*.apps.example.com` 的 `80/443` 流量转发到 `router-proxy:8088`。 + +## 模型配置方式 + +当前版本不会在注册创建实例时写入模型 provider、model 或 API key。 + +流程是: + +1. 注册先创建一个不含模型凭证的实例。 +2. `auth-portal` 进入模型配置引导页。 +3. 用户确认后,Portal 调用 `deploy-control /api/instances/configure-provider`。 +4. `deploy-control` 写入该实例的 `config.json` 并重启对应容器。 + +如果用户跳过引导,实例仍会创建成功,但后续需要在实例内补齐 provider 配置后才能正常调用模型。 + +## 持久化目录 + +至少保留: ```text -http://alice.203.0.113.10.nip.io:8088 +authz-service/runtime/data +app-instance/runtime/instances +app-instance/runtime/registry +router-proxy/runtime/conf.d ``` -## 9. 常见问题 +不要在需要保留账号、实例或配置时删除这些目录。 -### 1. 为什么不要把 `AUTHZ_ISSUER` 写成 `http://127.0.0.1:19090` +## 模板文件 -因为 `app-instance` 容器里访问 `127.0.0.1` 只会打到它自己,不会打到 AuthZ 容器。 -在当前实现里,`AUTHZ_ISSUER` 会被直接传给新实例当作 `authz_base_url`。 +可参考这些环境变量模板: -### 2. `DEPLOY_API_TOKEN` 和 `DEPLOY_CONTROL_API_TOKEN` 为什么要一样 +- [`.env.example`](./.env.example) +- [`auth-portal/src/.env.example`](./auth-portal/src/.env.example) +- [`authz-service/.env.example`](./authz-service/.env.example) +- [`deploy-control/.env.example`](./deploy-control/.env.example) +- [`router-proxy/.env.example`](./router-proxy/.env.example) -因为一个是客户端发出去的 token,一个是服务端拿来校验的 token。 +这些模板不会被脚本自动加载。你可以手动 `export`,也可以在 `docker run` 时使用 `--env-file`。 -### 3. 这些 provider 配置是写到哪里 +## 子项目文档 -写到每个实例自己的: - -```text -app-instance/runtime/instances//beaver-home/config.json -``` - -不是写在 AuthZ 的某个 setting 里。 - -### 4. 现在支持每个用户注册时填自己的 API key 吗 - -后端请求模型已经支持 `provider/model/api_key/api_base` 字段,但当前 `auth-portal` 页面没有把这些字段暴露出来。 -当前上线流程默认是:所有新实例先继承 `deploy-control` 上配置的默认 provider 凭证。 - -### 5. 现在内置 HTTPS 吗 - -没有。 -当前内置 `router-proxy` 是 HTTP 入口。如果你要公网 HTTPS: - -- 在外面再放一层 Nginx / Caddy / LB 做 TLS 终止 -- 再把流量转给 `router-proxy:8088` 和 `auth-portal:3081` - -## 10. 仓库结构 - -```text -/home/ivan/xuan/nano_project -├── README.md -├── app-instance/ -├── auth-portal/ -├── authz-service/ -├── deploy-control/ -└── router-proxy/ -``` - -各子目录更细的实现说明见: - -- [`app-instance/README.md`](/home/ivan/xuan/nano_project/app-instance/README.md) -- [`auth-portal/src/README.md`](/home/ivan/xuan/nano_project/auth-portal/src/README.md) -- [`authz-service/README.md`](/home/ivan/xuan/nano_project/authz-service/README.md) -- [`deploy-control/README.md`](/home/ivan/xuan/nano_project/deploy-control/README.md) -- [`router-proxy/README.md`](/home/ivan/xuan/nano_project/router-proxy/README.md) +- [`app-instance/README.md`](./app-instance/README.md) +- [`auth-portal/src/README.md`](./auth-portal/src/README.md) +- [`authz-service/README.md`](./authz-service/README.md) +- [`deploy-control/README.md`](./deploy-control/README.md) +- [`router-proxy/README.md`](./router-proxy/README.md) diff --git a/app-instance/README.md b/app-instance/README.md index 149f7d6..3ec143b 100644 --- a/app-instance/README.md +++ b/app-instance/README.md @@ -45,14 +45,14 @@ runtime/registry/instances.json ### 1. 构建镜像 ```bash -docker build -t nano/app-instance:latest . +docker build -t beaver/app-instance:latest . ``` ### 2. 创建实例 ```bash ./create-instance.sh \ - --image nano/app-instance:latest \ + --image beaver/app-instance:latest \ --instance-id demo-001 \ --auth-username admin \ --auth-password 123456 \ diff --git a/app-instance/backend/beaver/engine/loader.py b/app-instance/backend/beaver/engine/loader.py index 35ff333..86362f6 100644 --- a/app-instance/backend/beaver/engine/loader.py +++ b/app-instance/backend/beaver/engine/loader.py @@ -260,7 +260,12 @@ class EngineLoader: review_service=review_service, publisher=skill_publisher, safety_checker=SkillDraftSafetyChecker( - allowed_tool_names={spec.name for spec in tool_registry.list_specs()} + allowed_tool_names={spec.name for spec in tool_registry.list_specs()}, + allowed_tool_prefixes={ + f"mcp_{server_id}_" + for server_id in self.config.tools.mcp_servers + if str(server_id).strip() + }, ), evaluator=SkillDraftEvaluator(run_memory_store), ) diff --git a/app-instance/backend/beaver/interfaces/web/app.py b/app-instance/backend/beaver/interfaces/web/app.py index 4568e5e..7537b1f 100644 --- a/app-instance/backend/beaver/interfaces/web/app.py +++ b/app-instance/backend/beaver/interfaces/web/app.py @@ -1437,6 +1437,15 @@ def create_app( raise HTTPException(status_code=404, detail="Safety report not found") return report.to_dict() + @app.post("/api/skills/{skill_name}/drafts/{draft_id}/safety") + async def recheck_skill_draft_safety(skill_name: str, draft_id: str, request: Request) -> dict[str, Any]: + loaded = get_agent_service(request).create_loop().boot() + try: + report = loaded.skill_learning_pipeline.check_safety(skill_name, draft_id) # type: ignore[union-attr] + except ValueError as exc: + raise _skill_draft_http_error(exc) from exc + return report.to_dict() + @app.get("/api/skills/{skill_name}/drafts/{draft_id}/eval") async def get_skill_draft_eval(skill_name: str, draft_id: str, request: Request) -> dict[str, Any]: loaded = get_agent_service(request).create_loop().boot() @@ -1831,6 +1840,7 @@ def create_app( "model": _clean_text(payload.get("model")) or None, "provider_name": _clean_text(payload.get("provider_name")) or None, "embedding_model": _clean_text(payload.get("embedding_model")) or None, + "max_tool_iterations": _int_or_none(payload.get("max_tool_iterations")), } websocket_thinking_enabled = _bool_or_none(payload.get("thinking_enabled")) if websocket_thinking_enabled is not None: @@ -1844,6 +1854,7 @@ def create_app( "content": f"Run failed before completion: {exc}", "session_id": session_id, "finish_reason": "error", + "tool_iterations": 0, "metadata": { "error": str(exc), "input_metadata": _websocket_input_metadata(payload), @@ -2403,6 +2414,15 @@ def _bool_or_none(value: Any) -> bool | None: return None +def _int_or_none(value: Any) -> int | None: + if value in (None, ""): + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + def _websocket_message_payload(result: Any, *, input_payload: dict[str, Any]) -> dict[str, Any]: validation_result = getattr(result, "validation_result", None) task_id = getattr(result, "task_id", None) @@ -2414,6 +2434,7 @@ def _websocket_message_payload(result: Any, *, input_payload: dict[str, Any]) -> "session_id": getattr(result, "session_id", None), "run_id": getattr(result, "run_id", None), "finish_reason": getattr(result, "finish_reason", None), + "tool_iterations": getattr(result, "tool_iterations", 0), "provider_name": getattr(result, "provider_name", None), "model": getattr(result, "model", None), "usage": dict(getattr(result, "usage", {}) or {}), diff --git a/app-instance/backend/beaver/skills/learning/evidence.py b/app-instance/backend/beaver/skills/learning/evidence.py index 9a62369..4a15f7d 100644 --- a/app-instance/backend/beaver/skills/learning/evidence.py +++ b/app-instance/backend/beaver/skills/learning/evidence.py @@ -42,6 +42,8 @@ class EvidenceSelector: resolved_session_ids: list[str] = list(dict.fromkeys(session_ids or [])) task_summaries: list[str] = [] session_excerpts: list[str] = [] + tool_names: list[str] = [] + selected_tool_names: list[str] = [] for run_id in run_ids: record = runs_by_id.get(run_id) if record is None: @@ -56,12 +58,19 @@ class EvidenceSelector: excerpt = self._session_excerpt(record.session_id, run_id) if excerpt: session_excerpts.append(excerpt) + run_tool_names, run_selected_tool_names = self._run_tool_names(record.session_id, run_id) + tool_names.extend(run_tool_names) + selected_tool_names.extend(run_selected_tool_names) return EvidencePacket( run_ids=resolved_run_ids, session_ids=resolved_session_ids, task_summaries=task_summaries[:8], session_excerpts=session_excerpts[:6], - metadata={"bounded": True}, + metadata={ + "bounded": True, + "tool_names": _unique_strings(tool_names), + "selected_tool_names": _unique_strings(selected_tool_names), + }, ) def _session_excerpt(self, session_id: str, run_id: str) -> str: @@ -74,3 +83,37 @@ class EvidenceSelector: continue visible.append(f"{event.role}: {event.content.strip()}") return "\n".join(visible[:12])[:2000] + + def _run_tool_names(self, session_id: str, run_id: str) -> tuple[list[str], list[str]]: + if self.session_manager is None: + return [], [] + + names: list[str] = [] + selected_names: list[str] = [] + for event in self.session_manager.get_run_event_records(session_id, run_id): + if event.tool_name: + names.append(event.tool_name) + if event.tool_calls: + for call in event.tool_calls: + if not isinstance(call, dict): + continue + name = call.get("name") + function = call.get("function") + if not name and isinstance(function, dict): + name = function.get("name") + if name: + names.append(str(name)) + if event.event_type == "tool_selection_snapshotted" and isinstance(event.event_payload, dict): + selected = event.event_payload.get("tool_names") + if isinstance(selected, list): + selected_names.extend(str(item) for item in selected if str(item).strip()) + return _unique_strings(names), _unique_strings(selected_names) + + +def _unique_strings(values: list[str]) -> list[str]: + result: list[str] = [] + for value in values: + cleaned = str(value).strip() + if cleaned and cleaned not in result: + result.append(cleaned) + return result diff --git a/app-instance/backend/beaver/skills/learning/safety.py b/app-instance/backend/beaver/skills/learning/safety.py index 778d55b..98b51e7 100644 --- a/app-instance/backend/beaver/skills/learning/safety.py +++ b/app-instance/backend/beaver/skills/learning/safety.py @@ -32,8 +32,14 @@ class SkillDraftSafetyChecker: "credentials", } - def __init__(self, *, allowed_tool_names: set[str] | None = None) -> None: + def __init__( + self, + *, + allowed_tool_names: set[str] | None = None, + allowed_tool_prefixes: set[str] | None = None, + ) -> None: self.allowed_tool_names = allowed_tool_names + self.allowed_tool_prefixes = allowed_tool_prefixes or set() def check(self, draft: SkillDraft) -> SkillDraftSafetyReport: issues: list[str] = [] @@ -50,7 +56,7 @@ class SkillDraftSafetyChecker: tool_hints = _tool_hints(frontmatter) if self.allowed_tool_names is not None: - unknown = [name for name in tool_hints if name not in self.allowed_tool_names] + unknown = [name for name in tool_hints if not self._is_allowed_tool_hint(name)] if unknown: blocked.append(f"unknown tool hints: {', '.join(sorted(unknown))}") dangerous = sorted({name for name in tool_hints if name.lower() in self._DANGEROUS_TOOL_HINTS}) @@ -80,6 +86,11 @@ class SkillDraftSafetyChecker: created_at=_utc_now(), ) + def _is_allowed_tool_hint(self, name: str) -> bool: + if self.allowed_tool_names is not None and name in self.allowed_tool_names: + return True + return any(name.startswith(prefix) and len(name) > len(prefix) for prefix in self.allowed_tool_prefixes) + def _tool_hints(frontmatter: dict) -> list[str]: raw = frontmatter.get("tools") diff --git a/app-instance/backend/beaver/skills/learning/synthesizer.py b/app-instance/backend/beaver/skills/learning/synthesizer.py index b667c93..353fc0b 100644 --- a/app-instance/backend/beaver/skills/learning/synthesizer.py +++ b/app-instance/backend/beaver/skills/learning/synthesizer.py @@ -65,19 +65,29 @@ class SkillDraftSynthesizer: ) payload = self._parse_payload(response.content or "") if payload: - return payload + return self._normalize_payload(payload, evidence_packet) return self._fallback_payload(candidate, evidence_packet, action) @staticmethod def _build_prompt(candidate: SkillLearningCandidate, evidence_packet: EvidencePacket, action: str) -> str: + tool_names = _coerce_string_list(evidence_packet.metadata.get("tool_names")) + tool_section = ", ".join(tool_names) if tool_names else "none observed" + selected_tool_names = _coerce_string_list(evidence_packet.metadata.get("selected_tool_names")) + selected_tool_section = ", ".join(selected_tool_names) if selected_tool_names else "none recorded" return ( f"Action: {action}\n" f"Candidate kind: {candidate.kind}\n" f"Reason: {candidate.reason}\n" f"Related skills: {candidate.related_skill_names}\n" + f"Called tool names: {tool_section}\n" + f"Run-selected tool names: {selected_tool_section}\n" f"Task summaries:\n- " + "\n- ".join(evidence_packet.task_summaries) + "\n\nSession excerpts:\n" + "\n\n".join(evidence_packet.session_excerpts) - + "\n\nReturn JSON only." + + "\n\nReturn JSON only. The frontmatter object must include:" + + "\n- description: a concise skill description" + + "\n- tools: an explicit JSON array of exact tool names this skill needs. " + + "Prefer called tool names when the workflow depends on them; use run-selected tool names only when clearly required. " + + "Use [] only when no tool is required." ) @staticmethod @@ -103,6 +113,19 @@ class SkillDraftSynthesizer: "change_reason": str(payload.get("change_reason") or ""), } + @staticmethod + def _normalize_payload(payload: dict[str, Any], evidence_packet: EvidencePacket) -> dict[str, Any]: + frontmatter = dict(payload.get("frontmatter") or {}) + tool_hints = _coerce_string_list(frontmatter.get("tools")) + if not tool_hints: + tool_hints = _coerce_string_list(evidence_packet.metadata.get("tool_names")) + frontmatter["tools"] = tool_hints + return { + "frontmatter": frontmatter, + "content": str(payload.get("content") or "").strip(), + "change_reason": str(payload.get("change_reason") or ""), + } + @staticmethod def _fallback_payload(candidate: SkillLearningCandidate, evidence_packet: EvidencePacket, action: str) -> dict[str, Any]: related = candidate.related_skill_names[0] if candidate.related_skill_names else "generated-skill" @@ -111,8 +134,25 @@ class SkillDraftSynthesizer: return { "frontmatter": { "description": candidate.reason or f"Auto-generated {action} draft for {title}.", - "tools": [], + "tools": _coerce_string_list(evidence_packet.metadata.get("tool_names")), }, "content": f"# {title}\n\n## Evidence\n\n{content}\n", "change_reason": candidate.reason or f"Fallback {action} synthesis.", } + + +def _coerce_string_list(value: Any) -> list[str]: + raw_items: list[Any] + if isinstance(value, list): + raw_items = value + elif isinstance(value, str): + raw_items = value.split(",") + else: + raw_items = [] + + result: list[str] = [] + for item in raw_items: + cleaned = str(item).strip() + if cleaned and cleaned not in result: + result.append(cleaned) + return result diff --git a/app-instance/backend/beaver/tasks/router.py b/app-instance/backend/beaver/tasks/router.py index e771e2d..b1f5d6c 100644 --- a/app-instance/backend/beaver/tasks/router.py +++ b/app-instance/backend/beaver/tasks/router.py @@ -26,38 +26,42 @@ class MainAgentRouter: ) -> MainAgentDecision: if provider is None: return self._fallback(active_task=active_task, reason="router_provider_unavailable") - try: - chat_kwargs: dict[str, Any] = { - "messages": [ - { - "role": "system", - "content": ( - "You are Beaver's Intent Agent. Your only job is to route the user's " - "message to simple chat or internal Task mode. Return only compact JSON. " - "Do not answer the user. Do not explain." - ), - }, - { - "role": "user", - "content": self._prompt( - message=message, - active_task=active_task, - recent_messages=recent_messages or [], - intent_skill=intent_skill, - ), - }, - ], - "tools": None, - "model": model, - "max_tokens": 256, - "temperature": 0.0, - } - if thinking_enabled is not None: - chat_kwargs["thinking_enabled"] = thinking_enabled - response = await asyncio.wait_for(provider.chat(**chat_kwargs), timeout=timeout_seconds) - return self.from_json(response.content or "", active_task=active_task) - except Exception as exc: - return self._fallback(active_task=active_task, reason=f"router_failed: {exc}") + chat_kwargs: dict[str, Any] = { + "messages": [ + { + "role": "system", + "content": ( + "You are Beaver's Intent Agent. Your only job is to route the user's " + "message to simple chat or internal Task mode. Return only compact JSON. " + "Do not answer the user. Do not explain." + ), + }, + { + "role": "user", + "content": self._prompt( + message=message, + active_task=active_task, + recent_messages=recent_messages or [], + intent_skill=intent_skill, + ), + }, + ], + "tools": None, + "model": model, + "max_tokens": 256, + "temperature": 0.0, + } + if thinking_enabled is not None: + chat_kwargs["thinking_enabled"] = thinking_enabled + + last_error: Exception | None = None + for attempt_timeout in (timeout_seconds, 12.0): + try: + response = await asyncio.wait_for(provider.chat(**chat_kwargs), timeout=attempt_timeout) + return self.from_json(response.content or "", active_task=active_task) + except Exception as exc: + last_error = exc + return self._fallback(active_task=active_task, reason=f"router_failed: {last_error}") def from_json(self, text: str, *, active_task: TaskRecord | None = None) -> MainAgentDecision: payload = self._parse_json_object(text) diff --git a/app-instance/backend/docs/architecture/backend-overview.md b/app-instance/backend/docs/architecture/backend-overview.md index b1a65a9..d44cc39 100644 --- a/app-instance/backend/docs/architecture/backend-overview.md +++ b/app-instance/backend/docs/architecture/backend-overview.md @@ -2,10 +2,46 @@ 这是新 `Beaver` 后端的架构入口文档。 +可视化入口: + +- [Beaver Backend 可视化](backend-visualization.html) + +## 给零基础读者的版本 + +可以先把这个后端理解成一个“帮用户完成任务的后台工厂”: + +1. 用户在前端发一句话。 +2. 后端入口把这句话接住。 +3. 服务层判断它是闲聊,还是一个需要执行和跟踪的任务。 +4. 如果是任务,系统会创建或继续一个 `Task`。 +5. 运行内核 `AgentLoop` 准备上下文、选择技能、选择工具、调用模型。 +6. 如果模型需要查文件、写文件、搜索或调用外部系统,就通过 `tools` 执行。 +7. 执行结果会写回会话、任务记录和运行记录,后续可以继续追踪、验证和学习。 + +这套结构里最重要的原则是:**所有 agent 共用同一个运行内核 `engine`**。也就是说,主 agent 和被拆出去的小 agent 不是两套系统,它们最终都会回到 `AgentLoop`,使用同一套上下文、工具、技能和记录方式。 + +## 先认识几个词 + +- `interfaces`:入口层。负责接收 Web、CLI、Gateway、MCP 等不同来源的请求。 +- `services`:应用服务层。负责把入口请求转成系统内部要做的事情。 +- `engine`:运行内核。真正组织 prompt、调用模型、执行 tool loop 的地方。 +- `coordinator`:多 agent 编排层。负责把复杂任务拆成 sequence、parallel 或 DAG。 +- `skills`:技能层。可以理解成给 agent 的专项说明书。 +- `tools`:工具层。可以理解成 agent 能按需调用的动作,例如读文件、写文件、搜索、执行命令。 +- `memory`:记忆层。保存会话、任务结果、运行记录、反馈和技能学习数据。 +- `permissions`:权限与治理层。负责约束哪些能力能用、怎么用。 + +## 一句话请求的流转 + +典型路径是: + +`interfaces` -> `AgentService` -> `MainAgentRouter` -> `TaskService` / `TaskExecutionPlanner` -> `AgentLoop` -> `skills` / `tools` / `memory` -> 返回用户。 + +如果任务很简单,可能只走单 agent。如果任务更复杂,`TaskExecutionPlanner` 可能先生成一个 team plan,让 `coordinator` 安排多个 sub-agent 分别处理,最后再由主 agent 综合输出。 + 当前约束: 1. 所有 agent 共用 `engine`。 2. 多 agent 编排进入 `coordinator`。 3. skills、memory、permissions 独立成能力层。 4. `interfaces` 只做薄入口。 - diff --git a/app-instance/backend/docs/architecture/backend-visualization.html b/app-instance/backend/docs/architecture/backend-visualization.html new file mode 100644 index 0000000..4cee15b --- /dev/null +++ b/app-instance/backend/docs/architecture/backend-visualization.html @@ -0,0 +1,1388 @@ + + + + + + Beaver Backend Visualization + + + +
+
+
+

Beaver Backend 可视化

+

基于 app-instance/backend 当前代码结构绘制,适合配合讲解使用:先给零基础读者建立概念,再逐页展开 Task、自学习、Skill/Tool、Memory/Session 和 Agent Team。

+
+
+ 项目对比分析 + 范围: backend + 入口: Web / CLI / Gateway / MCP + 核心: shared AgentLoop +
+
+ +
+
+ + +
+
+
+

先把它当成一个“帮用户完成任务的后台工厂”

+

如果你完全没接触过后端、agent、skills、tools,先看这一页。它不要求你理解代码细节。

+
+
+ +
+

整体理解:用户一句话如何变成后台执行

+
+
用户说需求网页、命令行或其他入口收到一句话
+
入口接住把请求整理成后端内部格式
+
判断类型普通聊天还是需要跟踪的任务
+
执行任务选择技能、工具并调用模型
+
保存过程记录 session、task、run 和反馈
+
返回结果把最终答复展示给用户
+
+
+ +
+
+

这个后端在做什么

+

用户在前端发一句话,后端负责判断这句话是不是一个任务、要不要查资料或改文件、需不需要拆给多个小助手处理,最后把结果和过程记录下来。

+
+
+

为什么要分这么多层

+

因为“接收请求”“决定怎么做”“调用模型”“使用工具”“保存记忆”“学习新技能”是不同职责。分开以后,问题更容易定位,也方便单独替换或测试。

+
+
+

最重要的一句话

+

所有真正执行 agent 的地方,最后都会回到同一个 AgentLoop。这保证主 agent 和 sub-agent 用同一套运行规则。

+
+
+

看图时抓住四类东西

+

入口负责接话,服务层负责安排,运行内核负责执行,能力层提供工具、技能、记忆和外部连接。

+
+
+ +
+
用户

在网页、命令行或其他渠道输入一句话,比如“帮我整理这个项目的后端架构”。

+
入口层

把这句话接住,整理成后端能理解的请求格式。

+
服务层

判断这是不是一个任务。如果只是闲聊,就简单回答;如果需要执行,就创建或继续一个 Task。

+
运行内核

准备上下文、挑选技能和工具、调用大模型,并在模型要求使用工具时执行工具。

+
能力层

提供文件读写、搜索、记忆、MCP、定时任务、外部集成等具体能力。

+
状态层

记录这次对话、任务、工具结果、验证结果和用户反馈,方便继续任务或沉淀经验。

+
+ +
+

Backend

前端背后的服务程序。用户看不见它的界面,但所有保存、调用模型、执行工具都在这里发生。

+

Agent

可以根据目标自己决定下一步动作的模型运行单元,不只是简单问答。

+

Task

一个需要跟踪结果的工作项,例如实现功能、分析代码、修复问题。

+

Skill

给 agent 的专门说明书,告诉它处理某类任务时应该遵循什么步骤和标准。

+

Tool

agent 可以调用的具体动作,比如读文件、写文件、搜索、执行命令、访问 MCP。

+

Memory

系统保存的历史、经验、任务结果和反馈,让后续运行可以参考。

+
+ +
+

Demo:一句普通需求如何被后台处理

+

适合开场讲解:先不讲代码,只讲用户输入、后台判断、最终输出。

+
+

输入

{
+  "user_message": "帮我总结 backend 的 Task 机制",
+  "channel": "web"
+}
+

后台理解

{
+  "is_task": true,
+  "reason": "需要阅读代码并产出解释",
+  "needs_tools": ["read_file", "search_files"],
+  "needs_memory": true
+}
+

预期输出

{
+  "answer": "Task 是一张可追踪工作单...",
+  "task_created": true,
+  "saved_to_session": true,
+  "can_continue_later": true
+}
+
+
+
+ +
+
+
+

模块分层与依赖方向

+

这一页只展示大模块位置和依赖方向,适合先建立全局地图。

+
+
+ 入口 + 服务 + 运行内核 + 能力 + 状态 +
+
+ +
+

四层架构:从入口到能力

+
+
InterfacesWeb、CLI、Gateway、MCP 等入口
+
Services把请求安排成内部工作流
+
Engine统一 AgentLoop 执行模型调用和工具循环
+
CapabilitiesSkills、Tools、Memory、Permissions
+
+
+ +
+
+

Interfaces 薄入口

+ +
+ +
+ +
+ +
+

Application Services

+ +
+ + + +
+ +
+

Shared Runtime

+ +
+ + + +
+ +
+

Capabilities + State

+ + + + +
+
+ +
+

Demo:同一个请求经过四层架构

+

用一条请求把每层职责串起来,说明每层只做自己的事。

+
+

输入

POST /api/chat
+{
+  "session_id": "web-demo-001",
+  "message": "请分析这个项目的后端结构"
+}
+

层级流转

{
+  "interfaces": "接收 HTTP 请求",
+  "services": "判断是否进入 Task mode",
+  "engine": "调用模型并执行工具",
+  "capabilities": "提供 skill、tool、memory"
+}
+

预期输出

{
+  "session_id": "web-demo-001",
+  "task_id": "task_arch_001",
+  "message": "后端可以分为入口层、服务层..."
+}
+
+
+
+ +
+
+
+

Task 的概念:从一句话到可跟踪工作

+

这一页围绕 Intent Agent、主 Agent、Sub-agent 和 Task 展示。讲解重点是:不是所有聊天都是 Task,只有需要执行、追踪和反馈的工作才进入 Task mode。

+
+
+ +
+

Task 概念图:谁负责什么

+
+
用户消息可能是闲聊,也可能是工作请求
+
Intent Agent只判断路由,不回答问题
+
Task需要追踪的工作单
+
主 Agent负责最终答案和整体一致性
+
Sub-agent只处理被拆出去的局部工作
+
验收反馈满意、修改或放弃都会更新任务状态
+
+
+ +
+
1

用户输入

用户可能只是问一句普通问题,也可能要求系统完成一件事,例如“分析后端架构”“修复测试”“整理技能文档”。

+
2

Intent Agent 判断意图

MainAgentRouter 只做路由,不回答用户。它判断当前消息是 simple_chatnew_taskcontinue_taskclose_task 还是 abandon_task

+
3

TaskService 创建或恢复任务

如果要进入 Task mode,TaskService 会创建 TaskRecord,保存目标、状态、run 列表、反馈、验证结果和事件流。

+
4

主 Agent 负责最终回答

主 Agent 通过 AgentLoop 运行,负责综合上下文、技能、工具结果和 team 输出,最终给用户一个完整答复。

+
5

Sub-agent 只处理局部工作

复杂任务会先交给任务规划器判断是否拆分。Sub-agent 处理调研、检查、实现片段等局部任务,结果再交回主 Agent 综合。

+
6

验证和反馈让 Task 闭环

每次 Task run 后会记录 validation。用户点击满意、要求修改或放弃时,会更新 Task 状态,并影响后续学习候选。

+
+ +
+

TaskRecord 里有什么

  • task_idsession_id、目标和描述。
  • status 表示 open、running、awaiting_feedback、closed 等状态。
  • run_ids 连接每次模型运行。
  • feedbackvalidation_result 记录结果质量。
+

Task CRUD

  • Create:TaskService.create_task
  • Read:GET /api/tasksGET /api/tasks/{task_id}
  • Update:start run、append run、record validation、add feedback。
  • Delete:DELETE /api/tasks/{task_id}
+

讲解抓手

  • Intent Agent 是分流员。
  • Task 是工作单。
  • 主 Agent 是总负责人。
  • Sub-agent 是临时分工人员。
  • Validation 和 feedback 是验收记录。
+
+ +
+

Demo:Task 从创建到等待反馈

+

这组数据可以用来解释 Task 不是一句回复,而是一张有状态的工作单。

+
+

输入

{
+  "session_id": "web-demo-001",
+  "message": "分析 backend 的 Task 机制,并给我讲解稿"
+}
+

关键数据格式

{
+  "router_decision": {
+    "action": "new_task",
+    "short_title": "Task机制讲解"
+  },
+  "task": {
+    "task_id": "task_001",
+    "status": "awaiting_feedback",
+    "run_ids": ["run_001"],
+    "skill_names": ["backend-reader"]
+  },
+  "validation_result": {
+    "accepted": true,
+    "score": 0.91
+  }
+}
+

预期输出

{
+  "task_id": "task_001",
+  "task_status": "awaiting_feedback",
+  "answer": "Task 机制可以理解为...",
+  "feedback_options": [
+    "satisfied",
+    "revise",
+    "abandon"
+  ]
+}
+
+
+
+ +
+
+
+

自学习:从 Task 反馈到 Skill 生命周期

+

这一页围绕 Task、Skill、自学习 Skill 的 CRUD 展开。讲解重点是:系统不会自动发布新技能,必须经过候选、草稿、安全检查、评估、审核和发布。

+
+
+ +
+

自学习闭环:成功经验如何变成可复用 Skill

+
+
Task 成功有结果、验证和用户满意反馈
+
Learning Candidate系统发现这次经验值得沉淀
+
Skill Draft生成一个待审核的技能草稿
+
Safety / Eval安全检查和效果评估
+
Review人工 approve 或 reject
+
Publish发布成可选择的新版本 Skill
+
+
+ +
+
1

Task 产生证据

每次 Task run 都会写入 run record、激活过的 skill receipt、tool 使用结果、validation 和用户反馈。

+
2

满意反馈触发候选

当用户反馈 satisfied 且 validation 通过时,SkillLearningService 可以基于这次成功经验生成 learning candidate。

+
3

候选生成 Draft

POST /api/skills/candidates/{candidate_id}/draft 会合成技能草稿,同时生成 safety report 和 eval report。

+
4

人工 Review

草稿可 submit、approve 或 reject。高风险内容不能静默发布,发布接口需要显式确认。

+
5

Publish / Disable / Rollback

通过后进入 published skill;后续可以 disable 或 rollback 到指定版本。Worker 只生成和评估,不自动 approve/publish。

+
+ +
+

Candidate CRUD

  • List:GET /api/skills/candidates
  • Read:GET /api/skills/candidates/{candidate_id}
  • Create:由满意 Task 反馈或 worker run-once 生成。
  • Update:合成 draft、regenerate draft、写 audit event。
+

Draft / Review CRUD

  • List:GET /api/skills/drafts
  • Read:draft、safety、eval 三类详情接口。
  • Update:submit、approve、reject。
  • Delete:底层 DraftService.delete_draft 支持删除草稿。
+

Published Skill CRUD

  • Create:publish 生成新版本。
  • Read:skills list、detail、version、file、download。
  • Update:disable、rollback。
  • Delete:DELETE /api/skills/{name} 删除或归档技能入口。
+
+ +
+

Demo:一次满意反馈如何变成 Skill 草稿

+

适合讲“自学习不是自动上线”,它只是生成候选和草稿,后面仍要审核。

+
+

输入

POST /api/chat/feedback
+{
+  "session_id": "web-demo-001",
+  "run_id": "run_001",
+  "feedback_type": "satisfied",
+  "comment": "这次后端讲解结构很好"
+}
+

关键数据格式

{
+  "candidate": {
+    "candidate_id": "cand_001",
+    "status": "candidate",
+    "trigger_run_id": "run_001",
+    "evidence": ["Task 通过验证", "用户满意"]
+  },
+  "draft": {
+    "draft_id": "draft_001",
+    "skill_name": "backend-explainer",
+    "status": "draft",
+    "proposal_kind": "new_skill"
+  }
+}
+

预期输出

{
+  "learning_candidate_created": true,
+  "draft_created": true,
+  "safety_report": "passed",
+  "eval_report": "passed",
+  "next_step": "submit_review"
+}
+
+
+
+ +
+
+
+

Skill 和 Tool:如何选技能、选工具,以及 Skill 里的 tool 字段

+

这一页关注 Skill 选择器、工具选择器,以及 skill 中工具提示字段如何影响工具暴露。

+
+
+ +
+

Skill / Tool 选择链路

+
+
任务描述本轮要解决的问题
+
Skill 召回embedding 找到候选 Skill
+
Skill 精选LLM 选择真正激活的 Skill
+
tool_hintsSkill 建议常用工具
+
Tool 选择always tools + hints + embedding top-k
+
Tool Schema只把选中的工具暴露给模型
+
+
+ +
+
1

Skill 选择器先看任务

SkillAssembler 读取所有可选 skill 摘要,用 embedding 召回候选,再让辅助模型从候选中选出本轮真正激活的 skill。

+
2

激活 Skill 变成上下文

被选中的 skill 会变成 SkillContext,写入 system prompt,并生成 SkillActivationReceipt 方便后续学习和审计。

+
3

Skill 的 tool_hints 影响工具选择

Skill 版本里有 tool_hints。如果某个 skill 明确提示需要 read_filepatch_file 等工具,工具选择器会优先加入这些工具。

+
4

工具选择器再补充工具

ToolAssembler 先加入 always tools,再加入 skill hints,再通过 embedding 从 registry 中召回更多相关工具。

+
5

ToolRegistry 导出 schema

ToolRegistry 不执行工具,只把选中的 ToolSpec 导出成 provider 可消费的 function schema。

+
6

ToolExecutor 执行调用

模型返回 tool call 后,ToolExecutor 执行工具,并把 tool result 写回 session history,供下一轮模型继续使用。

+
+ +
+

Skill 中关键字段

  • name:技能唯一名。
  • description:选择器理解技能用途。
  • content:真正注入给 agent 的指导正文。
  • tool_hints:建议本 skill 常用哪些工具。
  • tagsowners:分类和治理信息。
+

ToolSpec 中关键字段

  • name:模型调用函数名。
  • description:模型理解工具何时可用。
  • input_schema:参数结构。
  • toolset:所属工具集。
  • always_available:是否每轮都暴露。
+

选择顺序

  • Skill:候选召回 -> LLM shortlist -> LLM final -> 注入上下文。
  • Tool:always tools -> skill tool_hints -> embedding top-k。
  • 最终:只把选中的工具 schema 给模型,减少噪音和误用。
+
+ +
+

Demo:Skill 选择器和工具选择器如何配合

+

用一个 Skill 的 frontmatter 解释 tool_hints 为什么会影响本轮可用工具。

+
+

输入

{
+  "task_description": "阅读 backend 代码并解释 Task 机制"
+}
+

关键数据格式

---
+name: backend-explainer
+description: Explain backend architecture
+tool_hints:
+  - search_files
+  - read_file
+  - session_search
+---
+先定位入口,再追踪服务层和运行内核。
+

预期输出

{
+  "activated_skills": [
+    "backend-explainer"
+  ],
+  "selected_tools": [
+    "memory",
+    "session_search",
+    "search_files",
+    "read_file"
+  ],
+  "tool_schema_count": 4
+}
+
+
+
+ +
+
+
+

Memory 和 Session:CRUD、装载与运行时快照

+

这一页解释会话、记忆和运行记录如何创建、读取、更新、归档,以及 AgentLoop 如何装载它们。

+
+
+ +
+

Memory / Session 装载和写回

+
+
EngineLoader创建状态组件
+
Session保存对话消息和事件
+
Memory Snapshot每次 run 捕获一份快照
+
AgentLoop用快照组装上下文并执行
+
Run Memory记录运行证据和 skill effect
+
Feedback反馈反写到任务和学习数据
+
+
+ +
+
1

EngineLoader 装载状态组件

EngineLoader.load 创建 SessionManager、curated MemoryStoreMemoryServiceRunMemoryStoreSkillLearningStore

+
2

Session 保存对话过程

每次 run 都会确保 session 存在,并追加 system、user、assistant、tool 等消息,同时记录 event payload、usage、run_id 和上下文可见性。

+
3

MemoryService 捕获快照

AgentLoop 每次运行都会调用 capture_snapshot_for_run。并行 team run 各拿自己的 frozen snapshot,避免互相覆盖。

+
4

RunMemoryStore 保存运行证据

run record 记录输入、输出、激活 skill、验证、反馈;skill effect 记录某个 skill 在这次 run 中的效果。

+
5

API 提供 Session CRUD

前端通过 sessions API 创建、查看、归档会话,也可以读取 session process 和 debug chat logs。

+
+ +
+

Session CRUD

  • Create:POST /api/sessions/{session_id} 或 run 时 ensure_session
  • Read:GET /api/sessionsGET /api/sessions/{session_id}
  • Update:append_messageupdate_usageupdate_system_prompt
  • Delete/Archive:DELETE /api/sessions/{session_id} 或 archive。
+

Memory / Run CRUD

  • Create:append run record、append skill effect、curated memory item。
  • Read:session search、memory tool、run list、skill effects。
  • Update:validation、feedback、success ratio、performance snapshot。
  • Archive:通过 session end reason 或 skill/version 状态保留历史。
+

装载顺序

  • 配置解析 workspace。
  • 初始化 curated memory store。
  • 创建 session 和 run stores。
  • 注入 ToolContext services。
  • 运行时按 run 捕获 memory snapshot。
+
+ +
+

Demo:Session、Message、Memory Snapshot 的关系

+

适合讲“为什么能继续对话”,以及“为什么并行 sub-agent 不会互相污染记忆”。

+
+

输入

POST /api/sessions/web-demo-001
+
+POST /api/chat
+{
+  "session_id": "web-demo-001",
+  "message": "继续解释刚才的 Task"
+}
+

关键数据格式

{
+  "session": {
+    "id": "web-demo-001",
+    "source": "web",
+    "message_count": 12
+  },
+  "message": {
+    "role": "user",
+    "run_id": "run_002",
+    "content": "继续解释刚才的 Task"
+  },
+  "memory_snapshot": {
+    "captured_for_run": "run_002",
+    "frozen": true
+  }
+}
+

预期输出

{
+  "session_loaded": true,
+  "history_used": true,
+  "run_id": "run_002",
+  "assistant_message_saved": true
+}
+
+
+
+ +
+
+
+

一次用户请求如何流过后端

+

从 WebSocket/HTTP 进入,到 AgentLoop 选择 skills/tools,最后写回 session 和 run memory。

+
+
+ +
+

请求主链:技术视角

+
+
HTTP / WS接口收到用户请求
+
AgentService统一收口 direct / running 模式
+
Router判断 simple chat 或 Task mode
+
Planner选择 single 或 team
+
AgentLoop模型调用和工具循环
+
Validation记录验证、反馈和学习证据
+
+
+ +
+
1

接口接收请求

interfaces/web/app.py 提供 /api/chat/ws/{session_id}、tasks、files、skills、cron 等入口,只做参数转换和响应拼装。

+
2

AgentService 收口

接口层不直接 new loop,而是调用 AgentService.process_directsubmit_direct,由 service 管理 direct/running mode。

+
3

Intent Agent 路由

MainAgentRouter 用辅助模型判断 simple chat、继续任务、新建任务、关闭或放弃任务;simple chat 会关闭 skill/tool assembly。

+
4

Task mode 规划

TaskService 创建或恢复任务,任务规划器决定单 agent 还是 team;team 模式先运行 sub-agent,再交给主 agent synthesis。

+
5

EngineLoader 装配运行时

统一加载 session manager、memory service、skills loader、tool registry、tool executor、skill learning pipeline、agent registry、MCP manager。

+
6

AgentLoop 运行主链

捕获 memory snapshot,组装 prompt,选择 skills 和 tools,调用 provider;若返回 tool calls,则执行 tool loop 并把结果追加回上下文。

+
7

验证、反馈和学习沉淀

Task 结果进入 validation 和 feedback gate;满意反馈会更新 run record、skill effects,并可能生成 assisted learning candidate。

+
+ +
+

Demo:完整 /api/chat 输入输出

+

这可以作为接口级例子:用户输入什么,后端可能返回什么。

+
+

输入

POST /api/chat
+{
+  "session_id": "web-demo-001",
+  "message": "帮我画出 Task、自学习、Skill 和 Memory 的关系",
+  "thinking_enabled": true
+}
+

关键内部事件

[
+  {"event_type": "intent_agent_decision", "action": "new_task"},
+  {"event_type": "task_execution_planned", "mode": "team"},
+  {"event_type": "skill_activation_snapshotted", "skills": ["backend-explainer"]},
+  {"event_type": "tool_selection_snapshotted", "tools": ["search_files", "read_file"]},
+  {"event_type": "task_validation_snapshotted", "accepted": true}
+]
+

预期输出

{
+  "session_id": "web-demo-001",
+  "run_id": "run_003",
+  "task_id": "task_002",
+  "task_status": "awaiting_feedback",
+  "message": "可以把这四者理解为..."
+}
+
+
+
+ +
+
+
+

Agent Team:什么时候拆分、怎么编排、如何回到主 Agent

+

这一页展示 Agent Team 的完整概念:注册的 agent 能力、Task planner 的 team plan、ExecutionGraph 的三种策略、每个节点的 skill 绑定,以及最终 synthesis。

+
+
+ +
+

Agent Team 执行图

+
+
Task复杂工作进入任务模式
+
Team PlanPlanner 生成执行图
+
Skill Binding为每个节点绑定 Skill 或临时指导
+
Scheduler按 sequence、parallel 或 DAG 推进
+
Sub-agents节点回到同一个 AgentLoop 执行
+
Main Synthesis主 Agent 综合所有节点输出
+
+
+ +
+
1

先判断是否值得组队

任务规划器只在独立调研、评审、实现切片、分阶段检查能明显提升结果时选择 team,否则保持 single agent。

+
2

生成 ExecutionGraph

team plan 会生成 ExecutionGraph,包含 strategy、nodes、node task、depends_on、expected_output 和 skill_query。

+
3

为节点绑定 Skill

TaskSkillResolver 为每个 node 选择 published skill;如果没有合适 skill,就合成 one-run ephemeral guidance。

+
4

LocalAgentRunner 执行节点

每个 sub-agent 节点都会通过 LocalAgentRunner 调回同一个 AgentLoop,只是 session_id、source、execution_context 不同。

+
5

主 Agent 综合输出

Team 的 node outputs 不直接作为最终答案,而是进入主 Agent 的 synthesis context,由主 Agent 负责最终一致性、取舍和用户回答。

+
+ +
+
+

Sequence

+
+ node Anode Bmain synthesis +
+

上游输出作为下游 dependency output;任一节点失败,后续节点被标记 blocked。

+
+
+

Parallel

+
+ node Anode Bnode Csynthesis +
+

适合独立调研、并行检查或拆分实现面;由 asyncio.gather 并发运行。

+
+
+

DAG

+
+ researchdesignreview +
+

显式 depends_on 控制依赖,scheduler 按 ready batch 推进并检测循环依赖。

+
+
+

Skill Binding

+
+ skill querypublished skill/ephemeral guidance +
+

TaskSkillResolver 优先 pin 已发布 skill;缺失时合成 one-run guidance,不自动发布。

+
+
+ +
+

Agent Team CRUD

  • List:GET /api/agentsGET /api/subagents
  • Create:POST /api/agentsPOST /api/subagents
  • Update:PATCH /api/agents/{agent_id}PUT /api/subagents/{agent_id}、disable。
  • Delete:DELETE /api/agents/{agent_id}DELETE /api/subagents/{agent_id}
+

节点输入里有什么

  • 父 task id 和父 run id。
  • 节点自己的 task 和 expected output。
  • 依赖节点的输出。
  • pinned skills 或 ephemeral guidance。
  • 节点角色、约束和 skill selection context。
+

失败如何处理

  • Sequence 中前序失败会阻塞后续节点。
  • DAG 中依赖失败会阻塞依赖它的节点。
  • Parallel 节点独立运行,失败会进入 team summary。
  • 主 Agent 会看到失败摘要,再决定如何向用户说明。
+
+ +
+

Demo:Agent Team 把复杂任务拆成三个节点

+

这个例子适合讲 sequence、parallel、DAG 之前先让用户理解“节点输出最后还要由主 Agent 综合”。

+
+

输入

{
+  "task_id": "task_002",
+  "user_message": "检查后端架构讲解是否完整,并补上缺口"
+}
+

关键数据格式

{
+  "mode": "team",
+  "strategy": "dag",
+  "nodes": [
+    {"node_id": "read", "task": "阅读现有文档", "depends_on": []},
+    {"node_id": "check", "task": "检查缺失主题", "depends_on": ["read"]},
+    {"node_id": "draft", "task": "补写讲解内容", "depends_on": ["check"]}
+  ]
+}
+

预期输出

{
+  "team_success": true,
+  "node_results": [
+    {"node_id": "read", "success": true},
+    {"node_id": "check", "success": true},
+    {"node_id": "draft", "success": true}
+  ],
+  "main_synthesis": "已补充 Task、自学习、Skill/Tool、Memory/Session 和 Agent Team 示例。"
+}
+
+
+
+ +
+
+
+

能力层和状态边界

+

这些层围绕 AgentLoop 工作,但各自保持独立职责。

+
+
+
+
+

Skills

+
    +
  • SkillsLoader 读取 published skill 和 selection candidates。
  • +
  • SkillAssembler 通过 embedding 召回和 LLM 决策激活 skill。
  • +
  • draft、review、publisher、learning pipeline 支持候选生成、审核和发布。
  • +
+
+
+

Tools

+
    +
  • ToolRegistry 只负责注册、查找和导出 provider schema。
  • +
  • ToolAssembler 结合 always tools、skill hints 和 embedding top-k。
  • +
  • ToolExecutor 执行 tool call,结果写入 session history。
  • +
+
+
+

Memory

+
    +
  • 每个 run 捕获自己的 frozen memory snapshot,避免并行 team run 串扰。
  • +
  • session manager 保存消息、事件 payload、usage、归档状态。
  • +
  • run memory 保存 receipts、validation、feedback、skill effects。
  • +
+
+
+

Providers

+
    +
  • ProviderBundle 分离 main、auxiliary、embedding runtime。
  • +
  • router、planner、skill selection 可走 auxiliary provider。
  • +
  • 主回答、tool loop 和 synthesis 走 main provider。
  • +
+
+
+

Integrations

+
    +
  • MCP manager 连接外部 MCP server,并把 tools 注册进 registry。
  • +
  • Outlook、WhatsApp、A2A、authz 保持在 integrations 边界内。
  • +
  • Web API 只暴露集成状态、连接测试和数据读取入口。
  • +
+
+
+

Cron

+
    +
  • CronService 在 FastAPI lifespan 中随 AgentService 启停。
  • +
  • notification 生成通知结果,task mode 生成可反馈的 TaskRecord。
  • +
  • 用户 engage 后可把定时结果转成继续编辑的任务。
  • +
+
+
+ +
+

Demo:一次运行会同时用到哪些能力

+

适合收尾时复盘:一次 Task 运行不是只调用模型,而是多个能力层一起工作。

+
+

输入

{
+  "task": "生成后端架构讲解页",
+  "session_id": "web-demo-001"
+}
+

能力调用

{
+  "providers": ["main", "auxiliary", "embedding"],
+  "skills": ["backend-explainer"],
+  "tools": ["search_files", "read_file", "write_file"],
+  "memory": ["session_history", "run_record"],
+  "integrations": ["mcp_optional"]
+}
+

预期输出

{
+  "artifact": "backend-visualization.html",
+  "session_updated": true,
+  "run_memory_written": true,
+  "skill_effect_recorded": true
+}
+
+
+
+
+ +
+
+ + + + diff --git a/app-instance/backend/docs/architecture/project-comparison.html b/app-instance/backend/docs/architecture/project-comparison.html new file mode 100644 index 0000000..668e283 --- /dev/null +++ b/app-instance/backend/docs/architecture/project-comparison.html @@ -0,0 +1,1071 @@ + + + + + + Agent 项目对比分析 + + + +
+
+
+

Agent 项目对比分析

+

用 OpenHarness、Hermes Agent、OpenClaw 对比 Beaver,面向讲解场景覆盖产品定位、架构结构、Skill、Tool、Session、Channel、权限、安全、自学习和 Agent Team。

+
+
+ Snapshot: 2026-05-20 + 来源: GitHub README + repo tree + 本地代码 + 返回 Beaver 架构页 +
+
+ +
+ + +
+
+
+

首页总览

+

先用四张卡片建立直觉:它们都在做 agent,但产品路线和架构重心不同。

+
+
+ +
+

讲解目标

+
+
产品定位谁是目标用户,核心体验是什么
+
运行架构入口、服务、agent loop、状态层如何分工
+
能力子系统Skill、Tool、Memory、Channel、Permission
+
差异优劣哪些成熟,哪些值得借鉴
+
Beaver 位置解释你的项目选择的路线
+
+
+ +
+
+

HKUDS/OpenHarness

+

Agent harness 基础设施 + ohmo personal agent。适合讲“LLM 要变成 agent,需要工具、技能、记忆、权限、MCP 和多 agent 外壳”。

+
+ PythonCLI / gateway / harnessMIT + agent loop43+ toolsswarm +
+
+
+

NousResearch/hermes-agent

+

自学习个人 agent。适合讲“长期助理如何跨会话记忆、从任务中生成技能、用多渠道和 cron 变成常驻助手”。

+
+ PythonCLI / TUI / gateway / cronMIT + closed learning loopsession searchsubagents +
+
+
+

openclaw/openclaw

+

Local-first 多渠道个人助手。适合讲“产品化 gateway、设备节点、消息渠道、WebChat、Canvas 和安全默认值”。

+
+ TypeScriptgateway / apps / nodes / canvasMIT + multi-channellocal-firstsandbox +
+
+
+

Beaver

+

单用户实例化 Web agent 工作台。适合讲“Web 管理面 + Task 工作单 + 审核型 Skill Learning + Agent Team”。

+
+ Python + Next.jsWeb app + per-user instance + Task modeSkill LearningAuthZ +
+
+
+ +
讲解建议:不要先陷入代码目录。先让听众理解四条路线:OpenHarness 是“基础设施”,Hermes 是“自学习长期助理”,OpenClaw 是“本地优先多渠道产品”,Beaver 是“Web 管理 + 可审核任务工作台”。
+
+ +
+
+
+

产品定位

+

这一页回答:谁会用、从哪里进入、产品强弱在哪里。

+
+
+ +
+
+

适合谁用

+
    +
  • OpenHarness:研究者、agent harness builder、CLI agent 集成开发者。
  • +
  • Hermes:想要长期个人助理、自学习、多渠道自动化的个人或团队。
  • +
  • OpenClaw:想要本地优先、多设备、多消息渠道个人助手的用户。
  • +
  • Beaver:想要 Web 化、可部署、可管理、可审核的单用户 agent 工作台。
  • +
+
+
+

主体验入口

+
    +
  • OpenHarnessoh / ohmo CLI + gateway。
  • +
  • Hermeshermes TUI、messaging gateway、cron。
  • +
  • OpenClawopenclaw onboard、gateway、apps/nodes、WebChat、Canvas。
  • +
  • Beaver:auth portal -> per-user app instance -> Web chat/workbench/settings/skills/tasks。
  • +
+
+
+

产品优劣

+
    +
  • OpenHarness:概念清晰、基础设施强,但偏工程/框架导向。
  • +
  • Hermes:自学习叙事最强,但系统面广、结构复杂。
  • +
  • OpenClaw:产品触点最多,但平台集成复杂、讲架构成本高。
  • +
  • Beaver:Web 管理和 Task 工作流清楚,但多渠道和生态成熟度弱。
  • +
+
+
+ +
+

四条路线

+
+
OpenHarness先做 agent harness,再在其上做 ohmo personal agent
+
Hermes先做长期助理体验,再强调自学习闭环
+
OpenClaw先做 local-first gateway 和多平台触点
+
Beaver先做 Web 管理、Task 工作单和审核型学习
+
+
+
+ +
+
+
+

总功能矩阵

+

横向扫一遍能力覆盖。重点看 Beaver 的强项和短板分别落在哪些行。

+
+
+ +
+ + + + + + + + + + + + + + + + +
能力OpenHarnessHermesOpenClawBeaver
Agent Loop有,harness 核心;强调 streaming tool-call cycle 和并行工具执行。有,conversation loop 是长期助理运行核心。有,agent runtime 嵌入 gateway、apps、nodes 场景。有,统一 AgentLoop,主 agent 和 sub-agent 共用。
Task 工作单有 tasks 目录和 autopilot/test 迹象,但不是核心产品叙事。有 kanban、goals、cron 等任务形态。有 session/agent message task 形态,偏产品会话和路由。强核心:TaskRecord、validation、feedback、active task。
Skill 系统bundled/user skills、frontmatter、plugin ecosystem。optional skills、Skills Hub、self-improving skills。workspace/bundled/managed skills、ClawHub。versioned skill、draft/review/publish/rollback、learning candidate。
Tool 系统43+ tools、MCP、parallel tool execution。40+ tools、toolsets、terminal backends、RPC。first-class tools、browser/canvas/nodes/cron/sessions。ToolRegistry、ToolAssembler、ToolExecutor、builtins、MCP wrapper。
MemoryMEMORY.md、memdir、session memory。agent-curated memory、FTS5 session search、user modeling。workspace memory/session model。curated memory、run memory、skill learning memory。
Channel/Gatewayohmo channels:Feishu、Slack、Telegram、Discord 等。Telegram、Discord、Slack、WhatsApp、Signal、Email gateway。超多渠道和 device nodes。当前主要 Web/WebSocket,另有 gateway/channels 基础。
Permissionspermissions checker、sandbox、hooks。tool guardrails、approval、terminal backend isolation。DM allowlist、sandbox non-main sessions、security defaults。authz integration、permission packages 还较空、MCP authz config。
Team/Sub-agentswarm、subagent、delegation。subagents、parallel workstreams。multi-agent routing / isolated agents。sequence/parallel/DAG ExecutionGraph
自学习有 memory/skills,但不是最强叙事。强:closed learning loop。有 skills registry,但自学习不是主线。强:candidate -> draft -> safety/eval -> review -> publish。
Web 管理面dashboard/terminal frontend 有,但非主线。website/web/TUI/gateway。control UI / WebChat / Canvas。强:tasks、skills、MCP、settings、files、logs。
+
+
+ +
+
+
+

Skill 装载、选择、版本和学习

+

重点讲 Beaver 的优势:不仅能选 skill,还把 skill 当成可审核、可发布、可回滚的资产。

+
+
+ +
+

OpenHarness

  • 目录迹象:src/openharness/skills.claude/skills.agents/skills
  • 形态:bundled skills、agent skills、plugin skills。
  • 优点:兼容 skill 文件生态,轻量易扩展。
  • 短板:审核/发布工作流不如 Beaver 明确。
+

Hermes

  • 目录迹象:skillsoptional-skillsagent/skill_*hermes_cli/skills_*
  • 形态:内置技能、可选技能、Skills Hub、迁移 OpenClaw skills。
  • 优点:技能生态和 self-improving 叙事强。
  • 短板:入口多,讲清最终选择链路成本高。
+

OpenClaw

  • 目录迹象:skills.agents/skills、docs skills、workspace skills。
  • 形态:bundled/managed/workspace skills,配合 ClawHub。
  • 优点:产品化技能注册和分发强。
  • 短板:技能系统与 gateway/apps/nodes 强绑定。
+

Beaver

  • 形态:SkillSpecStoreSkillsLoader、published versions、drafts、reviews。
  • 优点:上传/安装 -> 版本 -> draft -> review -> publish/disable/rollback 生命周期清楚。
  • 短板:外部生态和 marketplace 成熟度不如 OpenClaw/Hermes。
+
+ +
+

Beaver Skill 装载流程

+
+
workspace skill store技能文件和版本索引
+
SkillsLoader.list读取 published skill
+
selection candidates构建候选摘要
+
SkillAssemblerembedding + LLM 选择
+
SkillContext注入本轮上下文
+
ActivationReceipt写入激活审计记录
+
+
+ +
+ + + + + + + + + +
阶段OpenHarnessHermesOpenClawBeaver
发现 skillloader/registryskill bundles/config/hubworkspace/managed registrySkillsLoader
候选召回skill registry / prompt matchingskill preprocessing / commandsregistry/configembedding retriever
最终选择agent/runtime 决策agent skill utilitiesagent/tooling runtimeLLM shortlist + final select
注入方式prompt/contextprompt/contextprompt/contextSkillContext + activation messages
审计记录有测试/日志迹象有 memory/trajectory有 runtime/logsexplicit activation receipt
+
+ +
+ + + + + + + + +
CRUDBeaver 讲解重点
Createupload、marketplace install、learning candidate synthesize draft
Readlist/detail/version/file/download
Updateregenerate draft、submit、approve、reject、publish、disable、rollback
Deletedelete published skill / draft delete 底层能力
+
+ +
+

Demo:Skill 选择

+

这个例子适合讲“skill 不是手动塞进去,而是根据任务语义被选择并记录”。

+
+

输入

{
+  "task_description": "解释后端 Task 机制",
+  "available_skills": [
+    "backend-explainer",
+    "code-review",
+    "filesystem"
+  ]
+}
+

内部选择

{
+  "retrieved_candidates": [
+    "backend-explainer",
+    "filesystem"
+  ],
+  "llm_final_select": [
+    "backend-explainer"
+  ]
+}
+

预期输出

{
+  "activated_skills": [
+    "backend-explainer"
+  ],
+  "activation_reason": "llm_selected",
+  "tool_hints": [
+    "search_files",
+    "read_file"
+  ]
+}
+
+
+
+ +
+
+
+

Tool 装载、选择、执行和 MCP

+

重点讲 Beaver 的 ToolRegistry / ToolAssembler / ToolExecutor 三段式边界。

+
+
+ +
+

OpenHarness

43+ tools,覆盖 file、shell、search、web、MCP,并强调 parallel tool execution。

+

Hermes

40+ tools、toolsets、terminal backends、RPC、browser/tools config,工具体系覆盖面广。

+

OpenClaw

first-class tools,覆盖 browser、canvas、nodes、cron、sessions 和 channel actions。

+

Beaver

built-in tools + MCP tools + registry + assembler + executor,运行链路边界更容易讲清。

+
+ +
+

Beaver Tool 装载流程

+
+
EngineLoader创建工具运行时
+
ToolRegistry注册 built-in tools
+
MCP Manager连接外部 MCP server
+
ToolAssembleralways + skill hints + embedding
+
Provider Schema导出 function schema
+
ToolExecutor执行 tool call 并写回 session
+
+
+ +
+ + + + + + + + + +
阶段OpenHarnessHermesOpenClawBeaver
内置注册tools packagetools/toolsetspackages/extensions/toolsEngineLoader.register_many
外部工具MCP clientMCP serve/configplugin/extensions/process toolingMCP manager
工具选择harness runtimetoolsets + config + guardrailsgateway/runtime tool exposurealways tools + skill hints + embedding top-k
执行tool call cycle / paralleltool executor / terminal backendruntime/extensions/actionsToolExecutor.execute_tool_call
审计logs/hookstrajectory/tool classificationlogs/security toolingsession tool_result + run memory
+
+ +
讲解重点:tool_hints 不是直接执行工具。它只告诉工具选择器“这个 skill 常用哪些工具”。最终工具列表仍由 ToolAssembler 汇总 always tools、skill hints 和 embedding top-k。
+ +
+

Demo:Skill 中 tool 字段影响工具选择

+
+

Skill frontmatter

---
+name: backend-explainer
+description: Explain backend architecture from source files
+tool_hints:
+  - search_files
+  - read_file
+  - session_search
+---
+先定位入口,再追踪 service、engine、memory、skills。
+

工具选择器输入

{
+  "task": "解释后端 Task 机制",
+  "activated_skills": [
+    "backend-explainer"
+  ],
+  "always_tools": [
+    "memory",
+    "session_search"
+  ]
+}
+

预期工具选择

{
+  "always_tools": ["memory", "session_search"],
+  "from_skill_hints": ["search_files", "read_file"],
+  "from_embedding": ["list_directory"],
+  "final_tools": [
+    "memory",
+    "session_search",
+    "search_files",
+    "read_file",
+    "list_directory"
+  ]
+}
+
+
+
+ +
+
+
+

Session / Memory 管理

+

对比谁更擅长跨会话、谁更擅长运行证据、谁更擅长多渠道隔离。

+
+
+ +
+

OpenHarness

  • session_storagesession_backendchannels/bus
  • 偏 harness + channel runtime,支持 ohmo 长会话。
+

Hermes

  • gateway/session.py、conversation loop、session recap、FTS5 session search。
  • 强在跨会话检索、recap、conversation continuity。
+

OpenClaw

  • session model 是产品核心之一,gateway route 到 isolated agents/sessions。
  • 强在多渠道、多设备下 session 和 peer/account 隔离。
+

Beaver

  • SessionManager + SQLite/store + Web session API。
  • 每次 run 记录 system/user/assistant/tool/event payload。
  • Task 可通过 session_id 找 active task。
+
+ +
+ + + + + + + + + +
Memory 类型OpenHarnessHermesOpenClawBeaver
用户长期记忆MEMORY.md / memdiruser modeling / curated memoryworkspace memorycurated memory store
会话历史session storageconversation/session historygateway sessionssession manager
检索memory relevance/searchFTS5 + summarizationsession/tools docssession search tool
run 证据harness logs/teststrajectorieslogs/sessionsrun memory store
skill 效果部分支持自学习闭环强skill registry usageskill effect records
+
+ +
+

Demo:Beaver Session + Memory

+
+

输入

{
+  "session_id": "web-demo-001",
+  "message": "继续刚才的后端架构讲解"
+}
+

内部数据

{
+  "session": {
+    "id": "web-demo-001",
+    "source": "web",
+    "parent_session_id": null
+  },
+  "message": {
+    "role": "user",
+    "run_id": "run_002",
+    "context_visible": true
+  },
+  "memory_snapshot": {
+    "captured_for_run": "run_002",
+    "frozen": true
+  },
+  "run_memory": {
+    "activated_skills": ["backend-explainer"],
+    "tool_results": ["read_file", "search_files"],
+    "validation_result": {"accepted": true}
+  }
+}
+

预期输出

{
+  "answer": "继续上次内容...",
+  "session_updated": true,
+  "run_record_written": true,
+  "can_resume_later": true
+}
+
+
+
+ +
+
+
+

Channel / Gateway 管理

+

这页讲入口生态。Beaver 目前 Web 强,多渠道弱;OpenClaw 和 Hermes 是主要参照。

+
+
+ +
+

OpenHarness

  • ohmo gateway:Feishu、Slack、Telegram、Discord 等。
  • 目录:src/openharness/channels/impl/*
  • 适合讲 harness + personal agent channel adapters。
+

Hermes

  • Telegram、Discord、Slack、WhatsApp、Signal、Email、Home Assistant 等。
  • 目录:gateway/platforms/*
  • 强在 pairing、delivery、slash access、session context。
+

OpenClaw

  • 渠道覆盖最广:WhatsApp、Telegram、Slack、Discord、Google Chat、Signal、iMessage、Matrix、Feishu、LINE、WeChat、QQ、WebChat 等。
  • 强在 local-first gateway、device nodes、apps/canvas。
  • 安全重点:DM allowlist、pairing、non-main sandbox。
+

Beaver

  • 当前主入口是 Web + WebSocket。
  • 后端已有 interfaces/gatewayinterfaces/channels 基础。
  • 集成侧有 Outlook、MCP servers。
  • 短板:多渠道连接和 channel-specific 安全策略不成熟。
+
+ +
+

四种 Channel 数据流

+
+
OpenHarnessChannel Adapter -> Gateway Runtime -> ohmo session -> OpenHarness engine/tools
+
HermesPlatform webhook/bot -> gateway session context -> agent loop -> delivery
+
OpenClawExternal channel -> Gateway -> agent routing -> workspace/session/tools -> channel reply
+
BeaverBrowser/WebSocket -> FastAPI web app -> AgentService -> AgentLoop -> Web response
+
+
+ +
+

对 Beaver 的建议

+
    +
  • 优先借鉴 OpenClaw/Hermes 的 channel adapter 独立化。
  • +
  • 建立 channel identity / allowlist / pairing 概念,而不是只把消息当普通 Web 请求。
  • +
  • 设计 per-channel session mapping,明确 channel、account、peer、session、user 的关系。
  • +
  • 保留当前 Web 管理面优势,先做少量高价值 channel,不直接复制 OpenClaw 的复杂度。
  • +
+
+
+ +
+
+
+

权限 / 安全 / 沙箱 / AuthZ

+

这页要拆成两层讲:平台部署隔离,以及 agent runtime 的工具权限。

+
+
+ +
+ + + + + + + + + +
维度OpenHarnessHermesOpenClawBeaver
Tool approvalpermissions checker / hooksguardrails / approvalssecurity defaults / tool exposureauthz integration + permission package scaffold
Channel trustchannel impl + security testspairing/slash access/platform rulesDM allowlist / pairingWeb auth/session + authz-service
SandboxDocker sandboxterminal backend isolationnon-main sandbox, Docker/SSH/OpenShellDocker per-instance isolation at deployment layer
MCP securityMCP config/clientMCP config/serveplugin/process boundaryMCP server config + authz config
Audittests/logging/hookstrajectories/logslogging/security workflowssession event payload + run memory
+
+ +
+
+

Beaver 平台部署层

+
    +
  • auth-portal:用户入口、登录注册。
  • +
  • authz-service:授权服务和后端身份。
  • +
  • deploy-control:创建和管理单用户实例。
  • +
  • router-proxy:按 Host 转发到实例。
  • +
  • 每个用户一个 app-instance,Docker 容器隔离 + per-instance workspace。
  • +
+
+
+

Beaver Agent Runtime 层

+
    +
  • permissions package 已有 profiles/guards/policies 结构,但目前偏骨架。
  • +
  • MCP 和 authz 已在 config / integration 中接入。
  • +
  • Skill draft safety checker 可用 allowed tool names/prefixes 控制技能草稿风险。
  • +
  • 当前短板是 tool call 前置 permission guard 还不如三方成熟。
  • +
+
+
+ +
+

当前优势

  • 用户实例边界清楚。
  • Web API 和 AuthZ 控制链路清楚。
  • skill 发布前有 safety/eval/review。
+

当前短板

  • runtime tool permission policy 还不完整。
  • channel trust model 还没展开。
  • 沙箱更多在部署层,不是每次 tool call 的细粒度策略。
+

下一步建议

  • 给 ToolSpec 增加 risk level / permission scope 展示。
  • 给 MCP server 增加 trust profile。
  • 给 channel/session 增加 source trust metadata。
  • 将 permission guard 接入 tool executor 前置检查。
+
+
+ +
+
+
+

Agent Team / Sub-agent 编排

+

Beaver 的优势是把 team 放进 Task 工作流,并且让主 agent synthesis 最终负责一致性。

+
+
+ +
+

OpenHarness

swarm、subagent spawning、delegation,强调 harness 多 agent 能力。

+

Hermes

isolated subagents、parallel workstreams、scripts via RPC。

+

OpenClaw

multi-agent routing、isolated agents/workspaces/sessions。

+

Beaver

  • 任务规划器生成 plan。
  • ExecutionGraph 支持 sequence / parallel / DAG。
  • TaskSkillResolver 绑定 published skill 或 ephemeral guidance。
  • LocalAgentRunner 让 sub-agent 回到统一 AgentLoop。
  • 主 Agent synthesis 输出最终答案。
+
+ +
+

Beaver Team 流程

+
+
Task复杂工作进入任务模式
+
Planner决定 single / team
+
ExecutionGraphsequence / parallel / DAG
+
Skill Bindingpublished skill 或 ephemeral guidance
+
LocalAgentRunner节点回到统一 AgentLoop
+
Main Synthesis主 agent 综合输出
+
+
+ +
+

Demo:Agent Team DAG

+
+

输入

{
+  "task_id": "task_arch_002",
+  "mode": "team",
+  "strategy": "dag"
+}
+

执行图

{
+  "nodes": [
+    {
+      "node_id": "read",
+      "task": "阅读三方项目 README",
+      "depends_on": []
+    },
+    {
+      "node_id": "compare",
+      "task": "对比 skill/tool/session/channel/permission",
+      "depends_on": ["read"]
+    },
+    {
+      "node_id": "synthesize",
+      "task": "生成讲解页结构",
+      "depends_on": ["compare"]
+    }
+  ]
+}
+

预期输出

{
+  "team_success": true,
+  "node_results": [
+    {"node_id": "read", "success": true},
+    {"node_id": "compare", "success": true},
+    {"node_id": "synthesize", "success": true}
+  ],
+  "main_synthesis": "输出一页完整项目对比讲解。"
+}
+
+
+
+ +
+
+
+

结论:Beaver 的位置和下一步

+

这页适合最后收束:不是说谁更好,而是说明每个项目选择了不同路线。

+
+
+ +
+
+

你的强项

+
    +
  • Web 管理面清晰。
  • +
  • Task 工作单、validation、feedback 闭环明确。
  • +
  • Skill Learning 生命周期比多数项目更可审核。
  • +
  • Agent Team DAG/parallel/sequence 边界清晰。
  • +
  • 单用户实例化部署方便隔离和商业化实例管理。
  • +
+
+
+

你的短板

+
    +
  • 多渠道能力弱于 Hermes/OpenClaw。
  • +
  • channel trust、安全、pairing、allowlist 体系还没展开。
  • +
  • permission guard/tool approval 还没有 OpenHarness/OpenClaw 成熟。
  • +
  • 生态规模、skills hub、插件分发弱于三方项目。
  • +
  • 桌面/移动/Canvas 类体验缺失。
  • +
+
+
+

可借鉴

+
    +
  • 从 OpenHarness 借鉴 harness 概念表达、hook/permission/tool ecosystem。
  • +
  • 从 Hermes 借鉴 self-improving narrative、gateway、cron、session search/user modeling。
  • +
  • 从 OpenClaw 借鉴 local-first gateway、多渠道、device nodes、DM security、Canvas/visual workspace。
  • +
+
+
+

讲解主线

+
    +
  • 先讲三方项目都是“让 LLM 变成 agent 的外壳”。
  • +
  • 再讲你的项目选择了“Web 管理 + Task 工作流 + 审核型 skill learning”路线。
  • +
  • 最后讲未来可吸收 channel、permission、ecosystem 能力。
  • +
+
+
+
+ +
+
+
+

来源区

+

页面内容是静态快照,不是实时 GitHub 数据。Snapshot: 2026-05-20。

+
+
+ +
+ + + + + + + + +
项目GitHub metadata snapshot主要来源
HKUDS/OpenHarnessPython / MIT / stars 12801 / forks 2115 / updated 2026-05-20T06:22:54ZRepo · README
NousResearch/hermes-agentPython / MIT / stars 158414 / forks 25664 / updated 2026-05-20T06:43:46ZRepo · README
openclaw/openclawTypeScript / MIT / stars 373326 / forks 77490 / updated 2026-05-20T06:42:58ZRepo · README
Beaver本地项目 / Python + Next.js / 单用户实例化 Web agent 工作台Beaver 架构可视化 · backend overview
+
+ +
+

OpenHarness 结构证据

仓库树显示 src/openharness/skillstoolsmemorychannelspermissionssandboxswarmohmo/gateway

https://github.com/HKUDS/OpenHarness
+

Hermes 结构证据

仓库树显示 agent/skill_*toolstoolsetsgateway/platformscronmcp_serve.pyoptional-skills

https://github.com/NousResearch/hermes-agent
+

OpenClaw 结构证据

仓库树显示 apps/androidapps/iosapps/macosskillsextensionsdocs、大量 channel/security workflows。

https://github.com/openclaw/openclaw
+

Beaver 结构证据

本地代码显示 beaver/enginetasksskillstoolsmemoryinterfaces/webcoordinatorintegrations/authz

查看本地 Beaver 架构页
+
+
+
+
+ + + + diff --git a/app-instance/backend/tests/unit/test_config_loader.py b/app-instance/backend/tests/unit/test_config_loader.py index 3d07234..622660b 100644 --- a/app-instance/backend/tests/unit/test_config_loader.py +++ b/app-instance/backend/tests/unit/test_config_loader.py @@ -158,7 +158,7 @@ def test_load_config_reads_mcp_authz_identity(tmp_path) -> None: }, "authz": { "enabled": True, - "baseUrl": "http://nano-authz-service:19090", + "baseUrl": "http://beaver-authz-service:19090", }, "backend_identity": { "backend_id": "stevenli", @@ -180,7 +180,7 @@ def test_load_config_reads_mcp_authz_identity(tmp_path) -> None: assert server.sensitive is True assert config.authz.enabled is True - assert config.authz.base_url == "http://nano-authz-service:19090" + assert config.authz.base_url == "http://beaver-authz-service:19090" assert config.backend_identity.backend_id == "stevenli" assert config.backend_identity.client_id == "stevenli" diff --git a/app-instance/backend/tests/unit/test_main_agent_router.py b/app-instance/backend/tests/unit/test_main_agent_router.py index a30961e..7bf07d8 100644 --- a/app-instance/backend/tests/unit/test_main_agent_router.py +++ b/app-instance/backend/tests/unit/test_main_agent_router.py @@ -38,6 +38,39 @@ class RouterProvider(LLMProvider): return "stub-model" +class SequenceRouterProvider(LLMProvider): + def __init__(self, responses: list[str | Exception]) -> None: + super().__init__() + self.responses = list(responses) + self.calls: list[dict] = [] + + async def chat( + self, + messages: list[dict], + tools: list[dict] | None = None, + model: str | None = None, + max_tokens: int = 4096, + temperature: float = 0.7, + thinking_enabled: bool | None = None, + ) -> LLMResponse: + self.calls.append( + { + "messages": messages, + "max_tokens": max_tokens, + "temperature": temperature, + "model": model, + "thinking_enabled": thinking_enabled, + } + ) + response = self.responses.pop(0) + if isinstance(response, Exception): + raise response + return LLMResponse(content=response, finish_reason="stop", provider_name="stub", model="stub-model") + + def get_default_model(self) -> str: + return "stub-model" + + def _task() -> TaskRecord: return TaskRecord( task_id="task-1", @@ -133,3 +166,38 @@ def test_router_fallback_keeps_active_task_but_not_new_task() -> None: assert active.is_task assert not inactive.is_task + + +def test_router_retries_once_after_provider_failure() -> None: + provider = SequenceRouterProvider( + [ + TimeoutError(), + '{"action":"new_task","reason":"needs search","short_title":"中美会面"}', + ] + ) + + decision = asyncio.run( + MainAgentRouter().classify( + "帮我看看昨天的中美会面都谈了什么?", + provider=provider, + ) + ) + + assert decision.is_task + assert decision.action == "create_task" + assert len(provider.calls) == 2 + + +def test_router_fallback_after_two_provider_failures() -> None: + provider = SequenceRouterProvider([TimeoutError(), RuntimeError("provider down")]) + + decision = asyncio.run( + MainAgentRouter().classify( + "帮我看看昨天的中美会面都谈了什么?", + provider=provider, + ) + ) + + assert not decision.is_task + assert decision.reason == "router_failed: provider down" + assert len(provider.calls) == 2 diff --git a/app-instance/backend/tests/unit/test_skill_learning_safety.py b/app-instance/backend/tests/unit/test_skill_learning_safety.py index 4c7666a..59da418 100644 --- a/app-instance/backend/tests/unit/test_skill_learning_safety.py +++ b/app-instance/backend/tests/unit/test_skill_learning_safety.py @@ -15,7 +15,12 @@ from beaver.skills.reviews import ReviewService from beaver.skills.specs import SkillSpecStore -def _pipeline(tmp_path: Path, *, allowed_tools: set[str] | None = None) -> SkillLearningPipelineService: +def _pipeline( + tmp_path: Path, + *, + allowed_tools: set[str] | None = None, + allowed_prefixes: set[str] | None = None, +) -> SkillLearningPipelineService: spec_store = SkillSpecStore(tmp_path) run_store = RunMemoryStore(tmp_path / "memory" / "runs") learning_store = SkillLearningStore(tmp_path / "memory" / "skills") @@ -32,7 +37,10 @@ def _pipeline(tmp_path: Path, *, allowed_tools: set[str] | None = None) -> Skill draft_service=drafts, review_service=ReviewService(spec_store), publisher=SkillPublisher(spec_store), - safety_checker=SkillDraftSafetyChecker(allowed_tool_names=allowed_tools), + safety_checker=SkillDraftSafetyChecker( + allowed_tool_names=allowed_tools, + allowed_tool_prefixes=allowed_prefixes, + ), ) @@ -106,3 +114,53 @@ def test_safety_blocks_unknown_tool_hint(tmp_path: Path) -> None: assert report.passed is False assert "unknown tool hints" in report.blocked_reasons[0] + + +def test_safety_allows_configured_mcp_tool_prefix(tmp_path: Path) -> None: + pipeline = _pipeline( + tmp_path, + allowed_tools={"echo"}, + allowed_prefixes={"mcp_officebench_"}, + ) + draft = pipeline.draft_service.create_new_skill_draft( + skill_name="officebench-excel", + proposed_content="# OfficeBench Excel\n\nUse the configured OfficeBench MCP tools.", + proposed_frontmatter={ + "description": "officebench", + "tools": [ + "mcp_officebench_shell_list_directory", + "mcp_officebench_excel_read_file", + "mcp_officebench_excel_set_cell", + ], + }, + created_by="test", + reason="test", + ) + + report = pipeline.check_safety(draft.skill_name, draft.draft_id) + + assert report.passed is True + assert report.blocked_reasons == [] + + +def test_safety_blocks_unconfigured_mcp_tool_prefix(tmp_path: Path) -> None: + pipeline = _pipeline( + tmp_path, + allowed_tools={"echo"}, + allowed_prefixes={"mcp_outlook_mcp_"}, + ) + draft = pipeline.draft_service.create_new_skill_draft( + skill_name="wrong-mcp", + proposed_content="# Wrong MCP\n\nUse an unconfigured MCP namespace.", + proposed_frontmatter={ + "description": "wrong mcp", + "tools": ["mcp_officebench_excel_set_cell"], + }, + created_by="test", + reason="test", + ) + + report = pipeline.check_safety(draft.skill_name, draft.draft_id) + + assert report.passed is False + assert "mcp_officebench_excel_set_cell" in report.blocked_reasons[0] diff --git a/app-instance/backend/tests/unit/test_skill_learning_worker.py b/app-instance/backend/tests/unit/test_skill_learning_worker.py index ba5acbe..87e90a4 100644 --- a/app-instance/backend/tests/unit/test_skill_learning_worker.py +++ b/app-instance/backend/tests/unit/test_skill_learning_worker.py @@ -7,6 +7,7 @@ from types import SimpleNamespace from beaver.engine.providers.base import LLMProvider, LLMResponse from beaver.engine.providers.factory import ProviderBundle +from beaver.engine.session import SessionManager from beaver.memory.runs import RunMemoryStore, RunRecord from beaver.memory.skills import SkillLearningCandidate, SkillLearningStore from beaver.skills.drafts import DraftService @@ -125,6 +126,78 @@ def test_worker_retries_and_marks_failed_after_limit(tmp_path: Path) -> None: assert "provider failed" in (candidate.last_error or "") +def test_synthesizer_fills_missing_tools_from_evidence(tmp_path: Path) -> None: + pipeline = _pipeline(tmp_path) + candidate = pipeline.get_candidate("candidate-1") + provider = JsonProvider( + payload={ + "frontmatter": {"description": "Generated skill"}, + "content": "# Generated\n\nUse the observed workflow.", + "change_reason": "learned", + } + ) + packet = EvidenceSelector(pipeline.learning_service.run_store).build_evidence_packet( + candidate.source_run_ids, + candidate.source_session_ids, + ) + packet.metadata["tool_names"] = ["web_fetch", "memory"] + + payload = asyncio.run( + SkillDraftSynthesizer().synthesize_new_skill(candidate, packet, provider, "stub") + ) + + assert payload["frontmatter"]["tools"] == ["web_fetch", "memory"] + + +def test_evidence_selector_records_run_tool_names(tmp_path: Path) -> None: + run_store = RunMemoryStore(tmp_path / "memory" / "runs") + run_store.append_run_record( + RunRecord( + run_id="run-1", + session_id="session-1", + task_text="research latest docs", + started_at="start", + ended_at="end", + success=True, + finish_reason="stop", + ) + ) + session_manager = SessionManager(tmp_path) + session_manager.ensure_session("session-1") + session_manager.append_message( + "session-1", + run_id="run-1", + role="system", + event_type="tool_selection_snapshotted", + event_payload={"tool_names": ["memory", "web_fetch"]}, + context_visible=False, + ) + session_manager.append_message( + "session-1", + run_id="run-1", + role="assistant", + tool_calls=[{"id": "call-1", "function": {"name": "web_search"}}], + ) + session_manager.append_message( + "session-1", + run_id="run-1", + role="tool", + tool_name="web_fetch", + content="ok", + ) + + try: + packet = EvidenceSelector(run_store, session_manager).build_evidence_packet( + ["run-1"], + ["session-1"], + ) + finally: + session_manager.close() + + assert packet.metadata["tool_names"] == ["web_search", "web_fetch"] + assert packet.metadata["selected_tool_names"] == ["memory", "web_fetch"] + + def test_worker_supersedes_candidate_when_active_draft_exists(tmp_path: Path) -> None: pipeline = _pipeline(tmp_path) pipeline.learning_store.record_learning_candidate( diff --git a/app-instance/backend/tests/unit/test_websocket_chat.py b/app-instance/backend/tests/unit/test_websocket_chat.py index 36e3cbc..718b4d3 100644 --- a/app-instance/backend/tests/unit/test_websocket_chat.py +++ b/app-instance/backend/tests/unit/test_websocket_chat.py @@ -78,6 +78,7 @@ def test_websocket_message_returns_chat_metadata_and_session_updated() -> None: "model": None, "provider_name": None, "embedding_model": None, + "max_tool_iterations": None, } ] assert message["type"] == "message" @@ -128,5 +129,6 @@ def test_websocket_runtime_error_returns_assistant_error_message() -> None: assert message["role"] == "assistant" assert message["session_id"] == "web:alpha" assert message["finish_reason"] == "error" + assert message["tool_iterations"] == 0 assert "boom" in message["content"] assert pong == {"type": "pong"} diff --git a/app-instance/create-instance.sh b/app-instance/create-instance.sh index af7a9a0..d2a40f1 100755 --- a/app-instance/create-instance.sh +++ b/app-instance/create-instance.sh @@ -4,7 +4,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REGISTRY_TOOL="${SCRIPT_DIR}/instance-registry.py" -IMAGE_NAME="${IMAGE_NAME:-nano/app-instance:latest}" +IMAGE_NAME="${IMAGE_NAME:-beaver/app-instance:latest}" INSTANCES_ROOT_DEFAULT="${SCRIPT_DIR}/runtime/instances" REGISTRY_PATH_DEFAULT="${SCRIPT_DIR}/runtime/registry/instances.json" KNOWN_PROVIDERS=" custom anthropic openai openrouter deepseek groq zhipu dashscope vllm gemini moonshot minimax aihubmix siliconflow volcengine " @@ -25,6 +25,7 @@ MODEL="openai/gpt-5" PROVIDER="openai" API_KEY="${API_KEY:-}" API_BASE="${API_BASE:-}" +SKIP_PROVIDER_CONFIG=0 AUTH_USERNAME="" AUTH_PASSWORD="" USERNAME="" @@ -42,22 +43,23 @@ REPLACE=0 usage() { cat <<'EOF' Usage: - ./create-instance.sh --instance-id demo --auth-username admin --auth-password 123456 --api-key sk-xxx [options] + ./create-instance.sh --instance-id demo --auth-username admin --auth-password 123456 [options] Required: --instance-id Unique instance id. --auth-username Initial web login username. --auth-password Initial web login password. - --api-key Provider API key for Boardware Genius. Optional: - --image Docker image tag. Default: nano/app-instance:latest + --image Docker image tag. Default: beaver/app-instance:latest --container-name Docker container name. Default: app-instance- --host-port Host port to publish. Default: auto-pick from 20000-29999. --public-url Public URL exposed to users. Default: http://127.0.0.1: --provider Provider key in config.json. Default: openai --api-base Optional custom provider base URL. + --api-key Provider API key for Boardware Genius. --model Model name. Default: openai/gpt-5 + --skip-provider-config Create the instance without model/provider/API key settings. --authz-base-url AuthZ service base URL. --authz-outlook-mcp-url Managed Outlook MCP URL for AuthZ mode. @@ -134,6 +136,7 @@ render_config_json() { PROVIDER="$PROVIDER" \ API_KEY="$API_KEY" \ API_BASE="$API_BASE" \ + SKIP_PROVIDER_CONFIG="$SKIP_PROVIDER_CONFIG" \ AUTHZ_BASE_URL="$AUTHZ_BASE_URL" \ AUTHZ_OUTLOOK_MCP_URL="$AUTHZ_OUTLOOK_MCP_URL" \ OUTLOOK_MCP_SERVER_ID="$OUTLOOK_MCP_SERVER_ID" \ @@ -151,11 +154,20 @@ target = Path(os.environ["TARGET_PATH"]) provider = os.environ["PROVIDER"] outlook_mcp_url = os.environ["AUTHZ_OUTLOOK_MCP_URL"].strip() outlook_server_id = os.environ["OUTLOOK_MCP_SERVER_ID"].strip() or "outlook_mcp" +skip_provider_config = os.environ["SKIP_PROVIDER_CONFIG"].strip() == "1" -provider_cfg = {"apiKey": os.environ["API_KEY"]} -api_base = os.environ["API_BASE"].strip() -if api_base: - provider_cfg["apiBase"] = api_base +providers = {} +agent_defaults = { + "workspace": "/root/.beaver/workspace", +} +if not skip_provider_config: + provider_cfg = {"apiKey": os.environ["API_KEY"]} + api_base = os.environ["API_BASE"].strip() + if api_base: + provider_cfg["apiBase"] = api_base + providers[provider] = provider_cfg + agent_defaults["provider"] = provider + agent_defaults["model"] = os.environ["MODEL"] outlook_tool_names = [ "auth_status", @@ -193,14 +205,9 @@ if outlook_mcp_url: data = { "agents": { - "defaults": { - "workspace": "/root/.beaver/workspace", - "model": os.environ["MODEL"], - } - }, - "providers": { - provider: provider_cfg, + "defaults": agent_defaults }, + "providers": providers, "tools": { "restrictToWorkspace": True, "mcpServers": default_mcp_servers, @@ -345,6 +352,10 @@ while [[ $# -gt 0 ]]; do MODEL="${2:-}" shift 2 ;; + --skip-provider-config) + SKIP_PROVIDER_CONFIG=1 + shift + ;; --auth-username) AUTH_USERNAME="${2:-}" shift 2 @@ -438,7 +449,9 @@ done [[ -n "$INSTANCE_ID" ]] || die "--instance-id is required" [[ -n "$AUTH_USERNAME" ]] || die "--auth-username is required" [[ -n "$AUTH_PASSWORD" ]] || die "--auth-password is required" -[[ -n "$API_KEY" ]] || die "--api-key is required" +if [[ "$SKIP_PROVIDER_CONFIG" -ne 1 ]]; then + [[ -n "$API_KEY" ]] || die "--api-key is required unless --skip-provider-config is set" +fi INSTANCE_SLUG="$(slugify "$INSTANCE_ID")" USERNAME="${USERNAME:-$AUTH_USERNAME}" @@ -469,10 +482,12 @@ if [[ -z "$INSTANCE_HOST" ]]; then INSTANCE_HOST="$(extract_url_host "$PUBLIC_URL")" fi -case "$KNOWN_PROVIDERS" in - *" ${PROVIDER} "*) ;; - *) die "unsupported provider '${PROVIDER}'" ;; -esac +if [[ "$SKIP_PROVIDER_CONFIG" -ne 1 ]]; then + case "$KNOWN_PROVIDERS" in + *" ${PROVIDER} "*) ;; + *) die "unsupported provider '${PROVIDER}'" ;; + esac +fi if [[ -n "$BACKEND_ID$CLIENT_ID$CLIENT_SECRET" ]]; then [[ -n "$BACKEND_ID" && -n "$CLIENT_ID" && -n "$CLIENT_SECRET" ]] || die "backend identity requires --backend-id, --client-id and --client-secret together" @@ -550,9 +565,9 @@ RUN_ARGS=( -e "APP_FRONTEND_PORT=3000" -e "APP_BACKEND_PORT=18080" -e "BEAVER_OUTLOOK_MCP_SERVER_ID=${OUTLOOK_MCP_SERVER_ID}" - --label "nano.instance.id=${INSTANCE_ID}" - --label "nano.instance.slug=${INSTANCE_SLUG}" - --label "nano.instance.public_url=${PUBLIC_URL}" + --label "beaver.instance.id=${INSTANCE_ID}" + --label "beaver.instance.slug=${INSTANCE_SLUG}" + --label "beaver.instance.public_url=${PUBLIC_URL}" ) if [[ -n "$NETWORK_NAME" ]]; then diff --git a/app-instance/frontend/app/(app)/skills/page.tsx b/app-instance/frontend/app/(app)/skills/page.tsx index 6a0ba78..4928b53 100644 --- a/app-instance/frontend/app/(app)/skills/page.tsx +++ b/app-instance/frontend/app/(app)/skills/page.tsx @@ -41,6 +41,7 @@ import { listSkillDrafts, listSkills, publishSkillDraft, + recheckSkillDraftSafety, regenerateSkillDraft, rejectSkillDraft, rollbackPublishedSkill, @@ -412,6 +413,11 @@ export default function SkillsPage() { rejectSkillDraft(draft.skill_name, draft.draft_id) ) } + onRecheckSafety={() => + runAction(`safety:${draft.draft_id}`, () => + recheckSkillDraftSafety(draft.skill_name, draft.draft_id) + ) + } onPublish={(confirmHighRisk) => runAction(`publish:${draft.draft_id}`, () => publishSkillDraft(draft.skill_name, draft.draft_id, '', confirmHighRisk) @@ -697,6 +703,7 @@ function DraftCard({ onSubmit, onApprove, onReject, + onRecheckSafety, onPublish, }: { draft: SkillDraft; @@ -704,6 +711,7 @@ function DraftCard({ onSubmit: () => Promise; onApprove: () => Promise; onReject: () => Promise; + onRecheckSafety: () => Promise; onPublish: (confirmHighRisk: boolean) => Promise; }) { const { locale } = useAppI18n(); @@ -814,6 +822,10 @@ function DraftCard({ {t('拒绝', 'Reject')} +